diff --git a/README.md b/README.md index e7a9e00..745967a 100644 --- a/README.md +++ b/README.md @@ -320,14 +320,20 @@ precedence on NVIDIA systems. `large-v3-turbo` is the modern speed/quality sweet spot at the top end (≈8× faster decoding than `large-v3`); pick it if accuracy matters more than leaving the CPU idle. -Drop the file in `resources/whisper/models/` and set its filename in Settings, or pass -it to the script: `pwsh ./scripts/setup-whisper.ps1 -Model ggml-large-v3-turbo-q5_0.bin`. +Drop the file in `resources/whisper/models/`; Parley discovers installed `.bin` files +and lists each one under **Settings → Transcription → Model**. You can also pass it to +the setup script: `pwsh ./scripts/setup-whisper.ps1 -Model ggml-large-v3-turbo-q5_0.bin`. ### Or: use a remote transcription server -If you'd rather not transcribe on this machine, run a compatible server elsewhere -and set **Settings → Transcription → Remote transcription URL** (e.g. -`http://192.168.1.10:8765`). When set, Parley skips the bundled engine entirely. +If you'd rather not transcribe on this machine, run a compatible server elsewhere, +select **Settings → Transcription → Model → External server**, and enter its URL +(e.g. `http://192.168.1.10:8765`). Parley can test reachability but does not manage +the remote process. When selected, it skips local model loading entirely. + +The same section lists Automatic, installed Nemotron, and every installed Whisper +model. Local selections can be started, stopped to release memory, or restarted while +no meeting is active. Starting a meeting automatically reloads a stopped local model. > ⚠️ **Platform note:** the bundled-engine path is currently hard-coded to the Windows > layout (`bin/Release/whisper-server.exe`). On macOS/Linux, use the **remote URL** @@ -369,8 +375,8 @@ Nemotron model or Python/CUDA runtime. 3. **Settings** (gear icon): save one **LLM connection** per provider (name, base URL, model, optional API key) — a local llama-server / LM Studio / Ollama, or a cloud URL. Mark one **active** (★), **Test** each, and set the analysis interval - and transcription options. Switch which connection a meeting uses from the - **LLM connection dropdown in the header** (before you start the meeting). + and choose/manage the transcription model. Switch which LLM connection a meeting + uses from the **LLM connection dropdown in the header** (before you start the meeting). 4. Check the footer for the installed version and selected **Voice-to-text** model. Local model weights begin loading when Parley opens; if the footer still says *Loading local model…*, starting a meeting waits only for the remaining load time. @@ -414,23 +420,23 @@ pre-meeting context followed by every timestamped transcript line. - **"The local transcription engine isn't installed" on Start.** You haven't fetched the whisper engine yet — run **`task setup:whisper`** (or `scripts/setup-whisper.ps1`), - or set a remote transcription URL in Settings. Parley shows the reason in a red banner + or select an External server in Settings. Parley shows the reason in a red banner and writes full details to **`parley.log`** in your app-data folder (Windows: `%AppData%\Parley\`). For a packaged build, the `resources/whisper/` folder must sit next to the `.exe`. - **The installer says no NVIDIA GPU, but `nvidia-smi -L` shows one.** Install Parley **v0.1.3 or newer**. Older installers ran the 64-bit NVIDIA utility through a redirected 32-bit shell, which could incorrectly report no GPU. -- **Nemotron was not selected on an NVIDIA system.** The footer shows the backend - Parley actually selected. Check +- **Automatic did not select Nemotron on an NVIDIA system.** The footer shows the + backend Parley actually selected. Check `%AppData%\Parley\nemotron-server.log`. Parley requires a complete - `resources\nemotron` installation with a `.ready` marker and falls back to CPU - Whisper when the model cannot load. On an installed per-user copy, close Parley - and resume provisioning from 64-bit PowerShell (the script reuses files already - present): + Nemotron installation with a `.ready` marker and falls back to CPU Whisper when + the model cannot load. An explicitly selected Nemotron reports the failure instead + of silently switching models. On an installed per-user copy, close Parley and + resume provisioning from 64-bit PowerShell; the script reuses files already present: ```powershell - powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$env:LOCALAPPDATA\Programs\Parley\resources\nemotron\setup.ps1" -InstallRoot "$env:LOCALAPPDATA\Programs\Parley\resources\nemotron" + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$env:LOCALAPPDATA\Programs\Parley\resources\nemotron\setup.ps1" -InstallRoot "$env:LOCALAPPDATA\Parley\nemotron" ``` - **"No mic" with a mic selected.** The badge now reflects whether a microphone source actually started. If it still says *No mic*, that device failed to open (wrong device, diff --git a/frontend/bindings/github.com/tomvokac/parley/index.ts b/frontend/bindings/github.com/tomvokac/parley/index.ts index 55ae7f4..5af1244 100644 --- a/frontend/bindings/github.com/tomvokac/parley/index.ts +++ b/frontend/bindings/github.com/tomvokac/parley/index.ts @@ -12,5 +12,7 @@ export type { AnalysisStatusEvent, LoadedSession, RuntimeInfo, - StatusEvent + StatusEvent, + TranscriptionConfig, + TranscriptionModelOption } from "./models.js"; diff --git a/frontend/bindings/github.com/tomvokac/parley/internal/store/models.ts b/frontend/bindings/github.com/tomvokac/parley/internal/store/models.ts index fb52065..c53fe14 100644 --- a/frontend/bindings/github.com/tomvokac/parley/internal/store/models.ts +++ b/frontend/bindings/github.com/tomvokac/parley/internal/store/models.ts @@ -108,9 +108,15 @@ export interface Settings { "hasAPIKey": boolean; "captureSources": CaptureSource[] | null; + /** + * SttEngine selects how transcription is provided: "auto", "nemotron", + * "whisper", or "external". Auto preserves the original GPU-first fallback. + */ + "sttEngine": string; + /** * SttBaseURL, when set, points transcription at a remote /inference-compatible - * server (e.g. http://host:8765) instead of launching a local engine. + * server (e.g. http://host:8765). It is used only when SttEngine is "external". */ "sttBaseURL": string; diff --git a/frontend/bindings/github.com/tomvokac/parley/meetingservice.ts b/frontend/bindings/github.com/tomvokac/parley/meetingservice.ts index f9c426c..e0f2671 100644 --- a/frontend/bindings/github.com/tomvokac/parley/meetingservice.ts +++ b/frontend/bindings/github.com/tomvokac/parley/meetingservice.ts @@ -30,6 +30,14 @@ export function AddLiveNote(scope: string, text: string): $CancellablePromise { + return $Call.ByID(713856948, config); +} + /** * DeleteSession permanently removes a saved meeting (not the one in progress). */ @@ -85,6 +93,14 @@ export function ListSessions(): $CancellablePromise { return $Call.ByID(1774945289); } +/** + * ListTranscriptionModels discovers the selectable local installations and the + * always-available external-server choice. + */ +export function ListTranscriptionModels(): $CancellablePromise<$models.TranscriptionModelOption[] | null> { + return $Call.ByID(1026157818); +} + /** * LoadSession returns a saved meeting's full state for display. */ @@ -99,6 +115,13 @@ export function RenameSession(id: number, title: string): $CancellablePromise { + return $Call.ByID(3040477430); +} + /** * Resume continues a previously saved meeting, appending new transcript/analysis * to it. Pass the session id returned by ListSessions. @@ -115,9 +138,32 @@ export function Start(): $CancellablePromise { return $Call.ByID(4124033072); } +/** + * StartTranscriptionModel loads the selected local model without starting a meeting. + */ +export function StartTranscriptionModel(): $CancellablePromise { + return $Call.ByID(2331669113); +} + /** * Stop ends the session, flushing the final audio and closing recordings. */ export function Stop(): $CancellablePromise { return $Call.ByID(252224180); } + +/** + * StopTranscriptionModel releases the selected local model while Parley is idle. + */ +export function StopTranscriptionModel(): $CancellablePromise { + return $Call.ByID(207813981); +} + +/** + * TestExternalTranscription verifies that an external HTTP server is reachable. + * Any HTTP response counts as reachable; the inference path is exercised by the + * first real transcription request. + */ +export function TestExternalTranscription(baseURL: string): $CancellablePromise { + return $Call.ByID(1170675977, baseURL); +} diff --git a/frontend/bindings/github.com/tomvokac/parley/models.ts b/frontend/bindings/github.com/tomvokac/parley/models.ts index fd55e1f..65e7ef9 100644 --- a/frontend/bindings/github.com/tomvokac/parley/models.ts +++ b/frontend/bindings/github.com/tomvokac/parley/models.ts @@ -42,11 +42,18 @@ export interface LoadedSession { export interface RuntimeInfo { "appVersion": string; "transcriptionModel": string; + "transcriptionModelID": string; /** - * loading | ready | error + * local | external + */ + "transcriptionKind": string; + + /** + * stopped | loading | ready | configured | error */ "transcriptionStatus": string; + "transcriptionMessage": string; } /** @@ -61,3 +68,28 @@ export interface StatusEvent { "micAvailable": boolean; "activeSources": string[] | null; } + +/** + * TranscriptionConfig is the user-facing selection saved by the model manager. + * ModelID is one of auto, nemotron, whisper:, or external. + */ +export interface TranscriptionConfig { + "modelID": string; + "externalURL": string; +} + +/** + * TranscriptionModelOption describes one selectable transcription provider. + */ +export interface TranscriptionModelOption { + "id": string; + "label": string; + + /** + * automatic | local | external + */ + "kind": string; + "detail": string; + "available": boolean; + "unavailableReason": string; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1236c8c..4378283 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { Events } from "@wailsio/runtime"; import { AlertTriangle, AudioLines, + CheckCircle2, Download, History, Loader2, @@ -152,7 +153,10 @@ function App() { const [runtimeInfo, setRuntimeInfo] = useState({ appVersion: "", transcriptionModel: "Loading local model…", + transcriptionModelID: "auto", + transcriptionKind: "local", transcriptionStatus: "loading", + transcriptionMessage: "Loading the selected transcription model…", }); // Session-scoped suggestion pins/dismissals, keyed by normalized text. The // `analysis` listener is registered once, so it reads these via refs to avoid a @@ -265,6 +269,7 @@ function App() { ...info, transcriptionModel: "Model status unavailable", transcriptionStatus: "error", + transcriptionMessage: "Parley could not read the transcription model status.", })) ); @@ -708,11 +713,20 @@ function App() { {runtimeInfo.transcriptionStatus === "loading" && ( )} + {(runtimeInfo.transcriptionStatus === "ready" || runtimeInfo.transcriptionStatus === "configured") && ( + + )} + {runtimeInfo.transcriptionStatus === "error" && ( + + )} + {runtimeInfo.transcriptionStatus === "stopped" && ( + + )} Voice-to-text: {runtimeInfo.transcriptionModel} @@ -721,7 +735,12 @@ function App() { - + ({ id: 0, name: "", @@ -79,9 +111,13 @@ function friendlyError(raw: string): string { export function SettingsDialog({ open, onOpenChange, + meetingActive, + runtimeInfo, }: { open: boolean; onOpenChange: (v: boolean) => void; + meetingActive: boolean; + runtimeInfo: RuntimeInfo; }) { const [settings, setSettings] = useState(DEFAULTS); const [conns, setConns] = useState([]); @@ -90,6 +126,11 @@ export function SettingsDialog({ const [apiKey, setApiKey] = useState(""); const [test, setTest] = useState<"idle" | "running" | "ok" | "fail">("idle"); const [testMsg, setTestMsg] = useState(""); + const [transcriptionModels, setTranscriptionModels] = useState([]); + const [savedTranscription, setSavedTranscription] = useState({ modelID: "auto", externalURL: "" }); + const [modelAction, setModelAction] = useState<"" | "saving" | "starting" | "stopping" | "restarting" | "testing">(""); + const [modelMessage, setModelMessage] = useState(""); + const [modelTestOK, setModelTestOK] = useState(false); const loadConns = () => LibraryService.ListLLMConnections().then((c) => setConns(c ?? [])); @@ -99,15 +140,74 @@ export function SettingsDialog({ setApiKey(""); setTest("idle"); setTestMsg(""); + setModelAction(""); + setModelMessage(""); + setModelTestOK(false); + setTranscriptionModels([]); LibraryService.GetSettings() - .then((s) => setSettings(s ?? DEFAULTS)) - .catch(() => setSettings(DEFAULTS)); + .then((s) => { + const loaded = s ?? DEFAULTS; + setSettings(loaded); + setSavedTranscription({ + modelID: transcriptionModelID(loaded), + externalURL: loaded.sttBaseURL.trim().replace(/\/+$/, ""), + }); + }) + .catch(() => { + setSettings(DEFAULTS); + setSavedTranscription({ modelID: "auto", externalURL: "" }); + }); + + MeetingService.ListTranscriptionModels() + .then((models) => setTranscriptionModels(models ?? [])) + .catch(() => setTranscriptionModels([])); loadConns().catch(() => setConns([])); }, [open]); const saveOther = async () => { - await LibraryService.SaveSettings(settings); - onOpenChange(false); + setModelAction("saving"); + setModelMessage(""); + try { + const modelID = transcriptionModelID(settings); + const externalURL = settings.sttBaseURL.trim().replace(/\/+$/, ""); + await MeetingService.ConfigureTranscription({ modelID, externalURL }); + await LibraryService.SaveSettings({ ...settings, sttBaseURL: externalURL }); + onOpenChange(false); + } catch (e: any) { + setModelMessage(friendlyError(String(e?.message ?? e))); + } finally { + setModelAction(""); + } + }; + + const runModelAction = async (action: "starting" | "stopping" | "restarting") => { + setModelAction(action); + setModelMessage(""); + setModelTestOK(false); + try { + if (action === "starting") await MeetingService.StartTranscriptionModel(); + if (action === "stopping") await MeetingService.StopTranscriptionModel(); + if (action === "restarting") await MeetingService.RestartTranscriptionModel(); + } catch (e: any) { + setModelMessage(friendlyError(String(e?.message ?? e))); + } finally { + setModelAction(""); + } + }; + + const testExternal = async () => { + setModelAction("testing"); + setModelMessage(""); + setModelTestOK(false); + try { + await MeetingService.TestExternalTranscription(settings.sttBaseURL); + setModelTestOK(true); + setModelMessage("Connected — the external transcription server responded."); + } catch (e: any) { + setModelMessage(friendlyError(String(e?.message ?? e))); + } finally { + setModelAction(""); + } }; const setActive = async (id: number) => { @@ -154,6 +254,14 @@ export function SettingsDialog({ }; const canSaveConn = !!editing?.name.trim() && !!editing?.baseURL.trim(); + const selectedModelID = transcriptionModelID(settings); + const selectedModel = transcriptionModels.find((model) => model.id === selectedModelID); + const normalizedExternalURL = settings.sttBaseURL.trim().replace(/\/+$/, ""); + const modelDirty = + selectedModelID !== savedTranscription.modelID || + (selectedModelID === "external" && normalizedExternalURL !== savedTranscription.externalURL); + const lifecycleDisabled = meetingActive || modelDirty || modelAction !== ""; + const isLocalRuntime = runtimeInfo.transcriptionKind !== "external"; return ( @@ -373,43 +481,156 @@ export function SettingsDialog({
Transcription
- - setSettings({ ...settings, sttBaseURL: e.target.value })} - /> + +

