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">
|
<script lang="ts">
|
||||||
import type { Node } from '$lib/spacetime';
|
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 TraitPanel from './TraitPanel.svelte';
|
||||||
import {
|
import {
|
||||||
getStatus,
|
getStatus,
|
||||||
|
|
@ -16,7 +18,6 @@
|
||||||
muteChannel,
|
muteChannel,
|
||||||
unmuteChannel,
|
unmuteChannel,
|
||||||
setMasterGain,
|
setMasterGain,
|
||||||
getMasterGain,
|
|
||||||
muteMaster,
|
muteMaster,
|
||||||
unmuteMaster,
|
unmuteMaster,
|
||||||
getChannelIdentities,
|
getChannelIdentities,
|
||||||
|
|
@ -34,10 +35,8 @@
|
||||||
// LiveKit state
|
// LiveKit state
|
||||||
let participants: LiveKitParticipant[] = $state([]);
|
let participants: LiveKitParticipant[] = $state([]);
|
||||||
let localIdentity: string = $state('');
|
let localIdentity: string = $state('');
|
||||||
let isConnected = $derived(getStatus() === 'connected');
|
|
||||||
|
|
||||||
// Per-channel UI state: tracks gain values and mute state for UI
|
// Master state (local only — master is per-client, not shared)
|
||||||
let channelStates: Map<string, { gain: number; muted: boolean }> = $state(new Map());
|
|
||||||
let masterState = $state({ gain: 1.0, muted: false });
|
let masterState = $state({ gain: 1.0, muted: false });
|
||||||
|
|
||||||
// VU meter levels (updated via animation frame)
|
// VU meter levels (updated via animation frame)
|
||||||
|
|
@ -47,23 +46,84 @@
|
||||||
// Animation frame for VU meters
|
// Animation frame for VU meters
|
||||||
let animFrameId: number | null = null;
|
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(() => {
|
$effect(() => {
|
||||||
const unsub = subscribe(() => {
|
const unsub = subscribe(() => {
|
||||||
participants = getParticipants();
|
participants = getParticipants();
|
||||||
localIdentity = getLocalIdentity();
|
localIdentity = getLocalIdentity();
|
||||||
|
|
||||||
// Sync channel states for new participants
|
// Ensure STDB mixer channels exist for new participants
|
||||||
const activeIdentities = getChannelIdentities();
|
const activeIdentities = getChannelIdentities();
|
||||||
|
const conn = stdb.getConnection();
|
||||||
|
if (conn && roomId) {
|
||||||
for (const id of activeIdentities) {
|
for (const id of activeIdentities) {
|
||||||
if (!channelStates.has(id)) {
|
const existing = mixerChannelStore.byParticipant(roomId, id);
|
||||||
channelStates.set(id, { gain: getChannelGain(id), muted: false });
|
if (!existing) {
|
||||||
channelStates = new Map(channelStates);
|
conn.reducers.createMixerChannel({ roomId, targetUserId: id, updatedBy: localIdentity });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return unsub;
|
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
|
// Start/stop VU meter animation loop based on connection
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (getStatus() === 'connected') {
|
if (getStatus() === 'connected') {
|
||||||
|
|
@ -77,7 +137,6 @@
|
||||||
function startVuLoop() {
|
function startVuLoop() {
|
||||||
if (animFrameId !== null) return;
|
if (animFrameId !== null) return;
|
||||||
function tick() {
|
function tick() {
|
||||||
// Read levels for all channels
|
|
||||||
const newLevels = new Map<string, ChannelLevels>();
|
const newLevels = new Map<string, ChannelLevels>();
|
||||||
for (const id of getChannelIdentities()) {
|
for (const id of getChannelIdentities()) {
|
||||||
const l = getChannelLevels(id);
|
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) {
|
function handleGainChange(identity: string, value: number) {
|
||||||
setChannelGain(identity, value);
|
setChannelGain(identity, value);
|
||||||
const state = channelStates.get(identity);
|
|
||||||
if (state) {
|
const conn = stdb.getConnection();
|
||||||
state.gain = value;
|
if (conn && roomId) {
|
||||||
state.muted = false;
|
suppressRemoteSync = true;
|
||||||
channelStates = new Map(channelStates);
|
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) {
|
function handleEmergencyMute(identity: string) {
|
||||||
const state = channelStates.get(identity);
|
const state = getSharedState(identity);
|
||||||
if (!state) return;
|
const newMuted = !state.muted;
|
||||||
|
|
||||||
if (state.muted) {
|
if (newMuted) {
|
||||||
unmuteChannel(identity, state.gain);
|
|
||||||
state.muted = false;
|
|
||||||
} else {
|
|
||||||
muteChannel(identity);
|
muteChannel(identity);
|
||||||
state.muted = true;
|
} else {
|
||||||
}
|
unmuteChannel(identity, state.gain);
|
||||||
channelStates = new Map(channelStates);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
function handleMasterGainChange(value: number) {
|
||||||
setMasterGain(value);
|
setMasterGain(value);
|
||||||
masterState.gain = 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
|
// VU meter helpers
|
||||||
function vuPercent(levels: ChannelLevels | null | undefined): number {
|
function vuPercent(levels: ChannelLevels | null | undefined): number {
|
||||||
if (!levels) return 0;
|
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 {
|
function vuColor(levels: ChannelLevels | null | undefined): string {
|
||||||
|
|
@ -164,6 +237,12 @@
|
||||||
return Math.round(gain * 100);
|
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)
|
// Channels that have actual audio (from mixer.ts)
|
||||||
const activeChannels = $derived(getChannelIdentities());
|
const activeChannels = $derived(getChannelIdentities());
|
||||||
// Show participants that are in the room, with mixer channels where available
|
// Show participants that are in the room, with mixer channels where available
|
||||||
|
|
@ -187,17 +266,23 @@
|
||||||
<!-- Channel strips -->
|
<!-- Channel strips -->
|
||||||
<div class="space-y-2 sm:space-y-3">
|
<div class="space-y-2 sm:space-y-3">
|
||||||
{#each displayChannels as identity (identity)}
|
{#each displayChannels as identity (identity)}
|
||||||
{@const state = channelStates.get(identity)}
|
{@const state = getSharedState(identity)}
|
||||||
{@const levels = channelLevels.get(identity)}
|
{@const levels = channelLevels.get(identity)}
|
||||||
{@const isMuted = state?.muted ?? false}
|
{@const isMuted = state.muted}
|
||||||
{@const gain = state?.gain ?? 1.0}
|
{@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 -->
|
<!-- Mobile: stacked / Desktop: horizontal -->
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
<!-- Name label -->
|
<!-- Name label with role indicator -->
|
||||||
<span class="text-xs font-medium text-gray-700 truncate sm:w-28 sm:flex-shrink-0">
|
<span class="text-xs font-medium text-gray-700 truncate sm:w-28 sm:flex-shrink-0 flex items-center gap-1">
|
||||||
{displayName(identity)}
|
{displayName(identity)}
|
||||||
|
{#if isChannelViewer}
|
||||||
|
<span class="text-[10px] text-gray-400 uppercase" title="Kun visning">(V)</span>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- VU meter -->
|
<!-- VU meter -->
|
||||||
|
|
@ -217,7 +302,7 @@
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={gain}
|
value={gain}
|
||||||
oninput={(e) => handleGainChange(identity, parseFloat((e.target as HTMLInputElement).value))}
|
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"
|
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">
|
<span class="text-xs text-gray-500 w-10 text-right tabular-nums flex-shrink-0">
|
||||||
|
|
@ -228,10 +313,12 @@
|
||||||
<!-- Emergency mute button -->
|
<!-- Emergency mute button -->
|
||||||
<button
|
<button
|
||||||
onclick={() => handleEmergencyMute(identity)}
|
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
|
class="flex-shrink-0 rounded-lg px-3 py-2 text-xs font-bold uppercase tracking-wide transition-colors
|
||||||
{isMuted
|
{isMuted
|
||||||
? 'bg-red-600 text-white shadow-md hover:bg-red-700'
|
? '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'}
|
title={isMuted ? 'Slå på lyd' : 'Nøddemp'}
|
||||||
>
|
>
|
||||||
{isMuted ? 'DEMPET' : 'DEMP'}
|
{isMuted ? 'DEMPET' : 'DEMP'}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ function connect(token?: string): DbConnection {
|
||||||
'SELECT * FROM node',
|
'SELECT * FROM node',
|
||||||
'SELECT * FROM edge',
|
'SELECT * FROM edge',
|
||||||
'SELECT * FROM node_access',
|
'SELECT * FROM node_access',
|
||||||
|
'SELECT * FROM mixer_channel',
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
.onConnectError((_ctx: ErrorContext, err: Error) => {
|
.onConnectError((_ctx: ErrorContext, err: Error) => {
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,5 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { stdb, connectionState } from './connection.svelte';
|
export { stdb, connectionState } from './connection.svelte';
|
||||||
export { nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from './stores.svelte';
|
export { nodeStore, edgeStore, nodeAccessStore, mixerChannelStore, nodeVisibility } from './stores.svelte';
|
||||||
export type { Node, Edge, NodeAccess } from './module_bindings/types';
|
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 UpdateEdgeReducer from "./update_edge_reducer";
|
||||||
import UpdateNodeReducer from "./update_node_reducer";
|
import UpdateNodeReducer from "./update_node_reducer";
|
||||||
import UpsertNodeAccessReducer from "./upsert_node_access_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 procedure arg schemas
|
||||||
|
|
||||||
// Import all table schema definitions
|
// Import all table schema definitions
|
||||||
import EdgeRow from "./edge_table";
|
import EdgeRow from "./edge_table";
|
||||||
|
import MixerChannelRow from "./mixer_channel_table";
|
||||||
import NodeRow from "./node_table";
|
import NodeRow from "./node_table";
|
||||||
import NodeAccessRow from "./node_access_table";
|
import NodeAccessRow from "./node_access_table";
|
||||||
|
|
||||||
|
|
@ -73,6 +80,23 @@ const tablesSchema = __schema({
|
||||||
{ name: 'edge_id_key', constraint: 'unique', columns: ['id'] },
|
{ name: 'edge_id_key', constraint: 'unique', columns: ['id'] },
|
||||||
],
|
],
|
||||||
}, EdgeRow),
|
}, 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({
|
node: __table({
|
||||||
name: 'node',
|
name: 'node',
|
||||||
indexes: [
|
indexes: [
|
||||||
|
|
@ -115,6 +139,12 @@ const reducersSchema = __reducers(
|
||||||
__reducerSchema("update_edge", UpdateEdgeReducer),
|
__reducerSchema("update_edge", UpdateEdgeReducer),
|
||||||
__reducerSchema("update_node", UpdateNodeReducer),
|
__reducerSchema("update_node", UpdateNodeReducer),
|
||||||
__reducerSchema("upsert_node_access", UpsertNodeAccessReducer),
|
__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. */
|
/** 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 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", {
|
export const NodeAccess = __t.object("NodeAccess", {
|
||||||
id: __t.string(),
|
id: __t.string(),
|
||||||
subjectId: __t.string(),
|
subjectId: __t.string(),
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ import DeleteNodeAccessForSubjectReducer from "../delete_node_access_for_subject
|
||||||
import UpdateEdgeReducer from "../update_edge_reducer";
|
import UpdateEdgeReducer from "../update_edge_reducer";
|
||||||
import UpdateNodeReducer from "../update_node_reducer";
|
import UpdateNodeReducer from "../update_node_reducer";
|
||||||
import UpsertNodeAccessReducer from "../upsert_node_access_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 ClearAllParams = __Infer<typeof ClearAllReducer>;
|
||||||
export type CreateEdgeParams = __Infer<typeof CreateEdgeReducer>;
|
export type CreateEdgeParams = __Infer<typeof CreateEdgeReducer>;
|
||||||
|
|
@ -27,4 +33,10 @@ export type DeleteNodeAccessForSubjectParams = __Infer<typeof DeleteNodeAccessFo
|
||||||
export type UpdateEdgeParams = __Infer<typeof UpdateEdgeReducer>;
|
export type UpdateEdgeParams = __Infer<typeof UpdateEdgeReducer>;
|
||||||
export type UpdateNodeParams = __Infer<typeof UpdateNodeReducer>;
|
export type UpdateNodeParams = __Infer<typeof UpdateNodeReducer>;
|
||||||
export type UpsertNodeAccessParams = __Infer<typeof UpsertNodeAccessReducer>;
|
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'));
|
* 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';
|
import type { DbConnection, EventContext } from './module_bindings';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -271,6 +271,85 @@ function createNodeAccessStore() {
|
||||||
|
|
||||||
export const nodeAccessStore = 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
|
// Visibility filter
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -332,6 +411,7 @@ export function bindStores(conn: DbConnection) {
|
||||||
nodeStore._clear();
|
nodeStore._clear();
|
||||||
edgeStore._clear();
|
edgeStore._clear();
|
||||||
nodeAccessStore._clear();
|
nodeAccessStore._clear();
|
||||||
|
mixerChannelStore._clear();
|
||||||
|
|
||||||
// Register callbacks
|
// Register callbacks
|
||||||
conn.db.node.onInsert(nodeStore._onInsert);
|
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.onInsert(nodeAccessStore._onInsert);
|
||||||
conn.db.node_access.onDelete(nodeAccessStore._onDelete);
|
conn.db.node_access.onDelete(nodeAccessStore._onDelete);
|
||||||
conn.db.node_access.onUpdate(nodeAccessStore._onUpdate);
|
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(())
|
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)
|
// Live-rom (sanntidslyd via LiveKit)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -403,6 +427,12 @@ pub fn close_live_room(
|
||||||
ctx.db.room_participant().id().delete(&p.id);
|
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
|
// Marker rommet som inaktivt
|
||||||
if let Some(room) = ctx.db.live_room().room_id().find(&room_id) {
|
if let Some(room) = ctx.db.live_room().room_id().find(&room_id) {
|
||||||
ctx.db.live_room().room_id().update(LiveRoom {
|
ctx.db.live_room().room_id().update(LiveRoom {
|
||||||
|
|
@ -414,11 +444,194 @@ pub fn close_live_room(
|
||||||
Ok(())
|
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
|
// 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]
|
#[reducer]
|
||||||
pub fn clear_all(ctx: &ReducerContext) -> Result<(), String> {
|
pub fn clear_all(ctx: &ReducerContext) -> Result<(), String> {
|
||||||
let all_access: Vec<_> = ctx.db.node_access().iter().collect();
|
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 {
|
for n in all_nodes {
|
||||||
ctx.db.node().id().delete(&n.id);
|
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(())
|
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.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.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).
|
- [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.
|
- [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.
|
||||||
> Påbegynt: 2026-03-18T04:58
|
|
||||||
- [ ] 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.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.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).
|
- [ ] 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