feat(ui B.1 #20): sound cues on start / stop / complete via Web Audio
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:
@@ -6,6 +6,7 @@
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
|
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
|
||||||
import { markGenerating, markGenerationDone } from "$lib/stores/llmStatus.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 { profilesStore } from "$lib/stores/profiles.svelte.ts";
|
||||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||||
import Card from "$lib/components/Card.svelte";
|
import Card from "$lib/components/Card.svelte";
|
||||||
@@ -358,6 +359,9 @@
|
|||||||
page.statusColor = "#e87171";
|
page.statusColor = "#e87171";
|
||||||
timerInterval = setInterval(updateTimer, 1000);
|
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
|
// 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
|
// 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).
|
// focused (i.e. the user is dictating into some other app).
|
||||||
@@ -389,6 +393,9 @@
|
|||||||
page.status = "Finalising...";
|
page.status = "Finalising...";
|
||||||
page.statusColor = "#e8c86e";
|
page.statusColor = "#e8c86e";
|
||||||
|
|
||||||
|
// Stop cue (brief item #20). "Recording over, finalising now."
|
||||||
|
playStopCue(settings.soundCueVolume, settings.soundCues);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const activityBeforeStop = lastLiveActivityAt;
|
const activityBeforeStop = lastLiveActivityAt;
|
||||||
drainingSessionId = sessionId;
|
drainingSessionId = sessionId;
|
||||||
@@ -570,6 +577,11 @@
|
|||||||
page.taskSidebarOpen = true;
|
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;
|
saved = true;
|
||||||
setTimeout(() => { saved = false; extractedCount = 0; }, 4000);
|
setTimeout(() => { saved = false; extractedCount = 0; }, 4000);
|
||||||
} else if (settings.transcriptionPreview) {
|
} else if (settings.transcriptionPreview) {
|
||||||
|
|||||||
@@ -1656,6 +1656,34 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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.includeTimestamps} label="Include timestamps in exports" />
|
||||||
<Toggle
|
<Toggle
|
||||||
bind:checked={settings.saveAudio}
|
bind:checked={settings.saveAudio}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ const defaults: SettingsState = {
|
|||||||
transcriptionPreview: false,
|
transcriptionPreview: false,
|
||||||
meetingAutoCapture: false,
|
meetingAutoCapture: false,
|
||||||
meetingAutoCaptureApps: ["zoom", "teams"],
|
meetingAutoCaptureApps: ["zoom", "teams"],
|
||||||
|
soundCues: false,
|
||||||
|
soundCueVolume: 0.15,
|
||||||
includeTimestamps: true,
|
includeTimestamps: true,
|
||||||
theme: "Dark",
|
theme: "Dark",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface SettingsState {
|
|||||||
transcriptionPreview: boolean;
|
transcriptionPreview: boolean;
|
||||||
meetingAutoCapture: boolean;
|
meetingAutoCapture: boolean;
|
||||||
meetingAutoCaptureApps: string[];
|
meetingAutoCaptureApps: string[];
|
||||||
|
soundCues: boolean;
|
||||||
|
soundCueVolume: number;
|
||||||
includeTimestamps: boolean;
|
includeTimestamps: boolean;
|
||||||
theme: "Dark" | "Light" | "System";
|
theme: "Dark" | "Light" | "System";
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
|
|||||||
99
src/lib/utils/sounds.ts
Normal file
99
src/lib/utils/sounds.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user