Fullfører oppgave 16.4: Delt mixer-kontroll via SpacetimeDB

Implementerer sanntids mixer-synkronisering mellom alle deltakere i et
LiveKit-rom via SpacetimeDB. Når én deltaker justerer gain eller muter
en kanal, oppdateres alle klienters Web Audio-graf og UI umiddelbart.

SpacetimeDB-modul (Rust):
- MixerChannel-tabell med room_id/target_user_id-indekser
- Reducers: set_gain (clamped 0.0-1.5), set_mute, toggle_effect,
  create/delete_mixer_channel, set_mixer_role (editor/viewer)
- Viewer-sjekk i reducers — viewer kan ikke endre andres kanaler
- Opprydding av mixer-kanaler i close_live_room og clear_all

Frontend (SvelteKit):
- mixerChannelStore med reaktive callbacks og room_id-indeks
- MixerTrait leser delt state fra STDB, skriver endringer via reducers
- suppressRemoteSync-flagg forhindrer feedback-loop ved egne endringer
- Viewer-modus: disabled sliders/knapper for låste deltakere
- Visuell (V)-indikator for viewer-kanaler

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 05:08:23 +00:00
parent de498f26a1
commit 453ec4fb59
17 changed files with 862 additions and 41 deletions

249
docs/features/lydmixer.md Normal file
View file

