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}
+
+
+ {: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).