Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/pause-resume-conversation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@elevenlabs/client": patch
"@elevenlabs/react": patch
---

Add pause and resume controls with paused state for active voice conversations.
106 changes: 104 additions & 2 deletions packages/client/src/BaseConversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,48 @@ const noopConnection = {
sendMessage: () => {},
} as unknown as BaseConnection;

function createConnection(overrides: Partial<BaseConnection> = {}) {
return {
...noopConnection,
...overrides,
} as unknown as BaseConnection;
}

class TestConversation extends BaseConversation {
public pauseCount = 0;
public resumeCount = 0;

public static getFullOptions(partialOptions: PartialOptions): Options {
return super.getFullOptions(partialOptions);
}

public static create(options: { origin?: string } = {}): TestConversation {
public static create(
options: { origin?: string } = {},
connection = noopConnection
): TestConversation {
const fullOptions = TestConversation.getFullOptions({
agentId: "test-agent-id",
connectionType: "webrtc",
...options,
});
return new TestConversation(fullOptions, noopConnection);
return new TestConversation(fullOptions, connection);
}

constructor(options: Options, connection: BaseConnection) {
super(options, connection);
}

protected override async handlePause(): Promise<() => Promise<void>> {
this.pauseCount++;
return async () => {
this.resumeCount++;
};
}

protected override shouldHandleAudio(): boolean {
return true;
}

public setVolume(): void {}
public setMicMuted(): void {}
public getInputByteFrequencyData(): Uint8Array {
Expand Down Expand Up @@ -100,6 +124,84 @@ describe("BaseConversation", () => {
);
});

describe("pause and resume", () => {
afterEach(() => {
vi.useRealTimers();
});

it("reports pause state", async () => {
vi.useFakeTimers();
const conversation = TestConversation.create(
{},
createConnection({ sendMessage: vi.fn() })
);

expect(conversation.isPaused()).toBe(false);

await conversation.pause();
expect(conversation.isPaused()).toBe(true);

await conversation.resume();
expect(conversation.isPaused()).toBe(false);
});

it("sends user activity while paused and stops after resume", async () => {
vi.useFakeTimers();
const sendMessage = vi.fn();
const conversation = TestConversation.create(
{},
createConnection({ sendMessage })
);

await conversation.pause();

expect(conversation.pauseCount).toBe(1);
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenLastCalledWith({ type: "user_activity" });

vi.advanceTimersByTime(1000);
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(sendMessage).toHaveBeenLastCalledWith({ type: "user_activity" });

await conversation.resume();
expect(conversation.resumeCount).toBe(1);

vi.advanceTimersByTime(1000);
expect(sendMessage).toHaveBeenCalledTimes(2);
});

it("is idempotent", async () => {
vi.useFakeTimers();
const conversation = TestConversation.create(
{},
createConnection({ sendMessage: vi.fn() })
);

await conversation.pause();
await conversation.pause();
await conversation.resume();
await conversation.resume();

expect(conversation.pauseCount).toBe(1);
expect(conversation.resumeCount).toBe(1);
});

it("clears paused activity when the session ends", async () => {
vi.useFakeTimers();
const sendMessage = vi.fn();
const conversation = TestConversation.create(
{},
createConnection({ sendMessage })
);

await conversation.pause();
await conversation.endSession();

vi.advanceTimersByTime(1000);
expect(sendMessage).toHaveBeenCalledTimes(1);
});
});

describe("uploadFile", () => {
let fetchSpy: ReturnType<typeof vi.fn<typeof fetch>>;

Expand Down
62 changes: 62 additions & 0 deletions packages/client/src/BaseConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type { InputConfig } from "./utils/input.js";
import type { OutputConfig } from "./utils/output.js";

const HTTPS_API_ORIGIN = "https://api.elevenlabs.io";
const PAUSED_ACTIVITY_INTERVAL_MS = 1000;

export type { Role, Mode, Status, Callbacks } from "@elevenlabs/types";
export { CALLBACK_KEYS } from "@elevenlabs/types";
Expand Down Expand Up @@ -84,6 +85,8 @@ export type ClientToolsConfig = {
>;
};

type ResumeAfterPauseHandler = () => void | Promise<void>;

export function isTextOnly(options: PartialOptions): boolean | undefined {
const { textOnly: textOnlyOverride } = options.overrides?.conversation ?? {};
const { textOnly } = options;
Expand Down Expand Up @@ -112,6 +115,9 @@ export abstract class BaseConversation {
protected currentEventId = 1;
protected lastFeedbackEventId = 0;
protected canSendFeedback = false;
protected paused = false;
private pausedActivityInterval: ReturnType<typeof setInterval> | null = null;
private resumeAfterPause: ResumeAfterPauseHandler | null = null;

protected static getFullOptions(partialOptions: PartialOptions): Options {
const textOnly = isTextOnly(partialOptions);
Expand Down Expand Up @@ -159,6 +165,9 @@ export abstract class BaseConversation {
private endSessionWithDetails = async (details: DisconnectionDetails) => {
if (this.status !== "connected" && this.status !== "connecting") return;
this.updateStatus("disconnecting");
this.stopPausedActivityInterval();
this.resumeAfterPause = null;
this.paused = false;
await this.handleEndSession();
this.updateStatus("disconnected");
if (this.options.onDisconnect) {
Expand All @@ -170,6 +179,50 @@ export abstract class BaseConversation {
this.connection.close();
}

protected abstract handlePause(): Promise<ResumeAfterPauseHandler>;

private startPausedActivityInterval() {
this.sendUserActivity();
this.pausedActivityInterval = setInterval(() => {
this.sendUserActivity();
}, PAUSED_ACTIVITY_INTERVAL_MS);
}

private stopPausedActivityInterval() {
if (this.pausedActivityInterval) {
clearInterval(this.pausedActivityInterval);
this.pausedActivityInterval = null;
}
}

public async pause(): Promise<void> {
if (this.paused) return;

this.paused = true;
try {
this.resumeAfterPause = await this.handlePause();
this.startPausedActivityInterval();
} catch (error) {
this.resumeAfterPause = null;
this.paused = false;
throw error;
}
}

public async resume(): Promise<void> {
if (!this.paused) return;

this.stopPausedActivityInterval();
try {
await this.resumeAfterPause?.();
this.resumeAfterPause = null;
this.paused = false;
} catch (error) {
this.startPausedActivityInterval();
throw error;
}
}

protected updateMode(mode: Mode) {
if (mode !== this.mode) {
this.mode = mode;
Expand Down Expand Up @@ -314,6 +367,8 @@ export abstract class BaseConversation {

protected handleAudio(event: AgentAudioEvent) {}

protected abstract shouldHandleAudio(event: AgentAudioEvent): boolean;

protected handleMCPToolCall(event: MCPToolCallClientEvent) {
if (this.options.onMCPToolCall) {
this.options.onMCPToolCall(event.mcp_tool_call);
Expand Down Expand Up @@ -426,6 +481,9 @@ export abstract class BaseConversation {
return;
}
case "audio": {
if (!this.shouldHandleAudio(parsedEvent)) {
return;
}
this.handleAudio(parsedEvent);
return;
}
Expand Down Expand Up @@ -514,6 +572,10 @@ export abstract class BaseConversation {
return this.status === "connected";
}

public isPaused() {
return this.paused;
}

public abstract setVolume(options: { volume: number }): void;
public abstract setMicMuted(isMuted: boolean): void;
/**
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/OutputController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface OutputController {
setDevice(config?: Partial<FormatConfig> & OutputDeviceConfig): Promise<void>;
setVolume(volume: number): void;
interrupt(resetDuration?: number): void;
setPlaybackEnabled(isEnabled: boolean): void;

/**
* @deprecated AnalyserNode is a web-only API and will not work on all
Expand Down
8 changes: 8 additions & 0 deletions packages/client/src/TextConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ const EMPTY_FREQUENCY_DATA = new Uint8Array(0);
export class TextConversation extends BaseConversation {
readonly type = "text";

protected override async handlePause(): Promise<() => Promise<void>> {
throw new Error("pause is not supported in text conversations");
}

protected override shouldHandleAudio(): boolean {
return false;
}

public setVolume(): void {
throw new Error("setVolume is not supported in text conversations");
}
Expand Down
28 changes: 28 additions & 0 deletions packages/client/src/VoiceConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,34 @@ export class VoiceConversation extends BaseConversation {
}
}

protected override async handlePause(): Promise<() => Promise<void>> {
const pausedMicMuted = this.input.isMuted();
const pausedVolume = this.volume;
this.output.setPlaybackEnabled(false);
try {
this.output.interrupt(0);
this.setVolume({ volume: 0 });
await this.input.setMuted(true);
this.updateMode("listening");
} catch (error) {
this.output.setPlaybackEnabled(true);
throw error;
}

return async () => {
try {
this.setVolume({ volume: pausedVolume });
await this.input.setMuted(pausedMicMuted);
} finally {
this.output.setPlaybackEnabled(true);
}
};
}

protected override shouldHandleAudio(_event: AgentAudioEvent): boolean {
return !this.paused;
}

private static readonly FREQUENCY_BIN_COUNT = 1024;

public setMicMuted(isMuted: boolean) {
Expand Down
Loading
Loading