mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
sherpa-onnx cannot load model files or hotword lists from Windows paths containing non-ASCII characters. - Resolve speech model and hotwords paths to ASCII-safe alternatives like ProgramData when the default userData path contains non-ASCII - Migrate existing downloaded models from the legacy non-ASCII cache - Ensure deleted models are removed from the legacy source folder to prevent them from being re-migrated on subsequent launches
214 lines
7.6 KiB
TypeScript
214 lines
7.6 KiB
TypeScript
import { ipcMain, BrowserWindow, systemPreferences } from 'electron'
|
|
import { join } from 'node:path'
|
|
import { writeFile, unlink } from 'node:fs/promises'
|
|
import { createHash } from 'node:crypto'
|
|
import { SPEECH_MODEL_CATALOG } from '../speech/model-catalog'
|
|
import { deleteLocalSpeechModel } from '../speech/speech-model-deletion'
|
|
import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service'
|
|
import {
|
|
clearOpenAiSpeechApiKey,
|
|
hasOpenAiSpeechApiKey,
|
|
saveOpenAiSpeechApiKey
|
|
} from '../speech/openai-api-key-store'
|
|
import type { Store } from '../persistence'
|
|
|
|
export function registerSpeechHandlers(store: Store): void {
|
|
ipcMain.handle('speech:getCatalog', () => {
|
|
return SPEECH_MODEL_CATALOG
|
|
})
|
|
|
|
ipcMain.handle('speech:getModelStates', async () => {
|
|
return getSpeechModelManager(store).getModelStates()
|
|
})
|
|
|
|
ipcMain.handle('speech:getOpenAiApiKeyStatus', async () => {
|
|
return { configured: hasOpenAiSpeechApiKey() }
|
|
})
|
|
|
|
ipcMain.handle('speech:saveOpenAiApiKey', async (_event, apiKey: string) => {
|
|
saveOpenAiSpeechApiKey(apiKey)
|
|
return { configured: true }
|
|
})
|
|
|
|
ipcMain.handle('speech:clearOpenAiApiKey', async () => {
|
|
clearOpenAiSpeechApiKey()
|
|
return { configured: false }
|
|
})
|
|
|
|
ipcMain.handle('speech:downloadModel', async (event, modelId: string) => {
|
|
const manager = getSpeechModelManager(store)
|
|
const window = BrowserWindow.fromWebContents(event.sender)
|
|
if (!window) {
|
|
return
|
|
}
|
|
const clearProgressCallback = manager.setProgressCallback((id, progress) => {
|
|
if (!window.isDestroyed()) {
|
|
window.webContents.send('speech:downloadProgress', { modelId: id, progress })
|
|
}
|
|
})
|
|
// Why: ModelManager is process-wide; scope this BrowserWindow closure to
|
|
// the download/window lifetime so stale windows are not retained.
|
|
let progressCallbackCleared = false
|
|
const cleanupProgressCallback = (): void => {
|
|
if (progressCallbackCleared) {
|
|
return
|
|
}
|
|
progressCallbackCleared = true
|
|
window.off('closed', cleanupProgressCallback)
|
|
clearProgressCallback()
|
|
}
|
|
window.once('closed', cleanupProgressCallback)
|
|
try {
|
|
await manager.downloadModel(modelId)
|
|
} finally {
|
|
cleanupProgressCallback()
|
|
}
|
|
})
|
|
|
|
ipcMain.handle('speech:cancelDownload', async (_event, modelId: string) => {
|
|
getSpeechModelManager(store).cancelDownload(modelId)
|
|
})
|
|
|
|
ipcMain.handle('speech:deleteModel', async (_event, modelId: string) => {
|
|
await deleteLocalSpeechModel({
|
|
store,
|
|
modelManager: getSpeechModelManager(store),
|
|
sttService: getSpeechSttService(store),
|
|
modelId
|
|
})
|
|
})
|
|
|
|
const getHotwordsFilePath = (content: string): string => {
|
|
const digest = createHash('sha256').update(content).digest('hex').slice(0, 12)
|
|
// Why: sherpa-onnx cannot read non-ASCII Windows paths, so co-locate the
|
|
// hotwords file with the ASCII-safe model cache instead of userData.
|
|
return join(getSpeechModelManager(store).getModelsDir(), `speech-hotwords-${digest}.txt`)
|
|
}
|
|
|
|
const getDesktopOwner = (senderId: number, sessionId: string): string =>
|
|
`desktop:${senderId}:${sessionId}`
|
|
|
|
ipcMain.handle(
|
|
'speech:startDictation',
|
|
async (event, modelId: string, hotwords?: string[], sessionId = 'desktop') => {
|
|
const window = BrowserWindow.fromWebContents(event.sender)
|
|
if (!window) {
|
|
return
|
|
}
|
|
let resolvedHotwordsPath: string | undefined
|
|
let windowClosed = false
|
|
const owner = getDesktopOwner(event.sender.id, sessionId)
|
|
const cleanupOnWindowClosed = (): void => {
|
|
windowClosed = true
|
|
void getSpeechSttService(store)
|
|
.stopDictation(owner)
|
|
.finally(() => {
|
|
if (resolvedHotwordsPath) {
|
|
unlink(resolvedHotwordsPath).catch(() => {})
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
const cleanupSessionListener = (): void => {
|
|
window.off('closed', cleanupOnWindowClosed)
|
|
}
|
|
window.once('closed', cleanupOnWindowClosed)
|
|
|
|
try {
|
|
// Why: on macOS, the Electron binary needs explicit TCC permission for
|
|
// the microphone. Without it, getUserMedia succeeds but returns a silent
|
|
// stream (all zeros). Check status and attempt to trigger the system
|
|
// permission prompt if not yet granted.
|
|
if (process.platform === 'darwin') {
|
|
const micStatus = systemPreferences.getMediaAccessStatus('microphone')
|
|
if (micStatus !== 'granted') {
|
|
await systemPreferences.askForMediaAccess('microphone')
|
|
const newStatus = systemPreferences.getMediaAccessStatus('microphone')
|
|
if (newStatus !== 'granted') {
|
|
throw new Error(
|
|
'Microphone access not granted. In System Settings > Privacy & Security > Microphone, ' +
|
|
'click "+" and add the Electron app, then restart Orca.'
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hotwords && hotwords.length > 0) {
|
|
const content = `${hotwords.map((w) => `${w} :2.0`).join('\n')}\n`
|
|
const hotwordsFilePath = getHotwordsFilePath(content)
|
|
await writeFile(hotwordsFilePath, content, 'utf-8')
|
|
resolvedHotwordsPath = hotwordsFilePath
|
|
}
|
|
|
|
if (windowClosed || window.isDestroyed()) {
|
|
cleanupSessionListener()
|
|
if (resolvedHotwordsPath) {
|
|
unlink(resolvedHotwordsPath).catch(() => {})
|
|
}
|
|
return
|
|
}
|
|
|
|
await getSpeechSttService(store).startDictation(
|
|
modelId,
|
|
(msg) => {
|
|
if (window.isDestroyed()) {
|
|
return
|
|
}
|
|
switch (msg.type) {
|
|
case 'ready':
|
|
window.webContents.send('speech:ready', { sessionId })
|
|
break
|
|
case 'partial':
|
|
window.webContents.send('speech:partial', { text: msg.text ?? '', sessionId })
|
|
break
|
|
case 'final':
|
|
window.webContents.send('speech:final', { text: msg.text ?? '', sessionId })
|
|
break
|
|
case 'stopped':
|
|
cleanupSessionListener()
|
|
window.webContents.send('speech:stopped', { sessionId })
|
|
break
|
|
case 'error':
|
|
window.webContents.send('speech:error', { error: msg.error ?? '', sessionId })
|
|
void getSpeechSttService(store)
|
|
.stopDictation(owner)
|
|
.catch(() => undefined)
|
|
.finally(cleanupSessionListener)
|
|
break
|
|
}
|
|
},
|
|
resolvedHotwordsPath,
|
|
owner
|
|
)
|
|
if (resolvedHotwordsPath) {
|
|
unlink(resolvedHotwordsPath).catch(() => {})
|
|
}
|
|
} catch (err) {
|
|
cleanupSessionListener()
|
|
if (resolvedHotwordsPath) {
|
|
unlink(resolvedHotwordsPath).catch(() => {})
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'speech:feedAudio',
|
|
async (_event, buffer: Buffer, sampleRate: number, sessionId = 'desktop') => {
|
|
// Why: the preload sends audio as a Buffer to avoid Float32Array data
|
|
// being zeroed out during contextBridge + IPC serialization.
|
|
const samples = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4)
|
|
getSpeechSttService(store).feedAudio(
|
|
samples,
|
|
sampleRate,
|
|
getDesktopOwner(_event.sender.id, sessionId)
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle('speech:stopDictation', async (_event, sessionId = 'desktop') => {
|
|
await getSpeechSttService(store).stopDictation(getDesktopOwner(_event.sender.id, sessionId))
|
|
})
|
|
}
|