@ -0,0 +1,249 @@
# Feature: Lydmixer (Virtuelt studioverktøy)
**Filsti:** `docs/features/lydmixer.md`
## 1. Konsept
En nettleserbasert lydmixer inspirert av RødeCaster Pro II. Gir programledere
og møtedeltakere kontroll over lydnivåer, lydeffekter og stemmeeffekter
direkte i Synops — uten behov for ekstern maskinvare.
Bygget på Web Audio API med LiveKit-integrasjon. Alle lydstrømmer (mikrofon,
remote-deltakere, sound pads) rutes gjennom en Web Audio-graf som gir
per-kanal prosessering før avspilling.
## 2. Brukeropplevelse
1. Brukeren åpner studioet/møterommet og kobler seg til LiveKit-rommet.
2. I bunnen av skjermen vises en **mixerstripe** med én kanal per deltaker.
3. Hver kanal har: volumslider, mute-knapp, og valgfri effektkjede.
4. Over mixeren ligger et **pad-brett** med konfigurerbare lydeffekter.
5. En **effektvelger** per kanal lar brukeren slå av/på stemmeeffekter
(monster, robot) og lydbehandling (fat bottom, exciter, sparkle).
## 3. Kjernekomponenter
### 3.1 Kanalstripe (per deltaker)
Hver LiveKit-deltaker får en dedikert kanalstripe i mixeren:
| Element | Funksjon | Web Audio |
|---|---|---|
| **Volumslider** | Visuell fader 0150% | `GainNode` (0.01.5) |
| **Nød-mute** | Stor, rød knapp — umiddelbar demping | `gain.setValueAtTime(0, now)` |
| **Nivåmeter** | VU-meter som viser live lydnivå | `AnalyserNode` → canvas |
| **Navnelabel** | Deltakerens display_name | Fra LiveKit participant |
### 3.2 Effektkjede (per kanal, valgfri)
Hver kanal kan ha en kjede av prosesseringsmoduler som slås av/på individuelt:
| Effekt | Beskrivelse | Web Audio |
|---|---|---|
| **Fat bottom** | Lavfrekvent fylde (~200Hz, +612dB) | `BiquadFilterNode` lowshelf |
| **Exciter** | Harmonisk tilstedeværelse (35kHz) | `WaveShaperNode` + highshelf |
| **Sparkle** | Høyfrekvent luft (~1016kHz, +36dB) | `BiquadFilterNode` highshelf |
| **Monsterstemme** | Pitch ned 48 halvtoner | `AudioWorkletNode` (phase vocoder) |
| **Robotstemme** | Metallisk ring-modulasjon | `OscillatorNode``GainNode.gain` |
Signalflyt per kanal:
```
Kilde → HighPass(80Hz) → FatBottom → Exciter → Sparkle → PitchShift → GainNode(fader) → Master
```
### 3.3 Sound Pads
Et grid med konfigurerbare lyd-pads (inspirert av RødeCaster Pro II sine 8×8 pads):
| Egenskap | Detalj |
|---|---|
| **Layout** | Grid, f.eks. 4×2 (utvidbart) |
| **Avspilling** | Trykk → spill fra start til slutt |
| **Lydkilde** | Forhåndslastede `AudioBuffer` fra CAS |
| **Volum** | Egen `GainNode` per pad |
| **Synkronisering** | LiveKit Data Message → alle klienter spiller samtidig |
| **Konfigurasjon** | Velg lydfil, farge, label per pad |
Eksempler på standard-pads: jingle/intro, applaus, latter, dramatisk pause,
"breaking news"-sting, rim shot, sad trombone, airhorn.
### 3.4 Delt mixer-kontroll (flerbruker)
Alle deltakere i rommet kan se og bruke mixeren samtidig. Mixer-state
synkroniseres i sanntid via SpacetimeDB, slik at volumendringer, mutes,
effekttogles og pad-avspilling reflekteres hos alle klienter umiddelbart.
| Element | Synkronisering |
|---|---|
| **Volumslider** | STDB: `MixerChannel`-tabell med `gain`-verdi per kanal |
| **Mute** | STDB: `is_muted` boolean per kanal |
| **Effekt av/på** | STDB: `active_effects` JSON per kanal |
| **Pad-trigger** | LiveKit Data Message (lav latens) |
| **Pad-konfig** | Node metadata (persistent, sjelden endring) |
**Tilgangsnivåer:**
- **Full kontroll** (default): alle deltakere kan justere alle kanaler,
trigge pads og endre effekter. Tilsvarer at alle sitter rundt samme
RødeCaster.
- **Begrenset**: eier/admin kan låse enkeltdeltakere til kun "viewer" —
de ser mixeren live men kan ikke interagere. Bruker eksisterende
rolle-system (owner/admin/member-edges).
**Konflikthåndtering:** Last-write-wins. Volumslidere er kontinuerlige
verdier som oppdateres via STDB-reducers. Ved samtidig endring av samme
kanal vinner siste skriving — i praksis uproblematisk fordi endringer
er visuelt synlige for alle og deltakerne koordinerer naturlig.
**SpacetimeDB-tabeller:**
```rust
#[spacetimedb::table(accessor = mixer_channel, public)]
pub struct MixerChannel {
#[primary_key]
pub id: String, // "{room_id}:{target_user_id}"
pub room_id: String, // "communication_{node_uuid}"
pub target_user_id: String, // hvem kanalen tilhører
pub gain: f64, // 0.01.5
pub is_muted: bool,
pub active_effects: String, // JSON: {"fat_bottom": true, "robot": false, ...}
pub role: String, // "editor" | "viewer" — tilgangskontroll per kanal
pub updated_by: String, // hvem som sist endret
pub updated_at: Timestamp,
}
```
**STDB Reducers:**
- `create_mixer_channel(room_id, target_user_id, updated_by)` — idempotent opprettelse
- `set_gain(room_id, target_user_id, gain, updated_by)` — clamped 0.01.5, viewer-sjekk
- `set_mute(room_id, target_user_id, is_muted, updated_by)` — viewer-sjekk
- `toggle_effect(room_id, target_user_id, effect_name, updated_by)` — JSON-toggle
- `delete_mixer_channel(room_id, target_user_id)` — opprydding ved disconnect
- `set_mixer_role(room_id, target_user_id, role, updated_by)` — sett editor/viewer
Mixer-kanaler ryddes automatisk ved `close_live_room` og `clear_all`.
### 3.5 Master-seksjon
| Element | Funksjon |
|---|---|
| **Master fader** | Samlet utgangsnivå |
| **Master mute** | Demper all lyd |
| **Master VU** | Stereo nivåmeter for utgangen |
## 4. Teknisk arkitektur
### 4.1 Web Audio-graf
```
┌─────────────────────────────────┐
│ AudioContext │
│ │
Remote Track 1 ──→ MediaStreamSource → EffektKjede → GainNode ─┐
Remote Track 2 ──→ MediaStreamSource → EffektKjede → GainNode ─┤
Lokal mikrofon ──→ MediaStreamSource → EffektKjede → GainNode ─┼→ MasterGain → destination
Sound Pad ──→ AudioBufferSource ─────────────→ GainNode ─┘
│ │
└─────────────────────────────────┘
```
### 4.2 LiveKit-integrasjon
- **Innkommende lyd:** Deaktiver LiveKit sin auto-attach av `<audio>`-elementer.
Hent `MediaStreamTrack` fra remote participant, opprett
`MediaStreamSourceNode`, rut gjennom Web Audio-kjeden.
- **Utgående lyd:** Mikrofon → Web Audio-kjede → `MediaStreamAudioDestinationNode`
→ publiser den prosesserte tracken via LiveKit.
- **Sound pads over nett:** Send LiveKit Data Message (reliable) med pad-ID
og tidsstempel. Alle klienter trigge samme pad lokalt. Akseptabel synk: <50ms.
### 4.3 Stemmeeffekter (AudioWorklet)
Pitch-shifting (monsterstemme) krever en `AudioWorkletProcessor` som kjører
på lyd-tråden:
- **Algoritme:** Phase vocoder med identity phase locking
- **Bibliotek:** Vurder `phaze` eller `SoundTouchJS` AudioWorklet
- **Latens:** ~2040ms avhengig av FFT-vindu (10242048 samples ved 48kHz)
- **Parametere:** Pitch-faktor (0.5 = oktav ned, 0.7 = monster, 1.0 = normal)
Robot-stemme bruker ring-modulasjon — ingen AudioWorklet nødvendig:
- `OscillatorNode` (sinus, 50200Hz) kobles til `GainNode.gain` AudioParam
- Stemmekilden kobles til samme `GainNode`
- Resultatet er metallisk, Dalek-aktig stemme
### 4.4 Pad-konfigurasjon (lagring)
Sound pads lagres i samlingsnoden sin metadata:
```json
{
"traits": ["recording", "mixer"],
"mixer": {
"pads": [
{ "label": "Jingle", "cas_hash": "abc123...", "color": "#FF6B6B" },
{ "label": "Applaus", "cas_hash": "def456...", "color": "#4ECDC4" }
]
}
}
```
## 5. Avhengigheter
| Avhengighet | Type | Lisens | Formål |
|---|---|---|---|
| `livekit-client` | npm | Apache-2.0 | WebRTC-klient for LiveKit |
| Web Audio API | nettleser | — | All lydprosessering (innebygd) |
| CAS | eksisterende | — | Lagring av pad-lydfiler |
**Ingen betalte tjenester.** All lydprosessering skjer lokalt i nettleseren
via Web Audio API. Stemmeeffekter (robot, monster) og EQ (fat bottom, exciter,
sparkle) bygges med innebygde Web Audio-noder og en egenutviklet
`AudioWorkletProcessor` for pitch shifting. Phase vocoder-algoritmen er
veldokumentert og implementeres direkte — ingen tredjepartslisens nødvendig.
## 6. Node/trait-integrasjon
Lydmixeren aktiveres via `mixer`-traitet på en samlings-node. Krever at
`recording`-traitet også er aktivt (LiveKit-avhengighet).
- **Trait:** `mixer` (kategori: Lyd & video)
- **Frontend:** MixerTrait-komponent i samlingssiden
- **Backend:** Ingen nye endepunkter — bruker eksisterende LiveKit token-generering
og CAS for pad-lydfiler
## 7. Avgrensning
- Mixeren er for **live-bruk**, ikke postproduksjon/redigering
- Effektene prosesseres **lokalt** i nettleseren — ikke på serveren
- Hver bruker kontrollerer sin egen mixer (personlig mix)
- Pad-synkronisering er best-effort (~50ms) — akseptabelt for lydeffekter
- Støtter ikke multitrack-opptak (det er en separat feature)
## 8. Utviklingsfaser
### Fase A: Grunnleggende mixer (MVP)
- [x] `livekit-client` integrasjon i frontend (koble til rom, vise deltakere)
- [x] Web Audio-graf: MediaStreamSource per remote track → GainNode → destination
- [x] Kanalstripe-UI: volumslider + mute-knapp per deltaker
- [x] VU-meter (AnalyserNode → canvas/CSS)
- [x] Master fader og master mute
### Fase B: Delt mixer-kontroll
- [x] SpacetimeDB: `MixerChannel`-tabell + reducers (set_gain, set_mute, toggle_effect, set_mixer_role)
- [x] Frontend abonnerer på mixer-state, oppdaterer Web Audio-graf ved endringer
- [x] Visuell feedback: alle ser sliders bevege seg i sanntid
- [x] Tilgangskontroll: eier/admin kan sette deltaker til "viewer" (kun observere)
### Fase C: Sound Pads
- [ ] Pad-grid UI (4×2 grid med fargede knapper)
- [ ] Last lydfiler fra CAS → AudioBuffer
- [ ] Avspilling ved trykk (AudioBufferSourceNode)
- [ ] Pad-konfigurasjon: velg lydfil, farge, label (lagres i node metadata)
- [ ] LiveKit Data Message for synkronisert avspilling på tvers av deltakere
### Fase D: Lydbehandling (EQ)
- [ ] Fat bottom (lowshelf filter)
- [ ] Sparkle (highshelf filter)
- [ ] Exciter (WaveShaperNode + filter)
- [ ] Per-kanal av/på-toggles for hver effekt (synkronisert via STDB)
- [ ] Preset-konfigurasjon (f.eks. "Podcast-stemme", "Radio-stemme")
### Fase E: Stemmeeffekter
- [ ] Robotstemme (ring-modulasjon med OscillatorNode)
- [ ] Monsterstemme (pitch shift via egenutviklet AudioWorklet)
- [ ] Effektvelger-UI per kanal
- [ ] Parameterjustering (pitch-faktor, oscillator-frekvens)
## 9. Instruks for Claude Code
- Lydmixeren er **ren frontend** — ingen nye Rust-endepunkter nødvendig
- Bruk Web Audio API, IKKE `<audio>`-elementer for prosessering
- AudioWorklet for pitch shifting — aldri ScriptProcessorNode (deprecated)
- `AudioContext` må opprettes fra brukergest (klikk/tap) — nettleserkrav
- Pad-lydfiler lagres i CAS via eksisterende `upload_media` intention
- Pad-konfigurasjon i samlingsnoden sin `metadata.mixer`-nøkkel
- Stemmeeffekter prosesseres lokalt — ikke send prosessert lyd over LiveKit
med mindre brukeren eksplisitt vil at andre skal høre effekten

