-
Notifications
You must be signed in to change notification settings - Fork 122
H-6763: Evaluate realtime voice interviewing experiments (superseded) #9297
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
kostandinang
wants to merge
19
commits into
main
from
kostandin/h-6763-support-for-realtime-audio-interviewing-of-domain-experts
Closed
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
d1f4226
H-6763: Add the realtime voice experiment shell
kostandinang d66828a
H-6763: Add the OpenAI Realtime voice experiment
kostandinang ad0a210
H-6763: Simplify the voice experiment interface
kostandinang bca938d
H-6763: Add the ElevenLabs Brunch voice experiment
kostandinang ec86f1b
H-6763: Refine voice experiments and add tool diagnostics
kostandinang e527e30
H-6763: Elicit Petri net models in the OpenAI voice experiment
kostandinang ef4c975
H-6763: Integrate voice sessions with the AI prompt
kostandinang 1bfc266
H-6763: Use semantic turn detection for OpenAI realtime
kostandinang 067f2bd
H-6763: Enforce alternating voice interview turns
kostandinang 5e905b6
H-6763: Fix OpenAI realtime turn handoff
kostandinang f7e25f8
H-6763: Connect OpenAI realtime to Brunch elicitation
kostandinang 81e2f25
H-6763: Allow Brunch from the website dev server
kostandinang b1da72e
H-6763: Close the voice interview draft loop
kostandinang c5c1916
H-6763: Rename the mock draft option to projector
kostandinang 53b93e3
H-6763: Project voice interview drafts incrementally
kostandinang 154059c
H-6763: Improve voice interview diagnostics
kostandinang eec6c90
H-6763: Address voice experiment CI findings
kostandinang 1f8fd3a
H-6763: Fix voice interview review findings
kostandinang 2012f05
H-6763: Move voice launcher to the bottom right
kostandinang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| ELEVENLABS_API_KEY=sk_xxxx | ||
| ELEVENLABS_SPEECH_ENGINE_ID=seng_xxxx | ||
| ELEVENLABS_SPEECH_ENGINE_HOST=127.0.0.1 | ||
| ELEVENLABS_SPEECH_ENGINE_PORT=3001 | ||
| BRUNCH_CHAT_ORIGIN=http://127.0.0.1:4321 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { createServer } from "node:http"; | ||
|
|
||
| import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js"; | ||
|
|
||
| import { BrunchVoiceBridge } from "@hashintel/brunch-agent-transport-aisdk/voice-bridge"; | ||
|
|
||
| import { | ||
| applySpeechEngineInterviewConfig, | ||
| createElevenLabsSpeechEngineCallbacks, | ||
| } from "./elevenlabs-speech-engine.ts"; | ||
|
|
||
| const apiKey = process.env.ELEVENLABS_API_KEY; | ||
| const speechEngineId = process.env.ELEVENLABS_SPEECH_ENGINE_ID; | ||
| if (!apiKey || !speechEngineId) { | ||
| throw new Error( | ||
| "ELEVENLABS_API_KEY and ELEVENLABS_SPEECH_ENGINE_ID are required.", | ||
| ); | ||
| } | ||
|
|
||
| const port = Number(process.env.ELEVENLABS_SPEECH_ENGINE_PORT ?? "3001"); | ||
| if (!Number.isInteger(port) || port < 1 || port > 65_535) { | ||
| throw new Error("ELEVENLABS_SPEECH_ENGINE_PORT must be a valid port."); | ||
| } | ||
|
|
||
| const host = process.env.ELEVENLABS_SPEECH_ENGINE_HOST ?? "127.0.0.1"; | ||
| const brunchChatOrigin = | ||
| process.env.BRUNCH_CHAT_ORIGIN ?? "http://127.0.0.1:4321"; | ||
| const chatEndpoint = new URL("/api/chat", brunchChatOrigin).toString(); | ||
|
|
||
| const bridge = new BrunchVoiceBridge({ chatEndpoint }); | ||
| const callbacks = createElevenLabsSpeechEngineCallbacks({ bridge }); | ||
| const elevenLabs = new ElevenLabsClient({ apiKey }); | ||
|
|
||
| await applySpeechEngineInterviewConfig({ | ||
| speechEngine: elevenLabs.speechEngine, | ||
| speechEngineId, | ||
| }); | ||
|
|
||
| console.log( | ||
| "Applied Speech Engine interview config (opening message, patient turn_v3)", | ||
| ); | ||
|
|
||
| const httpServer = createServer((request, response) => { | ||
| if (request.method === "GET" && request.url === "/health") { | ||
| response.writeHead(200, { | ||
| "cache-control": "no-store", | ||
| "content-type": "application/json", | ||
| }); | ||
| response.end(JSON.stringify({ status: "ok" })); | ||
| return; | ||
| } | ||
|
|
||
| response.writeHead(404, { "content-type": "application/json" }); | ||
| response.end(JSON.stringify({ error: "Not found" })); | ||
| }); | ||
|
|
||
| // Authentication remains enabled: the SDK verifies ElevenLabs' signed JWT on | ||
| // every WebSocket upgrade before any transcript can reach Brunch. | ||
| const attachment = elevenLabs.speechEngine.attach( | ||
| speechEngineId, | ||
| httpServer, | ||
| "/ws", | ||
| callbacks, | ||
| ); | ||
|
|
||
| await new Promise<void>((resolve, reject) => { | ||
| httpServer.once("error", reject); | ||
| httpServer.listen(port, host, resolve); | ||
| }); | ||
|
|
||
| console.log( | ||
| `ElevenLabs Speech Engine listening on http://${host}:${port}/ws; Brunch chat is ${chatEndpoint}`, | ||
| ); | ||
|
|
||
| let shuttingDown = false; | ||
| const shutdown = async () => { | ||
| if (shuttingDown) { | ||
| return; | ||
| } | ||
| shuttingDown = true; | ||
| await attachment.close(); | ||
| await new Promise<void>((resolve, reject) => { | ||
| httpServer.close((error) => (error ? reject(error) : resolve())); | ||
| }); | ||
| }; | ||
|
|
||
| for (const signal of ["SIGINT", "SIGTERM"] as const) { | ||
| process.once(signal, () => { | ||
| void shutdown().finally(() => process.exit(0)); | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| import type { SpeechEngineCallbacks } from "@elevenlabs/elevenlabs-js"; | ||
|
|
||
| type TranscriptMessage = { | ||
| content: string; | ||
| role: "agent" | "user"; | ||
| }; | ||
|
|
||
| type VoiceBridge = { | ||
| release(conversationId: string): void; | ||
| respond(input: { | ||
| conversationId: string; | ||
| signal: AbortSignal; | ||
| transcript: string; | ||
| }): AsyncIterable<string>; | ||
| }; | ||
|
|
||
| type CallbackDependencies = { | ||
| bridge: VoiceBridge; | ||
| log?: (reason: string, context?: Record<string, unknown>) => void; | ||
| }; | ||
|
|
||
| type VoiceSession = { | ||
| conversationId?: string; | ||
| sendResponse(response: AsyncIterable<string>): Promise<void>; | ||
| }; | ||
|
|
||
| type QueuedVoiceTurn = { | ||
| session: VoiceSession; | ||
| signal: AbortSignal; | ||
| transcript: string; | ||
| }; | ||
|
|
||
| type VoiceTurnState = { | ||
| active: Promise<void> | null; | ||
| lastCompletedSignal: AbortSignal | null; | ||
| queued: QueuedVoiceTurn | null; | ||
| }; | ||
|
|
||
| const MAX_TRANSCRIPT_CHARACTERS = 12_000; | ||
|
|
||
| const normalizeTranscript = (transcript: string): string => { | ||
| let sanitized = ""; | ||
| for (const character of transcript.normalize("NFKC")) { | ||
| const codePoint = character.codePointAt(0) ?? 0; | ||
| if ( | ||
| codePoint === 9 || | ||
| codePoint === 10 || | ||
| codePoint === 13 || | ||
| codePoint >= 32 | ||
| ) { | ||
| sanitized += character; | ||
| } | ||
| } | ||
|
|
||
| return sanitized | ||
| .replace(/\s+/gu, " ") | ||
| .trim() | ||
| .slice(0, MAX_TRANSCRIPT_CHARACTERS); | ||
| }; | ||
|
|
||
| const latestUserTranscript = (transcript: TranscriptMessage[]): string => { | ||
| for (let index = transcript.length - 1; index >= 0; index -= 1) { | ||
| const message = transcript[index]; | ||
| if (message?.role === "user") { | ||
| return normalizeTranscript(message.content); | ||
| } | ||
| } | ||
| return ""; | ||
| }; | ||
|
|
||
| const defaultLog = (reason: string, context: Record<string, unknown> = {}) => { | ||
| // Provider transcript, response text, and secrets must never be logged here. | ||
| console.error(`[ElevenLabs Speech Engine] ${reason}`, context); | ||
|
|
||
| }; | ||
|
|
||
| export const speechEngineTurnConfig = { | ||
| turnEagerness: "patient", | ||
| turnModel: "turn_v3", | ||
| turnTimeout: 10, | ||
| } as const; | ||
|
|
||
| export const speechEngineOverrides = { | ||
| firstMessage: true, | ||
| } as const; | ||
|
|
||
| type SpeechEngineConfigClient = { | ||
| update( | ||
| speechEngineId: string, | ||
| request: { | ||
| overrides: typeof speechEngineOverrides; | ||
| turn: typeof speechEngineTurnConfig; | ||
| }, | ||
| ): Promise<unknown>; | ||
| }; | ||
|
|
||
| export const applySpeechEngineInterviewConfig = async ({ | ||
| speechEngine, | ||
| speechEngineId, | ||
| }: { | ||
| speechEngine: SpeechEngineConfigClient; | ||
| speechEngineId: string; | ||
| }): Promise<void> => { | ||
| await speechEngine.update(speechEngineId, { | ||
| overrides: speechEngineOverrides, | ||
| turn: speechEngineTurnConfig, | ||
| }); | ||
| }; | ||
|
|
||
| export const createElevenLabsSpeechEngineCallbacks = ({ | ||
| bridge, | ||
| log = defaultLog, | ||
| }: CallbackDependencies): SpeechEngineCallbacks => { | ||
| const turnStateByConversationId = new Map<string, VoiceTurnState>(); | ||
|
|
||
| const startTurn = ( | ||
| conversationId: string, | ||
| state: VoiceTurnState, | ||
| turn: QueuedVoiceTurn, | ||
| ): void => { | ||
| const active = turn.session | ||
| .sendResponse( | ||
| bridge.respond({ | ||
| conversationId, | ||
| signal: turn.signal, | ||
| transcript: turn.transcript, | ||
| }), | ||
| ) | ||
| .then(() => { | ||
| if (!turn.signal.aborted) { | ||
| state.lastCompletedSignal = turn.signal; | ||
| } | ||
| }) | ||
| .catch((error: unknown) => { | ||
| if (!turn.signal.aborted) { | ||
| log("Could not stream the Brunch voice response", { | ||
| errorName: error instanceof Error ? error.name : "unknown", | ||
| }); | ||
| } | ||
| }) | ||
| .finally(() => { | ||
| if (state.active !== active) { | ||
| return; | ||
| } | ||
| state.active = null; | ||
| const queued = state.queued; | ||
| state.queued = null; | ||
| if ( | ||
| queued && | ||
| !queued.signal.aborted && | ||
| queued.signal !== state.lastCompletedSignal | ||
| ) { | ||
| startTurn(conversationId, state, queued); | ||
| } | ||
| }); | ||
| state.active = active; | ||
| }; | ||
|
|
||
| const release = (conversationId: string | undefined): void => { | ||
| if (!conversationId) { | ||
| return; | ||
| } | ||
| turnStateByConversationId.delete(conversationId); | ||
| bridge.release(conversationId); | ||
| }; | ||
|
|
||
| return { | ||
| debug: process.env.NODE_ENV !== "production", | ||
| onTranscript(transcript, signal, session) { | ||
| const conversationId = session.conversationId; | ||
| if (!conversationId) { | ||
| log("Rejected transcript before session initialization"); | ||
| return; | ||
| } | ||
| const userTurn = latestUserTranscript(transcript); | ||
| if (!userTurn) { | ||
| return; | ||
| } | ||
|
|
||
| const state = turnStateByConversationId.get(conversationId) ?? { | ||
| active: null, | ||
| lastCompletedSignal: null, | ||
| queued: null, | ||
| }; | ||
| turnStateByConversationId.set(conversationId, state); | ||
| if (signal === state.lastCompletedSignal) { | ||
| return; | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| const turn = { session, signal, transcript: userTurn }; | ||
| if (state.active) { | ||
| state.queued = turn; | ||
| return; | ||
| } | ||
| startTurn(conversationId, state, turn); | ||
| }, | ||
| onClose(session) { | ||
| release(session.conversationId); | ||
| }, | ||
| onDisconnect(session) { | ||
| release(session.conversationId); | ||
| }, | ||
| onError(error) { | ||
| log("Speech Engine session failed", { errorName: error.name }); | ||
| }, | ||
| }; | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.