mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-10-03 17:54:12 +00:00
* fix(gather): clear a finalized deepgram interim word so UtteranceEnd is not deferred forever The #1088 check defers UtteranceEnd while an interim word newer than last_word_end is pending. The marker was only cleared by a non-empty is_final, so an interim word that Deepgram later dropped (e.g. line noise, followed by an empty final) left it set: the gather waited for the caller to speak again and merged both utterances. Clear the marker on any Deepgram is_final result whose window (start + duration) covers it. A word that Deepgram carries into the next segment still defers UtteranceEnd. Port of jambonz/feature-server#223. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(gather): return the buffer when a deferred deepgram UtteranceEnd is satisfied Clearing the unprocessed-word marker only helps if the covering final arrives before UtteranceEnd. If it arrives after, UtteranceEnd has already been deferred and Deepgram sends no further UtteranceEnd until new speech, so the next utterance is still merged in. Remember a deferred UtteranceEnd, and when a later final clears the marker, return the buffered transcript once that final has been handled (setImmediate, since _onTranscription has early returns). Port of jambonz/feature-server#225. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(gather): only an empty final ends a gather early after a deferred UtteranceEnd A final with words means the caller resumed talking after the pause; Deepgram will send a new UtteranceEnd after those words, so keep the existing flow instead of cutting the caller off mid-sentence (or skipping the asrTimeout wait). Only a final that drops the pending word (empty) leaves nothing else to end the gather. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
4.8 KiB
JavaScript
132 lines
4.8 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
|
|
const TaskGather = require('../../lib/tasks/gather');
|
|
|
|
const logger = {debug() {}, info() {}, error() {}, warn() {}};
|
|
|
|
/* a continuous-asr deepgram gather; the constructor is not run, only what _onTranscription touches is set */
|
|
const makeGather = () => {
|
|
const resolved = [];
|
|
const gather = Object.create(TaskGather.prototype);
|
|
const {normalizeTranscription, consolidateTranscripts} = require('../../lib/utils/transcription-utils')(logger);
|
|
const cs = {emit() {}, calculateSttLatency() {}, callGone: false};
|
|
Object.assign(gather, {
|
|
logger,
|
|
normalizeTranscription,
|
|
consolidateTranscripts,
|
|
vendor: 'deepgram',
|
|
language: 'en-US',
|
|
data: {recognizer: {}},
|
|
isContinuousAsr: true,
|
|
asrTimeout: 2000,
|
|
_bufferedTranscripts: [],
|
|
_sonioxTranscripts: [],
|
|
cs,
|
|
eventIsForOurBug: () => true,
|
|
doesVendorContinueListeningAfterFinalTranscript: () => true,
|
|
_clearTimer: () => false,
|
|
_startTimer() {},
|
|
_startAsrTimer() {},
|
|
_clearAsrTimer() {},
|
|
_resolve(reason, evt) {
|
|
this.resolved = true;
|
|
resolved.push({reason, transcript: evt.alternatives[0].transcript});
|
|
}
|
|
});
|
|
const fsEvent = {getHeader: () => undefined};
|
|
/* a deferred UtteranceEnd resolves on setImmediate, after the final has been handled */
|
|
const send = async(evt) => {
|
|
gather._onTranscription(cs, {}, evt, fsEvent);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
};
|
|
return {gather, send, resolved};
|
|
};
|
|
|
|
const results = ({start, duration, is_final, speech_final = false, words = []}) => ({
|
|
type: 'Results',
|
|
start,
|
|
duration,
|
|
is_final,
|
|
speech_final,
|
|
channel: {
|
|
alternatives: [{
|
|
transcript: words.map((w) => w.word).join(' '),
|
|
confidence: 0.9,
|
|
words
|
|
}]
|
|
}
|
|
});
|
|
|
|
const firstUtterance = results({
|
|
start: 0, duration: 3, is_final: true,
|
|
words: [{word: 'pay', start: 0.5, end: 1.0}, {word: 'my', start: 1.1, end: 1.4}, {word: 'bill', start: 1.5, end: 2.9}]
|
|
});
|
|
const strayInterim = results({start: 3, duration: 1.5, is_final: false, words: [{word: 'uh', start: 4.0, end: 4.2}]});
|
|
const utteranceEnd = {type: 'UtteranceEnd', channel: [0, 1], last_word_end: 2.9};
|
|
|
|
test('an empty final covering a stray interim word lets UtteranceEnd return the buffer', async() => {
|
|
const {gather, send, resolved} = makeGather();
|
|
await send(firstUtterance);
|
|
await send(strayInterim);
|
|
assert.strictEqual(gather._dgTimeOfLastUnprocessedWord, 4.2);
|
|
|
|
await send(results({start: 3, duration: 2, is_final: true}));
|
|
assert.strictEqual(gather._dgTimeOfLastUnprocessedWord, null);
|
|
|
|
await send(utteranceEnd);
|
|
assert.deepStrictEqual(resolved, [{reason: 'speech', transcript: 'pay my bill'}]);
|
|
});
|
|
|
|
test('an empty final that ends before the interim word keeps UtteranceEnd waiting', async() => {
|
|
const {gather, send, resolved} = makeGather();
|
|
await send(firstUtterance);
|
|
await send(strayInterim);
|
|
|
|
/* deepgram finalized [3, 4.0) but the word ending at 4.2 falls in the next segment */
|
|
await send(results({start: 3, duration: 1.0, is_final: true}));
|
|
assert.strictEqual(gather._dgTimeOfLastUnprocessedWord, 4.2);
|
|
|
|
await send(utteranceEnd);
|
|
assert.deepStrictEqual(resolved, []);
|
|
});
|
|
|
|
test('an empty final arriving after a deferred UtteranceEnd returns the buffer', async() => {
|
|
const {send, resolved} = makeGather();
|
|
await send(firstUtterance);
|
|
await send(strayInterim);
|
|
|
|
await send(utteranceEnd);
|
|
assert.deepStrictEqual(resolved, []);
|
|
|
|
/* deepgram sends no second UtteranceEnd until new speech, so this final must end the gather */
|
|
await send(results({start: 3, duration: 2, is_final: true}));
|
|
assert.deepStrictEqual(resolved, [{reason: 'speech', transcript: 'pay my bill'}]);
|
|
});
|
|
|
|
test('a final with words after a deferred UtteranceEnd keeps listening for the next UtteranceEnd (#1088)', async() => {
|
|
const {send, resolved} = makeGather();
|
|
await send(firstUtterance);
|
|
await send(results({start: 3, duration: 1.5, is_final: false, words: [{word: 'please', start: 3.8, end: 4.2}]}));
|
|
|
|
await send(utteranceEnd);
|
|
assert.deepStrictEqual(resolved, []);
|
|
|
|
/* the caller may still be talking, so the words alone must not end the gather */
|
|
await send(results({start: 3, duration: 1.5, is_final: true, words: [{word: 'please', start: 3.8, end: 4.2}]}));
|
|
assert.deepStrictEqual(resolved, []);
|
|
|
|
await send({...utteranceEnd, last_word_end: 4.2});
|
|
assert.deepStrictEqual(resolved, [{reason: 'speech', transcript: 'pay my bill please'}]);
|
|
});
|
|
|
|
test('a deferred UtteranceEnd is not resolved by a final that ends before the pending word', async() => {
|
|
const {send, resolved} = makeGather();
|
|
await send(firstUtterance);
|
|
await send(strayInterim);
|
|
|
|
await send(utteranceEnd);
|
|
await send(results({start: 3, duration: 1.0, is_final: true}));
|
|
assert.deepStrictEqual(resolved, []);
|
|
});
|