Fullfør oppgave 7.4: Lyd-avspilling med waveform-visning

Legger til AudioPlayer-komponent som spiller av lyd fra CAS-noder
med waveform-visualisering via wavesurfer.js. Komponenten viser
play/pause, tidslinje, og kan ekspandere transkripsjonen.

Chat-visningen inkluderer nå media-noder (has_media-edges) sammen
med tekstmeldinger, sortert kronologisk. Talenotater vises med
mikrofon-ikon, waveform og transkripsjon.

Mottak-siden viser også AudioPlayer for media-noder med lyd.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 18:07:29 +01:00
parent 8059d0f84f
commit 63d4bbd41b
6 changed files with 257 additions and 20 deletions

View file

@ -16,7 +16,8 @@
"@tiptap/extension-placeholder": "^3.20.4", "@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/pm": "^3.20.4", "@tiptap/pm": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4", "@tiptap/starter-kit": "^3.20.4",
"spacetimedb": "^2.0.4" "spacetimedb": "^2.0.4",
"wavesurfer.js": "^7.12.4"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
@ -3388,6 +3389,12 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/wavesurfer.js": {
"version": "7.12.4",
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.4.tgz",
"integrity": "sha512-b/+XnWfJejNdvNUmtm4M5QzQepHhUbTo+62wYybwdV1B/Sn9vHhgb1xckRm0rGY2ZefJwLkE7lYcKnLfIia4cQ==",
"license": "BSD-3-Clause"
},
"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

@ -31,6 +31,7 @@
"@tiptap/extension-placeholder": "^3.20.4", "@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/pm": "^3.20.4", "@tiptap/pm": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4", "@tiptap/starter-kit": "^3.20.4",
"spacetimedb": "^2.0.4" "spacetimedb": "^2.0.4",
"wavesurfer.js": "^7.12.4"
} }
} }

View file

