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>
102 lines
3.6 KiB
TypeScript
102 lines
3.6 KiB
TypeScript
/// 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 0–100
|
||
// 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;
|