diff --git a/__tests__/speaking-timeout.test.ts b/__tests__/speaking-timeout.test.ts new file mode 100644 index 000000000..a63ee654c --- /dev/null +++ b/__tests__/speaking-timeout.test.ts @@ -0,0 +1,61 @@ +import Vapi from "../vapi"; + +jest.mock("@daily-co/daily-js", () => ({ + __esModule: true, + default: { createCallObject: () => null }, +})); + +jest.mock("../client", () => ({ + client: { baseUrl: "", setSecurityData: () => {}, call: {} }, +})); + +const speaking = { participantsAudioLevel: { assistant: 0.1 } } as any; + +describe("speech-end timer", () => { + let vapi: Vapi; + let events: string[]; + + beforeEach(() => { + jest.useFakeTimers(); + vapi = new Vapi("dummy_token"); + vapi["call"] = { destroy: jest.fn().mockResolvedValue(undefined) } as any; + events = []; + vapi.on("speech-start", () => events.push("speech-start")); + vapi.on("speech-end", () => events.push("speech-end")); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("emits speech-end once the assistant stops speaking", () => { + vapi["handleRemoteParticipantsAudioLevel"](speaking); + jest.advanceTimersByTime(1000); + expect(events).toEqual(["speech-start", "speech-end"]); + }); + + it("does not fire a stale speech-end into the next call after stop()", async () => { + vapi["handleRemoteParticipantsAudioLevel"](speaking); + await vapi.stop(); + events.length = 0; + + // Next call: the assistant starts speaking 500 ms later. + jest.advanceTimersByTime(500); + vapi["handleRemoteParticipantsAudioLevel"](speaking); + jest.advanceTimersByTime(600); + + // The old timer must not end the new call's speech early. + expect(events).toEqual(["speech-start"]); + jest.advanceTimersByTime(500); + expect(events).toEqual(["speech-start", "speech-end"]); + }); + + it("emits speech-end when the call is torn down while the assistant speaks", async () => { + vapi["handleRemoteParticipantsAudioLevel"](speaking); + events.length = 0; + await vapi.stop(); + expect(events).toEqual(["speech-end"]); + jest.advanceTimersByTime(5000); + expect(events).toEqual(["speech-end"]); + }); +}); diff --git a/vapi.ts b/vapi.ts index 6fa5e7f47..58fc69ed0 100644 --- a/vapi.ts +++ b/vapi.ts @@ -384,7 +384,7 @@ export default class Vapi extends VapiEventEmitter { } this.audioPlayer = null; this.pendingAudioBuilds.clear(); - this.speakingTimeout = null; + this.endSpeaking(); } private isMobileDevice() { @@ -1081,6 +1081,19 @@ export default class Vapi extends VapiEventEmitter { }, 1000); } + /** + * Cancels the pending speech-end timer. If the assistant was mid-speech, + * emits speech-end now, so it is not fired later into the next call. + */ + private endSpeaking() { + if (!this.speakingTimeout) { + return; + } + clearTimeout(this.speakingTimeout); + this.speakingTimeout = null; + this.emit('speech-end'); + } + private handleLocalAudioLevel(e: DailyEventObjectLocalAudioLevel) { this.emit('local-volume-level', e.audioLevel); } @@ -1099,7 +1112,7 @@ export default class Vapi extends VapiEventEmitter { } this.audioPlayer = null; this.pendingAudioBuilds.clear(); - this.speakingTimeout = null; + this.endSpeaking(); } /**