Fullfører oppgave 16.1: LiveKit-klient i frontend

Installerer livekit-client og bygger grunnlaget for sanntidslyd:
- $lib/livekit.ts: LiveKit-wrapper med romtilkobling, deltakersporing,
  og Web Audio-ruting. Auto-attach av <audio> er deaktivert — all lyd
  rutes gjennom AudioContext med GainNode per deltaker (klar for mixer).
- api.ts: joinCommunication/leaveCommunication API-funksjoner
- RecordingTrait.svelte: UI for tilkobling, mikrofon-toggle og deltakerliste
  med live speaking-indikator

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 04:49:02 +00:00
parent 7555038106
commit a838e0e1c2
7 changed files with 647 additions and 5 deletions

View file

@ -17,6 +17,7 @@
"@tiptap/pm": "^3.20.4", "@tiptap/pm": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4", "@tiptap/starter-kit": "^3.20.4",
"d3": "^7.9.0", "d3": "^7.9.0",
"livekit-client": "^2.17.3",
"spacetimedb": "^2.0.4", "spacetimedb": "^2.0.4",
"wavesurfer.js": "^7.12.4" "wavesurfer.js": "^7.12.4"
}, },
@ -96,6 +97,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
@ -557,6 +564,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@livekit/mutex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.44.0.tgz",
"integrity": "sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@panva/hkdf": { "node_modules/@panva/hkdf": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
@ -2079,6 +2101,13 @@
"@types/d3-selection": "*" "@types/d3-selection": "*"
} }
}, },
"node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -2789,6 +2818,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -3202,12 +3240,45 @@
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/livekit-client": {
"version": "2.17.3",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.3.tgz",
"integrity": "sha512-htwsAL/BMylY/zwdcT/z00U789csbi9DldSW7DO+5tz7Q15pwu++E1X+ZdtZDfkmlysfQLLibdcqlyg9FY7veQ==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.44.0",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "^9.0.1"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
}
},
"node_modules/locate-character": { "node_modules/locate-character": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -3718,6 +3789,16 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/sade": { "node_modules/sade": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@ -3746,6 +3827,21 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sdp": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",
@ -3938,6 +4034,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -4069,6 +4180,19 @@
"integrity": "sha512-b/+XnWfJejNdvNUmtm4M5QzQepHhUbTo+62wYybwdV1B/Sn9vHhgb1xckRm0rGY2ZefJwLkE7lYcKnLfIia4cQ==", "integrity": "sha512-b/+XnWfJejNdvNUmtm4M5QzQepHhUbTo+62wYybwdV1B/Sn9vHhgb1xckRm0rGY2ZefJwLkE7lYcKnLfIia4cQ==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/webrtc-adapter": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz",
"integrity": "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/zimmerframe": { "node_modules/zimmerframe": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",

View file

@ -33,6 +33,7 @@
"@tiptap/pm": "^3.20.4", "@tiptap/pm": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4", "@tiptap/starter-kit": "^3.20.4",
"d3": "^7.9.0", "d3": "^7.9.0",
"livekit-client": "^2.17.3",
"spacetimedb": "^2.0.4", "spacetimedb": "^2.0.4",
"wavesurfer.js": "^7.12.4" "wavesurfer.js": "^7.12.4"
} }

View file

