From 5213a7925b35e0e98515277d764b9c0ed8170be1 Mon Sep 17 00:00:00 2001 From: zachm-vapi <276893101+zachm-vapi@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:58:59 -0700 Subject: [PATCH] Make it possible to retry video recording if it fails --- README.md | 14 +++++++++++ __tests__/vapi.test.ts | 28 ++++++++++++++++++++++ vapi.ts | 53 ++++++++++++++++++++++++++++-------------- 3 files changed, 77 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d62ffa300..b70ef3bad 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,20 @@ vapi.on('error', (e) => { }); ``` +## Recording + +When video recording is enabled on the call's artifact plan, Vapi starts a recording automatically when the call begins. Recording start is asynchronous: success is signaled by the `recording-started` event and failure by the `recording-error` event. + +A failed recording start is not retried automatically. Use `startRecording()` and `stopRecording()` to control the recording yourself. For example, to retry after a failure: + +```javascript +vapi.on('recording-error', (e) => { + console.error('Recording failed', e); + // Retry after a short delay + setTimeout(() => vapi.startRecording(), 5000); +}); +``` + ## Events You can listen to the following events: diff --git a/__tests__/vapi.test.ts b/__tests__/vapi.test.ts index fd44ffd3f..7f4d269aa 100644 --- a/__tests__/vapi.test.ts +++ b/__tests__/vapi.test.ts @@ -105,6 +105,8 @@ describe("Vapi", () => { setLocalAudio: jest.fn(), localAudio: jest.fn(), destroy: jest.fn().mockResolvedValue(undefined), + startRecording: jest.fn(), + stopRecording: jest.fn(), }; // Initialize Vapi instance and inject the mock vapi = new Vapi("dummy_token"); @@ -254,6 +256,32 @@ describe("Vapi", () => { expect(vapi.getAudioPlayer()).toBeNull(); }); }); + + describe("startRecording", () => { + it("should start a recording", () => { + vapi.startRecording(); + expect(mockCall.startRecording).toHaveBeenCalled(); + }); + + it("should throw when call object is not available", () => { + vapi["call"] = null; // Simulate call object not being available + expect(() => vapi.startRecording()).toThrow( + "Call object is not available." + ); + }); + }); + + describe("stopRecording", () => { + it("should stop the recording", () => { + vapi.stopRecording(); + expect(mockCall.stopRecording).toHaveBeenCalled(); + }); + + it("should not throw when call object is not available", () => { + vapi["call"] = null; // Simulate call object not being available + expect(() => vapi.stopRecording()).not.toThrow(); + }); + }); }); // Daily applies input settings through its remotely loaded call machine, so a diff --git a/vapi.ts b/vapi.ts index 1576fbf1e..744b96d70 100644 --- a/vapi.ts +++ b/vapi.ts @@ -735,14 +735,7 @@ export default class Vapi extends VapiEventEmitter { const recordingStartTime = Date.now(); try { - this.call.startRecording({ - width: 1280, - height: 720, - backgroundColor: '#FF1F2D3D', - layout: { - preset: 'default', - }, - }); + this.startRecording(); const recordingSetupDuration = Date.now() - recordingStartTime; this.emit('call-start-progress', { @@ -752,7 +745,7 @@ export default class Vapi extends VapiEventEmitter { timestamp: new Date().toISOString() }); - this.call.on('recording-started', () => { + this.call.once('recording-started', () => { const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000; this.emit('call-start-progress', { stage: 'video-recording-started', @@ -1350,6 +1343,37 @@ export default class Vapi extends VapiEventEmitter { this.call?.stopScreenShare(); } + /** + * Starts (or retries) a cloud recording of the call. + * + * Recording start is asynchronous: success is signaled by the + * 'recording-started' event and failure by 'recording-error'. Daily does not + * retry automatically, so if you receive 'recording-error' (e.g. the + * recording start timed out), you can call this method again a few seconds + * later to retry. + */ + public startRecording(): void { + if (!this.call) { + throw new Error('Call object is not available.'); + } + this.call.startRecording({ + width: 1280, + height: 720, + backgroundColor: '#FF1F2D3D', + layout: { + preset: 'default', + }, + }); + } + + /** + * Stops the in-progress recording. Completion is signaled by the + * 'recording-stopped' event. + */ + public stopRecording(): void { + this.call?.stopRecording(); + } + /** * Reconnects to an active call. * @@ -1614,14 +1638,7 @@ export default class Vapi extends VapiEventEmitter { const recordingRequestedTime = new Date().getTime(); try { - this.call.startRecording({ - width: 1280, - height: 720, - backgroundColor: '#FF1F2D3D', - layout: { - preset: 'default', - }, - }); + this.startRecording(); const recordingSetupDuration = Date.now() - recordingStartTime; this.emit('call-start-progress', { @@ -1631,7 +1648,7 @@ export default class Vapi extends VapiEventEmitter { timestamp: new Date().toISOString() }); - this.call.on('recording-started', () => { + this.call.once('recording-started', () => { const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000; this.emit('call-start-progress', { stage: 'video-recording-started',