Fullfør oppgave 7.3: Voice memo — opptak-knapp i frontend

Legger til VoiceRecorder-komponent som bruker MediaRecorder API for
lydopptak i nettleseren. Opptaket lastes opp til CAS via eksisterende
uploadMedia-endepunkt, som automatisk trigger Whisper-transkripsjon.

Komponenten er integrert i:
- ChatInput: mikrofon-knapp mellom tekstfelt og send-knapp
- NodeEditor: mikrofon-knapp i verktøylinjen

Flyten: opptak → webm/opus blob → upload → CAS → whisper_transcribe-jobb.
Ingen backend-endringer nødvendig — hele transkripsjons-pipelinen fra
oppgave 7.2 gjenbrukes uendret.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 17:51:40 +01:00
parent 79c681dbf6
commit ebbec982b3
5 changed files with 224 additions and 9 deletions

View file

@ -1,10 +1,15 @@
<script lang="ts">
import VoiceRecorder from './VoiceRecorder.svelte';
interface Props {
onsubmit: (content: string) => Promise<void>;
disabled?: boolean;
accessToken?: string;
/** Context node for voice memo attachment (e.g. communication node) */
contextId?: string;
}
let { onsubmit, disabled = false }: Props = $props();
let { onsubmit, disabled = false, accessToken, contextId }: Props = $props();
let content = $state('');
let submitting = $state(false);
@ -62,6 +67,12 @@
<p class="absolute -top-6 left-0 text-xs text-red-600">{error}</p>
{/if}
</div>
<VoiceRecorder
{accessToken}
sourceId={contextId}
disabled={disabled || submitting}
onerror={(msg) => { error = msg; }}
/>
<button
onclick={handleSubmit}
disabled={isEmpty || submitting || disabled}

View file

