Fullfører oppgave 16.3: MixerTrait-komponent med kanalstriper

Implementerer Mixer-UI som trait-komponent for samlingssider:
- Kanalstripe per deltaker med volumslider (0–150%), VU-meter (CSS),
  nød-mute-knapp (rød, tydelig) og navnelabel
- Master-seksjon med fader, VU-meter og master-mute
- Responsivt design: stacked layout på mobil, horisontal på desktop
- Animert VU-meter via requestAnimationFrame og AnalyserNode-data
- Integrert i collection-siden som 'mixer'-trait

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 04:57:22 +00:00
parent 82c45c4208
commit f1c9b281bc
3 changed files with 296 additions and 3 deletions

View file

@ -0,0 +1,291 @@
<script lang="ts">
import type { Node } from '$lib/spacetime';
import TraitPanel from './TraitPanel.svelte';
import {
getStatus,
getParticipants,
getLocalIdentity,
subscribe,
type LiveKitParticipant,
} from '$lib/livekit';
import {
getChannelLevels,
getMasterLevels,
setChannelGain,
getChannelGain,
muteChannel,
unmuteChannel,
setMasterGain,
getMasterGain,
muteMaster,
unmuteMaster,
getChannelIdentities,
type ChannelLevels,
} from '$lib/mixer';
interface Props {
collection: Node;
config: Record<string, unknown>;
accessToken?: string;
}
let { collection, config, accessToken }: Props = $props();
// LiveKit state
let participants: LiveKitParticipant[] = $state([]);
let localIdentity: string = $state('');
let isConnected = $derived(getStatus() === 'connected');
// Per-channel UI state: tracks gain values and mute state for UI
let channelStates: Map<string, { gain: number; muted: boolean }> = $state(new Map());
let masterState = $state({ gain: 1.0, muted: false });
// VU meter levels (updated via animation frame)
let channelLevels: Map<string, ChannelLevels> = $state(new Map());
let masterLevels: ChannelLevels | null = $state(null);
// Animation frame for VU meters
let animFrameId: number | null = null;
$effect(() => {
const unsub = subscribe(() => {
participants = getParticipants();
localIdentity = getLocalIdentity();
// Sync channel states for new participants
const activeIdentities = getChannelIdentities();
for (const id of activeIdentities) {
if (!channelStates.has(id)) {
channelStates.set(id, { gain: getChannelGain(id), muted: false });
channelStates = new Map(channelStates);
}
}
});
return unsub;
});
// Start/stop VU meter animation loop based on connection
$effect(() => {
if (getStatus() === 'connected') {
startVuLoop();
} else {
stopVuLoop();
}
return () => stopVuLoop();
});
function startVuLoop() {
if (animFrameId !== null) return;
function tick() {
// Read levels for all channels
const newLevels = new Map<string, ChannelLevels>();
for (const id of getChannelIdentities()) {
const l = getChannelLevels(id);
if (l) newLevels.set(id, l);
}
channelLevels = newLevels;
masterLevels = getMasterLevels();
animFrameId = requestAnimationFrame(tick);
}
animFrameId = requestAnimationFrame(tick);
}
function stopVuLoop() {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId);
animFrameId = null;
}
}
// Channel controls
function handleGainChange(identity: string, value: number) {
setChannelGain(identity, value);
const state = channelStates.get(identity);
if (state) {
state.gain = value;
state.muted = false;
channelStates = new Map(channelStates);
}
}
function handleEmergencyMute(identity: string) {
const state = channelStates.get(identity);
if (!state) return;
if (state.muted) {
unmuteChannel(identity, state.gain);
state.muted = false;
} else {
muteChannel(identity);
state.muted = true;
}
channelStates = new Map(channelStates);
}
// Master controls
function handleMasterGainChange(value: number) {
setMasterGain(value);
masterState.gain = value;
masterState.muted = false;
}
function handleMasterMute() {
if (masterState.muted) {
unmuteMaster(masterState.gain);
masterState.muted = false;
} else {
muteMaster();
masterState.muted = true;
}
}
// VU meter helpers
function vuPercent(levels: ChannelLevels | null | undefined): number {
if (!levels) return 0;
return Math.min(100, levels.rms * 100 * 3); // scale up for visibility
}
function vuColor(levels: ChannelLevels | null | undefined): string {
if (!levels) return 'bg-gray-300';
const peak = levels.peak;
if (peak > 0.9) return 'bg-red-500';
if (peak > 0.6) return 'bg-yellow-400';
return 'bg-green-500';
}
function displayName(identity: string): string {
const p = participants.find(p => p.identity === identity);
if (!p) return identity;
if (p.identity === localIdentity) return `${p.displayName} (deg)`;
return p.displayName;
}
function gainToPercent(gain: number): number {
return Math.round(gain * 100);
}
// Channels that have actual audio (from mixer.ts)
const activeChannels = $derived(getChannelIdentities());
// Show participants that are in the room, with mixer channels where available
const displayChannels = $derived.by(() => {
const identities = new Set<string>();
for (const id of activeChannels) identities.add(id);
for (const p of participants) {
if (p.identity !== localIdentity) identities.add(p.identity);
}
return Array.from(identities);
});
</script>
<TraitPanel name="mixer" label="Mixer" icon="🎚️">
{#snippet children()}
{#if getStatus() !== 'connected'}
<p class="text-sm text-gray-400">Koble til opptak-rommet for å bruke mixeren.</p>
{:else if displayChannels.length === 0}
<p class="text-sm text-gray-400">Ingen lydkanaler aktive.</p>
{:else}
<!-- Channel strips -->
<div class="space-y-2 sm:space-y-3">
{#each displayChannels as identity (identity)}
{@const state = channelStates.get(identity)}
{@const levels = channelLevels.get(identity)}
{@const isMuted = state?.muted ?? false}
{@const gain = state?.gain ?? 1.0}
<div class="rounded-lg border border-gray-100 bg-gray-50 p-2 sm:p-3">
<!-- Mobile: stacked / Desktop: horizontal -->
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<!-- Name label -->
<span class="text-xs font-medium text-gray-700 truncate sm:w-28 sm:flex-shrink-0">
{displayName(identity)}
</span>
<!-- VU meter -->
<div class="h-2 flex-shrink-0 rounded-full bg-gray-200 overflow-hidden sm:w-20 sm:flex-shrink-0">
<div
class="h-full rounded-full transition-none {isMuted ? 'bg-gray-300' : vuColor(levels)}"
style="width: {isMuted ? 0 : vuPercent(levels)}%"
></div>
</div>
<!-- Volume slider -->
<div class="flex items-center gap-2 flex-1 min-w-0">
<input
type="range"
min="0"
max="1.5"
step="0.01"
value={gain}
oninput={(e) => handleGainChange(identity, parseFloat((e.target as HTMLInputElement).value))}
disabled={isMuted}
class="flex-1 h-2 accent-indigo-600 disabled:opacity-40"
/>
<span class="text-xs text-gray-500 w-10 text-right tabular-nums flex-shrink-0">
{gainToPercent(gain)}%
</span>
</div>
<!-- Emergency mute button -->
<button
onclick={() => handleEmergencyMute(identity)}
class="flex-shrink-0 rounded-lg px-3 py-2 text-xs font-bold uppercase tracking-wide transition-colors
{isMuted
? 'bg-red-600 text-white shadow-md hover:bg-red-700'
: 'bg-red-100 text-red-700 hover:bg-red-200'}"
title={isMuted ? 'Slå på lyd' : 'Nøddemp'}
>
{isMuted ? 'DEMPET' : 'DEMP'}
</button>
</div>
</div>
{/each}
</div>
<!-- Master section -->
<div class="mt-4 rounded-lg border-2 border-gray-200 bg-white p-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<span class="text-xs font-bold text-gray-800 uppercase tracking-wide sm:w-28 sm:flex-shrink-0">
Master
</span>
<!-- Master VU meter -->
<div class="h-2.5 flex-shrink-0 rounded-full bg-gray-200 overflow-hidden sm:w-20 sm:flex-shrink-0">
<div
class="h-full rounded-full transition-none {masterState.muted ? 'bg-gray-300' : vuColor(masterLevels)}"
style="width: {masterState.muted ? 0 : vuPercent(masterLevels)}%"
></div>
</div>
<!-- Master slider -->
<div class="flex items-center gap-2 flex-1 min-w-0">
<input
type="range"
min="0"
max="1.5"
step="0.01"
value={masterState.gain}
oninput={(e) => handleMasterGainChange(parseFloat((e.target as HTMLInputElement).value))}
disabled={masterState.muted}
class="flex-1 h-2 accent-indigo-600 disabled:opacity-40"
/>
<span class="text-xs text-gray-500 w-10 text-right tabular-nums flex-shrink-0">
{gainToPercent(masterState.gain)}%
</span>
</div>
<!-- Master mute -->
<button
onclick={handleMasterMute}
class="flex-shrink-0 rounded-lg px-4 py-2.5 text-sm font-bold uppercase tracking-wide transition-colors
{masterState.muted
? 'bg-red-600 text-white shadow-lg hover:bg-red-700 ring-2 ring-red-300'
: 'bg-red-100 text-red-700 hover:bg-red-200'}"
title={masterState.muted ? 'Slå på master' : 'Demp alt'}
>
{masterState.muted ? 'DEMPET' : 'DEMP'}
</button>
</div>
</div>
{/if}
{/snippet}
</TraitPanel>

View file

@ -14,6 +14,7 @@
import RecordingTrait from '$lib/components/traits/RecordingTrait.svelte'; import RecordingTrait from '$lib/components/traits/RecordingTrait.svelte';
import TranscriptionTrait from '$lib/components/traits/TranscriptionTrait.svelte'; import TranscriptionTrait from '$lib/components/traits/TranscriptionTrait.svelte';
import StudioTrait from '$lib/components/traits/StudioTrait.svelte'; import StudioTrait from '$lib/components/traits/StudioTrait.svelte';
import MixerTrait from '$lib/components/traits/MixerTrait.svelte';
import GenericTrait from '$lib/components/traits/GenericTrait.svelte'; import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte'; import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte';
import NodeUsage from '$lib/components/NodeUsage.svelte'; import NodeUsage from '$lib/components/NodeUsage.svelte';
@ -49,7 +50,7 @@
/** Traits with dedicated components */ /** Traits with dedicated components */
const knownTraits = new Set([ const knownTraits = new Set([
'editor', 'chat', 'kanban', 'podcast', 'publishing', 'editor', 'chat', 'kanban', 'podcast', 'publishing',
'rss', 'calendar', 'recording', 'transcription', 'studio' 'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer'
]); ]);
/** Traits that have a dedicated component */ /** Traits that have a dedicated component */
@ -169,6 +170,8 @@
<TranscriptionTrait collection={collectionNode} config={traits[trait]} /> <TranscriptionTrait collection={collectionNode} config={traits[trait]} />
{:else if trait === 'studio'} {:else if trait === 'studio'}
<StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} /> <StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
{:else if trait === 'mixer'}
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{/if} {/if}
{/each} {/each}

View file

@ -179,8 +179,7 @@ Ref: `docs/features/lydmixer.md`
- [x] 16.1 LiveKit-klient i frontend: installer `livekit-client`, koble til rom, vis deltakerliste. Deaktiver LiveKit sin auto-attach av `<audio>`-elementer — lyd rutes gjennom Web Audio API i stedet. - [x] 16.1 LiveKit-klient i frontend: installer `livekit-client`, koble til rom, vis deltakerliste. Deaktiver LiveKit sin auto-attach av `<audio>`-elementer — lyd rutes gjennom Web Audio API i stedet.
- [x] 16.2 Web Audio mixer-graf: opprett `AudioContext`, `MediaStreamSourceNode` per remote track → per-kanal `GainNode` → master `GainNode``destination`. `AnalyserNode` per kanal for VU-meter. - [x] 16.2 Web Audio mixer-graf: opprett `AudioContext`, `MediaStreamSourceNode` per remote track → per-kanal `GainNode` → master `GainNode``destination`. `AnalyserNode` per kanal for VU-meter.
- [~] 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.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).
> Påbegynt: 2026-03-18T04:54
- [ ] 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. - [ ] 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.
- [ ] 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.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). - [ ] 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).