From 171c9b991ac8332371488e3a4e517409ea87bc43 Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 05:24:24 +0000 Subject: [PATCH] =?UTF-8?q?Fullf=C3=B8rer=20oppgave=2016.6:=20EQ-effektkje?= =?UTF-8?q?de=20med=20per-kanal=20toggles=20og=20presets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementerer tre EQ-effekter i Web Audio-grafen: - Fat bottom: BiquadFilterNode lowshelf +8dB @ 200Hz - Sparkle: BiquadFilterNode highshelf +4dB @ 10kHz - Exciter: WaveShaperNode (soft-clip saturation) + highshelf +4dB @ 3.5kHz - Highpass 80Hz alltid aktiv for rumble-fjerning Signalkjede per kanal: Source → Analyser → HighPass → FatBottom → Exciter → Sparkle → Gain → Master Per-kanal toggles synkroniseres via STDB toggle_effect reducer (allerede implementert i fase B). UI viser fargede toggle-knapper og preset-velger (Av, Podcast-stemme, Radio-stemme). Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/features/lydmixer.md | 11 +- .../lib/components/traits/MixerTrait.svelte | 143 ++++++++++++++ frontend/src/lib/mixer.ts | 179 +++++++++++++++++- tasks.md | 3 +- 4 files changed, 323 insertions(+), 13 deletions(-) diff --git a/docs/features/lydmixer.md b/docs/features/lydmixer.md index 77057ac..572dcbb 100644 --- a/docs/features/lydmixer.md +++ b/docs/features/lydmixer.md @@ -226,11 +226,12 @@ Lydmixeren aktiveres via `mixer`-traitet på en samlings-node. Krever at - [x] 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") +- [x] Fat bottom (lowshelf filter, +8dB @ 200Hz) +- [x] Sparkle (highshelf filter, +4dB @ 10kHz) +- [x] Exciter (WaveShaperNode soft-clip + highshelf @ 3.5kHz) +- [x] Per-kanal av/på-toggles for hver effekt (synkronisert via STDB `active_effects`) +- [x] Preset-konfigurasjon: "Av", "Podcast-stemme" (bass+luft), "Radio-stemme" (bass+luft+exciter) +- [x] Highpass-filter (80Hz) alltid aktiv for rumble-fjerning ### Fase E: Stemmeeffekter - [ ] Robotstemme (ring-modulasjon med OscillatorNode) diff --git a/frontend/src/lib/components/traits/MixerTrait.svelte b/frontend/src/lib/components/traits/MixerTrait.svelte index e0258ea..61f4212 100644 --- a/frontend/src/lib/components/traits/MixerTrait.svelte +++ b/frontend/src/lib/components/traits/MixerTrait.svelte @@ -22,7 +22,13 @@ muteMaster, unmuteMaster, getChannelIdentities, + setChannelEffect, + applyActiveEffectsJson, + applyEqPreset, + EQ_PRESETS, type ChannelLevels, + type EqEffectName, + type EqState, } from '$lib/mixer'; interface Props { @@ -213,6 +219,82 @@ } } + // ─── EQ effect handling ───────────────────────────────────────────────── + + // Parse active_effects JSON from STDB into typed state + function parseEffects(json: string | undefined): EqState { + if (!json) return { fat_bottom: false, sparkle: false, exciter: false }; + try { + const parsed = JSON.parse(json); + return { + fat_bottom: parsed.fat_bottom === true, + sparkle: parsed.sparkle === true, + exciter: parsed.exciter === true, + }; + } catch { + return { fat_bottom: false, sparkle: false, exciter: false }; + } + } + + function getSharedEffects(identity: string): EqState { + if (!roomId) return { fat_bottom: false, sparkle: false, exciter: false }; + const ch = mixerChannelStore.byParticipant(roomId, identity); + return parseEffects(ch?.activeEffects); + } + + function handleToggleEffect(identity: string, effect: EqEffectName) { + const current = getSharedEffects(identity); + const newEnabled = !current[effect]; + setChannelEffect(identity, effect, newEnabled); + + const conn = stdb.getConnection(); + if (conn && roomId) { + suppressRemoteSync = true; + conn.reducers.toggleEffect({ roomId, targetUserId: identity, effectName: effect, updatedBy: localIdentity }); + requestAnimationFrame(() => { suppressRemoteSync = false; }); + } + } + + function handleApplyPreset(identity: string, presetName: string) { + const preset = EQ_PRESETS.find(p => p.name === presetName); + if (!preset) return; + + applyEqPreset(identity, preset); + + // Sync each effect to STDB + const conn = stdb.getConnection(); + if (conn && roomId) { + suppressRemoteSync = true; + const current = getSharedEffects(identity); + for (const [effect, enabled] of Object.entries(preset.effects)) { + if (current[effect as EqEffectName] !== enabled) { + conn.reducers.toggleEffect({ roomId, targetUserId: identity, effectName: effect, updatedBy: localIdentity }); + } + } + requestAnimationFrame(() => { suppressRemoteSync = false; }); + } + } + + // Find which preset matches current effects (if any) + function matchingPreset(effects: EqState): string { + for (const preset of EQ_PRESETS) { + if (preset.effects.fat_bottom === effects.fat_bottom && + preset.effects.sparkle === effects.sparkle && + preset.effects.exciter === effects.exciter) { + return preset.name; + } + } + return 'custom'; + } + + // Sync EQ state from STDB to Web Audio when remote changes arrive + $effect(() => { + if (suppressRemoteSync) return; + for (const ch of sharedChannels) { + applyActiveEffectsJson(ch.targetUserId, ch.activeEffects); + } + }); + // VU meter helpers function vuPercent(levels: ChannelLevels | null | undefined): number { if (!levels) return 0; @@ -273,6 +355,8 @@ {@const gain = state.gain} {@const role = channelRole(identity)} {@const isChannelViewer = role === 'viewer'} + {@const effects = getSharedEffects(identity)} + {@const currentPreset = matchingPreset(effects)}
@@ -325,6 +409,65 @@ {isMuted ? 'DEMPET' : 'DEMP'}
+ + +
+ EQ + + + + + + +
{/each} diff --git a/frontend/src/lib/mixer.ts b/frontend/src/lib/mixer.ts index 863f896..43405fe 100644 --- a/frontend/src/lib/mixer.ts +++ b/frontend/src/lib/mixer.ts @@ -2,20 +2,50 @@ * Web Audio mixer graph for Synops. * * Manages the audio processing graph: - * MediaStreamSource (per channel) → AnalyserNode → GainNode → MasterGain → destination + * MediaStreamSource (per channel) → AnalyserNode → EQ chain → GainNode → MasterGain → destination * * Each remote participant and the local microphone gets a channel. * AnalyserNodes provide real-time level data for VU meters. - * Future phases will insert effect chains between source and gain. + * EQ chain: HighPass(80Hz) → FatBottom(lowshelf 200Hz) → Exciter(WaveShaper+highshelf) → Sparkle(highshelf 10kHz) */ // ─── Types ────────────────────────────────────────────────────────────────── +export type EqEffectName = 'fat_bottom' | 'sparkle' | 'exciter'; + +export interface EqNodes { + highpass: BiquadFilterNode; + fatBottom: BiquadFilterNode; + sparkle: BiquadFilterNode; + exciterShaper: WaveShaperNode; + exciterFilter: BiquadFilterNode; +} + +export interface EqState { + fat_bottom: boolean; + sparkle: boolean; + exciter: boolean; +} + +export interface EqPreset { + name: string; + label: string; + effects: EqState; +} + +export const EQ_PRESETS: EqPreset[] = [ + { name: 'off', label: 'Av', effects: { fat_bottom: false, sparkle: false, exciter: false } }, + { name: 'podcast', label: 'Podcast-stemme', effects: { fat_bottom: true, sparkle: true, exciter: false } }, + { name: 'radio', label: 'Radio-stemme', effects: { fat_bottom: true, sparkle: true, exciter: true } }, +]; + export interface MixerChannel { identity: string; source: MediaStreamAudioSourceNode; analyser: AnalyserNode; gain: GainNode; + eq: EqNodes; + eqState: EqState; } export interface PadState { @@ -82,7 +112,7 @@ export function getAudioContext(): AudioContext | null { /** * Add a channel for a participant's audio track. - * Creates: MediaStreamSource → AnalyserNode → GainNode → MasterGain + * Creates: MediaStreamSource → Analyser → HighPass → FatBottom → Exciter → Sparkle → Gain → Master */ export function addChannel(identity: string, mediaStream: MediaStream): MixerChannel { const ctx = ensureAudioContext(); @@ -96,15 +126,26 @@ export function addChannel(identity: string, mediaStream: MediaStream): MixerCha analyser.fftSize = 256; analyser.smoothingTimeConstant = 0.3; + // EQ chain nodes + const eq = createEqNodes(ctx); + const gain = ctx.createGain(); gain.gain.value = 1.0; - // Signal chain: source → analyser → gain → masterGain + // Signal chain: source → analyser → highpass → fatBottom → exciterShaper → exciterFilter → sparkle → gain → masterGain source.connect(analyser); - analyser.connect(gain); + analyser.connect(eq.highpass); + eq.highpass.connect(eq.fatBottom); + eq.fatBottom.connect(eq.exciterShaper); + eq.exciterShaper.connect(eq.exciterFilter); + eq.exciterFilter.connect(eq.sparkle); + eq.sparkle.connect(gain); gain.connect(masterGain!); - const channel: MixerChannel = { identity, source, analyser, gain }; + // All effects start bypassed (unity/flat) + const eqState: EqState = { fat_bottom: false, sparkle: false, exciter: false }; + + const channel: MixerChannel = { identity, source, analyser, gain, eq, eqState }; channels.set(identity, channel); return channel; @@ -119,6 +160,11 @@ export function removeChannel(identity: string): void { channel.source.disconnect(); channel.analyser.disconnect(); + channel.eq.highpass.disconnect(); + channel.eq.fatBottom.disconnect(); + channel.eq.exciterShaper.disconnect(); + channel.eq.exciterFilter.disconnect(); + channel.eq.sparkle.disconnect(); channel.gain.disconnect(); channels.delete(identity); } @@ -264,6 +310,127 @@ function readAnalyserLevels(identity: string, analyser: AnalyserNode): ChannelLe }; } +// ─── EQ Effect Chain ──────────────────────────────────────────────────────── + +/** + * Create EQ processing nodes for a channel. All effects start in bypass (flat) state. + * Chain: HighPass(80Hz) → FatBottom(lowshelf 200Hz) → Exciter(WaveShaper+highshelf) → Sparkle(highshelf 10kHz) + */ +function createEqNodes(ctx: AudioContext): EqNodes { + // High-pass filter at 80Hz — always active, removes rumble + const highpass = ctx.createBiquadFilter(); + highpass.type = 'highpass'; + highpass.frequency.value = 80; + highpass.Q.value = 0.7; + + // Fat bottom: lowshelf at 200Hz — bypassed = 0dB gain + const fatBottom = ctx.createBiquadFilter(); + fatBottom.type = 'lowshelf'; + fatBottom.frequency.value = 200; + fatBottom.gain.value = 0; // bypassed + + // Sparkle: highshelf at 10kHz — bypassed = 0dB gain + const sparkle = ctx.createBiquadFilter(); + sparkle.type = 'highshelf'; + sparkle.frequency.value = 10000; + sparkle.gain.value = 0; // bypassed + + // Exciter: WaveShaperNode for subtle harmonic saturation + highshelf to focus on presence range + const exciterShaper = ctx.createWaveShaper(); + exciterShaper.curve = createExciterCurve(0); // bypassed = linear + exciterShaper.oversample = '2x'; + + const exciterFilter = ctx.createBiquadFilter(); + exciterFilter.type = 'highshelf'; + exciterFilter.frequency.value = 3500; + exciterFilter.gain.value = 0; // bypassed + + return { highpass, fatBottom, sparkle, exciterShaper, exciterFilter }; +} + +/** + * Generate a WaveShaper curve for the exciter effect. + * amount=0 → linear (bypass), amount=1 → subtle saturation + */ +function createExciterCurve(amount: number): Float32Array { + const samples = 256; + const curve = new Float32Array(samples); + for (let i = 0; i < samples; i++) { + const x = (i * 2) / samples - 1; + if (amount <= 0) { + curve[i] = x; // linear passthrough + } else { + // Soft clipping with adjustable drive + const drive = 1 + amount * 3; + curve[i] = Math.tanh(x * drive) / Math.tanh(drive); + } + } + return curve; +} + +/** + * Set an EQ effect on/off for a channel. Updates the Web Audio nodes directly. + */ +export function setChannelEffect(identity: string, effect: EqEffectName, enabled: boolean): void { + const channel = channels.get(identity); + if (!channel) return; + + channel.eqState[effect] = enabled; + + switch (effect) { + case 'fat_bottom': + // +8dB lowshelf boost when active, 0dB when bypassed + channel.eq.fatBottom.gain.value = enabled ? 8 : 0; + break; + case 'sparkle': + // +4dB highshelf boost when active, 0dB when bypassed + channel.eq.sparkle.gain.value = enabled ? 4 : 0; + break; + case 'exciter': + // Subtle saturation + presence boost when active + channel.eq.exciterShaper.curve = createExciterCurve(enabled ? 0.6 : 0); + channel.eq.exciterFilter.gain.value = enabled ? 4 : 0; + break; + } +} + +/** + * Get the current EQ state for a channel. + */ +export function getChannelEqState(identity: string): EqState | null { + const channel = channels.get(identity); + return channel ? { ...channel.eqState } : null; +} + +/** + * Apply a preset to a channel (sets all effects at once). + */ +export function applyEqPreset(identity: string, preset: EqPreset): void { + for (const [effect, enabled] of Object.entries(preset.effects)) { + setChannelEffect(identity, effect as EqEffectName, enabled); + } +} + +/** + * Apply active_effects JSON from STDB to a channel's Web Audio nodes. + */ +export function applyActiveEffectsJson(identity: string, json: string): void { + const channel = channels.get(identity); + if (!channel) return; + + try { + const effects = JSON.parse(json || '{}'); + for (const name of ['fat_bottom', 'sparkle', 'exciter'] as EqEffectName[]) { + const enabled = effects[name] === true; + if (channel.eqState[name] !== enabled) { + setChannelEffect(identity, name, enabled); + } + } + } catch { + // Invalid JSON — ignore + } +} + // ─── Sound Pads ───────────────────────────────────────────────────────────── /** diff --git a/tasks.md b/tasks.md index 5f59131..12aeeb4 100644 --- a/tasks.md +++ b/tasks.md @@ -182,8 +182,7 @@ Ref: `docs/features/lydmixer.md` - [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. - [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). - > Påbegynt: 2026-03-18T05:18 +- [x] 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). ## Fase 17: Lydstudio-utbedring