From 578c9f3397783a14587fd15455866916a5dc358f Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 14:39:11 +0200 Subject: [PATCH] feat(dreams): voice capture via mana-stt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a one-tap voice recorder at the top of the Dreams module. Speak your dream right after waking, the audio is sent through a server-side proxy to mana-stt, and the transcript appears in the entry as soon as it lands. - New /api/v1/dreams/transcribe SvelteKit server route proxies the upload to mana-stt with the server-held MANA_STT_API_KEY (never exposed to the browser); validates mime, size, missing config - Adds MANA_STT_URL + MANA_STT_API_KEY to the mana-web env config in generate-env.mjs (private, not PUBLIC_ prefixed) - New DreamRecorder class wraps MediaRecorder with reactive $state — status, elapsed timer, error; supports cancel - dreamsStore.createFromVoice creates a placeholder dream with processingStatus='transcribing' and kicks off the upload - dreamsStore.transcribeBlob uploads, writes the result back into the dream, falls back to processingStatus='failed' on errors - Adds processingStatus + processingError + audioDurationMs to LocalDream; backwards-compatible defaults in toDream - Mic button in ListView with idle / requesting / recording (with elapsed timer + pulsing red) / stopping states - Cancel button discards the in-flight recording - Transcribing badge ●●● + failed ! badge on dream rows - Inline editor shows live transcription status; while it's running and the user hasn't typed anything, the transcript folds into the edit buffer as soon as it arrives Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.development | 2 + .../src/lib/modules/dreams/ListView.svelte | 228 ++++++++++++++++++ .../web/src/lib/modules/dreams/collections.ts | 6 + .../web/src/lib/modules/dreams/queries.ts | 3 + .../src/lib/modules/dreams/recorder.svelte.ts | 173 +++++++++++++ .../modules/dreams/stores/dreams.svelte.ts | 118 ++++++++- .../apps/web/src/lib/modules/dreams/types.ts | 7 + .../api/v1/dreams/transcribe/+server.ts | 93 +++++++ scripts/generate-env.mjs | 3 + 9 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 apps/mana/apps/web/src/lib/modules/dreams/recorder.svelte.ts create mode 100644 apps/mana/apps/web/src/routes/api/v1/dreams/transcribe/+server.ts diff --git a/.env.development b/.env.development index 94f4bbc46..989e7f5e9 100644 --- a/.env.development +++ b/.env.development @@ -282,6 +282,8 @@ CALENDAR_DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_platform # Production: https://stt-api.mana.how # Local dev: http://localhost:3020 STT_URL=https://stt-api.mana.how +# API key for mana-stt (set in your local .env, never commit a real key) +MANA_STT_API_KEY= # ============================================ # CONTEXT PROJECT diff --git a/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte b/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte index bb6ec0371..0ba47be89 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte @@ -11,6 +11,7 @@ useAllDreams, } from './queries'; import { dreamsStore } from './stores/dreams.svelte'; + import { dreamRecorder, formatElapsed } from './recorder.svelte'; import { MOOD_COLORS, MOOD_LABELS, type Dream, type DreamMood, type SleepQuality } from './types'; import type { ViewProps } from '$lib/app-registry'; import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui'; @@ -59,6 +60,18 @@ symbolFilter ? filteredByMode.filter((d) => d.symbols?.includes(symbolFilter!)) : filteredByMode ); let filtered = $derived(searchDreams(filteredBySymbol, searchQuery)); + + // While the inline editor is open, the `dreams` array updates whenever the + // transcript lands. If the user hasn't typed anything yet, fold the fresh + // content into the edit buffer so they see the transcription appear inline. + $effect(() => { + if (!editingId) return; + const live = dreams.find((d) => d.id === editingId); + if (!live) return; + if (!editContent.trim() && live.content.trim()) { + editContent = live.content; + } + }); let grouped = $derived(groupByMonth(filtered)); let insights = $derived(computeInsights(dreams)); @@ -166,6 +179,38 @@ ); const MOODS: DreamMood[] = ['angenehm', 'neutral', 'unangenehm', 'albtraum']; + + // ── Voice capture ───────────────────────────────────────── + let recError = $state(null); + + async function handleMicClick() { + recError = null; + if (dreamRecorder.status === 'recording') { + try { + const result = await dreamRecorder.stop(); + if (result.durationMs < 500) { + recError = 'Aufnahme war zu kurz.'; + return; + } + const dream = await dreamsStore.createFromVoice(result.blob, result.durationMs, 'de'); + // Open the dream so the user sees the transcript appear inline + viewMode = 'list'; + startEdit(dream); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg !== 'cancelled') recError = msg; + } + } else if (dreamRecorder.status === 'idle') { + await dreamRecorder.start(); + if (dreamRecorder.error) { + recError = dreamRecorder.error; + } + } + } + + function cancelRecording() { + dreamRecorder.cancel(); + }
@@ -191,6 +236,38 @@ }} /> {:else} + +
+ + {#if dreamRecorder.status === 'recording'} + + {/if} +
+ {#if recError} +

{recError}

+ {/if} +
e.preventDefault()} class="quick-add"> 🌙 @@ -289,6 +366,18 @@ placeholder="Titel (optional)..." autofocus /> + {#if dream.processingStatus === 'transcribing'} +
+ ●●● + Transkribiert deine Aufnahme… +
+ {:else if dream.processingStatus === 'failed'} +
+ Transkription fehlgeschlagen{dream.processingError + ? `: ${dream.processingError}` + : ''} +
+ {/if}