From 70b97c5273c5faa03a3764bcaa8b2030826ad070 Mon Sep 17 00:00:00 2001 From: Jake Date: Tue, 21 Apr 2026 16:48:44 +0100 Subject: [PATCH] feat(ui B.1 #20): sound cues on start / stop / complete via Web Audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hands-off feedback for recording lifecycle: a short C5 pitch at record start, a falling G4 at record stop, and a staggered C5–E5–G5 major third when the finalise flow completes. Synthesised at runtime via the Web Audio API (OscillatorNode + linear-ramped GainNode envelope) rather than shipping WAV assets — keeps the binary size flat and lets us tweak timbre without touching bundled files. Off by default. Settings → Output exposes the toggle with a volume slider (0–100%, default 15%) and a "Test" button that plays the completion cue so the user can confirm loudness without recording. Hooked at three call sites in DictationPage: - playStartCue after page.recording = true in startRecording - playStopCue at the top of stopRecording - playCompleteCue just before `saved = true` at the end of finaliseTranscription's transcript-present branch All three no-op when settings.soundCues is false. The Web Audio context is lazily constructed on first cue (most browsers suspend it until a user gesture — Tauri's webview inherits that). If the AudioContext can't be built we silently drop the cue rather than throwing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/pages/DictationPage.svelte | 12 ++++ src/lib/pages/SettingsPage.svelte | 28 +++++++++ src/lib/stores/page.svelte.ts | 2 + src/lib/types/app.ts | 2 + src/lib/utils/sounds.ts | 99 ++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+) create mode 100644 src/lib/utils/sounds.ts diff --git a/src/lib/pages/DictationPage.svelte b/src/lib/pages/DictationPage.svelte index 7c1aeb8..71e2437 100644 --- a/src/lib/pages/DictationPage.svelte +++ b/src/lib/pages/DictationPage.svelte @@ -6,6 +6,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js"; import { markGenerating, markGenerationDone } from "$lib/stores/llmStatus.svelte.js"; + import { playStartCue, playStopCue, playCompleteCue } from "$lib/utils/sounds.js"; import { profilesStore } from "$lib/stores/profiles.svelte.ts"; import { toasts } from "$lib/stores/toasts.svelte.js"; import Card from "$lib/components/Card.svelte"; @@ -358,6 +359,9 @@ page.statusColor = "#e87171"; timerInterval = setInterval(updateTimer, 1000); + // Start cue (brief item #20). Off by default; volume user-set. + playStartCue(settings.soundCueVolume, settings.soundCues); + // Preview overlay: if the user opted in, reset any leftover state // from a prior run and open the window when the main window is not // focused (i.e. the user is dictating into some other app). @@ -389,6 +393,9 @@ page.status = "Finalising..."; page.statusColor = "#e8c86e"; + // Stop cue (brief item #20). "Recording over, finalising now." + playStopCue(settings.soundCueVolume, settings.soundCues); + try { const activityBeforeStop = lastLiveActivityAt; drainingSessionId = sessionId; @@ -570,6 +577,11 @@ page.taskSidebarOpen = true; } + // Complete cue (brief item #20) — major-third arpeggio. Plays + // after the full finalise flow so the user hears "done" + // aligned with the history entry + paste landing. + playCompleteCue(settings.soundCueVolume, settings.soundCues); + saved = true; setTimeout(() => { saved = false; extractedCount = 0; }, 4000); } else if (settings.transcriptionPreview) { diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index 03fbe3c..35b1cb4 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -1656,6 +1656,34 @@ /> {/if} + + {#if settings.soundCues} +
+ + + {Math.round(settings.soundCueVolume * 100)}% + +
+ {/if} {}); + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = "sine"; + osc.frequency.setValueAtTime(frequency, ctx.currentTime + startOffsetS); + + const t0 = ctx.currentTime + startOffsetS; + gain.gain.setValueAtTime(0, t0); + gain.gain.linearRampToValueAtTime(volume, t0 + ATTACK_S); + gain.gain.setValueAtTime(volume, t0 + TONE_DURATION_S - RELEASE_S); + gain.gain.linearRampToValueAtTime(0, t0 + TONE_DURATION_S); + + osc.connect(gain).connect(ctx.destination); + osc.start(t0); + osc.stop(t0 + TONE_DURATION_S + 0.02); +} + +function playChord(frequencies: number[], volume: number, stagger = 0): void { + for (let i = 0; i < frequencies.length; i += 1) { + playTone(frequencies[i], i * stagger, volume); + } +} + +function resolveVolume(userVolume: number | undefined, enabled: boolean): number { + if (!enabled) return 0; + const v = typeof userVolume === "number" ? userVolume : DEFAULT_VOLUME; + // Clamp: 0 → silent, 1 → loud. Protect against bad settings. + if (v <= 0) return 0; + if (v >= 1) return 1; + return v; +} + +export function playStartCue(volume?: number, enabled = true): void { + const v = resolveVolume(volume, enabled); + if (v === 0) return; + playChord(START_FREQS, v); +} + +export function playStopCue(volume?: number, enabled = true): void { + const v = resolveVolume(volume, enabled); + if (v === 0) return; + playChord(STOP_FREQS, v); +} + +export function playCompleteCue(volume?: number, enabled = true): void { + const v = resolveVolume(volume, enabled); + if (v === 0) return; + // Slightly staggered arpeggio for the "done" cue — feels resolved + // rather than alarm-like. + playChord(COMPLETE_FREQS, v, 0.05); +} + +export const DEFAULT_SOUND_VOLUME = DEFAULT_VOLUME;