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",
|
CallState = "io.lotus.call_state",
|
||||||
/** toWidget: pin/spotlight (or clear, with userId=null) a participant. */
|
/** toWidget: pin/spotlight (or clear, with userId=null) a participant. */
|
||||||
FocusParticipant = "io.lotus.focus_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",
|
InjectAudio = "io.lotus.inject_audio",
|
||||||
/** toWidget: set audio/screenshare encoding quality (bitrate/framerate). */
|
/** toWidget: set audio/screenshare encoding quality (bitrate/framerate). */
|
||||||
SetQuality = "io.lotus.set_quality",
|
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
|
* that was impossible against the prebuilt EC bundle (LiveKit's
|
||||||
* LocalParticipant lived in EC's module scope).
|
* LocalParticipant lived in EC's module scope).
|
||||||
*
|
*
|
||||||
* Action data: `{ url: string, volume?: number }`. `url` must already be a
|
* Action data: `{ url: string, volume?: number }`. `url` must be an https/blob
|
||||||
* fetchable http(s)/blob URL (the host resolves mxc → media URL).
|
* 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 {
|
export function startLotusAudioInject(vm: CallViewModel): () => void {
|
||||||
const w = widget;
|
const w = widget;
|
||||||
@@ -42,23 +43,25 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
|
|||||||
rooms = items.map((i) => i.livekitRoom);
|
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 => {
|
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||||
void w.api.transport.reply(ev.detail, {});
|
void w.api.transport.reply(ev.detail, {});
|
||||||
const data = ev.detail.data as
|
const data = ev.detail.data as
|
||||||
| { url?: unknown; volume?: unknown }
|
| { url?: unknown; volume?: unknown }
|
||||||
| undefined;
|
| undefined;
|
||||||
const url = typeof data?.url === "string" ? data.url : null;
|
const url = typeof data?.url === "string" ? safeMediaUrl(data.url) : null;
|
||||||
if (!url) {
|
if (!url) {
|
||||||
logger.warn("[lotus] inject_audio: missing url");
|
logger.warn("[lotus] inject_audio: missing/invalid url");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const volume =
|
const volume =
|
||||||
typeof data?.volume === "number" &&
|
typeof data?.volume === "number" && data.volume >= 0 && data.volume <= 1
|
||||||
data.volume >= 0 &&
|
|
||||||
data.volume <= 1
|
|
||||||
? data.volume
|
? data.volume
|
||||||
: 1;
|
: 1;
|
||||||
void playInjectedClip(url, volume, rooms).catch((e) =>
|
void playInjectedClip(url, volume, rooms, activeClips).catch((e) =>
|
||||||
logger.warn("[lotus] inject_audio failed", e),
|
logger.warn("[lotus] inject_audio failed", e),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -67,25 +70,56 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
|
|||||||
return () => {
|
return () => {
|
||||||
sub.unsubscribe();
|
sub.unsubscribe();
|
||||||
w.lazyActions.off(LotusWidgetActions.InjectAudio, handler);
|
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(
|
async function playInjectedClip(
|
||||||
url: string,
|
url: string,
|
||||||
volume: number,
|
volume: number,
|
||||||
rooms: LivekitRoom[],
|
rooms: LivekitRoom[],
|
||||||
|
activeClips: Set<() => void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (rooms.length === 0) {
|
if (rooms.length === 0) {
|
||||||
logger.warn("[lotus] inject_audio: no connected rooms");
|
logger.warn("[lotus] inject_audio: no connected rooms");
|
||||||
return;
|
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}`);
|
if (!resp.ok) throw new Error(`fetch ${url} -> ${resp.status}`);
|
||||||
const arrayBuffer = await resp.arrayBuffer();
|
const arrayBuffer = await resp.arrayBuffer();
|
||||||
|
|
||||||
const ctx = new AudioContext();
|
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 dest = ctx.createMediaStreamDestination();
|
||||||
const gain = ctx.createGain();
|
const gain = ctx.createGain();
|
||||||
gain.gain.value = volume;
|
gain.gain.value = volume;
|
||||||
@@ -102,8 +136,9 @@ async function playInjectedClip(
|
|||||||
// Publish (a clone of) the clip track to every connected room.
|
// Publish (a clone of) the clip track to every connected room.
|
||||||
const publications = await Promise.all(
|
const publications = await Promise.all(
|
||||||
rooms.map(async (room) => {
|
rooms.map(async (room) => {
|
||||||
|
const clone = mst.clone();
|
||||||
try {
|
try {
|
||||||
const pub = await room.localParticipant.publishTrack(mst.clone(), {
|
const pub = await room.localParticipant.publishTrack(clone, {
|
||||||
source: Track.Source.Unknown,
|
source: Track.Source.Unknown,
|
||||||
name: "lotus-soundboard",
|
name: "lotus-soundboard",
|
||||||
dtx: false,
|
dtx: false,
|
||||||
@@ -111,6 +146,7 @@ async function playInjectedClip(
|
|||||||
});
|
});
|
||||||
return { room, pub };
|
return { room, pub };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
clone.stop(); // don't leak the clone if publish failed
|
||||||
logger.warn("[lotus] inject_audio: publish failed", e);
|
logger.warn("[lotus] inject_audio: publish failed", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -121,6 +157,12 @@ async function playInjectedClip(
|
|||||||
const cleanup = (): void => {
|
const cleanup = (): void => {
|
||||||
if (cleanedUp) return;
|
if (cleanedUp) return;
|
||||||
cleanedUp = true;
|
cleanedUp = true;
|
||||||
|
activeClips.delete(cleanup);
|
||||||
|
try {
|
||||||
|
source.stop();
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
for (const entry of publications) {
|
for (const entry of publications) {
|
||||||
if (entry?.pub.track)
|
if (entry?.pub.track)
|
||||||
void entry.room.localParticipant
|
void entry.room.localParticipant
|
||||||
@@ -129,10 +171,15 @@ async function playInjectedClip(
|
|||||||
}
|
}
|
||||||
void ctx.close().catch(() => undefined);
|
void ctx.close().catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
activeClips.add(cleanup);
|
||||||
|
|
||||||
source.onended = cleanup;
|
source.onended = cleanup;
|
||||||
// Safety net: clip metadata can lie, so force teardown after the cap.
|
// Safety net: clip metadata can lie (NaN/huge duration), so force teardown
|
||||||
const guard = setTimeout(cleanup, Math.min(MAX_CLIP_MS, buffer.duration * 1000 + 500));
|
// 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.addEventListener("ended", () => clearTimeout(guard));
|
||||||
|
|
||||||
source.start();
|
source.start();
|
||||||
|
|||||||
Reference in New Issue
Block a user