@ -0,0 +1,146 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import WaveSurfer from 'wavesurfer.js';
interface Props {
/** CAS URL for the audio file */
src: string;
/** Duration in seconds (from transcription metadata, used as fallback) */
duration?: number;
/** Transcript text to show below the player */
transcript?: string;
/** Compact mode for chat bubbles */
compact?: boolean;
}
let { src, duration, transcript, compact = false }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let wavesurfer: WaveSurfer | undefined = $state();
let playing = $state(false);
let currentTime = $state(0);
let totalDuration = $state(0);
$effect(() => { if (duration && !ready) totalDuration = duration; });
let ready = $state(false);
let loadError = $state(false);
let showTranscript = $state(false);
function formatTime(seconds: number): string {
if (!seconds || !isFinite(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
onMount(() => {
if (!container) return;
wavesurfer = WaveSurfer.create({
container,
height: compact ? 32 : 48,
waveColor: '#93c5fd',
progressColor: '#2563eb',
cursorColor: '#1d4ed8',
cursorWidth: 2,
barWidth: 2,
barGap: 1,
barRadius: 2,
normalize: true,
url: src,
});
wavesurfer.on('ready', () => {
ready = true;
totalDuration = wavesurfer!.getDuration();
});
wavesurfer.on('timeupdate', (time: number) => {
currentTime = time;
});
wavesurfer.on('finish', () => {
playing = false;
});
wavesurfer.on('error', () => {
loadError = true;
});
wavesurfer.on('play', () => {
playing = true;
});
wavesurfer.on('pause', () => {
playing = false;
});
});
onDestroy(() => {
wavesurfer?.destroy();
});
function togglePlayback() {
wavesurfer?.playPause();
}
</script>
<div class="audio-player flex flex-col gap-1 {compact ? 'min-w-[200px]' : 'min-w-[240px]'}">
<div class="flex items-center gap-2">
<!-- Play/pause button -->
<button
onclick={togglePlayback}
disabled={!ready && !loadError}
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors
{ready ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-gray-200 text-gray-400'}
disabled:cursor-not-allowed"
aria-label={playing ? 'Pause' : 'Spill av'}
>
{#if !ready && !loadError}
<svg class="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{:else if playing}
<svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
{:else}
<svg class="h-3.5 w-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
<!-- Waveform -->
<div class="flex-1 min-w-0">
<div bind:this={container} class="w-full"></div>
</div>
</div>
<!-- Time display -->
<div class="flex items-center justify-between px-1">
<span class="text-[10px] text-gray-400">{formatTime(currentTime)}</span>
<span class="text-[10px] text-gray-400">{formatTime(totalDuration)}</span>
</div>
{#if loadError}
<p class="text-[10px] text-red-400 px-1">Kunne ikke laste lydfilen</p>
{/if}
<!-- Transcript toggle -->
{#if transcript}
<button
onclick={() => { showTranscript = !showTranscript; }}
class="flex items-center gap-1 px-1 text-[10px] text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="h-3 w-3 transition-transform {showTranscript ? 'rotate-90' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
Transkripsjon
</button>
{#if showTranscript}
<p class="text-xs text-gray-500 px-1 py-1 bg-gray-50 rounded whitespace-pre-wrap">{transcript}</p>
{/if}
{/if}
</div>

View file

@ -5,7 +5,8 @@
import type { Node } from '$lib/spacetime'; import type { Node } from '$lib/spacetime';
import NodeEditor from '$lib/components/NodeEditor.svelte'; import NodeEditor from '$lib/components/NodeEditor.svelte';
import NewChatDialog from '$lib/components/NewChatDialog.svelte'; import NewChatDialog from '$lib/components/NewChatDialog.svelte';
import { createNode, createEdge, createCommunication } from '$lib/api'; import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import { createNode, createEdge, createCommunication, casUrl } from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined); const session = $derived($page.data.session as Record<string, unknown> | undefined);
const nodeId = $derived(session?.nodeId as string | undefined); const nodeId = $derived(session?.nodeId as string | undefined);
@ -103,6 +104,21 @@
return edges.filter((e) => !e.system).map((e) => e.edgeType); return edges.filter((e) => !e.system).map((e) => e.edgeType);
} }
/** Check if a node is an audio media node and extract its metadata */
function audioMeta(node: Node): { src: string; duration?: number } | null {
if (node.nodeKind !== 'media') return null;
try {
const meta = JSON.parse(node.metadata ?? '{}');
if (typeof meta.mime === 'string' && meta.mime.startsWith('audio/') && meta.cas_hash) {
return {
src: casUrl(meta.cas_hash),
duration: meta.transcription?.duration,
};
}
} catch { /* ignore */ }
return null;
}
let showNewChatDialog = $state(false); let showNewChatDialog = $state(false);
/** Open the new chat dialog to pick a participant */ /** Open the new chat dialog to pick a participant */
@ -280,13 +296,22 @@
</a> </a>
</li> </li>
{:else} {:else}
{@const audio = audioMeta(node)}
<li class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm"> <li class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<h3 class="font-medium text-gray-900"> <h3 class="font-medium text-gray-900">
{node.title || 'Uten tittel'} {node.title || 'Uten tittel'}
</h3> </h3>
{#if vis === 'full' && node.content} {#if audio}
<div class="mt-2">
<AudioPlayer
src={audio.src}
duration={audio.duration}
transcript={vis === 'full' ? (node.content || undefined) : undefined}
/>
</div>
{:else if vis === 'full' && node.content}
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">
{excerpt(node.content)} {excerpt(node.content)}
</p> </p>

View file

@ -2,8 +2,9 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { connectionState, nodeStore, edgeStore, nodeVisibility } from '$lib/spacetime'; import { connectionState, nodeStore, edgeStore, nodeVisibility } from '$lib/spacetime';
import type { Node } from '$lib/spacetime'; import type { Node } from '$lib/spacetime';
import { createNode } from '$lib/api'; import { createNode, casUrl } from '$lib/api';
import ChatInput from '$lib/components/ChatInput.svelte'; import ChatInput from '$lib/components/ChatInput.svelte';
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import { tick } from 'svelte'; import { tick } from 'svelte';
const session = $derived($page.data.session as Record<string, unknown> | undefined); const session = $derived($page.data.session as Record<string, unknown> | undefined);
@ -25,15 +26,27 @@
const messages = $derived.by(() => { const messages = $derived.by(() => {
if (!connected || !communicationId) return []; if (!connected || !communicationId) return [];
// Find all belongs_to edges pointing to this communication node const seen = new Set<string>();
const belongsToEdges = edgeStore.byTarget(communicationId)
.filter(e => e.edgeType === 'belongs_to');
// Resolve source nodes (the messages)
const nodes: Node[] = []; const nodes: Node[] = [];
for (const edge of belongsToEdges) {
// Text messages: belongs_to edges pointing to this communication node
// Edge direction: message (source) --belongs_to--> communication (target)
for (const edge of edgeStore.byTarget(communicationId)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId); const node = nodeStore.get(edge.sourceId);
if (node && nodeVisibility(node, nodeId) !== 'hidden') { if (node && nodeVisibility(node, nodeId) !== 'hidden' && !seen.has(node.id)) {
seen.add(node.id);
nodes.push(node);
}
}
// Media nodes: has_media edges from this communication node
// Edge direction: communication (source) --has_media--> media (target)
for (const edge of edgeStore.bySource(communicationId)) {
if (edge.edgeType !== 'has_media') continue;
const node = nodeStore.get(edge.targetId);
if (node && nodeVisibility(node, nodeId) !== 'hidden' && !seen.has(node.id)) {
seen.add(node.id);
nodes.push(node); nodes.push(node);
} }
} }
@ -124,6 +137,31 @@
function isOwnMessage(node: Node): boolean { function isOwnMessage(node: Node): boolean {
return !!nodeId && node.createdBy === nodeId; return !!nodeId && node.createdBy === nodeId;
} }
/** Check if this node is an audio media node */
function isAudioNode(node: Node): boolean {
if (node.nodeKind !== 'media') return false;
try {
const meta = JSON.parse(node.metadata ?? '{}');
return typeof meta.mime === 'string' && meta.mime.startsWith('audio/');
} catch { return false; }
}
/** Get CAS audio URL for a media node */
function audioSrc(node: Node): string {
try {
const meta = JSON.parse(node.metadata ?? '{}');
return casUrl(meta.cas_hash);
} catch { return ''; }
}
/** Get transcription duration from metadata */
function audioDuration(node: Node): number | undefined {
try {
const meta = JSON.parse(node.metadata ?? '{}');
return meta.transcription?.duration;
} catch { return undefined; }
}
</script> </script>
<div class="flex h-screen flex-col bg-gray-50"> <div class="flex h-screen flex-col bg-gray-50">
@ -179,17 +217,34 @@
<div class="space-y-3"> <div class="space-y-3">
{#each messages as msg (msg.id)} {#each messages as msg (msg.id)}
{@const own = isOwnMessage(msg)} {@const own = isOwnMessage(msg)}
{@const audio = isAudioNode(msg)}
<div class="flex {own ? 'justify-end' : 'justify-start'}"> <div class="flex {own ? 'justify-end' : 'justify-start'}">
<div class="max-w-[75%] {own ? 'bg-blue-600 text-white' : 'bg-white border border-gray-200 text-gray-900'} rounded-2xl px-4 py-2 shadow-sm"> <div class="max-w-[75%] {own ? (audio ? 'bg-blue-50 border border-blue-200 text-gray-900' : 'bg-blue-600 text-white') : 'bg-white border border-gray-200 text-gray-900'} rounded-2xl px-4 py-2 shadow-sm">
{#if !own} {#if !own}
<p class="mb-0.5 text-xs font-medium {own ? 'text-blue-200' : 'text-blue-600'}"> <p class="mb-0.5 text-xs font-medium text-blue-600">
{senderName(msg)} {senderName(msg)}
</p> </p>
{/if} {/if}
<p class="text-sm whitespace-pre-wrap break-words"> {#if audio}
{msg.content || ''} <div class="flex items-center gap-1.5 mb-1">
</p> <svg class="h-3.5 w-3.5 text-blue-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<p class="mt-1 text-right text-[10px] {own ? 'text-blue-200' : 'text-gray-400'}"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19 10v2a7 7 0 01-14 0v-2" />
</svg>
<span class="text-[11px] font-medium text-blue-600">Talenotat</span>
</div>
<AudioPlayer
src={audioSrc(msg)}
duration={audioDuration(msg)}
transcript={msg.content || undefined}
compact
/>
{:else}
<p class="text-sm whitespace-pre-wrap break-words">
{msg.content || ''}
</p>
{/if}
<p class="mt-1 text-right text-[10px] {own && !audio ? 'text-blue-200' : 'text-gray-400'}">
{formatTime(msg)} {formatTime(msg)}
</p> </p>
</div> </div>

View file

@ -96,8 +96,11 @@ Uavhengige faser kan fortsatt plukkes.
- [x] 7.1 faster-whisper oppsett: Docker-container, GPU hvis tilgjengelig, norsk modell. Ref: `docs/erfaringer/`. - [x] 7.1 faster-whisper oppsett: Docker-container, GPU hvis tilgjengelig, norsk modell. Ref: `docs/erfaringer/`.
- [x] 7.2 Transkripsjons-pipeline: lydfil i CAS → maskinrommet trigger Whisper → resultat i `content`-feltet. - [x] 7.2 Transkripsjons-pipeline: lydfil i CAS → maskinrommet trigger Whisper → resultat i `content`-feltet.
- [x] 7.3 Voice memo i frontend: opptak-knapp i input-komponenten → upload → CAS → transkripsjon. - [x] 7.3 Voice memo i frontend: opptak-knapp i input-komponenten → upload → CAS → transkripsjon.
- [~] 7.4 Lyd-avspilling: spiller av original lyd fra CAS-node. Waveform-visning. - [x] 7.4 Lyd-avspilling: spiller av original lyd fra CAS-node. Waveform-visning.
> Påbegynt: 2026-03-17T17:58 - [ ] 7.5 Segmenttabell-migrasjon: opprett `transcription_segments`-tabell i PG. Oppdater `transcribe.rs` til SRT-format → parse → skriv segmenter. Miljøvariabler: `WHISPER_MODEL` (default "medium"), `WHISPER_INITIAL_PROMPT`. Ref: `docs/concepts/podcastfabrikken.md` § 3.
- [ ] 7.6 Transkripsjonsvisning i frontend: segmenter med tidsstempler, avspillingsknapp per segment (hopper til riktig sted i lydfilen), redigerbare tekstfelt (setter `edited = true`). Universell komponent for podcast, møter, voice memos.
- [ ] 7.7 Re-transkripsjonsflyt: ved ny transkripsjon, vis side-om-side med forrige versjon. Highlight manuelt redigerte segmenter fra forrige versjon. Bruker velger per segment.
- [ ] 7.8 SRT-eksport: generer nedlastbar SRT-fil fra `transcription_segments`-tabellen.
## Fase 8: Aliaser ## Fase 8: Aliaser