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:
parent
79c681dbf6
commit
ebbec982b3
5 changed files with 224 additions and 9 deletions
|
|
@ -1,10 +1,15 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import VoiceRecorder from './VoiceRecorder.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onsubmit: (content: string) => Promise<void>;
|
onsubmit: (content: string) => Promise<void>;
|
||||||
disabled?: boolean;
|
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 content = $state('');
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
|
|
@ -62,6 +67,12 @@
|
||||||
<p class="absolute -top-6 left-0 text-xs text-red-600">{error}</p>
|
<p class="absolute -top-6 left-0 text-xs text-red-600">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<VoiceRecorder
|
||||||
|
{accessToken}
|
||||||
|
sourceId={contextId}
|
||||||
|
disabled={disabled || submitting}
|
||||||
|
onerror={(msg) => { error = msg; }}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
disabled={isEmpty || submitting || disabled}
|
disabled={isEmpty || submitting || disabled}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import Image from '@tiptap/extension-image';
|
import Image from '@tiptap/extension-image';
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
import { uploadMedia, casUrl } from '$lib/api';
|
import { uploadMedia, casUrl } from '$lib/api';
|
||||||
|
import VoiceRecorder from './VoiceRecorder.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onsubmit: (data: { title: string; content: string; html: string }) => Promise<void>;
|
onsubmit: (data: { title: string; content: string; html: string }) => Promise<void>;
|
||||||
|
|
@ -21,6 +22,7 @@
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let uploading = $state(0);
|
let uploading = $state(0);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let voiceMemoInfo = $state('');
|
||||||
|
|
||||||
async function handleImageUpload(file: File): Promise<void> {
|
async function handleImageUpload(file: File): Promise<void> {
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
|
|
@ -189,12 +191,24 @@
|
||||||
<p class="px-3 py-1 text-xs text-red-600">{error}</p>
|
<p class="px-3 py-1 text-xs text-red-600">{error}</p>
|
||||||
{/if}
|
{/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">
|
<div class="flex items-center justify-between border-t border-gray-100 px-3 py-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs text-gray-400">
|
<span class="text-xs text-gray-400">
|
||||||
{#if editor}
|
{#if editor}
|
||||||
Markdown · Ctrl+B/I · Dra/lim bilder · Ctrl+Enter for å sende
|
Markdown · Ctrl+B/I · Dra/lim bilder · Ctrl+Enter for å sende
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
<VoiceRecorder
|
||||||
|
{accessToken}
|
||||||
|
disabled={disabled || submitting}
|
||||||
|
onerror={(msg) => { error = msg; }}
|
||||||
|
onrecorded={() => { voiceMemoInfo = 'Talenotat lastet opp — transkriberes i bakgrunnen'; setTimeout(() => { voiceMemoInfo = ''; }, 5000); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
disabled={isEmpty || submitting || disabled || uploading > 0}
|
disabled={isEmpty || submitting || disabled || uploading > 0}
|
||||||
|
|
|
||||||
191
frontend/src/lib/components/VoiceRecorder.svelte
Normal file
191
frontend/src/lib/components/VoiceRecorder.svelte
Normal 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}
|
||||||
|
|
@ -204,7 +204,7 @@
|
||||||
{#if connected && accessToken && communicationNode}
|
{#if connected && accessToken && communicationNode}
|
||||||
<div class="shrink-0 border-t border-gray-200 bg-white">
|
<div class="shrink-0 border-t border-gray-200 bg-white">
|
||||||
<div class="mx-auto max-w-3xl px-4 py-3">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -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.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.
|
||||||
- [~] 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.
|
||||||
> Påbegynt: 2026-03-17T17:47
|
|
||||||
- [ ] 7.4 Lyd-avspilling: spiller av original lyd fra CAS-node. Waveform-visning.
|
- [ ] 7.4 Lyd-avspilling: spiller av original lyd fra CAS-node. Waveform-visning.
|
||||||
|
|
||||||
## Fase 8: Aliaser
|
## Fase 8: Aliaser
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue