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:
parent
de498f26a1
commit
453ec4fb59
17 changed files with 862 additions and 41 deletions
249
docs/features/lydmixer.md
Normal file
249
docs/features/lydmixer.md
Normal 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 0–150% | `GainNode` (0.0–1.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, +6–12dB) | `BiquadFilterNode` lowshelf |
|
||||
| **Exciter** | Harmonisk tilstedeværelse (3–5kHz) | `WaveShaperNode` + highshelf |
|
||||
| **Sparkle** | Høyfrekvent luft (~10–16kHz, +3–6dB) | `BiquadFilterNode` highshelf |
|
||||
| **Monsterstemme** | Pitch ned 4–8 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.0–1.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.0–1.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:** ~20–40ms avhengig av FFT-vindu (1024–2048 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, 50–200Hz) 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
|
||||
|
|
@ -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();
|
||||
const conn = stdb.getConnection();
|
||||
if (conn && roomId) {
|
||||
for (const id of activeIdentities) {
|
||||
if (!channelStates.has(id)) {
|
||||
channelStates.set(id, { gain: getChannelGain(id), muted: false });
|
||||
channelStates = new Map(channelStates);
|
||||
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;
|
||||
}
|
||||
channelStates = new Map(channelStates);
|
||||
} else {
|
||||
unmuteChannel(identity, state.gain);
|
||||
}
|
||||
|
||||
// Master controls
|
||||
const conn = stdb.getConnection();
|
||||
if (conn && roomId) {
|
||||
suppressRemoteSync = true;
|
||||
conn.reducers.setMute({ roomId, targetUserId: identity, isMuted: newMuted, updatedBy: localIdentity });
|
||||
requestAnimationFrame(() => { suppressRemoteSync = false; });
|
||||
}
|
||||
}
|
||||
|
||||
// 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'}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
@ -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(),
|
||||
};
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
|
|
@ -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(),
|
||||
};
|
||||
|
|
@ -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(),
|
||||
};
|
||||
|
|
@ -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(),
|
||||
};
|
||||
|
|
@ -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(),
|
||||
};
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.0–1.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.0–1.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(())
|
||||
}
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -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 (0–150%), 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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue