feat(ui B.1 #20): sound cues on start / stop / complete via Web Audio
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 16:48:44 +01:00
parent 1dd09e14ca
commit 70b97c5273
5 changed files with 143 additions and 0 deletions

View File

@@ -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) {

View File

@@ -1656,6 +1656,34 @@
/>
</div>
{/if}
<Toggle
bind:checked={settings.soundCues}
label="Sound cues on start / stop / complete"
description="Short synthesised tones for eyes-off feedback. Synthesised locally via Web Audio — no assets bundled."
/>
{#if settings.soundCues}
<div class="ml-[50px] mt-2 mb-1 animate-fade-in flex items-center gap-3">
<label for="sound-cue-volume" class="text-[11px] text-text-tertiary uppercase tracking-wider">Volume</label>
<input
id="sound-cue-volume"
type="range"
min="0"
max="1"
step="0.05"
bind:value={settings.soundCueVolume}
class="w-[160px] accent-accent"
data-no-transition
/>
<span class="text-[11px] text-text-secondary w-[34px]">{Math.round(settings.soundCueVolume * 100)}%</span>
<button
class="text-[11px] text-accent hover:text-accent-hover"
onclick={async () => {
const mod = await import("$lib/utils/sounds.js");
mod.playCompleteCue(settings.soundCueVolume, true);
}}
>Test</button>
</div>
{/if}
<Toggle bind:checked={settings.includeTimestamps} label="Include timestamps in exports" />
<Toggle
bind:checked={settings.saveAudio}