Add realtime dictation providers (#1329)

* feat: add realtime dictation providers

* fix: cancel realtime dictation startup

* fix: refresh local dictation availability

* fix: preserve dictation on disconnect

* fix: normalize OpenAI language hints
This commit is contained in:
SSU-WEI HUANG
2026-08-03 10:03:06 +08:00
committed by GitHub
parent 3c3bffdfbd
commit c3a5522207
21 changed files with 1378 additions and 46 deletions
+1 -1
View File
@@ -190,7 +190,7 @@ On first run, HAPI:
| `DB_PATH` | `~/.hapi/hapi.db` | - | Database file path |
| `ELEVENLABS_API_KEY` | - | - | ElevenLabs API key for voice |
| `ELEVENLABS_AGENT_ID` | Auto-created | - | Custom ElevenLabs agent ID |
| `OPENAI_API_KEY` | - | - | OpenAI API key for dictation (`gpt-4o-transcribe`) |
| `OPENAI_API_KEY` | - | - | OpenAI API key for dictation (`gpt-transcribe` / `gpt-live-transcribe`) |
| `DEEPGRAM_API_KEY` | - | - | Deepgram API key for dictation (`nova-3`) |
| `GROQ_API_KEY` | - | - | Groq API key for dictation (`whisper-large-v3`) |
| `TRANSCRIPTION_BASE_URL` | - | - | OpenAI-compatible/local transcription base URL |
+5 -4
View File
@@ -2,15 +2,15 @@
Control your AI coding agent with voice using the built-in voice assistant powered by ElevenLabs Conversational AI.
For speech-to-text without a spoken assistant, open **Settings → Voice**, choose **Dictation**, then select a configured provider. Dictation records until you tap the microphone again, inserts the transcript into the composer, and never sends it automatically. Standard mode is the default.
For speech-to-text without a spoken assistant, open **Settings → Voice**, choose **Dictation**, then select a configured provider. Dictation records until you tap the microphone again, inserts the transcript into the composer, and never sends it automatically. Standard mode is the default. Realtime mode shows a live transcript while you speak and inserts the final result when you stop.
Provider credentials are read only from the hub's startup environment:
```bash
# Pick any providers you use
export OPENAI_API_KEY="..." # gpt-4o-transcribe
export ELEVENLABS_API_KEY="..." # scribe_v2
export DEEPGRAM_API_KEY="..." # nova-3
export OPENAI_API_KEY="..." # gpt-transcribe / gpt-live-transcribe
export ELEVENLABS_API_KEY="..." # scribe_v2 / scribe_v2_realtime
export DEEPGRAM_API_KEY="..." # nova-3 standard / realtime
export GROQ_API_KEY="..." # whisper-large-v3
# Or an OpenAI-compatible local server such as Speaches
@@ -20,6 +20,7 @@ export TRANSCRIPTION_API_KEY="..." # optional
```
Restart the hub after changing credentials. API keys are not entered or stored in the web app.
Realtime OpenAI, ElevenLabs, and Deepgram sessions receive only short-lived credentials minted by the hub. Browsers with an installed on-device `SpeechRecognition` language pack also expose **Browser on-device** as a realtime-only provider; HAPI never falls back from that option to browser-hosted recognition.
## Overview
+5 -2
View File
@@ -28,8 +28,8 @@ See `src/configuration.ts` for all options.
- `ELEVENLABS_API_KEY` - ElevenLabs API key for voice assistant.
- `ELEVENLABS_AGENT_ID` - Custom ElevenLabs agent ID (auto-created if not set).
- `OPENAI_API_KEY` - OpenAI dictation (`gpt-4o-transcribe`).
- `DEEPGRAM_API_KEY` - Deepgram dictation (`nova-3`).
- `OPENAI_API_KEY` - OpenAI dictation (`gpt-transcribe` / `gpt-live-transcribe`).
- `DEEPGRAM_API_KEY` - Deepgram dictation (`nova-3`, standard and realtime).
- `GROQ_API_KEY` - Groq dictation (`whisper-large-v3`).
- `TRANSCRIPTION_BASE_URL` and `TRANSCRIPTION_MODEL` - OpenAI-compatible/local transcription endpoint and model.
- `TRANSCRIPTION_API_KEY` - Optional bearer token for the OpenAI-compatible endpoint.
@@ -131,6 +131,9 @@ See `src/web/routes/` for all endpoints.
### Voice (`src/web/routes/voice.ts`)
- `POST /api/voice/token` - Get ElevenLabs conversation token.
- `GET /api/voice/transcription/providers` - List configured providers and supported modes.
- `POST /api/voice/transcription` - Transcribe a bounded recording.
- `POST /api/voice/transcription/realtime-token` - Mint a short-lived OpenAI, ElevenLabs, or Deepgram credential.
### Push Notifications (`src/web/routes/push.ts`)
+80 -3
View File
@@ -100,7 +100,7 @@ describe('voice transcription routes', () => {
const res = await app.request('/api/voice/transcription/providers', { headers })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ providers: [
{ id: 'openai', label: 'OpenAI', modes: ['standard'] },
{ id: 'openai', label: 'OpenAI', modes: ['standard', 'realtime'] },
{ id: 'openai-compatible', label: 'OpenAI-compatible / local', modes: ['standard'] }
] })
@@ -144,8 +144,13 @@ describe('voice transcription routes', () => {
expect(upstreamUrl).toBe('https://api.openai.com/v1/audio/transcriptions')
expect(new Headers(upstreamInit?.headers).get('authorization')).toBe('Bearer server-only-key')
expect(upstreamInit?.body).toBeInstanceOf(FormData)
expect((upstreamInit?.body as FormData).get('model')).toBe('gpt-4o-transcribe')
expect((upstreamInit?.body as FormData).get('language')).toBe('zh')
expect((upstreamInit?.body as FormData).get('model')).toBe('gpt-transcribe')
expect((upstreamInit?.body as FormData).get('languages[]')).toBe('zh-cn')
form.set('language', 'en-US')
const englishRes = await app.request('/api/voice/transcription', { method: 'POST', headers, body: form })
expect(englishRes.status).toBe(200)
expect((upstreamInit?.body as FormData).get('languages[]')).toBe('en')
global.fetch = originalFetch
if (previousKey === undefined) delete process.env.OPENAI_API_KEY
@@ -179,6 +184,78 @@ describe('voice transcription routes', () => {
expect(res.status).toBe(413)
expect(await res.json()).toEqual({ error: 'Audio file too large' })
})
test('mints provider-specific short-lived realtime credentials without exposing API keys', async () => {
const app = createApp()
const headers = { ...(await authHeaders()), 'content-type': 'application/json' }
const previous = {
openai: process.env.OPENAI_API_KEY,
elevenlabs: process.env.ELEVENLABS_API_KEY,
deepgram: process.env.DEEPGRAM_API_KEY
}
process.env.OPENAI_API_KEY = 'openai-server-key'
process.env.ELEVENLABS_API_KEY = 'elevenlabs-server-key'
process.env.DEEPGRAM_API_KEY = 'deepgram-server-key'
const originalFetch = global.fetch
const requests: Array<{ url: string; init?: RequestInit }> = []
// @ts-expect-error test override
global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
requests.push({ url, init })
if (url.endsWith('/realtime/client_secrets')) {
return new Response(JSON.stringify({ value: 'openai-client-token' }), { status: 200 })
}
if (url.endsWith('/single-use-token/realtime_scribe')) {
return new Response(JSON.stringify({ token: 'elevenlabs-client-token' }), { status: 200 })
}
return new Response(JSON.stringify({ access_token: 'deepgram-client-token' }), { status: 200 })
}) as typeof fetch
for (const provider of ['openai', 'elevenlabs', 'deepgram'] as const) {
const res = await app.request('/api/voice/transcription/realtime-token', {
method: 'POST',
headers,
body: JSON.stringify({ provider, language: 'zh-TW' })
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ token: `${provider}-client-token` })
}
const englishOpenAI = await app.request('/api/voice/transcription/realtime-token', {
method: 'POST',
headers,
body: JSON.stringify({ provider: 'openai', language: 'en-US' })
})
expect(englishOpenAI.status).toBe(200)
expect(requests.map((request) => request.url)).toEqual([
'https://api.openai.com/v1/realtime/client_secrets',
'https://api.elevenlabs.io/v1/single-use-token/realtime_scribe',
'https://api.deepgram.com/v1/auth/grant',
'https://api.openai.com/v1/realtime/client_secrets'
])
expect(new Headers(requests[0]?.init?.headers).get('authorization')).toBe('Bearer openai-server-key')
expect(JSON.parse(String(requests[0]?.init?.body))).toMatchObject({
session: {
type: 'transcription',
audio: { input: { transcription: { model: 'gpt-live-transcribe', languages: ['zh-tw'] } } }
}
})
expect(JSON.parse(String(requests[3]?.init?.body))).toMatchObject({
session: { audio: { input: { transcription: { languages: ['en'] } } } }
})
expect(new Headers(requests[1]?.init?.headers).get('xi-api-key')).toBe('elevenlabs-server-key')
expect(new Headers(requests[2]?.init?.headers).get('authorization')).toBe('Token deepgram-server-key')
global.fetch = originalFetch
for (const [key, value] of Object.entries({
OPENAI_API_KEY: previous.openai,
ELEVENLABS_API_KEY: previous.elevenlabs,
DEEPGRAM_API_KEY: previous.deepgram
})) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
})
})
describe('POST /api/voice/token', () => {
+104 -4
View File
@@ -3,7 +3,13 @@ import { bodyLimit } from 'hono/body-limit'
import { z } from 'zod'
import type { WebAppEnv } from '../middleware/auth'
import {
DEEPGRAM_TRANSCRIPTION_MODEL,
ELEVENLABS_REALTIME_TRANSCRIPTION_MODEL,
ELEVENLABS_TRANSCRIPTION_MODEL,
ELEVENLABS_API_BASE,
GROQ_TRANSCRIPTION_MODEL,
OPENAI_REALTIME_TRANSCRIPTION_MODEL,
OPENAI_TRANSCRIPTION_MODEL,
VOICE_AGENT_NAME,
buildVoiceAgentConfig,
listConfiguredTranscriptionProviders,
@@ -43,6 +49,7 @@ const transcriptionProviderSchema = z.enum([
'groq',
'openai-compatible'
])
const realtimeTranscriptionProviderSchema = z.enum(['openai', 'elevenlabs', 'deepgram'])
const transcriptionLanguageSchema = z.string()
.trim()
.max(35)
@@ -52,11 +59,19 @@ const transcriptionLanguageSchema = z.string()
const MAX_TRANSCRIPTION_BYTES = 25 * 1024 * 1024
const MAX_TRANSCRIPTION_BODY_BYTES = MAX_TRANSCRIPTION_BYTES + 1024 * 1024
const TRANSCRIPTION_TIMEOUT_MS = 120_000
const REALTIME_TOKEN_TIMEOUT_MS = 15_000
const MAX_REALTIME_TOKEN_BODY_BYTES = 8 * 1024
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '')
}
function normalizeOpenAILanguage(language?: string): string | undefined {
const value = language?.toLowerCase()
if (!value) return undefined
return ['zh-cn', 'zh-tw', 'zh-hk'].includes(value) ? value : value.split('-')[0]
}
function getTranscriptionConfig(provider: TranscriptionProvider): {
apiKey?: string
baseUrl: string
@@ -65,13 +80,13 @@ function getTranscriptionConfig(provider: TranscriptionProvider): {
if (!listConfiguredTranscriptionProviders(process.env).some((candidate) => candidate.id === provider)) return null
switch (provider) {
case 'openai':
return { apiKey: process.env.OPENAI_API_KEY!.trim(), baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-transcribe' }
return { apiKey: process.env.OPENAI_API_KEY!.trim(), baseUrl: 'https://api.openai.com/v1', model: OPENAI_TRANSCRIPTION_MODEL }
case 'elevenlabs':
return { apiKey: process.env.ELEVENLABS_API_KEY!.trim(), baseUrl: ELEVENLABS_API_BASE, model: 'scribe_v2' }
return { apiKey: process.env.ELEVENLABS_API_KEY!.trim(), baseUrl: ELEVENLABS_API_BASE, model: ELEVENLABS_TRANSCRIPTION_MODEL }
case 'deepgram':
return { apiKey: process.env.DEEPGRAM_API_KEY!.trim(), baseUrl: 'https://api.deepgram.com/v1', model: 'nova-3' }
return { apiKey: process.env.DEEPGRAM_API_KEY!.trim(), baseUrl: 'https://api.deepgram.com/v1', model: DEEPGRAM_TRANSCRIPTION_MODEL }
case 'groq':
return { apiKey: process.env.GROQ_API_KEY!.trim(), baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3' }
return { apiKey: process.env.GROQ_API_KEY!.trim(), baseUrl: 'https://api.groq.com/openai/v1', model: GROQ_TRANSCRIPTION_MODEL }
case 'openai-compatible': {
const baseUrl = process.env.TRANSCRIPTION_BASE_URL?.trim()
const model = process.env.TRANSCRIPTION_MODEL?.trim()
@@ -79,6 +94,8 @@ function getTranscriptionConfig(provider: TranscriptionProvider): {
? { apiKey: process.env.TRANSCRIPTION_API_KEY?.trim(), baseUrl: trimTrailingSlash(baseUrl), model }
: null
}
case 'browser-local':
return null
}
}
@@ -110,6 +127,7 @@ async function transcribeStandard(
form.set(provider === 'elevenlabs' ? 'model_id' : 'model', config.model)
if (baseLanguage) {
if (provider === 'elevenlabs') form.set('language_code', baseLanguage)
else if (provider === 'openai') form.set('languages[]', normalizeOpenAILanguage(language) ?? baseLanguage)
else form.set('language', baseLanguage)
}
url = provider === 'elevenlabs'
@@ -151,6 +169,76 @@ async function transcribeStandard(
}
}
async function createRealtimeTranscriptionToken(
provider: z.infer<typeof realtimeTranscriptionProviderSchema>,
language?: string
): Promise<Response> {
const config = getTranscriptionConfig(provider)
if (!config?.apiKey) return Response.json({ error: `${provider} transcription is not configured` }, { status: 400 })
let url: string
let headers: Record<string, string>
let body: string | undefined
switch (provider) {
case 'openai': {
const openAILanguage = normalizeOpenAILanguage(language)
const languages = openAILanguage ? [openAILanguage] : undefined
url = 'https://api.openai.com/v1/realtime/client_secrets'
headers = { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' }
body = JSON.stringify({
expires_after: { anchor: 'created_at', seconds: 60 },
session: {
type: 'transcription',
audio: {
input: {
transcription: {
model: OPENAI_REALTIME_TRANSCRIPTION_MODEL,
delay: 'low',
...(languages ? { languages } : {})
},
turn_detection: null
}
}
}
})
break
}
case 'elevenlabs':
url = `${ELEVENLABS_API_BASE}/single-use-token/realtime_scribe`
headers = { 'xi-api-key': config.apiKey }
break
case 'deepgram':
url = 'https://api.deepgram.com/v1/auth/grant'
headers = { Authorization: `Token ${config.apiKey}`, 'Content-Type': 'application/json' }
body = '{}'
break
}
try {
const response = await fetch(url, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(REALTIME_TOKEN_TIMEOUT_MS)
})
if (!response.ok) {
console.warn('[Voice][RealtimeTranscription] Token request failed', { provider, status: response.status })
return Response.json({ error: `${provider} realtime transcription token failed (HTTP ${response.status})` }, { status: 502 })
}
const data = await response.json() as { value?: string; token?: string; access_token?: string }
const token = data.value ?? data.token ?? data.access_token
if (!token) return Response.json({ error: `${provider} realtime transcription returned no token` }, { status: 502 })
return Response.json({ token })
} catch (error) {
console.warn('[Voice][RealtimeTranscription] Token request error', {
provider,
error: error instanceof Error ? error.message : String(error)
})
return Response.json({ error: `${provider} realtime transcription token request failed` }, { status: 502 })
}
}
// Cache for auto-created agent IDs (keyed by API key hash)
const agentIdCache = new Map<string, string>()
@@ -404,6 +492,18 @@ export function createVoiceRoutes(): Hono<WebAppEnv> {
return transcribeStandard(provider.data, file, language.data)
})
app.post('/voice/transcription/realtime-token', bodyLimit({
maxSize: MAX_REALTIME_TOKEN_BODY_BYTES,
onError: (c) => c.json({ error: 'Realtime token request too large' }, 413)
}), async (c) => {
const json = await c.req.json().catch(() => null)
const provider = realtimeTranscriptionProviderSchema.safeParse(json?.provider)
const language = transcriptionLanguageSchema.safeParse(json?.language || undefined)
if (!provider.success) return c.json({ error: 'Invalid realtime transcription provider' }, 400)
if (!language.success) return c.json({ error: 'Invalid transcription language' }, 400)
return createRealtimeTranscriptionToken(provider.data, language.data)
})
// Get Gemini API key for Gemini Live voice sessions
// Gemini Live API does not support ephemeral tokens, so we proxy the key.
// The key is short-lived in the browser session and never persisted client-side.
+4 -2
View File
@@ -11,11 +11,13 @@ describe('listConfiguredTranscriptionProviders', () => {
expect(listConfiguredTranscriptionProviders({
OPENAI_API_KEY: 'openai',
ELEVENLABS_API_KEY: 'elevenlabs',
DEEPGRAM_API_KEY: 'deepgram',
TRANSCRIPTION_BASE_URL: 'http://localhost:8000/v1',
TRANSCRIPTION_MODEL: 'whisper-large-v3'
})).toEqual([
{ id: 'openai', label: 'OpenAI', modes: ['standard'] },
{ id: 'elevenlabs', label: 'ElevenLabs', modes: ['standard'] },
{ id: 'openai', label: 'OpenAI', modes: ['standard', 'realtime'] },
{ id: 'elevenlabs', label: 'ElevenLabs', modes: ['standard', 'realtime'] },
{ id: 'deepgram', label: 'Deepgram', modes: ['standard', 'realtime'] },
{ id: 'openai-compatible', label: 'OpenAI-compatible / local', modes: ['standard'] }
])
})
+15 -5
View File
@@ -237,7 +237,14 @@ export type VoiceBackendType = 'elevenlabs' | 'gemini-live' | 'qwen-realtime'
export type VoiceMode = 'assistant' | 'dictation'
export type TranscriptionMode = 'standard' | 'realtime'
export type TranscriptionProvider = 'openai' | 'elevenlabs' | 'deepgram' | 'groq' | 'openai-compatible'
export type TranscriptionProvider = 'openai' | 'elevenlabs' | 'deepgram' | 'groq' | 'openai-compatible' | 'browser-local'
export const OPENAI_TRANSCRIPTION_MODEL = 'gpt-transcribe'
export const OPENAI_REALTIME_TRANSCRIPTION_MODEL = 'gpt-live-transcribe'
export const ELEVENLABS_TRANSCRIPTION_MODEL = 'scribe_v2'
export const ELEVENLABS_REALTIME_TRANSCRIPTION_MODEL = 'scribe_v2_realtime'
export const DEEPGRAM_TRANSCRIPTION_MODEL = 'nova-3'
export const GROQ_TRANSCRIPTION_MODEL = 'whisper-large-v3'
export interface TranscriptionProviderInfo {
id: TranscriptionProvider
@@ -246,13 +253,16 @@ export interface TranscriptionProviderInfo {
}
const TRANSCRIPTION_PROVIDERS: Record<TranscriptionProvider, TranscriptionProviderInfo> = {
openai: { id: 'openai', label: 'OpenAI', modes: ['standard'] },
elevenlabs: { id: 'elevenlabs', label: 'ElevenLabs', modes: ['standard'] },
deepgram: { id: 'deepgram', label: 'Deepgram', modes: ['standard'] },
openai: { id: 'openai', label: 'OpenAI', modes: ['standard', 'realtime'] },
elevenlabs: { id: 'elevenlabs', label: 'ElevenLabs', modes: ['standard', 'realtime'] },
deepgram: { id: 'deepgram', label: 'Deepgram', modes: ['standard', 'realtime'] },
groq: { id: 'groq', label: 'Groq', modes: ['standard'] },
'openai-compatible': { id: 'openai-compatible', label: 'OpenAI-compatible / local', modes: ['standard'] }
'openai-compatible': { id: 'openai-compatible', label: 'OpenAI-compatible / local', modes: ['standard'] },
'browser-local': { id: 'browser-local', label: 'Browser on-device', modes: ['realtime'] }
}
export const BROWSER_LOCAL_TRANSCRIPTION_PROVIDER = TRANSCRIPTION_PROVIDERS['browser-local']
/** Transcription providers whose startup environment is complete. */
export function listConfiguredTranscriptionProviders(env: VoiceBackendEnv): TranscriptionProviderInfo[] {
const providers: TranscriptionProvider[] = []
+1
View File
@@ -85,6 +85,7 @@ See `src/router.tsx` for route definitions.
- ElevenLabs integration (@elevenlabs/react)
- Real-time voice control
- Standard and realtime composer dictation with provider capability selection
### New session (`src/components/NewSession/`)
+12
View File
@@ -972,6 +972,18 @@ export class ApiClient {
return await this.request('/api/voice/transcription', { method: 'POST', body: form })
}
async fetchRealtimeTranscriptionToken(
provider: 'openai' | 'elevenlabs' | 'deepgram',
language?: string,
signal?: AbortSignal
): Promise<{ token: string }> {
return await this.request('/api/voice/transcription/realtime-token', {
method: 'POST',
signal,
body: JSON.stringify({ provider, language })
})
}
async fetchQwenToken(): Promise<{
allowed: boolean
wsUrl?: string
@@ -1632,6 +1632,16 @@ export function HappyComposer(props: {
voiceStatus={effectiveVoiceStatus}
/>
{dictationActive && dictation.partialTranscript ? (
<div
role="status"
aria-live="polite"
className="mb-2 max-h-20 overflow-y-auto rounded-md bg-[var(--app-subtle-bg)] px-3 py-2 text-sm text-[var(--app-fg)]"
>
{dictation.partialTranscript}
</div>
) : null}
{dictationActive && dictation.error ? (
<div role="alert" className="mb-2 rounded-md bg-[var(--app-subtle-bg)] px-3 py-2 text-sm text-red-600">
{dictation.error}
+205
View File
@@ -0,0 +1,205 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startDeepgramRealtimeTranscription, startOpenAIRealtimeTranscription } from './realtimeTranscription'
describe('OpenAI realtime transcription', () => {
afterEach(() => vi.unstubAllGlobals())
it('streams partial text and returns the committed final transcript', async () => {
const stopTrack = vi.fn()
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(async () => ({
getTracks: () => [{ stop: stopTrack }],
getAudioTracks: () => [{ stop: stopTrack }]
}))
}
})
class MockDataChannel extends EventTarget {
readyState = 'open'
send = vi.fn()
close = vi.fn()
}
const channel = new MockDataChannel()
class MockPeerConnection extends EventTarget {
connectionState = 'connected'
createDataChannel() { return channel }
addTrack() {}
async createOffer() { return { type: 'offer', sdp: 'offer-sdp' } }
async setLocalDescription() {}
async setRemoteDescription() {}
close() {}
}
vi.stubGlobal('RTCPeerConnection', MockPeerConnection)
vi.stubGlobal('fetch', vi.fn(async () => new Response('answer-sdp', { status: 200 })))
const callbacks = {
onConnected: vi.fn(),
onPartial: vi.fn(),
onFinal: vi.fn(),
onError: vi.fn()
}
const session = await startOpenAIRealtimeTranscription({
getToken: async () => 'ephemeral-token',
callbacks
})
channel.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({
type: 'conversation.item.input_audio_transcription.delta',
delta: 'live text'
})
}))
expect(callbacks.onPartial).toHaveBeenCalledWith('live text')
const stopping = session.stop()
setTimeout(() => channel.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({
type: 'conversation.item.input_audio_transcription.completed',
transcript: 'final text'
})
})), 0)
await stopping
expect(callbacks.onFinal).toHaveBeenCalledWith('final text')
expect(callbacks.onError).not.toHaveBeenCalled()
expect(stopTrack).toHaveBeenCalled()
})
it('releases the microphone when startup is aborted', async () => {
const stopTrack = vi.fn()
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(async () => ({
getTracks: () => [{ stop: stopTrack }],
getAudioTracks: () => [{ stop: stopTrack }]
}))
}
})
class MockDataChannel extends EventTarget {
readyState = 'connecting'
close() {}
}
class MockPeerConnection extends EventTarget {
connectionState = 'connecting'
createDataChannel() { return new MockDataChannel() }
addTrack() {}
async createOffer() { return { type: 'offer', sdp: 'offer-sdp' } }
async setLocalDescription() {}
close() {}
}
vi.stubGlobal('RTCPeerConnection', MockPeerConnection)
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true })
}))
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
const starting = startOpenAIRealtimeTranscription({
getToken: async () => 'ephemeral-token',
signal: controller.signal,
callbacks: {
onConnected: vi.fn(),
onPartial: vi.fn(),
onFinal: vi.fn(),
onError: vi.fn()
}
})
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled())
controller.abort()
await expect(starting).rejects.toBeDefined()
expect(stopTrack).toHaveBeenCalled()
})
})
describe('Deepgram realtime transcription', () => {
afterEach(() => vi.unstubAllGlobals())
it('streams recorder chunks and returns the finalized transcript', async () => {
const stopTrack = vi.fn()
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(async () => ({
getTracks: () => [{ stop: stopTrack }]
}))
}
})
class MockSocket extends EventTarget {
static OPEN = 1
readyState = 0
sent: unknown[] = []
constructor(readonly url: string, readonly protocols: string[]) {
super()
queueMicrotask(() => {
this.readyState = MockSocket.OPEN
this.dispatchEvent(new Event('open'))
})
}
send(value: unknown) { this.sent.push(value) }
close() { this.readyState = 3 }
result(transcript: string, final: boolean, fromFinalize = false) {
this.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({
type: 'Results',
is_final: final,
from_finalize: fromFinalize,
channel: { alternatives: [{ transcript }] }
})
}))
}
}
const sockets: MockSocket[] = []
class MockWebSocket extends MockSocket {
constructor(url: string, protocols: string[]) {
super(url, protocols)
sockets.push(this)
}
}
Object.assign(MockWebSocket, { OPEN: MockSocket.OPEN })
vi.stubGlobal('WebSocket', MockWebSocket)
class MockRecorder {
static isTypeSupported() { return true }
state = 'inactive'
mimeType = 'audio/webm;codecs=opus'
ondataavailable: ((event: { data: Blob }) => void) | null = null
onerror: (() => void) | null = null
onstop: (() => void) | null = null
start() {
this.state = 'recording'
this.ondataavailable?.({ data: new Blob(['audio']) })
}
stop() {
this.state = 'inactive'
this.onstop?.()
}
}
vi.stubGlobal('MediaRecorder', MockRecorder)
const callbacks = {
onConnected: vi.fn(),
onPartial: vi.fn(),
onFinal: vi.fn(),
onError: vi.fn()
}
const session = await startDeepgramRealtimeTranscription({
getToken: async () => 'temporary-jwt',
callbacks
})
const socket = sockets[0]!
expect(socket.protocols).toEqual(['bearer', 'temporary-jwt'])
socket.result('live', false)
const stopping = session.stop()
socket.result('final text', true, true)
await stopping
expect(callbacks.onPartial).toHaveBeenCalledWith('live')
expect(callbacks.onFinal).toHaveBeenCalledWith('final text')
expect(callbacks.onError).not.toHaveBeenCalled()
expect(stopTrack).toHaveBeenCalled()
})
})
+452
View File
@@ -0,0 +1,452 @@
import { DEEPGRAM_TRANSCRIPTION_MODEL } from '@hapi/protocol/voice'
export interface RealtimeTranscriptionCallbacks {
onConnected: () => void
onPartial: (text: string) => void
onFinal: (text: string) => void
onError: (error: Error) => void
}
export interface RealtimeTranscriptionSession {
stop: () => Promise<void>
cancel: () => void
}
type TokenFactory = () => Promise<string>
function joinTranscriptParts(...parts: string[]): string {
return parts.map((part) => part.trim()).filter(Boolean).join(' ')
}
function errorMessage(value: unknown, fallback: string): Error {
return value instanceof Error ? value : new Error(fallback)
}
export async function startOpenAIRealtimeTranscription(options: {
getToken: TokenFactory
signal?: AbortSignal
callbacks: RealtimeTranscriptionCallbacks
}): Promise<RealtimeTranscriptionSession> {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
})
try {
options.signal?.throwIfAborted()
} catch (error) {
stream.getTracks().forEach((track) => track.stop())
throw error
}
let peer: RTCPeerConnection
let channel: RTCDataChannel
try {
peer = new RTCPeerConnection()
channel = peer.createDataChannel('oai-events')
} catch (error) {
stream.getTracks().forEach((track) => track.stop())
throw error
}
let partial = ''
let finished = false
let stopping = false
let stopTimer: ReturnType<typeof setTimeout> | null = null
const cleanup = () => {
if (stopTimer) clearTimeout(stopTimer)
stopTimer = null
stream.getTracks().forEach((track) => track.stop())
channel.close()
peer.close()
}
const finish = (text: string) => {
if (finished) return
finished = true
cleanup()
options.callbacks.onFinal(text)
}
const fail = (value: unknown) => {
if (finished) return
finished = true
cleanup()
options.callbacks.onError(errorMessage(value, 'OpenAI realtime transcription failed'))
}
try {
const track = stream.getAudioTracks()[0]
if (!track) throw new Error('No microphone audio track is available')
peer.addTrack(track, stream)
channel.addEventListener('message', (event) => {
let data: {
type?: string
delta?: string
transcript?: string
error?: { message?: string }
}
try {
data = JSON.parse(String(event.data)) as typeof data
} catch {
return
}
if (data.type === 'conversation.item.input_audio_transcription.delta' && data.delta) {
partial += data.delta
options.callbacks.onPartial(partial)
} else if (data.type === 'conversation.item.input_audio_transcription.completed') {
finish(data.transcript ?? partial)
} else if (data.type === 'error') {
fail(new Error(data.error?.message || 'OpenAI realtime transcription failed'))
}
})
peer.addEventListener('connectionstatechange', () => {
if (!finished && (peer.connectionState === 'failed' || peer.connectionState === 'closed')) {
fail(new Error('OpenAI realtime transcription disconnected'))
}
})
const offer = await peer.createOffer()
await peer.setLocalDescription(offer)
const token = await options.getToken()
const response = await fetch('https://api.openai.com/v1/realtime/calls', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/sdp'
},
body: offer.sdp,
signal: options.signal
? AbortSignal.any([options.signal, AbortSignal.timeout(15_000)])
: AbortSignal.timeout(15_000)
})
if (!response.ok) throw new Error(`OpenAI realtime connection failed (HTTP ${response.status})`)
await peer.setRemoteDescription({ type: 'answer', sdp: await response.text() })
await new Promise<void>((resolve, reject) => {
if (channel.readyState === 'open') return resolve()
const timeout = setTimeout(() => reject(new Error('OpenAI realtime connection timed out')), 10_000)
const onAbort = () => {
clearTimeout(timeout)
reject(options.signal?.reason)
}
options.signal?.addEventListener('abort', onAbort, { once: true })
channel.addEventListener('open', () => {
clearTimeout(timeout)
options.signal?.removeEventListener('abort', onAbort)
resolve()
}, { once: true })
channel.addEventListener('error', () => {
clearTimeout(timeout)
options.signal?.removeEventListener('abort', onAbort)
reject(new Error('OpenAI realtime connection failed'))
}, { once: true })
})
options.callbacks.onConnected()
} catch (error) {
fail(error)
throw error
}
return {
stop: async () => {
if (finished || stopping) return
stopping = true
stream.getTracks().forEach((track) => track.stop())
if (channel.readyState !== 'open') return finish(partial)
channel.send(JSON.stringify({ type: 'input_audio_buffer.commit' }))
await new Promise<void>((resolve) => {
stopTimer = setTimeout(() => {
clearInterval(check)
finish(partial)
resolve()
}, 2_500)
const check = setInterval(() => {
if (!finished) return
clearInterval(check)
resolve()
}, 25)
})
},
cancel: () => {
if (finished) return
finished = true
cleanup()
}
}
}
export async function startDeepgramRealtimeTranscription(options: {
getToken: TokenFactory
language?: string
signal?: AbortSignal
callbacks: RealtimeTranscriptionCallbacks
}): Promise<RealtimeTranscriptionSession> {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
})
try {
options.signal?.throwIfAborted()
} catch (error) {
stream.getTracks().forEach((track) => track.stop())
throw error
}
let token: string
try {
token = await options.getToken()
} catch (error) {
stream.getTracks().forEach((track) => track.stop())
throw error
}
const query = new URLSearchParams({
model: DEEPGRAM_TRANSCRIPTION_MODEL,
smart_format: 'true',
interim_results: 'true',
endpointing: 'false'
})
if (options.language) query.set('language', options.language)
const mimeType = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus'
].find((type) => typeof MediaRecorder.isTypeSupported !== 'function' || MediaRecorder.isTypeSupported(type))
let socket: WebSocket
let recorder: MediaRecorder
try {
socket = new WebSocket(`wss://api.deepgram.com/v1/listen?${query}`, ['bearer', token])
recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
} catch (error) {
stream.getTracks().forEach((track) => track.stop())
throw error
}
let committed = ''
let interim = ''
let finished = false
let stopping = false
let stopTimer: ReturnType<typeof setTimeout> | null = null
const cleanup = () => {
if (stopTimer) clearTimeout(stopTimer)
stopTimer = null
recorder.ondataavailable = null
recorder.onstop = null
if (recorder.state !== 'inactive') recorder.stop()
stream.getTracks().forEach((track) => track.stop())
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'CloseStream' }))
socket.close()
}
const finish = (text: string) => {
if (finished) return
finished = true
cleanup()
options.callbacks.onFinal(text)
}
const fail = (value: unknown) => {
if (finished) return
finished = true
cleanup()
options.callbacks.onError(errorMessage(value, 'Deepgram realtime transcription failed'))
}
recorder.ondataavailable = (event) => {
if (event.data.size > 0 && socket.readyState === WebSocket.OPEN) socket.send(event.data)
}
recorder.onerror = () => fail(new Error('Audio recording failed'))
recorder.onstop = () => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'Finalize' }))
}
socket.addEventListener('message', (event) => {
let data: {
type?: string
is_final?: boolean
from_finalize?: boolean
channel?: { alternatives?: Array<{ transcript?: string }> }
description?: string
}
try {
data = JSON.parse(String(event.data)) as typeof data
} catch {
return
}
if (data.type === 'Error') return fail(new Error(data.description || 'Deepgram realtime transcription failed'))
if (data.type !== 'Results') return
const text = data.channel?.alternatives?.[0]?.transcript ?? ''
if (data.is_final) {
committed = joinTranscriptParts(committed, text)
interim = ''
} else {
interim = text
}
const current = joinTranscriptParts(committed, interim)
options.callbacks.onPartial(current)
if (stopping && data.is_final && data.from_finalize) finish(current)
})
socket.addEventListener('close', () => {
if (!finished && !stopping) fail(new Error('Deepgram realtime transcription disconnected'))
})
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Deepgram realtime connection timed out')), 10_000)
const onAbort = () => {
clearTimeout(timeout)
reject(options.signal?.reason)
}
options.signal?.addEventListener('abort', onAbort, { once: true })
socket.addEventListener('open', () => {
clearTimeout(timeout)
options.signal?.removeEventListener('abort', onAbort)
recorder.start(250)
options.callbacks.onConnected()
resolve()
}, { once: true })
socket.addEventListener('error', () => {
clearTimeout(timeout)
options.signal?.removeEventListener('abort', onAbort)
reject(new Error('Deepgram realtime connection failed'))
}, { once: true })
}).catch((error) => {
fail(error)
throw error
})
return {
stop: async () => {
if (finished || stopping) return
stopping = true
if (recorder.state !== 'inactive') recorder.stop()
else if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'Finalize' }))
await new Promise<void>((resolve) => {
stopTimer = setTimeout(() => {
clearInterval(check)
finish(joinTranscriptParts(committed, interim))
resolve()
}, 2_500)
const check = setInterval(() => {
if (!finished) return
clearInterval(check)
resolve()
}, 25)
})
},
cancel: () => {
if (finished) return
finished = true
cleanup()
}
}
}
interface LocalSpeechRecognitionResult {
readonly isFinal: boolean
readonly 0: { readonly transcript: string }
}
interface LocalSpeechRecognitionEvent extends Event {
readonly results: { readonly length: number; readonly [index: number]: LocalSpeechRecognitionResult }
}
interface LocalSpeechRecognition extends EventTarget {
continuous: boolean
interimResults: boolean
lang: string
processLocally: boolean
onresult: ((event: LocalSpeechRecognitionEvent) => void) | null
onerror: ((event: Event & { error?: string }) => void) | null
onend: (() => void) | null
start: () => void
stop: () => void
abort: () => void
}
interface LocalSpeechRecognitionConstructor {
new(): LocalSpeechRecognition
prototype: LocalSpeechRecognition
available: (options: { langs: string[]; processLocally: true }) => Promise<string>
}
function localSpeechRecognitionConstructor(): LocalSpeechRecognitionConstructor | null {
const constructor = (globalThis as typeof globalThis & {
SpeechRecognition?: LocalSpeechRecognitionConstructor
}).SpeechRecognition
return constructor
&& typeof constructor.available === 'function'
&& 'processLocally' in constructor.prototype
? constructor
: null
}
export async function startBrowserLocalTranscription(options: {
language?: string
signal?: AbortSignal
callbacks: RealtimeTranscriptionCallbacks
}): Promise<RealtimeTranscriptionSession> {
options.signal?.throwIfAborted()
const constructor = localSpeechRecognitionConstructor()
if (!constructor) throw new Error('On-device speech recognition is not supported by this browser')
const language = options.language || navigator.language
if (await constructor.available({ langs: [language], processLocally: true }) !== 'available') {
throw new Error(`On-device speech recognition is not installed for ${language}`)
}
options.signal?.throwIfAborted()
const recognition = new constructor()
recognition.continuous = true
recognition.interimResults = true
recognition.lang = language
recognition.processLocally = true
let current = ''
let finished = false
let stopping = false
const finish = () => {
if (finished) return
finished = true
options.callbacks.onFinal(current)
}
recognition.onresult = (event) => {
const finalParts: string[] = []
const interimParts: string[] = []
for (let index = 0; index < event.results.length; index += 1) {
const result = event.results[index]
const transcript = result?.[0]?.transcript ?? ''
if (result?.isFinal) finalParts.push(transcript)
else interimParts.push(transcript)
}
current = joinTranscriptParts(...finalParts, ...interimParts)
options.callbacks.onPartial(current)
}
recognition.onerror = (event) => {
if (event.error === 'aborted' && stopping) return
finished = true
options.callbacks.onError(new Error(event.error ? `On-device transcription failed: ${event.error}` : 'On-device transcription failed'))
}
recognition.onend = () => {
if (stopping) finish()
else if (!finished) {
finished = true
options.callbacks.onError(new Error('On-device transcription stopped'))
}
}
recognition.start()
options.callbacks.onConnected()
return {
stop: async () => {
if (finished || stopping) return
stopping = true
recognition.stop()
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
clearInterval(check)
finish()
resolve()
}, 2_500)
const check = setInterval(() => {
if (!finished) return
clearInterval(check)
clearTimeout(timeout)
resolve()
}, 25)
})
},
cancel: () => {
if (finished) return
finished = true
recognition.abort()
}
}
}
+47
View File
@@ -60,4 +60,51 @@ describe('useDictation', () => {
expect(api.transcribeVoice).toHaveBeenCalledOnce()
expect(stopTrack).toHaveBeenCalled()
})
it('shows on-device partial text and inserts only the final transcript', async () => {
let recognition: MockSpeechRecognition | null = null
class MockSpeechRecognition {
static async available() { return 'available' }
continuous = false
interimResults = false
lang = ''
processLocally = false
onresult: ((event: Event & { results: unknown }) => void) | null = null
onerror: ((event: Event) => void) | null = null
onend: (() => void) | null = null
constructor() { recognition = this }
start() {}
stop() { this.onend?.() }
abort() {}
emit(text: string, isFinal: boolean) {
const result = Object.assign([{ transcript: text }], { isFinal })
this.onresult?.({ results: [result] } as unknown as Event & { results: unknown })
}
}
Object.defineProperty(MockSpeechRecognition.prototype, 'processLocally', {
configurable: true,
writable: true,
value: false
})
vi.stubGlobal('SpeechRecognition', MockSpeechRecognition)
const onTextChange = vi.fn()
const { result } = renderHook(() => useDictation({
api: {} as ApiClient,
provider: 'browser-local',
mode: 'realtime',
getCurrentText: () => 'existing draft',
onTextChange
}))
await act(() => result.current.toggle())
act(() => recognition?.emit('live words', false))
expect(result.current.partialTranscript).toBe('live words')
expect(onTextChange).not.toHaveBeenCalled()
act(() => recognition?.emit('final words', true))
await act(() => result.current.toggle())
await waitFor(() => expect(onTextChange).toHaveBeenCalledWith('existing draft final words'))
expect(result.current.partialTranscript).toBe('')
})
})
+16 -4
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { ApiClient } from '@/api/client'
import type { ConversationStatus } from '@/realtime/types'
import type { TranscriptionMode, TranscriptionProvider } from '@hapi/protocol/voice'
import { useRealtimeDictation } from './useRealtimeDictation'
export function appendTranscript(text: string, transcript: string): string {
const addition = transcript.trim()
@@ -33,10 +34,19 @@ export function useDictation(config: {
getCurrentText: () => string
onTextChange: (text: string) => void
}) {
const onFinalTranscript = useCallback((transcript: string) => {
config.onTextChange(appendTranscript(config.getCurrentText(), transcript))
}, [config])
const realtime = useRealtimeDictation({
api: config.api,
provider: config.provider,
mode: config.mode,
onFinalTranscript
})
const browserCanRecord = typeof navigator !== 'undefined'
&& typeof navigator.mediaDevices?.getUserMedia === 'function'
&& typeof MediaRecorder !== 'undefined'
const supported = config.api !== null
const standardSupported = config.api !== null
&& config.provider !== null
&& config.mode === 'standard'
&& browserCanRecord
@@ -55,7 +65,7 @@ export function useDictation(config: {
}, [])
const start = useCallback(async () => {
if (!supported || !config.provider || status === 'connecting' || status === 'connected') return
if (!standardSupported || !config.provider || status === 'connecting' || status === 'connected') return
const operation = ++operationRef.current
const provider = config.provider
const language = localStorage.getItem('hapi-voice-lang') || undefined
@@ -122,7 +132,7 @@ export function useDictation(config: {
setError(startError instanceof Error ? startError.message : 'Could not start transcription')
setStatus('error')
}
}, [config, status, stopTracks, supported])
}, [config, standardSupported, status, stopTracks])
const stop = useCallback(async () => {
if (transcribingRef.current) return
@@ -155,5 +165,7 @@ export function useDictation(config: {
}
}, [stopTracks])
return { supported, status, error, toggle }
return config.mode === 'realtime'
? realtime
: { supported: standardSupported, status, error, partialTranscript: '', toggle }
}
@@ -0,0 +1,61 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ApiClient } from '@/api/client'
import { useRealtimeDictation } from './useRealtimeDictation'
const scribe = vi.hoisted(() => ({
options: null as unknown,
connect: vi.fn(async () => {}),
disconnect: vi.fn(),
commit: vi.fn()
}))
vi.mock('@elevenlabs/react', () => ({
CommitStrategy: { MANUAL: 'manual' },
useScribe: (options: unknown) => {
scribe.options = options
return {
connect: scribe.connect,
disconnect: scribe.disconnect,
commit: scribe.commit
}
}
}))
type ScribeCallbacks = {
onPartialTranscript: (event: { text: string }) => void
onDisconnect: () => void
}
describe('useRealtimeDictation', () => {
afterEach(() => vi.clearAllMocks())
it('preserves partial ElevenLabs text on an unexpected disconnect', async () => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia: vi.fn() }
})
const api = {
fetchRealtimeTranscriptionToken: vi.fn(async () => ({ token: 'single-use-token' }))
} as unknown as ApiClient
const onFinalTranscript = vi.fn()
const { result } = renderHook(() => useRealtimeDictation({
api,
provider: 'elevenlabs',
mode: 'realtime',
onFinalTranscript
}))
await act(() => result.current.toggle())
const callbacks = scribe.options as ScribeCallbacks
act(() => callbacks.onPartialTranscript({ text: 'spoken words' }))
expect(onFinalTranscript).not.toHaveBeenCalled()
act(() => callbacks.onDisconnect())
await waitFor(() => expect(result.current.status).toBe('error'))
expect(result.current.error).toBe('ElevenLabs realtime transcription disconnected')
expect(result.current.partialTranscript).toBe('')
expect(onFinalTranscript).toHaveBeenCalledWith('spoken words')
})
})
+248
View File
@@ -0,0 +1,248 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { CommitStrategy, useScribe } from '@elevenlabs/react'
import {
ELEVENLABS_REALTIME_TRANSCRIPTION_MODEL,
type TranscriptionMode,
type TranscriptionProvider
} from '@hapi/protocol/voice'
import type { ApiClient } from '@/api/client'
import type { ConversationStatus } from '@/realtime/types'
import {
startBrowserLocalTranscription,
startDeepgramRealtimeTranscription,
startOpenAIRealtimeTranscription,
type RealtimeTranscriptionCallbacks,
type RealtimeTranscriptionSession
} from './realtimeTranscription'
function realtimeBrowserSupport(provider: TranscriptionProvider | null): boolean {
if (typeof navigator === 'undefined') return false
if (provider === 'browser-local') return true
if (typeof navigator.mediaDevices?.getUserMedia !== 'function') return false
if (provider === 'openai') return typeof RTCPeerConnection !== 'undefined'
if (provider === 'deepgram') return typeof WebSocket !== 'undefined' && typeof MediaRecorder !== 'undefined'
return provider === 'elevenlabs'
}
export function useRealtimeDictation(config: {
api: ApiClient | null
provider: TranscriptionProvider | null
mode: TranscriptionMode
onFinalTranscript: (text: string) => void
}) {
const supported = config.api !== null
&& config.mode === 'realtime'
&& ['openai', 'elevenlabs', 'deepgram', 'browser-local'].includes(config.provider ?? '')
&& realtimeBrowserSupport(config.provider)
const [status, setStatus] = useState<ConversationStatus>('disconnected')
const [error, setError] = useState<string | null>(null)
const [partialTranscript, setPartialTranscript] = useState('')
const mountedRef = useRef(true)
const operationRef = useRef(0)
const startAbortRef = useRef<AbortController | null>(null)
const sessionRef = useRef<RealtimeTranscriptionSession | null>(null)
const partialRef = useRef('')
const onFinalTranscriptRef = useRef(config.onFinalTranscript)
const elevenLabsActiveRef = useRef(false)
const elevenLabsFinalizedRef = useRef(false)
const resolveElevenLabsCommitRef = useRef<(() => void) | null>(null)
onFinalTranscriptRef.current = config.onFinalTranscript
const updatePartial = useCallback((text: string) => {
partialRef.current = text
if (mountedRef.current) setPartialTranscript(text)
}, [])
const finish = useCallback((text: string) => {
if (!mountedRef.current) return
updatePartial('')
onFinalTranscriptRef.current(text)
setStatus('disconnected')
}, [updatePartial])
const fail = useCallback((value: unknown) => {
if (!mountedRef.current) return
elevenLabsActiveRef.current = false
resolveElevenLabsCommitRef.current?.()
resolveElevenLabsCommitRef.current = null
sessionRef.current?.cancel()
sessionRef.current = null
setError(value instanceof Error ? value.message : 'Realtime transcription failed')
setStatus('error')
}, [])
const elevenLabs = useScribe({
modelId: ELEVENLABS_REALTIME_TRANSCRIPTION_MODEL,
commitStrategy: CommitStrategy.MANUAL,
onSessionStarted: () => {
if (mountedRef.current && elevenLabsActiveRef.current) setStatus('connected')
},
onPartialTranscript: ({ text }) => {
if (elevenLabsActiveRef.current) updatePartial(text)
},
onCommittedTranscript: ({ text }) => {
if (!elevenLabsActiveRef.current) return
elevenLabsFinalizedRef.current = true
elevenLabsActiveRef.current = false
finish(text)
resolveElevenLabsCommitRef.current?.()
resolveElevenLabsCommitRef.current = null
},
onError: (scribeError) => {
if (elevenLabsActiveRef.current) fail(scribeError)
},
onDisconnect: () => {
if (!mountedRef.current || !elevenLabsActiveRef.current) return
const partial = partialRef.current
elevenLabsActiveRef.current = false
resolveElevenLabsCommitRef.current?.()
resolveElevenLabsCommitRef.current = null
sessionRef.current = null
finish(partial)
setError('ElevenLabs realtime transcription disconnected')
setStatus('error')
}
})
const elevenLabsRef = useRef(elevenLabs)
elevenLabsRef.current = elevenLabs
const start = useCallback(async () => {
if (!supported || !config.api || !config.provider || status === 'connecting' || status === 'connected') return
const operation = ++operationRef.current
const controller = new AbortController()
startAbortRef.current = controller
const provider = config.provider
const language = localStorage.getItem('hapi-voice-lang') || undefined
setError(null)
updatePartial('')
setStatus('connecting')
const callbacks: RealtimeTranscriptionCallbacks = {
onConnected: () => {
if (mountedRef.current && operationRef.current === operation) setStatus('connected')
},
onPartial: (text) => {
if (operationRef.current === operation) updatePartial(text)
},
onFinal: (text) => {
if (operationRef.current === operation) finish(text)
},
onError: (realtimeError) => {
if (operationRef.current === operation) fail(realtimeError)
}
}
try {
if (provider === 'elevenlabs') {
const { token } = await config.api.fetchRealtimeTranscriptionToken('elevenlabs', language, controller.signal)
if (operationRef.current !== operation) return
elevenLabsActiveRef.current = true
elevenLabsFinalizedRef.current = false
await elevenLabs.connect({
token,
modelId: ELEVENLABS_REALTIME_TRANSCRIPTION_MODEL,
commitStrategy: CommitStrategy.MANUAL,
languageCode: language?.split('-')[0]?.toLowerCase(),
microphone: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
})
if (operationRef.current !== operation) {
elevenLabsActiveRef.current = false
elevenLabs.disconnect()
return
}
sessionRef.current = {
stop: async () => {
setStatus('connecting')
const committed = new Promise<void>((resolve) => {
resolveElevenLabsCommitRef.current = resolve
})
try {
elevenLabs.commit()
await Promise.race([
committed,
new Promise<void>((resolve) => setTimeout(resolve, 2_500))
])
} finally {
if (elevenLabsActiveRef.current && !elevenLabsFinalizedRef.current) finish(partialRef.current)
elevenLabsActiveRef.current = false
resolveElevenLabsCommitRef.current = null
elevenLabs.disconnect()
}
},
cancel: () => {
elevenLabsActiveRef.current = false
elevenLabs.disconnect()
}
}
return
}
const getToken = async () => {
if (operationRef.current !== operation) throw new Error('Realtime transcription cancelled')
const result = await config.api!.fetchRealtimeTranscriptionToken(
provider as 'openai' | 'deepgram',
language,
controller.signal
)
if (operationRef.current !== operation) throw new Error('Realtime transcription cancelled')
return result.token
}
const session = provider === 'openai'
? await startOpenAIRealtimeTranscription({ getToken, signal: controller.signal, callbacks })
: provider === 'deepgram'
? await startDeepgramRealtimeTranscription({ getToken, language, signal: controller.signal, callbacks })
: await startBrowserLocalTranscription({ language, signal: controller.signal, callbacks })
if (operationRef.current !== operation) session.cancel()
else sessionRef.current = session
} catch (startError) {
if (operationRef.current === operation) fail(startError)
} finally {
if (startAbortRef.current === controller) startAbortRef.current = null
}
}, [config.api, config.provider, elevenLabs, fail, finish, status, supported, updatePartial])
const stop = useCallback(async () => {
const session = sessionRef.current
if (!session) {
operationRef.current += 1
startAbortRef.current?.abort()
startAbortRef.current = null
if (elevenLabsActiveRef.current) {
elevenLabsActiveRef.current = false
elevenLabs.disconnect()
}
setStatus('disconnected')
return
}
setStatus('connecting')
try {
await session.stop()
} catch (stopError) {
fail(stopError)
} finally {
if (sessionRef.current === session) sessionRef.current = null
}
}, [elevenLabs, fail])
const toggle = useCallback(async () => {
if (status === 'connected' || status === 'connecting') await stop()
else await start()
}, [start, status, stop])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
operationRef.current += 1
startAbortRef.current?.abort()
startAbortRef.current = null
elevenLabsActiveRef.current = false
elevenLabsRef.current.disconnect()
resolveElevenLabsCommitRef.current?.()
sessionRef.current?.cancel()
sessionRef.current = null
}
}, [])
return { supported, status, error, partialTranscript, toggle }
}
+47 -16
View File
@@ -1,21 +1,39 @@
import { useCallback, useEffect, useState } from 'react'
import type { ApiClient } from '@/api/client'
import type {
TranscriptionMode,
TranscriptionProvider,
TranscriptionProviderInfo,
VoiceMode
import {
BROWSER_LOCAL_TRANSCRIPTION_PROVIDER,
type TranscriptionMode,
type TranscriptionProvider,
type TranscriptionProviderInfo,
type VoiceMode
} from '@hapi/protocol/voice'
const VOICE_MODE_KEY = 'hapi-voice-mode'
const TRANSCRIPTION_PROVIDER_KEY = 'hapi-transcription-provider'
const TRANSCRIPTION_MODE_KEY = 'hapi-transcription-mode'
const CHANGE_EVENT = 'hapi-voice-input-change'
export const VOICE_LANGUAGE_CHANGE_EVENT = 'hapi-voice-language-change'
function notifyChange(): void {
window.dispatchEvent(new Event(CHANGE_EVENT))
}
async function browserLocalTranscriptionSupported(): Promise<boolean> {
const constructor = (globalThis as typeof globalThis & {
SpeechRecognition?: {
prototype: object
available?: (options: { langs: string[]; processLocally: true }) => Promise<string>
}
}).SpeechRecognition
if (!constructor || typeof constructor.available !== 'function' || !('processLocally' in constructor.prototype)) return false
const language = localStorage.getItem('hapi-voice-lang') || navigator.language
try {
return await constructor.available({ langs: [language], processLocally: true }) === 'available'
} catch {
return false
}
}
function readVoiceMode(): VoiceMode {
return localStorage.getItem(VOICE_MODE_KEY) === 'dictation' ? 'dictation' : 'assistant'
}
@@ -33,7 +51,8 @@ function resolveMode(
stored: string | null
): TranscriptionMode {
const modes = providers.find((candidate) => candidate.id === provider)?.modes ?? ['standard']
return stored === 'realtime' && modes.includes('realtime') ? 'realtime' : 'standard'
if ((stored === 'standard' || stored === 'realtime') && modes.includes(stored)) return stored
return modes[0] ?? 'standard'
}
export function useVoiceInputPreferences(api: ApiClient | null) {
@@ -45,16 +64,28 @@ export function useVoiceInputPreferences(api: ApiClient | null) {
useEffect(() => {
if (!api) return
let cancelled = false
api.fetchTranscriptionProviders().then(({ providers: available }) => {
if (cancelled) return
setProviders(available)
const selectedProvider = resolveProvider(available, localStorage.getItem(TRANSCRIPTION_PROVIDER_KEY))
setProviderState(selectedProvider)
setTranscriptionModeState(resolveMode(available, selectedProvider, localStorage.getItem(TRANSCRIPTION_MODE_KEY)))
}).catch(() => {
if (!cancelled) setProviders([])
})
return () => { cancelled = true }
let request = 0
const refreshProviders = () => {
const current = ++request
Promise.all([api.fetchTranscriptionProviders(), browserLocalTranscriptionSupported()]).then(([{ providers: configured }, browserLocal]) => {
if (cancelled || current !== request) return
const available = browserLocal
? [...configured, BROWSER_LOCAL_TRANSCRIPTION_PROVIDER]
: configured
setProviders(available)
const selectedProvider = resolveProvider(available, localStorage.getItem(TRANSCRIPTION_PROVIDER_KEY))
setProviderState(selectedProvider)
setTranscriptionModeState(resolveMode(available, selectedProvider, localStorage.getItem(TRANSCRIPTION_MODE_KEY)))
}).catch(() => {
if (!cancelled && current === request) setProviders([])
})
}
refreshProviders()
window.addEventListener(VOICE_LANGUAGE_CHANGE_EVENT, refreshProviders)
return () => {
cancelled = true
window.removeEventListener(VOICE_LANGUAGE_CHANGE_EVENT, refreshProviders)
}
}, [api])
useEffect(() => {
+1 -1
View File
@@ -783,7 +783,7 @@ export default {
'settings.voice.transcriptionMode.standard': 'Standard',
'settings.voice.transcriptionMode.standard.hint': 'Record, then transcribe (default)',
'settings.voice.transcriptionMode.realtime': 'Realtime',
'settings.voice.transcriptionMode.realtime.hint': 'Insert text while speaking',
'settings.voice.transcriptionMode.realtime.hint': 'Show live text while speaking; insert the final result when stopped',
'settings.voice.voices.description': 'Choose the voice used by the selected backend.',
'settings.voice.sounds.title': 'How It Sounds',
'settings.voice.responds.title': 'How It Responds',
+1 -1
View File
@@ -787,7 +787,7 @@ export default {
'settings.voice.transcriptionMode.standard': '标准',
'settings.voice.transcriptionMode.standard.hint': '录音结束后转录(默认)',
'settings.voice.transcriptionMode.realtime': '实时',
'settings.voice.transcriptionMode.realtime.hint': '边说边插入文字',
'settings.voice.transcriptionMode.realtime.hint': '说话时显示实时文字,停止后插入最终结果',
'settings.voice.voices.description': '选择当前后端使用的声音。',
'settings.voice.sounds.title': '声音效果',
'settings.voice.responds.title': '对话风格',
@@ -1,10 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import type { TranscriptionProviderInfo } from '@hapi/protocol/voice'
import { I18nProvider } from '@/lib/i18n-context'
import { useVoiceSettings } from './useVoiceSettings'
const { fetchTranscriptionProviders, fetchVoiceBackend, fetchVoices, pause, play } = vi.hoisted(() => ({
fetchTranscriptionProviders: vi.fn(() => Promise.resolve({ providers: [] })),
fetchTranscriptionProviders: vi.fn((): Promise<{ providers: TranscriptionProviderInfo[] }> => Promise.resolve({ providers: [] })),
fetchVoiceBackend: vi.fn(),
fetchVoices: vi.fn(),
pause: vi.fn(),
@@ -26,6 +27,8 @@ function Wrapper(props: { children: React.ReactNode }) {
}
describe('useVoiceSettings', () => {
afterEach(() => vi.unstubAllGlobals())
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
@@ -80,4 +83,60 @@ describe('useVoiceSettings', () => {
unmount()
expect(pause).toHaveBeenCalled()
})
it('uses realtime for the realtime-only browser provider', async () => {
class MockSpeechRecognition {
static available() { return Promise.resolve('available') }
processLocally = false
}
Object.defineProperty(MockSpeechRecognition.prototype, 'processLocally', { value: false })
vi.stubGlobal('SpeechRecognition', MockSpeechRecognition)
localStorage.setItem('hapi-transcription-mode', 'standard')
const { result } = renderHook(() => useVoiceSettings(), { wrapper: Wrapper })
await waitFor(() => expect(result.current.provider).toBe('browser-local'))
expect(result.current.transcriptionMode).toBe('realtime')
})
it('does not expose browser dictation without the selected language pack', async () => {
fetchTranscriptionProviders.mockResolvedValueOnce({
providers: [{ id: 'openai', label: 'OpenAI', modes: ['standard', 'realtime'] }]
})
class MockSpeechRecognition {
static available() { return Promise.resolve('unavailable') }
processLocally = false
}
Object.defineProperty(MockSpeechRecognition.prototype, 'processLocally', { value: false })
vi.stubGlobal('SpeechRecognition', MockSpeechRecognition)
const { result } = renderHook(() => useVoiceSettings(), { wrapper: Wrapper })
await waitFor(() => expect(result.current.provider).toBe('openai'))
expect(result.current.providers).toHaveLength(1)
})
it('rechecks the on-device language pack when the language changes', async () => {
fetchTranscriptionProviders.mockResolvedValue({
providers: [{ id: 'openai', label: 'OpenAI', modes: ['standard', 'realtime'] }]
})
class MockSpeechRecognition {
static available({ langs }: { langs: string[] }) {
return Promise.resolve(langs[0] === 'en-US' ? 'available' : 'unavailable')
}
processLocally = false
}
Object.defineProperty(MockSpeechRecognition.prototype, 'processLocally', { value: false })
vi.stubGlobal('SpeechRecognition', MockSpeechRecognition)
localStorage.setItem('hapi-voice-lang', 'en-US')
localStorage.setItem('hapi-transcription-provider', 'browser-local')
const { result } = renderHook(() => useVoiceSettings(), { wrapper: Wrapper })
await waitFor(() => expect(result.current.provider).toBe('browser-local'))
act(() => result.current.setVoiceLanguage({ code: 'zh-CN', name: 'Chinese', nativeName: '中文' }))
await waitFor(() => expect(result.current.provider).toBe('openai'))
act(() => result.current.setVoiceLanguage({ code: 'en-US', name: 'English', nativeName: 'English' }))
await waitFor(() => expect(result.current.provider).toBe('browser-local'))
})
})
+2 -1
View File
@@ -12,7 +12,7 @@ import {
writeStoredVoiceSelection,
} from '@/lib/voicePickerPreferences'
import type { VoiceBackendType } from '@hapi/protocol/voice'
import { useVoiceInputPreferences } from '@/hooks/useVoiceInputPreferences'
import { useVoiceInputPreferences, VOICE_LANGUAGE_CHANGE_EVENT } from '@/hooks/useVoiceInputPreferences'
export function useVoiceSettings() {
const { api } = useAppContext()
@@ -83,6 +83,7 @@ export function useVoiceSettings() {
setVoiceLanguageState(language.code)
if (language.code === null) localStorage.removeItem('hapi-voice-lang')
else localStorage.setItem('hapi-voice-lang', language.code)
window.dispatchEvent(new Event(VOICE_LANGUAGE_CHANGE_EVENT))
}, [])
const stopPreview = useCallback(() => {