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