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
|
- [x] LiveKit Data Message for synkronisert avspilling på tvers av deltakere
|
||||||
|
|
||||||
### Fase D: Lydbehandling (EQ)
|
### Fase D: Lydbehandling (EQ)
|
||||||
- [ ] Fat bottom (lowshelf filter)
|
- [x] Fat bottom (lowshelf filter, +8dB @ 200Hz)
|
||||||
- [ ] Sparkle (highshelf filter)
|
- [x] Sparkle (highshelf filter, +4dB @ 10kHz)
|
||||||
- [ ] Exciter (WaveShaperNode + filter)
|
- [x] Exciter (WaveShaperNode soft-clip + highshelf @ 3.5kHz)
|
||||||
- [ ] Per-kanal av/på-toggles for hver effekt (synkronisert via STDB)
|
- [x] Per-kanal av/på-toggles for hver effekt (synkronisert via STDB `active_effects`)
|
||||||
- [ ] Preset-konfigurasjon (f.eks. "Podcast-stemme", "Radio-stemme")
|
- [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
|
### Fase E: Stemmeeffekter
|
||||||
- [ ] Robotstemme (ring-modulasjon med OscillatorNode)
|
- [ ] Robotstemme (ring-modulasjon med OscillatorNode)
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,13 @@
|
||||||
muteMaster,
|
muteMaster,
|
||||||
unmuteMaster,
|
unmuteMaster,
|
||||||
getChannelIdentities,
|
getChannelIdentities,
|
||||||
|
setChannelEffect,
|
||||||
|
applyActiveEffectsJson,
|
||||||
|
applyEqPreset,
|
||||||
|
EQ_PRESETS,
|
||||||
type ChannelLevels,
|
type ChannelLevels,
|
||||||
|
type EqEffectName,
|
||||||
|
type EqState,
|
||||||
} from '$lib/mixer';
|
} from '$lib/mixer';
|
||||||
|
|
||||||
interface Props {
|
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
|
// VU meter helpers
|
||||||
function vuPercent(levels: ChannelLevels | null | undefined): number {
|
function vuPercent(levels: ChannelLevels | null | undefined): number {
|
||||||
if (!levels) return 0;
|
if (!levels) return 0;
|
||||||
|
|
@ -273,6 +355,8 @@
|
||||||
{@const gain = state.gain}
|
{@const gain = state.gain}
|
||||||
{@const role = channelRole(identity)}
|
{@const role = channelRole(identity)}
|
||||||
{@const isChannelViewer = role === 'viewer'}
|
{@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
|
<div class="rounded-lg border border-gray-100 bg-gray-50 p-2 sm:p-3
|
||||||
{isChannelViewer ? 'opacity-60' : ''}">
|
{isChannelViewer ? 'opacity-60' : ''}">
|
||||||
|
|
@ -325,6 +409,65 @@
|
||||||
{isMuted ? 'DEMPET' : 'DEMP'}
|
{isMuted ? 'DEMPET' : 'DEMP'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,50 @@
|
||||||
* Web Audio mixer graph for Synops.
|
* Web Audio mixer graph for Synops.
|
||||||
*
|
*
|
||||||
* Manages the audio processing graph:
|
* 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.
|
* Each remote participant and the local microphone gets a channel.
|
||||||
* AnalyserNodes provide real-time level data for VU meters.
|
* 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 ──────────────────────────────────────────────────────────────────
|
// ─── 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 {
|
export interface MixerChannel {
|
||||||
identity: string;
|
identity: string;
|
||||||
source: MediaStreamAudioSourceNode;
|
source: MediaStreamAudioSourceNode;
|
||||||
analyser: AnalyserNode;
|
analyser: AnalyserNode;
|
||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
|
eq: EqNodes;
|
||||||
|
eqState: EqState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PadState {
|
export interface PadState {
|
||||||
|
|
@ -82,7 +112,7 @@ export function getAudioContext(): AudioContext | null {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a channel for a participant's audio track.
|
* 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 {
|
export function addChannel(identity: string, mediaStream: MediaStream): MixerChannel {
|
||||||
const ctx = ensureAudioContext();
|
const ctx = ensureAudioContext();
|
||||||
|
|
@ -96,15 +126,26 @@ export function addChannel(identity: string, mediaStream: MediaStream): MixerCha
|
||||||
analyser.fftSize = 256;
|
analyser.fftSize = 256;
|
||||||
analyser.smoothingTimeConstant = 0.3;
|
analyser.smoothingTimeConstant = 0.3;
|
||||||
|
|
||||||
|
// EQ chain nodes
|
||||||
|
const eq = createEqNodes(ctx);
|
||||||
|
|
||||||
const gain = ctx.createGain();
|
const gain = ctx.createGain();
|
||||||
gain.gain.value = 1.0;
|
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);
|
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!);
|
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);
|
channels.set(identity, channel);
|
||||||
|
|
||||||
return channel;
|
return channel;
|
||||||
|
|
@ -119,6 +160,11 @@ export function removeChannel(identity: string): void {
|
||||||
|
|
||||||
channel.source.disconnect();
|
channel.source.disconnect();
|
||||||
channel.analyser.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();
|
channel.gain.disconnect();
|
||||||
channels.delete(identity);
|
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 ─────────────────────────────────────────────────────────────
|
// ─── 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.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.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.
|
- [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).
|
- [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).
|
||||||
> Påbegynt: 2026-03-18T05:18
|
|
||||||
- [ ] 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).
|
- [ ] 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
|
## Fase 17: Lydstudio-utbedring
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue