246 lines
4.9 KiB
JavaScript
246 lines
4.9 KiB
JavaScript
|
|
/**
|
|
* PCM playback for AgentVoice.
|
|
*
|
|
* Responsibilities:
|
|
* - Decode raw PCM signed 16-bit little-endian.
|
|
* - Schedule audio chunks in arrival order.
|
|
* - Track actual playback completion.
|
|
* - Stop scheduled audio immediately.
|
|
*
|
|
* Non-responsibilities:
|
|
* - WebSocket communication.
|
|
* - Voice activity detection.
|
|
* - Session or turn lifecycle.
|
|
*/
|
|
|
|
export class PCMPlayer {
|
|
constructor({
|
|
audioContext,
|
|
sampleRate = 48000,
|
|
onPlaybackStart = () => {},
|
|
onPlaybackEnd = () => {},
|
|
} = {}) {
|
|
if (!audioContext) {
|
|
throw new Error("AudioContext is required.");
|
|
}
|
|
|
|
if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
|
|
throw new Error("Invalid PCM sample rate.");
|
|
}
|
|
|
|
this.context = audioContext;
|
|
this.sampleRate = sampleRate;
|
|
this.analyser = this.context.createAnalyser();
|
|
this.analyser.fftSize = 1024;
|
|
this.analyser.smoothingTimeConstant = 0.65;
|
|
|
|
this.analyser.connect(this.context.destination);
|
|
|
|
this.onPlaybackStart = onPlaybackStart;
|
|
this.onPlaybackEnd = onPlaybackEnd;
|
|
|
|
this.sources = new Set();
|
|
this.nextStartTime = 0;
|
|
this.playing = false;
|
|
this.finished = false;
|
|
this.generation = 0;
|
|
|
|
this.waiters = [];
|
|
}
|
|
|
|
createAnalyser() {
|
|
return {
|
|
fftSize: 1024,
|
|
smoothingTimeConstant: 0.65,
|
|
connect() {},
|
|
disconnect() {},
|
|
};
|
|
}
|
|
|
|
getAnalyser() {
|
|
return this.analyser;
|
|
}
|
|
|
|
configure(format) {
|
|
if (
|
|
format?.encoding !== "pcm_s16le" ||
|
|
format.channels !== 1 ||
|
|
!Number.isInteger(format.sample_rate) ||
|
|
format.sample_rate < 8000 ||
|
|
format.sample_rate > 96000
|
|
) {
|
|
throw new Error("Formato de áudio de saída incompatível.");
|
|
}
|
|
if (this.sources.size > 0) {
|
|
throw new Error("Não é possível trocar o formato durante a reprodução.");
|
|
}
|
|
this.sampleRate = format.sample_rate;
|
|
}
|
|
|
|
decodePCM(buffer) {
|
|
if (!(buffer instanceof ArrayBuffer)) {
|
|
throw new TypeError("PCM chunk must be an ArrayBuffer.");
|
|
}
|
|
|
|
if (buffer.byteLength % 2 !== 0) {
|
|
throw new Error("Invalid PCM byte length.");
|
|
}
|
|
|
|
const view = new DataView(buffer);
|
|
const samples = new Float32Array(buffer.byteLength / 2);
|
|
|
|
for (let i = 0; i < samples.length; i++) {
|
|
samples[i] = view.getInt16(i * 2, true) / 32768;
|
|
}
|
|
|
|
return samples;
|
|
}
|
|
|
|
async enqueue(buffer) {
|
|
const generation = this.generation;
|
|
|
|
if (this.finished) {
|
|
// Playback was already stopped or finalized.
|
|
// Late audio chunks must not terminate the conversation.
|
|
return;
|
|
}
|
|
|
|
const samples = this.decodePCM(buffer);
|
|
|
|
if (samples.length === 0) {
|
|
return;
|
|
}
|
|
|
|
await this.context.resume();
|
|
|
|
if (generation !== this.generation) {
|
|
return;
|
|
}
|
|
|
|
if (this.finished) {
|
|
return;
|
|
}
|
|
|
|
const audioBuffer = this.context.createBuffer(
|
|
1,
|
|
samples.length,
|
|
this.sampleRate
|
|
);
|
|
|
|
audioBuffer.copyToChannel(samples, 0);
|
|
|
|
const source = this.context.createBufferSource();
|
|
source.buffer = audioBuffer;
|
|
source.connect(this.analyser);
|
|
|
|
const startAt = Math.max(
|
|
this.context.currentTime + 0.01,
|
|
this.nextStartTime
|
|
);
|
|
|
|
this.nextStartTime = startAt + audioBuffer.duration;
|
|
this.sources.add(source);
|
|
|
|
if (!this.playing) {
|
|
this.playing = true;
|
|
this.onPlaybackStart();
|
|
}
|
|
|
|
source.onended = () => {
|
|
source.disconnect();
|
|
this.sources.delete(source);
|
|
|
|
if (generation !== this.generation) {
|
|
return;
|
|
}
|
|
|
|
this.checkCompletion();
|
|
};
|
|
|
|
try {
|
|
source.start(startAt);
|
|
} catch (error) {
|
|
this.sources.delete(source);
|
|
source.disconnect();
|
|
this.checkCompletion();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
finish() {
|
|
this.finished = true;
|
|
this.checkCompletion();
|
|
}
|
|
|
|
checkCompletion() {
|
|
if (!this.finished || this.sources.size > 0) {
|
|
return;
|
|
}
|
|
|
|
const wasPlaying = this.playing;
|
|
this.playing = false;
|
|
this.nextStartTime = 0;
|
|
|
|
if (wasPlaying) {
|
|
this.onPlaybackEnd();
|
|
}
|
|
|
|
this.resolveWaiters();
|
|
}
|
|
|
|
waitForCompletion() {
|
|
if (this.finished && this.sources.size === 0) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
this.waiters.push(resolve);
|
|
});
|
|
}
|
|
|
|
resolveWaiters() {
|
|
const waiters = this.waiters.splice(0);
|
|
|
|
for (const resolve of waiters) {
|
|
resolve();
|
|
}
|
|
}
|
|
|
|
stop() {
|
|
this.generation++;
|
|
|
|
for (const source of this.sources) {
|
|
source.onended = null;
|
|
|
|
try {
|
|
source.stop();
|
|
} catch {
|
|
// Source may already have finished.
|
|
}
|
|
|
|
source.disconnect();
|
|
}
|
|
|
|
this.sources.clear();
|
|
this.nextStartTime = 0;
|
|
|
|
const wasPlaying = this.playing;
|
|
this.playing = false;
|
|
this.finished = true;
|
|
|
|
if (wasPlaying) {
|
|
this.onPlaybackEnd();
|
|
}
|
|
|
|
this.resolveWaiters();
|
|
}
|
|
|
|
reset() {
|
|
this.stop();
|
|
|
|
this.finished = false;
|
|
this.nextStartTime = 0;
|
|
}
|
|
}
|