diff --git a/app/diagnostics-filter.js b/app/diagnostics-filter.js index 480b4b6c..c453ac3c 100644 --- a/app/diagnostics-filter.js +++ b/app/diagnostics-filter.js @@ -75,6 +75,10 @@ const ARGS_ECHO_REDACTORS = { 'set-remote-ollama-url': redactRest, 'test-remote-ollama': redactRest, 'set-cloud-api-url': redactRest, + // --api-url may embed credentials or a private org hostname (same class as + // set-cloud-api-url). The ASR key never travels via argv — it's env/ + // safeStorage only — so there's nothing key-shaped to redact here. + 'set-openai-asr-config': redactRest, // device_id + a user-assigned device label (e.g. "Valentin's AirPods") — // same PII class as set-user-name. 'set-microphone': redactRest, diff --git a/app/e2e-mock-ipc.js b/app/e2e-mock-ipc.js index 5c639633..2537a5d3 100644 --- a/app/e2e-mock-ipc.js +++ b/app/e2e-mock-ipc.js @@ -271,6 +271,22 @@ function install({ ipcMain }) { engine: process.env.STENOAI_E2E_MOCK_ENGINE || 'parakeet', }), + // OpenAI-compatible ASR config. Shape-only for first paint; the real + // set/get round-trip + key storage is covered by cloud-asr-config.t2. + 'get-openai-asr-config': async () => ({ + success: true, + api_url: 'https://api.openai.com/v1', + api_key_set: false, + model: 'whisper-1', + }), + 'set-openai-asr-config': async () => ({ + success: true, + api_url: 'https://api.openai.com/v1', + api_key_set: false, + model: 'whisper-1', + }), + 'set-openai-asr-key': async () => ({ success: true, api_key_set: true }), + // Default not-installed keeps most T1 specs on their routes; the pill-dock // T1 sets STENOAI_E2E_MOCK_PARAKEET_INSTALLED=1 so App.tsx's first-run // setup gate doesn't redirect it to /setup before it can hit Record. diff --git a/app/main.js b/app/main.js index b35b1a7f..67c344eb 100644 --- a/app/main.js +++ b/app/main.js @@ -3709,7 +3709,7 @@ function spawnLiveTranscribe(sessionName) { liveTranscribeSessionName = null; liveTranscribeStdoutBuf = ''; } - const aiEnv = getAiEnv(); + const aiEnv = { ...getAiEnv(), ...getTranscriptionEnv() }; const env = Object.keys(aiEnv).length > 0 ? { ...require('process').env, ...aiEnv } : undefined; @@ -3983,7 +3983,8 @@ function loadTranscriptionEngine() { if (!fs.existsSync(cfgPath)) return 'parakeet'; const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')); const engine = cfg.transcription_engine; - return engine === 'whisper' ? 'whisper' : 'parakeet'; + if (engine === 'whisper' || engine === 'openai-asr') return engine; + return 'parakeet'; } catch (_) { return 'parakeet'; } @@ -4000,12 +4001,21 @@ function loadTranscriptionContext() { return { engine: 'parakeet', model: 'parakeet', language: 'auto' }; } const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')); - const engine = cfg.transcription_engine === 'whisper' ? 'whisper' : 'parakeet'; - return { - engine, + const rawEngine = cfg.transcription_engine; + const engine = (rawEngine === 'whisper' || rawEngine === 'openai-asr') ? rawEngine : 'parakeet'; + let model; + if (engine === 'whisper') { + model = sanitizeModelForAnalytics(cfg.whisper_model); + } else if (engine === 'openai-asr') { + model = sanitizeModelForAnalytics(cfg.openai_asr_model) || 'whisper-1'; + } else { // Parakeet has no separate user-selectable model today (single bundled // default) -- report the engine name rather than guess a variant id. - model: engine === 'whisper' ? sanitizeModelForAnalytics(cfg.whisper_model) : 'parakeet', + model = 'parakeet'; + } + return { + engine, + model, language: cfg.language || 'auto', }; } catch (_) { @@ -4364,7 +4374,9 @@ async function processNextInQueue() { let transcriptionEndedAtMs = null; try { - const queueAiEnv = getAiEnv(); + // process-streaming does BOTH transcription (needs the ASR key) and + // summarization (needs the AI env), so merge both. + const queueAiEnv = { ...getAiEnv(), ...getTranscriptionEnv() }; const queueEnv = Object.keys(queueAiEnv).length > 0 ? { ...require('process').env, ...queueAiEnv } : undefined; const processArgs = ['process-streaming', currentProcessingJob.audioFile, '--name', currentProcessingJob.sessionName]; if (currentProcessingJob.notesFile && fs.existsSync(currentProcessingJob.notesFile)) { @@ -6511,6 +6523,49 @@ ipcMain.handle('set-transcription-engine', async (event, engine) => { } catch (e) { return { success: false, error: e.message }; } }); +// OpenAI-compatible ASR: the NON-SECRET config (url/model) shells to the CLI +// like set-cloud-api-url does. api_key_set is always overridden with the +// safeStorage truth (hasOpenAiAsrKey) — the CLI only sees the env-var key, +// which isn't injected on these calls, so its own api_key_set is unreliable. +ipcMain.handle('get-openai-asr-config', async () => { + try { + const result = await runPythonScript('simple_recorder.py', ['get-openai-asr-config'], true); + const jsonData = JSON.parse(result.trim()); + jsonData.api_key_set = hasOpenAiAsrKey(); + return jsonData; + } catch (e) { return { success: false, error: e.message }; } +}); + +ipcMain.handle('set-openai-asr-config', async (_event, cfg) => { + try { + const args = ['set-openai-asr-config']; + if (cfg && cfg.api_url !== undefined) { args.push('--api-url', cfg.api_url); } + if (cfg && cfg.model !== undefined) { args.push('--model', cfg.model); } + const result = await runPythonScript('simple_recorder.py', args, true); + const jsonData = JSON.parse(result.trim()); + jsonData.api_key_set = hasOpenAiAsrKey(); + return jsonData; + } catch (e) { return { success: false, error: e.message }; } +}); + +// The SECRET key: stored encrypted via safeStorage (never argv, never +// config.json), mirroring set-cloud-api-key. Passing an empty string clears it +// (deletes the file). +ipcMain.handle('set-openai-asr-key', async (_event, key) => { + try { + if (!key) { + try { + if (fs.existsSync(getOpenAiAsrKeyPath())) fs.unlinkSync(getOpenAiAsrKeyPath()); + } catch (e) { + return { success: false, error: e.message }; + } + return { success: true, api_key_set: false }; + } + const saved = saveOpenAiAsrKey(key); + return { success: saved, api_key_set: saved && hasOpenAiAsrKey() }; + } catch (e) { return { success: false, error: e.message }; } +}); + ipcMain.handle('list-parakeet-models', async () => { try { const result = await runPythonScript('simple_recorder.py', ['list-parakeet-models'], true); @@ -7432,6 +7487,48 @@ function hasCloudApiKey() { return fs.existsSync(getCloudKeyPath()); } +// --- OpenAI-compatible ASR (transcription) API key ------------------------- +// Mirrors the cloud summariser key exactly: encrypted-at-rest via safeStorage, +// stored under getUserDataDir() (honours STENOAI_USER_DATA_DIR test isolation), +// never written to config.json, and injected into the TRANSCRIPTION subprocess +// env as STENOAI_OAI_API_KEY. This is the security-critical difference from the +// upstream PR, which persisted the key in plaintext config.json. +function getOpenAiAsrKeyPath() { + return path.join(getUserDataDir(), '.openai-asr-api-key'); +} + +function saveOpenAiAsrKey(key) { + try { + const keyDir = path.dirname(getOpenAiAsrKeyPath()); + if (!fs.existsSync(keyDir)) { + fs.mkdirSync(keyDir, { recursive: true }); + } + const encrypted = safeStorage.encryptString(key); + fs.writeFileSync(getOpenAiAsrKeyPath(), encrypted); + return true; + } catch (error) { + console.error('Failed to save OpenAI ASR API key:', error.message); + return false; + } +} + +function loadOpenAiAsrKey() { + try { + const keyPath = getOpenAiAsrKeyPath(); + migrateLegacyCredentialFile(keyPath, '.openai-asr-api-key'); + if (!fs.existsSync(keyPath)) return null; + const encrypted = fs.readFileSync(keyPath); + return safeStorage.decryptString(encrypted); + } catch (error) { + console.error('Failed to load OpenAI ASR API key:', error.message); + return null; + } +} + +function hasOpenAiAsrKey() { + return fs.existsSync(getOpenAiAsrKeyPath()); +} + // Build the env additions a Python AI-driven subprocess needs. Merges // the encrypted-on-disk cloud key (decrypted only here, never written // to the env if absent) AND the org adapter URL+JWT when a session @@ -7450,6 +7547,17 @@ function getAiEnv() { return env; } +// Env additions a transcription subprocess needs. The OpenAI-compatible ASR +// key (decrypted from safeStorage only here) is surfaced as STENOAI_OAI_API_KEY +// so the Python transcriber's get_openai_asr_api_key() can read it. Empty when +// no key is set / the engine isn't openai-asr — the Python side no-ops on it. +function getTranscriptionEnv() { + const env = {}; + const oaiKey = loadOpenAiAsrKey(); + if (oaiKey) env.STENOAI_OAI_API_KEY = oaiKey; + return env; +} + // Read the Python-side ai_provider config so we can react to it on sign-in // / sign-out events. Returns 'local' on any error so an unreadable config // can't accidentally keep us in 'adapter' mode after logout. diff --git a/app/preload.js b/app/preload.js index d5a2f0c0..5cff8b53 100644 --- a/app/preload.js +++ b/app/preload.js @@ -226,6 +226,15 @@ const stenoai = { set: (engine) => invoke('set-transcription-engine', engine), }, + openaiAsr: { + getConfig: () => invoke('get-openai-asr-config'), + // cfg may include any subset of { api_url, model } — the key is NOT set + // here; use setKey (safeStorage-backed) for the credential. + setConfig: (cfg) => invoke('set-openai-asr-config', cfg), + // Pass an empty string to clear the stored key. + setKey: (key) => invoke('set-openai-asr-key', key), + }, + settings: { getNotifications: () => invoke('get-notifications'), setNotifications: (v) => invoke('set-notifications', v), diff --git a/app/renderer/src/hooks/useModels.ts b/app/renderer/src/hooks/useModels.ts index 18bda3fe..db2c6341 100644 --- a/app/renderer/src/hooks/useModels.ts +++ b/app/renderer/src/hooks/useModels.ts @@ -27,6 +27,11 @@ export const transcriptionEngineKeys = { current: () => [...transcriptionEngineKeys.all, 'current'] as const, }; +export const openaiAsrKeys = { + all: ['openaiAsrConfig'] as const, + config: () => [...openaiAsrKeys.all, 'config'] as const, +}; + function parseSizeGb(size?: string): number | undefined { if (!size) return undefined; const match = size.match(/^([\d.]+)\s*(GB|MB|KB|B)?$/i); @@ -604,3 +609,46 @@ export function useSetActiveTranscription() { }, }); } + +// --------------------------------------------------------------------------- +// OpenAI-compatible ASR config +// +// The non-secret endpoint config (api_url, model) round-trips through the +// backend config.json. `api_key_set` reflects the encrypted-on-disk key held +// by the main process (safeStorage) — the key value itself is never returned. +// --------------------------------------------------------------------------- + +/** Query for the current OpenAI ASR endpoint config (url, api_key_set, model). */ +export function useOpenAiAsrConfig() { + return useQuery({ + queryKey: openaiAsrKeys.config(), + queryFn: async () => unwrap(await ipc().openaiAsr.getConfig()), + }); +} + +/** + * Mutation to save the non-secret OpenAI ASR config (url and/or model). + * Pass only the fields you want to change; others are left untouched. The + * API key is set separately via useSetOpenAiAsrKey (never through here). + */ +export function useSetOpenAiAsrConfig() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (cfg: { api_url?: string; model?: string }) => + unwrap(await ipc().openaiAsr.setConfig(cfg)), + onSuccess: () => qc.invalidateQueries({ queryKey: openaiAsrKeys.all }), + }); +} + +/** + * Mutation to set (or clear, with an empty string) the OpenAI ASR API key. + * The key is stored encrypted by the main process; only `api_key_set` is + * ever surfaced back. + */ +export function useSetOpenAiAsrKey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (key: string) => unwrap(await ipc().openaiAsr.setKey(key)), + onSuccess: () => qc.invalidateQueries({ queryKey: openaiAsrKeys.all }), + }); +} diff --git a/app/renderer/src/lib/ipc.ts b/app/renderer/src/lib/ipc.ts index a2c20afa..14db1f08 100644 --- a/app/renderer/src/lib/ipc.ts +++ b/app/renderer/src/lib/ipc.ts @@ -478,13 +478,27 @@ export type ParakeetStatusResponse = Result<{ installed: boolean; }>; -export type TranscriptionEngine = 'parakeet' | 'whisper'; +export type TranscriptionEngine = 'parakeet' | 'whisper' | 'openai-asr'; export type GetTranscriptionEngineResponse = Result<{ engine: TranscriptionEngine; valid_engines: TranscriptionEngine[]; }>; +export type GetOpenAiAsrConfigResponse = Result<{ + api_url: string; + api_key_set: boolean; + model: string; +}>; + +export type SetOpenAiAsrConfigResponse = Result<{ + api_url: string; + api_key_set: boolean; + model: string; +}>; + +export type SetOpenAiAsrKeyResponse = Result<{ api_key_set: boolean }>; + export type GetNotificationsResponse = Result<{ notifications_enabled: boolean }>; export type GetTelemetryResponse = Result<{ telemetry_enabled: boolean; @@ -957,6 +971,15 @@ export interface StenoaiBridge { set: RequestFn<[engine: TranscriptionEngine], Result<{ engine: TranscriptionEngine }>>; }; + openaiAsr: { + getConfig: RequestFn<[], GetOpenAiAsrConfigResponse>; + setConfig: RequestFn< + [cfg: { api_url?: string; model?: string }], + SetOpenAiAsrConfigResponse + >; + setKey: RequestFn<[key: string], SetOpenAiAsrKeyResponse>; + }; + settings: { getNotifications: RequestFn<[], GetNotificationsResponse>; setNotifications: RequestFn<[v: boolean], Result>>; diff --git a/app/renderer/src/routes/settings/AiTab.tsx b/app/renderer/src/routes/settings/AiTab.tsx index 98d8e397..8dd987bc 100644 --- a/app/renderer/src/routes/settings/AiTab.tsx +++ b/app/renderer/src/routes/settings/AiTab.tsx @@ -19,7 +19,7 @@ import { GoogleIcon } from '@/components/ui/google-icon'; import { MetaIcon } from '@/components/ui/meta-icon'; import { QwenIcon } from '@/components/ui/qwen-icon'; import { cn } from '@/lib/utils'; -import type { AiProvider, CloudProvider } from '@/lib/ipc'; +import type { AiProvider, CloudProvider, TranscriptionEngine } from '@/lib/ipc'; import { useAiProvider, useSetAiProvider, @@ -37,12 +37,15 @@ import { useCurrentModel, useDeleteModel, useModels, + useOpenAiAsrConfig, useParakeetModels, usePullModel, usePullParakeetModel, usePullWhisperModel, useSetActiveTranscription, useSetCurrentModel, + useSetOpenAiAsrConfig, + useSetOpenAiAsrKey, useSwitchToFasterBuild, useTranscriptionEngine, useWhisperModels, @@ -64,13 +67,6 @@ export function AiTab() { return (
Transcription -

- Speech-to-text always runs on your device — your audio never leaves - your computer. -

Summarisation & Chat @@ -95,7 +91,9 @@ function TranscriptionSection() { const engineQuery = useTranscriptionEngine(); const engine = engineQuery.data ?? 'parakeet'; - const options = engine === 'whisper' ? LANGUAGES_WHISPER : LANGUAGES_PARAKEET; + // Parakeet has the narrower language set; Whisper and the OpenAI-compatible + // cloud ASR (whisper-1 family) both offer the full 99-language list. + const options = engine === 'parakeet' ? LANGUAGES_PARAKEET : LANGUAGES_WHISPER; // useSetActiveTranscription coerces language to 'auto' when switching // to an engine that doesn't support the current pick. So by the time // this renders, persisted is normally in `options`. Edge case (CLI @@ -109,6 +107,14 @@ function TranscriptionSection() { // Retains the pre-merge data-settings-tab="transcription" identity as a // nested wrapper (the page-level section is now data-settings-tab="ai").
+

+ {engine === 'openai-asr' + ? 'Speech-to-text is sent to an OpenAI-compatible cloud endpoint. Unlike the on-device engines, your audio leaves your computer.' + : 'Speech-to-text always runs on your device — your audio never leaves your computer.'} +

= { +const ENGINE_TAGLINE: Record = { parakeet: 'Fastest — English + European languages', whisper: 'Most accurate — 99 languages', + 'openai-asr': 'Cloud API — sends audio to an OpenAI-compatible endpoint', }; /** @@ -183,6 +190,7 @@ function TranscriptionModelList() { const setActive = useSetActiveTranscription(); const pullParakeet = usePullParakeetModel(); const pullWhisper = usePullWhisperModel(); + const [confirmCloudAsr, setConfirmCloudAsr] = React.useState(false); const isLoading = parakeet.isLoading || whisper.isLoading || engine.isLoading; const isError = parakeet.isError || whisper.isError || engine.isError; @@ -235,22 +243,29 @@ function TranscriptionModelList() { const whisperDownloading = pullWhisper.isPending; const downloadingEngine = parakeetDownloading ? 'parakeet' : whisperDownloading ? 'whisper' : null; const isDownloading = downloadingEngine !== null; - const value = downloadingEngine ?? activeEngine; + const value: TranscriptionEngine = downloadingEngine ?? activeEngine; - const options: Array<{ - engine: 'parakeet' | 'whisper'; - model: typeof parakeetModel; - icon: React.ReactNode; - }> = [ - { engine: 'parakeet', model: parakeetModel, icon: }, - { engine: 'whisper', model: whisperModel, icon: }, - ]; - const current = options.find((o) => o.engine === value)!; const whisperPercent = downloadingEngine === 'whisper' ? parsePullPercent(pullWhisper.progress[whisperModel.name]) : null; + // Trigger label: cloud ASR has no local model object, so resolve icon+name + // per engine rather than indexing the model-backed options array (which only + // covers parakeet/whisper). + const triggerFor: Record = { + parakeet: { icon: , name: parakeetModel.displayName ?? parakeetModel.name }, + whisper: { icon: , name: whisperModel.displayName ?? whisperModel.name }, + 'openai-asr': { icon: , name: 'Cloud API' }, + }; + const current = triggerFor[value]; + const onValueChange = (next: string) => { if (next === activeEngine) return; + if (next === 'openai-asr') { + // Switching to the cloud engine sends audio off-device — gate it behind + // an explicit privacy confirmation. + setConfirmCloudAsr(true); + return; + } if (next === 'parakeet') { if (parakeetModel.installed) { setActive.mutate({ engine: 'parakeet' }); @@ -265,46 +280,197 @@ function TranscriptionModelList() { }; return ( - - + + {/* A plain div, not a span: SelectTrigger applies + `[&>span]:line-clamp-1` to any direct-child span, and + line-clamp's `display: -webkit-box` clobbers this row's + `inline-flex`, stacking the icon above the name instead of + beside it. */} +
+ {current.icon} + {current.name} + {isDownloading && + (whisperPercent !== null ? ( + + {whisperPercent}% + + ) : ( + + ))} +
+
+ + - {o.icon} - {o.model.displayName ?? o.model.name} + + {parakeetModel.displayName ?? parakeetModel.name} - ))} - - -
+ + + + {whisperModel.displayName ?? whisperModel.name} + + + + + + Cloud API + + + + +
+ + {activeEngine === 'openai-asr' && } + + { + setConfirmCloudAsr(false); + setActive.mutate({ engine: 'openai-asr' }); + }} + /> + + ); +} + +// Registry defaults, mirrored from src/config.py's _get_default_config. Clearing +// a field resets to these rather than persisting a blank value (the backend +// rejects a blank URL/model — see set_openai_asr_api_url / set_openai_asr_model). +const DEFAULT_OPENAI_ASR_URL = 'https://api.openai.com/v1'; +const DEFAULT_OPENAI_ASR_MODEL = 'whisper-1'; + +/** + * Config sub-panel shown when the OpenAI-compatible cloud ASR engine is + * active. The API URL + model round-trip through config.json; the API key is + * held encrypted by the main process (safeStorage) and only its set/not-set + * state is ever surfaced (`api_key_set`) — the value is never read back. + */ +function OpenAiAsrConfig() { + const config = useOpenAiAsrConfig(); + const setConfig = useSetOpenAiAsrConfig(); + const setKey = useSetOpenAiAsrKey(); + + const [apiUrl, setApiUrl] = React.useState(''); + const [model, setModel] = React.useState(''); + const [apiKey, setApiKey] = React.useState(''); + + React.useEffect(() => { + if (config.data) { + setApiUrl(config.data.api_url); + setModel(config.data.model); + } + }, [config.data?.api_url, config.data?.model]); + + const keySet = config.data?.api_key_set ?? false; + + return ( +
+
+ + setApiUrl(e.target.value)} + placeholder="https://api.openai.com/v1" + onBlur={() => { + // A cleared (or whitespace-only) field resets to the default URL + // rather than trying to persist a blank the backend would reject — + // otherwise the stale value would return on the next refresh. + const next = apiUrl.trim() || DEFAULT_OPENAI_ASR_URL; + if (next !== apiUrl) setApiUrl(next); + setConfig.mutate({ api_url: next }); + }} + className={COMPACT_INPUT} + /> +
+
+ + setModel(e.target.value)} + placeholder="whisper-1" + onBlur={() => { + // Same as the URL: a cleared field resets to the default model + // rather than persisting a blank (which the backend rejects). + const next = model.trim() || DEFAULT_OPENAI_ASR_MODEL; + if (next !== model) setModel(next); + setConfig.mutate({ model: next }); + }} + className={COMPACT_INPUT} + /> +
+
+ +
+ setApiKey(e.target.value)} + placeholder={keySet ? '••••••••' : 'sk-…'} + onBlur={() => { + if (apiKey) { + setKey.mutate(apiKey); + // Don't retain the plaintext key in component state once saved. + setApiKey(''); + } + }} + className={cn(COMPACT_INPUT, 'flex-1')} + /> + {keySet && ( + + )} +
+
+ {keySet + ? 'A key is saved. Enter a new one to replace it, or clear it.' + : 'Stored encrypted on your device — never written to config or sent anywhere except your chosen endpoint.'} +
+
+
); } diff --git a/config.json.lock b/config.json.lock new file mode 100644 index 00000000..e69de29b diff --git a/e2e/specs/cloud-asr-config.t2.spec.ts b/e2e/specs/cloud-asr-config.t2.spec.ts new file mode 100644 index 00000000..093bbd8d --- /dev/null +++ b/e2e/specs/cloud-asr-config.t2.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from '../fixtures/electron'; +import { realUserDataDir, fileSig } from '../fixtures/real-user-data'; +import { readUserConfig } from '../fixtures/user-config'; +import { existsSync } from 'fs'; +import path from 'path'; + +/** + * T2 — OpenAI-compatible cloud ASR config. Drives the real backend's + * `openaiAsr` IPC and asserts both the get/set round-trip and the persisted + * config.json keys. Model-free + deterministic: every call here is a local + * config write or a local safeStorage encryption. No network, no real ASR + * endpoint is ever contacted (that's the whole point — this is the security + + * wiring contract, not a transcription smoke). + * + * Security keystone: the API KEY must NEVER land in config.json. It is stored + * encrypted (safeStorage) under the temp dir, exactly like the cloud + * summariser key. Only the non-secret url/model persist to config. + */ + +type AsrConfig = { + success: boolean; + api_url?: string; + api_key_set?: boolean; + model?: string; + error?: string; +}; +type SetKeyResult = { success: boolean; api_key_set?: boolean; error?: string }; + +type StenoWindow = Window & { + stenoai: { + openaiAsr: { + getConfig: () => Promise; + setConfig: (cfg: { api_url?: string; model?: string }) => Promise; + setKey: (key: string) => Promise; + }; + }; +}; + +const getConfig = (page: import('@playwright/test').Page) => + page.evaluate(() => (window as StenoWindow).stenoai.openaiAsr.getConfig()); + +test('non-secret openai-asr config (url/model) round-trips and persists to config.json', async ({ + launchApp, + userDataDir, +}) => { + const realDirBefore = fileSig(realUserDataDir()); + const { page } = await launchApp(); + + // Fresh config → the registry defaults. + const initial = await getConfig(page); + expect(initial.success).toBe(true); + expect(initial.api_url).toBe('https://api.openai.com/v1'); + expect(initial.model).toBe('whisper-1'); + expect(initial.api_key_set).toBe(false); + + // Set both non-secret fields. + await page.evaluate(() => + (window as StenoWindow).stenoai.openaiAsr.setConfig({ + api_url: 'https://api.groq.example/openai/v1', + model: 'whisper-large-v3', + }), + ); + + // They persist to the right config.json keys... + await expect + .poll(() => { + const cfg = readUserConfig(userDataDir); + return { url: cfg.openai_asr_api_url, model: cfg.openai_asr_model }; + }) + .toEqual({ + url: 'https://api.groq.example/openai/v1', + model: 'whisper-large-v3', + }); + + // ...and round-trip back through the getter. + await expect + .poll(async () => { + const c = await getConfig(page); + return { url: c.api_url, model: c.model }; + }) + .toEqual({ + url: 'https://api.groq.example/openai/v1', + model: 'whisper-large-v3', + }); + + // Keystone: the real user-data dir is byte-for-byte untouched. + expect(fileSig(realUserDataDir())).toBe(realDirBefore); +}); + +test('openai-asr API key is stored encrypted (safeStorage), not in config.json', async ({ + launchApp, + userDataDir, +}) => { + const { app, page } = await launchApp(); + + // The key is persisted via safeStorage; on a headless runner with no usable + // keyring it is unavailable — skip LOUDLY rather than emit a misleading red + // (mirrors ai-provider.t2's cloud-key guard). + const encryptionAvailable = await app.evaluate(({ safeStorage }) => + safeStorage.isEncryptionAvailable(), + ); + if (!encryptionAvailable) { + // eslint-disable-next-line no-console + console.warn('[t2] SKIPPED openai-asr-key: safeStorage unavailable on this runner.'); + test.info().annotations.push({ + type: 'skip-reason', + description: 'safeStorage unavailable; openai-asr key cannot persist on this runner', + }); + } + test.skip(!encryptionAvailable, 'safeStorage unavailable on this runner'); + + const SECRET = 'sk-oai-e2e-secret-987'; + const setResult = await page.evaluate( + (k) => (window as StenoWindow).stenoai.openaiAsr.setKey(k), + SECRET, + ); + expect(setResult.success).toBe(true); + + // Encrypted blob lands in the temp dir... + await expect + .poll(() => existsSync(path.join(userDataDir, '.openai-asr-api-key')), { timeout: 10_000 }) + .toBe(true); + // ...the config reports it set... + await expect.poll(async () => (await getConfig(page)).api_key_set).toBe(true); + // ...and the plaintext key never appears in config.json. + expect(JSON.stringify(readUserConfig(userDataDir))).not.toContain(SECRET); + + // Clearing removes the encrypted file and flips api_key_set back to false. + await page.evaluate(() => (window as StenoWindow).stenoai.openaiAsr.setKey('')); + await expect + .poll(() => existsSync(path.join(userDataDir, '.openai-asr-api-key'))) + .toBe(false); + await expect.poll(async () => (await getConfig(page)).api_key_set).toBe(false); +}); diff --git a/simple_recorder.py b/simple_recorder.py index 01d5ee54..e3346c3e 100644 --- a/simple_recorder.py +++ b/simple_recorder.py @@ -1447,6 +1447,56 @@ def parakeet_status_cmd(): })) +@cli.command(name='get-openai-asr-config') +def get_openai_asr_config_cmd(): + """Return the current OpenAI-compatible ASR endpoint config (non-secret).""" + from src.config import get_config + config = get_config() + print(json.dumps({ + "success": True, + "api_url": config.get_openai_asr_api_url(), + # Never return the actual key. This reflects only whether the env-var + # key is present in THIS process; the Electron main process overrides + # it with its safeStorage-backed hasOpenAiAsrKey() check, which is the + # authoritative source of truth. + "api_key_set": bool(config.get_openai_asr_api_key()), + "model": config.get_openai_asr_model(), + })) + + +@cli.command(name='set-openai-asr-config') +@click.option('--api-url', default=None, help='Base URL of the OpenAI-compatible STT endpoint') +@click.option('--model', default=None, help='Model name (e.g. whisper-1)') +def set_openai_asr_config_cmd(api_url, model): + """Persist OpenAI-compatible ASR endpoint settings (url/model only). + + The API key is intentionally NOT accepted here -- it is a credential and + is stored encrypted by the Electron main process (safeStorage), never + passed through argv or written to config.json. Omit an option to leave it + unchanged. + """ + from src.config import get_config + config = get_config() + errors = [] + + if api_url is not None: + if not config.set_openai_asr_api_url(api_url): + errors.append("Failed to save api_url") + if model is not None: + if not config.set_openai_asr_model(model): + errors.append("Failed to save model") + + if errors: + print(json.dumps({"success": False, "error": "; ".join(errors)})) + else: + print(json.dumps({ + "success": True, + "api_url": config.get_openai_asr_api_url(), + "api_key_set": bool(config.get_openai_asr_api_key()), + "model": config.get_openai_asr_model(), + })) + + @cli.command(name='onnx-selftest') def onnx_selftest_cmd(): """Prove ONNX Runtime's native libraries load + run inside the bundle. diff --git a/src/config.py b/src/config.py index 7a8262af..737a3a57 100644 --- a/src/config.py +++ b/src/config.py @@ -254,7 +254,7 @@ class Config: "yi": "Yiddish", "yo": "Yoruba", "zh": "Chinese", } - VALID_TRANSCRIPTION_ENGINES = ("parakeet", "whisper") + VALID_TRANSCRIPTION_ENGINES = ("parakeet", "whisper", "openai-asr") def __init__(self, config_path: Optional[Path] = None): """ @@ -688,6 +688,16 @@ def _get_default_config(self) -> Dict[str, Any]: "auto_summarize_enabled": True, "whisper_model": "large-v3-turbo", "transcription_engine": "parakeet", + # OpenAI-compatible ASR endpoint settings. + # api_url: base URL of any OpenAI Speech-to-Text compatible server + # (e.g. https://api.openai.com/v1, Groq, Azure, etc.). + # model: model name passed in the multipart form (e.g. whisper-1). + # The API key is NOT stored here -- it is held encrypted by the + # Electron main process (safeStorage) and injected into the + # transcription subprocess env as STENOAI_OAI_API_KEY, exactly like + # the cloud summariser key. See get_openai_asr_api_key(). + "openai_asr_api_url": "https://api.openai.com/v1", + "openai_asr_model": "whisper-1", "version": "1.0" } @@ -1062,8 +1072,10 @@ def set_silence_auto_stop_minutes(self, minutes: int) -> bool: def get_transcription_engine(self) -> str: - """Return the active ASR engine ('parakeet' or 'whisper'). + """Return the active ASR engine. + One of VALID_TRANSCRIPTION_ENGINES: 'parakeet' or 'whisper' (both + on-device) or 'openai-asr' (an OpenAI-compatible cloud endpoint). Falls back to 'parakeet' for unknown values. The renderer's Settings → Transcribe tab writes this; the live VAD pipeline reads it to pick which transcribe_samples() implementation to import. @@ -1099,6 +1111,68 @@ def set_whisper_model(self, model_size: str) -> bool: self._config["whisper_model"] = model_size return self._save() + # ------------------------------------------------------------------ + # OpenAI-compatible ASR endpoint settings + # + # Only the NON-SECRET fields (url, model) live in config.json. The API + # key is a credential and is NEVER persisted here: it is held encrypted + # by the Electron main process (safeStorage) and injected into the + # transcription subprocess env as STENOAI_OAI_API_KEY, exactly like the + # cloud summariser key (get_cloud_api_key). This is deliberate -- a + # plaintext key in config.json would leak into backups, diagnostics, and + # sync. + # ------------------------------------------------------------------ + + def get_openai_asr_api_url(self) -> str: + """Base URL of the OpenAI-compatible STT endpoint. + + Defaults to the official OpenAI endpoint. Users can override with + any compatible server: Groq, Azure OpenAI, local llama.cpp, etc. + The transcriber appends ``/audio/transcriptions`` to this URL. + """ + return self._config.get("openai_asr_api_url", "https://api.openai.com/v1") + + def set_openai_asr_api_url(self, url: str) -> bool: + """Set the base URL for the OpenAI-compatible STT endpoint. + + Rejects a blank/whitespace-only URL (mirrors set_openai_asr_model): an + empty base URL can't form a valid ``/audio/transcriptions`` request, so + persisting it would leave the endpoint unusable. Returns False and keeps + the prior value instead. The renderer treats a cleared field as + "reset to default" and sends the default URL explicitly. + """ + if not url or not url.strip(): + logger.error("openai_asr_api_url must not be empty") + return False + self._config["openai_asr_api_url"] = url.strip() + return self._save() + + def get_openai_asr_api_key(self) -> str: + """Bearer token for the OpenAI-compatible STT endpoint. + + Read from the env var set by Electron via safeStorage (mirrors + get_cloud_api_key). NEVER stored in config.json. Empty string when + unset. + """ + import os + return os.environ.get("STENOAI_OAI_API_KEY", "") + + def get_openai_asr_model(self) -> str: + """Model name passed to the OpenAI-compatible STT endpoint. + + Defaults to ``whisper-1`` (the standard OpenAI Whisper model). + Groq uses ``whisper-large-v3``; other providers vary. + """ + return self._config.get("openai_asr_model", "whisper-1") or "whisper-1" + + def set_openai_asr_model(self, model: str) -> bool: + """Set the model name for the OpenAI-compatible STT endpoint.""" + if not model or not model.strip(): + logger.error("openai_asr_model must not be empty") + return False + self._config["openai_asr_model"] = model.strip() + return self._save() + def get_system_audio_enabled(self) -> bool: """Get whether system audio capture is enabled.""" return self._config.get("system_audio_enabled", True) diff --git a/src/transcriber.py b/src/transcriber.py index 76f8b1a7..ad96fd05 100644 --- a/src/transcriber.py +++ b/src/transcriber.py @@ -455,20 +455,15 @@ class WhisperTranscriber: """ def __init__(self, model_size: str = "large-v3-turbo"): - if not (PARAKEET_AVAILABLE or WHISPER_CPP_AVAILABLE): - raise ImportError( - "No ASR backend available. Need parakeet-mlx (Apple Silicon) " - "or pywhispercpp (cross-platform). Rebuild the PyInstaller " - "bundle or `pip install` the relevant package." - ) - # Kept on the instance so existing callers / logs that read - # ``model_size`` and ``backend`` don't change. Backend selection + # ``model_size`` / ``backend`` are kept on the instance so existing + # callers / logs that read them don't change. Backend selection # respects the user-selected engine from Settings → Transcribe # (Config.get_transcription_engine). Without this, an arm64 user # who picked Whisper would still get Parakeet on the post-stop # pass — live and final would silently use different engines # and the diarised transcript wouldn't match what they previewed - # live. Fallback order when the requested engine isn't installed: + # live. Fallback order when the requested on-device engine isn't + # installed: # * engine='whisper' but pywhispercpp missing → use Parakeet # * engine='parakeet' but parakeet-mlx missing (x64 Macs) → # fall back to whisper.cpp as before @@ -477,23 +472,57 @@ def __init__(self, model_size: str = "large-v3-turbo"): try: from src.config import get_config - requested = get_config().get_transcription_engine() + _cfg = get_config() + requested = _cfg.get_transcription_engine() except Exception: requested = "parakeet" - - if requested == "whisper" and WHISPER_CPP_AVAILABLE: - self.backend = "whisper.cpp" - self._load_whisper_cpp() - elif PARAKEET_AVAILABLE: - self.backend = "parakeet-tdt-v3" + _cfg = None + + if requested == "openai-asr": + # Cloud ASR is a pure-Python (urllib) REST call and needs NO + # bundled local model. The PARAKEET_AVAILABLE / WHISPER_CPP_AVAILABLE + # guard below is therefore deliberately NOT applied here: a user who + # selected + configured the cloud endpoint must be able to + # transcribe even on an install where local ASR can't import + # (missing dylib, pruned bundle). The guard runs only for the + # on-device engines. + self.backend = "openai-asr" + # Read endpoint config once and cache on the instance so the batch + # transcriber doesn't re-read config on every call. The API key is + # env-only (STENOAI_OAI_API_KEY, injected by Electron via + # safeStorage) -- never read from config.json. Fall back to empty + # strings; _run_openai_asr surfaces a useful error if unset. + try: + self._openai_asr_api_url = _cfg.get_openai_asr_api_url() if _cfg else "https://api.openai.com/v1" + self._openai_asr_api_key = _cfg.get_openai_asr_api_key() if _cfg else "" + self._openai_asr_model = _cfg.get_openai_asr_model() if _cfg else "whisper-1" + except Exception as e: + logger.warning("Could not read openai-asr config: %s", e) + self._openai_asr_api_url = "https://api.openai.com/v1" + self._openai_asr_api_key = "" + self._openai_asr_model = "whisper-1" + logger.info("ASR engine selected: requested=openai-asr using=openai-asr") else: - self.backend = "whisper.cpp" - self._load_whisper_cpp() - fallback = (self.backend == "whisper.cpp") != (requested == "whisper") - logger.info( - "ASR engine selected: requested=%s using=%s fallback=%s", - requested, self.backend, fallback, - ) + # On-device engines require a bundled ASR backend to be importable. + if not (PARAKEET_AVAILABLE or WHISPER_CPP_AVAILABLE): + raise ImportError( + "No ASR backend available. Need parakeet-mlx (Apple Silicon) " + "or pywhispercpp (cross-platform). Rebuild the PyInstaller " + "bundle or `pip install` the relevant package." + ) + if requested == "whisper" and WHISPER_CPP_AVAILABLE: + self.backend = "whisper.cpp" + self._load_whisper_cpp() + elif PARAKEET_AVAILABLE: + self.backend = "parakeet-tdt-v3" + else: + self.backend = "whisper.cpp" + self._load_whisper_cpp() + fallback = (self.backend == "whisper.cpp") != (requested == "whisper") + logger.info( + "ASR engine selected: requested=%s using=%s fallback=%s", + requested, self.backend, fallback, + ) self._ensure_ffmpeg_in_path() def _load_whisper_cpp(self) -> None: @@ -664,10 +693,213 @@ def _preprocess_audio(self, audio_filepath: Path) -> Tuple[Path, bool]: def _run_backend(self, audio_filepath: Path, language: str) -> dict: """Dispatch to whichever ASR backend is active for this instance.""" + if self.backend == "openai-asr": + return self._run_openai_asr(audio_filepath, language) if self.backend == "parakeet-tdt-v3": return self._run_parakeet(audio_filepath, language) return self._run_whisper_cpp(audio_filepath, language) + # ------------------------------------------------------------------ + # OpenAI-compatible Speech-to-Text REST backend + # ------------------------------------------------------------------ + + def _run_openai_asr(self, audio_filepath: Path, language: str) -> dict: + """POST audio to an OpenAI-compatible /audio/transcriptions endpoint. + + Uses only Python stdlib (urllib + email) -- no new runtime dependency. + + Return shape is identical to ``_run_parakeet`` / ``_run_whisper_cpp`` + so the rest of the pipeline is unchanged. + + Two-pass strategy: + 1. Try ``response_format=verbose_json`` to get per-segment timestamps. + 2. If the endpoint returns a non-200 or malformed response, fall back + to ``response_format=text`` and synthesise a single full-text + segment with no timestamps. + + Errors surface as a raised exception so ``transcribe_audio``'s outer + try/except records them as ``transcription_failed`` (audio preserved, + reprocessable) rather than silently returning an empty meeting. + """ + import json as _json + import mimetypes + import os + import urllib.error + import urllib.parse + import urllib.request + import uuid + + api_url = (getattr(self, "_openai_asr_api_url", "") or "https://api.openai.com/v1").rstrip("/") + api_key = getattr(self, "_openai_asr_api_key", "") + model = getattr(self, "_openai_asr_model", "") or "whisper-1" + + if "/audio/transcriptions" not in api_url: + if "?" in api_url: + parts = urllib.parse.urlsplit(api_url) + new_path = parts.path.rstrip("/") + "/audio/transcriptions" + endpoint = urllib.parse.urlunsplit((parts.scheme, parts.netloc, new_path, parts.query, parts.fragment)) + else: + endpoint = f"{api_url}/audio/transcriptions" + else: + endpoint = api_url + + logger.info( + "openai-asr: POST %s model=%s file=%s", + endpoint, model, audio_filepath.name, + ) + + if not api_key: + raise RuntimeError( + "openai-asr: No API key configured. " + "Set it in Settings > Transcription > OpenAI-compatible ASR." + ) + + boundary = uuid.uuid4().hex + mime_type = mimetypes.guess_type(str(audio_filepath))[0] or "audio/wav" + + def _field(name: str, value: str) -> bytes: + return ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"\r\n\r\n' + f"{value}\r\n" + ).encode() + + def _file_field_header(name: str, filename: str, ctype: str) -> bytes: + return ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n' + f"Content-Type: {ctype}\r\n\r\n" + ).encode() + + class _MultipartStream: + def __init__(self, response_format: str): + self.prefix_parts = [ + _field("model", model), + _field("response_format", response_format), + ] + if language and language != "auto": + self.prefix_parts.append(_field("language", language)) + self.prefix_parts.append(_file_field_header("file", audio_filepath.name, mime_type)) + + self.prefix_bytes = b"".join(self.prefix_parts) + self.suffix_bytes = f"\r\n--{boundary}--\r\n".encode() + + self.file_size = os.path.getsize(audio_filepath) + self.total_size = len(self.prefix_bytes) + self.file_size + len(self.suffix_bytes) + + def __iter__(self): + yield self.prefix_bytes + with open(audio_filepath, "rb") as fh: + while True: + chunk = fh.read(8192 * 8) + if not chunk: + break + yield chunk + yield self.suffix_bytes + + def __len__(self): + return self.total_size + + headers = { + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Authorization": f"Bearer {api_key}", + } + + # Prevent credential leak to cross-origin redirect targets. + class NoRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, hdrs, newurl): + old_url = urllib.parse.urlsplit(req.full_url) + target_url = urllib.parse.urlsplit(newurl) + if (old_url.scheme, old_url.netloc) == (target_url.scheme, target_url.netloc): + # Same-origin redirect. Replay the original POST with its + # body + headers, avoiding urllib's default of dropping + # POST bodies on redirect. + return urllib.request.Request( + newurl, + data=req.data, + headers=req.headers, + method=req.method, + origin_req_host=req.origin_req_host, + unverifiable=True, + ) + raise urllib.error.HTTPError( + req.full_url, code, f"Cross-origin redirect to {newurl} denied", hdrs, fp + ) + opener = urllib.request.build_opener(NoRedirectHandler) + + def _do_request(response_format: str) -> bytes: + stream = _MultipartStream(response_format) + req = urllib.request.Request( + endpoint, + data=stream, + headers={**headers, "Content-Length": str(len(stream))}, + method="POST", + ) + try: + with opener.open(req, timeout=300) as resp: + return resp.read() + except urllib.error.HTTPError as e: + err_body = e.read().decode(errors="replace")[:500] + raise RuntimeError( + f"openai-asr HTTP {e.code}: {err_body}" + ) from e + + # --- Pass 1: verbose_json (segments + timestamps) --------------- + try: + raw = _do_request("verbose_json") + data = _json.loads(raw.decode()) + # verbose_json shape: {"text": "...", "segments": [...], ...} + raw_text = (data.get("text") or "").strip() + raw_segs = data.get("segments") or [] + detected_lang = data.get("language") or (None if language == "auto" else language) + segments = [ + { + "text": s.get("text", "").strip(), + "start": float(s.get("start") or 0.0), + "end": float(s.get("end") or 0.0), + } + for s in raw_segs + if s.get("text", "").strip() + ] + logger.info( + "openai-asr verbose_json: %d chars, %d segments", + len(raw_text), len(segments), + ) + return { + "text": raw_text or None, + "segments": segments, + "duration_seconds": float(data.get("duration") or 0) or None, + "detected_language": detected_lang, + "detected_language_probability": None, + } + except Exception as primary_err: + fallback = False + if isinstance(primary_err, _json.JSONDecodeError): + fallback = True + elif isinstance(primary_err, RuntimeError) and getattr(primary_err.__cause__, "code", None) in (400, 422, 501): + fallback = True + + if not fallback: + raise + + logger.warning( + "openai-asr verbose_json failed (%s); falling back to text format", + primary_err, + ) + + # --- Pass 2: plain text fallback -------------------------------- + raw = _do_request("text") + text = raw.decode(errors="replace").strip() + detected_lang = None if language == "auto" else language + logger.info("openai-asr text fallback: %d chars", len(text)) + return { + "text": text or None, + "segments": [{"text": text, "start": 0.0, "end": 0.0}] if text else [], + "duration_seconds": None, + "detected_language": detected_lang, + "detected_language_probability": None, + } + def _run_parakeet(self, audio_filepath: Path, language: str) -> dict: """Call into ``src.parakeet`` and normalise the result shape. @@ -1248,16 +1480,46 @@ def transcribe_diarised(self, audio_filepath: Path, language: str = "en") -> Opt # Chronologically interleave segments from both channels and # collapse runs of consecutive same-speaker segments into a # single labelled turn. - tagged: list[tuple[float, str, str]] = [] + # + # Real ASR backends (parakeet / whisper.cpp / the openai-asr + # verbose_json pass) emit per-segment timestamps, so sorting by + # ``start`` orders speakers by who actually spoke first. But the + # openai-asr TEXT-ONLY fallback (an endpoint that doesn't support + # verbose_json) has no timestamps and synthesises a single + # whole-channel segment at start=end=0. Sorting THOSE by start would + # be meaningless: both channels sit at 0, so a stable sort would + # always emit [You] before [Others] regardless of order — and in a + # mixed case a timeless (0.0) channel would leapfrog a real-timed + # one to the front. So: only sort when a channel carries real + # timing, and keep timeless segments in insertion order after any + # timed content rather than fabricating a chronology (and, below, + # omit the misleading [00:00] timestamp for those turns). + def _has_real_timing(segs) -> bool: + # A real segment always advances past 0 (nonzero start or end); + # the text-only fallback's synthetic segment is start=end=0. + return any( + (float(s.get("start") or 0.0) > 0.0) + or (float(s.get("end") or 0.0) > 0.0) + for s in segs + ) + + mic_timed = _has_real_timing(mic_segments) + sys_timed = _has_real_timing(system_segments) + + tagged: list[tuple[float, str, str, bool]] = [] for s in mic_segments: text = (s.get("text") or "").strip() if text: - tagged.append((float(s.get("start") or 0.0), "You", text)) + tagged.append((float(s.get("start") or 0.0), "You", text, mic_timed)) for s in system_segments: text = (s.get("text") or "").strip() if text: - tagged.append((float(s.get("start") or 0.0), "Others", text)) - tagged.sort(key=lambda t: t[0]) + tagged.append((float(s.get("start") or 0.0), "Others", text, sys_timed)) + if mic_timed or sys_timed: + # Timed segments sort by start; timeless (synthetic 0.0) + # segments sort to the end (not the front), stable within each + # group. ``not timed`` → False(0) before True(1). + tagged.sort(key=lambda t: (not t[3], t[0])) # Each turn carries the start offset of its FIRST segment so the # diarised transcript can be timestamped. Only diarised_text is @@ -1267,21 +1529,27 @@ def transcribe_diarised(self, audio_filepath: Path, language: str = "en") -> Opt # or transcript_text), so the summariser strips these [MM:SS] markers # back out on the way in (summarizer._strip_leading_timestamps) — # summarisation is unaffected by this display feature. - turns: list[tuple[float, str, list[str]]] = [] - for start, speaker, text in tagged: + turns: list[tuple[float, str, list[str], bool]] = [] + for start, speaker, text, timed in tagged: if turns and turns[-1][1] == speaker: turns[-1][2].append(text) else: - turns.append((start, speaker, [text])) + turns.append((start, speaker, [text], timed)) - plain_parts = [' '.join(parts) for _start, _speaker, parts in turns] + plain_parts = [' '.join(parts) for _start, _speaker, parts, _timed in turns] plain_text = "\n\n".join(plain_parts) if plain_parts else SILENCE_SENTINEL is_diarised = bool(mic_segments) and bool(system_segments) if is_diarised: + # Emit the [MM:SS] prefix only for turns whose channel carried + # real timing. A timeless turn (text-only endpoint) gets the + # speaker label but no fabricated timestamp — the renderer's + # parser already handles timestamp-less diarised lines. labelled_parts = [ - f"[{_format_timestamp(start)}] [{speaker}] {' '.join(parts)}" - for start, speaker, parts in turns + (f"[{_format_timestamp(start)}] [{speaker}] {' '.join(parts)}" + if timed + else f"[{speaker}] {' '.join(parts)}") + for start, speaker, parts, timed in turns ] diarised_text = "\n\n".join(labelled_parts) else: diff --git a/tests/test_config.py b/tests/test_config.py index 21e16df0..65513268 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -143,6 +143,40 @@ def test_get_whisper_model_falls_back_when_stored_value_invalid(self): self.assertEqual(config.get_whisper_model(), "large-v3-turbo") +class ConfigOpenAiAsrTests(unittest.TestCase): + def test_defaults_on_fresh_config(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config = Config(config_path=Path(tmp_dir) / "config.json") + self.assertEqual(config.get_openai_asr_api_url(), "https://api.openai.com/v1") + self.assertEqual(config.get_openai_asr_model(), "whisper-1") + + def test_set_api_url_persists_and_strips(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / "config.json" + config = Config(config_path=path) + self.assertTrue(config.set_openai_asr_api_url(" https://api.groq.example/openai/v1 ")) + self.assertEqual(config.get_openai_asr_api_url(), "https://api.groq.example/openai/v1") + reloaded = Config(config_path=path) + self.assertEqual(reloaded.get_openai_asr_api_url(), "https://api.groq.example/openai/v1") + + def test_set_api_url_rejects_blank_and_keeps_prior(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config = Config(config_path=Path(tmp_dir) / "config.json") + self.assertTrue(config.set_openai_asr_api_url("https://custom.example/v1")) + # A blank / whitespace-only URL is rejected; the prior value stays. + self.assertFalse(config.set_openai_asr_api_url("")) + self.assertFalse(config.set_openai_asr_api_url(" ")) + self.assertEqual(config.get_openai_asr_api_url(), "https://custom.example/v1") + + def test_set_model_rejects_blank_and_keeps_prior(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config = Config(config_path=Path(tmp_dir) / "config.json") + self.assertTrue(config.set_openai_asr_model("whisper-large-v3")) + self.assertFalse(config.set_openai_asr_model("")) + self.assertFalse(config.set_openai_asr_model(" ")) + self.assertEqual(config.get_openai_asr_model(), "whisper-large-v3") + + class ConfigSummaryModelTests(unittest.TestCase): def test_default_model_is_gemma4_e2b(self): self.assertEqual(Config.DEFAULT_MODEL, "gemma4:e2b-it-qat") diff --git a/tests/test_transcriber_diarisation.py b/tests/test_transcriber_diarisation.py index ba86bd39..52faf027 100644 --- a/tests/test_transcriber_diarisation.py +++ b/tests/test_transcriber_diarisation.py @@ -111,6 +111,26 @@ def test_single_source_is_not_timestamped_or_diarised(self): self.assertFalse(result["is_diarised"]) self.assertIsNone(result["diarised_text"]) + def test_textonly_fallback_has_no_fabricated_timestamps(self): + # An OpenAI-compatible text-only endpoint yields no per-segment + # timestamps; each channel returns a single whole-channel segment at + # start=end=0. Sorting those by start would collapse both channels to + # time 0 and always emit [You] before [Others]. Instead we keep the + # speaker labels but omit the fabricated [00:00] timestamp so we don't + # silently imply a chronology we don't actually have. + self.transcriber.transcribe_audio = Mock(side_effect=[ + {"text": "Hi from me.", "segments": [{"text": "Hi from me.", "start": 0.0, "end": 0.0}]}, + {"text": "And from them.", "segments": [{"text": "And from them.", "start": 0.0, "end": 0.0}]}, + ]) + result = self.transcriber.transcribe_diarised(self.audio_path) + self.assertTrue(result["is_diarised"]) + self.assertEqual( + result["diarised_text"], + "[You] Hi from me.\n\n[Others] And from them.", + ) + # No fabricated [MM:SS] marker anywhere in the diarised text. + self.assertNotIn("[00:00]", result["diarised_text"]) + class TokenJaccardTests(unittest.TestCase): def test_identical_strings_score_one(self):