implementacao de interrupcao em audio streming

This commit is contained in:
2026-10-16 01:15:34 +00:00
parent 19a415daae
commit 2449b34137
12 changed files with 2119 additions and 85 deletions

View File

@@ -0,0 +1,224 @@
/**
* 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;
}
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) {
if (this.finished) {
throw new Error("Playback has already been finalized.");
}
const samples = this.decodePCM(buffer);
if (samples.length === 0) {
return;
}
await this.context.resume();
if (this.finished) {
throw new Error("Playback was finalized during enqueue.");
}
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;
const generation = this.generation;
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;
}
}