implementacao de interrupcao em audio streming
This commit is contained in:
224
src/voice_assistant/audio_gateway/web/pcm-player.js
Normal file
224
src/voice_assistant/audio_gateway/web/pcm-player.js
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user