diff --git a/README.md b/README.md index 6abd4aea..a753902c 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,10 @@ If you're looking for a hosted desktop recording API, consider checking out [Recall.ai](https://www.recall.ai/product/desktop-recording-sdk?utm_source=github&utm_medium=sponsorship&utm_campaign=ruzin-stenoai), an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more. ## 📢 What's New -- **2026-07-24** ☁️ Cloud transcription — transcribe with any OpenAI-compatible speech-to-text endpoint alongside on-device Parakeet and Whisper; your API key is stored encrypted on-device. - **2026-07-24** 🔎 Settings search — press ⌘K on the Settings screen to search every setting and jump straight to the right tab. - **2026-07-24** 🔔 Cleaner notifications — meeting and note alerts now show as Steno's own in-app toast instead of native OS notifications. -- **2026-07-24** ✨ Meeting-list polish — notes show a letter avatar instead of a generic file icon, and Calendar settings show your connected account. +- **2026-07-24** ✨ Letter avatars — notes in the Previous list show their initial instead of a generic file icon. +- **2026-07-24** 📅 Connected calendar — Calendar settings now show the account you've connected. ## Features diff --git a/app/diagnostics-filter.js b/app/diagnostics-filter.js index c453ac3c..480b4b6c 100644 --- a/app/diagnostics-filter.js +++ b/app/diagnostics-filter.js @@ -75,10 +75,6 @@ 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 2537a5d3..5c639633 100644 --- a/app/e2e-mock-ipc.js +++ b/app/e2e-mock-ipc.js @@ -271,22 +271,6 @@ 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 bed522e2..6b2ca5a8 100644 --- a/app/main.js +++ b/app/main.js @@ -3857,7 +3857,7 @@ function spawnLiveTranscribe(sessionName) { liveTranscribeSessionName = null; liveTranscribeStdoutBuf = ''; } - const aiEnv = { ...getAiEnv(), ...getTranscriptionEnv() }; + const aiEnv = getAiEnv(); const env = Object.keys(aiEnv).length > 0 ? { ...require('process').env, ...aiEnv } : undefined; @@ -4131,8 +4131,7 @@ function loadTranscriptionEngine() { if (!fs.existsSync(cfgPath)) return 'parakeet'; const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')); const engine = cfg.transcription_engine; - if (engine === 'whisper' || engine === 'openai-asr') return engine; - return 'parakeet'; + return engine === 'whisper' ? 'whisper' : 'parakeet'; } catch (_) { return 'parakeet'; } @@ -4149,21 +4148,12 @@ function loadTranscriptionContext() { return { engine: 'parakeet', model: 'parakeet', language: 'auto' }; } const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')); - 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 = 'parakeet'; - } + const engine = cfg.transcription_engine === 'whisper' ? 'whisper' : 'parakeet'; return { engine, - model, + // 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', language: cfg.language || 'auto', }; } catch (_) { @@ -4522,9 +4512,7 @@ async function processNextInQueue() { let transcriptionEndedAtMs = null; try { - // process-streaming does BOTH transcription (needs the ASR key) and - // summarization (needs the AI env), so merge both. - const queueAiEnv = { ...getAiEnv(), ...getTranscriptionEnv() }; + const queueAiEnv = getAiEnv(); 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)) { @@ -6672,49 +6660,6 @@ 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); @@ -7639,48 +7584,6 @@ 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 @@ -7699,17 +7602,6 @@ 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 1013a54f..411c5fd9 100644 --- a/app/preload.js +++ b/app/preload.js @@ -226,15 +226,6 @@ 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 db2c6341..18bda3fe 100644 --- a/app/renderer/src/hooks/useModels.ts +++ b/app/renderer/src/hooks/useModels.ts @@ -27,11 +27,6 @@ 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); @@ -609,46 +604,3 @@ 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 89b64dc8..f6fa432c 100644 --- a/app/renderer/src/lib/ipc.ts +++ b/app/renderer/src/lib/ipc.ts @@ -478,27 +478,13 @@ export type ParakeetStatusResponse = Result<{ installed: boolean; }>; -export type TranscriptionEngine = 'parakeet' | 'whisper' | 'openai-asr'; +export type TranscriptionEngine = 'parakeet' | 'whisper'; 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; @@ -971,15 +957,6 @@ 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 8dd987bc..98d8e397 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, TranscriptionEngine } from '@/lib/ipc'; +import type { AiProvider, CloudProvider } from '@/lib/ipc'; import { useAiProvider, useSetAiProvider, @@ -37,15 +37,12 @@ import { useCurrentModel, useDeleteModel, useModels, - useOpenAiAsrConfig, useParakeetModels, usePullModel, usePullParakeetModel, usePullWhisperModel, useSetActiveTranscription, useSetCurrentModel, - useSetOpenAiAsrConfig, - useSetOpenAiAsrKey, useSwitchToFasterBuild, useTranscriptionEngine, useWhisperModels, @@ -67,6 +64,13 @@ export function AiTab() { return (
Transcription +

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

