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:
parent
28a600dd9e
commit
171c9b991a
4 changed files with 323 additions and 13 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
3
tasks.md
3
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue