Files
Lumotia/src/lib/utils/sounds.ts
Jake 5c17544a63 fix(sounds): fall back to default volume on out-of-range input
resolveVolume previously clamped any value >=1 to full blast. If a
future settings change ever leaks a 0–100 scale through (instead of
0–1), the user gets jump-scared at max volume. Treat any v>1 as a
scale-drift bug and play at DEFAULT_VOLUME instead.

Also reject NaN / Infinity explicitly rather than relying on the
<=0 branch catching them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:32:51 +01:00

102 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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;
if (!Number.isFinite(v) || v <= 0) return 0;
// Any value >1 means a scale drift somewhere upstream (e.g. a 0100
// slider leaking through unscaled). Clamping to 1 would blast the
// user at full volume; fall back to DEFAULT_VOLUME instead.
if (v > 1) return DEFAULT_VOLUME;
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;