@ -1148,6 +1148,51 @@ export async function fetchMyUsage(
return res.json(); return res.json();
} }
// =============================================================================
// LiveKit / Kommunikasjonsrom (oppgave 16.1)
// =============================================================================
export interface JoinCommunicationRequest {
communication_id: string;
role?: 'publisher' | 'subscriber';
}
export interface RoomParticipantInfo {
user_id: string;
display_name: string;
role: string;
}
export interface JoinCommunicationResponse {
livekit_room_name: string;
livekit_token: string;
livekit_url: string;
identity: string;
participants: RoomParticipantInfo[];
}
export interface LeaveCommunicationResponse {
status: string;
}
/** Bli med i et LiveKit-rom for en kommunikasjonsnode. */
export function joinCommunication(
accessToken: string,
data: JoinCommunicationRequest
): Promise<JoinCommunicationResponse> {
return post(accessToken, '/intentions/join_communication', data);
}
/** Forlat et LiveKit-rom. */
export function leaveCommunication(
accessToken: string,
communicationId: string
): Promise<LeaveCommunicationResponse> {
return post(accessToken, '/intentions/leave_communication', {
communication_id: communicationId
});
}
/** Hent ressursforbruk for en spesifikk node (kun eier). */ /** Hent ressursforbruk for en spesifikk node (kun eier). */
export async function fetchNodeUsage( export async function fetchNodeUsage(
accessToken: string, accessToken: string,

View file

@ -1,17 +1,216 @@
<script lang="ts"> <script lang="ts">
import type { Node } from '$lib/spacetime'; import type { Node } from '$lib/spacetime';
import { edgeStore, nodeStore } from '$lib/spacetime';
import TraitPanel from './TraitPanel.svelte'; import TraitPanel from './TraitPanel.svelte';
import {
joinCommunication,
leaveCommunication,
type JoinCommunicationResponse,
} from '$lib/api';
import {
connect,
disconnect,
toggleMute,
getStatus,
getParticipants,
getLocalIdentity,
subscribe,
type LiveKitParticipant,
type RoomStatus,
} from '$lib/livekit';
interface Props { interface Props {
collection: Node; collection: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
accessToken?: string;
} }
let { collection, config }: Props = $props(); let { collection, config, accessToken }: Props = $props();
let status: RoomStatus = $state('disconnected');
let participants: LiveKitParticipant[] = $state([]);
let localIdentity: string = $state('');
let error: string | null = $state(null);
let joinInfo: JoinCommunicationResponse | null = $state(null);
// Subscribe to LiveKit state changes
$effect(() => {
const unsub = subscribe(() => {
status = getStatus();
participants = getParticipants();
localIdentity = getLocalIdentity();
});
return unsub;
});
// Clean up on component destroy
$effect(() => {
return () => {
if (status !== 'disconnected') {
disconnect();
}
};
});
/** Find communication nodes linked to this collection */
const communicationNodes = $derived.by(() => {
const nodes: Node[] = [];
for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId);
if (node && node.nodeKind === 'communication') {
nodes.push(node);
}
}
for (const edge of edgeStore.bySource(collection.id)) {
if (edge.edgeType !== 'has_channel') continue;
const node = nodeStore.get(edge.targetId);
if (node && node.nodeKind === 'communication') {
nodes.push(node);
}
}
return nodes;
});
const primaryComm = $derived(communicationNodes[0] ?? null);
async function handleJoin() {
if (!accessToken || !primaryComm) return;
error = null;
try {
const resp = await joinCommunication(accessToken, {
communication_id: primaryComm.id,
});
joinInfo = resp;
await connect(resp.livekit_url, resp.livekit_token);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function handleLeave() {
if (!accessToken || !primaryComm) return;
error = null;
try {
await disconnect();
await leaveCommunication(accessToken, primaryComm.id);
joinInfo = null;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function handleToggleMute() {
try {
await toggleMute();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
const isConnected = $derived(status === 'connected');
const isConnecting = $derived(status === 'connecting' || status === 'reconnecting');
const localParticipant = $derived(participants.find(p => p.identity === localIdentity));
const remoteParticipants = $derived(participants.filter(p => p.identity !== localIdentity));
</script> </script>
<TraitPanel name="recording" label="Opptak" icon="🎤"> <TraitPanel name="recording" label="Opptak" icon="🎤">
{#snippet children()} {#snippet children()}
<p class="text-sm text-gray-500">LiveKit-studio for opptak og sanntidslyd.</p> {#if !accessToken}
<p class="text-sm text-gray-400">Logg inn for å bruke opptak.</p>
{:else if !primaryComm}
<p class="text-sm text-gray-400">Ingen kommunikasjonskanal knyttet til denne samlingen.</p>
{:else}
<!-- Status & controls -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="inline-block h-2.5 w-2.5 rounded-full {isConnected ? 'bg-green-500' : isConnecting ? 'bg-yellow-400 animate-pulse' : 'bg-gray-300'}"></span>
<span class="text-sm text-gray-600">
{#if isConnected}
Tilkoblet
{:else if isConnecting}
Kobler til…
{:else}
Frakoblet
{/if}
</span>
</div>
<div class="flex gap-2">
{#if isConnected}
<button
onclick={handleToggleMute}
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors {localParticipant?.isMuted ? 'bg-red-100 text-red-700 hover:bg-red-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
{localParticipant?.isMuted ? 'Slå på mikrofon' : 'Demp'}
</button>
<button
onclick={handleLeave}
class="rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 transition-colors"
>
Forlat
</button>
{:else}
<button
onclick={handleJoin}
disabled={isConnecting}
class="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{isConnecting ? 'Kobler til…' : 'Koble til'}
</button>
{/if}
</div>
</div>
<!-- Error -->
{#if error}
<div class="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{error}
</div>
{/if}
<!-- Participant list -->
{#if isConnected && participants.length > 0}
<div class="mt-4">
<h4 class="mb-2 text-xs font-medium text-gray-500 uppercase tracking-wide">
Deltakere ({participants.length})
</h4>
<ul class="space-y-1.5">
{#each participants as p (p.identity)}
<li class="flex items-center gap-2 rounded-lg px-2.5 py-1.5 {p.isSpeaking ? 'bg-green-50' : 'bg-gray-50'}">
<!-- Speaking indicator -->
<span class="inline-block h-2 w-2 rounded-full flex-shrink-0 {p.isSpeaking ? 'bg-green-500' : p.isMuted ? 'bg-red-400' : 'bg-gray-300'}"></span>
<!-- Name -->
<span class="text-sm text-gray-700 truncate flex-1">
{p.displayName}
{#if p.identity === localIdentity}
<span class="text-xs text-gray-400">(deg)</span>
{/if}
</span>
<!-- Mute icon -->
{#if p.isMuted}
<span class="text-xs text-red-400 flex-shrink-0" title="Dempet">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
<!-- Room info (debug) -->
{#if isConnected && joinInfo}
<p class="mt-3 text-xs text-gray-400">
Rom: {joinInfo.livekit_room_name}
</p>
{/if}
{/if}
{/snippet} {/snippet}
</TraitPanel> </TraitPanel>

274
frontend/src/lib/livekit.ts Normal file
View file

@ -0,0 +1,274 @@
/**
* LiveKit client wrapper for Synops.
*
* Handles room connection, participant tracking, and Web Audio routing.
* LiveKit's auto-attach of <audio> elements is disabled all audio is
* routed through the Web Audio API so the mixer (Fase 16) can process it.
*/
import {
Room,
RoomEvent,
Track,
ConnectionState,
type RemoteTrack,
type RemoteTrackPublication,
type RemoteParticipant,
type Participant,
} from 'livekit-client';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface LiveKitParticipant {
identity: string;
displayName: string;
isSpeaking: boolean;
audioLevel: number;
isMuted: boolean;
}
export type RoomStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
// ─── State ──────────────────────────────────────────────────────────────────
let room: Room | null = null;
let audioContext: AudioContext | null = null;
/** Map from participant identity → their Web Audio source node */
const audioSources = new Map<string, MediaStreamAudioSourceNode>();
/** Map from participant identity → their GainNode (for future mixer control) */
const gainNodes = new Map<string, GainNode>();
// Reactive state via callbacks
type StateListener = () => void;
const listeners = new Set<StateListener>();
let _status: RoomStatus = 'disconnected';
let _participants: LiveKitParticipant[] = [];
let _localIdentity: string = '';
export function getStatus(): RoomStatus { return _status; }
export function getParticipants(): LiveKitParticipant[] { return _participants; }
export function getLocalIdentity(): string { return _localIdentity; }
export function subscribe(fn: StateListener): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
function notify() {
for (const fn of listeners) fn();
}
function setStatus(s: RoomStatus) {
_status = s;
notify();
}
// ─── Participant tracking ───────────────────────────────────────────────────
function buildParticipantList(): LiveKitParticipant[] {
if (!room) return [];
const list: LiveKitParticipant[] = [];
// Local participant
const local = room.localParticipant;
list.push({
identity: local.identity,
displayName: local.name || local.identity,
isSpeaking: local.isSpeaking,
audioLevel: local.audioLevel,
isMuted: !local.isMicrophoneEnabled,
});
// Remote participants
for (const [, p] of room.remoteParticipants) {
list.push({
identity: p.identity,
displayName: p.name || p.identity,
isSpeaking: p.isSpeaking,
audioLevel: p.audioLevel,
isMuted: isParticipantMuted(p),
});
}
return list;
}
function isParticipantMuted(p: RemoteParticipant): boolean {
for (const [, pub] of p.trackPublications) {
if (pub.kind === Track.Kind.Audio) {
return pub.isMuted;
}
}
return true; // no audio track = effectively muted
}
function refreshParticipants() {
_participants = buildParticipantList();
notify();
}
// ─── Web Audio routing ─────────────────────────────────────────────────────
function ensureAudioContext(): AudioContext {
if (!audioContext || audioContext.state === 'closed') {
audioContext = new AudioContext();
}
return audioContext;
}
/**
* Route a remote participant's audio track through Web Audio API
* instead of letting LiveKit auto-attach an <audio> element.
*/
function attachTrackToWebAudio(track: RemoteTrack, participant: RemoteParticipant) {
if (track.kind !== Track.Kind.Audio) return;
const mediaStream = track.mediaStream;
if (!mediaStream) return;
const ctx = ensureAudioContext();
// Clean up previous source for this participant
detachParticipantAudio(participant.identity);
const source = ctx.createMediaStreamSource(mediaStream);
const gain = ctx.createGain();
gain.gain.value = 1.0;
source.connect(gain);
gain.connect(ctx.destination);
audioSources.set(participant.identity, source);
gainNodes.set(participant.identity, gain);
}
function detachParticipantAudio(identity: string) {
const source = audioSources.get(identity);
if (source) {
source.disconnect();
audioSources.delete(identity);
}
const gain = gainNodes.get(identity);
if (gain) {
gain.disconnect();
gainNodes.delete(identity);
}
}
// ─── Room connection ────────────────────────────────────────────────────────
export async function connect(wsUrl: string, token: string): Promise<void> {
if (room) {
await disconnect();
}
const newRoom = new Room({
// Disable auto-attach — we route audio through Web Audio API
audioCaptureDefaults: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
adaptiveStream: true,
dynacast: true,
});
// Set up event handlers before connecting
newRoom
.on(RoomEvent.Connected, () => {
_localIdentity = newRoom.localParticipant.identity;
setStatus('connected');
refreshParticipants();
})
.on(RoomEvent.Reconnecting, () => {
setStatus('reconnecting');
})
.on(RoomEvent.Reconnected, () => {
setStatus('connected');
refreshParticipants();
})
.on(RoomEvent.Disconnected, () => {
cleanupAudio();
setStatus('disconnected');
_participants = [];
notify();
})
.on(RoomEvent.ParticipantConnected, () => {
refreshParticipants();
})
.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {
detachParticipantAudio(participant.identity);
refreshParticipants();
})
.on(RoomEvent.TrackSubscribed, (track: RemoteTrack, pub: RemoteTrackPublication, participant: RemoteParticipant) => {
attachTrackToWebAudio(track, participant);
refreshParticipants();
})
.on(RoomEvent.TrackUnsubscribed, (_track: RemoteTrack, _pub: RemoteTrackPublication, participant: RemoteParticipant) => {
detachParticipantAudio(participant.identity);
refreshParticipants();
})
.on(RoomEvent.TrackMuted, () => {
refreshParticipants();
})
.on(RoomEvent.TrackUnmuted, () => {
refreshParticipants();
})
.on(RoomEvent.ActiveSpeakersChanged, () => {
refreshParticipants();
});
room = newRoom;
setStatus('connecting');
await newRoom.connect(wsUrl, token, {
autoSubscribe: true,
});
// Enable microphone after connecting
await newRoom.localParticipant.setMicrophoneEnabled(true);
refreshParticipants();
}
export async function disconnect(): Promise<void> {
if (!room) return;
cleanupAudio();
await room.disconnect();
room = null;
_status = 'disconnected';
_participants = [];
_localIdentity = '';
notify();
}
function cleanupAudio() {
for (const [identity] of audioSources) {
detachParticipantAudio(identity);
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close();
audioContext = null;
}
}
/** Toggle local microphone mute */
export async function toggleMute(): Promise<boolean> {
if (!room) return false;
const enabled = room.localParticipant.isMicrophoneEnabled;
await room.localParticipant.setMicrophoneEnabled(!enabled);
refreshParticipants();
return !enabled;
}
/** Get the GainNode for a participant (for future mixer integration) */
export function getParticipantGain(identity: string): GainNode | undefined {
return gainNodes.get(identity);
}
export function isConnected(): boolean {
return room?.state === ConnectionState.Connected;
}

View file

@ -164,7 +164,7 @@
{:else if trait === 'calendar'} {:else if trait === 'calendar'}
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} /> <CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
{:else if trait === 'recording'} {:else if trait === 'recording'}
<RecordingTrait collection={collectionNode} config={traits[trait]} /> <RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{:else if trait === 'transcription'} {:else if trait === 'transcription'}
<TranscriptionTrait collection={collectionNode} config={traits[trait]} /> <TranscriptionTrait collection={collectionNode} config={traits[trait]} />
{:else if trait === 'studio'} {:else if trait === 'studio'}

View file

@ -177,8 +177,7 @@ Uavhengige faser kan fortsatt plukkes.
Ref: `docs/features/lydmixer.md` Ref: `docs/features/lydmixer.md`
- [~] 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.
> Påbegynt: 2026-03-18T04:44
- [ ] 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.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). - [ ] 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).
- [ ] 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.