diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6687007..c3c2093 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 4d564c3..b28926f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/lib/components/AudioPlayer.svelte b/frontend/src/lib/components/AudioPlayer.svelte new file mode 100644 index 0000000..10ea107 --- /dev/null +++ b/frontend/src/lib/components/AudioPlayer.svelte @@ -0,0 +1,146 @@ + + +
+
+ + + + +
+
+
+
+ + +
+ {formatTime(currentTime)} + {formatTime(totalDuration)} +
+ + {#if loadError} +

Kunne ikke laste lydfilen

+ {/if} + + + {#if transcript} + + {#if showTranscript} +

{transcript}

+ {/if} + {/if} +
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index c8c7e1f..16af23d 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -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 | 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 @@ {:else} + {@const audio = audioMeta(node)}
  • {node.title || 'Uten tittel'}

    - {#if vis === 'full' && node.content} + {#if audio} +
    + +
    + {:else if vis === 'full' && node.content}

    {excerpt(node.content)}

    diff --git a/frontend/src/routes/chat/[id]/+page.svelte b/frontend/src/routes/chat/[id]/+page.svelte index d2ce801..964e286 100644 --- a/frontend/src/routes/chat/[id]/+page.svelte +++ b/frontend/src/routes/chat/[id]/+page.svelte @@ -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 | 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(); 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; } + }
    @@ -179,17 +217,34 @@
    {#each messages as msg (msg.id)} {@const own = isOwnMessage(msg)} + {@const audio = isAudioNode(msg)}
    -
    +
    {#if !own} -

    +

    {senderName(msg)}

    {/if} -

    - {msg.content || ''} -

    -

    + {#if audio} +

    + + + + + Talenotat +
    + + {:else} +

    + {msg.content || ''} +

    + {/if} +

    {formatTime(msg)}

    diff --git a/tasks.md b/tasks.md index 4f17012..c5144cb 100644 --- a/tasks.md +++ b/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