View file

@ -1,5 +1,7 @@
<script lang="ts">
import type { Node } from '$lib/spacetime';
import { edgeStore, nodeStore, mixerChannelStore, stdb } from '$lib/spacetime';
import type { MixerChannel } from '$lib/spacetime';
import TraitPanel from './TraitPanel.svelte';
import {
getStatus,
@ -16,7 +18,6 @@
muteChannel,
unmuteChannel,
setMasterGain,
getMasterGain,
muteMaster,
unmuteMaster,
getChannelIdentities,
@ -34,10 +35,8 @@
// LiveKit state
let participants: LiveKitParticipant[] = $state([]);
let localIdentity: string = $state('');
let isConnected = $derived(getStatus() === 'connected');
// Per-channel UI state: tracks gain values and mute state for UI
let channelStates: Map<string, { gain: number; muted: boolean }> = $state(new Map());
// Master state (local only — master is per-client, not shared)
let masterState = $state({ gain: 1.0, muted: false });
// VU meter levels (updated via animation frame)
@ -47,23 +46,84 @@
// Animation frame for VU meters
let animFrameId: number | null = null;
// Flag to suppress STDB sync feedback for own changes
let suppressRemoteSync = false;
// Derive room_id from the collection's communication node
// Pattern: "communication_{communication_node_id}"
const roomId = $derived.by(() => {
for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId);
if (node && node.nodeKind === 'communication') {
return `communication_${node.id}`;
}
}
for (const edge of edgeStore.bySource(collection.id)) {
if (edge.edgeType !== 'has_channel') continue;
const node = nodeStore.get(edge.targetId);
if (node && node.nodeKind === 'communication') {
return `communication_${node.id}`;
}
}
return null;
});
// Get shared mixer channels from STDB for this room
const sharedChannels = $derived(roomId ? mixerChannelStore.byRoom(roomId) : []);
// Check if the local user is a viewer (read-only)
const localRole = $derived.by(() => {
if (!roomId || !localIdentity) return 'editor';
const ch = mixerChannelStore.byParticipant(roomId, localIdentity);
return ch?.role ?? 'editor';
});
const isViewer = $derived(localRole === 'viewer');
// Get channel state from STDB shared state, fallback to local
function getSharedState(identity: string): { gain: number; muted: boolean } {
if (!roomId) return { gain: getChannelGain(identity), muted: false };
const ch = mixerChannelStore.byParticipant(roomId, identity);
if (ch) return { gain: ch.gain, muted: ch.isMuted };
return { gain: getChannelGain(identity), muted: false };
}
$effect(() => {
const unsub = subscribe(() => {
participants = getParticipants();
localIdentity = getLocalIdentity();
// Sync channel states for new participants
// Ensure STDB mixer channels exist for new participants
const activeIdentities = getChannelIdentities();
for (const id of activeIdentities) {
if (!channelStates.has(id)) {
channelStates.set(id, { gain: getChannelGain(id), muted: false });
channelStates = new Map(channelStates);
const conn = stdb.getConnection();
if (conn && roomId) {
for (const id of activeIdentities) {
const existing = mixerChannelStore.byParticipant(roomId, id);
if (!existing) {
conn.reducers.createMixerChannel({ roomId, targetUserId: id, updatedBy: localIdentity });
}
}
}
});
return unsub;
});
// Sync Web Audio graph when STDB mixer state changes from other clients
$effect(() => {
if (suppressRemoteSync) return;
for (const ch of sharedChannels) {
// Apply shared gain/mute to Web Audio graph
if (ch.isMuted) {
muteChannel(ch.targetUserId);
} else {
setChannelGain(ch.targetUserId, ch.gain);
// Ensure unmuted in Web Audio if was previously muted
unmuteChannel(ch.targetUserId, ch.gain);
}
}
});
// Start/stop VU meter animation loop based on connection
$effect(() => {
if (getStatus() === 'connected') {
@ -77,7 +137,6 @@
function startVuLoop() {
if (animFrameId !== null) return;
function tick() {
// Read levels for all channels
const newLevels = new Map<string, ChannelLevels>();
for (const id of getChannelIdentities()) {
const l = getChannelLevels(id);
@ -97,32 +156,38 @@
}
}
// Channel controls
// Channel controls — update local Web Audio AND broadcast via STDB
function handleGainChange(identity: string, value: number) {
setChannelGain(identity, value);
const state = channelStates.get(identity);
if (state) {
state.gain = value;
state.muted = false;
channelStates = new Map(channelStates);
const conn = stdb.getConnection();
if (conn && roomId) {
suppressRemoteSync = true;
conn.reducers.setGain({ roomId, targetUserId: identity, gain: value, updatedBy: localIdentity });
// Release suppress after a tick to allow STDB callback to settle
requestAnimationFrame(() => { suppressRemoteSync = false; });
}
}
function handleEmergencyMute(identity: string) {
const state = channelStates.get(identity);
if (!state) return;
const state = getSharedState(identity);
const newMuted = !state.muted;
if (state.muted) {
unmuteChannel(identity, state.gain);
state.muted = false;
} else {
if (newMuted) {
muteChannel(identity);
state.muted = true;
} else {
unmuteChannel(identity, state.gain);
}
const conn = stdb.getConnection();
if (conn && roomId) {
suppressRemoteSync = true;
conn.reducers.setMute({ roomId, targetUserId: identity, isMuted: newMuted, updatedBy: localIdentity });
requestAnimationFrame(() => { suppressRemoteSync = false; });
}
channelStates = new Map(channelStates);
}
// Master controls
// Master controls (local only — not shared across clients)
function handleMasterGainChange(value: number) {
setMasterGain(value);
masterState.gain = value;
@ -139,10 +204,18 @@
}
}
// Access control: set a participant to viewer mode
function handleSetRole(identity: string, role: 'editor' | 'viewer') {
const conn = stdb.getConnection();
if (conn && roomId) {
conn.reducers.setMixerRole({ roomId, targetUserId: identity, role, updatedBy: localIdentity });
}
}
// VU meter helpers
function vuPercent(levels: ChannelLevels | null | undefined): number {
if (!levels) return 0;
return Math.min(100, levels.rms * 100 * 3); // scale up for visibility
return Math.min(100, levels.rms * 100 * 3);
}
function vuColor(levels: ChannelLevels | null | undefined): string {
@ -164,6 +237,12 @@
return Math.round(gain * 100);
}
function channelRole(identity: string): string {
if (!roomId) return 'editor';
const ch = mixerChannelStore.byParticipant(roomId, identity);
return ch?.role ?? 'editor';
}
// Channels that have actual audio (from mixer.ts)
const activeChannels = $derived(getChannelIdentities());
// Show participants that are in the room, with mixer channels where available
@ -187,17 +266,23 @@
<!-- Channel strips -->
<div class="space-y-2 sm:space-y-3">
{#each displayChannels as identity (identity)}
{@const state = channelStates.get(identity)}
{@const state = getSharedState(identity)}
{@const levels = channelLevels.get(identity)}
{@const isMuted = state?.muted ?? false}
{@const gain = state?.gain ?? 1.0}
{@const isMuted = state.muted}
{@const gain = state.gain}
{@const role = channelRole(identity)}
{@const isChannelViewer = role === 'viewer'}
<div class="rounded-lg border border-gray-100 bg-gray-50 p-2 sm:p-3">
<div class="rounded-lg border border-gray-100 bg-gray-50 p-2 sm:p-3
{isChannelViewer ? 'opacity-60' : ''}">
<!-- Mobile: stacked / Desktop: horizontal -->
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<!-- Name label -->
<span class="text-xs font-medium text-gray-700 truncate sm:w-28 sm:flex-shrink-0">
<!-- Name label with role indicator -->
<span class="text-xs font-medium text-gray-700 truncate sm:w-28 sm:flex-shrink-0 flex items-center gap-1">
{displayName(identity)}
{#if isChannelViewer}
<span class="text-[10px] text-gray-400 uppercase" title="Kun visning">(V)</span>
{/if}
</span>
<!-- VU meter -->
@ -217,7 +302,7 @@
step="0.01"
value={gain}
oninput={(e) => handleGainChange(identity, parseFloat((e.target as HTMLInputElement).value))}
disabled={isMuted}
disabled={isMuted || (isViewer && identity !== localIdentity)}
class="flex-1 h-2 accent-indigo-600 disabled:opacity-40"
/>
<span class="text-xs text-gray-500 w-10 text-right tabular-nums flex-shrink-0">
@ -228,10 +313,12 @@
<!-- Emergency mute button -->
<button
onclick={() => handleEmergencyMute(identity)}
disabled={isViewer && identity !== localIdentity}
class="flex-shrink-0 rounded-lg px-3 py-2 text-xs font-bold uppercase tracking-wide transition-colors
{isMuted
? 'bg-red-600 text-white shadow-md hover:bg-red-700'
: 'bg-red-100 text-red-700 hover:bg-red-200'}"
: 'bg-red-100 text-red-700 hover:bg-red-200'}
disabled:opacity-40 disabled:cursor-not-allowed"
title={isMuted ? 'Slå på lyd' : 'Nøddemp'}
>
{isMuted ? 'DEMPET' : 'DEMP'}

View file

@ -64,6 +64,7 @@ function connect(token?: string): DbConnection {
'SELECT * FROM node',
'SELECT * FROM edge',
'SELECT * FROM node_access',
'SELECT * FROM mixer_channel',
]);
})
.onConnectError((_ctx: ErrorContext, err: Error) => {

View file

@ -6,5 +6,5 @@
*/
export { stdb, connectionState } from './connection.svelte';
export { nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from './stores.svelte';
export type { Node, Edge, NodeAccess } from './module_bindings/types';
export { nodeStore, edgeStore, nodeAccessStore, mixerChannelStore, nodeVisibility } from './stores.svelte';
export type { Node, Edge, NodeAccess, MixerChannel } from './module_bindings/types';

View file

@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
roomId: __t.string(),
targetUserId: __t.string(),
updatedBy: __t.string(),
};

View file

@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
roomId: __t.string(),
targetUserId: __t.string(),
};

View file

@ -44,11 +44,18 @@ import DeleteNodeAccessForSubjectReducer from "./delete_node_access_for_subject_
import UpdateEdgeReducer from "./update_edge_reducer";
import UpdateNodeReducer from "./update_node_reducer";
import UpsertNodeAccessReducer from "./upsert_node_access_reducer";
import CreateMixerChannelReducer from "./create_mixer_channel_reducer";
import SetGainReducer from "./set_gain_reducer";
import SetMuteReducer from "./set_mute_reducer";
import ToggleEffectReducer from "./toggle_effect_reducer";
import DeleteMixerChannelReducer from "./delete_mixer_channel_reducer";
import SetMixerRoleReducer from "./set_mixer_role_reducer";
// Import all procedure arg schemas
// Import all table schema definitions
import EdgeRow from "./edge_table";
import MixerChannelRow from "./mixer_channel_table";
import NodeRow from "./node_table";
import NodeAccessRow from "./node_access_table";
@ -73,6 +80,23 @@ const tablesSchema = __schema({
{ name: 'edge_id_key', constraint: 'unique', columns: ['id'] },
],
}, EdgeRow),
mixer_channel: __table({
name: 'mixer_channel',
indexes: [
{ accessor: 'id', name: 'mixer_channel_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
{ accessor: 'room_id', name: 'mixer_channel_room_id_idx_btree', algorithm: 'btree', columns: [
'roomId',
] },
{ accessor: 'target_user_id', name: 'mixer_channel_target_user_id_idx_btree', algorithm: 'btree', columns: [
'targetUserId',
] },
],
constraints: [
{ name: 'mixer_channel_id_key', constraint: 'unique', columns: ['id'] },
],
}, MixerChannelRow),
node: __table({
name: 'node',
indexes: [
@ -115,6 +139,12 @@ const reducersSchema = __reducers(
__reducerSchema("update_edge", UpdateEdgeReducer),
__reducerSchema("update_node", UpdateNodeReducer),
__reducerSchema("upsert_node_access", UpsertNodeAccessReducer),
__reducerSchema("create_mixer_channel", CreateMixerChannelReducer),
__reducerSchema("set_gain", SetGainReducer),
__reducerSchema("set_mute", SetMuteReducer),
__reducerSchema("toggle_effect", ToggleEffectReducer),
__reducerSchema("delete_mixer_channel", DeleteMixerChannelReducer),
__reducerSchema("set_mixer_role", SetMixerRoleReducer),
);
/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */

View file

@ -0,0 +1,23 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
id: __t.string().primaryKey(),
roomId: __t.string().name("room_id"),
targetUserId: __t.string().name("target_user_id"),
gain: __t.f64(),
isMuted: __t.bool().name("is_muted"),
activeEffects: __t.string().name("active_effects"),
role: __t.string(),
updatedBy: __t.string().name("updated_by"),
updatedAt: __t.timestamp().name("updated_at"),
});

View file

@ -0,0 +1,18 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
roomId: __t.string(),
targetUserId: __t.string(),
gain: __t.f64(),
updatedBy: __t.string(),
};

View file

@ -0,0 +1,18 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
roomId: __t.string(),
targetUserId: __t.string(),
role: __t.string(),
updatedBy: __t.string(),
};

View file

@ -0,0 +1,18 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
roomId: __t.string(),
targetUserId: __t.string(),
isMuted: __t.bool(),
updatedBy: __t.string(),
};

View file

@ -0,0 +1,18 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
roomId: __t.string(),
targetUserId: __t.string(),
effectName: __t.string(),
updatedBy: __t.string(),
};

View file

@ -34,6 +34,19 @@ export const Node = __t.object("Node", {
});
export type Node = __Infer<typeof Node>;
export const MixerChannel = __t.object("MixerChannel", {
id: __t.string(),
roomId: __t.string(),
targetUserId: __t.string(),
gain: __t.f64(),
isMuted: __t.bool(),
activeEffects: __t.string(),
role: __t.string(),
updatedBy: __t.string(),
updatedAt: __t.timestamp(),
});
export type MixerChannel = __Infer<typeof MixerChannel>;
export const NodeAccess = __t.object("NodeAccess", {
id: __t.string(),
subjectId: __t.string(),

View file

@ -16,6 +16,12 @@ import DeleteNodeAccessForSubjectReducer from "../delete_node_access_for_subject
import UpdateEdgeReducer from "../update_edge_reducer";
import UpdateNodeReducer from "../update_node_reducer";
import UpsertNodeAccessReducer from "../upsert_node_access_reducer";
import CreateMixerChannelReducer from "../create_mixer_channel_reducer";
import SetGainReducer from "../set_gain_reducer";
import SetMuteReducer from "../set_mute_reducer";
import ToggleEffectReducer from "../toggle_effect_reducer";
import DeleteMixerChannelReducer from "../delete_mixer_channel_reducer";
import SetMixerRoleReducer from "../set_mixer_role_reducer";
export type ClearAllParams = __Infer<typeof ClearAllReducer>;
export type CreateEdgeParams = __Infer<typeof CreateEdgeReducer>;
@ -27,4 +33,10 @@ export type DeleteNodeAccessForSubjectParams = __Infer<typeof DeleteNodeAccessFo
export type UpdateEdgeParams = __Infer<typeof UpdateEdgeReducer>;
export type UpdateNodeParams = __Infer<typeof UpdateNodeReducer>;
export type UpsertNodeAccessParams = __Infer<typeof UpsertNodeAccessReducer>;
export type CreateMixerChannelParams = __Infer<typeof CreateMixerChannelReducer>;
export type SetGainParams = __Infer<typeof SetGainReducer>;
export type SetMuteParams = __Infer<typeof SetMuteReducer>;
export type ToggleEffectParams = __Infer<typeof ToggleEffectReducer>;
export type DeleteMixerChannelParams = __Infer<typeof DeleteMixerChannelReducer>;
export type SetMixerRoleParams = __Infer<typeof SetMixerRoleReducer>;

View file

@ -13,7 +13,7 @@
* const myEdges = $derived(edgeStore.bySource('user-123'));
*/
import type { Node, Edge, NodeAccess } from './module_bindings/types';
import type { Node, Edge, NodeAccess, MixerChannel } from './module_bindings/types';
import type { DbConnection, EventContext } from './module_bindings';
// ---------------------------------------------------------------------------
@ -271,6 +271,85 @@ function createNodeAccessStore() {
export const nodeAccessStore = createNodeAccessStore();
// ---------------------------------------------------------------------------
// MixerChannel store
// ---------------------------------------------------------------------------
let _mixerChannels = $state<Map<string, MixerChannel>>(new Map());
let _mixerVersion = $state(0);
// Secondary index: room_id → set of mixer channel ids
let _mixerByRoom = $state<Map<string, Set<string>>>(new Map());
function createMixerChannelStore() {
return {
/** All mixer channels as an array. */
get all(): MixerChannel[] {
void _mixerVersion;
return [..._mixerChannels.values()];
},
/** Get a mixer channel by id ("{room_id}:{target_user_id}"). */
get(id: string): MixerChannel | undefined {
void _mixerVersion;
return _mixerChannels.get(id);
},
/** Get all mixer channels for a room. */
byRoom(roomId: string): MixerChannel[] {
void _mixerVersion;
const ids = _mixerByRoom.get(roomId);
if (!ids) return [];
return [...ids].map((id) => _mixerChannels.get(id)!).filter(Boolean);
},
/** Get a specific channel for a user in a room. */
byParticipant(roomId: string, userId: string): MixerChannel | undefined {
void _mixerVersion;
return _mixerChannels.get(`${roomId}:${userId}`);
},
/** Number of mixer channels. */
get count(): number {
void _mixerVersion;
return _mixerChannels.size;
},
// -- Internal callbacks for SpacetimeDB --
_onInsert(_ctx: EventContext, row: MixerChannel) {
_mixerChannels.set(row.id, row);
let set = _mixerByRoom.get(row.roomId);
if (!set) {
set = new Set();
_mixerByRoom.set(row.roomId, set);
}
set.add(row.id);
_mixerVersion++;
},
_onDelete(_ctx: EventContext, row: MixerChannel) {
_mixerChannels.delete(row.id);
const set = _mixerByRoom.get(row.roomId);
if (set) {
set.delete(row.id);
if (set.size === 0) _mixerByRoom.delete(row.roomId);
}
_mixerVersion++;
},
_onUpdate(_ctx: EventContext, oldRow: MixerChannel, newRow: MixerChannel) {
_mixerChannels.set(newRow.id, newRow);
// room_id doesn't change (part of composite key), so no index update needed
_mixerVersion++;
},
_clear() {
_mixerChannels = new Map();
_mixerByRoom = new Map();
_mixerVersion++;
},
};
}
export const mixerChannelStore = createMixerChannelStore();
// ---------------------------------------------------------------------------
// Visibility filter
// ---------------------------------------------------------------------------
@ -332,6 +411,7 @@ export function bindStores(conn: DbConnection) {
nodeStore._clear();
edgeStore._clear();
nodeAccessStore._clear();
mixerChannelStore._clear();
// Register callbacks
conn.db.node.onInsert(nodeStore._onInsert);
@ -345,4 +425,8 @@ export function bindStores(conn: DbConnection) {
conn.db.node_access.onInsert(nodeAccessStore._onInsert);
conn.db.node_access.onDelete(nodeAccessStore._onDelete);
conn.db.node_access.onUpdate(nodeAccessStore._onUpdate);
conn.db.mixer_channel.onInsert(mixerChannelStore._onInsert);
conn.db.mixer_channel.onDelete(mixerChannelStore._onDelete);
conn.db.mixer_channel.onUpdate(mixerChannelStore._onUpdate);
}

View file

@ -273,6 +273,30 @@ pub fn delete_edge(ctx: &ReducerContext, id: String) -> Result<(), String> {
Ok(())
}
// =============================================================================
// Mixer-kanaler (delt mixer-kontroll via SpacetimeDB)
// =============================================================================
/// Delt mixer-state per kanal i et live-rom.
/// Transient — finnes bare mens rommet er aktivt.
/// Alle deltakere abonnerer og ser endringer i sanntid.
#[spacetimedb::table(accessor = mixer_channel, public)]
pub struct MixerChannel {
#[primary_key]
pub id: String, // "{room_id}:{target_user_id}"
#[index(btree)]
pub room_id: String,
#[index(btree)]
pub target_user_id: String,
pub gain: f64, // 0.01.5
pub is_muted: bool,
pub active_effects: String, // JSON: {"fat_bottom": true, "robot": false, ...}
pub role: String, // "editor" | "viewer" — tilgangskontroll
pub updated_by: String,
pub updated_at: Timestamp,
}
// =============================================================================
// Live-rom (sanntidslyd via LiveKit)
// =============================================================================
@ -403,6 +427,12 @@ pub fn close_live_room(
ctx.db.room_participant().id().delete(&p.id);
}
// Fjern alle mixer-kanaler for dette rommet
let mixer_channels: Vec<_> = ctx.db.mixer_channel().room_id().filter(&room_id).collect();
for mc in mixer_channels {
ctx.db.mixer_channel().id().delete(&mc.id);
}
// Marker rommet som inaktivt
if let Some(room) = ctx.db.live_room().room_id().find(&room_id) {
ctx.db.live_room().room_id().update(LiveRoom {
@ -414,11 +444,194 @@ pub fn close_live_room(
Ok(())
}
// =============================================================================
// Mixer-kontroll reducers
// =============================================================================
/// Opprett eller oppdater en mixer-kanal med standardverdier.
#[reducer]
pub fn create_mixer_channel(
ctx: &ReducerContext,
room_id: String,
target_user_id: String,
updated_by: String,
) -> Result<(), String> {
let id = format!("{room_id}:{target_user_id}");
// Idempotent — oppdater hvis allerede finnes
if let Some(existing) = ctx.db.mixer_channel().id().find(&id) {
ctx.db.mixer_channel().id().update(MixerChannel {
updated_by,
updated_at: ctx.timestamp,
..existing
});
return Ok(());
}
ctx.db.mixer_channel().insert(MixerChannel {
id,
room_id,
target_user_id,
gain: 1.0,
is_muted: false,
active_effects: "{}".to_string(),
role: "editor".to_string(),
updated_by,
updated_at: ctx.timestamp,
});
Ok(())
}
/// Sett gain (volum) for en mixer-kanal. Clampes til 0.01.5.
#[reducer]
pub fn set_gain(
ctx: &ReducerContext,
room_id: String,
target_user_id: String,
gain: f64,
updated_by: String,
) -> Result<(), String> {
let id = format!("{room_id}:{target_user_id}");
let clamped = gain.clamp(0.0, 1.5);
let existing = ctx.db.mixer_channel().id().find(&id)
.ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?;
// Tilgangskontroll: viewer kan ikke endre
if existing.role == "viewer" && existing.target_user_id != updated_by {
return Err("Viewer kan ikke endre mixer-innstillinger".into());
}
ctx.db.mixer_channel().id().update(MixerChannel {
gain: clamped,
updated_by,
updated_at: ctx.timestamp,
..existing
});
Ok(())
}
/// Sett mute-status for en mixer-kanal.
#[reducer]
pub fn set_mute(
ctx: &ReducerContext,
room_id: String,
target_user_id: String,
is_muted: bool,
updated_by: String,
) -> Result<(), String> {
let id = format!("{room_id}:{target_user_id}");
let existing = ctx.db.mixer_channel().id().find(&id)
.ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?;
if existing.role == "viewer" && existing.target_user_id != updated_by {
return Err("Viewer kan ikke endre mixer-innstillinger".into());
}
ctx.db.mixer_channel().id().update(MixerChannel {
is_muted,
updated_by,
updated_at: ctx.timestamp,
..existing
});
Ok(())
}
/// Toggle en effekt for en mixer-kanal. Effektnavnet slås av/på i active_effects JSON.
#[reducer]
pub fn toggle_effect(
ctx: &ReducerContext,
room_id: String,
target_user_id: String,
effect_name: String,
updated_by: String,
) -> Result<(), String> {
let id = format!("{room_id}:{target_user_id}");
let existing = ctx.db.mixer_channel().id().find(&id)
.ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?;
if existing.role == "viewer" && existing.target_user_id != updated_by {
return Err("Viewer kan ikke endre mixer-innstillinger".into());
}
// Parse, toggle, serialize tilbake
// Enkel JSON-håndtering uten serde (STDB-moduler er lette)
let mut effects = existing.active_effects.clone();
if effects.contains(&format!("\"{}\":true", effect_name)) {
effects = effects.replace(
&format!("\"{}\":true", effect_name),
&format!("\"{}\":false", effect_name),
);
} else if effects.contains(&format!("\"{}\":false", effect_name)) {
effects = effects.replace(
&format!("\"{}\":false", effect_name),
&format!("\"{}\":true", effect_name),
);
} else {
// Effekten finnes ikke — legg til som aktiv
if effects == "{}" {
effects = format!("{{\"{}\":true}}", effect_name);
} else {
// Sett inn før siste }
effects = effects.trim_end_matches('}').to_string()
+ &format!(",\"{}\":true}}", effect_name);
}
}
ctx.db.mixer_channel().id().update(MixerChannel {
active_effects: effects,
updated_by,
updated_at: ctx.timestamp,
..existing
});
Ok(())
}
/// Slett en mixer-kanal (når deltaker forlater rommet).
#[reducer]
pub fn delete_mixer_channel(
ctx: &ReducerContext,
room_id: String,
target_user_id: String,
) -> Result<(), String> {
let id = format!("{room_id}:{target_user_id}");
ctx.db.mixer_channel().id().delete(&id);
Ok(())
}
/// Sett rolle (editor/viewer) for en mixer-kanal.
/// Bare eier/admin kan endre rolle.
#[reducer]
pub fn set_mixer_role(
ctx: &ReducerContext,
room_id: String,
target_user_id: String,
role: String,
_updated_by: String,
) -> Result<(), String> {
if role != "editor" && role != "viewer" {
return Err(format!("Ugyldig rolle: {}. Bruk 'editor' eller 'viewer'", role));
}
let id = format!("{room_id}:{target_user_id}");
let existing = ctx.db.mixer_channel().id().find(&id)
.ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?;
ctx.db.mixer_channel().id().update(MixerChannel {
role,
updated_at: ctx.timestamp,
..existing
});
Ok(())
}
// =============================================================================
// Warmup/vedlikehold
// =============================================================================
/// Tøm alle noder, edges og node_access (brukes ved restart/warmup for å unngå duplikater)
/// Tøm alle noder, edges, node_access og transiente data (brukes ved restart/warmup)
#[reducer]
pub fn clear_all(ctx: &ReducerContext) -> Result<(), String> {
let all_access: Vec<_> = ctx.db.node_access().iter().collect();
@ -433,6 +646,11 @@ pub fn clear_all(ctx: &ReducerContext) -> Result<(), String> {
for n in all_nodes {
ctx.db.node().id().delete(&n.id);
}
log::info!("Alle noder, edges og node_access slettet (clear_all)");
// Rydd opp transiente data
let all_mixer: Vec<_> = ctx.db.mixer_channel().iter().collect();
for m in all_mixer {
ctx.db.mixer_channel().id().delete(&m.id);
}
log::info!("Alle noder, edges, node_access og mixer_channels slettet (clear_all)");
Ok(())
}

View file

@ -180,8 +180,7 @@ Ref: `docs/features/lydmixer.md`
- [x] 16.1 LiveKit-klient i frontend: installer `livekit-client`, koble til rom, vis deltakerliste. Deaktiver LiveKit sin auto-attach av `<audio>`-elementer — lyd rutes gjennom Web Audio API i stedet.
- [x] 16.2 Web Audio mixer-graf: opprett `AudioContext`, `MediaStreamSourceNode` per remote track → per-kanal `GainNode` → master `GainNode``destination`. `AnalyserNode` per kanal for VU-meter.
- [x] 16.3 Mixer-UI (MixerTrait-komponent): kanalstripe per deltaker med volumslider (0150%), nød-mute-knapp (stor, rød), VU-meter (canvas/CSS), navnelabel. Master-fader og master-mute. Responsivt design (mobil: kompakt fader-modus).
- [~] 16.4 Delt mixer-kontroll via SpacetimeDB: `MixerChannel`-tabell + reducers (`set_gain`, `set_mute`, `toggle_effect`). Frontend abonnerer og oppdaterer Web Audio-graf ved endring fra andre deltakere. Visuell feedback (sliders beveger seg i sanntid). Tilgangskontroll: eier/admin kan sette deltaker til viewer-modus.
> Påbegynt: 2026-03-18T04:58
- [x] 16.4 Delt mixer-kontroll via SpacetimeDB: `MixerChannel`-tabell + reducers (`set_gain`, `set_mute`, `toggle_effect`). Frontend abonnerer og oppdaterer Web Audio-graf ved endring fra andre deltakere. Visuell feedback (sliders beveger seg i sanntid). Tilgangskontroll: eier/admin kan sette deltaker til viewer-modus.
- [ ] 16.5 Sound pads: pad-grid UI (4×2), forhåndslast lydfiler fra CAS til `AudioBuffer`. Avspilling ved trykk (`AudioBufferSourceNode`). Pad-konfig i `metadata.mixer.pads` (label, farge, cas_hash). Synkronisert avspilling via LiveKit Data Message.
- [ ] 16.6 EQ-effektkjede: fat bottom (`BiquadFilterNode` lowshelf ~200Hz), sparkle (`BiquadFilterNode` highshelf ~10kHz), exciter (`WaveShaperNode` + highshelf). Per-kanal toggles, synkronisert via STDB. Presets (podcast-stemme, radio-stemme).
- [ ] 16.7 Stemmeeffekter: robotstemme (ring-modulasjon: `OscillatorNode``GainNode.gain`), monsterstemme (egenutviklet `AudioWorkletProcessor` med phase vocoder for pitch shift). Effektvelger-UI per kanal. Parameterjustering (pitch-faktor, oscillator-frekvens).