Skip to content
Merged
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
21 changes: 21 additions & 0 deletions .changeset/scribe-retry-after-mic-failure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@elevenlabs/client": patch
"@elevenlabs/react": patch
---

Fix a microphone-setup failure wedging `useScribe`, so a denied or dismissed
permission prompt can be retried without remounting.

A microphone-mode session whose `getUserMedia` call rejects can never send
audio, but the socket was left open holding that session. `useScribe` kept its
connection ref, and every later `connect()` short-circuited on `"Already
connected"`. The failed setup now closes the connection, which releases the
stranded socket and lets the hook's existing close handling clear the ref.
`onError` still fires first; the session then ends as `disconnected` with the
error preserved.

The hook's close handler also nulled its ref for whichever connection reported
a close, so a late close from a replaced socket tore down the session that had
replaced it — the race a consumer hit when working around the above with
disconnect-then-reconnect. A close is now ignored when a newer connection owns
the ref, while an explicit `disconnect()` still reports normally.
36 changes: 36 additions & 0 deletions packages/client/src/scribe/scribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,42 @@ describe("Scribe", () => {
server.close();
});

it("closes the connection when microphone setup fails", async () => {
setScribeMicrophoneSetup(
vi.fn(() => Promise.reject(new Error("Permission denied")))
);

const server = new Server(
"wss://api.elevenlabs.io/v1/speech-to-text/realtime?model_id=scribe_v2_realtime&token=sutkn_123"
);
onTestFinished(() => server.close());
const clientPromise = new Promise<Client>((resolve, reject) => {
server.on("connection", socket => resolve(socket));
server.on("error", reject);
setTimeout(() => reject(new Error("timeout")), 5000);
});

const connection = Scribe.connect({
token: TEST_TOKEN,
modelId: TEST_MODEL_ID,
microphone: {},
});

const onError = vi.fn();
const onClose = vi.fn();
connection.on(RealtimeEvents.ERROR, onError);
connection.on(RealtimeEvents.CLOSE, onClose);

await clientPromise;
await sleep(100);

expect(onError).toHaveBeenCalledTimes(1);

// Without a microphone the session can only ever be silent, so the
// socket must not be left open holding it.
expect(onClose).toHaveBeenCalledTimes(1);
});

it("forwards workletPaths.scribeAudioProcessor to the registered microphone setup", async () => {
const mockTrack = { enabled: true } as MediaStreamTrack;
const cleanup = vi.fn();
Expand Down
7 changes: 7 additions & 0 deletions packages/client/src/scribe/scribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,13 @@ export class ScribeRealtime {
} catch (error) {
console.error("Failed to start microphone streaming:", error);
connection._emitError(error);

// A microphone-mode session that never acquired a microphone can never
// send audio, so the socket is stranded: it holds a session open that
// will only ever be silent. Close it so the failure ends the connection
// the way any other terminal one does, and consumers tracking CLOSE can
// start a fresh session instead of waiting on this one.
connection.close();
}
}
}
74 changes: 73 additions & 1 deletion packages/react/src/scribe.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { renderHook, act } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AudioFormat, Scribe } from "@elevenlabs/client";
import { AudioFormat, RealtimeEvents, Scribe } from "@elevenlabs/client";
import type { RealtimeConnection } from "@elevenlabs/client";
import { useScribe } from "./scribe.js";

Expand Down Expand Up @@ -111,4 +111,76 @@ describe("useScribe", () => {
})
);
});

describe("close handling", () => {
const SESSION = {
token: "test-token",
modelId: "scribe_v2_realtime",
microphone: {},
};

function closeHandlerFor(connection: RealtimeConnection) {
return vi
.mocked(connection.on)
.mock.calls.find(([event]) => event === RealtimeEvents.CLOSE)?.[1] as
| (() => void)
| undefined;
}

it("ignores a close from a connection that has been replaced", async () => {
const first = createMockConnection();
const second = createMockConnection();
vi.mocked(Scribe.connect)
.mockReturnValueOnce(first)
.mockReturnValueOnce(second);

const onDisconnect = vi.fn();
const { result } = renderHook(() => useScribe({ onDisconnect }));

await act(async () => {
await result.current.connect(SESSION);
});
act(() => {
result.current.disconnect();
});
await act(async () => {
await result.current.connect(SESSION);
});

expect(Scribe.connect).toHaveBeenCalledTimes(2);

// The replaced socket only closes now. It must not tear down the
// session that took its place.
act(() => {
closeHandlerFor(first)?.();
});

expect(onDisconnect).not.toHaveBeenCalled();
expect(result.current.status).not.toBe("disconnected");
});

it("still reports a close for the current connection after disconnect()", async () => {
const connection = createMockConnection();
vi.mocked(Scribe.connect).mockReturnValue(connection);

const onDisconnect = vi.fn();
const { result } = renderHook(() => useScribe({ onDisconnect }));

await act(async () => {
await result.current.connect(SESSION);
});

// disconnect() releases the ref before the socket's close arrives, so
// the close still belongs to this session.
act(() => {
result.current.disconnect();
});
act(() => {
closeHandlerFor(connection)?.();
});

expect(onDisconnect).toHaveBeenCalledTimes(1);
expect(result.current.status).toBe("disconnected");
});
});
});
7 changes: 7 additions & 0 deletions packages/react/src/scribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,13 @@ export function useScribe(options: ScribeHookOptions = {}): UseScribeReturn {
});

connection.on(RealtimeEvents.CLOSE, () => {
// A socket that has already been replaced must not tear down the
// session that replaced it. A null ref still runs: disconnect()
// clears it before this fires, and that close is this session's.
if (connectionRef.current && connectionRef.current !== connection) {
return;
}

setStatus("disconnected");
setIsMuted(false);
connectionRef.current = null;
Expand Down
Loading