lotus(#3): soundboard audio injection via widget action
Adds io.lotus.inject_audio (toWidget): mixes a soundboard clip into the call so other participants hear it. Publishes the clip as a separate Unknown-source LiveKit track (rendered by MatrixAudioRenderer) rather than splicing into the mic track, so the denoise pipeline is untouched; the track is unpublished when the clip ends (with a 30s safety cap). This is the real call-audio injection that was impossible against the prebuilt EC bundle. Additive: no-op unless the host sends the action. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0f90600a59
commit
c73eec0781
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
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 { type Room as LivekitRoom, Track } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { widget } from "../widget";
|
||||
import { LotusWidgetActions } from "./lotusActions";
|
||||
|
||||
/** Hard cap so a malformed/huge clip can't hold a published track open forever. */
|
||||
const MAX_CLIP_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Handle the host's `io.lotus.inject_audio` toWidget action (#3): mix a
|
||||
* soundboard clip into the call so other participants hear it.
|
||||
*
|
||||
* Rather than splice into the local mic track (which would fight the denoise
|
||||
* pipeline), we publish the clip as a separate `Unknown`-source LiveKit audio
|
||||
* track — which `MatrixAudioRenderer` already renders for valid call members —
|
||||
* and unpublish it when the clip ends. This is the real call-audio injection
|
||||
* 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).
|
||||
*
|
||||
* No effect unless the host sends the action. Returns a teardown function.
|
||||
*/
|
||||
export function startLotusAudioInject(vm: CallViewModel): () => void {
|
||||
const w = widget;
|
||||
if (!w) return () => undefined;
|
||||
|
||||
// Track the set of connected LiveKit rooms to publish into.
|
||||
let rooms: LivekitRoom[] = [];
|
||||
const sub = vm.livekitRoomItems$.subscribe((items) => {
|
||||
rooms = items.map((i) => i.livekitRoom);
|
||||
});
|
||||
|
||||
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;
|
||||
if (!url) {
|
||||
logger.warn("[lotus] inject_audio: missing url");
|
||||
return;
|
||||
}
|
||||
const volume =
|
||||
typeof data?.volume === "number" &&
|
||||
data.volume >= 0 &&
|
||||
data.volume <= 1
|
||||
? data.volume
|
||||
: 1;
|
||||
void playInjectedClip(url, volume, rooms).catch((e) =>
|
||||
logger.warn("[lotus] inject_audio failed", e),
|
||||
);
|
||||
};
|
||||
|
||||
w.lazyActions.on(LotusWidgetActions.InjectAudio, handler);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
w.lazyActions.off(LotusWidgetActions.InjectAudio, handler);
|
||||
};
|
||||
}
|
||||
|
||||
async function playInjectedClip(
|
||||
url: string,
|
||||
volume: number,
|
||||
rooms: LivekitRoom[],
|
||||
): Promise<void> {
|
||||
if (rooms.length === 0) {
|
||||
logger.warn("[lotus] inject_audio: no connected rooms");
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(url);
|
||||
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);
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.value = volume;
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(gain).connect(dest);
|
||||
|
||||
const mst = dest.stream.getAudioTracks()[0];
|
||||
if (!mst) {
|
||||
void ctx.close();
|
||||
throw new Error("no audio track from destination");
|
||||
}
|
||||
|
||||
// Publish (a clone of) the clip track to every connected room.
|
||||
const publications = await Promise.all(
|
||||
rooms.map(async (room) => {
|
||||
try {
|
||||
const pub = await room.localParticipant.publishTrack(mst.clone(), {
|
||||
source: Track.Source.Unknown,
|
||||
name: "lotus-soundboard",
|
||||
dtx: false,
|
||||
red: false,
|
||||
});
|
||||
return { room, pub };
|
||||
} catch (e) {
|
||||
logger.warn("[lotus] inject_audio: publish failed", e);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let cleanedUp = false;
|
||||
const cleanup = (): void => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
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);
|
||||
};
|
||||
|
||||
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));
|
||||
source.addEventListener("ended", () => clearTimeout(guard));
|
||||
|
||||
source.start();
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { widget } from "../widget";
|
||||
import { startLotusCallState } from "../lotus/lotusCallState";
|
||||
import { startLotusFocus } from "../lotus/lotusFocus";
|
||||
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
|
||||
import styles from "./InCallView.module.css";
|
||||
import { GridTile } from "../tile/GridTile";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
@@ -287,6 +288,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
// [lotus] Handle the host's io.lotus.focus_participant action to pin a
|
||||
// participant to the spotlight (#4). No-op unless the host sends it.
|
||||
useEffect(() => startLotusFocus(vm), [vm]);
|
||||
// [lotus] Handle the host's io.lotus.inject_audio action to mix a soundboard
|
||||
// clip into the call as a separate track (#3). No-op unless the host sends it.
|
||||
useEffect(() => startLotusAudioInject(vm), [vm]);
|
||||
|
||||
const fatalCallError = useBehavior(vm.fatalError$);
|
||||
// Stop the rendering and throw for the error boundary
|
||||
|
||||
Reference in New Issue
Block a user