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