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:
parent
8059d0f84f
commit
63d4bbd41b
6 changed files with 257 additions and 20 deletions
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
|
|
@ -16,7 +16,8 @@
|
|||
"@tiptap/extension-placeholder": "^3.20.4",
|
||||
"@tiptap/pm": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.20.4",
|
||||
"spacetimedb": "^2.0.4"
|
||||
"spacetimedb": "^2.0.4",
|
||||
"wavesurfer.js": "^7.12.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
|
|
@ -3388,6 +3389,12 @@
|
|||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
"@tiptap/extension-placeholder": "^3.20.4",
|
||||
"@tiptap/pm": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.20.4",
|
||||
"spacetimedb": "^2.0.4"
|
||||
"spacetimedb": "^2.0.4",
|
||||
"wavesurfer.js": "^7.12.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
146
frontend/src/lib/components/AudioPlayer.svelte
Normal file
146
frontend/src/lib/components/AudioPlayer.svelte
Normal 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>
|
||||
|
|
@ -5,7 +5,8 @@
|
|||
import type { Node } from '$lib/spacetime';
|
||||
import NodeEditor from '$lib/components/NodeEditor.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 nodeId = $derived(session?.nodeId as string | undefined);
|
||||
|
|
@ -103,6 +104,21 @@
|
|||
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);
|
||||
|
||||
/** Open the new chat dialog to pick a participant */
|
||||
|
|
@ -280,13 +296,22 @@
|
|||
</a>
|
||||
</li>
|
||||
{:else}
|
||||
{@const audio = audioMeta(node)}
|
||||
<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="min-w-0 flex-1">
|
||||
<h3 class="font-medium text-gray-900">
|
||||
{node.title || 'Uten tittel'}
|
||||
</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">
|
||||
{excerpt(node.content)}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
import { page } from '$app/stores';
|
||||
import { connectionState, nodeStore, edgeStore, nodeVisibility } 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 AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||
|
|
@ -25,15 +26,27 @@
|
|||
const messages = $derived.by(() => {
|
||||
if (!connected || !communicationId) return [];
|
||||
|
||||
// Find all belongs_to edges pointing to this communication node
|
||||
const belongsToEdges = edgeStore.byTarget(communicationId)
|
||||
.filter(e => e.edgeType === 'belongs_to');
|
||||
|
||||
// Resolve source nodes (the messages)
|
||||
const seen = new Set<string>();
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -124,6 +137,31 @@
|
|||
function isOwnMessage(node: Node): boolean {
|
||||
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>
|
||||
|
||||
<div class="flex h-screen flex-col bg-gray-50">
|
||||
|
|
@ -179,17 +217,34 @@
|
|||
<div class="space-y-3">
|
||||
{#each messages as msg (msg.id)}
|
||||
{@const own = isOwnMessage(msg)}
|
||||
{@const audio = isAudioNode(msg)}
|
||||
<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}
|
||||
<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)}
|
||||
</p>
|
||||
{/if}
|
||||
{#if audio}
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
<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">
|
||||
<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>
|
||||
<p class="mt-1 text-right text-[10px] {own ? 'text-blue-200' : 'text-gray-400'}">
|
||||
{/if}
|
||||
<p class="mt-1 text-right text-[10px] {own && !audio ? 'text-blue-200' : 'text-gray-400'}">
|
||||
{formatTime(msg)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
7
tasks.md
7
tasks.md
|
|
@ -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.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.
|
||||
- [~] 7.4 Lyd-avspilling: spiller av original lyd fra CAS-node. Waveform-visning.
|
||||
> Påbegynt: 2026-03-17T17:58
|
||||
- [x] 7.4 Lyd-avspilling: spiller av original lyd fra CAS-node. Waveform-visning.
|
||||
- [ ] 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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue