Fullfører oppgave 16.6: EQ-effektkjede med per-kanal toggles og presets

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) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 05:24:24 +00:00
parent 28a600dd9e
commit 171c9b991a
4 changed files with 323 additions and 13 deletions

View file

@ -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)

View file

@ -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)}
<div class="rounded-lg border border-gray-100 bg-gray-50 p-2 sm:p-3
{isChannelViewer ? 'opacity-60' : ''}">
@ -325,6 +409,65 @@
{isMuted ? 'DEMPET' : 'DEMP'}
</button>
</div>
<!-- EQ effect toggles -->
<div class="mt-2 flex flex-wrap items-center gap-1.5">
<span class="text-[10px] text-gray-400 uppercase tracking-wider mr-1">EQ</span>
<button
onclick={() => handleToggleEffect(identity, 'fat_bottom')}
disabled={isViewer && identity !== localIdentity}
class="rounded px-2 py-0.5 text-[11px] font-medium transition-colors
{effects.fat_bottom
? 'bg-amber-500 text-white'
: 'bg-gray-200 text-gray-500 hover:bg-gray-300'}
disabled:opacity-40 disabled:cursor-not-allowed"
title="Fat bottom: lavfrekvent fylde (+8dB @ 200Hz)"
>
Bass
</button>
<button
onclick={() => handleToggleEffect(identity, 'sparkle')}
disabled={isViewer && identity !== localIdentity}
class="rounded px-2 py-0.5 text-[11px] font-medium transition-colors
{effects.sparkle
? 'bg-sky-500 text-white'
: 'bg-gray-200 text-gray-500 hover:bg-gray-300'}
disabled:opacity-40 disabled:cursor-not-allowed"
title="Sparkle: høyfrekvent luft (+4dB @ 10kHz)"
>
Luft
</button>
<button
onclick={() => handleToggleEffect(identity, 'exciter')}
disabled={isViewer && identity !== localIdentity}
class="rounded px-2 py-0.5 text-[11px] font-medium transition-colors
{effects.exciter
? 'bg-purple-500 text-white'
: 'bg-gray-200 text-gray-500 hover:bg-gray-300'}
disabled:opacity-40 disabled:cursor-not-allowed"
title="Exciter: harmonisk tilstedeværelse (3.5kHz)"
>
Exciter
</button>
<!-- Preset selector -->
<select
onchange={(e) => handleApplyPreset(identity, (e.target as HTMLSelectElement).value)}
disabled={isViewer && identity !== localIdentity}
class="ml-auto rounded border border-gray-200 bg-white px-1.5 py-0.5 text-[11px] text-gray-600
disabled:opacity-40 disabled:cursor-not-allowed"
title="EQ-preset"
>
{#each EQ_PRESETS as preset (preset.name)}
<option value={preset.name} selected={currentPreset === preset.name}>
{preset.label}
</option>
{/each}
{#if currentPreset === 'custom'}
<option value="custom" selected disabled>Egendefinert</option>
{/if}
</select>
</div>
</div>
{/each}
</div>

View file

@ -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 ─────────────────────────────────────────────────────────────
/**

View file

@ -182,8 +182,7 @@ Ref: `docs/features/lydmixer.md`
- [x] 16.3 Mixer-UI (MixerTrait-komponent): kanalstripe per deltaker med volumslider (0150%), 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