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

99
src/lib/utils/sounds.ts Normal file
View File

@@ -0,0 +1,99 @@
/// Sound-cue synthesis for recording state transitions (brief item B.1
/// #20). Uses the Web Audio API directly rather than shipping WAV
/// assets — keeps the Tauri binary the same size, sidesteps packaging
/// concerns, and gives the three cues a coherent sound signature we
/// can tweak without touching binary files.
///
/// The three cues are pitched as an ascending / descending pattern
/// that maps the mental model: start = go (rising C), stop = pause
/// (falling), complete = resolve (major third chord).
const START_FREQS = [523.25]; // C5
const STOP_FREQS = [392.0]; // G4
const COMPLETE_FREQS = [523.25, 659.25, 783.99]; // C5 E5 G5 major chord
const DEFAULT_VOLUME = 0.15; // 15% — well below system notification chirp volume
const TONE_DURATION_S = 0.14;
const ATTACK_S = 0.01;
const RELEASE_S = 0.06;
let cachedCtx: AudioContext | null = null;
function getAudioContext(): AudioContext | null {
if (typeof window === "undefined") return null;
if (cachedCtx && cachedCtx.state !== "closed") {
return cachedCtx;
}
const Ctor =
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
.AudioContext ??
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return null;
try {
cachedCtx = new Ctor();
return cachedCtx;
} catch {
return null;
}
}
function playTone(frequency: number, startOffsetS: number, volume: number): void {
const ctx = getAudioContext();
if (!ctx) return;
// Browsers suspend AudioContext until a user gesture; Tauri's webview
// inherits the same behaviour. Resume best-effort; if it fails silently
// we'd rather lose a cue than throw.
if (ctx.state === "suspended") ctx.resume().catch(() => {});
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;