116 lines
4.1 KiB
TypeScript
116 lines
4.1 KiB
TypeScript
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;
|
|
}
|