diff --git a/src/main/audio-bridge.ts b/src/main/audio-bridge.ts index c37f230..65e3449 100644 --- a/src/main/audio-bridge.ts +++ b/src/main/audio-bridge.ts @@ -9,10 +9,12 @@ import { app } from 'electron'; import { isDebugEnabled, getDebugLogPath } from './debug-log'; import { initVstCrashGuard, armSentinel, disarmSentinel, armEditorSentinel, getSentinelPath } from './vst-crash-guard'; import { createAudioEffectsExecutor } from './audio-effects-executor'; +import { initLeaseBridge, LeaseBridge } from './lease-bridge'; type AudioModule = Record any>; let audio: AudioModule | null = null; +let leaseBridge: LeaseBridge | null = null; type AudioDeviceSettings = { type: string; // legacy alias = inputType when only type was stored @@ -254,6 +256,23 @@ function loadNativeAddon(): AudioModule | null { export function initAudioBridge(): void { audio = loadNativeAddon(); 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) { // Redirect native stderr to the debug log before init() runs — that's @@ -585,11 +604,21 @@ export function initAudioBridge(): void { // ── 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(); + 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(); }); @@ -746,8 +775,17 @@ export function initAudioBridge(): void { // 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 // 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; + 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 { audio.setNoteDetectionEnabled(Boolean(enabled)); } catch (e) { @@ -1394,6 +1432,10 @@ export function initAudioBridge(): void { } export function shutdownAudio(): void { + if (leaseBridge) { + try { leaseBridge.dispose(); } catch { /* silent fail during shutdown */ } + leaseBridge = null; + } if (audio) { try { audio.shutdown(); diff --git a/src/main/lease-bridge.ts b/src/main/lease-bridge.ts new file mode 100644 index 0000000..1a581fd --- /dev/null +++ b/src/main/lease-bridge.ts @@ -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 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; + 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>(); + const watchedSenders = new Set(); + // Log-once-per-session-per-caller telemetry for legacy surfaces (§6.8: + // "that telemetry IS the migration progress dashboard"). + const legacyCallsLogged = new Set(); + // 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 }; diff --git a/src/main/preload.ts b/src/main/preload.ts index bac485e..e1277ec 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -249,6 +249,27 @@ const feedBackDesktopApi = { stopAudio: () => ipcRenderer.invoke('audio:stopAudio'), 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 setGain: (which: string, value: number) => ipcRenderer.invoke('audio:setGain', which, value), setInputChannel: (channel: number) => ipcRenderer.invoke('audio:setInputChannel', channel), diff --git a/tests/_load-ts.js b/tests/_load-ts.js index 9cc716b..b83f934 100644 --- a/tests/_load-ts.js +++ b/tests/_load-ts.js @@ -9,6 +9,24 @@ const ts = require('typescript'); 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) { const file = path.join(ROOT, relPath); const source = fs.readFileSync(file, 'utf8'); diff --git a/tests/contracts/ipc-channels.json b/tests/contracts/ipc-channels.json index 9b9dee0..417bbdb 100644 --- a/tests/contracts/ipc-channels.json +++ b/tests/contracts/ipc-channels.json @@ -44,6 +44,13 @@ "audio:isMlNoteDetection", "audio:isMonitorMuted", "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:listSources", "audio:loadBackingTrack", diff --git a/tests/contracts/preload-audio-api.json b/tests/contracts/preload-audio-api.json index 50e124c..0defa07 100644 --- a/tests/contracts/preload-audio-api.json +++ b/tests/contracts/preload-audio-api.json @@ -39,6 +39,7 @@ "isMlNoteDetection", "isMonitorMuted", "isStreamOutputActive", + "leases", "listInputDevices", "listSources", "loadBackingTrack", diff --git a/tests/lease-bridge.test.js b/tests/lease-bridge.test.js new file mode 100644 index 0000000..922d476 --- /dev/null +++ b/tests/lease-bridge.test.js @@ -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(); +});