lotus(#3): harden audio-inject per review
- 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:
co-authored by
Claude Opus 4.8
parent
6d739db8b8
commit
10e6ba46e2
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user