Summarisation & Chat @@ -91,9 +95,7 @@ function TranscriptionSection() { const engineQuery = useTranscriptionEngine(); const engine = engineQuery.data ?? '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; + const options = engine === 'whisper' ? LANGUAGES_WHISPER : LANGUAGES_PARAKEET; // 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 @@ -107,14 +109,6 @@ 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' | 'whisper', string> = { parakeet: 'Fastest — English + European languages', whisper: 'Most accurate — 99 languages', - 'openai-asr': 'Cloud API — sends audio to an OpenAI-compatible endpoint', }; /** @@ -190,7 +183,6 @@ 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; @@ -243,29 +235,22 @@ function TranscriptionModelList() { const whisperDownloading = pullWhisper.isPending; const downloadingEngine = parakeetDownloading ? 'parakeet' : whisperDownloading ? 'whisper' : null; const isDownloading = downloadingEngine !== null; - const value: TranscriptionEngine = downloadingEngine ?? activeEngine; + const value = 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' }); @@ -280,197 +265,46 @@ 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.model.displayName ?? current.model.name} + {isDownloading && + (whisperPercent !== null ? ( + + {whisperPercent}% + + ) : ( + + ))} +
+
+ + {options.map((o) => ( + - - Cloud API + {o.icon} + {o.model.displayName ?? o.model.name} - - -
- - {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/docs/changelog.mdx b/docs/changelog.mdx index 33f75bd1..db9797f3 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -5,7 +5,6 @@ description: "What's new in Steno -- recent releases and notable changes." **New** -- **Cloud transcription** — Transcribe with any OpenAI-compatible speech-to-text endpoint alongside on-device Parakeet and Whisper. Your API key is stored encrypted on your device, never in plain config. - **Settings search** — Press ⌘K on the Settings screen to search every setting and jump straight to the tab it lives on. - Notes in the Previous list now show a letter avatar (the note's initial) instead of a generic file icon. - Calendar settings now show the account you've connected. diff --git a/e2e/specs/cloud-asr-config.t2.spec.ts b/e2e/specs/cloud-asr-config.t2.spec.ts deleted file mode 100644 index 093bbd8d..00000000 --- a/e2e/specs/cloud-asr-config.t2.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -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 e3346c3e..01d5ee54 100644 --- a/simple_recorder.py +++ b/simple_recorder.py @@ -1447,56 +1447,6 @@ 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 737a3a57..7a8262af 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", "openai-asr") + VALID_TRANSCRIPTION_ENGINES = ("parakeet", "whisper") def __init__(self, config_path: Optional[Path] = None): """ @@ -688,16 +688,6 @@ 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" } @@ -1072,10 +1062,8 @@ def set_silence_auto_stop_minutes(self, minutes: int) -> bool: def get_transcription_engine(self) -> str: - """Return the active ASR engine. + """Return the active ASR engine ('parakeet' or 'whisper'). - 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. @@ -1111,68 +1099,6 @@ 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 ad96fd05..76f8b1a7 100644 --- a/src/transcriber.py +++ b/src/transcriber.py @@ -455,15 +455,20 @@ class WhisperTranscriber: """ def __init__(self, model_size: str = "large-v3-turbo"): - # ``model_size`` / ``backend`` are kept on the instance so existing - # callers / logs that read them don't change. Backend selection + 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 # 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 on-device engine isn't - # installed: + # live. Fallback order when the requested 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 @@ -472,57 +477,23 @@ def __init__(self, model_size: str = "large-v3-turbo"): try: from src.config import get_config - _cfg = get_config() - requested = _cfg.get_transcription_engine() + requested = get_config().get_transcription_engine() except Exception: requested = "parakeet" - _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") + + if requested == "whisper" and WHISPER_CPP_AVAILABLE: + self.backend = "whisper.cpp" + self._load_whisper_cpp() + elif PARAKEET_AVAILABLE: + self.backend = "parakeet-tdt-v3" else: - # 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.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: @@ -693,213 +664,10 @@ 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. @@ -1480,46 +1248,16 @@ 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. - # - # 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]] = [] + tagged: list[tuple[float, str, str]] = [] for s in mic_segments: text = (s.get("text") or "").strip() if text: - tagged.append((float(s.get("start") or 0.0), "You", text, mic_timed)) + tagged.append((float(s.get("start") or 0.0), "You", text)) for s in system_segments: text = (s.get("text") or "").strip() if text: - 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])) + tagged.append((float(s.get("start") or 0.0), "Others", text)) + tagged.sort(key=lambda t: t[0]) # Each turn carries the start offset of its FIRST segment so the # diarised transcript can be timestamped. Only diarised_text is @@ -1529,27 +1267,21 @@ def _has_real_timing(segs) -> bool: # 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], bool]] = [] - for start, speaker, text, timed in tagged: + turns: list[tuple[float, str, list[str]]] = [] + for start, speaker, text in tagged: if turns and turns[-1][1] == speaker: turns[-1][2].append(text) else: - turns.append((start, speaker, [text], timed)) + turns.append((start, speaker, [text])) - plain_parts = [' '.join(parts) for _start, _speaker, parts, _timed in turns] + plain_parts = [' '.join(parts) for _start, _speaker, parts 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)}" - if timed - else f"[{speaker}] {' '.join(parts)}") - for start, speaker, parts, timed in turns + f"[{_format_timestamp(start)}] [{speaker}] {' '.join(parts)}" + for start, speaker, parts in turns ] diarised_text = "\n\n".join(labelled_parts) else: diff --git a/tests/test_config.py b/tests/test_config.py index 65513268..21e16df0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -143,40 +143,6 @@ 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 52faf027..ba86bd39 100644 --- a/tests/test_transcriber_diarisation.py +++ b/tests/test_transcriber_diarisation.py @@ -111,26 +111,6 @@ 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):