lotus(#3): harden audio-inject per review
CI / Build embedded bundle (push) Successful in 3m22s
CI / Publish to Gitea npm registry (push) Has been skipped

- resume() the AudioContext (host postMessage isn't a gesture) so the clip
  isn't silent; warn if it stays suspended (HIGH).
- Close the AudioContext on decode failure (no context leak) (MED).
- Abort in-flight clips on teardown (unmount/vm-change/leave) so audio
  doesn't keep blasting to peers (MED).
- Stop the cloned MediaStreamTrack when a room publish fails (MED).
- Validate url is https/blob and fetch with credentials:omit, mode:cors
  (MED security).
- Guard against NaN clip duration; fix stale enum doc comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lotus CI
2026-06-29 23:26:45 -04:00
co-authored by Claude Opus 4.8
parent 6d739db8b8
commit 10e6ba46e2
2 changed files with 62 additions and 15 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ export enum LotusWidgetActions {
CallState = "io.lotus.call_state",
/** toWidget: pin/spotlight (or clear, with userId=null) a participant. */
FocusParticipant = "io.lotus.focus_participant",
/** toWidget: mix an audio clip into the local published mic track. */
/** toWidget: play an audio clip into the call as a separate published track. */
InjectAudio = "io.lotus.inject_audio",
/** toWidget: set audio/screenshare encoding quality (bitrate/framerate). */
SetQuality = "io.lotus.set_quality",
+61 -14
View File
@@ -27,10 +27,11 @@ const MAX_CLIP_MS = 30_000;
* that was impossible against the prebuilt EC bundle (LiveKit's
* LocalParticipant lived in EC's module scope).
*
* Action data: `{ url: string, volume?: number }`. `url` must already be a
* fetchable http(s)/blob URL (the host resolves mxc → media URL).
* Action data: `{ url: string, volume?: number }`. `url` must be an https/blob
* URL (the host resolves mxc → media URL).
*
* No effect unless the host sends the action. Returns a teardown function.
* No effect unless the host sends the action. Returns a teardown function that
* also aborts any clip still playing.
*/
export function startLotusAudioInject(vm: CallViewModel): () => void {
const w = widget;
@@ -42,23 +43,25 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
rooms = items.map((i) => i.livekitRoom);
});
// In-flight clips, so we can abort them on teardown (unmount / vm change /
// call leave) instead of leaving audio blasting to peers.
const activeClips = new Set<() => void>();
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
void w.api.transport.reply(ev.detail, {});
const data = ev.detail.data as
| { url?: unknown; volume?: unknown }
| undefined;
const url = typeof data?.url === "string" ? data.url : null;
const url = typeof data?.url === "string" ? safeMediaUrl(data.url) : null;
if (!url) {
logger.warn("[lotus] inject_audio: missing url");
logger.warn("[lotus] inject_audio: missing/invalid url");
return;
}
const volume =
typeof data?.volume === "number" &&
data.volume >= 0 &&
data.volume <= 1
typeof data?.volume === "number" && data.volume >= 0 && data.volume <= 1
? data.volume
: 1;
void playInjectedClip(url, volume, rooms).catch((e) =>
void playInjectedClip(url, volume, rooms, activeClips).catch((e) =>
logger.warn("[lotus] inject_audio failed", e),
);
};
@@ -67,25 +70,56 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
return () => {
sub.unsubscribe();
w.lazyActions.off(LotusWidgetActions.InjectAudio, handler);
// Abort anything still playing.
for (const abort of [...activeClips]) abort();
};
}
/** Only allow fetchable media URLs; never same-origin credentialed GETs etc. */
function safeMediaUrl(raw: string): string | null {
try {
const u = new URL(raw, window.location.href);
return u.protocol === "https:" || u.protocol === "blob:" ? u.href : null;
} catch {
return null;
}
}
async function playInjectedClip(
url: string,
volume: number,
rooms: LivekitRoom[],
activeClips: Set<() => void>,
): Promise<void> {
if (rooms.length === 0) {
logger.warn("[lotus] inject_audio: no connected rooms");
return;
}
const resp = await fetch(url);
const resp = await fetch(url, { credentials: "omit", mode: "cors" });
if (!resp.ok) throw new Error(`fetch ${url} -> ${resp.status}`);
const arrayBuffer = await resp.arrayBuffer();
const ctx = new AudioContext();
const buffer = await ctx.decodeAudioData(arrayBuffer);
// 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.
try {
await ctx.resume();
} catch {
/* best effort */
}
if (ctx.state !== "running")
logger.warn(`[lotus] inject_audio: AudioContext is ${ctx.state}`);
let buffer: AudioBuffer;
try {
buffer = await ctx.decodeAudioData(arrayBuffer);
} catch (e) {
void ctx.close();
throw e;
}
const dest = ctx.createMediaStreamDestination();
const gain = ctx.createGain();
gain.gain.value = volume;
@@ -102,8 +136,9 @@ async function playInjectedClip(
// Publish (a clone of) the clip track to every connected room.
const publications = await Promise.all(
rooms.map(async (room) => {
const clone = mst.clone();
try {
const pub = await room.localParticipant.publishTrack(mst.clone(), {
const pub = await room.localParticipant.publishTrack(clone, {
source: Track.Source.Unknown,
name: "lotus-soundboard",
dtx: false,
@@ -111,6 +146,7 @@ async function playInjectedClip(
});
return { room, pub };
} catch (e) {
clone.stop(); // don't leak the clone if publish failed
logger.warn("[lotus] inject_audio: publish failed", e);
return null;
}
@@ -121,6 +157,12 @@ async function playInjectedClip(
const cleanup = (): void => {
if (cleanedUp) return;
cleanedUp = true;
activeClips.delete(cleanup);
try {
source.stop();
} catch {
/* already stopped */
}
for (const entry of publications) {
if (entry?.pub.track)
void entry.room.localParticipant
@@ -129,10 +171,15 @@ async function playInjectedClip(
}
void ctx.close().catch(() => undefined);
};
activeClips.add(cleanup);
source.onended = cleanup;
// Safety net: clip metadata can lie, so force teardown after the cap.
const guard = setTimeout(cleanup, Math.min(MAX_CLIP_MS, buffer.duration * 1000 + 500));
// Safety net: clip metadata can lie (NaN/huge duration), so force teardown
// after a sane, capped delay.
const durationMs = Number.isFinite(buffer.duration)
? buffer.duration * 1000 + 500
: MAX_CLIP_MS;
const guard = setTimeout(cleanup, Math.min(MAX_CLIP_MS, Math.max(0, durationMs)));
source.addEventListener("ended", () => clearTimeout(guard));
source.start();