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:
parent
82c45c4208
commit
f1c9b281bc
3 changed files with 296 additions and 3 deletions
291
frontend/src/lib/components/traits/MixerTrait.svelte
Normal file
291
frontend/src/lib/components/traits/MixerTrait.svelte
Normal 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>
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -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 (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).
|
||||||
> 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).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue