From 68eafcb9a8bf58886641b42f412aa527970ce307 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 13 Sep 2026 01:22:20 -0400 Subject: [PATCH] fix(lotus): soundboard refuses while muted (replies reason:"muted"); one shared AudioContext - Injection is gated on localParticipant.isMicrophoneEnabled and replies { played:false, reason:"muted" } (host UI follow-up in cinny) (#13). - One lazily created module-level AudioContext/destination for all clips, ref-counted and closed on last teardown; per clip only a BufferSource + Gain. The replace-mode race handling is preserved and three latent dangling-placeholder paths are closed (#14). Fixes #13 Fixes #14 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/lotus/lotusAudioInject.test.ts | 225 +++++++++++++++++++++++++++++ src/lotus/lotusAudioInject.ts | 101 ++++++++++--- 2 files changed, 306 insertions(+), 20 deletions(-) create mode 100644 src/lotus/lotusAudioInject.test.ts diff --git a/src/lotus/lotusAudioInject.test.ts b/src/lotus/lotusAudioInject.test.ts new file mode 100644 index 00000000..ee0dcff9 --- /dev/null +++ b/src/lotus/lotusAudioInject.test.ts @@ -0,0 +1,225 @@ +/* +Copyright 2026 Lotus Guild + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { EventEmitter } from "events"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { of } from "rxjs"; + +import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; +import { startLotusAudioInject } from "./lotusAudioInject"; +import { LotusWidgetActions } from "./lotusActions"; + +const lazyActions = new EventEmitter(); +const reply = vi.fn(); + +vi.mock("../widget", () => ({ + widget: { + api: { transport: { reply: (...args: unknown[]) => reply(...args) } }, + // Getter: `vi.mock` factories run at import time, before the const above. + get lazyActions(): EventEmitter { + return lazyActions; + }, + }, +})); + +function send(data: unknown): void { + lazyActions.emit(LotusWidgetActions.InjectAudio, { detail: { data } }); +} + +/** Flush the microtask queue enough times to drain the async awaits in + * `playInjectedClip` (fetch -> arrayBuffer -> resume -> decodeAudioData -> + * publishTrack...), none of which use real timers in this test. */ +async function flush(times = 30): Promise { + for (let i = 0; i < times; i++) await Promise.resolve(); +} + +function makeTrack(): MediaStreamTrack { + return { + clone: vi.fn(() => makeTrack()), + stop: vi.fn(), + } as unknown as MediaStreamTrack; +} + +function makeRoom(isMicrophoneEnabled: boolean): { + localParticipant: { + isMicrophoneEnabled: boolean; + publishTrack: ReturnType; + unpublishTrack: ReturnType; + }; +} { + return { + localParticipant: { + isMicrophoneEnabled, + publishTrack: vi.fn(async (clone: unknown) => { + await Promise.resolve(); + return { track: clone }; + }), + unpublishTrack: vi.fn().mockResolvedValue(undefined), + }, + }; +} + +function mockVm(rooms: unknown[]): CallViewModel { + return { + allConnections$: of({ + getConnections: () => rooms.map((livekitRoom) => ({ livekitRoom })), + }), + } as unknown as CallViewModel; +} + +/** Fake AudioContext/nodes good enough to drive playInjectedClip end to end, + * tracking how many contexts and destinations get constructed (#14). */ +let contextInstances: FakeAudioContext[]; +class FakeAudioContext { + public state = "running"; + public resume = vi.fn().mockResolvedValue(undefined); + public close = vi.fn().mockResolvedValue(undefined); + public decodeAudioData = vi + .fn() + .mockResolvedValue({ duration: 0.01 } as AudioBuffer); + public createMediaStreamDestination = vi.fn(() => ({ + stream: { getAudioTracks: (): MediaStreamTrack[] => [makeTrack()] }, + })); + public createGain = vi.fn(() => ({ + gain: { value: 0 }, + connect: vi.fn((n: unknown) => n), + disconnect: vi.fn(), + })); + public createBufferSource = vi.fn(() => ({ + buffer: undefined as AudioBuffer | undefined, + connect: vi.fn((n: unknown) => n), + disconnect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + onended: null as (() => void) | null, + addEventListener: vi.fn(), + })); + public constructor() { + contextInstances.push(this); + } +} + +beforeEach(() => { + contextInstances = []; + reply.mockClear(); + // The module sets a real `setTimeout` guard per clip (MAX_CLIP_MS safety + // net); use fake timers so a test ending before that guard fires doesn't + // leave a real timer pending. + vi.useFakeTimers(); + vi.stubGlobal("AudioContext", FakeAudioContext); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => { + await Promise.resolve(); + return new ArrayBuffer(8); + }, + }), + ); + // `lotusParam`/`lotusFlag` re-parse `window.location` on every call, so + // just set the flag before each test. + window.location.hash = "#/room?lotusAudioInject=1"; +}); + +afterEach(() => { + lazyActions.removeAllListeners(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +test("#13: a muted local mic blocks the clip and replies with a machine-readable reason", async () => { + const room = makeRoom(false); + const vm = mockVm([room]); + const stop = startLotusAudioInject(vm); + + send({ url: "https://example.com/clip.mp3" }); + await flush(); + + expect(reply).toHaveBeenCalledWith(expect.anything(), { + played: false, + reason: "muted", + }); + expect(room.localParticipant.publishTrack).not.toHaveBeenCalled(); + // No context should even be created for a clip that never plays. + expect(contextInstances).toHaveLength(0); + + stop(); +}); + +test("#13: an unmuted local mic plays the clip normally", async () => { + const room = makeRoom(true); + const vm = mockVm([room]); + const stop = startLotusAudioInject(vm); + + send({ url: "https://example.com/clip.mp3" }); + await flush(); + + expect(reply).toHaveBeenCalledWith(expect.anything(), {}); + expect(room.localParticipant.publishTrack).toHaveBeenCalledTimes(1); + + stop(); +}); + +test("#14: one shared AudioContext/destination is reused across clips, and closed only on the last teardown", async () => { + const room = makeRoom(true); + const vm = mockVm([room]); + const stop = startLotusAudioInject(vm); + + send({ url: "https://example.com/a.mp3" }); + await flush(); + expect(contextInstances).toHaveLength(1); + const ctx = contextInstances[0]; + expect(ctx.createMediaStreamDestination).toHaveBeenCalledTimes(1); + + // Finish the first clip (as if it played to completion) before starting a + // second one, matching the module's own "one clip at a time" contract. + const firstSource = ctx.createBufferSource.mock.results[0]!.value as { + onended: (() => void) | null; + }; + firstSource.onended?.(); + await flush(); + expect(room.localParticipant.unpublishTrack).toHaveBeenCalledTimes(1); + + // A second clip must reuse the SAME context/destination rather than + // creating a new one. + send({ url: "https://example.com/b.mp3" }); + await flush(); + expect(contextInstances).toHaveLength(1); + expect(ctx.createMediaStreamDestination).toHaveBeenCalledTimes(1); + expect(ctx.close).not.toHaveBeenCalled(); + + const secondSource = ctx.createBufferSource.mock.results[1]!.value as { + onended: (() => void) | null; + }; + secondSource.onended?.(); + await flush(); + + // Tearing down the (only) active instance closes the shared context. + stop(); + expect(ctx.close).toHaveBeenCalledTimes(1); +}); + +test("#14: the shared context stays open while another instance is still active", async () => { + const roomA = makeRoom(true); + const roomB = makeRoom(true); + const stopA = startLotusAudioInject(mockVm([roomA])); + const stopB = startLotusAudioInject(mockVm([roomB])); + + send({ url: "https://example.com/a.mp3" }); + await flush(); + expect(contextInstances).toHaveLength(1); + const ctx = contextInstances[0]; + + // Tearing down the first (of two) active instances must not close the + // context out from under the other one. + stopA(); + expect(ctx.close).not.toHaveBeenCalled(); + + stopB(); + expect(ctx.close).toHaveBeenCalledTimes(1); +}); diff --git a/src/lotus/lotusAudioInject.ts b/src/lotus/lotusAudioInject.ts index db3a7a35..19dd6a47 100644 --- a/src/lotus/lotusAudioInject.ts +++ b/src/lotus/lotusAudioInject.ts @@ -17,6 +17,30 @@ import { lotusFlag } from "./lotusWidget"; /** Hard cap so a malformed/huge clip can't hold a published track open forever. */ const MAX_CLIP_MS = 30_000; +// [lotus] One shared module-level AudioContext + MediaStreamAudioDestinationNode +// for ALL injected clips (#14), instead of `new AudioContext()` per clip. An +// in-call page already sits close to Chrome's per-document AudioContext limit +// (~6) between useAudioContext, MatrixAudioRenderer, LiveKit's own Room +// context and LotusDenoiseProcessor; rapid clip replacement (replace-mode +// closing the previous clip's context in the background) could transiently +// exceed the cap and make `new AudioContext()` throw. Lazily created on first +// use, ref-counted by the number of active `startLotusAudioInject` instances, +// and closed only when the last one tears down. +let sharedCtx: AudioContext | undefined; +let sharedDest: MediaStreamAudioDestinationNode | undefined; +let handlerCount = 0; + +function acquireSharedAudio(): { + ctx: AudioContext; + dest: MediaStreamAudioDestinationNode; +} { + if (!sharedCtx || sharedCtx.state === "closed") { + sharedCtx = new AudioContext(); + sharedDest = sharedCtx.createMediaStreamDestination(); + } + return { ctx: sharedCtx, dest: sharedDest! }; +} + /** * Handle the host's `io.lotus.inject_audio` toWidget action (#3): mix a * soundboard clip into the call so other participants hear it. @@ -38,6 +62,10 @@ export function startLotusAudioInject(vm: CallViewModel): () => void { const w = widget; if (!w) return () => undefined; + // [lotus] Count this instance toward the shared AudioContext's lifetime + // (#14) — closed only once the last active instance tears down. + handlerCount++; + // Track the set of connected LiveKit rooms to publish into. Drive off the // LOCAL participant's connection(s), not `livekitRoomItems$` — that stream // omits rooms with no remote members, so inject would no-op while you're @@ -52,16 +80,19 @@ export function startLotusAudioInject(vm: CallViewModel): () => void { const activeClips = new Set<() => void>(); const handler = (ev: CustomEvent): void => { - // Always ack so the transport doesn't hang, but only act when the host has - // explicitly opted in: audio-inject publishes under the local user's - // identity, so it must not be silently armed for every call. - w.api.transport.reply(ev.detail, {}); - if (!lotusFlag("lotusAudioInject")) return; + if (!lotusFlag("lotusAudioInject")) { + // Always ack so the transport doesn't hang, but only act when the host + // has explicitly opted in: audio-inject publishes under the local + // user's identity, so it must not be silently armed for every call. + w.api.transport.reply(ev.detail, {}); + return; + } const data = ev.detail.data as | { url?: unknown; volume?: unknown } | undefined; const url = typeof data?.url === "string" ? safeMediaUrl(data.url) : null; if (!url) { + w.api.transport.reply(ev.detail, {}); logger.warn("[lotus] inject_audio: missing/invalid url"); return; } @@ -69,6 +100,21 @@ export function startLotusAudioInject(vm: CallViewModel): () => void { typeof data?.volume === "number" && data.volume >= 0 && data.volume <= 1 ? data.volume : 1; + + // [lotus] Gate on the local mic being enabled (#13): the clip is + // published as an independent track, fully decoupled from the mic + // publication's mute state, so without this a muted (or push-to-talk + // idle) user could still transmit soundboard audio under their own + // identity — breaking the "I am muted, nothing I do makes noise" mental + // model. Reply with a machine-readable reason so cinny's soundboard UI + // can surface a hint instead of the click silently doing nothing. + const micEnabled = rooms[0]?.localParticipant.isMicrophoneEnabled ?? true; + if (!micEnabled) { + w.api.transport.reply(ev.detail, { played: false, reason: "muted" }); + return; + } + + w.api.transport.reply(ev.detail, {}); void playInjectedClip(url, volume, rooms, activeClips).catch((e) => logger.warn("[lotus] inject_audio failed", e), ); @@ -86,6 +132,15 @@ export function startLotusAudioInject(vm: CallViewModel): () => void { // activeClips while we iterate it. // eslint-disable-next-line unicorn/no-useless-spread for (const abort of [...activeClips]) abort(); + // [lotus] Close the shared AudioContext only when the last active + // instance tears down (#14). + handlerCount--; + if (handlerCount === 0 && sharedCtx) { + const ctx = sharedCtx; + sharedCtx = undefined; + sharedDest = undefined; + void ctx.close().catch(() => undefined); + } }; } @@ -146,23 +201,25 @@ async function playInjectedClip( throw e; } if (aborted) return; - if (!resp.ok) throw new Error(`fetch ${url} -> ${resp.status}`); + if (!resp.ok) { + activeClips.delete(placeholder); + throw new Error(`fetch ${url} -> ${resp.status}`); + } const arrayBuffer = await resp.arrayBuffer(); if (aborted) return; - const ctx = new AudioContext(); + // [lotus] Reuse the shared module-level context/destination (#14) rather + // than `new AudioContext()` per clip — see the declaration above. + const { ctx, dest } = acquireSharedAudio(); // The action arrives via host postMessage, not a gesture in this iframe, so // the context may start suspended — resume it or the clip is silent and - // `onended` never fires. + // `onended` never fires. A no-op if an earlier clip already resumed it. try { await ctx.resume(); } catch { /* best effort */ } - if (aborted) { - void ctx.close(); - return; - } + if (aborted) return; if (ctx.state !== "running") logger.warn(`[lotus] inject_audio: AudioContext is ${ctx.state}`); @@ -170,15 +227,13 @@ async function playInjectedClip( try { buffer = await ctx.decodeAudioData(arrayBuffer); } catch (e) { - void ctx.close(); + activeClips.delete(placeholder); throw e; } - if (aborted) { - void ctx.close(); - return; - } + if (aborted) return; - const dest = ctx.createMediaStreamDestination(); + // Per clip, only the BufferSource/GainNode are created (#14) — the shared + // context/destination are reused across every clip. const gain = ctx.createGain(); gain.gain.value = volume; const source = ctx.createBufferSource(); @@ -187,7 +242,9 @@ async function playInjectedClip( const mst = dest.stream.getAudioTracks()[0]; if (!mst) { - void ctx.close(); + source.disconnect(); + gain.disconnect(); + activeClips.delete(placeholder); throw new Error("no audio track from destination"); } @@ -221,13 +278,17 @@ async function playInjectedClip( } catch { /* already stopped */ } + // [lotus] Dispose only this clip's own nodes (#14) — the shared + // AudioContext/destination outlive it and are closed separately, only + // when the last startLotusAudioInject instance tears down. + source.disconnect(); + gain.disconnect(); for (const entry of publications) { if (entry?.pub.track) void entry.room.localParticipant .unpublishTrack(entry.pub.track, true) .catch(() => undefined); } - void ctx.close().catch(() => undefined); }; // Swap the synchronous placeholder for the real cleanup: from here an abort // (teardown or a newer clip) must unpublish the LIVE track, not just cancel a