feat(lotus): soundboard works in voice channels with a call policy (#47) #59

Merged
jared merged 2 commits from feat/soundboard-mic-mix into lotus 2026-10-19 23:36:00 -04:00
Owner

Fixes #47 (the remaining part; #52 already refused up front with not_permitted).

Problem: soundboard clips publish as a separate Unknown-source track. Tokens for rooms with a call policy (io.lotus.room_quality) only allow listed sources, so LiveKit refuses that track. The soundboard therefore didn't work in any policy room, not just audio-only ones.

Now (soundboardMode() picks per room):

  • "track": unrestricted rooms keep the separate track, unchanged.
  • "mic": where the token allows the microphone, the clip is mixed into the published microphone for its duration.
    • The track currently on the mic's RTCRtpSender (the denoised one, when noise suppression is on) and the clip feed a MediaStreamAudioDestinationNode. The sender switches to the mix with replaceTrack, and the original goes back when the clip ends.
    • The server still sees one ordinary microphone track.
    • Muting mid-clip stops the clip (#13: muted means silent).
    • If LiveKit swapped its own track in meanwhile (device change, denoise toggle), the restore leaves it alone.
  • null: no microphone grant either, so still not_permitted.

Verified

  • Unit tests, 5 new (13/13 in the file): the mode table; the mix path (no separate publish, sender swapped, clip started, original restored and the mixed track stopped on end); mute mid-clip restores; a LiveKit-swapped track isn't overwritten; no mic grant → not_permitted.
  • Full suite under Node 24: 756 passed. tsc, oxlint and knip are clean.
  • Two-person call in an audio-only voice room (allow_camera: false, allow_screenshare: false), both with a silent fake mic, Audit Main pressing "Beep":
    • Bob's received audio energy from Audit Main: 0 → 0.040 during the clip → 0 after.
    • Audit Main's outgoing mic track: 4659b8c5 → d999d550 (the mix) → back to 4659b8c5.
    • No "soundboard isn't available", and no permission errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5

Fixes #47 (the remaining part; #52 already refused up front with `not_permitted`). **Problem:** soundboard clips publish as a separate `Unknown`-source track. Tokens for rooms with a call policy (`io.lotus.room_quality`) only allow listed sources, so LiveKit refuses that track. The soundboard therefore didn't work in **any** policy room, not just audio-only ones. **Now** (`soundboardMode()` picks per room): - `"track"`: unrestricted rooms keep the separate track, unchanged. - `"mic"`: where the token allows the microphone, the clip is **mixed into the published microphone** for its duration. - The track currently on the mic's `RTCRtpSender` (the denoised one, when noise suppression is on) and the clip feed a `MediaStreamAudioDestinationNode`. The sender switches to the mix with `replaceTrack`, and the original goes back when the clip ends. - The server still sees one ordinary microphone track. - **Muting mid-clip stops the clip** (#13: muted means silent). - If LiveKit swapped its own track in meanwhile (device change, denoise toggle), the restore leaves it alone. - `null`: no microphone grant either, so still `not_permitted`. **Verified** - Unit tests, 5 new (13/13 in the file): the mode table; the mix path (no separate publish, sender swapped, clip started, original restored and the mixed track stopped on end); mute mid-clip restores; a LiveKit-swapped track isn't overwritten; no mic grant → `not_permitted`. - Full suite under Node 24: 756 passed. tsc, oxlint and knip are clean. - **Two-person call** in an audio-only voice room (`allow_camera: false, allow_screenshare: false`), both with a silent fake mic, Audit Main pressing "Beep": - Bob's received audio energy from Audit Main: **0 → 0.040 during the clip → 0 after**. - Audit Main's outgoing mic track: `4659b8c5` → `d999d550` (the mix) → back to `4659b8c5`. - No "soundboard isn't available", and no permission errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
jared added 1 commit 2026-10-19 23:23:07 -04:00
feat(lotus): soundboard works in voice channels with a call policy (#47)
CI / Publish to Gitea npm registry (pull_request) Skipped
CI / Build embedded bundle (pull_request) Failing after 1m1s
657271163f
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
jared added 1 commit 2026-10-19 23:30:58 -04:00
style(lotus): format LOTUS.md table
CI / Publish to Gitea npm registry (pull_request) Skipped
CI / Build embedded bundle (pull_request) Successful in 1m41s
e12b3e5979
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
jared merged commit 0534fa393c into lotus 2026-10-19 23:36:00 -04:00
Sign in to join this conversation.