feat(lotus): soundboard works in voice channels with a call policy (#47)
CI / Build embedded bundle (pull_request) Failing after 1m1s
CI / Publish to Gitea npm registry (pull_request) Skipped

Clips publish as a separate Unknown-source track, which tokens limited to
listed sources (any room with io.lotus.room_quality) refuse, so #52 could
only answer not_permitted. Where the token allows the microphone, the
clip is now mixed into the published mic for its duration: the track on
the mic's RTCRtpSender (post-denoise) and the clip feed a
MediaStreamAudioDestinationNode, the sender switches to the mix with
replaceTrack, and the original goes back when the clip ends. Muting
mid-clip stops it; a track LiveKit swapped in meanwhile is left alone.
not_permitted remains for tokens without the microphone.

Fixes #47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
Lotus CI
2026-10-19 23:23:06 -04:00
co-authored by Claude Opus 5.5
parent 60398b0a7d
commit 657271163f
3 changed files with 303 additions and 10 deletions
+167 -9
View File
@@ -5,7 +5,12 @@ 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 {
type LocalTrack,
type Room as LivekitRoom,
Track,
TrackEvent,
} from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { type IWidgetApiRequest } from "matrix-widget-api";
@@ -52,6 +57,10 @@ function acquireSharedAudio(): {
* that was impossible against the prebuilt EC bundle (LiveKit's
* LocalParticipant lived in EC's module scope).
*
* [EC #47] Exception: rooms with a call policy issue tokens that only allow
* listed sources, which refuse that separate track. There the clip is mixed
* into the published microphone for its duration (`playIntoMicrophone`).
*
* Action data: `{ url?: string, audio?: ArrayBuffer, volume?: number }`.
* `audio` is the clip's bytes (cinny #43: a host `blob:` URL can't be fetched
* once EC is served from its own origin, so the host sends the bytes and they
@@ -76,6 +85,25 @@ export function canPublishSoundboard(
return sources.length === 0 || sources.includes(0);
}
/**
* Pure: how a clip can reach the call under these permissions (EC #47).
* - `"track"`: as its own Unknown-source track (unrestricted rooms).
* - `"mic"`: mixed into the published microphone track. Rooms with a call
* policy grant only listed sources (microphone, maybe screenshare), which
* refuse the separate track, but the mic is allowed.
* - `null`: neither (no publishing at all).
*/
export function soundboardMode(
perm:
| { canPublish?: boolean; canPublishSources?: readonly number[] }
| undefined,
): "track" | "mic" | null {
if (canPublishSoundboard(perm)) return "track";
if (perm?.canPublish === false) return null;
// TrackSource.MICROPHONE = 2
return perm?.canPublishSources?.includes(2) ? "mic" : null;
}
export function startLotusAudioInject(vm: CallViewModel): () => void {
const w = widget;
if (!w) return () => undefined;
@@ -132,21 +160,24 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
return;
}
// [EC #47] Clips publish as an Unknown-source track. Audio-only rooms get
// a token limited to the microphone source, where LiveKit refuses that
// publish *after* we replied; say so up front instead of failing silently.
if (
rooms.some((r) => !canPublishSoundboard(r.localParticipant.permissions))
) {
// [EC #47] Clips publish as an Unknown-source track, which rooms with a
// call policy refuse (their tokens list allowed sources). There the clip
// is mixed into the published microphone instead; only when neither is
// possible say so up front instead of failing silently.
const modes = rooms.map((r) =>
soundboardMode(r.localParticipant.permissions),
);
if (modes.some((m) => m === null)) {
w.api.transport.reply(ev.detail, {
played: false,
reason: "not_permitted",
});
return;
}
const mode = modes.includes("mic") ? "mic" : "track";
w.api.transport.reply(ev.detail, {});
void playInjectedClip(clip, volume, rooms, activeClips).catch((e) =>
void playInjectedClip(clip, volume, rooms, activeClips, mode).catch((e) =>
logger.warn("[lotus] inject_audio failed", e),
);
};
@@ -211,6 +242,7 @@ async function playInjectedClip(
volume: number,
rooms: LivekitRoom[],
activeClips: Set<() => void>,
mode: "track" | "mic" = "track",
): Promise<void> {
if (rooms.length === 0) {
logger.warn("[lotus] inject_audio: no connected rooms");
@@ -295,8 +327,23 @@ async function playInjectedClip(
gain.gain.value = volume;
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(gain).connect(dest);
source.connect(gain);
if (mode === "mic") {
playIntoMicrophone(
ctx,
source,
gain,
buffer,
rooms,
activeClips,
placeholder,
() => aborted,
);
return;
}
gain.connect(dest);
const mst = dest.stream.getAudioTracks()[0];
if (!mst) {
source.disconnect();
@@ -375,3 +422,114 @@ async function playInjectedClip(
source.start();
}
/**
* [EC #47] Mix the clip into the published microphone for its duration. For
* every room, the track currently going out on the mic's RTCRtpSender (the
* denoised one, when noise suppression is on) and the clip are mixed in a
* MediaStreamAudioDestinationNode, and the sender switches to the mix with
* `replaceTrack`. The server still sees one ordinary microphone track, which
* rooms with a call policy allow. When the clip ends the original track goes
* back. Muting mid-clip stops the clip (#13: muted means silent), and if
* LiveKit swapped its own track in meanwhile (device change, denoise toggle)
* that one is left alone.
*/
function playIntoMicrophone(
ctx: AudioContext,
source: AudioBufferSourceNode,
gain: GainNode,
buffer: AudioBuffer,
rooms: LivekitRoom[],
activeClips: Set<() => void>,
placeholder: () => void,
isAborted: () => boolean,
): void {
type Mix = {
sender: RTCRtpSender;
original: MediaStreamTrack;
mixed: MediaStreamTrack;
mic: MediaStreamAudioSourceNode;
dest: MediaStreamAudioDestinationNode;
track: LocalTrack;
};
const mixes: Mix[] = [];
let cleanedUp = false;
const cleanup = (): void => {
if (cleanedUp) return;
cleanedUp = true;
activeClips.delete(cleanup);
activeClips.delete(placeholder);
try {
source.stop();
} catch {
/* not started or already stopped */
}
source.disconnect();
gain.disconnect();
for (const m of mixes) {
m.track.off(TrackEvent.Muted, cleanup);
m.mic.disconnect();
// Only undo our own swap: LiveKit may have replaced the track since.
if (m.sender.track === m.mixed)
void m.sender.replaceTrack(m.original).catch(() => undefined);
m.mixed.stop();
}
};
for (const room of rooms) {
const track = room.localParticipant.getTrackPublication(
Track.Source.Microphone,
)?.track;
const sender = track?.sender;
const original = sender?.track;
if (!track || !sender || !original) continue;
const dest = ctx.createMediaStreamDestination();
const mic = ctx.createMediaStreamSource(new MediaStream([original]));
mic.connect(dest);
gain.connect(dest);
const mixed = dest.stream.getAudioTracks()[0];
if (!mixed) {
mic.disconnect();
continue;
}
mixes.push({ sender, original, mixed, mic, dest, track });
track.on(TrackEvent.Muted, cleanup);
}
activeClips.delete(placeholder);
activeClips.add(cleanup);
if (mixes.length === 0) {
logger.warn("[lotus] inject_audio: no published microphone to mix into");
cleanup();
return;
}
if (isAborted()) {
cleanup();
return;
}
// Switch every sender to its mix, then start the clip once they're live so
// its start isn't clipped.
void Promise.all(
mixes.map(async (m) =>
m.sender.replaceTrack(m.mixed).catch((e) => {
logger.warn("[lotus] inject_audio: replaceTrack failed", e);
}),
),
).then(() => {
if (cleanedUp || isAborted()) {
cleanup();
return;
}
source.onended = cleanup;
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();
});
}