agent-chime.ts4.3 KBView on GitHub /**
* The "your agent finished" chime.
*
* Synthesised rather than shipped as an audio file: it's two sine notes and an envelope, so a
* ~10-line oscillator beats a binary asset that has to be fetched, cached and version-controlled —
* and it can be tuned by reading the numbers below rather than by opening an editor.
*
* Deliberately quiet and short. This fires while the user is doing something else (a run that
* finished in the background is the only kind that chimes), so it has to be noticeable without
* being an interruption — a soft rising major third, under a fifth of a second.
*/
/** A rising major third — G5 → B5. Rising reads as "completed"; falling reads as "failed". */
const NOTES_HZ = [784, 988];
/** Peak gain per note. Low: this is an ambient cue, not an alert. */
const PEAK_GAIN = 0.05;
/** Seconds between the two notes, and how long each one rings. */
const NOTE_STAGGER_S = 0.09;
const NOTE_DURATION_S = 0.34;
/** Two agents finishing at once should chime ONCE, not twice on top of each other. */
const MIN_INTERVAL_MS = 600;
let audioContext: AudioContext | null = null;
let lastPlayedAt = 0;
/**
* Threads whose run the user stopped.
*
* An interrupt ends a run exactly the way a completion does — the thread's status flips to
* 'finished' either way — so the finish edge this chime hangs off fires for both. But "I pressed
* stop" is not news to the person who pressed it, so cancelling marks the thread here and the
* next finish edge for it swallows the chime instead of playing it.
*/
const stoppedThreadIds = new Set<string>();
/** Called when the user cancels a run: its imminent 'finished' edge must not chime. */
export function suppressAgentDoneChime(threadId: string): void {
stoppedThreadIds.add(threadId);
}
/**
* Called when a thread starts a new run. Clears any stale mark, so a stop that never produced a
* visible finish edge (e.g. you stopped the chat you were looking at) can't silence the *next*
* run's chime.
*/
export function clearAgentDoneChimeSuppression(threadId: string): void {
stoppedThreadIds.delete(threadId);
}
function getAudioContext(): AudioContext | null {
if (typeof window === 'undefined') return null;
const Ctor = window.AudioContext ?? (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return null;
// One context for the tab's lifetime — browsers cap how many you may create.
audioContext ??= new Ctor();
return audioContext;
}
export function playAgentDoneChime(threadId?: string): void {
// Consume the mark rather than just reading it: one stop silences one finish.
if (threadId && stoppedThreadIds.delete(threadId)) return;
const now = Date.now();
if (now - lastPlayedAt < MIN_INTERVAL_MS) return;
const ctx = getAudioContext();
if (!ctx) return;
lastPlayedAt = now;
// Created before the user's first gesture, the context starts suspended. `resume()` is async, so
// checking `state` on the next line would ALWAYS still read 'suspended' and swallow the chime —
// which in practice meant the first background completion of every session was silent. Await it
// instead and schedule after. If the browser refuses (genuinely no interaction yet) the promise
// rejects and we simply stay quiet.
if (ctx.state === 'suspended') {
void ctx
.resume()
.then(() => scheduleNotes(ctx))
.catch(() => undefined);
return;
}
if (ctx.state !== 'running') return;
scheduleNotes(ctx);
}
/** The two-note envelope itself. Split out so it can run before OR after a `resume()`. */
function scheduleNotes(ctx: AudioContext): void {
const startAt = ctx.currentTime;
NOTES_HZ.forEach((frequency, index) => {
const at = startAt + index * NOTE_STAGGER_S;
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = frequency;
// Ramp in over a few ms (a hard start clicks), then decay exponentially like a struck bell.
gain.gain.setValueAtTime(0.0001, at);
gain.gain.exponentialRampToValueAtTime(PEAK_GAIN, at + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, at + NOTE_DURATION_S);
oscillator.connect(gain).connect(ctx.destination);
oscillator.start(at);
oscillator.stop(at + NOTE_DURATION_S + 0.02);
});
}