@ -6,6 +6,7 @@
import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import { uploadMedia, casUrl } from '$lib/api';
import VoiceRecorder from './VoiceRecorder.svelte';
interface Props {
onsubmit: (data: { title: string; content: string; html: string }) => Promise<void>;
@ -21,6 +22,7 @@
let submitting = $state(false);
let uploading = $state(0);
let error = $state('');
let voiceMemoInfo = $state('');
async function handleImageUpload(file: File): Promise<void> {
if (!accessToken) {
@ -189,12 +191,24 @@
<p class="px-3 py-1 text-xs text-red-600">{error}</p>
{/if}
{#if voiceMemoInfo}
<p class="px-3 py-1 text-xs text-green-600">{voiceMemoInfo}</p>
{/if}
<div class="flex items-center justify-between border-t border-gray-100 px-3 py-2">
<span class="text-xs text-gray-400">
{#if editor}
Markdown · Ctrl+B/I · Dra/lim bilder · Ctrl+Enter for å sende
{/if}
</span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">
{#if editor}
Markdown · Ctrl+B/I · Dra/lim bilder · Ctrl+Enter for å sende
{/if}
</span>
<VoiceRecorder
{accessToken}
disabled={disabled || submitting}
onerror={(msg) => { error = msg; }}
onrecorded={() => { voiceMemoInfo = 'Talenotat lastet opp — transkriberes i bakgrunnen'; setTimeout(() => { voiceMemoInfo = ''; }, 5000); }}
/>
</div>
<button
onclick={handleSubmit}
disabled={isEmpty || submitting || disabled || uploading > 0}

View file

@ -0,0 +1,191 @@
<script lang="ts">
import { uploadMedia } from '$lib/api';
interface Props {
accessToken?: string;
/** Called when upload+transcription is triggered. Returns media_node_id. */
onrecorded?: (result: { media_node_id: string; cas_hash: string }) => void;
/** Called on error */
onerror?: (message: string) => void;
/** Optional context node (e.g. communication node) to attach media to */
sourceId?: string;
disabled?: boolean;
}
let { accessToken, onrecorded, onerror, sourceId, disabled = false }: Props = $props();
type RecordingState = 'idle' | 'recording' | 'uploading';
let recState: RecordingState = $state('idle');
let mediaRecorder: MediaRecorder | undefined = $state();
let stream: MediaStream | undefined = $state();
let chunks: Blob[] = [];
let duration = $state(0);
let durationInterval: ReturnType<typeof setInterval> | undefined;
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
async function startRecording() {
if (!accessToken) {
onerror?.('Ikke innlogget — kan ikke ta opp lyd');
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
onerror?.('Ingen tilgang til mikrofon. Sjekk nettleserinnstillinger.');
return;
}
chunks = [];
duration = 0;
// Prefer webm/opus, fall back to whatever the browser supports
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: '';
mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
mediaRecorder.onstop = async () => {
stopTimer();
stopStream();
if (chunks.length === 0) {
recState = 'idle';
return;
}
const blob = new Blob(chunks, { type: mediaRecorder?.mimeType || 'audio/webm' });
chunks = [];
// Upload
recState = 'uploading';
try {
const ext = blob.type.includes('webm') ? 'webm' : 'ogg';
const file = new File([blob], `voice-memo.${ext}`, { type: blob.type });
const result = await uploadMedia(accessToken!, {
file,
title: 'Talenotat',
visibility: 'hidden',
source_id: sourceId,
});
onrecorded?.({
media_node_id: result.media_node_id,
cas_hash: result.cas_hash,
});
} catch (e) {
onerror?.(e instanceof Error ? e.message : 'Feil ved opplasting av lydopptak');
} finally {
recState = 'idle';
}
};
mediaRecorder.start(1000); // Collect data every second
recState = 'recording';
startTimer();
}
function stopRecording() {
if (mediaRecorder?.state === 'recording') {
mediaRecorder.stop();
}
}
function cancelRecording() {
chunks = []; // Clear so onstop won't upload
if (mediaRecorder?.state === 'recording') {
mediaRecorder.stop();
}
stopTimer();
stopStream();
recState = 'idle';
}
function startTimer() {
durationInterval = setInterval(() => { duration++; }, 1000);
}
function stopTimer() {
if (durationInterval) {
clearInterval(durationInterval);
durationInterval = undefined;
}
}
function stopStream() {
stream?.getTracks().forEach(t => t.stop());
stream = undefined;
}
</script>
{#if recState === 'idle'}
<button
onclick={startRecording}
disabled={disabled || !accessToken}
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Ta opp talenotat"
title="Ta opp talenotat"
>
<!-- Microphone icon -->
<svg class="h-5 w-5" 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" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
</button>
{:else if recState === 'recording'}
<div class="flex items-center gap-2">
<!-- Cancel button -->
<button
onclick={cancelRecording}
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Avbryt opptak"
title="Avbryt opptak"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Recording indicator -->
<div class="flex items-center gap-1.5">
<span class="h-2 w-2 animate-pulse rounded-full bg-red-500"></span>
<span class="text-xs font-medium text-red-600">{formatDuration(duration)}</span>
</div>
<!-- Stop/send button -->
<button
onclick={stopRecording}
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-600 text-white transition-colors hover:bg-red-700"
aria-label="Stopp og send opptak"
title="Stopp og send opptak"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
</div>
{:else if recState === 'uploading'}
<div class="flex items-center gap-1.5">
<svg class="h-4 w-4 animate-spin text-blue-600" 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>
<span class="text-xs text-blue-600">Laster opp…</span>
</div>
{/if}

View file

@ -204,7 +204,7 @@
{#if connected && accessToken && communicationNode}
<div class="shrink-0 border-t border-gray-200 bg-white">
<div class="mx-auto max-w-3xl px-4 py-3">
<ChatInput onsubmit={handleSendMessage} disabled={!connected} />
<ChatInput onsubmit={handleSendMessage} disabled={!connected} {accessToken} contextId={communicationId} />
</div>
</div>
{/if}

View file

@ -95,8 +95,7 @@ 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.
- [~] 7.3 Voice memo i frontend: opptak-knapp i input-komponenten → upload → CAS → transkripsjon.
> Påbegynt: 2026-03-17T17:47
- [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.
## Fase 8: Aliaser