feat: Add support for inbound "calls" from triggers (#2500)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent f4c686c298
commit 15e335a647
39 changed files with 2915 additions and 133 deletions
+115
View File
@@ -0,0 +1,115 @@
import { Timer } from '../../../utils/timer';
import { RingtoneFinishedHandler, Tone, ToneEnvelope } from './types';
// Shared scaffolding for tones generated via the Web Audio API: owns the
// AudioContext lifecycle, the repeat timer, and the bell-shaped note envelope.
// Subclasses implement `_play()` to define one iteration of their pattern, and
// call `_scheduleNext()` to loop.
//
// `repeat` caps how many iterations are played per `start()`. `0` means loop
// indefinitely; otherwise the tone schedules a final no-op timer to let the
// last iteration's decay tail finish audibly, then fires `finishedHandler` and
// self-stops.
export abstract class GeneratedTone implements Tone {
private _context: AudioContext | null = null;
private _timer = new Timer();
private _finishedHandler: RingtoneFinishedHandler | null = null;
private readonly _repeat: number;
private _remaining = 0;
constructor(repeat: number) {
this._repeat = repeat;
}
public start(finishedHandler?: RingtoneFinishedHandler): void {
if (this._context) {
return;
}
try {
this._context = new AudioContext();
} catch {
this._context = null;
// Treat AudioContext construction failure as natural completion so the
// caller can release any lock it holds on our behalf -- otherwise the
// orchestrator can't tell silent failure from active playback.
finishedHandler?.();
return;
}
this._finishedHandler = finishedHandler ?? null;
this._remaining = this._repeat;
this._play();
}
public stop(): void {
this._timer.stop();
this._context?.close().catch(() => {});
this._context = null;
// Suppress any natural-completion callback -- the caller asked to stop.
this._finishedHandler = null;
}
// Current AudioContext time. Subclasses only read this from inside `_play()`
// where the context is always set.
protected get _currentTime(): number {
/* istanbul ignore next: _currentTime is only read by subclasses from
_play() during an active context -- @preserve */
return this._context?.currentTime ?? 0;
}
// Schedule the next iteration of `_play()`. Subclasses call this at the end
// of their pattern to loop. No-ops if the context has already been closed so
// a stopped tone can never re-arm its loop.
protected _scheduleNext(intervalSeconds: number): void {
/* istanbul ignore next: defensive guard against a subclass calling
_scheduleNext after stop() — JS single-threading makes this unreachable
from the existing subclasses -- @preserve */
if (!this._context) {
return;
}
if (this._repeat > 0 && --this._remaining <= 0) {
// Schedule one last wait for the decay tail.
this._timer.start(intervalSeconds, () => {
const finishedHandler = this._finishedHandler;
this.stop();
finishedHandler?.();
});
return;
}
this._timer.start(intervalSeconds, () => {
/* istanbul ignore next: Timer.stop() cancels pending callbacks, so this
re-entry guard is unreachable in practice -- @preserve */
if (!this._context) {
return;
}
this._play();
});
}
// Plays one note: a smooth tone that rises to peak volume and then fades.
protected _playNote(freq: number, when: number, envelope: ToneEnvelope): void {
/* istanbul ignore next: _playNote is only called by subclasses from
_play() during an active context -- @preserve */
if (!this._context) {
return;
}
const oscillator = this._context.createOscillator();
const gain = this._context.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = freq;
oscillator.connect(gain);
gain.connect(this._context.destination);
gain.gain.setValueAtTime(0, when);
gain.gain.linearRampToValueAtTime(envelope.peak, when + envelope.attack);
gain.gain.setTargetAtTime(0, when + envelope.attack, envelope.decayTau);
oscillator.start(when);
oscillator.stop(when + envelope.hold);
}
// One iteration of the pattern. Implementations should call `_playNote(...)`
// for each note and finish with `_scheduleNext(...)` to loop.
protected abstract _play(): void;
}