diff --git a/docs/features/lydmixer.md b/docs/features/lydmixer.md index 54d0556..77057ac 100644 --- a/docs/features/lydmixer.md +++ b/docs/features/lydmixer.md @@ -219,11 +219,11 @@ Lydmixeren aktiveres via `mixer`-traitet på en samlings-node. Krever at - [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 +- [x] Pad-grid UI (4×2 grid med fargede knapper) +- [x] Last lydfiler fra CAS → AudioBuffer +- [x] Avspilling ved trykk (AudioBufferSourceNode) +- [x] Pad-konfigurasjon: velg lydfil, farge, label (lagres i node metadata) +- [x] LiveKit Data Message for synkronisert avspilling på tvers av deltakere ### Fase D: Lydbehandling (EQ) - [ ] Fat bottom (lowshelf filter) diff --git a/frontend/src/lib/components/traits/MixerTrait.svelte b/frontend/src/lib/components/traits/MixerTrait.svelte index 43224dc..e0258ea 100644 --- a/frontend/src/lib/components/traits/MixerTrait.svelte +++ b/frontend/src/lib/components/traits/MixerTrait.svelte @@ -3,6 +3,7 @@ import { edgeStore, nodeStore, mixerChannelStore, stdb } from '$lib/spacetime'; import type { MixerChannel } from '$lib/spacetime'; import TraitPanel from './TraitPanel.svelte'; + import SoundPadGrid from './SoundPadGrid.svelte'; import { getStatus, getParticipants, @@ -328,6 +329,11 @@ {/each} + +
+ +
+
diff --git a/frontend/src/lib/components/traits/SoundPadGrid.svelte b/frontend/src/lib/components/traits/SoundPadGrid.svelte new file mode 100644 index 0000000..a074bcb --- /dev/null +++ b/frontend/src/lib/components/traits/SoundPadGrid.svelte @@ -0,0 +1,327 @@ + + +
+
+

Sound Pads

+ {#if !isViewer} + + {/if} +
+ + +
+ {#each padConfigs as pad, index (index)} + {@const isPlaying = playingPads.has(index)} + {@const isLoading = loadingPads.has(index)} + {@const isEmpty = !pad.cas_hash} + {@const isEditing = configMode && editIndex === index} + + {#if isEditing} + +
+
+ + +
+
+ + {#if pad.cas_hash} + + {/if} + +
+
+ {:else} + +
+ + {#if configMode && !isEmpty} + + {/if} +
+ {/if} + {/each} +
+
diff --git a/frontend/src/lib/livekit.ts b/frontend/src/lib/livekit.ts index 6b1baf9..1a34ff2 100644 --- a/frontend/src/lib/livekit.ts +++ b/frontend/src/lib/livekit.ts @@ -186,6 +186,12 @@ export async function connect(wsUrl: string, token: string): Promise { }) .on(RoomEvent.ActiveSpeakersChanged, () => { refreshParticipants(); + }) + .on(RoomEvent.DataReceived, (payload: Uint8Array, participant?: RemoteParticipant, kind?: DataPacket_Kind, topic?: string) => { + const senderIdentity = participant?.identity; + for (const handler of dataListeners) { + handler(payload, senderIdentity, topic); + } }); room = newRoom; @@ -232,3 +238,30 @@ export async function toggleMute(): Promise { export function isConnected(): boolean { return room?.state === ConnectionState.Connected; } + +// ─── Data Messages (for sound pad sync) ──────────────────────────────────── + +export type DataMessageHandler = (payload: Uint8Array, senderIdentity: string | undefined, topic: string | undefined) => void; + +const dataListeners = new Set(); + +/** + * Subscribe to incoming data messages from other participants. + * Returns an unsubscribe function. + */ +export function onDataMessage(handler: DataMessageHandler): () => void { + dataListeners.add(handler); + return () => { dataListeners.delete(handler); }; +} + +/** + * Send a reliable data message to all participants in the room. + * Uses DataPacket_Kind.RELIABLE for guaranteed delivery. + */ +export async function sendDataMessage(payload: Uint8Array, topic?: string): Promise { + if (!room || room.state !== ConnectionState.Connected) return; + await room.localParticipant.publishData(payload, { + reliable: true, + topic, + }); +} diff --git a/frontend/src/lib/mixer.ts b/frontend/src/lib/mixer.ts index 803860b..863f896 100644 --- a/frontend/src/lib/mixer.ts +++ b/frontend/src/lib/mixer.ts @@ -18,6 +18,12 @@ export interface MixerChannel { gain: GainNode; } +export interface PadState { + buffer: AudioBuffer; + gain: GainNode; + activeSource: AudioBufferSourceNode | null; +} + export interface ChannelLevels { identity: string; peak: number; // 0.0–1.0, peak amplitude @@ -31,6 +37,7 @@ let masterGain: GainNode | null = null; let masterAnalyser: AnalyserNode | null = null; const channels = new Map(); +const pads = new Map(); // Reusable buffer for analyser readings (allocated once per context) let analyserBuffer: Float32Array | null = null; @@ -257,6 +264,117 @@ function readAnalyserLevels(identity: string, analyser: AnalyserNode): ChannelLe }; } +// ─── Sound Pads ───────────────────────────────────────────────────────────── + +/** + * Load an audio file from a URL into an AudioBuffer for a pad. + * Caches the buffer so subsequent plays are instant. + */ +export async function loadPadAudio(padId: string, url: string): Promise { + const ctx = ensureAudioContext(); + + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to fetch pad audio: ${response.status}`); + const arrayBuffer = await response.arrayBuffer(); + const audioBuffer = await ctx.decodeAudioData(arrayBuffer); + + // Create a dedicated gain node for this pad, connected to master + const existing = pads.get(padId); + const gain = existing?.gain ?? ctx.createGain(); + if (!existing) { + gain.gain.value = 1.0; + gain.connect(masterGain!); + } + + pads.set(padId, { buffer: audioBuffer, gain, activeSource: existing?.activeSource ?? null }); +} + +/** + * Check if a pad's audio buffer is loaded and ready to play. + */ +export function isPadLoaded(padId: string): boolean { + return pads.has(padId); +} + +/** + * Play a sound pad. Creates a new AudioBufferSourceNode each time + * (they are one-shot — cannot be restarted after stopping). + * If the pad is already playing, stops the current playback first. + */ +export function playPad(padId: string): void { + const pad = pads.get(padId); + if (!pad || !audioContext) return; + + // Stop any currently playing instance + stopPad(padId); + + const source = audioContext.createBufferSource(); + source.buffer = pad.buffer; + source.connect(pad.gain); + + // Clean up reference when playback ends naturally + source.onended = () => { + if (pad.activeSource === source) { + pad.activeSource = null; + } + }; + + pad.activeSource = source; + source.start(0); +} + +/** + * Stop a currently playing pad. + */ +export function stopPad(padId: string): void { + const pad = pads.get(padId); + if (!pad?.activeSource) return; + + try { + pad.activeSource.stop(); + } catch { + // Already stopped — ignore + } + pad.activeSource = null; +} + +/** + * Set gain for a specific pad (0.0–1.5). + */ +export function setPadGain(padId: string, value: number): void { + const pad = pads.get(padId); + if (!pad) return; + pad.gain.gain.value = Math.max(0, Math.min(1.5, value)); +} + +/** + * Check if a pad is currently playing. + */ +export function isPadPlaying(padId: string): boolean { + const pad = pads.get(padId); + return pad?.activeSource !== null && pad?.activeSource !== undefined; +} + +/** + * Unload a pad's audio buffer and disconnect its gain node. + */ +export function unloadPad(padId: string): void { + const pad = pads.get(padId); + if (!pad) return; + stopPad(padId); + pad.gain.disconnect(); + pads.delete(padId); +} + +/** + * Unload all pads. + */ +export function unloadAllPads(): void { + for (const [padId] of pads) { + unloadPad(padId); + } +} + // ─── Cleanup ─────────────────────────────────────────────────────────────── /** @@ -267,6 +385,8 @@ export function destroyMixer(): void { removeChannel(identity); } + unloadAllPads(); + if (masterAnalyser) { masterAnalyser.disconnect(); masterAnalyser = null; diff --git a/tasks.md b/tasks.md index 4fc06b3..c49caca 100644 --- a/tasks.md +++ b/tasks.md @@ -181,8 +181,7 @@ Ref: `docs/features/lydmixer.md` - [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.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. - > Påbegynt: 2026-03-18T05:09 +- [x] 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).