- Blank = transcribe locally (private, no setup), using Nemotron on a - supported NVIDIA GPU or the bundled CPU Whisper fallback. Set this - to a compatible server URL (e.g. - http://192.168.1.10:8765) to offload transcription to another machine. + {selectedModel?.unavailableReason || selectedModel?.detail || + "Installed local models and compatible external servers appear here."}

-
- - setSettings({ ...settings, whisperModel: e.target.value })} - /> -

- Filename under resources/whisper/models. Defaults to - ggml-small.en-q5_1.bin — quantized, accurate on names/jargon, and - light enough to leave your laptop free for other work. Drop in - ggml-base.en.bin for a lighter load, or ggml-large-v3-turbo-q5_0.bin - for top accuracy if you have CPU headroom. -

+ + {selectedModelID === "external" && ( +
+ +
+ { + setSettings({ ...settings, sttBaseURL: e.target.value }); + setModelMessage(""); + setModelTestOK(false); + }} + /> + +
+

+ Parley sends audio to the server's /inference endpoint but does not + start, stop, or restart the remote process. +

+
+ )} + +
+
+ {runtimeInfo.transcriptionStatus === "loading" ? ( + + ) : runtimeInfo.transcriptionStatus === "ready" || runtimeInfo.transcriptionStatus === "configured" ? ( + + ) : runtimeInfo.transcriptionStatus === "error" ? ( + + ) : ( + + )} +
+
+ {runtimeInfo.transcriptionModel || "Transcription model"} +
+
+ {modelDirty + ? "Save these settings to apply the selected model." + : runtimeInfo.transcriptionMessage || "Model status unavailable."} +
+
+
+ + {isLocalRuntime && ( +
+ + + +
+ )} + + {meetingActive && ( +

+ Stop the active meeting before changing or restarting transcription. +

+ )}
+ + {modelMessage && ( +
+ {modelTestOK ? ( + + ) : ( + + )} + {modelMessage} +
+ )}
- +
diff --git a/internal/store/store.go b/internal/store/store.go index cc30964..c3aa902 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -46,8 +46,11 @@ type Settings struct { ActiveProfileID int64 `json:"activeProfileID"` HasAPIKey bool `json:"hasAPIKey"` CaptureSources []CaptureSource `json:"captureSources"` + // SttEngine selects how transcription is provided: "auto", "nemotron", + // "whisper", or "external". Auto preserves the original GPU-first fallback. + SttEngine string `json:"sttEngine"` // SttBaseURL, when set, points transcription at a remote /inference-compatible - // server (e.g. http://host:8765) instead of launching a local engine. + // server (e.g. http://host:8765). It is used only when SttEngine is "external". SttBaseURL string `json:"sttBaseURL"` // WhisperModel is the model filename under resources/whisper/models used by the // bundled engine. Defaults to ggml-small.en-q5_1.bin (quantized; accurate but @@ -169,6 +172,17 @@ CREATE TABLE IF NOT EXISTS llm_connections ( if err := s.addColumn("settings", "stt_base_url", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } + if err := s.addColumn("settings", "stt_engine", "TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + // A blank value only exists on databases created before explicit engine + // selection. Preserve their old behavior: a configured URL meant remote; + // otherwise Parley automatically preferred Nemotron and fell back to Whisper. + if _, err := s.db.Exec(`UPDATE settings + SET stt_engine = CASE WHEN TRIM(stt_base_url) <> '' THEN 'external' ELSE 'auto' END + WHERE TRIM(stt_engine) = ''`); err != nil { + return err + } if err := s.addColumn("settings", "whisper_model", "TEXT NOT NULL DEFAULT 'ggml-small.en-q5_1.bin'"); err != nil { return err } @@ -240,8 +254,8 @@ func (s *Store) addColumn(table, column, decl string) error { func (s *Store) GetSettings() (Settings, error) { var st Settings var sourcesJSON string - row := s.db.QueryRow(`SELECT llm_base_url, llm_model, analysis_interval_sec, analysis_timeout_sec, logging_level, active_profile_id, capture_sources, stt_base_url, whisper_model, active_llm_connection_id FROM settings WHERE id = 1`) - if err := row.Scan(&st.LLMBaseURL, &st.LLMModel, &st.AnalysisIntervalSec, &st.AnalysisTimeoutSec, &st.LoggingLevel, &st.ActiveProfileID, &sourcesJSON, &st.SttBaseURL, &st.WhisperModel, &st.ActiveLLMConnectionID); err != nil { + row := s.db.QueryRow(`SELECT llm_base_url, llm_model, analysis_interval_sec, analysis_timeout_sec, logging_level, active_profile_id, capture_sources, stt_engine, stt_base_url, whisper_model, active_llm_connection_id FROM settings WHERE id = 1`) + if err := row.Scan(&st.LLMBaseURL, &st.LLMModel, &st.AnalysisIntervalSec, &st.AnalysisTimeoutSec, &st.LoggingLevel, &st.ActiveProfileID, &sourcesJSON, &st.SttEngine, &st.SttBaseURL, &st.WhisperModel, &st.ActiveLLMConnectionID); err != nil { return Settings{}, err } if st.AnalysisIntervalSec <= 0 { @@ -253,6 +267,7 @@ func (s *Store) GetSettings() (Settings, error) { if st.WhisperModel == "" { st.WhisperModel = "ggml-small.en-q5_1.bin" } + st.SttEngine = normalizeSTTEngine(st.SttEngine, st.SttBaseURL) st.LoggingLevel = normalizeLoggingLevel(st.LoggingLevel) st.CaptureSources = []CaptureSource{} if sourcesJSON != "" { @@ -275,12 +290,37 @@ func (s *Store) SaveSettings(st Settings) error { return err } _, err = s.db.Exec( - `UPDATE settings SET llm_base_url = ?, llm_model = ?, analysis_interval_sec = ?, analysis_timeout_sec = ?, logging_level = ?, active_profile_id = ?, capture_sources = ?, stt_base_url = ?, whisper_model = ?, active_llm_connection_id = ? WHERE id = 1`, - st.LLMBaseURL, st.LLMModel, st.AnalysisIntervalSec, st.AnalysisTimeoutSec, normalizeLoggingLevel(st.LoggingLevel), st.ActiveProfileID, string(sourcesJSON), st.SttBaseURL, st.WhisperModel, st.ActiveLLMConnectionID, + `UPDATE settings SET llm_base_url = ?, llm_model = ?, analysis_interval_sec = ?, analysis_timeout_sec = ?, logging_level = ?, active_profile_id = ?, capture_sources = ?, stt_engine = ?, stt_base_url = ?, whisper_model = ?, active_llm_connection_id = ? WHERE id = 1`, + st.LLMBaseURL, st.LLMModel, st.AnalysisIntervalSec, st.AnalysisTimeoutSec, normalizeLoggingLevel(st.LoggingLevel), st.ActiveProfileID, string(sourcesJSON), normalizeSTTEngine(st.SttEngine, st.SttBaseURL), st.SttBaseURL, st.WhisperModel, st.ActiveLLMConnectionID, + ) + return err +} + +// SaveTranscriptionSettings updates only transcription configuration so the +// lifecycle service can validate and persist a model change atomically without +// overwriting unrelated edits in the Settings dialog. +func (s *Store) SaveTranscriptionSettings(engine, baseURL, whisperModel string) error { + _, err := s.db.Exec( + `UPDATE settings SET stt_engine = ?, stt_base_url = ?, whisper_model = ? WHERE id = 1`, + normalizeSTTEngine(engine, baseURL), strings.TrimSpace(baseURL), strings.TrimSpace(whisperModel), ) return err } +func normalizeSTTEngine(engine, baseURL string) string { + switch strings.ToLower(strings.TrimSpace(engine)) { + case "nemotron", "whisper", "external": + return strings.ToLower(strings.TrimSpace(engine)) + case "auto": + return "auto" + default: + if strings.TrimSpace(baseURL) != "" { + return "external" + } + return "auto" + } +} + func normalizeLoggingLevel(level string) string { switch strings.ToLower(strings.TrimSpace(level)) { case "error", "none": diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 4536c74..83bb17f 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -20,20 +20,44 @@ func TestSettingsDefaultsAndUpdate(t *testing.T) { if got.LoggingLevel != "trace" { t.Fatalf("logging level default = %q, want trace", got.LoggingLevel) } + if got.SttEngine != "auto" { + t.Fatalf("transcription engine default = %q, want auto", got.SttEngine) + } got.LLMModel = "qwen2.5" got.AnalysisIntervalSec = 20 got.AnalysisTimeoutSec = 45 got.LoggingLevel = "error" + got.SttEngine = "whisper" + got.WhisperModel = "ggml-base.en.bin" if err := s.SaveSettings(got); err != nil { t.Fatalf("SaveSettings: %v", err) } again, _ := s.GetSettings() - if again.LLMModel != "qwen2.5" || again.AnalysisIntervalSec != 20 || again.AnalysisTimeoutSec != 45 || again.LoggingLevel != "error" { + if again.LLMModel != "qwen2.5" || again.AnalysisIntervalSec != 20 || again.AnalysisTimeoutSec != 45 || again.LoggingLevel != "error" || again.SttEngine != "whisper" || again.WhisperModel != "ggml-base.en.bin" { t.Fatalf("settings not persisted: %+v", again) } } +func TestLegacyRemoteSettingsMigrateToExternalEngine(t *testing.T) { + s := openTemp(t) + if _, err := s.db.Exec(`UPDATE settings SET stt_engine = '', stt_base_url = 'http://stt.local:8765' WHERE id = 1`); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(`UPDATE settings + SET stt_engine = CASE WHEN TRIM(stt_base_url) <> '' THEN 'external' ELSE 'auto' END + WHERE TRIM(stt_engine) = ''`); err != nil { + t.Fatal(err) + } + got, err := s.GetSettings() + if err != nil { + t.Fatal(err) + } + if got.SttEngine != "external" { + t.Fatalf("legacy remote engine = %q, want external", got.SttEngine) + } +} + func TestProfileCRUD(t *testing.T) { s := openTemp(t) diff --git a/meeting_service.go b/meeting_service.go index ba6fd27..1fdc27b 100644 --- a/meeting_service.go +++ b/meeting_service.go @@ -3,10 +3,14 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "log" + "net/http" + "net/url" "os" "path/filepath" + "sort" "strings" "sync" "sync/atomic" @@ -50,13 +54,33 @@ type localEngineResult struct { model string } +// TranscriptionConfig is the user-facing selection saved by the model manager. +// ModelID is one of auto, nemotron, whisper:, or external. +type TranscriptionConfig struct { + ModelID string `json:"modelID"` + ExternalURL string `json:"externalURL"` +} + +// TranscriptionModelOption describes one selectable transcription provider. +type TranscriptionModelOption struct { + ID string `json:"id"` + Label string `json:"label"` + Kind string `json:"kind"` // automatic | local | external + Detail string `json:"detail"` + Available bool `json:"available"` + UnavailableReason string `json:"unavailableReason"` +} + // RuntimeInfo is the build and transcription metadata currently in use. The // frontend displays it persistently so packaged-version or model-selection // problems can be identified without opening log files. type RuntimeInfo struct { - AppVersion string `json:"appVersion"` - TranscriptionModel string `json:"transcriptionModel"` - TranscriptionStatus string `json:"transcriptionStatus"` // loading | ready | error + AppVersion string `json:"appVersion"` + TranscriptionModel string `json:"transcriptionModel"` + TranscriptionModelID string `json:"transcriptionModelID"` + TranscriptionKind string `json:"transcriptionKind"` // local | external + TranscriptionStatus string `json:"transcriptionStatus"` // stopped | loading | ready | configured | error + TranscriptionMessage string `json:"transcriptionMessage"` } // StatusEvent is broadcast whenever the capture/transcription state changes. @@ -127,17 +151,20 @@ type MeetingService struct { // The local transcription server belongs to the app, not an individual // meeting. ServiceStartup begins loading it in the background; Start waits on - // the same result if preparation is still underway, and ServiceShutdown is the - // only normal path that releases the model weights. + // the same result if preparation is still underway. Idle lifecycle controls or + // ServiceShutdown can release the model weights. localMu sync.Mutex localDone chan struct{} localCancel context.CancelFunc localResult localEngineResult localErr error + localState string + localKey string + localGen uint64 hasNVIDIAGPU func() bool newNemotron func() (managedSTTServer, error) - newCPUWhisper func(store.Settings) (managedSTTServer, string, error) + newCPUWhisper func(store.Settings, bool) (managedSTTServer, string, error) sessionID atomic.Int64 // active session row; 0 when not persisting lastSessionID atomic.Int64 // most recent persisted session, retained after Stop for export @@ -164,8 +191,8 @@ func NewMeetingService(s *store.Store) *MeetingService { hasNVIDIAGPU: stt.HasNVIDIAGPU, } m.newNemotron = func() (managedSTTServer, error) { return newNemotronServer() } - m.newCPUWhisper = func(settings store.Settings) (managedSTTServer, string, error) { - return newCPUWhisperServer(settings) + m.newCPUWhisper = func(settings store.Settings, allowFallback bool) (managedSTTServer, string, error) { + return newCPUWhisperServer(settings, allowFallback) } return m } @@ -179,7 +206,7 @@ func (m *MeetingService) ServiceStartup(ctx context.Context, _ application.Servi log.Printf("[stt] could not read settings for startup preload: %v", err) return nil } - if strings.TrimSpace(settings.SttBaseURL) != "" { + if settings.SttEngine == "external" { log.Printf("[stt] remote transcription configured; skipping local model preload") return nil } @@ -204,42 +231,55 @@ func (m *MeetingService) IsRunning() bool { // Nemotron was preferred but could not start, rather than merely echoing the // configured model filename. func (m *MeetingService) GetRuntimeInfo() RuntimeInfo { - info := RuntimeInfo{AppVersion: appVersion, TranscriptionStatus: "loading"} + info := RuntimeInfo{AppVersion: appVersion, TranscriptionKind: "local", TranscriptionStatus: "stopped"} + var settings store.Settings if m.store != nil { - settings, err := m.store.GetSettings() + var err error + settings, err = m.store.GetSettings() if err != nil { info.TranscriptionModel = "Unavailable" info.TranscriptionStatus = "error" + info.TranscriptionMessage = "Could not read transcription settings." return info } - if remote := strings.TrimSpace(settings.SttBaseURL); remote != "" { - info.TranscriptionModel = "Remote model · " + strings.TrimRight(remote, "/") - info.TranscriptionStatus = "ready" + info.TranscriptionModelID = transcriptionModelID(settings) + if settings.SttEngine == "external" { + remote := strings.TrimRight(strings.TrimSpace(settings.SttBaseURL), "/") + info.TranscriptionKind = "external" + info.TranscriptionModel = "External · " + remote + info.TranscriptionStatus = "configured" + info.TranscriptionMessage = "Parley does not manage the external server process." return info } } m.localMu.Lock() - done := m.localDone result := m.localResult loadErr := m.localErr + state := m.localState m.localMu.Unlock() - if done == nil { - info.TranscriptionModel = "Preparing local model…" - return info + if info.TranscriptionModelID == "" { + info.TranscriptionModelID = "auto" } - select { - case <-done: - if loadErr != nil || result.server == nil { - info.TranscriptionModel = "Local model unavailable" - info.TranscriptionStatus = "error" - return info - } + info.TranscriptionModel = configuredTranscriptionLabel(settings) + switch state { + case "loading": + info.TranscriptionStatus = "loading" + info.TranscriptionMessage = "Loading the selected transcription model…" + case "ready": info.TranscriptionModel = result.model info.TranscriptionStatus = "ready" + info.TranscriptionMessage = "Ready for transcription." + case "error": + info.TranscriptionStatus = "error" + info.TranscriptionMessage = "The selected model could not start." + if loadErr != nil { + info.TranscriptionMessage = loadErr.Error() + } default: - info.TranscriptionModel = "Loading local model…" + info.TranscriptionStatus = "stopped" + info.TranscriptionMessage = "The selected local model is stopped." } return info } @@ -264,12 +304,16 @@ func (m *MeetingService) start(resumeID int64) error { settings, _ := m.store.GetSettings() - // Transcription endpoint: a configured compatible server, or a supervised - // local engine. NVIDIA systems prefer the optional Nemotron installation; - // bundled CPU Whisper remains the always-available fallback. + // Transcription endpoint: the explicitly configured remote server or a + // supervised local engine. Automatic local selection retains the original + // Nemotron-first, Whisper-fallback behavior. var sttURL string streamingSTT := false - if remote := strings.TrimSpace(settings.SttBaseURL); remote != "" { + if settings.SttEngine == "external" { + remote := strings.TrimSpace(settings.SttBaseURL) + if remote == "" { + return m.fail("The external transcription URL is empty. Choose a local model or configure the server URL in Settings.", errors.New("external transcription URL is empty")) + } log.Printf("[stt] using remote transcription server: %s", remote) sttURL = strings.TrimRight(remote, "/") } else { @@ -354,7 +398,7 @@ func newNemotronServer() (*stt.Server, error) { } // newCPUWhisperServer resolves the bundled fallback without starting it. -func newCPUWhisperServer(settings store.Settings) (*stt.Server, string, error) { +func newCPUWhisperServer(settings store.Settings, allowFallback bool) (*stt.Server, string, error) { cpuBinPath, err := resolveResource(filepath.Join("resources", "whisper", "bin", "Release", "whisper-server.exe")) if err != nil { return nil, "", fmt.Errorf("resolve bundled whisper server: %w", err) @@ -364,7 +408,7 @@ func newCPUWhisperServer(settings store.Settings) (*stt.Server, string, error) { modelName = "ggml-small.en-q5_1.bin" } modelPath, err := resolveResource(filepath.Join("resources", "whisper", "models", modelName)) - if err != nil { + if err != nil && allowFallback { // The configured filename isn't present — commonly because the default // model changed but a stale name is still saved in Settings, or a // differently-named file was downloaded. Rather than hard-fail, fall back @@ -373,36 +417,60 @@ func newCPUWhisperServer(settings store.Settings) (*stt.Server, string, error) { log.Printf("[stt] configured model %q not found; falling back to installed model %q", modelName, altName) modelPath = alt modelName = altName + err = nil } else { return nil, "", fmt.Errorf("transcription model %q is missing and no fallback model is installed: %w", modelName, err) } } + if err != nil { + return nil, "", fmt.Errorf("transcription model %q is missing: %w", modelName, err) + } return stt.NewServer(cpuBinPath, modelPath, whisperHost, whisperPort, filepath.Join(dataDir(), "whisper-server.log")), modelName, nil } -// beginLocalEngine starts one app-lifetime preparation attempt and returns the -// channel closed when either Nemotron or the CPU fallback is ready (or both have -// failed). Every caller observes the same result, preventing duplicate model -// loads when Start is clicked while startup preparation is still running. +// beginLocalEngine starts (or joins) a preparation attempt for the configured +// local model. A generation token prevents a canceled load from publishing a +// stale process after Stop, Restart, or a model change. func (m *MeetingService) beginLocalEngine(parent context.Context, settings store.Settings) <-chan struct{} { + key := localEngineKey(settings) m.localMu.Lock() - if m.localDone != nil { + if m.localDone != nil && m.localKey == key && (m.localState == "loading" || m.localState == "ready") { done := m.localDone m.localMu.Unlock() return done } + m.localGen++ + generation := m.localGen ctx, cancel := context.WithCancel(parent) done := make(chan struct{}) m.localDone = done m.localCancel = cancel + m.localResult = localEngineResult{} + m.localErr = nil + m.localState = "loading" + m.localKey = key m.localMu.Unlock() + emitRuntimeInfo(m.GetRuntimeInfo()) go func() { result, err := m.loadLocalEngine(ctx, settings) m.localMu.Lock() + if generation != m.localGen { + m.localMu.Unlock() + if result.server != nil { + result.server.Stop() + } + close(done) + return + } m.localResult = result m.localErr = err m.localCancel = nil + if err != nil || result.server == nil { + m.localState = "error" + } else { + m.localState = "ready" + } close(done) m.localMu.Unlock() emitRuntimeInfo(m.GetRuntimeInfo()) @@ -411,6 +479,43 @@ func (m *MeetingService) beginLocalEngine(parent context.Context, settings store } func (m *MeetingService) loadLocalEngine(ctx context.Context, settings store.Settings) (localEngineResult, error) { + switch settings.SttEngine { + case "nemotron": + if !m.hasNVIDIAGPU() { + return localEngineResult{}, errors.New("Nemotron requires a working NVIDIA GPU") + } + nemotron, err := m.newNemotron() + if err != nil { + return localEngineResult{}, fmt.Errorf("Nemotron is not fully installed: %w", err) + } + if err := nemotron.Start(ctx); err != nil { + nemotron.Stop() + return localEngineResult{}, fmt.Errorf("Nemotron: %w", err) + } + return localEngineResult{ + server: nemotron, streaming: true, name: "Nemotron 3.5 ASR Streaming", + model: "NVIDIA Nemotron 3.5 ASR Streaming 0.6B · GPU", + }, nil + case "whisper": + whisper, modelName, err := m.newCPUWhisper(settings, false) + if err == nil { + err = whisper.Start(ctx) + } + if err != nil { + if whisper != nil { + whisper.Stop() + } + return localEngineResult{}, fmt.Errorf("Whisper %s: %w", settings.WhisperModel, err) + } + return localEngineResult{ + server: whisper, name: "bundled CPU Whisper", model: "Whisper " + modelName + " · CPU", + }, nil + case "auto", "": + // Continue below with the original Nemotron-first fallback behavior. + default: + return localEngineResult{}, fmt.Errorf("unsupported transcription engine %q", settings.SttEngine) + } + var nemotronErr error if m.hasNVIDIAGPU() { nemotron, err := m.newNemotron() @@ -440,7 +545,7 @@ func (m *MeetingService) loadLocalEngine(ctx context.Context, settings store.Set return localEngineResult{}, err } - whisper, modelName, err := m.newCPUWhisper(settings) + whisper, modelName, err := m.newCPUWhisper(settings, true) if err == nil { err = whisper.Start(ctx) } @@ -470,15 +575,25 @@ func (m *MeetingService) waitLocalEngine(ctx context.Context, settings store.Set } m.localMu.Lock() defer m.localMu.Unlock() + if m.localKey != localEngineKey(settings) { + return localEngineResult{}, errors.New("transcription model changed while it was loading") + } return m.localResult, m.localErr } -// shutdownLocalEngine cancels an in-progress preload, waits for its goroutine, -// and releases a successfully prepared app-lifetime server. +// shutdownLocalEngine invalidates the current generation, then cancels/waits +// without holding localMu so the loader can finish cleanup without deadlocking. func (m *MeetingService) shutdownLocalEngine() { m.localMu.Lock() + m.localGen++ cancel := m.localCancel done := m.localDone + server := m.localResult.server + m.localDone = nil + m.localCancel = nil + m.localResult = localEngineResult{} + m.localErr = nil + m.localState = "stopped" m.localMu.Unlock() if cancel != nil { cancel() @@ -487,13 +602,320 @@ func (m *MeetingService) shutdownLocalEngine() { <-done } - m.localMu.Lock() - server := m.localResult.server - m.localResult = localEngineResult{} - m.localMu.Unlock() if server != nil { server.Stop() } + emitRuntimeInfo(m.GetRuntimeInfo()) +} + +func localEngineKey(settings store.Settings) string { + return settings.SttEngine + "\x00" + settings.WhisperModel +} + +func transcriptionModelID(settings store.Settings) string { + switch settings.SttEngine { + case "nemotron", "external": + return settings.SttEngine + case "whisper": + return "whisper:" + settings.WhisperModel + default: + return "auto" + } +} + +func configuredTranscriptionLabel(settings store.Settings) string { + switch settings.SttEngine { + case "nemotron": + return "NVIDIA Nemotron 3.5 ASR Streaming 0.6B · GPU" + case "whisper": + return "Whisper " + settings.WhisperModel + " · CPU" + case "external": + return "External · " + strings.TrimRight(strings.TrimSpace(settings.SttBaseURL), "/") + default: + return "Automatic local model" + } +} + +// ListTranscriptionModels discovers the selectable local installations and the +// always-available external-server choice. +func (m *MeetingService) ListTranscriptionModels() ([]TranscriptionModelOption, error) { + whisperModels, whisperModelsErr := installedWhisperModels() + _, whisperBinErr := resolveResource(filepath.Join("resources", "whisper", "bin", "Release", "whisper-server.exe")) + nemotronReason := nemotronUnavailableReason(m.hasNVIDIAGPU()) + nemotronAvailable := nemotronReason == "" + whisperAvailable := whisperModelsErr == nil && whisperBinErr == nil && len(whisperModels) > 0 + + auto := TranscriptionModelOption{ + ID: "auto", Label: "Automatic (recommended)", Kind: "automatic", + Detail: "Use Nemotron on a supported NVIDIA GPU, then fall back to installed CPU Whisper.", + Available: nemotronAvailable || whisperAvailable, + } + if !auto.Available { + if whisperModelsErr != nil { + auto.UnavailableReason = "Could not read installed Whisper models: " + whisperModelsErr.Error() + } else { + auto.UnavailableReason = "No usable local Nemotron or Whisper installation was found." + } + } + options := []TranscriptionModelOption{auto, { + ID: "nemotron", Label: "Nemotron 3.5 ASR Streaming", Kind: "local", + Detail: "Low-latency NVIDIA GPU transcription.", Available: nemotronAvailable, + UnavailableReason: nemotronReason, + }} + for _, name := range whisperModels { + option := TranscriptionModelOption{ + ID: "whisper:" + name, Label: "Whisper · " + name, Kind: "local", + Detail: "Bundled whisper.cpp CPU transcription.", Available: whisperBinErr == nil, + } + if whisperBinErr != nil { + option.UnavailableReason = "The bundled whisper.cpp server is not installed." + } + options = append(options, option) + } + + var settings store.Settings + var err error + if m.store != nil { + settings, err = m.store.GetSettings() + } + if err == nil && settings.SttEngine == "whisper" { + selectedID := transcriptionModelID(settings) + found := false + for _, option := range options { + if option.ID == selectedID { + found = true + break + } + } + if !found { + options = append(options, TranscriptionModelOption{ + ID: selectedID, Label: "Missing Whisper model · " + settings.WhisperModel, + Kind: "local", Detail: "This previously selected model is no longer installed.", + UnavailableReason: "The model file could not be found.", + }) + } + } + options = append(options, TranscriptionModelOption{ + ID: "external", Label: "External server", Kind: "external", Available: true, + Detail: "Use an HTTP server with a whisper.cpp-compatible /inference endpoint.", + }) + return options, nil +} + +// ConfigureTranscription validates and persists a model selection, then +// reconciles the idle runtime. Local loading continues asynchronously. +func (m *MeetingService) ConfigureTranscription(config TranscriptionConfig) error { + current, err := m.store.GetSettings() + if err != nil { + return err + } + next, err := settingsForTranscriptionConfig(current, config) + if err != nil { + return err + } + unchanged := current.SttEngine == next.SttEngine && current.WhisperModel == next.WhisperModel && strings.TrimSpace(current.SttBaseURL) == strings.TrimSpace(next.SttBaseURL) + if unchanged { + return nil + } + if err := m.ensureTranscriptionIdle(); err != nil { + return err + } + if err := m.validateTranscriptionSettings(next); err != nil { + return err + } + if err := m.store.SaveTranscriptionSettings(next.SttEngine, next.SttBaseURL, next.WhisperModel); err != nil { + return err + } + m.shutdownLocalEngine() + if next.SttEngine != "external" { + m.beginLocalEngine(context.Background(), next) + } else { + emitRuntimeInfo(m.GetRuntimeInfo()) + } + return nil +} + +// StartTranscriptionModel loads the selected local model without starting a meeting. +func (m *MeetingService) StartTranscriptionModel() error { + if err := m.ensureTranscriptionIdle(); err != nil { + return err + } + settings, err := m.store.GetSettings() + if err != nil { + return err + } + if settings.SttEngine == "external" { + return errors.New("external server processes cannot be started by Parley; use Test connection instead") + } + if err := m.validateTranscriptionSettings(settings); err != nil { + return err + } + m.beginLocalEngine(context.Background(), settings) + return nil +} + +// StopTranscriptionModel releases the selected local model while Parley is idle. +func (m *MeetingService) StopTranscriptionModel() error { + if err := m.ensureTranscriptionIdle(); err != nil { + return err + } + settings, err := m.store.GetSettings() + if err != nil { + return err + } + if settings.SttEngine == "external" { + return errors.New("external server processes cannot be stopped by Parley") + } + m.shutdownLocalEngine() + return nil +} + +// RestartTranscriptionModel replaces the current local process with a fresh load. +func (m *MeetingService) RestartTranscriptionModel() error { + if err := m.ensureTranscriptionIdle(); err != nil { + return err + } + settings, err := m.store.GetSettings() + if err != nil { + return err + } + if settings.SttEngine == "external" { + return errors.New("external server processes cannot be restarted by Parley") + } + if err := m.validateTranscriptionSettings(settings); err != nil { + return err + } + m.shutdownLocalEngine() + m.beginLocalEngine(context.Background(), settings) + return nil +} + +// TestExternalTranscription verifies that an external HTTP server is reachable. +// Any HTTP response counts as reachable; the inference path is exercised by the +// first real transcription request. +func (m *MeetingService) TestExternalTranscription(baseURL string) error { + clean, err := normalizeExternalURL(baseURL) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, clean+"/", nil) + if err != nil { + return err + } + resp, err := (&http.Client{Timeout: 4 * time.Second}).Do(req) + if err != nil { + return fmt.Errorf("could not reach the external transcription server: %w", err) + } + resp.Body.Close() + return nil +} + +func (m *MeetingService) ensureTranscriptionIdle() error { + m.mu.Lock() + defer m.mu.Unlock() + if m.running { + return errors.New("stop the active meeting before changing the transcription model") + } + return nil +} + +func (m *MeetingService) validateTranscriptionSettings(settings store.Settings) error { + switch settings.SttEngine { + case "external": + _, err := normalizeExternalURL(settings.SttBaseURL) + return err + case "nemotron": + if !m.hasNVIDIAGPU() { + return errors.New("Nemotron requires a working NVIDIA GPU") + } + _, err := m.newNemotron() + if err != nil { + return fmt.Errorf("Nemotron is not fully installed: %w", err) + } + return nil + case "whisper": + _, _, err := m.newCPUWhisper(settings, false) + return err + case "auto", "": + if m.hasNVIDIAGPU() { + if _, err := m.newNemotron(); err == nil { + return nil + } + } + _, _, err := m.newCPUWhisper(settings, true) + return err + default: + return fmt.Errorf("unsupported transcription engine %q", settings.SttEngine) + } +} + +func settingsForTranscriptionConfig(current store.Settings, config TranscriptionConfig) (store.Settings, error) { + modelID := strings.TrimSpace(config.ModelID) + next := current + next.SttBaseURL = strings.TrimSpace(config.ExternalURL) + switch { + case modelID == "auto": + next.SttEngine = "auto" + case modelID == "nemotron": + next.SttEngine = "nemotron" + case modelID == "external": + next.SttEngine = "external" + clean, err := normalizeExternalURL(config.ExternalURL) + if err != nil { + return store.Settings{}, err + } + next.SttBaseURL = clean + case strings.HasPrefix(modelID, "whisper:"): + name := strings.TrimSpace(strings.TrimPrefix(modelID, "whisper:")) + if name == "" || filepath.Base(name) != name || !strings.HasSuffix(strings.ToLower(name), ".bin") { + return store.Settings{}, errors.New("select an installed Whisper model") + } + next.SttEngine = "whisper" + next.WhisperModel = name + default: + return store.Settings{}, fmt.Errorf("unsupported transcription model %q", modelID) + } + return next, nil +} + +func normalizeExternalURL(raw string) (string, error) { + clean := strings.TrimRight(strings.TrimSpace(raw), "/") + parsed, err := url.Parse(clean) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "", errors.New("enter a complete external transcription URL beginning with http:// or https://") + } + return clean, nil +} + +func nemotronUnavailableReason(hasGPU bool) string { + if !hasGPU { + return "A working NVIDIA GPU was not detected." + } + if _, err := resolveNemotronInstall(); err != nil { + return "The Nemotron runtime or model installation is incomplete." + } + return "" +} + +func installedWhisperModels() ([]string, error) { + dir, err := resolveResource(filepath.Join("resources", "whisper", "models")) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + models := make([]string, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(strings.ToLower(entry.Name()), ".bin") { + models = append(models, entry.Name()) + } + } + sort.Strings(models) + return models, nil } // anyInstalledModel returns the first *.bin model present under @@ -504,14 +926,12 @@ func anyInstalledModel() (path, name string, ok bool) { if err != nil { return "", "", false } - entries, err := os.ReadDir(dir) + models, err := installedWhisperModels() if err != nil { return "", "", false } - for _, e := range entries { - if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".bin") { - return filepath.Join(dir, e.Name()), e.Name(), true - } + if len(models) > 0 { + return filepath.Join(dir, models[0]), models[0], true } return "", "", false } diff --git a/meeting_service_test.go b/meeting_service_test.go index 8aa356d..1cb2a42 100644 --- a/meeting_service_test.go +++ b/meeting_service_test.go @@ -3,6 +3,8 @@ package main import ( "context" "errors" + "net/http" + "net/http/httptest" "path/filepath" "sync" "sync/atomic" @@ -123,7 +125,7 @@ func TestServiceStartupPreloadsAndReusesNemotron(t *testing.T) { nemotron.release = make(chan struct{}) m.hasNVIDIAGPU = func() bool { return true } m.newNemotron = func() (managedSTTServer, error) { return nemotron, nil } - m.newCPUWhisper = func(store.Settings) (managedSTTServer, string, error) { + m.newCPUWhisper = func(store.Settings, bool) (managedSTTServer, string, error) { return nil, "", errors.New("CPU fallback should not be used") } @@ -191,7 +193,7 @@ func TestLocalEnginePreloadFallsBackToCPU(t *testing.T) { whisper := newFakeManagedSTTServer() m.hasNVIDIAGPU = func() bool { return true } m.newNemotron = func() (managedSTTServer, error) { return nemotron, nil } - m.newCPUWhisper = func(store.Settings) (managedSTTServer, string, error) { + m.newCPUWhisper = func(store.Settings, bool) (managedSTTServer, string, error) { return whisper, "ggml-small.en-q5_1.bin", nil } @@ -224,6 +226,7 @@ func TestGetRuntimeInfoReportsResolvedLocalModel(t *testing.T) { server: newFakeManagedSTTServer(), model: "Whisper ggml-small.en-q5_1.bin · CPU", } + m.localState = "ready" got := m.GetRuntimeInfo() if got.AppVersion != appVersion { @@ -244,15 +247,16 @@ func TestGetRuntimeInfoReportsRemoteServer(t *testing.T) { t.Fatalf("GetSettings: %v", err) } settings.SttBaseURL = " http://transcription.local:8765/ " + settings.SttEngine = "external" if err := s.SaveSettings(settings); err != nil { t.Fatalf("SaveSettings: %v", err) } got := NewMeetingService(s).GetRuntimeInfo() - if got.TranscriptionStatus != "ready" { - t.Fatalf("TranscriptionStatus = %q, want ready", got.TranscriptionStatus) + if got.TranscriptionStatus != "configured" { + t.Fatalf("TranscriptionStatus = %q, want configured", got.TranscriptionStatus) } - if got.TranscriptionModel != "Remote model · http://transcription.local:8765" { + if got.TranscriptionModel != "External · http://transcription.local:8765" { t.Fatalf("TranscriptionModel = %q", got.TranscriptionModel) } } @@ -264,7 +268,7 @@ func TestServiceShutdownCancelsInProgressPreload(t *testing.T) { nemotron.release = make(chan struct{}) m.hasNVIDIAGPU = func() bool { return true } m.newNemotron = func() (managedSTTServer, error) { return nemotron, nil } - m.newCPUWhisper = func(store.Settings) (managedSTTServer, string, error) { + m.newCPUWhisper = func(store.Settings, bool) (managedSTTServer, string, error) { return nil, "", errors.New("shutdown should prevent fallback startup") } @@ -292,3 +296,136 @@ func TestServiceShutdownCancelsInProgressPreload(t *testing.T) { t.Fatal("canceled preload retained a server") } } + +func TestExplicitNemotronFailureDoesNotFallBack(t *testing.T) { + m := NewMeetingService(nil) + nemotron := newFakeManagedSTTServer() + nemotron.startErr = errors.New("CUDA failed") + cpuCalls := 0 + m.hasNVIDIAGPU = func() bool { return true } + m.newNemotron = func() (managedSTTServer, error) { return nemotron, nil } + m.newCPUWhisper = func(store.Settings, bool) (managedSTTServer, string, error) { + cpuCalls++ + return newFakeManagedSTTServer(), "fallback.bin", nil + } + + _, err := m.loadLocalEngine(context.Background(), store.Settings{SttEngine: "nemotron"}) + if err == nil { + t.Fatal("explicit Nemotron failure should be returned") + } + if cpuCalls != 0 { + t.Fatalf("explicit Nemotron used CPU fallback %d times", cpuCalls) + } +} + +func TestConfigureExternalStopsLocalModelAndPersists(t *testing.T) { + s := openMeetingTestStore(t) + m := NewMeetingService(s) + local := newFakeManagedSTTServer() + done := make(chan struct{}) + close(done) + m.localDone = done + m.localResult = localEngineResult{server: local, model: "Whisper current.bin · CPU"} + m.localState = "ready" + + if err := m.ConfigureTranscription(TranscriptionConfig{ + ModelID: "external", ExternalURL: " http://stt.example:8765/ ", + }); err != nil { + t.Fatal(err) + } + if local.stopCalls.Load() != 1 { + t.Fatalf("local model Stop calls = %d, want 1", local.stopCalls.Load()) + } + settings, _ := s.GetSettings() + if settings.SttEngine != "external" || settings.SttBaseURL != "http://stt.example:8765" { + t.Fatalf("persisted transcription settings = %+v", settings) + } + if got := m.GetRuntimeInfo(); got.TranscriptionStatus != "configured" || got.TranscriptionKind != "external" { + t.Fatalf("runtime info = %+v", got) + } +} + +func TestLocalModelCanStopAndStartAgain(t *testing.T) { + s := openMeetingTestStore(t) + settings, _ := s.GetSettings() + settings.SttEngine = "whisper" + settings.WhisperModel = "test.bin" + if err := s.SaveSettings(settings); err != nil { + t.Fatal(err) + } + + m := NewMeetingService(s) + whisper := newFakeManagedSTTServer() + m.newCPUWhisper = func(store.Settings, bool) (managedSTTServer, string, error) { + return whisper, "test.bin", nil + } + if err := m.StartTranscriptionModel(); err != nil { + t.Fatal(err) + } + waitForLocalLoad(t, m) + if err := m.StopTranscriptionModel(); err != nil { + t.Fatal(err) + } + if got := m.GetRuntimeInfo().TranscriptionStatus; got != "stopped" { + t.Fatalf("status after Stop = %q", got) + } + if err := m.StartTranscriptionModel(); err != nil { + t.Fatal(err) + } + waitForLocalLoad(t, m) + if whisper.startCalls.Load() != 2 || whisper.stopCalls.Load() != 1 { + t.Fatalf("lifecycle calls Start=%d Stop=%d", whisper.startCalls.Load(), whisper.stopCalls.Load()) + } + m.shutdownLocalEngine() +} + +func TestTranscriptionLifecycleBlockedDuringMeeting(t *testing.T) { + m := NewMeetingService(openMeetingTestStore(t)) + m.running = true + if err := m.StopTranscriptionModel(); err == nil { + t.Fatal("StopTranscriptionModel should reject an active meeting") + } + if err := m.ConfigureTranscription(TranscriptionConfig{ModelID: "external", ExternalURL: "http://stt.example"}); err == nil { + t.Fatal("ConfigureTranscription should reject an active meeting") + } +} + +func TestExternalTranscriptionReachability(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + m := NewMeetingService(nil) + if err := m.TestExternalTranscription(server.URL); err != nil { + t.Fatalf("any HTTP response should count as reachable: %v", err) + } + if err := m.TestExternalTranscription("not-a-url"); err == nil { + t.Fatal("invalid external URL should fail validation") + } +} + +func TestNemotronInventoryRecognizesSharedInstall(t *testing.T) { + root := filepath.Join(t.TempDir(), "shared-nemotron") + writeCompleteNemotronRoot(t, root) + t.Setenv("PARLEY_NEMOTRON_HOME", root) + + if reason := nemotronUnavailableReason(true); reason != "" { + t.Fatalf("shared Nemotron install reported unavailable: %s", reason) + } +} + +func waitForLocalLoad(t *testing.T, m *MeetingService) { + t.Helper() + m.localMu.Lock() + done := m.localDone + m.localMu.Unlock() + if done == nil { + t.Fatal("local model load was not started") + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("local model load did not finish") + } +}