mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-12 08:48:12 +00:00
feat(audio): wire lease registry into IPC — leases surface, demand glue, legacy telemetry
Phase A wiring (plan §6.8/§8/§9): - lease-bridge.ts: holder identity derived from the IPC sender (compound identity — enforced wc id + attributed tag), webContents destroy → death invalidation, main-frame navigation → reload grace window, capture demand drives startAudio/stopAudio, detection demand drives setNoteDetectionEnabled. - audio-bridge.ts: audio:leases:* channels; raw startAudio/stopAudio now resume/suspend demands (user authority, §8.3); raw detection disarm is ignored while a demand holder still needs it armed (fixes 6.3's last-disarmer-kills-concurrent-consumer); log-once legacy telemetry. - preload: audio.leases.* (acquire/release/takeover/getHolder/ acquireDemand/releaseDemand/snapshot/onEvent). - contract snapshots regenerated deliberately (7 IPC channels, 1 preload key). - tests: lease-bridge suite (9) incl. death matrix destroy/reload paths; _load-ts gains a .ts require hook for cross-module TS imports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f5276b787c
commit
5ecf55cb0c
@@ -9,10 +9,12 @@ import { app } from 'electron';
|
|||||||
import { isDebugEnabled, getDebugLogPath } from './debug-log';
|
import { isDebugEnabled, getDebugLogPath } from './debug-log';
|
||||||
import { initVstCrashGuard, armSentinel, disarmSentinel, armEditorSentinel, getSentinelPath } from './vst-crash-guard';
|
import { initVstCrashGuard, armSentinel, disarmSentinel, armEditorSentinel, getSentinelPath } from './vst-crash-guard';
|
||||||
import { createAudioEffectsExecutor } from './audio-effects-executor';
|
import { createAudioEffectsExecutor } from './audio-effects-executor';
|
||||||
|
import { initLeaseBridge, LeaseBridge } from './lease-bridge';
|
||||||
|
|
||||||
type AudioModule = Record<string, (...args: any[]) => any>;
|
type AudioModule = Record<string, (...args: any[]) => any>;
|
||||||
|
|
||||||
let audio: AudioModule | null = null;
|
let audio: AudioModule | null = null;
|
||||||
|
let leaseBridge: LeaseBridge | null = null;
|
||||||
|
|
||||||
type AudioDeviceSettings = {
|
type AudioDeviceSettings = {
|
||||||
type: string; // legacy alias = inputType when only type was stored
|
type: string; // legacy alias = inputType when only type was stored
|
||||||
@@ -254,6 +256,23 @@ function loadNativeAddon(): AudioModule | null {
|
|||||||
export function initAudioBridge(): void {
|
export function initAudioBridge(): void {
|
||||||
audio = loadNativeAddon();
|
audio = loadNativeAddon();
|
||||||
const audioEffects = createAudioEffectsExecutor(() => audio);
|
const audioEffects = createAudioEffectsExecutor(() => audio);
|
||||||
|
leaseBridge = initLeaseBridge(() => audio);
|
||||||
|
|
||||||
|
// ── Lease registry surface (ownership plan §2/§8; wiring in lease-bridge) ──
|
||||||
|
|
||||||
|
ipcMain.handle('audio:leases:acquire', (event, scope: unknown, tag: unknown) =>
|
||||||
|
leaseBridge!.acquire(event.sender, scope, tag));
|
||||||
|
ipcMain.handle('audio:leases:release', (event, scope: unknown, tag: unknown) =>
|
||||||
|
leaseBridge!.release(event.sender, scope, tag));
|
||||||
|
ipcMain.handle('audio:leases:takeover', (event, scope: unknown, tag: unknown) =>
|
||||||
|
leaseBridge!.takeover(event.sender, scope, tag));
|
||||||
|
ipcMain.handle('audio:leases:getHolder', (_event, scope: unknown) =>
|
||||||
|
leaseBridge!.getHolder(scope));
|
||||||
|
ipcMain.handle('audio:leases:acquireDemand', (event, scope: unknown, tag: unknown) =>
|
||||||
|
leaseBridge!.acquireDemand(event.sender, scope, tag));
|
||||||
|
ipcMain.handle('audio:leases:releaseDemand', (event, scope: unknown, tag: unknown) =>
|
||||||
|
leaseBridge!.releaseDemand(event.sender, scope, tag));
|
||||||
|
ipcMain.handle('audio:leases:snapshot', () => leaseBridge!.snapshot());
|
||||||
|
|
||||||
if (audio) {
|
if (audio) {
|
||||||
// Redirect native stderr to the debug log before init() runs — that's
|
// Redirect native stderr to the debug log before init() runs — that's
|
||||||
@@ -585,11 +604,21 @@ export function initAudioBridge(): void {
|
|||||||
|
|
||||||
// ── Audio Control ──────────────────────────────────────────────────────
|
// ── Audio Control ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
ipcMain.handle('audio:startAudio', () => {
|
ipcMain.handle('audio:startAudio', (event) => {
|
||||||
|
// Raw start = user authority (device screen). Resumes any demands the
|
||||||
|
// last raw stop suspended (§8.3). Plugin callers should migrate to
|
||||||
|
// the `capture` demand (audio:leases:acquireDemand) — log-once
|
||||||
|
// telemetry tracks who still comes through here (§6.8).
|
||||||
|
leaseBridge?.noteLegacyCall(event.sender, 'audio:startAudio');
|
||||||
audio?.startAudio();
|
audio?.startAudio();
|
||||||
|
leaseBridge?.onUserStartAudio();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('audio:stopAudio', () => {
|
ipcMain.handle('audio:stopAudio', (event) => {
|
||||||
|
// Raw stop always wins: engine stops, demands suspend (not clear) so
|
||||||
|
// holders resume on the next user start (§8.3).
|
||||||
|
leaseBridge?.noteLegacyCall(event.sender, 'audio:stopAudio');
|
||||||
|
leaseBridge?.onUserStopAudio();
|
||||||
audio?.stopAudio();
|
audio?.stopAudio();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -746,8 +775,17 @@ export function initAudioBridge(): void {
|
|||||||
// verifier path and the always-on home tuner cost no ONNX inference.
|
// verifier path and the always-on home tuner cost no ONNX inference.
|
||||||
// typeof-guarded so a downlevel addon (no gate) simply ignores it — ML then
|
// typeof-guarded so a downlevel addon (no gate) simply ignores it — ML then
|
||||||
// runs as before, i.e. fail-safe to current behaviour.
|
// runs as before, i.e. fail-safe to current behaviour.
|
||||||
ipcMain.handle('audio:setNoteDetectionEnabled', (_event, enabled: boolean) => {
|
ipcMain.handle('audio:setNoteDetectionEnabled', (event, enabled: boolean) => {
|
||||||
if (!audio || typeof audio.setNoteDetectionEnabled !== 'function') return;
|
if (!audio || typeof audio.setNoteDetectionEnabled !== 'function') return;
|
||||||
|
leaseBridge?.noteLegacyCall(event.sender, 'audio:setNoteDetectionEnabled');
|
||||||
|
// Ownership plan 6.3: a raw disarm must not kill detection while a
|
||||||
|
// demand holder still needs it armed — the "whichever minigame
|
||||||
|
// disarms last kills a concurrent consumer" bug. Raw arms pass
|
||||||
|
// through (they agree with any active demand).
|
||||||
|
if (!enabled && leaseBridge?.shouldIgnoreRawDetectionDisarm()) {
|
||||||
|
console.info('[audio] raw detection disarm ignored — detection demand active (plan 6.3)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
audio.setNoteDetectionEnabled(Boolean(enabled));
|
audio.setNoteDetectionEnabled(Boolean(enabled));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1394,6 +1432,10 @@ export function initAudioBridge(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownAudio(): void {
|
export function shutdownAudio(): void {
|
||||||
|
if (leaseBridge) {
|
||||||
|
try { leaseBridge.dispose(); } catch { /* silent fail during shutdown */ }
|
||||||
|
leaseBridge = null;
|
||||||
|
}
|
||||||
if (audio) {
|
if (audio) {
|
||||||
try {
|
try {
|
||||||
audio.shutdown();
|
audio.shutdown();
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
// Lease bridge — wires the LeaseRegistry (docs/audio-ownership-plan.md §2/§8)
|
||||||
|
// to Electron IPC. Owns the three things the registry deliberately does not:
|
||||||
|
//
|
||||||
|
// 1. holder-identity DERIVATION from the IPC sender (webContents id + an
|
||||||
|
// optional caller-attributed tag — the compound identity of §9: the
|
||||||
|
// webContents part is enforced, the tag part is soft attribution),
|
||||||
|
// 2. webContents lifecycle → death invalidation / reload grace windows,
|
||||||
|
// 3. the demand→engine glue: capture demand drives startAudio/stopAudio,
|
||||||
|
// detection demand drives setNoteDetectionEnabled.
|
||||||
|
//
|
||||||
|
// The ipcMain.handle registrations live in audio-bridge.ts (thin wrappers over
|
||||||
|
// this module) so the contract snapshot keeps seeing every channel in one file.
|
||||||
|
|
||||||
|
import type { WebContents } from 'electron';
|
||||||
|
import { LeaseRegistry, HolderId } from './lease-registry';
|
||||||
|
|
||||||
|
type AudioModule = Record<string, (...args: any[]) => any> | null;
|
||||||
|
|
||||||
|
type BroadcastFn = (channel: string, data: unknown) => void;
|
||||||
|
|
||||||
|
// Default broadcast: every open BrowserWindow. Lazily required so the module
|
||||||
|
// stays loadable in the node:test harness (no electron runtime there).
|
||||||
|
function electronBroadcast(channel: string, data: unknown): void {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const { BrowserWindow } = require('electron');
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
if (!win.isDestroyed()) win.webContents.send(channel, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_RE = /^[\w.-]{1,128}$/;
|
||||||
|
const DETECTION_SCOPE_DEFAULT = 'detection:desktop-main';
|
||||||
|
|
||||||
|
export type LeaseBridge = {
|
||||||
|
registry: LeaseRegistry;
|
||||||
|
acquire(sender: WebContents, scope: unknown, tag: unknown): unknown;
|
||||||
|
release(sender: WebContents, scope: unknown, tag: unknown): boolean;
|
||||||
|
takeover(sender: WebContents, scope: unknown, tag: unknown): Promise<unknown>;
|
||||||
|
getHolder(scope: unknown): HolderId | null;
|
||||||
|
acquireDemand(sender: WebContents, scope: unknown, tag: unknown): boolean;
|
||||||
|
releaseDemand(sender: WebContents, scope: unknown, tag: unknown): boolean;
|
||||||
|
snapshot(): unknown;
|
||||||
|
// Hooks for the legacy raw channels (§6.8 migration semantics):
|
||||||
|
onUserStartAudio(): void;
|
||||||
|
onUserStopAudio(): void;
|
||||||
|
// true = swallow the raw disarm because a demand holder still needs
|
||||||
|
// detection armed (the 6.3 "last disarmer kills a concurrent consumer" fix).
|
||||||
|
shouldIgnoreRawDetectionDisarm(): boolean;
|
||||||
|
noteLegacyCall(sender: WebContents, surface: string): void;
|
||||||
|
dispose(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function initLeaseBridge(getAudio: () => AudioModule, options: { broadcast?: BroadcastFn } = {}): LeaseBridge {
|
||||||
|
const registry = new LeaseRegistry();
|
||||||
|
const broadcastFn = options.broadcast ?? electronBroadcast;
|
||||||
|
|
||||||
|
// Holder ids this bridge minted, per webContents id — the set we must
|
||||||
|
// invalidate when that webContents dies or reloads.
|
||||||
|
const mintedBySender = new Map<number, Set<HolderId>>();
|
||||||
|
const watchedSenders = new Set<number>();
|
||||||
|
// Log-once-per-session-per-caller telemetry for legacy surfaces (§6.8:
|
||||||
|
// "that telemetry IS the migration progress dashboard").
|
||||||
|
const legacyCallsLogged = new Set<string>();
|
||||||
|
// Whether the engine is running because capture demand started it (as
|
||||||
|
// opposed to a user raw start). Demand-started engines stop when the
|
||||||
|
// demand drains; user-started engines only stop on user raw stop (§8.3).
|
||||||
|
let engineStartedByDemand = false;
|
||||||
|
|
||||||
|
function sanitizeTag(tag: unknown): string | null {
|
||||||
|
if (typeof tag !== 'string' || !TAG_RE.test(tag)) return null;
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveHolder(sender: WebContents, tag: unknown): HolderId {
|
||||||
|
const cleanTag = sanitizeTag(tag);
|
||||||
|
return cleanTag ? `wc:${sender.id}#${cleanTag}` : `wc:${sender.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grace identity (§8.2): the webContents id survives a reload, so both
|
||||||
|
// tagged and untagged holders restore under the same key.
|
||||||
|
function identityKey(sender: WebContents, tag: unknown): string {
|
||||||
|
const cleanTag = sanitizeTag(tag);
|
||||||
|
return cleanTag ? `wc:${sender.id}#${cleanTag}` : `wc:${sender.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackSender(sender: WebContents, holderId: HolderId): void {
|
||||||
|
let minted = mintedBySender.get(sender.id);
|
||||||
|
if (!minted) {
|
||||||
|
minted = new Set();
|
||||||
|
mintedBySender.set(sender.id, minted);
|
||||||
|
}
|
||||||
|
minted.add(holderId);
|
||||||
|
if (watchedSenders.has(sender.id)) return;
|
||||||
|
watchedSenders.add(sender.id);
|
||||||
|
|
||||||
|
sender.once('destroyed', () => {
|
||||||
|
const ids = mintedBySender.get(sender.id);
|
||||||
|
mintedBySender.delete(sender.id);
|
||||||
|
watchedSenders.delete(sender.id);
|
||||||
|
if (ids) for (const id of ids) registry.releaseHolder(id);
|
||||||
|
});
|
||||||
|
// A main-frame navigation (reload or page swap) drops the renderer
|
||||||
|
// context: everything it held goes into the grace window; the same
|
||||||
|
// identity re-acquiring restores it (§8.2).
|
||||||
|
sender.on('did-start-navigation', (_event, _url, _isInPlace, isMainFrame) => {
|
||||||
|
if (!isMainFrame) return;
|
||||||
|
const ids = mintedBySender.get(sender.id);
|
||||||
|
if (!ids) return;
|
||||||
|
for (const id of ids) registry.beginGrace(id, id);
|
||||||
|
ids.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(event: string, payload: unknown): void {
|
||||||
|
try {
|
||||||
|
broadcastFn('audio:leases:event', { event, payload });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[leases] event broadcast failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of [
|
||||||
|
'lease-granted', 'lease-released', 'lease-revoked', 'lease-refused', 'lease-suspended',
|
||||||
|
'demand-changed', 'demand-suspended', 'demand-resumed', 'value-changed',
|
||||||
|
]) {
|
||||||
|
registry.on(event, payload => broadcast(event, payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── demand → engine glue ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registry.on('demand-changed', ({ scope, active }: { scope: string; active: boolean }) => {
|
||||||
|
const audio = getAudio();
|
||||||
|
if (!audio) return;
|
||||||
|
if (scope === 'capture') {
|
||||||
|
try {
|
||||||
|
const running = typeof audio.isAudioRunning === 'function' && audio.isAudioRunning() === true;
|
||||||
|
if (active && !running) {
|
||||||
|
audio.startAudio?.();
|
||||||
|
engineStartedByDemand = true;
|
||||||
|
} else if (!active && running && engineStartedByDemand) {
|
||||||
|
// Only stop an engine the demand path started; a
|
||||||
|
// user-started engine outlives its demands (§8.3).
|
||||||
|
audio.stopAudio?.();
|
||||||
|
engineStartedByDemand = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[leases] capture demand glue failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
} else if (scope.startsWith('detection:')) {
|
||||||
|
// v1: the native pipeline has one global detection gate; any
|
||||||
|
// active detection scope arms it. Per-route arming follows the
|
||||||
|
// per-route native split (plan phase C/E).
|
||||||
|
try {
|
||||||
|
if (typeof audio.setNoteDetectionEnabled === 'function') {
|
||||||
|
const anyActive = registry.demandActive(scope) || anyDetectionActive();
|
||||||
|
audio.setNoteDetectionEnabled(anyActive);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[leases] detection demand glue failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function anyDetectionActive(): boolean {
|
||||||
|
return registry.snapshot().demands.some(d => d.scope.startsWith('detection:') && !d.suspended && d.holders.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
registry,
|
||||||
|
|
||||||
|
acquire(sender, scope, tag) {
|
||||||
|
const holderId = deriveHolder(sender, tag);
|
||||||
|
trackSender(sender, holderId);
|
||||||
|
// A reloaded identity gets its suspended leases back first (§8.2).
|
||||||
|
registry.tryRestore(identityKey(sender, tag), holderId);
|
||||||
|
return registry.acquire(String(scope), holderId);
|
||||||
|
},
|
||||||
|
|
||||||
|
release(sender, scope, tag) {
|
||||||
|
return registry.release(String(scope), deriveHolder(sender, tag));
|
||||||
|
},
|
||||||
|
|
||||||
|
async takeover(sender, scope, tag) {
|
||||||
|
const holderId = deriveHolder(sender, tag);
|
||||||
|
trackSender(sender, holderId);
|
||||||
|
// Drain hook: the chain-mutation serializer joins here in phase C
|
||||||
|
// (§8.1). Until chain ops are lease-scoped there is nothing of the
|
||||||
|
// old holder's to drain, so the immediate grant is exact.
|
||||||
|
return registry.takeover(String(scope), holderId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getHolder(scope) {
|
||||||
|
return registry.getHolder(String(scope));
|
||||||
|
},
|
||||||
|
|
||||||
|
acquireDemand(sender, scope, tag) {
|
||||||
|
const holderId = deriveHolder(sender, tag);
|
||||||
|
trackSender(sender, holderId);
|
||||||
|
registry.tryRestore(identityKey(sender, tag), holderId);
|
||||||
|
return registry.acquireDemand(String(scope), holderId);
|
||||||
|
},
|
||||||
|
|
||||||
|
releaseDemand(sender, scope, tag) {
|
||||||
|
return registry.releaseDemand(String(scope), deriveHolder(sender, tag));
|
||||||
|
},
|
||||||
|
|
||||||
|
snapshot() {
|
||||||
|
return registry.snapshot();
|
||||||
|
},
|
||||||
|
|
||||||
|
onUserStartAudio() {
|
||||||
|
// User raw start is the only thing that resumes suspended
|
||||||
|
// demands (§8.3).
|
||||||
|
engineStartedByDemand = false;
|
||||||
|
registry.resumeDemands('capture');
|
||||||
|
registry.resumeDemands('detection:');
|
||||||
|
},
|
||||||
|
|
||||||
|
onUserStopAudio() {
|
||||||
|
// User raw stop always wins: demands suspend (registration kept),
|
||||||
|
// holders learn via demand-suspended events (§8.3).
|
||||||
|
engineStartedByDemand = false;
|
||||||
|
registry.suspendDemands('capture');
|
||||||
|
registry.suspendDemands('detection:');
|
||||||
|
},
|
||||||
|
|
||||||
|
shouldIgnoreRawDetectionDisarm() {
|
||||||
|
return anyDetectionActive();
|
||||||
|
},
|
||||||
|
|
||||||
|
noteLegacyCall(sender, surface) {
|
||||||
|
const key = `${surface}@wc:${sender.id}`;
|
||||||
|
if (legacyCallsLogged.has(key)) return;
|
||||||
|
legacyCallsLogged.add(key);
|
||||||
|
console.info(`[leases] legacy surface ${surface} called by wc:${sender.id} — migration telemetry (plan §6.8)`);
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
registry.dispose();
|
||||||
|
mintedBySender.clear();
|
||||||
|
watchedSenders.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DETECTION_SCOPE_DEFAULT };
|
||||||
@@ -249,6 +249,27 @@ const feedBackDesktopApi = {
|
|||||||
stopAudio: () => ipcRenderer.invoke('audio:stopAudio'),
|
stopAudio: () => ipcRenderer.invoke('audio:stopAudio'),
|
||||||
isAudioRunning: () => ipcRenderer.invoke('audio:isAudioRunning'),
|
isAudioRunning: () => ipcRenderer.invoke('audio:isAudioRunning'),
|
||||||
|
|
||||||
|
// Ownership leases (docs/audio-ownership-plan.md §2/§8). Exclusive
|
||||||
|
// leases arbitrate conflicting writers (signal chains, device config,
|
||||||
|
// playback); refcounted demands express additive intent (capture,
|
||||||
|
// detection) — the engine acts while any holder needs it. `tag` is the
|
||||||
|
// caller-attributed soft identity (§9 compound identity); the hard
|
||||||
|
// part is derived main-side from the sender and cannot be spoofed.
|
||||||
|
leases: {
|
||||||
|
acquire: (scope: string, tag?: string) => ipcRenderer.invoke('audio:leases:acquire', scope, tag),
|
||||||
|
release: (scope: string, tag?: string) => ipcRenderer.invoke('audio:leases:release', scope, tag),
|
||||||
|
takeover: (scope: string, tag?: string) => ipcRenderer.invoke('audio:leases:takeover', scope, tag),
|
||||||
|
getHolder: (scope: string) => ipcRenderer.invoke('audio:leases:getHolder', scope),
|
||||||
|
acquireDemand: (scope: string, tag?: string) => ipcRenderer.invoke('audio:leases:acquireDemand', scope, tag),
|
||||||
|
releaseDemand: (scope: string, tag?: string) => ipcRenderer.invoke('audio:leases:releaseDemand', scope, tag),
|
||||||
|
snapshot: () => ipcRenderer.invoke('audio:leases:snapshot'),
|
||||||
|
onEvent: (callback: (event: { event: string; payload: unknown }) => void) => {
|
||||||
|
const listener = (_e: Electron.IpcRendererEvent, data: { event: string; payload: unknown }) => callback(data);
|
||||||
|
ipcRenderer.on('audio:leases:event', listener);
|
||||||
|
return () => ipcRenderer.removeListener('audio:leases:event', listener);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Gain
|
// Gain
|
||||||
setGain: (which: string, value: number) => ipcRenderer.invoke('audio:setGain', which, value),
|
setGain: (which: string, value: number) => ipcRenderer.invoke('audio:setGain', which, value),
|
||||||
setInputChannel: (channel: number) => ipcRenderer.invoke('audio:setInputChannel', channel),
|
setInputChannel: (channel: number) => ipcRenderer.invoke('audio:setInputChannel', channel),
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ const ts = require('typescript');
|
|||||||
|
|
||||||
const ROOT = path.join(__dirname, '..');
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
// Let a loaded .ts module require sibling .ts modules (e.g. lease-bridge.ts
|
||||||
|
// → ./lease-registry). Registering the extension also makes Node's resolver
|
||||||
|
// consider .ts files for extension-less requires.
|
||||||
|
if (!Module._extensions['.ts']) {
|
||||||
|
Module._extensions['.ts'] = (mod, filename) => {
|
||||||
|
const source = fs.readFileSync(filename, 'utf8');
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
esModuleInterop: true,
|
||||||
|
},
|
||||||
|
fileName: filename,
|
||||||
|
}).outputText;
|
||||||
|
mod._compile(compiled, filename);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function loadTs(relPath) {
|
function loadTs(relPath) {
|
||||||
const file = path.join(ROOT, relPath);
|
const file = path.join(ROOT, relPath);
|
||||||
const source = fs.readFileSync(file, 'utf8');
|
const source = fs.readFileSync(file, 'utf8');
|
||||||
|
|||||||
@@ -44,6 +44,13 @@
|
|||||||
"audio:isMlNoteDetection",
|
"audio:isMlNoteDetection",
|
||||||
"audio:isMonitorMuted",
|
"audio:isMonitorMuted",
|
||||||
"audio:isStreamOutputActive",
|
"audio:isStreamOutputActive",
|
||||||
|
"audio:leases:acquire",
|
||||||
|
"audio:leases:acquireDemand",
|
||||||
|
"audio:leases:getHolder",
|
||||||
|
"audio:leases:release",
|
||||||
|
"audio:leases:releaseDemand",
|
||||||
|
"audio:leases:snapshot",
|
||||||
|
"audio:leases:takeover",
|
||||||
"audio:listInputDevices",
|
"audio:listInputDevices",
|
||||||
"audio:listSources",
|
"audio:listSources",
|
||||||
"audio:loadBackingTrack",
|
"audio:loadBackingTrack",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"isMlNoteDetection",
|
"isMlNoteDetection",
|
||||||
"isMonitorMuted",
|
"isMonitorMuted",
|
||||||
"isStreamOutputActive",
|
"isStreamOutputActive",
|
||||||
|
"leases",
|
||||||
"listInputDevices",
|
"listInputDevices",
|
||||||
"listSources",
|
"listSources",
|
||||||
"loadBackingTrack",
|
"loadBackingTrack",
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Lease bridge (docs/audio-ownership-plan.md §6.8/§8/§9): holder derivation
|
||||||
|
// from the sender, webContents lifecycle → death/grace, capture demand →
|
||||||
|
// engine glue, user-stop suspend semantics, raw-disarm guard, telemetry.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { EventEmitter } = require('node:events');
|
||||||
|
const { loadTs } = require('./_load-ts');
|
||||||
|
|
||||||
|
const { initLeaseBridge } = loadTs('src/main/lease-bridge.ts');
|
||||||
|
|
||||||
|
function fakeSender(id) {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
emitter.id = id;
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeAudio() {
|
||||||
|
const calls = [];
|
||||||
|
let running = false;
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
isAudioRunning: () => running,
|
||||||
|
startAudio: () => { running = true; calls.push('start'); },
|
||||||
|
stopAudio: () => { running = false; calls.push('stop'); },
|
||||||
|
setNoteDetectionEnabled: (v) => calls.push(`detect:${v}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBridge(audio) {
|
||||||
|
const events = [];
|
||||||
|
const bridge = initLeaseBridge(() => audio, { broadcast: (_ch, data) => events.push(data) });
|
||||||
|
return { bridge, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('holder identity derived from sender; tag is optional attributed suffix', () => {
|
||||||
|
const audio = fakeAudio();
|
||||||
|
const { bridge } = makeBridge(audio);
|
||||||
|
const sender = fakeSender(7);
|
||||||
|
|
||||||
|
bridge.acquire(sender, 'signal-chain:desktop-main', 'nam_tone');
|
||||||
|
assert.equal(bridge.getHolder('signal-chain:desktop-main'), 'wc:7#nam_tone');
|
||||||
|
|
||||||
|
// a hostile tag cannot inject identity syntax — it is dropped, not trusted
|
||||||
|
bridge.acquire(sender, 'playback', 'evil#wc:1');
|
||||||
|
assert.equal(bridge.getHolder('playback'), 'wc:7');
|
||||||
|
bridge.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('capture demand starts the engine; last release stops a demand-started engine', () => {
|
||||||
|
const audio = fakeAudio();
|
||||||
|
const { bridge } = makeBridge(audio);
|
||||||
|
const tuner = fakeSender(1);
|
||||||
|
const minigame = fakeSender(2);
|
||||||
|
|
||||||
|
bridge.acquireDemand(tuner, 'capture', 'tuner');
|
||||||
|
assert.deepEqual(audio.calls, ['start']);
|
||||||
|
bridge.acquireDemand(minigame, 'capture', 'minigame');
|
||||||
|
bridge.releaseDemand(tuner, 'capture', 'tuner');
|
||||||
|
assert.deepEqual(audio.calls, ['start']); // still one holder — keeps running
|
||||||
|
bridge.releaseDemand(minigame, 'capture', 'minigame');
|
||||||
|
assert.deepEqual(audio.calls, ['start', 'stop']);
|
||||||
|
bridge.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user-started engine outlives its demands (§8.3)', () => {
|
||||||
|
const audio = fakeAudio();
|
||||||
|
const { bridge } = makeBridge(audio);
|
||||||
|
const sender = fakeSender(1);
|
||||||
|
|
||||||
|
bridge.onUserStartAudio();
|
||||||
|
audio.startAudio();
|
||||||
|
audio.calls.length = 0;
|
||||||
|
|
||||||
|
bridge.acquireDemand(sender, 'capture', 'tuner');
|
||||||
|
bridge.releaseDemand(sender, 'capture', 'tuner');
|
||||||
|
// demand drained but the user started this engine — no stop
|
||||||
|
assert.deepEqual(audio.calls, []);
|
||||||
|
bridge.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user stop suspends demands; user start resumes and restarts (§8.3)', () => {
|
||||||
|
const audio = fakeAudio();
|
||||||
|
const { bridge } = makeBridge(audio);
|
||||||
|
const sender = fakeSender(1);
|
||||||
|
|
||||||
|
bridge.acquireDemand(sender, 'capture', 'tuner');
|
||||||
|
assert.deepEqual(audio.calls, ['start']);
|
||||||
|
|
||||||
|
bridge.onUserStopAudio(); // raw stop: demand suspended, registration kept
|
||||||
|
audio.stopAudio();
|
||||||
|
audio.calls.length = 0;
|
||||||
|
|
||||||
|
// suspended demand does not restart the engine
|
||||||
|
bridge.acquireDemand(sender, 'capture', 'tuner');
|
||||||
|
assert.deepEqual(audio.calls, []);
|
||||||
|
|
||||||
|
bridge.onUserStartAudio(); // user start resumes demands → glue restarts
|
||||||
|
assert.deepEqual(audio.calls, ['start']);
|
||||||
|
bridge.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detection demand arms native; raw disarm guarded while demand active (6.3)', () => {
|
||||||
|
const audio = fakeAudio();
|
||||||
|
const { bridge } = makeBridge(audio);
|
||||||
|
const notedetect = fakeSender(1);
|
||||||
|
const strumFighter = fakeSender(2);
|
||||||
|
|
||||||
|
bridge.acquireDemand(notedetect, 'detection:desktop-main', 'notedetect');
|
||||||
|
assert.deepEqual(audio.calls.filter(c => c.startsWith('detect')), ['detect:true']);
|
||||||
|
assert.equal(bridge.shouldIgnoreRawDetectionDisarm(), true);
|
||||||
|
|
||||||
|
bridge.acquireDemand(strumFighter, 'detection:desktop-main', 'strum-fighter');
|
||||||
|
bridge.releaseDemand(notedetect, 'detection:desktop-main', 'notedetect');
|
||||||
|
// one consumer left — still armed, raw disarm still guarded
|
||||||
|
assert.equal(bridge.shouldIgnoreRawDetectionDisarm(), true);
|
||||||
|
|
||||||
|
bridge.releaseDemand(strumFighter, 'detection:desktop-main', 'strum-fighter');
|
||||||
|
assert.equal(bridge.shouldIgnoreRawDetectionDisarm(), false);
|
||||||
|
assert.equal(audio.calls.filter(c => c.startsWith('detect')).pop(), 'detect:false');
|
||||||
|
bridge.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sender destroyed → everything it held is released (death matrix: destroy)', () => {
|
||||||
|
const audio = fakeAudio();
|
||||||
|
const { bridge } = makeBridge(audio);
|
||||||
|
const sender = fakeSender(1);
|
||||||
|
|
||||||
|
bridge.acquire(sender, 'signal-chain:desktop-main', 'nam_tone');
|
||||||
|
bridge.acquireDemand(sender, 'capture', 'nam_tone');
|
||||||
|
assert.deepEqual(audio.calls, ['start']);
|
||||||
|
|
||||||
|
sender.emit('destroyed');
|
||||||
|
assert.equal(bridge.getHolder('signal-chain:desktop-main'), null);
|
||||||
|
assert.deepEqual(audio.calls, ['start', 'stop']); // demand died with the holder
|
||||||
|
bridge.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('main-frame navigation → grace; same identity re-acquiring restores (death matrix: reload)', () => {
|
||||||
|
const audio = fakeAudio();
|
||||||
|
const { bridge } = makeBridge(audio);
|
||||||
|
const sender = fakeSender(1);
|
||||||
|
|
||||||
|
bridge.acquire(sender, 'signal-chain:desktop-main', 'nam_tone');
|
||||||
|
sender.emit('did-start-navigation', null, 'app://reload', false, true);
|
||||||
|
|
||||||
|
// during grace the scope reads free but a re-acquire from the same
|
||||||
|
// identity (same wc id + tag after reload) restores it
|
||||||
|
assert.equal(bridge.getHolder('signal-chain:desktop-main'), null);
|
||||||
|
const result = bridge.acquire(sender, 'signal-chain:desktop-main', 'nam_tone');
|
||||||
|
assert.equal(result.granted, true);
|
||||||
|
assert.equal(bridge.getHolder('signal-chain:desktop-main'), 'wc:1#nam_tone');
|
||||||
|
|
||||||
|
// subframe navigations never trigger grace
|
||||||
|
bridge.acquire(sender, 'playback', 'nam_tone');
|
||||||
|
sender.emit('did-start-navigation', null, 'app://iframe', false, false);
|
||||||
|
assert.equal(bridge.getHolder('playback'), 'wc:1#nam_tone');
|
||||||
|
bridge.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy-call telemetry logs once per surface per sender (§6.8)', () => {
|
||||||
|
const audio = fakeAudio();
|
||||||
|
const { bridge } = makeBridge(audio);
|
||||||
|
const sender = fakeSender(1);
|
||||||
|
const infos = [];
|
||||||
|
const original = console.info;
|
||||||
|
console.info = (msg) => infos.push(String(msg));
|
||||||
|
try {
|
||||||
|
bridge.noteLegacyCall(sender, 'audio:startAudio');
|
||||||
|
bridge.noteLegacyCall(sender, 'audio:startAudio');
|
||||||
|
bridge.noteLegacyCall(sender, 'audio:stopAudio');
|
||||||
|
bridge.noteLegacyCall(fakeSender(2), 'audio:startAudio');
|
||||||
|
} finally {
|
||||||
|
console.info = original;
|
||||||
|
}
|
||||||
|
assert.equal(infos.filter(m => m.includes('audio:startAudio')).length, 2); // wc:1 once + wc:2 once
|
||||||
|
assert.equal(infos.filter(m => m.includes('audio:stopAudio')).length, 1);
|
||||||
|
bridge.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registry events reach the broadcast channel', () => {
|
||||||
|
const audio = fakeAudio();
|
||||||
|
const { bridge, events } = makeBridge(audio);
|
||||||
|
const sender = fakeSender(1);
|
||||||
|
bridge.acquire(sender, 'signal-chain:desktop-main', 'nam_tone');
|
||||||
|
bridge.acquireDemand(sender, 'capture', 'nam_tone');
|
||||||
|
const names = events.map(e => e.event);
|
||||||
|
assert.ok(names.includes('lease-granted'));
|
||||||
|
assert.ok(names.includes('demand-changed'));
|
||||||
|
bridge.dispose();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user