From 2f045ee090f70ef8585999950857af6530fa6926 Mon Sep 17 00:00:00 2001 From: Vassista Date: Sat, 18 Jul 2026 20:04:23 +0530 Subject: [PATCH 01/24] feat: add support for OpenAI-compatible ASR engines with configurable API settings --- app/main.js | 36 +++- app/preload.js | 6 + app/renderer/src/hooks/useModels.ts | 47 ++++- app/renderer/src/lib/ipc.ts | 22 +- .../src/routes/settings/TranscriptionTab.tsx | 192 +++++++++++++++++- simple_recorder.py | 44 ++++ src/config.py | 57 +++++- src/transcriber.py | 173 +++++++++++++++- 8 files changed, 549 insertions(+), 28 deletions(-) diff --git a/app/main.js b/app/main.js index 0eab1454..63966c38 100644 --- a/app/main.js +++ b/app/main.js @@ -3845,7 +3845,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'; } @@ -3862,12 +3863,19 @@ 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'; + 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 { + model = 'parakeet'; + } return { engine, - // 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, language: cfg.language || 'auto', }; } catch (_) { @@ -6322,6 +6330,24 @@ ipcMain.handle('set-transcription-engine', async (event, engine) => { } catch (e) { return { success: false, error: e.message }; } }); +ipcMain.handle('get-openai-asr-config', async () => { + try { + const result = await runPythonScript('simple_recorder.py', ['get-openai-asr-config'], true); + return JSON.parse(result.trim()); + } 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.api_url !== undefined) { args.push('--api-url', cfg.api_url); } + if (cfg.api_key !== undefined) { args.push('--api-key', cfg.api_key); } + if (cfg.model !== undefined) { args.push('--model', cfg.model); } + const result = await runPythonScript('simple_recorder.py', args); + return JSON.parse(result.trim()); + } 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); diff --git a/app/preload.js b/app/preload.js index 035bba0e..78181f8e 100644 --- a/app/preload.js +++ b/app/preload.js @@ -219,6 +219,12 @@ const stenoai = { set: (engine) => invoke('set-transcription-engine', engine), }, + openaiAsr: { + getConfig: () => invoke('get-openai-asr-config'), + // cfg may include any subset of { api_url, api_key, model } + setConfig: (cfg) => invoke('set-openai-asr-config', cfg), + }, + 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..69921b58 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); @@ -545,13 +550,13 @@ export function useTranscriptionEngine() { /** * Whether live (during-recording) transcription is available: Parakeet only — - * Whisper never spawns the transcribe-stream sidecar, so it has no live - * drawer, no partials, and its recording pill keeps Pause/Resume inline. - * Defaults to parakeet while the query hydrates so the first paint doesn't - * briefly hide live-only controls. Single-sourced here because the - * pause-reachability invariant spans PrimaryDock (panel gate), LiveDock - * (inline controls), and LiveTranscriptBar (footer controls) — they must all - * agree. + * Whisper and OpenAI ASR never spawn the transcribe-stream sidecar, so they + * have no live drawer, no partials, and their recording pill keeps + * Pause/Resume inline. Defaults to parakeet while the query hydrates so + * the first paint doesn't briefly hide live-only controls. Single-sourced + * here because the pause-reachability invariant spans PrimaryDock (panel + * gate), LiveDock (inline controls), and LiveTranscriptBar (footer + * controls) — they must all agree. */ export function useLiveTranscriptAvailable(): boolean { const engineQuery = useTranscriptionEngine(); @@ -604,3 +609,31 @@ export function useSetActiveTranscription() { }, }); } + +// --------------------------------------------------------------------------- +// OpenAI-compatible ASR config +// --------------------------------------------------------------------------- + +/** Query for the current OpenAI ASR endpoint config (url, api_key_set, model). */ +export function useOpenAiAsrConfig() { + return useQuery({ + queryKey: openaiAsrKeys.config(), + queryFn: async () => { + const raw = unwrap(await ipc().openaiAsr.getConfig()); + return raw; + }, + }); +} + +/** + * Mutation to save any subset of the OpenAI ASR config. + * Pass only the fields you want to change; others are left untouched. + */ +export function useSetOpenAiAsrConfig() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (cfg: { api_url?: string; api_key?: string; model?: string }) => + unwrap(await ipc().openaiAsr.setConfig(cfg)), + onSuccess: () => qc.invalidateQueries({ queryKey: openaiAsrKeys.all }), + }); +} diff --git a/app/renderer/src/lib/ipc.ts b/app/renderer/src/lib/ipc.ts index 44ba9a87..c143f880 100644 --- a/app/renderer/src/lib/ipc.ts +++ b/app/renderer/src/lib/ipc.ts @@ -479,13 +479,25 @@ 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 GetNotificationsResponse = Result<{ notifications_enabled: boolean }>; export type GetTelemetryResponse = Result<{ telemetry_enabled: boolean; @@ -903,6 +915,14 @@ export interface StenoaiBridge { set: RequestFn<[engine: TranscriptionEngine], Result<{ engine: TranscriptionEngine }>>; }; + openaiAsr: { + getConfig: RequestFn<[], GetOpenAiAsrConfigResponse>; + setConfig: RequestFn< + [cfg: { api_url?: string; api_key?: string; model?: string }], + SetOpenAiAsrConfigResponse + >; + }; + settings: { getNotifications: RequestFn<[], GetNotificationsResponse>; setNotifications: RequestFn<[v: boolean], Result>>; diff --git a/app/renderer/src/routes/settings/TranscriptionTab.tsx b/app/renderer/src/routes/settings/TranscriptionTab.tsx index 1f4389ed..e325faf9 100644 --- a/app/renderer/src/routes/settings/TranscriptionTab.tsx +++ b/app/renderer/src/routes/settings/TranscriptionTab.tsx @@ -1,5 +1,8 @@ +import * as React from 'react'; import { Loader2 } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; import { Select, SelectContent, @@ -16,10 +19,12 @@ import { useSetLanguage, } from '@/hooks/useSettings'; import { + useOpenAiAsrConfig, useParakeetModels, usePullParakeetModel, usePullWhisperModel, useSetActiveTranscription, + useSetOpenAiAsrConfig, useTranscriptionEngine, useWhisperModels, } from '@/hooks/useModels'; @@ -104,17 +109,11 @@ export function TranscriptionTab() { } /** - * Unified Parakeet + Whisper picker. One row per model, regardless of - * engine — clicking [Select] on an installed row activates that engine - * (and, for Whisper rows, also sets `whisper_model`). Clicking [Download] - * pulls the model; on success the pull-completion handler in the - * use(Parakeet|Whisper)Model hook flips the active engine over, so the - * user lands on the row they just downloaded. - * - * Parakeet sits at the top because new installs default to it; the - * migration in src/config.py keeps existing users on Whisper, but if - * they're seeing this UI on an upgraded install the Whisper row will - * already be marked Selected so the position-as-default reading is OK. + * Unified Parakeet + Whisper + OpenAI-compatible ASR picker. + * One row per model, regardless of engine — clicking [Select] on an + * installed row activates that engine. The OpenAI ASR row additionally + * expands config fields (API endpoint, key, model) when it is the active + * engine. */ function TranscriptionModelList() { const parakeet = useParakeetModels(); @@ -235,6 +234,177 @@ function TranscriptionModelList() { onSelect={row.onSelect} /> ))} + setActive.mutate({ engine: 'openai-asr' })} + /> + + ); +} + +// --------------------------------------------------------------------------- +// OpenAI-compatible ASR card — Select button + config fields when active +// --------------------------------------------------------------------------- + +function OpenAiAsrCard({ + isActive, + onActivate, +}: { + isActive: boolean; + onActivate: () => void; +}) { + const configQuery = useOpenAiAsrConfig(); + const setConfig = useSetOpenAiAsrConfig(); + + // Local state mirrors the persisted value; saves on blur (same pattern as + // AiTab's API key / URL fields so the behaviour feels native). + const [apiUrl, setApiUrl] = React.useState(''); + const [apiKey, setApiKey] = React.useState(''); + const [model, setModel] = React.useState(''); + + React.useEffect(() => { + if (configQuery.data) { + setApiUrl(configQuery.data.api_url ?? 'https://api.openai.com/v1'); + setModel(configQuery.data.model ?? 'whisper-1'); + // Never pre-fill the key field — only show the placeholder sentinel. + } + }, [configQuery.data]); + + const apiKeySet = configQuery.data?.api_key_set ?? false; + + return ( +
+ {/* Header row — name + Select button */} +
+
+
+ + OpenAI-compatible ASR + +
+
+ Use any OpenAI Speech-to-Text compatible API — OpenAI, Groq, Azure + OpenAI, or a local server. No model download required; transcription + is sent to the endpoint you configure. +
+
+
+ {isActive ? ( + + ) : ( + + )} +
+
+ + {/* Config fields — always shown so users can configure before switching */} +
+ {/* API endpoint */} +
+ + setApiUrl(e.target.value)} + placeholder="https://api.openai.com/v1" + onBlur={() => { + if (apiUrl !== (configQuery.data?.api_url ?? '')) { + setConfig.mutate({ api_url: apiUrl }); + } + }} + className="h-[30px] text-[13px]" + /> +
+ + {/* API key */} +
+ + setApiKey(e.target.value)} + placeholder={apiKeySet ? '••••••••' : 'sk-…'} + onBlur={() => { + if (apiKey) setConfig.mutate({ api_key: apiKey }); + }} + className="h-[30px] text-[13px]" + /> +
+ + {/* Model name */} +
+ + setModel(e.target.value)} + placeholder="whisper-1" + onBlur={() => { + if (model && model !== (configQuery.data?.model ?? '')) { + setConfig.mutate({ model }); + } + }} + className="h-[30px] text-[13px]" + /> +
+
+ + {/* Hint */} +
+ Examples: OpenAI → whisper-1 · Groq →{' '} + whisper-large-v3 · Azure → your deployment name +
); } diff --git a/simple_recorder.py b/simple_recorder.py index 3998dab7..4bb89a91 100644 --- a/simple_recorder.py +++ b/simple_recorder.py @@ -1447,6 +1447,50 @@ 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.""" + 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; the UI only needs to know if one is set. + "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('--api-key', default=None, help='Bearer token / API key') +@click.option('--model', default=None, help='Model name (e.g. whisper-1)') +def set_openai_asr_config_cmd(api_url, api_key, model): + """Persist OpenAI-compatible ASR endpoint settings. 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 api_key is not None: + if not config.set_openai_asr_api_key(api_key): + errors.append("Failed to save api_key") + 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 dee43d0a..e495272f 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): """ @@ -617,6 +617,14 @@ 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.). + # api_key: Bearer token sent in the Authorization header. + # model: model name passed in the multipart form (e.g. whisper-1). + "openai_asr_api_url": "https://api.openai.com/v1", + "openai_asr_api_key": "", + "openai_asr_model": "whisper-1", "version": "1.0" } @@ -983,6 +991,53 @@ def set_whisper_model(self, model_size: str) -> bool: self._config["whisper_model"] = model_size return self._save() + # ------------------------------------------------------------------ + # OpenAI-compatible ASR endpoint settings + # ------------------------------------------------------------------ + + 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.""" + self._config["openai_asr_api_url"] = (url or "").strip() + return self._save() + + def get_openai_asr_api_key(self) -> str: + """Bearer token for the OpenAI-compatible STT endpoint. + + Stored in config.json (same convention as cloud_api_key for the + summarisation cloud provider). Empty string when unset. + """ + return self._config.get("openai_asr_api_key", "") + + def set_openai_asr_api_key(self, key: str) -> bool: + """Persist the API key for the OpenAI-compatible STT endpoint.""" + self._config["openai_asr_api_key"] = (key or "").strip() + return self._save() + + 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..a640f940 100644 --- a/src/transcriber.py +++ b/src/transcriber.py @@ -477,11 +477,28 @@ 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: + _cfg = None + + if requested == "openai-asr": + self.backend = "openai-asr" + # Read endpoint config from config; store on instance so the + # batch transcriber can pick them up without re-reading config + # on every call. Fall back to empty strings — _run_openai_asr + # will surface a useful error if they're not set. + 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" + elif requested == "whisper" and WHISPER_CPP_AVAILABLE: self.backend = "whisper.cpp" self._load_whisper_cpp() elif PARAKEET_AVAILABLE: @@ -664,10 +681,160 @@ 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 io + import json as _json + import mimetypes + import urllib.error + 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" + endpoint = f"{api_url}/audio/transcriptions" + + 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 → Transcribe → OpenAI-compatible ASR." + ) + + # Build multipart/form-data body using only stdlib. + 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(name: str, filename: str, content: bytes, ctype: str) -> bytes: + header = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n' + f"Content-Type: {ctype}\r\n\r\n" + ).encode() + return header + content + b"\r\n" + + def _build_body(response_format: str) -> bytes: + with open(audio_filepath, "rb") as fh: + file_bytes = fh.read() + parts: list[bytes] = [ + _field("model", model), + _field("response_format", response_format), + ] + # Only send language when it's a concrete code, not "auto" + # (some endpoints reject "auto" as a language value). + if language and language != "auto": + parts.append(_field("language", language)) + parts.append(_file_field("file", audio_filepath.name, file_bytes, mime_type)) + parts.append(f"--{boundary}--\r\n".encode()) + return b"".join(parts) + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + } + + def _do_request(response_format: str) -> bytes: + body = _build_body(response_format) + req = urllib.request.Request( + endpoint, + data=body, + headers={**headers, "Content-Length": str(len(body))}, + method="POST", + ) + try: + with urllib.request.urlopen(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: + 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. From 5805d986d81837058956b92ccdf64360ab539597 Mon Sep 17 00:00:00 2001 From: Vassista Date: Sat, 18 Jul 2026 20:28:55 +0530 Subject: [PATCH 02/24] feat: improve settings UI layout and navigation --- .../src/components/CommandPalette.tsx | 152 +++++-- app/renderer/src/components/Sidebar.tsx | 388 ++++++++++-------- app/renderer/src/routes/Settings.tsx | 124 ++---- .../src/routes/settings/TranscriptionTab.tsx | 290 +++++++------ 4 files changed, 500 insertions(+), 454 deletions(-) diff --git a/app/renderer/src/components/CommandPalette.tsx b/app/renderer/src/components/CommandPalette.tsx index 4cee51d4..18bd8bb3 100644 --- a/app/renderer/src/components/CommandPalette.tsx +++ b/app/renderer/src/components/CommandPalette.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Search } from 'lucide-react'; import { useMeetings, LIVE_SUMMARY_PREFIX } from '@/hooks/useMeetings'; import { searchNotes, snippet } from '@/lib/noteSearch'; -import { navigate } from '@/lib/router'; +import { navigate, useRoute } from '@/lib/router'; import type { Meeting } from '@/lib/ipc'; interface PaletteContextValue { @@ -45,7 +45,30 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode ); } +const SETTINGS_INDEX = [ + { id: 'general-theme', tab: 'general', title: 'Appearance', sub: 'Dark mode, light mode, system theme' }, + { id: 'general-name', tab: 'general', title: 'Your name', sub: 'In-app greetings' }, + { id: 'general-calendar', tab: 'general', title: 'Calendar', sub: 'Google, Outlook integration' }, + { id: 'general-notifications', tab: 'general', title: 'Desktop notifications', sub: 'App notifications' }, + { id: 'general-mic', tab: 'general', title: 'Microphone', sub: 'Input device' }, + { id: 'general-system-audio', tab: 'general', title: 'Record system audio', sub: 'Screen recording access' }, + { id: 'general-autodetect', tab: 'general', title: 'Auto-detect meetings', sub: 'Notification when another app starts using mic' }, + { id: 'general-launch', tab: 'general', title: 'Launch on login', sub: 'Start automatically' }, + { id: 'transcription-engine', tab: 'transcription', title: 'Transcription engine', sub: 'Parakeet, Whisper, Cloud API (OpenAI)' }, + { id: 'transcription-lang', tab: 'transcription', title: 'Language', sub: 'Transcription and summaries language' }, + { id: 'transcription-keep', tab: 'transcription', title: 'Keep recordings', sub: 'Save audio files' }, + { id: 'transcription-notes', tab: 'transcription', title: 'Generate notes automatically', sub: 'Summarise after transcription' }, + { id: 'ai-provider', tab: 'ai', title: 'AI provider', sub: 'Local, Private Server, Cloud API, Organisation' }, + { id: 'templates', tab: 'templates', title: 'Templates', sub: 'Custom note formats' }, + { id: 'org', tab: 'organisation', title: 'Organisation', sub: 'Sign in to share notes' }, + { id: 'advanced-export', tab: 'advanced', title: 'Export data', sub: 'Download all notes' }, + { id: 'advanced-cache', tab: 'advanced', title: 'Storage & Cache', sub: 'Clear cached models' }, + { id: 'developer', tab: 'developer', title: 'Developer', sub: 'Debug logs, experimental features' }, +]; + function CommandPalette({ onClose }: { onClose: () => void }) { + const currentRoute = useRoute(); + const isSettingsMode = currentRoute.startsWith('/settings'); const meetings = useMeetings(); // One recency sort feeds both paths: empty-query recents and search results // (searchNotes preserves input order, so results stay newest-first). @@ -75,16 +98,28 @@ function CommandPalette({ onClose }: { onClose: () => void }) { return () => prev?.focus?.(); }, []); - const results = React.useMemo(() => { + const settingsResults = React.useMemo(() => { + if (!isSettingsMode) return []; + if (!query.trim()) return SETTINGS_INDEX; + const q = query.toLowerCase(); + return SETTINGS_INDEX.filter((s) => + s.title.toLowerCase().includes(q) || s.sub.toLowerCase().includes(q) + ); + }, [isSettingsMode, query]); + + const noteResults = React.useMemo(() => { + if (isSettingsMode) return []; if (!query.trim()) return sorted.slice(0, RECENT_COUNT); return searchNotes(sorted, query).slice(0, MAX_RESULTS); - }, [sorted, query]); + }, [isSettingsMode, sorted, query]); + + const resultCount = isSettingsMode ? settingsResults.length : noteResults.length; // Keep selection within [0, len-1]; never let it stick at -1 once results // appear (ArrowDown on an empty list would otherwise leave it negative). React.useEffect(() => { - setSelected((s) => Math.max(0, Math.min(s, results.length - 1))); - }, [results.length]); + setSelected((s) => Math.max(0, Math.min(s, resultCount - 1))); + }, [resultCount]); // Scroll the active option into view as the keyboard selection moves. React.useEffect(() => { @@ -99,6 +134,12 @@ function CommandPalette({ onClose }: { onClose: () => void }) { onClose(); }; + const openSetting = (s: typeof SETTINGS_INDEX[0] | undefined) => { + if (!s) return; + navigate(`/settings?tab=${s.tab}`); + onClose(); + }; + const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); @@ -108,13 +149,17 @@ function CommandPalette({ onClose }: { onClose: () => void }) { onClose(); } else if (e.key === 'ArrowDown') { e.preventDefault(); - setSelected((s) => Math.min(s + 1, results.length - 1)); + setSelected((s) => Math.min(s + 1, resultCount - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelected((s) => Math.max(s - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); - openMeeting(results[selected]); + if (isSettingsMode) { + openSetting(settingsResults[selected]); + } else { + openMeeting(noteResults[selected]); + } } else if (e.key === 'Tab') { // The input is the only tab stop in the dialog; trap Tab so focus can't // escape behind the aria-modal overlay. @@ -122,7 +167,7 @@ function CommandPalette({ onClose }: { onClose: () => void }) { } }; - const activeId = results[selected] ? `cmdk-opt-${selected}` : undefined; + const activeId = resultCount > 0 ? `cmdk-opt-${selected}` : undefined; return (
void }) { data-testid="command-palette-input" className="w-full bg-transparent text-[14px] outline-none" style={{ color: 'var(--fg-1)', fontFamily: 'var(--font-sans)' }} - placeholder="Search notes…" - aria-label="Search notes" + placeholder={isSettingsMode ? "Search settings…" : "Search notes…"} + aria-label={isSettingsMode ? "Search settings" : "Search notes"} role="combobox" aria-expanded="true" aria-controls="cmdk-listbox" @@ -171,44 +216,71 @@ function CommandPalette({ onClose }: { onClose: () => void }) { aria-label="Search results" className="scrollbar-clean max-h-[50vh] overflow-auto py-1" > - {results.length === 0 ? ( + {resultCount === 0 ? (
  • - {query.trim() ? `No notes match “${query.trim()}”` : 'No notes yet'} + {query.trim() + ? `No results for “${query.trim()}”` + : (isSettingsMode ? 'No settings match' : 'No notes yet')}
  • ) : ( - results.map((m, i) => { - const title = m.session_info.name || 'Untitled Meeting'; - const sub = snippet(m.summary, query); - return ( -
  • setSelected(i)} - onMouseDown={(e) => { - e.preventDefault(); - openMeeting(m); - }} - > -
    - {title} -
    - {sub && ( + isSettingsMode + ? settingsResults.map((s, i) => ( +
  • setSelected(i)} + onMouseDown={(e) => { + e.preventDefault(); + openSetting(s); + }} + > +
    + {s.title} +
    - {sub} + {s.sub}
    - )} -
  • - ); - }) + + )) + : noteResults.map((m, i) => { + const title = m.session_info.name || 'Untitled Meeting'; + const sub = snippet(m.summary, query); + return ( +
  • setSelected(i)} + onMouseDown={(e) => { + e.preventDefault(); + openMeeting(m); + }} + > +
    + {title} +
    + {sub && ( +
    + {sub} +
    + )} +
  • + ); + }) )} diff --git a/app/renderer/src/components/Sidebar.tsx b/app/renderer/src/components/Sidebar.tsx index abebd712..9763f1f7 100644 --- a/app/renderer/src/components/Sidebar.tsx +++ b/app/renderer/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { + ArrowLeft, ChevronDown, Globe, Home as HomeIcon, @@ -11,7 +12,8 @@ import { Search, Settings as SettingsIcon, } from 'lucide-react'; -import { navigate, rememberNonSettingsRoute, toggleSettings } from '@/lib/router'; +import { navigate, rememberNonSettingsRoute, toggleSettings, getLastNonSettingsRoute, getRouteParam } from '@/lib/router'; +import { SETTINGS_TABS } from '@/routes/Settings'; import { cn, shortcut } from '@/lib/utils'; import { LucideIcon, IconPicker } from '@/components/IconPicker'; import { useUpdateFolderIcon } from '@/hooks/useFolders'; @@ -164,6 +166,9 @@ export function Sidebar({ const [dragOverAllMeetings, setDragOverAllMeetings] = React.useState(false); const isDraggingRef = React.useRef(false); const [iconPicker, setIconPicker] = React.useState<{ id: string; anchorRect: DOMRect } | null>(null); + + const isSettingsMode = currentRoute.startsWith('/settings'); + const activeSettingsTab = isSettingsMode ? (getRouteParam(currentRoute, 'tab') || 'general') : null; const updateIcon = useUpdateFolderIcon(); const isHomeActive = currentRoute === '/' || currentRoute === ''; @@ -319,9 +324,9 @@ export function Sidebar({ onClick={() => palette.open()} className="flex h-[30px] w-full items-center rounded-md border-0 px-[10px] pl-[30px] text-left text-[13px] outline-none transition-colors hover:shadow-[inset_0_0_0_1px_hsl(var(--border))] focus-visible:shadow-[inset_0_0_0_1px_hsl(var(--border))]" style={{ background: 'rgba(27,27,25,0.04)', color: 'var(--fg-muted)', fontFamily: 'var(--font-sans)' }} - aria-label="Search notes" + aria-label={isSettingsMode ? 'Search settings' : 'Search notes'} > - Search + {isSettingsMode ? 'Search settings...' : 'Search'} - - -
    { - if (e.dataTransfer.types.includes('application/x-steno-meeting')) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - setDragOverAllMeetings(true); - } - }} - onDragLeave={(e) => { - if (e.currentTarget.contains(e.relatedTarget as Node)) return; - setDragOverAllMeetings(false); - }} - onDrop={(e) => handleFolderDrop(e, null)} - > - -
    - - + {isSettingsMode ? ( + <> +
    + +
    + +
    + {SETTINGS_TABS.map((t) => { + const isActive = activeSettingsTab === t.id; + const Icon = t.icon; + return ( + + ); + })} +
    + + ) : ( + <> + - {sharedNotes.enabled && ( - - )} +
    { + if (e.dataTransfer.types.includes('application/x-steno-meeting')) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverAllMeetings(true); + } + }} + onDragLeave={(e) => { + if (e.currentTarget.contains(e.relatedTarget as Node)) return; + setDragOverAllMeetings(false); + }} + onDrop={(e) => handleFolderDrop(e, null)} + > + +
    - {/* Folders group */} -
    -
    setFoldersOpen((o) => !o)} - > - - - Folders - -
    - {foldersOpen && - folders.map((folder) => { - const isOver = dragOverFolder === folder.id; - const isActive = activeFolderId === folder.id; - return ( -
    { - if (e.dataTransfer.types.includes('application/x-steno-meeting')) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - setDragOverFolder(folder.id); - } - }} - onDragLeave={(e) => { - if (e.currentTarget.contains(e.relatedTarget as Node)) return; - setDragOverFolder(null); - }} - onDrop={(e) => handleFolderDrop(e, folder.id)} - onContextMenu={(e) => handleFolderContext(e, folder.id)} + {sharedNotes.enabled && ( + + )} + + {/* Folders group */} +
    +
    setFoldersOpen((o) => !o)} + > + + + Folders + + +
    + + {foldersOpen && + folders.map((folder) => { + const isOver = dragOverFolder === folder.id; + const isActive = activeFolderId === folder.id; + return ( +
    { + if (e.dataTransfer.types.includes('application/x-steno-meeting')) { e.preventDefault(); - e.stopPropagation(); - setIconPicker({ id: folder.id, anchorRect: e.currentTarget.getBoundingClientRect() }); + e.dataTransfer.dropEffect = 'move'; + setDragOverFolder(folder.id); } }} + onDragLeave={(e) => { + if (e.currentTarget.contains(e.relatedTarget as Node)) return; + setDragOverFolder(null); + }} + onDrop={(e) => handleFolderDrop(e, folder.id)} + onContextMenu={(e) => handleFolderContext(e, folder.id)} > - - - {folder.name} - - {folder.meetings.length} - - -
    - ); - })} -
    + +
    + ); + })} +
    + + )} {/* Profile chip + Settings cog. When the user is signed in to an org @@ -484,49 +529,52 @@ export function Sidebar({ CTA, but ONLY for users who've previously connected to an org — personal users who have never signed in don't see clutter for a feature they don't use. */} -
    - {orgSignedIn ? ( - orgLogout.mutate()} - /> - ) : orgSession.data?.everSignedIn ? ( + {/* Profile chip + Settings cog */} + {!isSettingsMode && ( +
    + {orgSignedIn ? ( + orgLogout.mutate()} + /> + ) : orgSession.data?.everSignedIn ? ( + + ) : ( + + )} - ) : ( - - )} - -
    +
    + )} {iconPicker && ( void; - children: React.ReactNode; -} +export const SETTINGS_TABS = [ + { id: 'general', label: 'General', icon: SettingsIcon }, + { id: 'transcription', label: 'Transcribe', icon: Mic }, + { id: 'ai', label: 'AI', icon: Sparkles }, + { id: 'templates', label: 'Templates', icon: LayoutTemplate }, + { id: 'organisation', label: 'Organisation', icon: Building2 }, + { id: 'advanced', label: 'Advanced', icon: Sliders }, + { id: 'developer', label: 'Developer', icon: TerminalSquare }, +] as const; -function TabButton({ active, onClick, children }: TabButtonProps) { - return ( - - ); -} +export type SettingsTabId = (typeof SETTINGS_TABS)[number]['id']; // --------------------------------------------------------------------------- -// Settings page — a thin shell that owns the tab state + chrome and renders -// one self-contained component per tab from ./settings/. Each tab drives its -// own hooks (there is no shared cross-tab state), so no context/prop-drilling -// is needed here. +// Settings page — a thin shell that renders one self-contained component per +// tab from ./settings/. Each tab drives its own hooks (there is no shared +// cross-tab state), so no context/prop-drilling is needed here. // --------------------------------------------------------------------------- export function Settings() { - const navigate = useNavigate(); - // Deep-link support: /settings?tab= opens the matching tab on mount. - // Used by the sidebar's "Sign in to organisation" CTA to land users - // directly on the org sign-in form rather than the General tab. const route = useRoute(); - const initialTab = React.useMemo(() => { + + const tab = React.useMemo(() => { const requested = getRouteParam(route, 'tab'); - if (requested && TABS.some((t) => t.id === requested)) return requested as TabId; + if (requested && SETTINGS_TABS.some((t) => t.id === requested)) { + return requested as SettingsTabId; + } return 'general'; - }, []); // Intentional — only consume the URL param on first mount. - const [tab, setTab] = React.useState(initialTab); + }, [route]); + const version = useAppVersion(); return ( @@ -82,52 +59,13 @@ export function Settings() { className="flex h-full min-h-0 flex-1 flex-col overflow-hidden" style={{ background: 'var(--page)' }} > -
    -
    - -

    - Settings -

    -
    -
    - {TABS.map((t) => ( - setTab(t.id)} - > - {t.label} - - ))} -
    -
    {tab === 'general' && } diff --git a/app/renderer/src/routes/settings/TranscriptionTab.tsx b/app/renderer/src/routes/settings/TranscriptionTab.tsx index e325faf9..71c4184c 100644 --- a/app/renderer/src/routes/settings/TranscriptionTab.tsx +++ b/app/renderer/src/routes/settings/TranscriptionTab.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; +import { cn } from '@/lib/utils'; import { Loader2 } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; import { Select, SelectContent, @@ -102,7 +102,6 @@ export function TranscriptionTab() { /> - Models ); @@ -110,10 +109,8 @@ export function TranscriptionTab() { /** * Unified Parakeet + Whisper + OpenAI-compatible ASR picker. - * One row per model, regardless of engine — clicking [Select] on an - * installed row activates that engine. The OpenAI ASR row additionally - * expands config fields (API endpoint, key, model) when it is the active - * engine. + * Renders a dropdown to pick the engine, then conditionally renders + * the corresponding model list or configuration fields below it. */ function TranscriptionModelList() { const parakeet = useParakeetModels(); @@ -217,42 +214,91 @@ function TranscriptionModelList() { }, })); - const rows: Row[] = [...parakeetRows, ...whisperRows]; - return ( -
    - {rows.map((row) => ( - - ))} - setActive.mutate({ engine: 'openai-asr' })} - /> +
    + + + + + {activeEngine === 'parakeet' && ( +
    + Model + {parakeetRows.map((row) => ( + + ))} +
    + )} + + {activeEngine === 'whisper' && ( +
    + Model + {whisperRows.map((row) => ( + + ))} +
    + )} + + {activeEngine === 'openai-asr' && }
    ); } // --------------------------------------------------------------------------- -// OpenAI-compatible ASR card — Select button + config fields when active +// OpenAI-compatible ASR Config // --------------------------------------------------------------------------- -function OpenAiAsrCard({ - isActive, - onActivate, -}: { - isActive: boolean; - onActivate: () => void; -}) { +function OpenAiAsrConfig() { const configQuery = useOpenAiAsrConfig(); const setConfig = useSetOpenAiAsrConfig(); @@ -274,127 +320,69 @@ function OpenAiAsrCard({ return (
    - {/* Header row — name + Select button */} -
    -
    -
    - - OpenAI-compatible ASR - -
    -
    - Use any OpenAI Speech-to-Text compatible API — OpenAI, Groq, Azure - OpenAI, or a local server. No model download required; transcription - is sent to the endpoint you configure. -
    -
    -
    - {isActive ? ( - - ) : ( - - )} -
    +
    + + setApiUrl(e.target.value)} + placeholder="https://api.openai.com/v1" + onBlur={() => { + if (apiUrl !== (configQuery.data?.api_url ?? '')) { + setConfig.mutate({ api_url: apiUrl }); + } + }} + className="h-[30px] text-[13px]" + />
    - {/* Config fields — always shown so users can configure before switching */} -
    - {/* API endpoint */} -
    - - setApiUrl(e.target.value)} - placeholder="https://api.openai.com/v1" - onBlur={() => { - if (apiUrl !== (configQuery.data?.api_url ?? '')) { - setConfig.mutate({ api_url: apiUrl }); - } - }} - className="h-[30px] text-[13px]" - /> -
    - - {/* API key */} -
    - - setApiKey(e.target.value)} - placeholder={apiKeySet ? '••••••••' : 'sk-…'} - onBlur={() => { - if (apiKey) setConfig.mutate({ api_key: apiKey }); - }} - className="h-[30px] text-[13px]" - /> -
    +
    + + setApiKey(e.target.value)} + placeholder={apiKeySet ? '••••••••' : 'sk-…'} + onBlur={() => { + if (apiKey) setConfig.mutate({ api_key: apiKey }); + }} + className="h-[30px] text-[13px]" + /> +
    - {/* Model name */} -
    - - setModel(e.target.value)} - placeholder="whisper-1" - onBlur={() => { - if (model && model !== (configQuery.data?.model ?? '')) { - setConfig.mutate({ model }); - } - }} - className="h-[30px] text-[13px]" - /> -
    +
    + + setModel(e.target.value)} + placeholder="whisper-1" + onBlur={() => { + if (model && model !== (configQuery.data?.model ?? '')) { + setConfig.mutate({ model }); + } + }} + className="h-[30px] text-[13px]" + />
    {/* Hint */} From 9a779756b352b5c34b46d7ceafb1d73ecdf1f9fb Mon Sep 17 00:00:00 2001 From: Vassista Date: Sat, 18 Jul 2026 20:40:43 +0530 Subject: [PATCH 03/24] feat: resolve CI lint errors and add privacy confirmation dialog for Cloud ASR --- .../src/routes/settings/TranscriptionTab.tsx | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/app/renderer/src/routes/settings/TranscriptionTab.tsx b/app/renderer/src/routes/settings/TranscriptionTab.tsx index 71c4184c..2b8f617d 100644 --- a/app/renderer/src/routes/settings/TranscriptionTab.tsx +++ b/app/renderer/src/routes/settings/TranscriptionTab.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; import { Loader2 } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import type { TranscriptionEngine } from '@/lib/ipc'; import { Input } from '@/components/ui/input'; import { Select, @@ -120,6 +122,8 @@ function TranscriptionModelList() { const pullParakeet = usePullParakeetModel(); const pullWhisper = usePullWhisperModel(); + const [showPrivacyWarning, setShowPrivacyWarning] = React.useState(false); + const isLoading = parakeet.isLoading || whisper.isLoading || engine.isLoading; const isError = parakeet.isError || whisper.isError || engine.isError; @@ -222,7 +226,13 @@ function TranscriptionModelList() { > { if (apiKey !== '') { setConfig.mutate({ api_key: apiKey }); + setApiKey(''); } }} className="h-[30px] text-[13px]" diff --git a/docs/features/transcription.mdx b/docs/features/transcription.mdx index 3e63b32f..1fef6d6a 100644 --- a/docs/features/transcription.mdx +++ b/docs/features/transcription.mdx @@ -1,9 +1,9 @@ --- title: "Transcription" -description: "Steno transcribes recordings on-device with two engines: Parakeet (default) and Whisper (optional). Automatic language detection, live transcription, no audio uploads." +description: "Steno transcribes recordings on-device by default with Parakeet and Whisper, with optional support for OpenAI-compatible cloud transcription endpoints." --- -Steno transcribes with two on-device engines: **Parakeet** (the default, NVIDIA Parakeet TDT v3 running via MLX on Apple Silicon) and **Whisper** (an optional alternate, OpenAI's Whisper `large-v3-turbo`). Both run entirely on your device -- your audio is never sent to any server. You choose the engine in **Settings → Transcribe**. +Steno transcribes on-device by default using two local engines: **Parakeet** (the default, NVIDIA Parakeet TDT v3 running via MLX on Apple Silicon) and **Whisper** (an optional alternate, OpenAI's Whisper `large-v3-turbo`). Both run locally on your device -- your audio is never sent to any server unless you explicitly opt in to an **OpenAI-compatible ASR endpoint** in **Settings → Transcribe**. ## How it works diff --git a/src/config.py b/src/config.py index e495272f..8a5eb053 100644 --- a/src/config.py +++ b/src/config.py @@ -623,7 +623,6 @@ def _get_default_config(self) -> Dict[str, Any]: # api_key: Bearer token sent in the Authorization header. # model: model name passed in the multipart form (e.g. whisper-1). "openai_asr_api_url": "https://api.openai.com/v1", - "openai_asr_api_key": "", "openai_asr_model": "whisper-1", "version": "1.0" } @@ -1006,21 +1005,40 @@ def get_openai_asr_api_url(self) -> str: def set_openai_asr_api_url(self, url: str) -> bool: """Set the base URL for the OpenAI-compatible STT endpoint.""" - self._config["openai_asr_api_url"] = (url or "").strip() + cleaned = (url or "").strip() + if cleaned: + import urllib.parse + parts = urllib.parse.urlsplit(cleaned) + scheme = parts.scheme.lower() + hostname = (parts.hostname or "").lower() + is_local = hostname in ("localhost", "127.0.0.1", "::1") or hostname.endswith(".local") + if scheme == "http" and not is_local: + logger.error("openai_asr_api_url must use HTTPS for remote endpoints") + return False + if scheme not in ("http", "https"): + logger.error("openai_asr_api_url must start with https:// (or http:// for localhost)") + return False + self._config["openai_asr_api_url"] = cleaned return self._save() def get_openai_asr_api_key(self) -> str: """Bearer token for the OpenAI-compatible STT endpoint. - Stored in config.json (same convention as cloud_api_key for the - summarisation cloud provider). Empty string when unset. + Stored encrypted on disk using Electron safeStorage in .openai-asr-api-key + and passed via STENOAI_OAI_API_KEY env. Never persisted to config.json. """ - return self._config.get("openai_asr_api_key", "") + return os.environ.get("STENOAI_OAI_API_KEY", "") def set_openai_asr_api_key(self, key: str) -> bool: - """Persist the API key for the OpenAI-compatible STT endpoint.""" - self._config["openai_asr_api_key"] = (key or "").strip() - return self._save() + """Set the API key in environment. + + Encrypted storage is managed by Electron safeStorage in .openai-asr-api-key. + """ + os.environ["STENOAI_OAI_API_KEY"] = (key or "").strip() + if "openai_asr_api_key" in self._config: + del self._config["openai_asr_api_key"] + self._save() + return True def get_openai_asr_model(self) -> str: """Model name passed to the OpenAI-compatible STT endpoint. diff --git a/src/transcriber.py b/src/transcriber.py index a2890c4e..9e75849c 100644 --- a/src/transcriber.py +++ b/src/transcriber.py @@ -455,12 +455,21 @@ class WhisperTranscriber: """ def __init__(self, model_size: str = "large-v3-turbo"): - if not (PARAKEET_AVAILABLE or WHISPER_CPP_AVAILABLE): + try: + from src.config import get_config + _cfg = get_config() + requested = _cfg.get_transcription_engine() + except Exception: + requested = "parakeet" + _cfg = None + + if requested != "openai-asr" and 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 @@ -475,14 +484,6 @@ def __init__(self, model_size: str = "large-v3-turbo"): self.model_size = model_size self.model = None - try: - from src.config import get_config - _cfg = get_config() - requested = _cfg.get_transcription_engine() - except Exception: - requested = "parakeet" - _cfg = None - if requested == "openai-asr": self.backend = "openai-asr" # Read endpoint config from config; store on instance so the @@ -714,17 +715,51 @@ def _run_openai_asr(self, audio_filepath: Path, language: str) -> dict: import urllib.error import urllib.request import uuid - import os import urllib.parse + import tempfile + import wave + + raw_url = getattr(self, "_openai_asr_api_url", None) + if not raw_url: + from src.config import get_config + raw_url = get_config().get_openai_asr_api_url() + raw_url = (raw_url or "").strip() + if not raw_url: + raise RuntimeError( + "openai-asr: Endpoint URL is not configured. " + "Set it in Settings → Transcribe → OpenAI-compatible ASR." + ) + + api_url = raw_url.rstrip("/") + parts = urllib.parse.urlsplit(api_url) + scheme = parts.scheme.lower() + hostname = (parts.hostname or "").lower() + is_local = hostname in ("localhost", "127.0.0.1", "::1") or hostname.endswith(".local") + if scheme == "http" and not is_local: + raise RuntimeError( + f"openai-asr: Insecure HTTP endpoint '{api_url}' is not allowed for remote servers. " + "Use HTTPS to protect audio and credentials." + ) + if scheme not in ("http", "https"): + raise RuntimeError( + f"openai-asr: Invalid URL scheme '{scheme}'. Endpoint URL must start with https:// (or http:// for localhost)." + ) + + api_key = getattr(self, "_openai_asr_api_key", None) + if not api_key: + from src.config import get_config + api_key = get_config().get_openai_asr_api_key() + if not api_key: + raise RuntimeError( + "openai-asr: No API key configured. " + "Set it in Settings → Transcribe → OpenAI-compatible ASR." + ) - 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: @@ -732,160 +767,215 @@ def _run_openai_asr(self, audio_filepath: Path, language: str) -> dict: 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 → Transcribe → 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 request with its body - # and headers, avoiding standard urllib behavior which drops POST bodies. - new_req = urllib.request.Request( - newurl, - data=req.data, - headers=req.headers, - method=req.method, - origin_req_host=req.origin_req_host, - unverifiable=True - ) - return new_req + if code in (307, 308): + new_req = urllib.request.Request( + newurl, + data=req.data, + headers=req.headers, + method=req.method, + origin_req_host=req.origin_req_host, + unverifiable=True + ) + return new_req + return super().redirect_request(req, fp, code, msg, hdrs, newurl) 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", - ) + def _transcribe_file_chunk(target_path: Path, offset_sec: float) -> dict: + boundary = uuid.uuid4().hex + mime_type = mimetypes.guess_type(str(target_path))[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", target_path.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(target_path) + self.total_size = len(self.prefix_bytes) + self.file_size + len(self.suffix_bytes) + + def __iter__(self): + yield self.prefix_bytes + with open(target_path, "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}" + } + + 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 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), + raw = _do_request("verbose_json") + data = _json.loads(raw.decode()) + 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) + dur = float(data.get("duration") or 0.0) + segments = [ + { + "text": s.get("text", "").strip(), + "start": float(s.get("start") or 0.0) + offset_sec, + "end": float(s.get("end") or 0.0) + offset_sec, + } + for s in raw_segs + if s.get("text", "").strip() + ] + if raw_text and not segments: + segments = [{"text": raw_text, "start": offset_sec, "end": offset_sec + dur}] + return { + "text": raw_text or None, + "segments": segments, + "duration_seconds": dur or None, + "detected_language": detected_lang, } - 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), - ) + 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, 406, 415, 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 return { - "text": raw_text or None, - "segments": segments, - "duration_seconds": float(data.get("duration") or 0) or None, + "text": text or None, + "segments": [{"text": text, "start": offset_sec, "end": offset_sec}] if text else [], + "duration_seconds": 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 + # Chunking if file > 24 MB (~13 min of 16 kHz mono WAV) + file_size = os.path.getsize(audio_filepath) + chunks_to_process = [] + temp_dir_to_clean = None + + if file_size > 24 * 1024 * 1024: + try: + with wave.open(str(audio_filepath), "rb") as wf: + n_channels = wf.getnchannels() + sampwidth = wf.getsampwidth() + framerate = wf.getframerate() + total_frames = wf.getnframes() + frames_per_chunk = 600 * framerate # 10 minutes + offset_frames = 0 + temp_dir_to_clean = Path(tempfile.mkdtemp(prefix="steno_asr_chunks_")) + idx = 0 + while offset_frames < total_frames: + chunk_frames = min(frames_per_chunk, total_frames - offset_frames) + off_sec = offset_frames / float(framerate) + chunk_p = temp_dir_to_clean / f"chunk_{idx}.wav" + wf.setpos(offset_frames) + data_frames = wf.readframes(chunk_frames) + with wave.open(str(chunk_p), "wb") as cwf: + cwf.setnchannels(n_channels) + cwf.setsampwidth(sampwidth) + cwf.setframerate(framerate) + cwf.writeframes(data_frames) + chunks_to_process.append((chunk_p, off_sec)) + offset_frames += chunk_frames + idx += 1 + logger.info("openai-asr: split audio %s (%d MB) into %d chunks", audio_filepath.name, file_size // (1024*1024), len(chunks_to_process)) + except Exception as e: + logger.warning("openai-asr WAV chunking failed: %s; processing unchunked", e) + chunks_to_process = [(audio_filepath, 0.0)] + else: + chunks_to_process = [(audio_filepath, 0.0)] - logger.warning( - "openai-asr verbose_json failed (%s); falling back to text format", - primary_err, - ) + all_text_parts = [] + all_segments = [] + total_dur = 0.0 + det_lang = None + + try: + for c_path, c_off in chunks_to_process: + res = _transcribe_file_chunk(c_path, c_off) + if res.get("text"): + all_text_parts.append(res["text"]) + if res.get("segments"): + all_segments.extend(res["segments"]) + if res.get("duration_seconds"): + total_dur += res["duration_seconds"] + if not det_lang and res.get("detected_language"): + det_lang = res["detected_language"] + finally: + if temp_dir_to_clean and temp_dir_to_clean.exists(): + import shutil + try: + shutil.rmtree(temp_dir_to_clean) + except Exception: + pass - # --- 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)) + merged_text = " ".join(all_text_parts).strip() or None return { - "text": text or None, - "segments": [{"text": text, "start": 0.0, "end": 0.0}] if text else [], - "duration_seconds": None, - "detected_language": detected_lang, + "text": merged_text, + "segments": all_segments, + "duration_seconds": total_dur or None, + "detected_language": det_lang, "detected_language_probability": None, } From dbe1b464e89ba545488065665cace50b2e322ff0 Mon Sep 17 00:00:00 2001 From: Vassista Date: Tue, 4 Aug 2026 09:52:05 +0530 Subject: [PATCH 21/24] fix(main): clean up duplicate Notification class and leftover conflict marker --- app/main.js | 105 ---------------------------------------------------- 1 file changed, 105 deletions(-) diff --git a/app/main.js b/app/main.js index 7f910499..15cc9bad 100644 --- a/app/main.js +++ b/app/main.js @@ -48,99 +48,6 @@ if (process.platform !== 'darwin') { } -const { EventEmitter } = require('events'); - -class Notification extends EventEmitter { - constructor(options) { - super(); - this.options = options; - this.payload = { - id: Math.random().toString(36).substring(7), - title: options.title, - body: options.body || options.subtitle || '', - actions: (options.actions || []).map(a => ({ - id: a.text, - text: a.text, - type: a.type - })) - }; - if (options.iconType) this.payload.iconType = options.iconType; - } - - show() { - if (notificationWindow && !notificationWindow.isDestroyed()) { - notificationWindow.close(); - } - const { screen } = require('electron'); - const primaryDisplay = screen.getPrimaryDisplay(); - const { width } = primaryDisplay.workAreaSize; - const { x, y } = primaryDisplay.workArea; - - const win = new BrowserWindow({ - width: 400, - height: 70, - x: x + width - 425, - y: y + 1, - frame: false, - transparent: true, - alwaysOnTop: true, - resizable: false, - skipTaskbar: true, - focusable: false, - hasShadow: false, - backgroundColor: '#00000000', - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - sandbox: true, - preload: path.join(__dirname, 'preload.js'), - }, - }); - notificationWindow = win; - - const rendererDist = path.join(__dirname, 'renderer', 'dist', 'index.html'); - win.loadFile(rendererDist, { hash: '/notification' }); - - win._activeCustomNotification = this; - win._analyticsInteracted = false; - let autoCloseTimer; - - win.once('ready-to-show', () => { - win.showInactive(); - win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - win.setAlwaysOnTop(true, 'screen-saver', 1); - - autoCloseTimer = setTimeout(() => { - if (!win.isDestroyed()) { - win.close(); - } - }, 15000); - - win.on('closed', () => { - if (autoCloseTimer) clearTimeout(autoCloseTimer); - this.emit('close'); - if (notificationWindow === win) { - notificationWindow = null; - } - }); - - win.webContents.send('show-notification', this.payload); - }); - } - - close() { - if (notificationWindow && notificationWindow._activeCustomNotification === this) { - if (!notificationWindow.isDestroyed()) { - notificationWindow.close(); - } - } - } - - static isSupported() { - return true; - } -} - const path = require('path'); // Backend CLI seam (spawn wrapper, process-tree kill, bundled-backend paths, // runPythonScript), the debug-log sink, and the quit teardown registry are @@ -8402,19 +8309,7 @@ async function showNoteReadyNotification(payload) { ipcMain.handle('show-note-ready-notification', async (_event, payload) => { try { - return showNoteReadyNotification(payload); - } catch (e) { - sendDebugLog(`Failed to show note-ready notification: ${e.message}`); - return { success: false, error: e.message }; - } -}); - const outcome = hardFailure ? 'hard_failure' : failed ? 'failed' : 'success'; - trackNotificationLifecycle(notif, 'note_ready', { outcome }); - notif.show(); - return { success: true, shown: true }; -======= return await showNoteReadyNotification(payload); ->>>>>>> upstream/main } catch (e) { sendDebugLog(`Failed to show note-ready notification: ${e.message}`); return { success: false, error: e.message }; From 8159297694bcb29640715eed246d31f69d08af9c Mon Sep 17 00:00:00 2001 From: Vassista Date: Tue, 4 Aug 2026 10:19:23 +0530 Subject: [PATCH 22/24] fix: resolve review findings for cloud ASR, safeStorage, config fallback, and UI typography --- app/main.js | 18 +++++++++-- app/renderer/src/components/Sidebar.tsx | 23 +++----------- app/renderer/src/routes/settings/AiTab.tsx | 21 +++++++++--- e2e/specs/settings-roundtrip.t2.spec.ts | 25 +++++++++++++++ simple_recorder.py | 3 +- src/config.py | 8 +++-- src/transcriber.py | 37 ++++++++++++---------- 7 files changed, 87 insertions(+), 48 deletions(-) diff --git a/app/main.js b/app/main.js index 15cc9bad..4fe735b9 100644 --- a/app/main.js +++ b/app/main.js @@ -7949,7 +7949,8 @@ ipcMain.handle('get-openai-asr-config', async () => { ipcMain.handle('set-openai-asr-config', async (_event, cfg) => { try { if (cfg.api_key !== undefined) { - saveOpenAiAsrApiKey(cfg.api_key); + const saved = saveOpenAiAsrApiKey(cfg.api_key); + if (!saved) return { success: false, error: 'Failed to save OpenAI ASR API key' }; } const args = ['set-openai-asr-config']; if (cfg.api_url !== undefined) { args.push('--api-url', cfg.api_url); } @@ -8767,6 +8768,10 @@ function loadCloudApiKey() { } } +function hasCloudApiKey() { + return fs.existsSync(getCloudKeyPath()); +} + function getOpenAiAsrKeyPath() { return path.join(getUserDataDir(), '.openai-asr-api-key'); } @@ -8784,7 +8789,12 @@ function saveOpenAiAsrApiKey(key) { if (!fs.existsSync(keyDir)) { fs.mkdirSync(keyDir, { recursive: true }); } - const encrypted = safeStorage.encryptString(key.trim()); + const safe = getSafeStorage(); + if (!safe || !safe.isEncryptionAvailable()) { + console.error('safeStorage is not available to encrypt OpenAI ASR key'); + return false; + } + const encrypted = safe.encryptString(key.trim()); fs.writeFileSync(keyPath, encrypted); return true; } catch (error) { @@ -8797,8 +8807,10 @@ function loadOpenAiAsrApiKey() { try { const keyPath = getOpenAiAsrKeyPath(); if (!fs.existsSync(keyPath)) return null; + const safe = getSafeStorage(); + if (!safe || !safe.isEncryptionAvailable()) return null; const encrypted = fs.readFileSync(keyPath); - return safeStorage.decryptString(encrypted); + return safe.decryptString(encrypted); } catch (error) { console.error('Failed to load OpenAI ASR API key:', error.message); return null; diff --git a/app/renderer/src/components/Sidebar.tsx b/app/renderer/src/components/Sidebar.tsx index b9dee9b3..ccac9ec7 100644 --- a/app/renderer/src/components/Sidebar.tsx +++ b/app/renderer/src/components/Sidebar.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { - ArrowLeft, ChevronDown, Globe, HelpCircle, @@ -13,7 +12,7 @@ import { Search, Settings as SettingsIcon, } from 'lucide-react'; -import { navigate, rememberNonSettingsRoute, toggleSettings, getLastNonSettingsRoute } from '@/lib/router'; +import { navigate, rememberNonSettingsRoute, toggleSettings } from '@/lib/router'; import { cn, shortcut } from '@/lib/utils'; import { ipc } from '@/lib/ipc'; import { LucideIcon, IconPicker } from '@/components/IconPicker'; @@ -169,7 +168,6 @@ export function Sidebar({ const isDraggingRef = React.useRef(false); const [iconPicker, setIconPicker] = React.useState<{ id: string; anchorRect: DOMRect } | null>(null); - const isSettingsMode = currentRoute === '/settings' || currentRoute.startsWith('/settings?'); const updateIcon = useUpdateFolderIcon(); @@ -305,9 +303,9 @@ export function Sidebar({ onClick={() => palette.open()} className="flex h-[30px] w-full items-center rounded-md border-0 px-[10px] pl-[30px] text-left text-[13px] outline-none transition-colors hover:shadow-[inset_0_0_0_1px_hsl(var(--border))] focus-visible:shadow-[inset_0_0_0_1px_hsl(var(--border))]" style={{ background: 'rgba(27,27,25,0.04)', color: 'var(--fg-muted)', fontFamily: 'var(--font-sans)' }} - aria-label={isSettingsMode ? 'Search settings' : 'Search notes'} + aria-label="Search notes" > - {isSettingsMode ? 'Search settings...' : 'Search'} + Search… - {isSettingsMode ? ( -
    - -
    - ) : ( - <> + <>
    - {/* Profile chip + Settings cog */} diff --git a/src/transcriber.py b/src/transcriber.py index f8cdf559..16512cde 100644 --- a/src/transcriber.py +++ b/src/transcriber.py @@ -789,9 +789,19 @@ def redirect_request(self, req, fp, code, msg, hdrs, newurl): ) opener = urllib.request.build_opener(NoRedirectHandler) - def _transcribe_file_chunk(target_path: Path, offset_sec: float) -> dict: + def _transcribe_single_chunk(chunk_path: Path, offset_sec: float) -> dict: + chunk_dur = 0.0 + try: + with wave.open(str(chunk_path), "rb") as wf: + frames = wf.getnframes() + rate = wf.getframerate() + if rate > 0: + chunk_dur = float(frames) / float(rate) + except Exception: + pass + boundary = uuid.uuid4().hex - mime_type = mimetypes.guess_type(str(target_path))[0] or "audio/wav" + mime_type = mimetypes.guess_type(str(chunk_path))[0] or "audio/wav" def _field(name: str, value: str) -> bytes: return ( From 9dfa10110bb4df868034fee585b1cc26caed5855 Mon Sep 17 00:00:00 2001 From: Sai Vassista <119868415+Vassista@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:00:47 +0530 Subject: [PATCH 24/24] Update src/transcriber.py Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- src/transcriber.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/transcriber.py b/src/transcriber.py index 16512cde..f285ac41 100644 --- a/src/transcriber.py +++ b/src/transcriber.py @@ -789,10 +789,10 @@ def redirect_request(self, req, fp, code, msg, hdrs, newurl): ) opener = urllib.request.build_opener(NoRedirectHandler) - def _transcribe_single_chunk(chunk_path: Path, offset_sec: float) -> dict: + def _transcribe_file_chunk(target_path: Path, offset_sec: float) -> dict: chunk_dur = 0.0 try: - with wave.open(str(chunk_path), "rb") as wf: + with wave.open(str(target_path), "rb") as wf: frames = wf.getnframes() rate = wf.getframerate() if rate > 0: @@ -801,7 +801,7 @@ def _transcribe_single_chunk(chunk_path: Path, offset_sec: float) -> dict: pass boundary = uuid.uuid4().hex - mime_type = mimetypes.guess_type(str(chunk_path))[0] or "audio/wav" + mime_type = mimetypes.guess_type(str(target_path))[0] or "audio/wav" def _field(name: str, value: str) -> bytes: return (