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);
  });
}