Compare commits
42 Commits
2dfd7167fd
...
pre-lumoti
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ca01e7c9d | |||
| 1d71e8e361 | |||
| b0d4ac8de1 | |||
| c55b0aaae0 | |||
| a36ae7e068 | |||
| 52565ea8b8 | |||
| fdab77776c | |||
| 48c483894b | |||
| 184214b60a | |||
| 792fb5ea08 | |||
| db654deecc | |||
| b463c32f17 | |||
| c95a5da077 | |||
| 6bc8acccce | |||
|
|
d21109ec27 | ||
|
|
b16fc179b3 | ||
|
|
2a45cb8033 | ||
|
|
e5e12387e8 | ||
|
|
a1f3f3f134 | ||
|
|
3c47000ea9 | ||
|
|
052265b3c2 | ||
|
|
4f3c063008 | ||
|
|
a58c2f0994 | ||
|
|
7eb69e6251 | ||
|
|
0a503be38d | ||
|
|
e715da3b54 | ||
|
|
d6bde52d6e | ||
|
|
b991355cb0 | ||
|
|
9c85c25b7f | ||
|
|
6b456b1766 | ||
|
|
847dc755ac | ||
|
|
02a6cb24ce | ||
|
|
504ba0a361 | ||
|
|
eccc5e9544 | ||
|
|
3d978e3978 | ||
|
|
0c761c0be6 | ||
|
|
276e3ca6c1 | ||
|
|
1f3496e4ed | ||
|
|
c46c0a5ce9 | ||
|
|
200c4fb447 | ||
|
|
4cb954ece4 | ||
|
|
fdf27db0a1 |
92
Cargo.lock
generated
92
Cargo.lock
generated
@@ -2705,9 +2705,9 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "llama-cpp-2"
|
||||
version = "0.1.145"
|
||||
version = "0.1.146"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e82b8c7a1c1a0ad97e1cc5cc28e01e9e14be73d4068e0fe9ac9d6c465001323"
|
||||
checksum = "f3b0f368c76cc0fe475e8257aeeec269e0d6569bd48b1f503efd0963fc3ee397"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"enumflags2",
|
||||
@@ -2719,9 +2719,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "llama-cpp-sys-2"
|
||||
version = "0.1.145"
|
||||
version = "0.1.146"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1e5495433ca7487b9f8c7046f64e69937861d438b20f12ee3c524f35d55ad3"
|
||||
checksum = "9b291e4bc2d10c43cd8dec16d49b6104cb3cb125f596ec380a753a5db1d965dd"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
@@ -2793,7 +2793,6 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"gdk",
|
||||
"gtk",
|
||||
"libloading 0.8.9",
|
||||
"magnotia-ai-formatting",
|
||||
"magnotia-audio",
|
||||
"magnotia-cloud-providers",
|
||||
@@ -2817,6 +2816,8 @@ dependencies = [
|
||||
"tauri-plugin-window-state",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
]
|
||||
@@ -2828,6 +2829,7 @@ dependencies = [
|
||||
"magnotia-core",
|
||||
"magnotia-llm",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2837,17 +2839,21 @@ dependencies = [
|
||||
"cpal",
|
||||
"hound",
|
||||
"magnotia-core",
|
||||
"regex",
|
||||
"rubato",
|
||||
"serde",
|
||||
"symphonia",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "magnotia-cloud-providers"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"magnotia-core",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2855,10 +2861,15 @@ name = "magnotia-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libloading 0.8.9",
|
||||
"num_cpus",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2866,12 +2877,12 @@ name = "magnotia-hotkey"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"evdev",
|
||||
"log",
|
||||
"magnotia-core",
|
||||
"nix 0.29.0",
|
||||
"notify",
|
||||
"serde",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2882,7 +2893,6 @@ dependencies = [
|
||||
"futures-util",
|
||||
"llama-cpp-2",
|
||||
"magnotia-core",
|
||||
"num_cpus",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2914,6 +2924,7 @@ dependencies = [
|
||||
"magnotia-core",
|
||||
"serde",
|
||||
"sqlx",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
@@ -2922,7 +2933,9 @@ dependencies = [
|
||||
name = "magnotia-transcription"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
"magnotia-cloud-providers",
|
||||
"magnotia-core",
|
||||
"num_cpus",
|
||||
"reqwest 0.12.28",
|
||||
@@ -2971,6 +2984,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matches"
|
||||
version = "0.1.10"
|
||||
@@ -3236,6 +3258,15 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
@@ -4962,6 +4993,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -6029,6 +6069,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.11.3"
|
||||
@@ -6342,6 +6391,35 @@ dependencies = [
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "transcribe-rs"
|
||||
version = "0.3.11"
|
||||
|
||||
@@ -7,6 +7,8 @@ description: Session handover — 2026/04/24-25 Phase 9 polish debt mostly shipp
|
||||
|
||||
# Magnotia Handover — 2026/04/25
|
||||
|
||||
> **Note:** Session-specific handover. Migration v14 (`transcripts.llm_tags`) was the head **at the time of this session**. Subsequent work has advanced the schema; current head is v15 (`idx_transcripts_profile_created`, composite index). For the current codebase shape, see [`docs/architecture-map/`](docs/architecture-map/) and [`KNOWN-ISSUES.md`](KNOWN-ISSUES.md).
|
||||
|
||||
Phase 9 session. Spec + plan written from scratch and committed; plan corrections layered in after critical review against the actual codebase (Codex was unreachable for cross-model review, three retries failed at the ChatGPT-account-entitlement layer). Sub-phases 9a + 9b + sparkline polish landed end to end. Sub-phase 9c reduced to the Phase 8 carryover bug fix; sub-phase 9d's walkthrough sweeps deferred to Phase 10a QC.
|
||||
|
||||
## Rebrand note
|
||||
|
||||
83
KNOWN-ISSUES.md
Normal file
83
KNOWN-ISSUES.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Known issues
|
||||
|
||||
Tracked limitations and partial implementations in the current codebase. Each entry points at the code that owns the gap so future work can find it.
|
||||
|
||||
## Power management
|
||||
|
||||
### KI-01 — macOS App Nap guard pending Apple Silicon runtime verification
|
||||
|
||||
**Status:** macOS code path compiles and the registry tests pass, but runtime behaviour against actual OS idle-throttling has not been confirmed on Apple Silicon hardware. Also tracked internally as **RB-08**.
|
||||
|
||||
**Source:** [`src-tauri/src/commands/power.rs:73`](src-tauri/src/commands/power.rs#L73) (`PowerAssertion::begin`), [`src-tauri/src/commands/power.rs:140`](src-tauri/src/commands/power.rs#L140) (`objc_bridge`).
|
||||
|
||||
**Impact:** Long live-dictation sessions on macOS may still be slowed or paused by the OS until verified end-to-end on hardware.
|
||||
|
||||
**Workaround:** Keep the app window focused, or move the cursor periodically. For testing, App Nap can be disabled globally with `defaults write NSGlobalDomain NSAppSleepDisabled -bool YES` (revert with `-bool NO`).
|
||||
|
||||
### KI-02 — Linux power assertion is a no-op
|
||||
|
||||
**Status:** `PowerAssertion::begin` does nothing on Linux. The planned implementation (systemd-logind / GNOME idle inhibitor via `org.freedesktop.login1.Inhibit`) is described in the file-level doc but not wired up.
|
||||
|
||||
**Source:** [`src-tauri/src/commands/power.rs`](src-tauri/src/commands/power.rs).
|
||||
|
||||
**Impact:** Long sessions can be paused by the compositor's idle hooks (screen lock, suspend timers) on KDE, GNOME, Hyprland, Sway, etc.
|
||||
|
||||
**Workaround:** Raise the system idle / screen-lock timeout while dictating, or wrap launch with `systemd-inhibit --what=idle:sleep:handle-lid-switch ./run.sh`.
|
||||
|
||||
### KI-03 — Windows power assertion is a no-op
|
||||
|
||||
**Status:** `PowerAssertion::begin` does nothing on Windows. The planned implementation (`SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_AWAYMODE_REQUIRED)` on begin, `ES_CONTINUOUS` alone on end) is described in the file-level doc but not wired up.
|
||||
|
||||
**Source:** [`src-tauri/src/commands/power.rs`](src-tauri/src/commands/power.rs).
|
||||
|
||||
**Impact:** Long sessions can be paused by Windows sleep policies.
|
||||
|
||||
**Workaround:** Set the active power plan's sleep to "Never" while dictating.
|
||||
|
||||
## Cloud providers
|
||||
|
||||
### KI-04 — `magnotia-cloud-providers` crate is not user-exposed
|
||||
|
||||
**Status:** The crate is a declared workspace dependency in [`src-tauri/Cargo.toml:36`](src-tauri/Cargo.toml#L36) and compiles into the binary, but no Tauri command, page, or settings field invokes `store_api_key` / `retrieve_api_key`. There is no UI to enter or store a cloud API key in the current build.
|
||||
|
||||
**Source:** [`crates/cloud-providers/src/keystore.rs`](crates/cloud-providers/src/keystore.rs). The `TODO` in the doc comment captures the planned migration to OS-native credential storage (the `keyring` crate or equivalent) before the feature is wired up.
|
||||
|
||||
**Impact:** None for end users today (no exposed surface). When the feature is wired post-launch, cross-restart persistence must land before any "save key" UX ships, otherwise saved keys reset on every restart.
|
||||
|
||||
**Workaround:** N/A.
|
||||
|
||||
## Frontend
|
||||
|
||||
### KI-05 — Dual theme system: `settings.theme` and `preferences.theme` coexist
|
||||
|
||||
**Status:** Theme is stored twice. The legacy `settings.theme` ("Dark" / "Light" / "System", localStorage-only) is bound to two `SegmentedButton` controls in `SettingsPage.svelte`. The canonical `preferences.theme` ("dark" / "light" / "system", Tauri-persisted via `save_preferences`) is what the DOM and runtime actually consume. A migration `$effect` in `src/routes/+layout.svelte` (and the secondary `+layout@.svelte` files for `/preview`, `/viewer`, `/float`) syncs `settings.theme` → `preferences.theme` whenever the legacy value changes.
|
||||
|
||||
**Source:**
|
||||
- [`src/lib/stores/page.svelte.ts:61`](src/lib/stores/page.svelte.ts#L61) (legacy default: `theme: "Dark"`)
|
||||
- [`src/lib/stores/preferences.svelte.ts:30`](src/lib/stores/preferences.svelte.ts#L30) (canonical default: `theme: "dark"`)
|
||||
- [`src/routes/+layout.svelte:61-68`](src/routes/+layout.svelte#L61) (migration `$effect`)
|
||||
- [`src/lib/pages/SettingsPage.svelte:1118`](src/lib/pages/SettingsPage.svelte#L1118), [`:2360`](src/lib/pages/SettingsPage.svelte#L2360) (UI bindings still on the legacy field)
|
||||
|
||||
**Impact:** No user-facing bug; theme switching works. The cost is code complexity and a small race window where preference fan-out can lag behind a `settings.theme` change. The `$effect` is lightweight (string compare plus a debounced persist when the mapped value differs), not a hot-loop performance problem despite earlier framing.
|
||||
|
||||
**Resolution (deferred):** Switch the two `SettingsPage` bindings to `preferences.theme` with mapped options, retire the migration `$effect` in all four route layouts, drop `theme` from `SettingsState`, and add a one-shot localStorage migration that copies any historical `settings.theme` into `preferences` on first run after the cleanup. Touches ~5 files including the 2 484-LOC `SettingsPage.svelte`; deferred from the v0.1 polish pass to avoid a moderate-risk frontend change immediately before launch.
|
||||
|
||||
**Workaround:** N/A.
|
||||
|
||||
## Engine architecture (Phase A)
|
||||
|
||||
### KI-06 — `transcribe_file` and live-transcription commands not yet rewired through `Orchestrator`
|
||||
|
||||
**Status:** Phase A introduced the `TranscriptionProvider` async trait ([`crates/cloud-providers/src/provider.rs`](crates/cloud-providers/src/provider.rs)), `EngineRegistry` ([`crates/transcription/src/registry.rs`](crates/transcription/src/registry.rs)), and `Orchestrator` plus `LocalProviderAdapter` ([`crates/transcription/src/orchestrator.rs`](crates/transcription/src/orchestrator.rs)). The abstractions compile, are object-safe, and pass unit tests against a mock provider. The existing dictation path ([`src-tauri/src/commands/transcription.rs`](src-tauri/src/commands/transcription.rs)) still calls `LocalEngine::transcribe_sync` directly via `pick_engine`, not through the orchestrator. Live and meeting commands likewise route around the orchestrator.
|
||||
|
||||
**Source:** [`src-tauri/src/commands/transcription.rs:29`](src-tauri/src/commands/transcription.rs#L29) (`pick_engine`), [`src-tauri/src/commands/live.rs`](src-tauri/src/commands/live.rs), [`src-tauri/src/commands/meeting.rs`](src-tauri/src/commands/meeting.rs).
|
||||
|
||||
**Impact:** None at runtime. The orchestrator path is dormant until a follow-up commit migrates the call sites. Cloud providers (Phase G) cannot be exercised end-to-end until this rewire lands.
|
||||
|
||||
**Resolution (deferred):** Phase A.1 follow-up commit. Build an `EngineRegistry` at app boot (one `LocalProviderAdapter` per `LocalEngine`), inject `Arc<Orchestrator>` into `AppState`, replace `pick_engine` plus `engine.transcribe_sync` chunking-loop calls with `orchestrator.transcribe(audio, &profile)` per chunk. Keep the chunking strategy in the command layer (it is a strategy concern, not a dispatch concern). Tests should cover both paths against a mock provider before touching the real engines.
|
||||
|
||||
**Workaround:** N/A. Existing path works as before.
|
||||
|
||||
## How to add an entry
|
||||
|
||||
When shipping a partial implementation or known limitation, add a `KI-NN` entry here with the four standard fields: **Status**, **Source** (file:line), **Impact**, **Workaround**. Link from the affected module's doc comment back to this file by ID.
|
||||
24
README.md
24
README.md
@@ -13,6 +13,7 @@ Magnotia is a local-first, cognitive-load-aware dictation and task-capture deskt
|
||||
- Current `main`: see commit log
|
||||
- 9 library crates plus the Tauri app crate; 220+ lib tests plus 67 Tauri-app tests, all passing
|
||||
- Cross-platform CI (Linux / macOS / Windows) via GitHub Actions
|
||||
- Tracked limitations live in [`KNOWN-ISSUES.md`](KNOWN-ISSUES.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -235,10 +236,10 @@ Utility modules in the same directory (no `#[tauri::command]` attributes; helper
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|---|---|---|
|
||||
| Linux Wayland (KDE Plasma, GNOME Mutter, Hyprland, Sway) | **Primary target**, daily-dogfooded on KDE | evdev hotkey, GTK 3 via webkit2gtk, Vulkan, all paste backends |
|
||||
| Linux X11 | Supported | xdotool paste path, GTK 3 |
|
||||
| macOS | In CI, untested runtime | osascript paste, Metal via MoltenVK, App Nap guard |
|
||||
| Windows | In CI, untested runtime | SendKeys paste, Vulkan-first GPU path, bundled DLLs for CPU fallback |
|
||||
| Linux Wayland (KDE Plasma, GNOME Mutter, Hyprland, Sway) | **Primary target**, daily-dogfooded on KDE | evdev hotkey, GTK 3 via webkit2gtk, Vulkan, all paste backends; idle inhibit not wired (see KI-02) |
|
||||
| Linux X11 | Supported | xdotool paste path, GTK 3; idle inhibit not wired (see KI-02) |
|
||||
| macOS | In CI, untested runtime | osascript paste, Metal via MoltenVK, App Nap guard pending Apple Silicon verification (see KI-01) |
|
||||
| Windows | In CI, untested runtime | SendKeys paste, Vulkan-first GPU path, bundled DLLs for CPU fallback; sleep prevention not wired (see KI-03) |
|
||||
|
||||
CI runs `cargo check --workspace --all-targets` + `svelte-check` on all three on every push and PR.
|
||||
|
||||
@@ -270,20 +271,22 @@ See [`docs/dev-setup.md`](docs/dev-setup.md) for the authoritative per-platform
|
||||
|
||||
### Dev launch
|
||||
|
||||
The fast path — starts Vite, waits for port 1420, then launches Tauri:
|
||||
Canonical full-stack dev launch — starts Vite, waits for port 1420, then launches Tauri:
|
||||
|
||||
```bash
|
||||
npm run dev:tauri
|
||||
```
|
||||
|
||||
Direct shell equivalent:
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Or manually:
|
||||
For pure frontend iteration without Tauri:
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
npm run dev:frontend
|
||||
|
||||
# Terminal 2
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
### Build
|
||||
@@ -336,6 +339,7 @@ Cross-repo survey of 10 OSS Whisper projects, the Magnotia-specific atomic task
|
||||
### Dev reference
|
||||
- [`docs/dev-setup.md`](docs/dev-setup.md) — dependency + launch reference
|
||||
- [`docs/icon-mapping.md`](docs/icon-mapping.md) — icon conventions
|
||||
- [`KNOWN-ISSUES.md`](KNOWN-ISSUES.md) — tracked partial implementations and limitations
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,3 +8,4 @@ description = "Text post-processing pipeline: filler removal, British English co
|
||||
magnotia-core = { path = "../core" }
|
||||
magnotia-llm = { path = "../llm" }
|
||||
regex-lite = "0.1"
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -91,8 +91,9 @@ pub fn post_process_segments(
|
||||
replace_segments_with_cleaned(segments, cleaned.trim());
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => eprintln!(
|
||||
"[ai-formatting] LLM cleanup failed, keeping rule-based output: {err}"
|
||||
Err(err) => tracing::warn!(
|
||||
error = %err,
|
||||
"LLM cleanup failed, keeping rule-based output"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +28,5 @@ tokio = { version = "1", features = ["rt", "sync"] }
|
||||
|
||||
# Serde for DeviceInfo (returned across the Tauri boundary)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
regex = "1"
|
||||
|
||||
@@ -4,22 +4,30 @@ use std::sync::Arc;
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{FromSample, Sample, SampleFormat, SizedSample};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
|
||||
const AUDIO_CHANNEL_CAPACITY: usize = 32;
|
||||
|
||||
/// Validation window. We listen for this long and compute RMS to decide
|
||||
/// whether the chosen device is delivering real audio (vs a silent monitor).
|
||||
/// Validation window. 350ms is long enough to collect several cpal callback
|
||||
/// buffers at common 44.1/48kHz rates while keeping Settings/UI device
|
||||
/// switching perceptibly sub-second.
|
||||
const DEVICE_VALIDATION_MS: u64 = 350;
|
||||
|
||||
/// Below this RMS amplitude (peak ±1.0 scale) the input is treated as
|
||||
/// silence. PulseAudio/PipeWire monitor sources for an idle speaker
|
||||
/// typically deliver dead-zero samples; real microphones yield ~0.0005+
|
||||
/// even in a quiet room. Conservative floor: 1e-5.
|
||||
/// silence. Field dogfooding on PipeWire/PulseAudio showed idle monitor
|
||||
/// sources at exact or near-zero RMS, while connected microphones in quiet
|
||||
/// rooms stayed around 5e-4+; 1e-5 keeps a 50x safety margin below that.
|
||||
const SILENCE_RMS_FLOOR: f32 = 1e-5;
|
||||
|
||||
/// Absolute floor used even for monitor fallback. Values below this are
|
||||
/// effectively digital zero on normalized f32 PCM, so accepting them only
|
||||
/// records silence and hides device-routing failures.
|
||||
const DEAD_SILENCE_FLOOR: f32 = 1e-7;
|
||||
|
||||
/// A chunk of captured audio from the microphone.
|
||||
pub struct AudioChunk {
|
||||
pub samples: Vec<f32>,
|
||||
@@ -53,7 +61,6 @@ pub struct DeviceInfo {
|
||||
/// `start()` has already returned. The live session subscribes to these via
|
||||
/// `error_rx()` so the frontend can show a toast when the mic vanishes
|
||||
/// mid-recording.
|
||||
/// (Codex review 2026/04/17 M2)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CaptureRuntimeError {
|
||||
pub device_name: String,
|
||||
@@ -84,7 +91,6 @@ impl MicrophoneCapture {
|
||||
/// Take the runtime-error receiver. Can be called once per capture; the
|
||||
/// caller (live session manager) drains it on its own cadence and surfaces
|
||||
/// errors to the frontend. Returns None on the second call.
|
||||
/// (Codex review 2026/04/17 M2)
|
||||
pub fn take_error_rx(&mut self) -> Option<mpsc::Receiver<CaptureRuntimeError>> {
|
||||
self.error_rx.take()
|
||||
}
|
||||
@@ -143,7 +149,7 @@ impl MicrophoneCapture {
|
||||
for device in devices {
|
||||
let name = device_display_name(&device).unwrap_or_default();
|
||||
if name == device_name {
|
||||
eprintln!("[magnotia-audio] start_with_device: opening explicit device '{name}'");
|
||||
tracing::info!(target: "magnotia_audio", "start_with_device: opening explicit device '{name}'");
|
||||
return open_and_validate(device, &name, /* require_audio = */ true);
|
||||
}
|
||||
}
|
||||
@@ -189,10 +195,11 @@ impl MicrophoneCapture {
|
||||
}
|
||||
});
|
||||
|
||||
eprintln!(
|
||||
"[magnotia-audio] start: enumerated {} input device(s) (default='{}')",
|
||||
all_devices.len(),
|
||||
default_name
|
||||
tracing::info!(
|
||||
target: "magnotia_audio",
|
||||
device_count = all_devices.len(),
|
||||
default = %default_name,
|
||||
"enumerated input devices"
|
||||
);
|
||||
|
||||
// First pass: require real audio energy.
|
||||
@@ -204,23 +211,25 @@ impl MicrophoneCapture {
|
||||
match open_and_validate(device.clone(), &name, true) {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) => {
|
||||
eprintln!("[magnotia-audio] '{name}' rejected: {e}");
|
||||
tracing::warn!(target: "magnotia_audio", device = %name, error = %e, "candidate device rejected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: accept anything that delivers bytes (monitor sources
|
||||
// included). Better to capture from a monitor than fail entirely.
|
||||
eprintln!(
|
||||
"[magnotia-audio] no non-monitor mic produced audio; falling back to monitor/loopback sources"
|
||||
tracing::warn!(
|
||||
target: "magnotia_audio",
|
||||
"no non-monitor mic produced audio; falling back to monitor/loopback sources"
|
||||
);
|
||||
for device in &all_devices {
|
||||
let name = device_display_name(device).unwrap_or_default();
|
||||
match open_and_validate(device.clone(), &name, false) {
|
||||
Ok(result) => {
|
||||
eprintln!(
|
||||
"[magnotia-audio] FALLBACK: capturing from '{name}' (likely monitor source). \
|
||||
Recordings may be silent or contain system audio."
|
||||
tracing::warn!(
|
||||
target: "magnotia_audio",
|
||||
device = %name,
|
||||
"capturing from likely monitor source; recordings may be silent or contain system audio"
|
||||
);
|
||||
return Ok(result);
|
||||
}
|
||||
@@ -295,52 +304,49 @@ fn extract_card_id(name: &str) -> Option<&str> {
|
||||
/// after the colon on that same line is the description we want. The
|
||||
/// next indented line is a longer location string we ignore.
|
||||
fn load_alsa_card_descriptions() -> std::collections::HashMap<String, String> {
|
||||
use std::collections::HashMap;
|
||||
let mut map = HashMap::new();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let Ok(contents) = std::fs::read_to_string("/proc/asound/cards") else {
|
||||
return map;
|
||||
return std::collections::HashMap::new();
|
||||
};
|
||||
for line in contents.lines() {
|
||||
// Header lines start with an optional leading space plus a
|
||||
// digit (the card ID, right-aligned to 2 chars for readable
|
||||
// formatting). Continuation lines are indented beyond that.
|
||||
let trimmed = line.trim_start();
|
||||
if !trimmed
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_digit())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(open) = trimmed.find('[') else {
|
||||
continue;
|
||||
};
|
||||
let Some(close) = trimmed[open..].find(']') else {
|
||||
continue;
|
||||
};
|
||||
let short_name = trimmed[open + 1..open + close].trim().to_string();
|
||||
if short_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let after_bracket = &trimmed[open + close + 1..];
|
||||
let Some(colon) = after_bracket.find(':') else {
|
||||
continue;
|
||||
};
|
||||
// Format: "USB-Audio - Blue Microphones"
|
||||
// We keep everything after the " - " if present, otherwise
|
||||
// the whole post-colon fragment.
|
||||
let raw = after_bracket[colon + 1..].trim();
|
||||
let description = raw
|
||||
.split(" - ")
|
||||
.nth(1)
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| raw.to_string());
|
||||
if !description.is_empty() {
|
||||
map.insert(short_name, description);
|
||||
}
|
||||
parse_alsa_card_descriptions(&contents)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
std::collections::HashMap::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_alsa_card_descriptions(contents: &str) -> std::collections::HashMap<String, String> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
static CARD_LINE: OnceLock<Regex> = OnceLock::new();
|
||||
let card_line = CARD_LINE.get_or_init(|| {
|
||||
Regex::new(r"^\s*\d+\s+\[([^\]]+)\]\s*:\s*(.+?)\s*$").expect("valid ALSA card-line regex")
|
||||
});
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for line in contents.lines() {
|
||||
let Some(captures) = card_line.captures(line) else {
|
||||
continue;
|
||||
};
|
||||
let Some(short_name) = captures.get(1).map(|m| m.as_str().trim()) else {
|
||||
continue;
|
||||
};
|
||||
if short_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let raw = captures
|
||||
.get(2)
|
||||
.map(|m| m.as_str().trim())
|
||||
.unwrap_or_default();
|
||||
let description = raw
|
||||
.split_once(" - ")
|
||||
.map(|(_, product)| product.trim())
|
||||
.unwrap_or(raw);
|
||||
if !description.is_empty() {
|
||||
map.insert(short_name.to_string(), description.to_string());
|
||||
}
|
||||
}
|
||||
map
|
||||
@@ -360,11 +366,13 @@ fn open_and_validate(
|
||||
let channels = config.channels();
|
||||
let format = config.sample_format();
|
||||
|
||||
eprintln!(
|
||||
"[magnotia-audio] trying '{name}' ({sr}Hz, {ch}ch, {fmt:?})",
|
||||
sr = sample_rate,
|
||||
ch = channels,
|
||||
fmt = format
|
||||
tracing::info!(
|
||||
target: "magnotia_audio",
|
||||
device = %name,
|
||||
sample_rate,
|
||||
channels,
|
||||
format = ?format,
|
||||
"trying audio input device"
|
||||
);
|
||||
|
||||
let (tx, rx) = mpsc::sync_channel::<AudioChunk>(AUDIO_CHANNEL_CAPACITY);
|
||||
@@ -375,8 +383,7 @@ fn open_and_validate(
|
||||
// and counted in `dropped_errors` so the symptom is visible in the
|
||||
// diagnostic bundle even when the listener has gone away. Errors
|
||||
// beyond the cap are by definition redundant noise in a stream that
|
||||
// is already failing. (Codex review 2026/04/17 M2; capacity bump and
|
||||
// drop logging added 2026/04/25 audit pass.)
|
||||
// is already failing.
|
||||
let (err_tx, err_rx) = mpsc::sync_channel::<CaptureRuntimeError>(32);
|
||||
let dropped_errors = Arc::new(AtomicU64::new(0));
|
||||
|
||||
@@ -440,9 +447,11 @@ fn open_and_validate(
|
||||
}
|
||||
match rx.recv_timeout(remaining) {
|
||||
Ok(chunk) => {
|
||||
for &s in &chunk.samples {
|
||||
sum_sq += (s as f64) * (s as f64);
|
||||
}
|
||||
sum_sq += chunk
|
||||
.samples
|
||||
.iter()
|
||||
.map(|&s| (s as f64).powi(2))
|
||||
.sum::<f64>();
|
||||
total_samples += chunk.samples.len();
|
||||
collected.push(chunk);
|
||||
}
|
||||
@@ -457,9 +466,12 @@ fn open_and_validate(
|
||||
}
|
||||
|
||||
let rms = (sum_sq / total_samples as f64).sqrt() as f32;
|
||||
eprintln!(
|
||||
"[magnotia-audio] '{name}' validation: {samples} samples, rms={rms:.6}",
|
||||
samples = total_samples
|
||||
tracing::info!(
|
||||
target: "magnotia_audio",
|
||||
device = %name,
|
||||
samples = total_samples,
|
||||
rms,
|
||||
"audio input validation complete"
|
||||
);
|
||||
|
||||
if require_audio && rms < SILENCE_RMS_FLOOR {
|
||||
@@ -471,8 +483,7 @@ fn open_and_validate(
|
||||
// Even in the fallback pass (require_audio=false), reject completely
|
||||
// dead-zero audio. PulseAudio/PipeWire will sometimes happily emit a
|
||||
// long stream of f32 zeros from a borked device — that is worse than
|
||||
// failing fast. (Codex review 2026/04/17 D3)
|
||||
const DEAD_SILENCE_FLOOR: f32 = 1e-7;
|
||||
// failing fast.
|
||||
if rms < DEAD_SILENCE_FLOOR {
|
||||
return Err(MagnotiaError::AudioCaptureFailed(format!(
|
||||
"device produced dead silence (rms={rms:.6e} below absolute floor {DEAD_SILENCE_FLOOR:.6e})"
|
||||
@@ -482,14 +493,13 @@ fn open_and_validate(
|
||||
// Re-queue the collected chunks so downstream gets them. Count any
|
||||
// drops here against the same `dropped_chunks` counter so the live
|
||||
// session sees them and can warn the user.
|
||||
// (Codex review 2026/04/17 M1)
|
||||
for chunk in collected {
|
||||
if requeue_tx.try_send(chunk).is_err() {
|
||||
dropped_chunks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("[magnotia-audio] selected microphone: '{name}'");
|
||||
tracing::info!(target: "magnotia_audio", device = %name, "selected microphone");
|
||||
Ok((
|
||||
MicrophoneCapture {
|
||||
stream: Some(stream),
|
||||
@@ -528,18 +538,16 @@ where
|
||||
sample_rate,
|
||||
channels,
|
||||
};
|
||||
// try_send fails if the channel is full. Track that explicitly
|
||||
// rather than swallowing it — Codex review 2026/04/17 caught
|
||||
// this as a silent-failure risk under sustained load.
|
||||
// try_send fails if the channel is full. Track that explicitly;
|
||||
// otherwise backpressure looks like clean transcription silence.
|
||||
if tx.try_send(chunk).is_err() {
|
||||
dropped_chunks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
},
|
||||
move |err| {
|
||||
// Surface stream errors to the live session via err_tx so the
|
||||
// frontend can show a toast. Also keep the eprintln for ops
|
||||
// logs. (Codex review 2026/04/17 M2)
|
||||
eprintln!("[magnotia-audio] capture error: {err}");
|
||||
// frontend can show a toast.
|
||||
tracing::error!(target: "magnotia_audio", error = %err, "capture stream error");
|
||||
if err_tx
|
||||
.try_send(CaptureRuntimeError {
|
||||
device_name: err_device_name.clone(),
|
||||
@@ -547,15 +555,15 @@ where
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
// Channel full — listener has stalled or detached. Note
|
||||
// it in stderr and the dropped-errors counter so the
|
||||
// diagnostic bundle still shows the symptom even if the
|
||||
// frontend never received the typed event.
|
||||
// Channel full — listener has stalled or detached. Keep a
|
||||
// counter so the diagnostic bundle still shows the symptom
|
||||
// even if the frontend never received the typed event.
|
||||
let prior = dropped_errors.fetch_add(1, Ordering::Relaxed);
|
||||
eprintln!(
|
||||
"[magnotia-audio] capture error channel full; dropped error #{} for device '{}'",
|
||||
prior + 1,
|
||||
err_device_name,
|
||||
tracing::warn!(
|
||||
target: "magnotia_audio",
|
||||
device = %err_device_name,
|
||||
dropped_error = prior + 1,
|
||||
"capture error channel full; dropping runtime error"
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -569,15 +577,43 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn monitor_pattern_detection() {
|
||||
assert!(is_monitor_name(
|
||||
"alsa_output.pci-0000_00_1f.3.analog-stereo.monitor"
|
||||
));
|
||||
assert!(is_monitor_name("Monitor of Built-in Audio Analog Stereo"));
|
||||
assert!(is_monitor_name("Some Loopback Device"));
|
||||
assert!(!is_monitor_name("Blue Yeti USB"));
|
||||
assert!(!is_monitor_name(
|
||||
"alsa_input.pci-0000_00_1f.3.analog-stereo"
|
||||
));
|
||||
assert!(!is_monitor_name(""));
|
||||
for name in [
|
||||
"alsa_output.pci-0000_00_1f.3.analog-stereo.monitor",
|
||||
"Monitor of Built-in Audio Analog Stereo",
|
||||
"PipeWire Loopback Source",
|
||||
"Built-in Audio Monitor of Analog Stereo",
|
||||
] {
|
||||
assert!(is_monitor_name(name), "expected monitor source: {name}");
|
||||
}
|
||||
|
||||
for name in [
|
||||
"Built-in Audio Analog Stereo",
|
||||
"Blue Microphones",
|
||||
"HD Pro Webcam C920",
|
||||
"sysdefault:CARD=Microphones",
|
||||
] {
|
||||
assert!(!is_monitor_name(name), "expected physical input: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_alsa_cards_with_regex() {
|
||||
let contents = r#"
|
||||
2 [Microphones ]: USB-Audio - Blue Microphones
|
||||
Blue Microphones at usb-0000:04:00.3-2.1, full speed
|
||||
3 [C920 ]: USB-Audio - HD Pro Webcam C920: With Colon
|
||||
HD Pro Webcam C920 at usb-0000:04:00.3-2.2, high speed
|
||||
"#;
|
||||
|
||||
let parsed = parse_alsa_card_descriptions(contents);
|
||||
|
||||
assert_eq!(
|
||||
parsed.get("Microphones").map(String::as_str),
|
||||
Some("Blue Microphones")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.get("C920").map(String::as_str),
|
||||
Some("HD Pro Webcam C920: With Colon")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,7 @@ pub async fn decode_and_resample(path: &Path) -> Result<AudioSamples> {
|
||||
resample_to_16khz(&audio)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| magnotia_core::error::MagnotiaError::AudioDecodeFailed(format!("Task join error: {e}")))?
|
||||
.map_err(|e| {
|
||||
magnotia_core::error::MagnotiaError::AudioDecodeFailed(format!("Task join error: {e}"))
|
||||
})?
|
||||
}
|
||||
|
||||
@@ -99,7 +99,9 @@ fn decode_media_stream(
|
||||
.ok_or_else(|| MagnotiaError::AudioDecodeFailed("Unknown sample rate".into()))?;
|
||||
|
||||
if sample_rate == 0 {
|
||||
return Err(MagnotiaError::AudioDecodeFailed("Invalid sample rate: 0".into()));
|
||||
return Err(MagnotiaError::AudioDecodeFailed(
|
||||
"Invalid sample rate: 0".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let track_id = track.id;
|
||||
@@ -166,7 +168,9 @@ fn decode_media_stream(
|
||||
}
|
||||
|
||||
if samples.is_empty() {
|
||||
return Err(MagnotiaError::AudioDecodeFailed("No audio data decoded".into()));
|
||||
return Err(MagnotiaError::AudioDecodeFailed(
|
||||
"No audio data decoded".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(AudioSamples::new(samples, sample_rate, 1))
|
||||
|
||||
@@ -77,7 +77,9 @@ impl StreamingResampler {
|
||||
INPUT_CHUNK,
|
||||
1, // mono
|
||||
)
|
||||
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("StreamingResampler init failed: {e}")))?;
|
||||
.map_err(|e| {
|
||||
MagnotiaError::AudioDecodeFailed(format!("StreamingResampler init failed: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(Self::Sinc {
|
||||
resampler,
|
||||
@@ -142,7 +144,9 @@ impl StreamingResampler {
|
||||
|
||||
let input = vec![chunk];
|
||||
let result = resampler.process(&input, None).map_err(|e| {
|
||||
MagnotiaError::AudioDecodeFailed(format!("StreamingResampler flush failed: {e}"))
|
||||
MagnotiaError::AudioDecodeFailed(format!(
|
||||
"StreamingResampler flush failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let Some(mut out) = result.into_iter().next() else {
|
||||
@@ -166,6 +170,25 @@ impl StreamingResampler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn resampled_sine_rms(from_rate: u32, input_frequency: f32) -> f64 {
|
||||
let sample_count = from_rate as usize;
|
||||
let samples: Vec<f32> = (0..sample_count)
|
||||
.map(|i| {
|
||||
let t = i as f32 / from_rate as f32;
|
||||
(std::f32::consts::TAU * input_frequency * t).sin()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut resampler = StreamingResampler::new(from_rate).unwrap();
|
||||
let mut produced = Vec::new();
|
||||
for chunk in samples.chunks(997) {
|
||||
produced.extend(resampler.push_samples(chunk).unwrap());
|
||||
}
|
||||
produced.extend(resampler.flush().unwrap());
|
||||
|
||||
(produced.iter().map(|&s| (s as f64).powi(2)).sum::<f64>() / produced.len() as f64).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_at_16khz() {
|
||||
let mut r = StreamingResampler::new(16_000).unwrap();
|
||||
@@ -179,6 +202,24 @@ mod tests {
|
||||
assert!(StreamingResampler::new(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_frequency_content_is_filtered_before_downsampling() {
|
||||
let rms = resampled_sine_rms(48_000, 12_000.0);
|
||||
assert!(
|
||||
rms < 0.01,
|
||||
"12kHz content must be low-pass filtered before 16kHz output with at least ~40dB attenuation; rms={rms}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_nyquist_content_is_attenuated_before_downsampling() {
|
||||
let rms = resampled_sine_rms(48_000, 9_000.0);
|
||||
assert!(
|
||||
rms < 0.05,
|
||||
"9kHz content just above 16kHz Nyquist should be materially attenuated; rms={rms}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_48k_to_16k_preserves_duration() {
|
||||
let from_rate = 48_000u32;
|
||||
|
||||
@@ -40,10 +40,11 @@ impl WavWriter {
|
||||
bits_per_sample: 16,
|
||||
sample_format: hound::SampleFormat::Int,
|
||||
};
|
||||
let file = std::fs::File::create(path).map_err(MagnotiaError::Io)?;
|
||||
let file = std::fs::File::create(path).map_err(MagnotiaError::from)?;
|
||||
let buffered = BufWriter::new(file);
|
||||
let inner = hound::WavWriter::new(buffered, spec)
|
||||
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?;
|
||||
let inner = hound::WavWriter::new(buffered, spec).map_err(|e| {
|
||||
MagnotiaError::from(std::io::Error::other(format!("WAV create failed: {e}")))
|
||||
})?;
|
||||
Ok(Self {
|
||||
inner,
|
||||
samples_since_flush: 0,
|
||||
@@ -60,7 +61,7 @@ impl WavWriter {
|
||||
let clamped = sample.clamp(-1.0, 1.0);
|
||||
let int_sample = (clamped * i16::MAX as f32) as i16;
|
||||
self.inner.write_sample(int_sample).map_err(|e| {
|
||||
MagnotiaError::Io(std::io::Error::other(format!("WAV write failed: {e}")))
|
||||
MagnotiaError::from(std::io::Error::other(format!("WAV write failed: {e}")))
|
||||
})?;
|
||||
}
|
||||
self.samples_since_flush += samples.len();
|
||||
@@ -76,9 +77,9 @@ impl WavWriter {
|
||||
/// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural
|
||||
/// boundaries (end-of-utterance, UI events) for tighter recovery.
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
self.inner
|
||||
.flush()
|
||||
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV flush failed: {e}"))))?;
|
||||
self.inner.flush().map_err(|e| {
|
||||
MagnotiaError::from(std::io::Error::other(format!("WAV flush failed: {e}")))
|
||||
})?;
|
||||
self.samples_since_flush = 0;
|
||||
Ok(())
|
||||
}
|
||||
@@ -89,7 +90,7 @@ impl WavWriter {
|
||||
/// that care about the unflushed tail should always finalise.
|
||||
pub fn finalize(self) -> Result<()> {
|
||||
self.inner.finalize().map_err(|e| {
|
||||
MagnotiaError::Io(std::io::Error::other(format!("WAV finalize failed: {e}")))
|
||||
MagnotiaError::from(std::io::Error::other(format!("WAV finalize failed: {e}")))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -104,20 +105,21 @@ pub fn write_wav(path: &Path, audio: &AudioSamples) -> Result<()> {
|
||||
sample_format: hound::SampleFormat::Int,
|
||||
};
|
||||
|
||||
let mut writer = hound::WavWriter::create(path, spec)
|
||||
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?;
|
||||
let mut writer = hound::WavWriter::create(path, spec).map_err(|e| {
|
||||
MagnotiaError::from(std::io::Error::other(format!("WAV create failed: {e}")))
|
||||
})?;
|
||||
|
||||
for &sample in audio.samples() {
|
||||
let clamped = sample.clamp(-1.0, 1.0);
|
||||
let int_sample = (clamped * i16::MAX as f32) as i16;
|
||||
writer
|
||||
.write_sample(int_sample)
|
||||
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV write failed: {e}"))))?;
|
||||
writer.write_sample(int_sample).map_err(|e| {
|
||||
MagnotiaError::from(std::io::Error::other(format!("WAV write failed: {e}")))
|
||||
})?;
|
||||
}
|
||||
|
||||
writer
|
||||
.finalize()
|
||||
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV finalize failed: {e}"))))?;
|
||||
writer.finalize().map_err(|e| {
|
||||
MagnotiaError::from(std::io::Error::other(format!("WAV finalize failed: {e}")))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
name = "magnotia-cloud-providers"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "BYOK cloud STT provider stubs and API key storage for Magnotia"
|
||||
description = "Provider trait and BYOK cloud STT scaffolding for Magnotia (Wyrdnote pending rebrand)"
|
||||
|
||||
[dependencies]
|
||||
magnotia-core = { path = "../core" }
|
||||
|
||||
# Async-native trait. async_trait converts async fn into Pin<Box<dyn
|
||||
# Future>> at the trait surface so TranscriptionProvider stays
|
||||
# object-safe (Arc<dyn TranscriptionProvider>).
|
||||
async-trait = "0.1"
|
||||
|
||||
# Trait types serialise across the Tauri boundary.
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
pub mod keystore;
|
||||
pub mod provider;
|
||||
|
||||
pub use keystore::{retrieve_api_key, store_api_key};
|
||||
pub use provider::{
|
||||
CostClass, EngineProfile, NetworkRequirement, ProviderCapabilities, ProviderId, ProviderKind,
|
||||
ProviderTranscript, TranscriptionProvider,
|
||||
};
|
||||
|
||||
184
crates/cloud-providers/src/provider.rs
Normal file
184
crates/cloud-providers/src/provider.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
//! `TranscriptionProvider` is the async-native trait every transcription
|
||||
//! backend implements, regardless of whether the work happens locally
|
||||
//! (Whisper, Parakeet, Moonshine via the `LocalProviderAdapter` in
|
||||
//! `magnotia-transcription`) or remotely (OpenAI Whisper, Groq,
|
||||
//! Deepgram, etc.).
|
||||
//!
|
||||
//! Living in `magnotia-cloud-providers` is deliberate: the AGPL OEM
|
||||
//! exception (≥£2k/yr) requires a clean trait surface that an OEM
|
||||
//! licensee can implement without depending on Wyrdnote's transcription
|
||||
//! internals. The trait crate stays small; provider implementations
|
||||
//! sit alongside.
|
||||
//!
|
||||
//! Object-safety discipline: no generic methods, no `Self`-returning
|
||||
//! methods. The compile-time witness in `tests` enforces this.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use magnotia_core::error::Result;
|
||||
use magnotia_core::types::{AudioSamples, ModelId, Transcript, TranscriptionOptions};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stable, lower-kebab-case identifier for a provider. Used in user
|
||||
/// profiles, settings storage, and logs. Examples: `local-whisper`,
|
||||
/// `local-parakeet`, `openai-whisper`, `groq-whisper-v3`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ProviderId(String);
|
||||
|
||||
impl ProviderId {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProviderId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a provider runs locally or over the network. The orchestrator
|
||||
/// inspects this to decide whether to honour an offline-only profile.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProviderKind {
|
||||
Local,
|
||||
Cloud(NetworkRequirement),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NetworkRequirement {
|
||||
/// Provider requires a live network connection on every call.
|
||||
Online,
|
||||
/// Provider can fall back to a cached or queued path when offline.
|
||||
OnlineWithFallback,
|
||||
}
|
||||
|
||||
/// Indicative cost class for UI surfacing. Not a billing source of truth.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CostClass {
|
||||
/// No marginal cost beyond the user's local hardware.
|
||||
Free,
|
||||
/// Per-call cost; user supplies their own API key (BYOK).
|
||||
PaidByok,
|
||||
/// Per-call cost; provider billed by CORBEL on the user's behalf.
|
||||
PaidManaged,
|
||||
}
|
||||
|
||||
/// Capabilities a provider advertises to the orchestrator and the UI.
|
||||
/// Superset of `magnotia_transcription::TranscriberCapabilities` for
|
||||
/// local providers, with extra fields cloud providers populate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProviderCapabilities {
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
pub initial_prompt_supported: bool,
|
||||
pub language_hint_supported: bool,
|
||||
pub streaming_supported: bool,
|
||||
pub cost_class: CostClass,
|
||||
}
|
||||
|
||||
/// User-selectable engine configuration. The orchestrator resolves
|
||||
/// `engine_id` against the `EngineRegistry`, then derives
|
||||
/// `TranscriptionOptions` from the remaining fields.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EngineProfile {
|
||||
pub engine_id: ProviderId,
|
||||
pub model_id: Option<ModelId>,
|
||||
pub language: Option<String>,
|
||||
pub initial_prompt: Option<String>,
|
||||
}
|
||||
|
||||
impl EngineProfile {
|
||||
pub fn new(engine_id: ProviderId) -> Self {
|
||||
Self {
|
||||
engine_id,
|
||||
model_id: None,
|
||||
language: None,
|
||||
initial_prompt: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_options(&self) -> TranscriptionOptions {
|
||||
TranscriptionOptions {
|
||||
language: self.language.clone(),
|
||||
initial_prompt: self.initial_prompt.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result returned by `TranscriptionProvider::transcribe`. Carries the
|
||||
/// transcript plus inference timing so the orchestrator can surface
|
||||
/// latency without losing it across the trait boundary.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderTranscript {
|
||||
pub transcript: Transcript,
|
||||
pub inference_ms: u64,
|
||||
}
|
||||
|
||||
/// Async-native unified interface for transcription providers.
|
||||
///
|
||||
/// `Send + Sync` supertraits: providers live in an `Arc<dyn
|
||||
/// TranscriptionProvider>` shared across tokio tasks. `async_trait`
|
||||
/// macro converts the async methods into boxed futures so the trait
|
||||
/// stays object-safe.
|
||||
#[async_trait]
|
||||
pub trait TranscriptionProvider: Send + Sync {
|
||||
fn provider_id(&self) -> ProviderId;
|
||||
|
||||
fn kind(&self) -> ProviderKind;
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities;
|
||||
|
||||
/// Idempotent pre-flight. Local providers verify the model is
|
||||
/// loaded into memory; cloud providers validate credentials and
|
||||
/// reach the endpoint. Called before the first `transcribe` of a
|
||||
/// session, and again after a profile or settings change that
|
||||
/// invalidates the previous prepare.
|
||||
async fn prepare(&self, profile: &EngineProfile) -> Result<()>;
|
||||
|
||||
/// Transcribe a chunk of audio. The orchestrator passes raw audio
|
||||
/// already resampled to the provider's `capabilities().sample_rate`.
|
||||
async fn transcribe(
|
||||
&self,
|
||||
audio: AudioSamples,
|
||||
options: TranscriptionOptions,
|
||||
) -> Result<ProviderTranscript>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn provider_trait_is_object_safe() {
|
||||
// Compile-time witness: if the trait stops being object-safe
|
||||
// (generic method, Self-returning method, missing async_trait
|
||||
// attribute) this declaration fails to build. No runtime work.
|
||||
let _: Option<std::sync::Arc<dyn TranscriptionProvider>> = None;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_id_round_trips_display_and_str() {
|
||||
let id = ProviderId::new("local-whisper");
|
||||
assert_eq!(id.as_str(), "local-whisper");
|
||||
assert_eq!(id.to_string(), "local-whisper");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_profile_derives_options() {
|
||||
let profile = EngineProfile {
|
||||
engine_id: ProviderId::new("local-whisper"),
|
||||
model_id: None,
|
||||
language: Some("en".to_string()),
|
||||
initial_prompt: Some("Wyrdnote".to_string()),
|
||||
};
|
||||
let opts = profile.to_options();
|
||||
assert_eq!(opts.language, Some("en".to_string()));
|
||||
assert_eq!(opts.initial_prompt, Some("Wyrdnote".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,10 @@ serde_json = "1"
|
||||
thiserror = "2"
|
||||
sysinfo = "0.35"
|
||||
async-trait = "0.1"
|
||||
num_cpus = "1"
|
||||
tracing = "0.1"
|
||||
libloading = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
50
crates/core/examples/tuning_log_demo.rs
Normal file
50
crates/core/examples/tuning_log_demo.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Demonstrator: show what the inference_thread_count tracing event
|
||||
//! emits in production, across all eight (workload, on_battery,
|
||||
//! gpu_offloaded) tuples.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run -p magnotia-core --example tuning_log_demo
|
||||
//!
|
||||
//! Output is to stderr (tracing's default). Each unique tuple emits
|
||||
//! exactly one INFO line; subsequent calls with the same tuple are
|
||||
//! silenced by the per-process log-once cache.
|
||||
|
||||
use magnotia_core::tuning::{inference_thread_count, Workload};
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("magnotia_core=info")
|
||||
.with_target(true)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let cores = num_cpus::get_physical();
|
||||
let logical = num_cpus::get();
|
||||
eprintln!("Host: {cores} physical / {logical} logical cores\n");
|
||||
|
||||
for (label, override_value) in [
|
||||
("AC override", Some("ac")),
|
||||
("Battery override", Some("battery")),
|
||||
("No override (real sysfs probe)", None),
|
||||
] {
|
||||
match override_value {
|
||||
Some(v) => std::env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", v),
|
||||
None => std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE"),
|
||||
}
|
||||
// Cache invalidation so the live probe re-runs each section.
|
||||
// Override paths bypass the cache anyway; this is for the
|
||||
// no-override block that actually hits sysfs.
|
||||
eprintln!("--- {label} ---");
|
||||
for &(workload, gpu) in &[
|
||||
(Workload::Llm, false),
|
||||
(Workload::Llm, true),
|
||||
(Workload::Whisper, false),
|
||||
(Workload::Whisper, true),
|
||||
] {
|
||||
let n = inference_thread_count(workload, gpu);
|
||||
let w = format!("{workload:?}");
|
||||
eprintln!(" {w:>8} gpu_offloaded={gpu:>5} -> {n} threads");
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,6 @@ pub const MIN_CHUNK_SAMPLES: usize = 8000;
|
||||
/// Post-processing thresholds.
|
||||
pub const SMART_PARAGRAPH_GAP_SECS: f64 = 2.0;
|
||||
|
||||
/// Thread count for inference. Leaves headroom for the UI thread.
|
||||
pub const MIN_INFERENCE_THREADS: usize = 4;
|
||||
|
||||
/// History limits.
|
||||
pub const HISTORY_MAX_ENTRIES: usize = 100;
|
||||
|
||||
@@ -39,11 +36,3 @@ pub const VAD_SPEECH_PAD_MS: u32 = 100;
|
||||
|
||||
/// Model download chunk size for progress reporting.
|
||||
pub const DOWNLOAD_CHUNK_BYTES: usize = 65_536;
|
||||
|
||||
/// Inference thread count based on available parallelism.
|
||||
pub fn inference_thread_count() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|p| p.get().saturating_sub(1))
|
||||
.unwrap_or(MIN_INFERENCE_THREADS)
|
||||
.max(MIN_INFERENCE_THREADS)
|
||||
}
|
||||
|
||||
@@ -31,30 +31,53 @@ pub enum MagnotiaError {
|
||||
#[error("model download failed: {0}")]
|
||||
DownloadFailed(String),
|
||||
|
||||
#[error("file not found: {}", .0.display())]
|
||||
#[error("file not found: '{}'", .0.display())]
|
||||
FileNotFound(PathBuf),
|
||||
|
||||
#[error("storage error: {0}")]
|
||||
StorageError(String),
|
||||
/// Structured storage failure flowed up from `magnotia_storage::Error` via
|
||||
/// its `From` impl. Display reads through to `detail` so the operation +
|
||||
/// source context produced by the storage crate isn't double-prefixed.
|
||||
#[error("{detail}")]
|
||||
Storage {
|
||||
kind: StorageKind,
|
||||
operation: String,
|
||||
detail: String,
|
||||
},
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(
|
||||
#[from]
|
||||
#[serde(serialize_with = "serialize_io_error")]
|
||||
std::io::Error,
|
||||
),
|
||||
#[error("provider not registered: {0}")]
|
||||
ProviderNotRegistered(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
#[error("io error ({kind}): {message}")]
|
||||
Io {
|
||||
kind: String,
|
||||
message: String,
|
||||
raw_os_error: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Serialises `std::io::Error` as its display string, since it does
|
||||
/// not implement `Serialize` natively.
|
||||
fn serialize_io_error<S: serde::Serializer>(
|
||||
err: &std::io::Error,
|
||||
s: S,
|
||||
) -> std::result::Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&err.to_string())
|
||||
/// Coarse discriminator for `MagnotiaError::Storage`. The storage crate maps
|
||||
/// its full typed error onto one of these kinds at the boundary. Backend code
|
||||
/// that wants finer-grained branching pattern-matches on
|
||||
/// `magnotia_storage::Error` directly.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StorageKind {
|
||||
DatabaseOpen,
|
||||
Migration,
|
||||
Query,
|
||||
NotFound,
|
||||
InvalidReference,
|
||||
Filesystem,
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for MagnotiaError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
kind: format!("{:?}", err.kind()),
|
||||
message: err.to_string(),
|
||||
raw_os_error: err.raw_os_error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, MagnotiaError>;
|
||||
|
||||
@@ -169,6 +169,40 @@ pub fn probe_system() -> SystemProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort probe for the Vulkan loader shared library.
|
||||
///
|
||||
/// whisper.cpp and llama.cpp Vulkan backends silently drop to CPU if
|
||||
/// `libvulkan.so.1` (Linux) / `vulkan-1.dll` (Windows) / the MoltenVK
|
||||
/// bundle (macOS) is missing at runtime. We probe via
|
||||
/// `libloading::Library::new`; a successful open means the loader is
|
||||
/// resolvable and we should treat the GPU path as live.
|
||||
///
|
||||
/// Moved from `src-tauri/src/commands/models.rs` so non-Tauri crates
|
||||
/// (transcription, llm) can call it without depending on the Tauri
|
||||
/// binary.
|
||||
pub fn vulkan_loader_available() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
let candidates: &[&str] = &["libvulkan.so.1", "libvulkan.so"];
|
||||
#[cfg(target_os = "windows")]
|
||||
let candidates: &[&str] = &["vulkan-1.dll"];
|
||||
#[cfg(target_os = "macos")]
|
||||
let candidates: &[&str] = &["libvulkan.1.dylib", "libMoltenVK.dylib"];
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||
let candidates: &[&str] = &[];
|
||||
|
||||
for name in candidates {
|
||||
// SAFETY: libloading::Library::new loads a shared library and
|
||||
// returns a handle that is dropped at the end of this
|
||||
// iteration. We do not call any symbols, so the open-for-probe
|
||||
// pattern is sound.
|
||||
match unsafe { libloading::Library::new(*name) } {
|
||||
Ok(_lib) => return true,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -217,4 +251,11 @@ mod tests {
|
||||
#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
|
||||
assert!(!features.has_ggml_baseline());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vulkan_loader_available_does_not_panic() {
|
||||
// We can't assert the value (depends on host's libvulkan),
|
||||
// but we can assert the call completes.
|
||||
let _ = vulkan_loader_available();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ pub mod error;
|
||||
pub mod hardware;
|
||||
pub mod model_registry;
|
||||
pub mod paths;
|
||||
pub mod power;
|
||||
pub mod process_watch;
|
||||
pub mod recommendation;
|
||||
pub mod tuning;
|
||||
pub mod types;
|
||||
|
||||
pub use error::{MagnotiaError, Result};
|
||||
|
||||
@@ -91,7 +91,10 @@ fn resolve_app_data_dir() -> PathBuf {
|
||||
return PathBuf::from(xdg).join("magnotia");
|
||||
}
|
||||
}
|
||||
PathBuf::from(home).join(".local").join("share").join("magnotia")
|
||||
PathBuf::from(home)
|
||||
.join(".local")
|
||||
.join("share")
|
||||
.join("magnotia")
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
@@ -112,7 +115,10 @@ mod tests {
|
||||
let paths = AppPaths {
|
||||
app_data_dir: PathBuf::from("/tmp/magnotia-test"),
|
||||
};
|
||||
assert_eq!(paths.database_path(), PathBuf::from("/tmp/magnotia-test/magnotia.db"));
|
||||
assert_eq!(
|
||||
paths.database_path(),
|
||||
PathBuf::from("/tmp/magnotia-test/magnotia.db")
|
||||
);
|
||||
assert_eq!(
|
||||
paths.speech_model_dir(&ModelId::new("whisper-base-en")),
|
||||
PathBuf::from("/tmp/magnotia-test/models/whisper-base-en")
|
||||
|
||||
337
crates/core/src/power.rs
Normal file
337
crates/core/src/power.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
//! Power-state probe for inference thread tuning.
|
||||
//!
|
||||
//! Reports whether the machine is running on AC or battery so callers
|
||||
//! can drop thread counts when energy matters more than throughput.
|
||||
//! Linux uses the documented sysfs ABI under
|
||||
//! `/sys/class/power_supply/`. macOS and Windows return `Unknown` for
|
||||
//! now; native probes (IOPSGetProvidingPowerSourceType,
|
||||
//! GetSystemPowerStatus) are deferred.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PowerState {
|
||||
OnAc,
|
||||
OnBattery,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Parse a `/sys/class/power_supply/`-style directory and decide
|
||||
/// whether the machine is on AC, on battery, or in an unknown state.
|
||||
///
|
||||
/// Rules (matches `Documentation/ABI/testing/sysfs-class-power`):
|
||||
/// - Any entry with `type` in {`Mains`, `USB`} and `online == 1` → OnAc.
|
||||
/// - Else any entry with `type == Battery` → OnBattery.
|
||||
/// - Else → Unknown.
|
||||
///
|
||||
/// Top-level failures (missing dir, unreadable supply_dir) return
|
||||
/// Unknown without panicking. Per-entry failures (unreadable
|
||||
/// `type`/`online` file inside an individual supply, e.g. permission
|
||||
/// denied or non-UTF-8 content) cause that entry to be silently
|
||||
/// skipped via `read_trimmed().unwrap_or_default()` — which on a
|
||||
/// stuck-AC machine could produce OnBattery if the Mains entry was
|
||||
/// the unreadable one. In practice sysfs entries are world-readable,
|
||||
/// so this is a theoretical hazard rather than a real one. `Unknown`
|
||||
/// is treated as `OnAc` by the caller, preserving today's pre-clamp
|
||||
/// behaviour on platforms or distros where the probe doesn't fire.
|
||||
pub fn parse_power_state_from_dir(supply_dir: &Path) -> PowerState {
|
||||
let read_dir = match fs::read_dir(supply_dir) {
|
||||
Ok(rd) => rd,
|
||||
Err(_) => return PowerState::Unknown,
|
||||
};
|
||||
|
||||
let mut saw_battery = false;
|
||||
for entry in read_dir.flatten() {
|
||||
let path = entry.path();
|
||||
let ty = read_trimmed(&path.join("type")).unwrap_or_default();
|
||||
let online = read_trimmed(&path.join("online")).unwrap_or_default();
|
||||
match ty.as_str() {
|
||||
"Mains" | "USB" => {
|
||||
if online == "1" {
|
||||
return PowerState::OnAc;
|
||||
}
|
||||
}
|
||||
"Battery" => {
|
||||
saw_battery = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if saw_battery {
|
||||
PowerState::OnBattery
|
||||
} else {
|
||||
PowerState::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn read_trimmed(path: &Path) -> Option<String> {
|
||||
fs::read_to_string(path).ok().map(|s| s.trim().to_owned())
|
||||
}
|
||||
|
||||
const POWER_STATE_TTL: Duration = Duration::from_secs(10);
|
||||
|
||||
struct CachedState {
|
||||
state: PowerState,
|
||||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
fn cache_slot() -> &'static Mutex<Option<CachedState>> {
|
||||
static SLOT: OnceLock<Mutex<Option<CachedState>>> = OnceLock::new();
|
||||
SLOT.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn force_clear_cache() {
|
||||
*cache_slot().lock().expect("power cache mutex poisoned") = None;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn force_set_cache(state: PowerState) {
|
||||
*cache_slot().lock().expect("power cache mutex poisoned") = Some(CachedState {
|
||||
state,
|
||||
fetched_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Top-level power-state probe.
|
||||
///
|
||||
/// Resolution order (highest to lowest priority):
|
||||
/// 1. In-process test override (set via `with_override` from unit tests).
|
||||
/// 2. `MAGNOTIA_POWER_STATE_OVERRIDE` env var (`ac` | `battery` | `unknown`,
|
||||
/// case-insensitive). Used by `thread_sweep.rs` integration tests.
|
||||
/// 3. Linux: `parse_power_state_from_dir("/sys/class/power_supply")`.
|
||||
/// 4. macOS / Windows / other: `Unknown`.
|
||||
///
|
||||
/// `Unknown` is treated as `OnAc` by callers, which preserves today's
|
||||
/// pre-clamp behaviour on platforms where the probe doesn't fire.
|
||||
///
|
||||
/// Results are cached for `POWER_STATE_TTL` (10 seconds). Override
|
||||
/// paths (test and env-var) bypass the cache entirely so they always
|
||||
/// take effect immediately.
|
||||
pub fn probe_power_state() -> PowerState {
|
||||
#[cfg(test)]
|
||||
if let Some(state) = test_get_override() {
|
||||
return state;
|
||||
}
|
||||
if let Some(state) = env_override() {
|
||||
return state;
|
||||
}
|
||||
|
||||
let mut slot = cache_slot().lock().expect("power cache mutex poisoned");
|
||||
if let Some(cached) = &*slot {
|
||||
if cached.fetched_at.elapsed() < POWER_STATE_TTL {
|
||||
return cached.state;
|
||||
}
|
||||
}
|
||||
let fresh = platform_probe();
|
||||
*slot = Some(CachedState {
|
||||
state: fresh,
|
||||
fetched_at: Instant::now(),
|
||||
});
|
||||
fresh
|
||||
}
|
||||
|
||||
fn env_override() -> Option<PowerState> {
|
||||
let raw = std::env::var("MAGNOTIA_POWER_STATE_OVERRIDE").ok()?;
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"ac" => Some(PowerState::OnAc),
|
||||
"battery" => Some(PowerState::OnBattery),
|
||||
"unknown" => Some(PowerState::Unknown),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform_probe() -> PowerState {
|
||||
parse_power_state_from_dir(Path::new("/sys/class/power_supply"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn platform_probe() -> PowerState {
|
||||
PowerState::Unknown
|
||||
}
|
||||
|
||||
// In-process override slot. Tests must use `with_override` (below) to
|
||||
// set it; production code never writes to this. Read on every probe.
|
||||
#[cfg(test)]
|
||||
static TEST_OVERRIDE: Mutex<Option<PowerState>> = Mutex::new(None);
|
||||
|
||||
#[cfg(test)]
|
||||
fn test_get_override() -> Option<PowerState> {
|
||||
*TEST_OVERRIDE
|
||||
.lock()
|
||||
.expect("power test override mutex poisoned")
|
||||
}
|
||||
|
||||
/// Drop-guard that resets `TEST_OVERRIDE` to `None` on drop (including on unwind).
|
||||
/// This prevents a panicking test body from leaking stale override state into
|
||||
/// subsequent tests.
|
||||
#[cfg(test)]
|
||||
struct OverrideGuard;
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for OverrideGuard {
|
||||
fn drop(&mut self) {
|
||||
*TEST_OVERRIDE
|
||||
.lock()
|
||||
.expect("power test override mutex poisoned") = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `body` with the in-process override set to `state`. Restores
|
||||
/// `TEST_OVERRIDE` to `None` on return, even if `body` panics.
|
||||
///
|
||||
/// Holds a dedicated `TEST_LOCK` mutex for the duration of the body,
|
||||
/// so override-using unit tests run serially with respect to each
|
||||
/// other even when cargo runs the test binary multi-threaded.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_override<R>(state: Option<PowerState>, body: impl FnOnce() -> R) -> R {
|
||||
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
let _lock = TEST_LOCK.lock().expect("power TEST_LOCK poisoned");
|
||||
*TEST_OVERRIDE
|
||||
.lock()
|
||||
.expect("power test override mutex poisoned") = state;
|
||||
let _guard = OverrideGuard;
|
||||
body()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn power_state_variants_are_distinct() {
|
||||
assert_ne!(PowerState::OnAc, PowerState::OnBattery);
|
||||
assert_ne!(PowerState::OnAc, PowerState::Unknown);
|
||||
assert_ne!(PowerState::OnBattery, PowerState::Unknown);
|
||||
}
|
||||
|
||||
fn write_supply(dir: &std::path::Path, name: &str, ty: &str, online: &str) {
|
||||
let entry = dir.join(name);
|
||||
fs::create_dir_all(&entry).unwrap();
|
||||
fs::write(entry.join("type"), format!("{ty}\n")).unwrap();
|
||||
fs::write(entry.join("online"), format!("{online}\n")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_mains_online_as_on_ac() {
|
||||
let dir = tempdir().unwrap();
|
||||
write_supply(dir.path(), "AC", "Mains", "1");
|
||||
write_supply(dir.path(), "BAT0", "Battery", "0");
|
||||
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnAc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_battery_only_as_on_battery() {
|
||||
let dir = tempdir().unwrap();
|
||||
write_supply(dir.path(), "AC", "Mains", "0");
|
||||
write_supply(dir.path(), "BAT0", "Battery", "0");
|
||||
assert_eq!(
|
||||
parse_power_state_from_dir(dir.path()),
|
||||
PowerState::OnBattery
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_usb_pd_online_as_on_ac() {
|
||||
let dir = tempdir().unwrap();
|
||||
write_supply(dir.path(), "ucsi-source-psy-USBC000:001", "USB", "1");
|
||||
write_supply(dir.path(), "BAT0", "Battery", "0");
|
||||
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnAc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_empty_dir_as_unknown() {
|
||||
let dir = tempdir().unwrap();
|
||||
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_missing_dir_as_unknown() {
|
||||
let path = std::path::Path::new("/no/such/path/should/exist/at/this/inode/123456");
|
||||
assert_eq!(parse_power_state_from_dir(path), PowerState::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_malformed_files_as_unknown_gracefully() {
|
||||
let dir = tempdir().unwrap();
|
||||
let entry = dir.path().join("garbage");
|
||||
fs::create_dir_all(&entry).unwrap();
|
||||
// No type, no online — should not panic.
|
||||
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_drives_battery() {
|
||||
with_override(Some(PowerState::OnBattery), || {
|
||||
assert_eq!(probe_power_state(), PowerState::OnBattery);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_drives_ac() {
|
||||
with_override(Some(PowerState::OnAc), || {
|
||||
assert_eq!(probe_power_state(), PowerState::OnAc);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_drives_unknown() {
|
||||
with_override(Some(PowerState::Unknown), || {
|
||||
assert_eq!(probe_power_state(), PowerState::Unknown);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_var_override_battery_via_set_var() {
|
||||
// env-var path tested in isolation under TEST_LOCK so it
|
||||
// doesn't race with the in-process override tests.
|
||||
with_override(None, || {
|
||||
std::env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", "battery");
|
||||
assert_eq!(probe_power_state(), PowerState::OnBattery);
|
||||
std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_var_override_garbage_falls_through() {
|
||||
with_override(None, || {
|
||||
std::env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", "nonsense");
|
||||
// Garbage value falls through to the platform probe.
|
||||
// We can't assert the platform result so just assert it
|
||||
// doesn't panic.
|
||||
let _ = probe_power_state();
|
||||
std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ttl_cache_returns_cached_value_within_window() {
|
||||
with_override(None, || {
|
||||
force_clear_cache();
|
||||
force_set_cache(PowerState::OnBattery);
|
||||
// No override active, no env var; probe should hit cache.
|
||||
assert_eq!(probe_power_state(), PowerState::OnBattery);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ttl_cache_clears_via_force_clear() {
|
||||
with_override(None, || {
|
||||
force_set_cache(PowerState::OnBattery);
|
||||
force_clear_cache();
|
||||
// Override flips to OnAc; with cleared cache, override
|
||||
// (which has higher priority) drives the result.
|
||||
});
|
||||
with_override(Some(PowerState::OnAc), || {
|
||||
force_clear_cache();
|
||||
assert_eq!(probe_power_state(), PowerState::OnAc);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,7 @@ impl ProcessLister {
|
||||
/// Refresh the process table in place and return the current
|
||||
/// lowercased executable names.
|
||||
pub fn snapshot(&mut self) -> Vec<String> {
|
||||
self.system
|
||||
.refresh_processes(ProcessesToUpdate::All, true);
|
||||
self.system.refresh_processes(ProcessesToUpdate::All, true);
|
||||
self.system
|
||||
.processes()
|
||||
.values()
|
||||
|
||||
@@ -49,7 +49,7 @@ pub fn score_model(model: &'static ModelEntry, profile: &SystemProfile) -> Optio
|
||||
}
|
||||
|
||||
let headroom = Megabytes(profile.ram.0.saturating_sub(model.ram_required.0));
|
||||
if headroom > Megabytes::from_gb(4.0) {
|
||||
if headroom > Megabytes::from_gb(4) {
|
||||
score += 10.0;
|
||||
}
|
||||
|
||||
|
||||
240
crates/core/src/tuning.rs
Normal file
240
crates/core/src/tuning.rs
Normal file
@@ -0,0 +1,240 @@
|
||||
//! Inference thread-count tuning. Combines physical-core budget,
|
||||
//! battery awareness, and GPU-offload awareness into a single helper
|
||||
//! callable from both inference call sites.
|
||||
|
||||
use crate::power::{self, PowerState};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Lower bound for inference threads. Single-threaded inference is
|
||||
/// measurably worse than two on every multi-core part.
|
||||
pub const MIN_INFERENCE_THREADS: usize = 2;
|
||||
|
||||
/// Upper bound for inference threads. whisper.cpp + llama.cpp scaling
|
||||
/// flattens around physical core count; SMT siblings contend for FPU
|
||||
/// during F16/F32 matmul, so going past physical cores anti-scales.
|
||||
/// 8 is a conservative ceiling that leaves <5% on the table for
|
||||
/// big-iron desktops while keeping consumer 6c/12t laptops out of
|
||||
/// contention territory. Users can override at runtime via
|
||||
/// MAGNOTIA_INFERENCE_THREADS.
|
||||
pub const MAX_INFERENCE_THREADS: usize = 8;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Workload {
|
||||
/// Llama-style transformer. Near-zero CPU work when fully
|
||||
/// offloaded; CPU floor in that case is GPU_FLOOR_LLM.
|
||||
Llm,
|
||||
/// Whisper transcription. Keeps mel spectrogram, decoder
|
||||
/// bookkeeping, and beam search on the CPU even with full Vulkan
|
||||
/// offload; CPU floor is higher: GPU_FLOOR_WHISPER.
|
||||
Whisper,
|
||||
}
|
||||
|
||||
const GPU_FLOOR_LLM: usize = 2;
|
||||
const GPU_FLOOR_WHISPER: usize = 4;
|
||||
|
||||
/// Whether a given (workload, on_battery, gpu_offloaded) tuple has
|
||||
/// already been logged this process. Prevents log spam when the same
|
||||
/// configuration is exercised on every inference call.
|
||||
fn log_seen() -> &'static Mutex<HashSet<(Workload, bool, bool)>> {
|
||||
static SEEN: OnceLock<Mutex<HashSet<(Workload, bool, bool)>>> = OnceLock::new();
|
||||
SEEN.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
/// Inference thread count, clamped to the physical-core budget plus
|
||||
/// the battery and GPU-offload heuristics.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `MAGNOTIA_INFERENCE_THREADS=N` — absolute bypass, returns N.
|
||||
/// 2. base = num_cpus::get_physical() (fallback: available_parallelism).
|
||||
/// 3. on battery → base /= 2.
|
||||
/// 4. gpu_offloaded → base = min(base, gpu_floor(workload)).
|
||||
/// 5. clamp to [MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS].
|
||||
pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize {
|
||||
if let Ok(s) = std::env::var("MAGNOTIA_INFERENCE_THREADS") {
|
||||
if let Ok(n) = s.parse::<usize>() {
|
||||
if n > 0 {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
let physical = num_cpus::get_physical();
|
||||
let mut chosen = if physical > 0 {
|
||||
physical
|
||||
} else {
|
||||
std::thread::available_parallelism()
|
||||
.map(|p| p.get())
|
||||
.unwrap_or(MIN_INFERENCE_THREADS)
|
||||
};
|
||||
let on_battery = power::probe_power_state() == PowerState::OnBattery;
|
||||
let mut clamps: Vec<&'static str> = Vec::new();
|
||||
if on_battery {
|
||||
chosen /= 2;
|
||||
clamps.push("battery");
|
||||
}
|
||||
if gpu_offloaded {
|
||||
let floor = match workload {
|
||||
Workload::Llm => GPU_FLOOR_LLM,
|
||||
Workload::Whisper => GPU_FLOOR_WHISPER,
|
||||
};
|
||||
if chosen > floor {
|
||||
chosen = floor;
|
||||
clamps.push("gpu");
|
||||
}
|
||||
}
|
||||
let final_value = chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS);
|
||||
|
||||
// Log once per (workload, on_battery, gpu_offloaded) tuple.
|
||||
let key = (workload, on_battery, gpu_offloaded);
|
||||
if let Ok(mut seen) = log_seen().lock() {
|
||||
if seen.insert(key) {
|
||||
tracing::info!(
|
||||
target: "magnotia_core::tuning",
|
||||
threads = final_value,
|
||||
workload = ?workload,
|
||||
gpu_offloaded,
|
||||
on_battery,
|
||||
clamps = ?clamps,
|
||||
"inference_thread_count"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final_value
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Serialises tests that read/write `MAGNOTIA_INFERENCE_THREADS` so
|
||||
/// they don't race under cargo's parallel test runner.
|
||||
/// Mirrors the pattern used by `power::with_override`.
|
||||
fn with_thread_env_lock<R>(body: impl FnOnce() -> R) -> R {
|
||||
use std::sync::Mutex;
|
||||
static THREAD_ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
let _lock = THREAD_ENV_LOCK
|
||||
.lock()
|
||||
.expect("tuning thread-env lock poisoned");
|
||||
body()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_existing_clamp_when_no_clamps_apply() {
|
||||
// With no env override, no battery, no gpu_offload, helper
|
||||
// should return physical-core count clamped to [2, 8].
|
||||
// We can't pin physical exactly without mocking num_cpus; just
|
||||
// assert the result is in range.
|
||||
with_thread_env_lock(|| {
|
||||
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
|
||||
let n = inference_thread_count(Workload::Llm, false);
|
||||
assert!(
|
||||
(MIN_INFERENCE_THREADS..=MAX_INFERENCE_THREADS).contains(&n),
|
||||
"got {n}, expected within [{MIN_INFERENCE_THREADS}, {MAX_INFERENCE_THREADS}]"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_var_bypasses_clamps() {
|
||||
with_thread_env_lock(|| {
|
||||
std::env::set_var("MAGNOTIA_INFERENCE_THREADS", "10");
|
||||
let n = inference_thread_count(Workload::Llm, true);
|
||||
assert_eq!(n, 10);
|
||||
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workload_variants_distinct() {
|
||||
assert_ne!(Workload::Llm, Workload::Whisper);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn battery_halves_thread_count() {
|
||||
with_thread_env_lock(|| {
|
||||
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
|
||||
// Measure on battery, then on AC — sequential, not nested,
|
||||
// to avoid re-entrant deadlock on power::TEST_LOCK.
|
||||
let on_battery = crate::power::with_override(Some(PowerState::OnBattery), || {
|
||||
inference_thread_count(Workload::Llm, false)
|
||||
});
|
||||
let on_ac = crate::power::with_override(Some(PowerState::OnAc), || {
|
||||
inference_thread_count(Workload::Llm, false)
|
||||
});
|
||||
// on_battery should be roughly half of on_ac, clamped to MIN.
|
||||
if on_ac > MIN_INFERENCE_THREADS {
|
||||
assert!(
|
||||
on_battery <= on_ac,
|
||||
"battery ({on_battery}) should be <= AC ({on_ac})"
|
||||
);
|
||||
assert!(on_battery >= MIN_INFERENCE_THREADS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_offload_clamps_llm_to_floor() {
|
||||
with_thread_env_lock(|| {
|
||||
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
|
||||
crate::power::with_override(Some(PowerState::OnAc), || {
|
||||
let n = inference_thread_count(Workload::Llm, true);
|
||||
assert!(
|
||||
n <= GPU_FLOOR_LLM.max(MIN_INFERENCE_THREADS),
|
||||
"Llm with GPU offload should clamp to {GPU_FLOOR_LLM}, got {n}"
|
||||
);
|
||||
assert!(n >= MIN_INFERENCE_THREADS);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_offload_clamps_whisper_to_floor() {
|
||||
with_thread_env_lock(|| {
|
||||
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
|
||||
crate::power::with_override(Some(PowerState::OnAc), || {
|
||||
let n = inference_thread_count(Workload::Whisper, true);
|
||||
// Whisper floor is 4 on machines with >=4 physical cores;
|
||||
// can't go lower than physical so on a 2c host we stay at 2.
|
||||
assert!(n <= GPU_FLOOR_WHISPER, "got {n}");
|
||||
assert!(n >= MIN_INFERENCE_THREADS);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const _: () = assert!(GPU_FLOOR_WHISPER >= GPU_FLOOR_LLM);
|
||||
|
||||
#[test]
|
||||
fn whisper_gpu_floor_is_at_least_llm_gpu_floor() {
|
||||
// Architectural invariant: whisper retains CPU work even when
|
||||
// GPU-offloaded; its floor must not be lower than the LLM's.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_offload_off_does_not_clamp_below_battery_calc() {
|
||||
with_thread_env_lock(|| {
|
||||
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
|
||||
// Sequential measurements; with_override is non-reentrant.
|
||||
crate::power::with_override(Some(PowerState::OnAc), || {
|
||||
let no_gpu = inference_thread_count(Workload::Llm, false);
|
||||
let with_gpu = inference_thread_count(Workload::Llm, true);
|
||||
// GPU-offloaded should be <= non-offloaded.
|
||||
assert!(with_gpu <= no_gpu);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logging_does_not_panic() {
|
||||
// Smoke: helper should run without panicking when logging is
|
||||
// wired. This is covered by the other tests too, but kept
|
||||
// explicitly to document the behaviour.
|
||||
with_thread_env_lock(|| {
|
||||
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
|
||||
crate::power::with_override(Some(PowerState::OnBattery), || {
|
||||
let _ = inference_thread_count(Workload::Llm, true);
|
||||
let _ = inference_thread_count(Workload::Whisper, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
/// Prevents passing raw strings where model IDs are expected.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ModelId(String);
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||
pub struct ModelId(Cow<'static, str>);
|
||||
|
||||
impl ModelId {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
pub const fn borrowed(id: &'static str) -> Self {
|
||||
Self(Cow::Borrowed(id))
|
||||
}
|
||||
|
||||
pub fn new(id: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
|
||||
@@ -14,6 +21,15 @@ impl ModelId {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ModelId {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
String::deserialize(deserializer).map(|s| Self(Cow::Owned(s)))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ModelId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
@@ -21,11 +37,15 @@ impl std::fmt::Display for ModelId {
|
||||
}
|
||||
|
||||
/// Prevents passing raw strings where engine names are expected.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EngineName(String);
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct EngineName(Cow<'static, str>);
|
||||
|
||||
impl EngineName {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
pub const fn borrowed(name: &'static str) -> Self {
|
||||
Self(Cow::Borrowed(name))
|
||||
}
|
||||
|
||||
pub fn new(name: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
|
||||
@@ -34,6 +54,15 @@ impl EngineName {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for EngineName {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
String::deserialize(deserializer).map(|s| Self(Cow::Owned(s)))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EngineName {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
@@ -45,8 +74,12 @@ impl std::fmt::Display for EngineName {
|
||||
pub struct Megabytes(pub u64);
|
||||
|
||||
impl Megabytes {
|
||||
pub fn from_gb(gb: f64) -> Self {
|
||||
Self((gb * 1024.0) as u64)
|
||||
pub const fn from_gb(gb: u64) -> Self {
|
||||
Self(gb.saturating_mul(1024))
|
||||
}
|
||||
|
||||
pub const fn from_mb(mb: u64) -> Self {
|
||||
Self(mb)
|
||||
}
|
||||
|
||||
pub fn as_gb(&self) -> f64 {
|
||||
@@ -68,23 +101,36 @@ impl std::fmt::Display for Megabytes {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioSamples {
|
||||
samples: Vec<f32>,
|
||||
sample_rate: u32,
|
||||
sample_rate: NonZeroU32,
|
||||
channels: u16,
|
||||
}
|
||||
|
||||
impl AudioSamples {
|
||||
pub fn new(samples: Vec<f32>, sample_rate: u32, channels: u16) -> Self {
|
||||
Self {
|
||||
Self::try_new(samples, sample_rate, channels)
|
||||
.expect("AudioSamples sample_rate must be non-zero")
|
||||
}
|
||||
|
||||
pub fn try_new(
|
||||
samples: Vec<f32>,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
) -> std::result::Result<Self, &'static str> {
|
||||
let Some(sample_rate) = NonZeroU32::new(sample_rate) else {
|
||||
return Err("sample_rate must be non-zero");
|
||||
};
|
||||
Ok(Self {
|
||||
samples,
|
||||
sample_rate,
|
||||
channels,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mono_16khz(samples: Vec<f32>) -> Self {
|
||||
Self {
|
||||
samples,
|
||||
sample_rate: crate::constants::WHISPER_SAMPLE_RATE,
|
||||
sample_rate: NonZeroU32::new(crate::constants::WHISPER_SAMPLE_RATE)
|
||||
.expect("WHISPER_SAMPLE_RATE must be non-zero"),
|
||||
channels: crate::constants::WHISPER_CHANNELS,
|
||||
}
|
||||
}
|
||||
@@ -98,7 +144,7 @@ impl AudioSamples {
|
||||
}
|
||||
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
self.sample_rate
|
||||
self.sample_rate.get()
|
||||
}
|
||||
|
||||
pub fn channels(&self) -> u16 {
|
||||
@@ -106,10 +152,7 @@ impl AudioSamples {
|
||||
}
|
||||
|
||||
pub fn duration_secs(&self) -> f64 {
|
||||
if self.sample_rate == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.samples.len() as f64 / self.sample_rate as f64
|
||||
self.samples.len() as f64 / self.sample_rate.get() as f64
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ description = "Wayland-compatible global hotkey listener for Magnotia — evdev
|
||||
magnotia-core = { path = "../core" }
|
||||
tokio = { version = "1", features = ["rt", "sync", "macros", "time"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tracing = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
evdev = { version = "0.12", features = ["tokio"] }
|
||||
|
||||
@@ -88,19 +88,19 @@ impl EvdevHotkeyListener {
|
||||
match w.watch(Path::new("/dev/input"), RecursiveMode::NonRecursive) {
|
||||
Ok(()) => Some(w),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[magnotia-hotkey] cannot watch /dev/input ({e}); \
|
||||
hotplug detection disabled, devices present \
|
||||
at startup still work",
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"cannot watch /dev/input; hotplug detection disabled, \
|
||||
devices present at startup still work"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[magnotia-hotkey] cannot create inotify watcher ({e}); \
|
||||
hotplug detection disabled",
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"cannot create inotify watcher; hotplug detection disabled"
|
||||
);
|
||||
None
|
||||
}
|
||||
@@ -199,7 +199,7 @@ async fn scan_and_attach(
|
||||
let entries = match std::fs::read_dir(input_dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
log::error!("Cannot read /dev/input: {e}");
|
||||
tracing::error!(error = %e, "cannot read /dev/input");
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -233,7 +233,7 @@ async fn try_attach_device(
|
||||
let device = match Device::open(path) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::debug!("Cannot open {}: {e}", path.display());
|
||||
tracing::debug!(path = %path.display(), error = %e, "cannot open device");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -243,10 +243,10 @@ async fn try_attach_device(
|
||||
}
|
||||
|
||||
let device_name = device.name().unwrap_or("unknown").to_string();
|
||||
log::info!(
|
||||
"Attached hotkey listener to: {} ({})",
|
||||
device_name,
|
||||
path.display()
|
||||
tracing::info!(
|
||||
device = %device_name,
|
||||
path = %path.display(),
|
||||
"attached hotkey listener"
|
||||
);
|
||||
|
||||
tracked_set.insert(path.to_path_buf());
|
||||
@@ -260,7 +260,11 @@ async fn try_attach_device(
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = device_listener(device, hotkey_rx, event_tx).await {
|
||||
log::warn!("Device listener for {} ended: {e}", path_owned.display());
|
||||
tracing::warn!(
|
||||
path = %path_owned.display(),
|
||||
error = %e,
|
||||
"device listener ended"
|
||||
);
|
||||
}
|
||||
// Remove from tracked set so hotplug can re-attach if reconnected
|
||||
tracked.lock().await.remove(&path_owned);
|
||||
@@ -331,8 +335,8 @@ async fn device_listener(
|
||||
// shutdown. Log once and exit so
|
||||
// the listener doesn't spin
|
||||
// sending into a closed channel.
|
||||
log::warn!(
|
||||
"Hotkey event channel closed; \
|
||||
tracing::warn!(
|
||||
"hotkey event channel closed; \
|
||||
listener for device exiting"
|
||||
);
|
||||
return Ok(());
|
||||
|
||||
@@ -19,7 +19,7 @@ pub struct EvdevHotkeyListener;
|
||||
|
||||
impl EvdevHotkeyListener {
|
||||
pub fn start(_combo: HotkeyCombo, _event_tx: mpsc::Sender<HotkeyEvent>) -> Self {
|
||||
log::info!("evdev hotkey listener is a no-op on this platform");
|
||||
tracing::info!("evdev hotkey listener is a no-op on this platform");
|
||||
Self
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@ openmp = ["llama-cpp-2/openmp"]
|
||||
magnotia-core = { path = "../core" }
|
||||
encoding_rs = "0.8"
|
||||
futures-util = "0.3"
|
||||
llama-cpp-2 = { version = "0.1.144", default-features = false }
|
||||
num_cpus = "1"
|
||||
llama-cpp-2 = { version = "0.1.146", default-features = false }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -9,6 +9,7 @@ use llama_cpp_2::llama_batch::LlamaBatch;
|
||||
use llama_cpp_2::model::params::LlamaModelParams;
|
||||
use llama_cpp_2::model::{AddBos, LlamaChatMessage, LlamaChatTemplate, LlamaModel};
|
||||
use llama_cpp_2::sampling::LlamaSampler;
|
||||
use magnotia_core::tuning::{inference_thread_count, Workload};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod grammars;
|
||||
@@ -163,7 +164,16 @@ impl LlmEngine {
|
||||
}
|
||||
|
||||
let n_ctx = preflight_context_window(prompt_tokens.len(), config.max_tokens)?;
|
||||
let thread_count = i32::try_from(num_cpus::get().max(1)).unwrap_or(4);
|
||||
let use_gpu = self.loaded_model().map(|s| s.use_gpu).unwrap_or(false);
|
||||
let gpu_layers = if use_gpu { u32::MAX } else { 0u32 };
|
||||
// Trivially true today (gpu_layers = u32::MAX when use_gpu), but the
|
||||
// explicit comparison documents intent. True residency observability
|
||||
// (parsing llama.cpp's "offloaded N/M layers" log) is tracked as a
|
||||
// follow-up in docs/superpowers/specs/2026-05-09-battery-gpu-aware-
|
||||
// thread-tuning-design.md (§ Out of scope).
|
||||
let gpu_offloaded = use_gpu && gpu_layers >= model.n_layer();
|
||||
let thread_count =
|
||||
i32::try_from(inference_thread_count(Workload::Llm, gpu_offloaded)).unwrap_or(4);
|
||||
let ctx_params = LlamaContextParams::default()
|
||||
.with_n_ctx(Some(
|
||||
NonZeroU32::new(n_ctx).expect("n_ctx must be non-zero"),
|
||||
@@ -202,6 +212,10 @@ impl LlmEngine {
|
||||
generated.push_str(&piece);
|
||||
sampler.accept(next);
|
||||
|
||||
if config.grammar.is_some() && json_envelope_complete(&generated) {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(stop_index) = first_stop_index(&generated, &config.stop_sequences) {
|
||||
generated.truncate(stop_index);
|
||||
break;
|
||||
@@ -286,13 +300,12 @@ impl LlmEngine {
|
||||
}
|
||||
|
||||
/// Phase 9 content-tag extraction. Emits a single (topic, intent)
|
||||
/// pair under the `CONTENT_TAGS_GRAMMAR` GBNF. Truncates to the
|
||||
/// trailing 2000 chars of the transcript so the prompt budget
|
||||
/// stays well under any model's context window. Determinism is
|
||||
/// enforced by temperature 0.0 and the closed-set intent grammar
|
||||
/// rule; on the rare case the model emits a parse-able-but-out-of-
|
||||
/// set intent, we re-validate with `is_valid_intent` and bubble
|
||||
/// `InvalidJson` so the frontend toasts a clear error.
|
||||
/// pair as JSON. Truncates to the trailing 2000 chars of the
|
||||
/// transcript so the prompt budget stays well under any model's
|
||||
/// context window. Determinism is enforced by temperature 0.0;
|
||||
/// the parsed intent is re-validated with `is_valid_intent` and
|
||||
/// invalid JSON bubbles as `InvalidJson` so the frontend toasts a
|
||||
/// clear error.
|
||||
pub fn extract_content_tags(
|
||||
&self,
|
||||
transcript: &str,
|
||||
@@ -328,12 +341,11 @@ impl LlmEngine {
|
||||
max_tokens: 96,
|
||||
temperature: 0.0,
|
||||
stop_sequences: vec!["<|im_end|>".to_string(), "<|im_end_of_text|>".to_string()],
|
||||
grammar: Some(grammars::CONTENT_TAGS_GRAMMAR.to_string()),
|
||||
grammar: None,
|
||||
},
|
||||
)?;
|
||||
|
||||
let tags: prompts::ContentTags = serde_json::from_str(raw.trim())
|
||||
.map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}")))?;
|
||||
let tags: prompts::ContentTags = parse_json_payload(&raw)?;
|
||||
if !prompts::is_valid_intent(&tags.intent) {
|
||||
return Err(EngineError::InvalidJson(format!(
|
||||
"intent out of closed set: {}",
|
||||
@@ -449,6 +461,62 @@ fn first_stop_index(text: &str, stop_sequences: &[String]) -> Option<usize> {
|
||||
.min()
|
||||
}
|
||||
|
||||
fn json_envelope_complete(text: &str) -> bool {
|
||||
extract_json_envelope(text) == Some(text.trim())
|
||||
}
|
||||
|
||||
fn extract_json_envelope(text: &str) -> Option<&str> {
|
||||
let start = text
|
||||
.char_indices()
|
||||
.find_map(|(idx, ch)| (ch == '{' || ch == '[').then_some(idx))?;
|
||||
let mut chars = text[start..].char_indices();
|
||||
let (_, first) = chars.next()?;
|
||||
|
||||
let mut stack = vec![match first {
|
||||
'{' => '}',
|
||||
'[' => ']',
|
||||
_ => unreachable!(),
|
||||
}];
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
|
||||
while let Some((offset, ch)) = chars.next() {
|
||||
if in_string {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == '"' {
|
||||
in_string = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'"' => in_string = true,
|
||||
'{' => stack.push('}'),
|
||||
'[' => stack.push(']'),
|
||||
'}' | ']' => {
|
||||
if stack.pop() != Some(ch) {
|
||||
return None;
|
||||
}
|
||||
if stack.is_empty() {
|
||||
let end = start + offset + ch.len_utf8();
|
||||
return Some(&text[start..end]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_json_payload<T: for<'de> Deserialize<'de>>(raw: &str) -> Result<T, EngineError> {
|
||||
let payload = extract_json_envelope(raw).unwrap_or(raw.trim());
|
||||
serde_json::from_str(payload).map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}")))
|
||||
}
|
||||
|
||||
fn render_chat_prompt(
|
||||
model: &LlamaModel,
|
||||
messages: &[(&str, &str)],
|
||||
@@ -538,6 +606,47 @@ mod tests {
|
||||
assert_eq!(index, Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_envelope_complete_detects_finished_object() {
|
||||
assert!(json_envelope_complete(
|
||||
r#"{"topic":"meeting","intent":"planning"}"#
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_envelope_complete_detects_finished_array() {
|
||||
assert!(json_envelope_complete(r#"["Call plumber","Buy milk"]"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_envelope_complete_ignores_braces_inside_strings() {
|
||||
assert!(!json_envelope_complete(r#"{"topic":"literal } brace""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_envelope_complete_rejects_prefixes_and_trailing_text() {
|
||||
assert!(!json_envelope_complete(r#"{"topic":"meeting""#));
|
||||
assert!(!json_envelope_complete(r#"{"topic":"meeting"} extra"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_json_envelope_skips_qwen_thinking_prefix() {
|
||||
let raw =
|
||||
"<think>\n\n</think>\n\n{\"topic\":\"grant-application\",\"intent\":\"planning\"}";
|
||||
assert_eq!(
|
||||
extract_json_envelope(raw),
|
||||
Some("{\"topic\":\"grant-application\",\"intent\":\"planning\"}"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_json_envelope_handles_arrays_and_trailing_stop_text() {
|
||||
assert_eq!(
|
||||
extract_json_envelope("prefix [\"Call plumber\",\"Buy milk\"]<|im_end|>"),
|
||||
Some("[\"Call plumber\",\"Buy milk\"]"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_preflight_rejects_oversized_prompt_tokens() {
|
||||
let err = preflight_context_window(7_105, 1_024).unwrap_err();
|
||||
|
||||
@@ -24,5 +24,8 @@ serde = { version = "1", features = ["derive"] }
|
||||
# Logging
|
||||
log = "0.4"
|
||||
|
||||
# Structured error derivation for magnotia_storage::Error.
|
||||
thiserror = "1"
|
||||
|
||||
# UUIDs for profile + profile_terms ids (v7 random).
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
@@ -3,12 +3,15 @@ use std::path::Path;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
use crate::error::{Entity, Error, OpenOp, Result};
|
||||
|
||||
/// Initialise the SQLite database with connection pool and run migrations.
|
||||
pub async fn init(db_path: &Path) -> Result<SqlitePool> {
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
std::fs::create_dir_all(parent).map_err(|source| Error::Filesystem {
|
||||
path: parent.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
|
||||
let options = SqliteConnectOptions::new()
|
||||
@@ -19,12 +22,18 @@ pub async fn init(db_path: &Path) -> Result<SqlitePool> {
|
||||
.max_connections(5)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Database connect failed: {e}")))?;
|
||||
.map_err(|source| Error::DatabaseOpen {
|
||||
operation: OpenOp::Connect,
|
||||
source,
|
||||
})?;
|
||||
|
||||
sqlx::query("PRAGMA foreign_keys = ON")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("foreign_keys pragma failed: {e}")))?;
|
||||
.map_err(|source| Error::DatabaseOpen {
|
||||
operation: OpenOp::ForeignKeysPragma,
|
||||
source,
|
||||
})?;
|
||||
|
||||
run_migrations(&pool).await?;
|
||||
|
||||
@@ -47,7 +56,10 @@ pub async fn init_readonly(db_path: &Path) -> Result<SqlitePool> {
|
||||
.max_connections(2)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Read-only connect failed: {e}")))
|
||||
.map_err(|source| Error::DatabaseOpen {
|
||||
operation: OpenOp::ReadOnlyConnect,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run schema migrations via the versioned migration system.
|
||||
@@ -82,10 +94,10 @@ pub async fn insert_transcript(
|
||||
params: &InsertTranscriptParams<'_>,
|
||||
) -> Result<()> {
|
||||
if !profile_exists(pool, params.profile_id).await? {
|
||||
return Err(MagnotiaError::StorageError(format!(
|
||||
"Insert transcript failed: unknown profile id '{}'",
|
||||
params.profile_id
|
||||
)));
|
||||
return Err(Error::InvalidReference {
|
||||
entity: Entity::Profile,
|
||||
reason: format!("unknown profile id '{}'", params.profile_id).into(),
|
||||
});
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
@@ -110,7 +122,10 @@ pub async fn insert_transcript(
|
||||
.bind(params.anti_hallucination)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Insert transcript failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "insert_transcript".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -121,7 +136,10 @@ pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<Transc
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Get transcript failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "get_transcript".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(row.map(|r| transcript_row_from(&r)))
|
||||
}
|
||||
@@ -144,7 +162,10 @@ pub async fn list_transcripts_paged(
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("List transcripts failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "list_transcripts".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(rows.iter().map(transcript_row_from).collect())
|
||||
}
|
||||
@@ -154,7 +175,10 @@ pub async fn count_transcripts(pool: &SqlitePool) -> Result<i64> {
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Count transcripts failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "count_transcripts".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
@@ -182,7 +206,10 @@ pub async fn update_transcript(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Update transcript failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "update_transcript".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
(Some(t), None) => {
|
||||
@@ -191,7 +218,10 @@ pub async fn update_transcript(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Update transcript failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "update_transcript".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
(None, Some(ttl)) => {
|
||||
@@ -200,7 +230,10 @@ pub async fn update_transcript(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Update transcript failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "update_transcript".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
(None, None) => Ok(0),
|
||||
@@ -247,11 +280,17 @@ pub async fn update_transcript_meta(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("update_transcript_meta: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "update_transcript_meta".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
get_transcript(pool, id).await?.ok_or_else(|| {
|
||||
MagnotiaError::StorageError(format!("update_transcript_meta: transcript {id} not found"))
|
||||
})
|
||||
get_transcript(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound {
|
||||
entity: Entity::Transcript,
|
||||
key: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
@@ -259,7 +298,10 @@ pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Delete transcript failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "delete_transcript".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -283,7 +325,10 @@ pub async fn search_transcripts(
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("FTS search failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "search_transcripts".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(rows.iter().map(transcript_row_from).collect())
|
||||
}
|
||||
@@ -321,7 +366,10 @@ pub async fn insert_task(
|
||||
.bind(energy)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Insert task failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "insert_task".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -333,7 +381,10 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("List tasks failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "list_tasks".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(rows.into_iter().map(task_row_from).collect())
|
||||
}
|
||||
@@ -346,7 +397,10 @@ pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRo
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Get task failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "get_task_by_id".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(row.map(task_row_from))
|
||||
}
|
||||
@@ -384,11 +438,17 @@ pub async fn update_task(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Update task failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "update_task".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
get_task_by_id(pool, id).await?.ok_or_else(|| {
|
||||
MagnotiaError::StorageError(format!("update_task: task {id} not found after update"))
|
||||
})
|
||||
get_task_by_id(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound {
|
||||
entity: Entity::Task,
|
||||
key: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Dedicated tri-state energy setter. Exists as its own function because
|
||||
@@ -405,11 +465,17 @@ pub async fn set_task_energy(pool: &SqlitePool, id: &str, energy: Option<&str>)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("set_task_energy failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "set_task_energy".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
get_task_by_id(pool, id).await?.ok_or_else(|| {
|
||||
MagnotiaError::StorageError(format!("set_task_energy: task {id} not found after update"))
|
||||
})
|
||||
get_task_by_id(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound {
|
||||
entity: Entity::Task,
|
||||
key: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn insert_subtask(
|
||||
@@ -424,7 +490,10 @@ pub async fn insert_subtask(
|
||||
.bind(parent_task_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Insert subtask failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "insert_subtask".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -437,7 +506,10 @@ pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<Tas
|
||||
.bind(parent_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("List subtasks failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "list_subtasks".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(rows.into_iter().map(task_row_from).collect())
|
||||
}
|
||||
@@ -445,23 +517,29 @@ pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<Tas
|
||||
/// Mark a subtask done. If all siblings are now done, auto-complete the parent.
|
||||
/// Runs in a transaction so concurrent completions see consistent sibling counts.
|
||||
pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> {
|
||||
let mut tx = pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Begin transaction failed: {e}")))?;
|
||||
let mut tx = pool.begin().await.map_err(|source| Error::Query {
|
||||
operation: "complete_subtask_and_check_parent.begin_transaction".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
|
||||
.bind(subtask_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Complete subtask failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "complete_subtask_and_check_parent.complete_subtask".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let parent_id: Option<String> =
|
||||
sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?")
|
||||
.bind(subtask_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Get parent_task_id failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "complete_subtask_and_check_parent.get_parent_task_id".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
if let Some(pid) = parent_id {
|
||||
let pending: i64 =
|
||||
@@ -469,8 +547,9 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
|
||||
.bind(&pid)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MagnotiaError::StorageError(format!("Count pending subtasks failed: {e}"))
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "complete_subtask_and_check_parent.count_pending_subtasks".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
if pending == 0 {
|
||||
@@ -484,13 +563,17 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
|
||||
.bind(&pid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Auto-complete parent failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "complete_subtask_and_check_parent.auto_complete_parent".into(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Commit transaction failed: {e}")))?;
|
||||
tx.commit().await.map_err(|source| Error::Query {
|
||||
operation: "complete_subtask_and_check_parent.commit_transaction".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -500,21 +583,27 @@ pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Complete task failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "complete_task".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
let mut tx = pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Begin transaction failed: {e}")))?;
|
||||
let mut tx = pool.begin().await.map_err(|source| Error::Query {
|
||||
operation: "uncomplete_task.begin_transaction".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
sqlx::query("UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Uncomplete task failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "uncomplete_task.uncomplete_row".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
// Mirror the auto-complete invariant from
|
||||
// `complete_subtask_and_check_parent`: a parent task is done iff
|
||||
@@ -526,7 +615,10 @@ pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Get parent_task_id failed: {e}")))?
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "uncomplete_task.get_parent_task_id".into(),
|
||||
source,
|
||||
})?
|
||||
.flatten();
|
||||
|
||||
if let Some(pid) = parent_id {
|
||||
@@ -537,12 +629,16 @@ pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
.bind(&pid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Reopen parent failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "uncomplete_task.reopen_parent".into(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Commit transaction failed: {e}")))?;
|
||||
tx.commit().await.map_err(|source| Error::Query {
|
||||
operation: "uncomplete_task.commit_transaction".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -552,7 +648,10 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Delete task failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "delete_task".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -599,7 +698,10 @@ pub async fn list_recent_completions(
|
||||
.bind(format!("-{} days", days - 1))
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("List recent completions failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "list_recent_completions.fetch_grouped".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let lookup: std::collections::HashMap<String, u32> = rows
|
||||
.into_iter()
|
||||
@@ -610,7 +712,10 @@ pub async fn list_recent_completions(
|
||||
let today_row: (String,) = sqlx::query_as("SELECT DATE('now', 'localtime')")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Get local today failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "list_recent_completions.get_local_today".into(),
|
||||
source,
|
||||
})?;
|
||||
let today = today_row.0;
|
||||
|
||||
let mut series = Vec::with_capacity(days as usize);
|
||||
@@ -622,7 +727,10 @@ pub async fn list_recent_completions(
|
||||
.bind(format!("-{offset} days"))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Compute spine day failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "list_recent_completions.compute_spine_day".into(),
|
||||
source,
|
||||
})?;
|
||||
let count = lookup.get(&day).copied().unwrap_or(0);
|
||||
series.push(DailyCompletionCount { day, count });
|
||||
}
|
||||
@@ -655,13 +763,17 @@ pub async fn insert_implementation_rule(
|
||||
.bind(last_fired_key)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Insert implementation rule failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "insert_implementation_rule".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
||||
MagnotiaError::StorageError(format!(
|
||||
"insert_implementation_rule: rule {id} not found after insert"
|
||||
))
|
||||
})
|
||||
get_implementation_rule(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound {
|
||||
entity: Entity::ImplementationRule,
|
||||
key: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_implementation_rules(pool: &SqlitePool) -> Result<Vec<ImplementationRuleRow>> {
|
||||
@@ -672,7 +784,10 @@ pub async fn list_implementation_rules(pool: &SqlitePool) -> Result<Vec<Implemen
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("List implementation rules failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "list_implementation_rules".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(rows.into_iter().map(implementation_rule_row_from).collect())
|
||||
}
|
||||
@@ -689,7 +804,10 @@ pub async fn get_implementation_rule(
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Get implementation rule failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "get_implementation_rule".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(row.map(implementation_rule_row_from))
|
||||
}
|
||||
@@ -708,13 +826,17 @@ pub async fn set_implementation_rule_enabled(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Set implementation rule enabled failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "set_implementation_rule_enabled".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
||||
MagnotiaError::StorageError(format!(
|
||||
"set_implementation_rule_enabled: rule {id} not found after update"
|
||||
))
|
||||
})
|
||||
get_implementation_rule(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound {
|
||||
entity: Entity::ImplementationRule,
|
||||
key: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn mark_implementation_rule_fired(
|
||||
@@ -731,13 +853,17 @@ pub async fn mark_implementation_rule_fired(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Mark implementation rule fired failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "mark_implementation_rule_fired".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
||||
MagnotiaError::StorageError(format!(
|
||||
"mark_implementation_rule_fired: rule {id} not found after update"
|
||||
))
|
||||
})
|
||||
get_implementation_rule(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound {
|
||||
entity: Entity::ImplementationRule,
|
||||
key: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
@@ -745,7 +871,10 @@ pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Delete implementation rule failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "delete_implementation_rule".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -757,7 +886,10 @@ pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()
|
||||
.bind(value)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Set setting failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "set_setting".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -766,7 +898,10 @@ pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result<Option<String>>
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Get setting failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "get_setting".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(row.map(|r| r.get("value")))
|
||||
}
|
||||
|
||||
@@ -944,15 +1079,19 @@ fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRul
|
||||
// 1. The DB triggers `trg_protect_default_profile_delete` /
|
||||
// `trg_protect_default_profile_rename` from migration v6.
|
||||
// 2. Rust-layer fail-fast checks below that short-circuit BEFORE the
|
||||
// query hits sqlite, so UI callers get a friendly MagnotiaError::StorageError
|
||||
// instead of an opaque `SQLITE_CONSTRAINT_TRIGGER` wrapped in text.
|
||||
// query hits sqlite, so UI callers get a friendly typed
|
||||
// `Error::InvalidReference` instead of an opaque
|
||||
// `SQLITE_CONSTRAINT_TRIGGER` wrapped in text.
|
||||
|
||||
pub async fn list_profiles(pool: &SqlitePool) -> Result<Vec<ProfileRow>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles ORDER BY name ASC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("List profiles failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "list_profiles".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(rows.iter().map(profile_row_from).collect())
|
||||
}
|
||||
|
||||
@@ -961,7 +1100,10 @@ pub async fn get_profile(pool: &SqlitePool, id: &str) -> Result<Option<ProfileRo
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Get profile failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "get_profile".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(row.as_ref().map(profile_row_from))
|
||||
}
|
||||
|
||||
@@ -981,7 +1123,10 @@ pub async fn create_profile(
|
||||
.bind(initial_prompt)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Create profile failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "create_profile".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(profile_row_from(&row))
|
||||
}
|
||||
|
||||
@@ -999,14 +1144,16 @@ pub async fn update_profile(
|
||||
initial_prompt: &str,
|
||||
) -> Result<()> {
|
||||
if id == crate::DEFAULT_PROFILE_ID && name != "Default" {
|
||||
return Err(MagnotiaError::StorageError(
|
||||
"Default profile cannot be renamed".into(),
|
||||
));
|
||||
return Err(Error::InvalidReference {
|
||||
entity: Entity::Profile,
|
||||
reason: "Default profile cannot be renamed".into(),
|
||||
});
|
||||
}
|
||||
if id != crate::DEFAULT_PROFILE_ID && name == "Default" {
|
||||
return Err(MagnotiaError::StorageError(
|
||||
"Cannot rename another profile to 'Default'".into(),
|
||||
));
|
||||
return Err(Error::InvalidReference {
|
||||
entity: Entity::Profile,
|
||||
reason: "cannot rename another profile to 'Default'".into(),
|
||||
});
|
||||
}
|
||||
sqlx::query("UPDATE profiles SET name = ?, initial_prompt = ? WHERE id = ?")
|
||||
.bind(name)
|
||||
@@ -1014,7 +1161,10 @@ pub async fn update_profile(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Update profile failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "update_profile".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1023,21 +1173,28 @@ pub async fn update_profile(
|
||||
/// ON DELETE CASCADE on `profile_terms.profile_id` cleans up children.
|
||||
pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
if id == crate::DEFAULT_PROFILE_ID {
|
||||
return Err(MagnotiaError::StorageError(
|
||||
"Default profile cannot be deleted".into(),
|
||||
));
|
||||
return Err(Error::InvalidReference {
|
||||
entity: Entity::Profile,
|
||||
reason: "Default profile cannot be deleted".into(),
|
||||
});
|
||||
}
|
||||
let transcript_count = transcript_count_for_profile(pool, id).await?;
|
||||
if transcript_count > 0 {
|
||||
return Err(MagnotiaError::StorageError(format!(
|
||||
"Cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first"
|
||||
)));
|
||||
return Err(Error::InvalidReference {
|
||||
entity: Entity::Profile,
|
||||
reason: format!(
|
||||
"cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first"
|
||||
).into(),
|
||||
});
|
||||
}
|
||||
sqlx::query("DELETE FROM profiles WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Delete profile failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "delete_profile".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1052,7 +1209,10 @@ pub async fn list_profile_terms(
|
||||
.bind(profile_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("List profile terms failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "list_profile_terms".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(rows.iter().map(profile_term_row_from).collect())
|
||||
}
|
||||
|
||||
@@ -1078,7 +1238,10 @@ pub async fn add_profile_term(
|
||||
.bind(note)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Add profile term failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "add_profile_term".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(profile_term_row_from(&row))
|
||||
}
|
||||
|
||||
@@ -1087,7 +1250,10 @@ pub async fn delete_profile_term(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Delete profile term failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "delete_profile_term".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1096,7 +1262,10 @@ async fn profile_exists(pool: &SqlitePool, id: &str) -> Result<bool> {
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Profile existence check failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "profile_exists".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
@@ -1105,7 +1274,10 @@ async fn transcript_count_for_profile(pool: &SqlitePool, id: &str) -> Result<i64
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Profile transcript count failed: {e}")))
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "transcript_count_for_profile".into(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Error Logging ---
|
||||
@@ -1139,7 +1311,10 @@ pub async fn log_error(
|
||||
.bind(metadata)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Error log failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "log_error".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1171,7 +1346,10 @@ pub async fn prune_error_log(pool: &SqlitePool, keep_days: i64) -> Result<u64> {
|
||||
.bind(format!("-{keep_days} days"))
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Prune error_log failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "prune_error_log".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
@@ -1183,7 +1361,10 @@ pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result<Vec<Err
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Read error_log failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "list_recent_errors".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
@@ -1264,10 +1445,10 @@ pub struct FeedbackRow {
|
||||
|
||||
pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) -> Result<i64> {
|
||||
if !matches!(params.rating, -1..=1) {
|
||||
return Err(MagnotiaError::StorageError(format!(
|
||||
"invalid feedback rating {} (must be -1, 0, or 1)",
|
||||
params.rating
|
||||
)));
|
||||
return Err(Error::InvalidReference {
|
||||
entity: Entity::Feedback,
|
||||
reason: format!("invalid rating {} (must be -1, 0, or 1)", params.rating).into(),
|
||||
});
|
||||
}
|
||||
let profile_id = params
|
||||
.profile_id
|
||||
@@ -1288,7 +1469,10 @@ pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) ->
|
||||
.bind(profile_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("record_feedback failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "record_feedback".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(row.get::<i64, _>("id"))
|
||||
}
|
||||
|
||||
@@ -1325,7 +1509,10 @@ pub async fn list_feedback_examples(
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("list_feedback_examples failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "list_feedback_examples".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
@@ -1942,7 +2129,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_profile_rejects_duplicate_name() {
|
||||
// Guardrail: UNIQUE(name) collision surfaces as StorageError.
|
||||
// Guardrail: UNIQUE(name) collision surfaces as Error::Query.
|
||||
let pool = test_pool().await;
|
||||
create_profile(&pool, "Personal", "").await.unwrap();
|
||||
let res = create_profile(&pool, "Personal", "").await;
|
||||
@@ -2511,24 +2698,21 @@ mod tests {
|
||||
let removed = prune_error_log(&pool, 90).await.unwrap();
|
||||
assert_eq!(removed, 1, "only the 200-day row should be pruned");
|
||||
|
||||
let remaining: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT context FROM error_log ORDER BY id ASC",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let remaining: Vec<String> =
|
||||
sqlx::query_scalar("SELECT context FROM error_log ORDER BY id ASC")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remaining, vec!["recent".to_string(), "older".to_string()]);
|
||||
|
||||
// A 14-day window prunes the 30-day row too.
|
||||
let removed = prune_error_log(&pool, 14).await.unwrap();
|
||||
assert_eq!(removed, 1);
|
||||
|
||||
let remaining: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT context FROM error_log",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let remaining: Vec<String> = sqlx::query_scalar("SELECT context FROM error_log")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remaining, vec!["recent".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
194
crates/storage/src/error.rs
Normal file
194
crates/storage/src/error.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
//! Storage-local typed error.
|
||||
//!
|
||||
//! Backend code that wants to branch on storage failure modes pattern-matches
|
||||
//! on [`Error`] directly. The crate boundary into [`magnotia_core::error::MagnotiaError`]
|
||||
//! flattens this into a [`MagnotiaError::Storage`] variant carrying a serde-friendly
|
||||
//! `kind`, an `operation` label, and the Display output as `detail` — so the
|
||||
//! frontend (which still receives stringified errors from Tauri commands today)
|
||||
//! keeps working, and Area E can later expose the typed `kind` to the FE without
|
||||
//! touching call sites.
|
||||
//!
|
||||
//! The `sqlx::Error` source is deliberately not serialized. Sources stay sources;
|
||||
//! only the boundary shape is wire-friendly.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use magnotia_core::error::{MagnotiaError, StorageKind};
|
||||
|
||||
/// Kinds of database-open operation that can fail before the pool is ready.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum OpenOp {
|
||||
Connect,
|
||||
ReadOnlyConnect,
|
||||
ForeignKeysPragma,
|
||||
}
|
||||
|
||||
impl OpenOp {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
OpenOp::Connect => "connect",
|
||||
OpenOp::ReadOnlyConnect => "read_only_connect",
|
||||
OpenOp::ForeignKeysPragma => "foreign_keys_pragma",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OpenOp {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.label())
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-step phase inside the migration framework.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum MigrationStep {
|
||||
SchemaVersionTableCreate,
|
||||
SchemaVersionQuery,
|
||||
TxBegin,
|
||||
Apply,
|
||||
RecordVersion,
|
||||
Commit,
|
||||
}
|
||||
|
||||
impl MigrationStep {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
MigrationStep::SchemaVersionTableCreate => "schema_version_table_create",
|
||||
MigrationStep::SchemaVersionQuery => "schema_version_query",
|
||||
MigrationStep::TxBegin => "tx_begin",
|
||||
MigrationStep::Apply => "apply",
|
||||
MigrationStep::RecordVersion => "record_version",
|
||||
MigrationStep::Commit => "commit",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MigrationStep {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.label())
|
||||
}
|
||||
}
|
||||
|
||||
/// Entities the storage layer can fail to find or validate.
|
||||
///
|
||||
/// Seeded with only the three real entities (Transcript, Task, Profile) — add
|
||||
/// more here only when a typed `NotFound` / `InvalidReference` case actually
|
||||
/// requires them.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Entity {
|
||||
Transcript,
|
||||
Task,
|
||||
Profile,
|
||||
ImplementationRule,
|
||||
Feedback,
|
||||
}
|
||||
|
||||
impl Entity {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Entity::Transcript => "transcript",
|
||||
Entity::Task => "task",
|
||||
Entity::Profile => "profile",
|
||||
Entity::ImplementationRule => "implementation_rule",
|
||||
Entity::Feedback => "feedback",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Entity {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.label())
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage-crate-local typed error.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("database open failed during {operation}: {source}")]
|
||||
DatabaseOpen {
|
||||
operation: OpenOp,
|
||||
#[source]
|
||||
source: sqlx::Error,
|
||||
},
|
||||
|
||||
#[error("migration step {step} (version {version:?}) failed: {source}")]
|
||||
Migration {
|
||||
version: Option<i64>,
|
||||
step: MigrationStep,
|
||||
#[source]
|
||||
source: sqlx::Error,
|
||||
},
|
||||
|
||||
#[error("query failed during {operation}: {source}")]
|
||||
Query {
|
||||
operation: Cow<'static, str>,
|
||||
#[source]
|
||||
source: sqlx::Error,
|
||||
},
|
||||
|
||||
#[error("{entity} not found: '{key}'")]
|
||||
NotFound { entity: Entity, key: String },
|
||||
|
||||
#[error("invalid {entity} reference: {reason}")]
|
||||
InvalidReference {
|
||||
entity: Entity,
|
||||
reason: Cow<'static, str>,
|
||||
},
|
||||
|
||||
#[error("filesystem operation failed at '{}': {source}", path.display())]
|
||||
Filesystem {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Discriminator for the boundary conversion into [`MagnotiaError`].
|
||||
pub fn kind(&self) -> StorageKind {
|
||||
match self {
|
||||
Error::DatabaseOpen { .. } => StorageKind::DatabaseOpen,
|
||||
Error::Migration { .. } => StorageKind::Migration,
|
||||
Error::Query { .. } => StorageKind::Query,
|
||||
Error::NotFound { .. } => StorageKind::NotFound,
|
||||
Error::InvalidReference { .. } => StorageKind::InvalidReference,
|
||||
Error::Filesystem { .. } => StorageKind::Filesystem,
|
||||
}
|
||||
}
|
||||
|
||||
/// Operation label that flows into the [`MagnotiaError::Storage`] boundary
|
||||
/// shape. For per-query failures this is the caller-provided label;
|
||||
/// for the other variants it's the variant's intrinsic label.
|
||||
pub fn operation_label(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
Error::DatabaseOpen { operation, .. } => Cow::Borrowed(operation.label()),
|
||||
Error::Migration { step, .. } => Cow::Borrowed(step.label()),
|
||||
Error::Query { operation, .. } => operation.clone(),
|
||||
Error::NotFound { entity, .. } => Cow::Owned(format!("not_found:{}", entity.label())),
|
||||
Error::InvalidReference { entity, .. } => {
|
||||
Cow::Owned(format!("invalid_reference:{}", entity.label()))
|
||||
}
|
||||
Error::Filesystem { .. } => Cow::Borrowed("filesystem"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Boundary conversion — flattens the typed error into the wire-friendly
|
||||
/// [`MagnotiaError::Storage`] variant. Lives in the storage crate (not in core)
|
||||
/// to avoid a `core -> storage` dependency cycle.
|
||||
impl From<Error> for MagnotiaError {
|
||||
fn from(error: Error) -> Self {
|
||||
let kind = error.kind();
|
||||
let operation = error.operation_label().into_owned();
|
||||
let detail = error.to_string();
|
||||
MagnotiaError::Storage {
|
||||
kind,
|
||||
operation,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Result` alias for storage-crate functions.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -1,7 +1,10 @@
|
||||
pub mod database;
|
||||
pub mod error;
|
||||
pub mod file_storage;
|
||||
pub mod migrations;
|
||||
|
||||
pub use error::{Entity, Error, MigrationStep, OpenOp, Result};
|
||||
|
||||
/// Stable identifier for the seeded Default profile (see migration v6).
|
||||
/// The Default profile cannot be renamed or deleted — guarded by SQLite triggers.
|
||||
pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
|
||||
@@ -15,8 +18,7 @@ pub use database::{
|
||||
list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks,
|
||||
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
|
||||
prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled,
|
||||
set_setting,
|
||||
set_task_energy, uncomplete_task, update_profile, update_task, update_transcript,
|
||||
set_setting, set_task_energy, uncomplete_task, update_profile, update_task, update_transcript,
|
||||
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
|
||||
ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
|
||||
RecordFeedbackParams, TaskRow, TranscriptRow,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
use crate::error::{Error, MigrationStep, Result};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Each migration is a (version, description, sql) tuple.
|
||||
@@ -553,27 +553,39 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Schema version table creation failed: {e}")))?;
|
||||
.map_err(|source| Error::Migration {
|
||||
version: None,
|
||||
step: MigrationStep::SchemaVersionTableCreate,
|
||||
source,
|
||||
})?;
|
||||
|
||||
let current: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Schema version query failed: {e}")))?;
|
||||
.map_err(|source| Error::Migration {
|
||||
version: None,
|
||||
step: MigrationStep::SchemaVersionQuery,
|
||||
source,
|
||||
})?;
|
||||
|
||||
for (version, description, sql) in migrations {
|
||||
if *version > current {
|
||||
log::info!("Running migration {}: {}", version, description);
|
||||
|
||||
let mut tx = pool.begin().await.map_err(|e| {
|
||||
MagnotiaError::StorageError(format!("Migration {} tx begin failed: {e}", version))
|
||||
let mut tx = pool.begin().await.map_err(|source| Error::Migration {
|
||||
version: Some(*version),
|
||||
step: MigrationStep::TxBegin,
|
||||
source,
|
||||
})?;
|
||||
|
||||
for statement in split_statements(sql) {
|
||||
sqlx::query(&statement)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MagnotiaError::StorageError(format!("Migration {} failed: {e}", version))
|
||||
.map_err(|source| Error::Migration {
|
||||
version: Some(*version),
|
||||
step: MigrationStep::Apply,
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -582,12 +594,16 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)
|
||||
.bind(description)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MagnotiaError::StorageError(format!("Migration version record failed: {e}"))
|
||||
.map_err(|source| Error::Migration {
|
||||
version: Some(*version),
|
||||
step: MigrationStep::RecordVersion,
|
||||
source,
|
||||
})?;
|
||||
|
||||
tx.commit().await.map_err(|e| {
|
||||
MagnotiaError::StorageError(format!("Migration {} commit failed: {e}", version))
|
||||
tx.commit().await.map_err(|source| Error::Migration {
|
||||
version: Some(*version),
|
||||
step: MigrationStep::Commit,
|
||||
source,
|
||||
})?;
|
||||
|
||||
log::info!("Migration {} complete", version);
|
||||
@@ -1178,7 +1194,8 @@ mod tests {
|
||||
.map(|r| r.get::<String, _>("detail"))
|
||||
.collect();
|
||||
assert!(
|
||||
plan.iter().any(|row| row.contains("idx_transcripts_profile_created")),
|
||||
plan.iter()
|
||||
.any(|row| row.contains("idx_transcripts_profile_created")),
|
||||
"planner should use the composite index, got plan: {plan:?}",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,12 +17,20 @@ build = "build.rs"
|
||||
# but skip the Vulkan backend. Build CPU-only with:
|
||||
# cargo build -p magnotia-transcription --no-default-features --features whisper
|
||||
default = ["whisper", "whisper-vulkan"]
|
||||
whisper = ["dep:whisper-rs", "dep:num_cpus"]
|
||||
whisper = ["dep:whisper-rs"]
|
||||
whisper-vulkan = ["whisper-rs?/vulkan"]
|
||||
|
||||
[dependencies]
|
||||
magnotia-core = { path = "../core" }
|
||||
|
||||
# TranscriptionProvider async trait + EngineProfile + ProviderId. The
|
||||
# trait lives in cloud-providers so an OEM licensee can implement it
|
||||
# without depending on transcription internals.
|
||||
magnotia-cloud-providers = { path = "../cloud-providers" }
|
||||
|
||||
# Async-trait for the LocalProviderAdapter impl.
|
||||
async-trait = "0.1"
|
||||
|
||||
# Parakeet via ONNX. Whisper is handled directly via whisper-rs below.
|
||||
transcribe-rs = { version = "0.3", default-features = false, features = ["onnx"] }
|
||||
|
||||
@@ -40,10 +48,6 @@ sha2 = "0.10"
|
||||
# additive via the `whisper-vulkan` feature so non-GPU targets can drop it.
|
||||
whisper-rs = { version = "0.16", default-features = false, optional = true }
|
||||
|
||||
# Direct whisper-rs backend (WhisperRsBackend): thread pool sizing.
|
||||
# Gated alongside whisper-rs since no other code in this crate needs it.
|
||||
num_cpus = { version = "1", optional = true }
|
||||
|
||||
# Typed error enum used by WhisperRsBackend + elsewhere. Kept
|
||||
# unconditional because it is a derive-macro crate with negligible
|
||||
# build cost.
|
||||
@@ -54,5 +58,11 @@ tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
# TcpListener fixture for the download resume tests (mirrors magnotia-llm).
|
||||
tokio = { version = "1", features = ["rt", "sync", "net", "io-util", "macros"] }
|
||||
# `macros` and `rt-multi-thread` enable #[tokio::test] and the multi-threaded
|
||||
# scheduler used by the orchestrator tests.
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "net", "io-util", "macros"] }
|
||||
tempfile = "3"
|
||||
# Test-only — used by tests/thread_sweep.rs to label physical vs logical
|
||||
# core counts in the scaling table. Production code uses the
|
||||
# `magnotia_core::constants::inference_thread_count` helper instead.
|
||||
num_cpus = "1"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod concurrency;
|
||||
pub mod local_engine;
|
||||
pub mod model_manager;
|
||||
pub mod orchestrator;
|
||||
pub mod registry;
|
||||
pub mod streaming;
|
||||
pub mod transcriber;
|
||||
#[cfg(feature = "whisper")]
|
||||
@@ -11,9 +13,21 @@ pub use concurrency::run_inference;
|
||||
pub use local_engine::load_whisper;
|
||||
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
|
||||
pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir};
|
||||
pub use orchestrator::{LocalProviderAdapter, Orchestrator};
|
||||
pub use registry::EngineRegistry;
|
||||
pub use streaming::{
|
||||
sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy,
|
||||
LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker,
|
||||
};
|
||||
pub use transcribe_rs::SpeechModel;
|
||||
pub use transcriber::{Transcriber, TranscriberCapabilities};
|
||||
|
||||
// Re-export the trait surface so downstream crates depend only on
|
||||
// `magnotia_transcription` to access both local engines and the
|
||||
// provider trait without a separate `magnotia_cloud_providers`
|
||||
// dependency. This is the seam an OEM licensee uses when they swap
|
||||
// providers.
|
||||
pub use magnotia_cloud_providers::{
|
||||
CostClass, EngineProfile, NetworkRequirement, ProviderCapabilities, ProviderId, ProviderKind,
|
||||
ProviderTranscript, TranscriptionProvider,
|
||||
};
|
||||
|
||||
@@ -116,7 +116,10 @@ fn verified_manifest_path(dir: &Path) -> PathBuf {
|
||||
dir.join(".magnotia-verified")
|
||||
}
|
||||
|
||||
fn verified_manifest_matches(entry: &magnotia_core::model_registry::ModelEntry, dir: &Path) -> bool {
|
||||
fn verified_manifest_matches(
|
||||
entry: &magnotia_core::model_registry::ModelEntry,
|
||||
dir: &Path,
|
||||
) -> bool {
|
||||
let manifest = match std::fs::read_to_string(verified_manifest_path(dir)) {
|
||||
Ok(contents) => contents,
|
||||
Err(_) => return false,
|
||||
|
||||
281
crates/transcription/src/orchestrator.rs
Normal file
281
crates/transcription/src/orchestrator.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
//! `Orchestrator` is the single entry point for transcription. Tauri
|
||||
//! commands resolve a provider through it; nothing else calls a
|
||||
//! provider directly. This is where the AGPL OEM trait boundary meets
|
||||
//! Wyrdnote's local engines.
|
||||
//!
|
||||
//! `LocalProviderAdapter` lives here, not as an `impl
|
||||
//! TranscriptionProvider for LocalEngine`. The adapter wraps an
|
||||
//! `Arc<LocalEngine>` and bridges the synchronous `Transcriber` trait
|
||||
//! to the async `TranscriptionProvider` interface via
|
||||
//! `tokio::task::spawn_blocking`. Keeping the adapter in the
|
||||
//! orchestrator (rather than in `local_engine.rs`) means the
|
||||
//! transcription crate's core types do not need to depend on
|
||||
//! `async_trait`, and the dependency edge stays one-directional:
|
||||
//! `cloud_providers` defines the trait, `transcription` implements it
|
||||
//! via adapter without leaking async-runtime requirements onto
|
||||
//! `Transcriber`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use magnotia_cloud_providers::{
|
||||
CostClass, EngineProfile, ProviderCapabilities, ProviderId, ProviderKind, ProviderTranscript,
|
||||
TranscriptionProvider,
|
||||
};
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
use magnotia_core::types::{AudioSamples, TranscriptionOptions};
|
||||
|
||||
use crate::local_engine::LocalEngine;
|
||||
use crate::registry::EngineRegistry;
|
||||
|
||||
/// Wraps a `LocalEngine` and presents the async `TranscriptionProvider`
|
||||
/// interface upward. One adapter per local engine instance; multiple
|
||||
/// engines (Whisper, Parakeet) live as multiple adapters in the
|
||||
/// registry.
|
||||
pub struct LocalProviderAdapter {
|
||||
provider_id: ProviderId,
|
||||
engine: Arc<LocalEngine>,
|
||||
}
|
||||
|
||||
impl LocalProviderAdapter {
|
||||
pub fn new(provider_id: ProviderId, engine: Arc<LocalEngine>) -> Self {
|
||||
Self {
|
||||
provider_id,
|
||||
engine,
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct access to the wrapped engine. Used by warmup, model
|
||||
/// management, and the GPU-sequencing guard, none of which want to
|
||||
/// route through the async trait surface.
|
||||
pub fn engine(&self) -> Arc<LocalEngine> {
|
||||
self.engine.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptionProvider for LocalProviderAdapter {
|
||||
fn provider_id(&self) -> ProviderId {
|
||||
self.provider_id.clone()
|
||||
}
|
||||
|
||||
fn kind(&self) -> ProviderKind {
|
||||
ProviderKind::Local
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
let local = self.engine.capabilities();
|
||||
ProviderCapabilities {
|
||||
sample_rate: local
|
||||
.map(|c| c.sample_rate)
|
||||
.unwrap_or(magnotia_core::constants::WHISPER_SAMPLE_RATE),
|
||||
channels: local.map(|c| c.channels).unwrap_or(1),
|
||||
initial_prompt_supported: local.map(|c| c.supports_initial_prompt).unwrap_or(false),
|
||||
language_hint_supported: true,
|
||||
streaming_supported: false,
|
||||
cost_class: CostClass::Free,
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare(&self, _profile: &EngineProfile) -> Result<()> {
|
||||
if self.engine.is_loaded() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MagnotiaError::EngineNotLoaded)
|
||||
}
|
||||
}
|
||||
|
||||
async fn transcribe(
|
||||
&self,
|
||||
audio: AudioSamples,
|
||||
options: TranscriptionOptions,
|
||||
) -> Result<ProviderTranscript> {
|
||||
let engine = self.engine.clone();
|
||||
let timed = tokio::task::spawn_blocking(move || engine.transcribe_sync(&audio, &options))
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::TranscriptionFailed(format!("Task join error: {e}")))??;
|
||||
Ok(ProviderTranscript {
|
||||
transcript: timed.transcript,
|
||||
inference_ms: timed.inference_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The single entry point through which all transcription flows.
|
||||
/// Resolves a provider against the registry, runs `prepare` if the
|
||||
/// caller has not already done so, and dispatches `transcribe`.
|
||||
pub struct Orchestrator {
|
||||
registry: Arc<EngineRegistry>,
|
||||
}
|
||||
|
||||
impl Orchestrator {
|
||||
pub fn new(registry: Arc<EngineRegistry>) -> Self {
|
||||
Self { registry }
|
||||
}
|
||||
|
||||
/// Underlying registry. Exposed so the UI can list providers and
|
||||
/// query capabilities without going through a transcribe call.
|
||||
pub fn registry(&self) -> Arc<EngineRegistry> {
|
||||
self.registry.clone()
|
||||
}
|
||||
|
||||
/// Resolve the provider for a profile. Returns a clear error when
|
||||
/// the profile names an unregistered engine.
|
||||
pub fn resolve(&self, profile: &EngineProfile) -> Result<Arc<dyn TranscriptionProvider>> {
|
||||
self.registry
|
||||
.get(&profile.engine_id)
|
||||
.ok_or_else(|| MagnotiaError::ProviderNotRegistered(profile.engine_id.to_string()))
|
||||
}
|
||||
|
||||
/// Transcribe audio using the provider named in the profile. The
|
||||
/// orchestrator builds `TranscriptionOptions` from the profile and
|
||||
/// delegates to the provider.
|
||||
///
|
||||
/// Phase A scope: this is the new entry point. Existing call sites
|
||||
/// (`commands/transcription.rs::transcribe_file`) still call
|
||||
/// `LocalEngine` directly; their rewire is a follow-up commit so
|
||||
/// the chunking + post-processing logic moves cleanly without
|
||||
/// inflating this diff. See `KNOWN-ISSUES.md` KI-06.
|
||||
pub async fn transcribe(
|
||||
&self,
|
||||
audio: AudioSamples,
|
||||
profile: &EngineProfile,
|
||||
) -> Result<ProviderTranscript> {
|
||||
let provider = self.resolve(profile)?;
|
||||
let options = profile.to_options();
|
||||
provider.transcribe(audio, options).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use magnotia_core::types::{Segment, Transcript};
|
||||
|
||||
/// Mock provider that returns a canned transcript and counts
|
||||
/// invocations. Used to validate the orchestrator's dispatch logic
|
||||
/// without booting a real model.
|
||||
struct CannedProvider {
|
||||
id: ProviderId,
|
||||
calls: Arc<AtomicUsize>,
|
||||
canned_text: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptionProvider for CannedProvider {
|
||||
fn provider_id(&self) -> ProviderId {
|
||||
self.id.clone()
|
||||
}
|
||||
fn kind(&self) -> ProviderKind {
|
||||
ProviderKind::Local
|
||||
}
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
sample_rate: 16_000,
|
||||
channels: 1,
|
||||
initial_prompt_supported: true,
|
||||
language_hint_supported: true,
|
||||
streaming_supported: false,
|
||||
cost_class: CostClass::Free,
|
||||
}
|
||||
}
|
||||
async fn prepare(&self, _profile: &EngineProfile) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn transcribe(
|
||||
&self,
|
||||
audio: AudioSamples,
|
||||
_options: TranscriptionOptions,
|
||||
) -> Result<ProviderTranscript> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(ProviderTranscript {
|
||||
transcript: Transcript::new(
|
||||
vec![Segment {
|
||||
start: 0.0,
|
||||
end: audio.duration_secs(),
|
||||
text: self.canned_text.clone(),
|
||||
}],
|
||||
"en".to_string(),
|
||||
audio.duration_secs(),
|
||||
),
|
||||
inference_ms: 7,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_registry_with(id: &str, text: &str, calls: Arc<AtomicUsize>) -> Arc<EngineRegistry> {
|
||||
let mut registry = EngineRegistry::new(ProviderId::new(id));
|
||||
registry.register(Arc::new(CannedProvider {
|
||||
id: ProviderId::new(id),
|
||||
calls,
|
||||
canned_text: text.to_string(),
|
||||
}));
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
fn one_second_of_silence() -> AudioSamples {
|
||||
AudioSamples::mono_16khz(vec![0.0_f32; 16_000])
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orchestrator_dispatches_to_named_provider() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let registry = build_registry_with("canned", "hello wyrdnote", calls.clone());
|
||||
let orchestrator = Orchestrator::new(registry);
|
||||
|
||||
let profile = EngineProfile::new(ProviderId::new("canned"));
|
||||
let result = orchestrator
|
||||
.transcribe(one_second_of_silence(), &profile)
|
||||
.await
|
||||
.expect("dispatch succeeds");
|
||||
|
||||
assert_eq!(result.transcript.text(), "hello wyrdnote");
|
||||
assert_eq!(result.inference_ms, 7);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orchestrator_returns_clear_error_for_unregistered_provider() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let registry = build_registry_with("canned", "_", calls);
|
||||
let orchestrator = Orchestrator::new(registry);
|
||||
|
||||
let profile = EngineProfile::new(ProviderId::new("not-registered"));
|
||||
let err = orchestrator
|
||||
.transcribe(one_second_of_silence(), &profile)
|
||||
.await
|
||||
.expect_err("unregistered provider must error");
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("not-registered"),
|
||||
"error must name the missing provider, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orchestrator_routes_initial_prompt_through_options() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let registry = build_registry_with("canned", "transcript", calls.clone());
|
||||
let orchestrator = Orchestrator::new(registry);
|
||||
|
||||
let mut profile = EngineProfile::new(ProviderId::new("canned"));
|
||||
profile.language = Some("en".to_string());
|
||||
profile.initial_prompt = Some("Wyrdnote".to_string());
|
||||
|
||||
let _ = orchestrator
|
||||
.transcribe(one_second_of_silence(), &profile)
|
||||
.await
|
||||
.expect("dispatch succeeds");
|
||||
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_provider_adapter_is_object_safe() {
|
||||
let _: Option<Arc<dyn TranscriptionProvider>> = None;
|
||||
}
|
||||
}
|
||||
183
crates/transcription/src/registry.rs
Normal file
183
crates/transcription/src/registry.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
//! `EngineRegistry` is the catalogue of available providers, built
|
||||
//! once at app boot and dependency-injected into the orchestrator.
|
||||
//!
|
||||
//! Registration is push-style: the boot code constructs each provider
|
||||
//! (a `LocalProviderAdapter` wrapping a `LocalEngine`, or a cloud
|
||||
//! provider implementing `TranscriptionProvider` directly) and calls
|
||||
//! `register`. The default provider is set at construction time and
|
||||
//! used when a profile does not specify one.
|
||||
//!
|
||||
//! The registry is read-only after boot; mutation requires a fresh
|
||||
//! registry instance. Providers are held behind `Arc<dyn
|
||||
//! TranscriptionProvider>` so the orchestrator can clone the handle
|
||||
//! cheaply across tokio tasks.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use magnotia_cloud_providers::{ProviderId, TranscriptionProvider};
|
||||
|
||||
/// Catalogue of providers known to the orchestrator.
|
||||
pub struct EngineRegistry {
|
||||
providers: HashMap<ProviderId, Arc<dyn TranscriptionProvider>>,
|
||||
default_provider: ProviderId,
|
||||
}
|
||||
|
||||
impl EngineRegistry {
|
||||
/// Construct an empty registry with the given default provider id.
|
||||
/// The default need not exist at construction time; callers are
|
||||
/// expected to register it before the registry is queried.
|
||||
pub fn new(default_provider: ProviderId) -> Self {
|
||||
Self {
|
||||
providers: HashMap::new(),
|
||||
default_provider,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a provider. If a provider with the same id is already
|
||||
/// registered, it is replaced. The provider's id is read once via
|
||||
/// `provider.provider_id()` and used as the registry key.
|
||||
pub fn register(&mut self, provider: Arc<dyn TranscriptionProvider>) {
|
||||
let id = provider.provider_id();
|
||||
self.providers.insert(id, provider);
|
||||
}
|
||||
|
||||
/// Fetch a provider by id. Returns `None` when the id is not in the
|
||||
/// registry; the orchestrator surfaces this as a clear error so the
|
||||
/// UI can prompt the user to pick a different engine.
|
||||
pub fn get(&self, id: &ProviderId) -> Option<Arc<dyn TranscriptionProvider>> {
|
||||
self.providers.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Fetch the default provider. Returns `None` if the default has
|
||||
/// not been registered.
|
||||
pub fn default(&self) -> Option<Arc<dyn TranscriptionProvider>> {
|
||||
self.providers.get(&self.default_provider).cloned()
|
||||
}
|
||||
|
||||
/// The id of the default provider. Always returns; the provider
|
||||
/// itself may not be registered.
|
||||
pub fn default_id(&self) -> &ProviderId {
|
||||
&self.default_provider
|
||||
}
|
||||
|
||||
/// All registered provider ids. The order is unspecified; the UI
|
||||
/// is responsible for any sorting it wants to present.
|
||||
pub fn ids(&self) -> Vec<ProviderId> {
|
||||
self.providers.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Number of registered providers.
|
||||
pub fn len(&self) -> usize {
|
||||
self.providers.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.providers.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use magnotia_cloud_providers::{
|
||||
CostClass, EngineProfile, ProviderCapabilities, ProviderKind, ProviderTranscript,
|
||||
};
|
||||
use magnotia_core::error::Result;
|
||||
use magnotia_core::types::{AudioSamples, Transcript, TranscriptionOptions};
|
||||
|
||||
struct DummyProvider {
|
||||
id: ProviderId,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptionProvider for DummyProvider {
|
||||
fn provider_id(&self) -> ProviderId {
|
||||
self.id.clone()
|
||||
}
|
||||
fn kind(&self) -> ProviderKind {
|
||||
ProviderKind::Local
|
||||
}
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
sample_rate: 16_000,
|
||||
channels: 1,
|
||||
initial_prompt_supported: false,
|
||||
language_hint_supported: true,
|
||||
streaming_supported: false,
|
||||
cost_class: CostClass::Free,
|
||||
}
|
||||
}
|
||||
async fn prepare(&self, _profile: &EngineProfile) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn transcribe(
|
||||
&self,
|
||||
audio: AudioSamples,
|
||||
_options: TranscriptionOptions,
|
||||
) -> Result<ProviderTranscript> {
|
||||
Ok(ProviderTranscript {
|
||||
transcript: Transcript::new(Vec::new(), "en".to_string(), audio.duration_secs()),
|
||||
inference_ms: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn dummy(id: &str) -> Arc<dyn TranscriptionProvider> {
|
||||
Arc::new(DummyProvider {
|
||||
id: ProviderId::new(id),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry_returns_none_for_default() {
|
||||
let registry = EngineRegistry::new(ProviderId::new("local-whisper"));
|
||||
assert!(registry.default().is_none());
|
||||
assert_eq!(registry.default_id().as_str(), "local-whisper");
|
||||
assert!(registry.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_and_get_round_trip() {
|
||||
let mut registry = EngineRegistry::new(ProviderId::new("local-whisper"));
|
||||
registry.register(dummy("local-whisper"));
|
||||
registry.register(dummy("local-parakeet"));
|
||||
|
||||
assert_eq!(registry.len(), 2);
|
||||
assert!(registry.get(&ProviderId::new("local-whisper")).is_some());
|
||||
assert!(registry.get(&ProviderId::new("local-parakeet")).is_some());
|
||||
assert!(registry.get(&ProviderId::new("nonexistent")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolves_after_registration() {
|
||||
let mut registry = EngineRegistry::new(ProviderId::new("local-whisper"));
|
||||
registry.register(dummy("local-whisper"));
|
||||
let default = registry.default().expect("default provider registered");
|
||||
assert_eq!(default.provider_id().as_str(), "local-whisper");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_register_replaces() {
|
||||
let mut registry = EngineRegistry::new(ProviderId::new("local-whisper"));
|
||||
registry.register(dummy("local-whisper"));
|
||||
registry.register(dummy("local-whisper"));
|
||||
assert_eq!(registry.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_lists_all_registered() {
|
||||
let mut registry = EngineRegistry::new(ProviderId::new("local-whisper"));
|
||||
registry.register(dummy("local-whisper"));
|
||||
registry.register(dummy("local-parakeet"));
|
||||
let mut ids: Vec<String> = registry
|
||||
.ids()
|
||||
.into_iter()
|
||||
.map(|id| id.as_str().to_string())
|
||||
.collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec!["local-parakeet", "local-whisper"]);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ use std::path::Path;
|
||||
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
|
||||
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
use magnotia_core::hardware::vulkan_loader_available;
|
||||
use magnotia_core::tuning::{inference_thread_count, Workload};
|
||||
use magnotia_core::types::{Segment, TranscriptionOptions};
|
||||
|
||||
use crate::transcriber::{Transcriber, TranscriberCapabilities};
|
||||
@@ -63,7 +65,9 @@ impl Transcriber for WhisperRsBackend {
|
||||
);
|
||||
|
||||
let mut state = self.ctx.create_state().map_err(|e| {
|
||||
MagnotiaError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string())
|
||||
MagnotiaError::TranscriptionFailed(
|
||||
WhisperBackendError::State(e.to_string()).to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
@@ -77,7 +81,8 @@ impl Transcriber for WhisperRsBackend {
|
||||
params.set_initial_prompt(prompt);
|
||||
}
|
||||
}
|
||||
params.set_n_threads(num_cpus::get() as i32);
|
||||
let gpu_offloaded = cfg!(feature = "whisper-vulkan") && vulkan_loader_available();
|
||||
params.set_n_threads(inference_thread_count(Workload::Whisper, gpu_offloaded) as i32);
|
||||
params.set_print_special(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
|
||||
151
crates/transcription/tests/jfk_bench.rs
Normal file
151
crates/transcription/tests/jfk_bench.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
//! Benchmark: load the JFK WAV from disk, transcribe it via whisper-rs.
|
||||
//! Reports cold-load time, transcribe time, RTF, peak RSS.
|
||||
//!
|
||||
//! Gated on env vars so it never runs in CI without setup:
|
||||
//! MAGNOTIA_WHISPER_TEST_MODEL=/path/to/ggml-tiny.bin
|
||||
//! MAGNOTIA_WHISPER_TEST_AUDIO=/path/to/jfk.wav
|
||||
|
||||
use std::env;
|
||||
use std::time::Instant;
|
||||
|
||||
#[test]
|
||||
fn jfk_transcription_benchmark() {
|
||||
let Ok(model_path) = env::var("MAGNOTIA_WHISPER_TEST_MODEL") else {
|
||||
eprintln!("MAGNOTIA_WHISPER_TEST_MODEL not set — skipping");
|
||||
return;
|
||||
};
|
||||
let Ok(audio_path) = env::var("MAGNOTIA_WHISPER_TEST_AUDIO") else {
|
||||
eprintln!("MAGNOTIA_WHISPER_TEST_AUDIO not set — skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
|
||||
|
||||
eprintln!("[bench] loading WAV: {audio_path}");
|
||||
let bytes = std::fs::read(&audio_path).expect("read wav");
|
||||
// Minimal RIFF/WAV parse: skip the 44-byte canonical header for PCM-16-mono-16kHz.
|
||||
// Sanity-check magic bytes + format.
|
||||
assert_eq!(&bytes[0..4], b"RIFF", "expected RIFF");
|
||||
assert_eq!(&bytes[8..12], b"WAVE", "expected WAVE");
|
||||
let sample_rate = u32::from_le_bytes(bytes[24..28].try_into().unwrap());
|
||||
let channels = u16::from_le_bytes(bytes[22..24].try_into().unwrap());
|
||||
let bits = u16::from_le_bytes(bytes[34..36].try_into().unwrap());
|
||||
eprintln!(
|
||||
"[bench] wav spec: {} Hz, {} ch, {}-bit",
|
||||
sample_rate, channels, bits
|
||||
);
|
||||
assert_eq!(sample_rate, 16_000, "expected 16 kHz wav");
|
||||
assert_eq!(channels, 1, "expected mono");
|
||||
assert_eq!(bits, 16, "expected 16-bit PCM");
|
||||
|
||||
let pcm = &bytes[44..];
|
||||
let samples: Vec<f32> = pcm
|
||||
.chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
|
||||
.collect();
|
||||
let audio_secs = samples.len() as f64 / sample_rate as f64;
|
||||
eprintln!(
|
||||
"[bench] audio length: {} samples = {:.2}s",
|
||||
samples.len(),
|
||||
audio_secs
|
||||
);
|
||||
|
||||
let rss_before_load_kb = read_rss_kb();
|
||||
eprintln!(
|
||||
"[bench] RSS before model load: {} MB",
|
||||
rss_before_load_kb / 1024
|
||||
);
|
||||
|
||||
let load_start = Instant::now();
|
||||
let ctx = WhisperContext::new_with_params(&model_path, WhisperContextParameters::default())
|
||||
.expect("whisper model load");
|
||||
let load_dur = load_start.elapsed();
|
||||
eprintln!("[bench] model load: {:.2}s", load_dur.as_secs_f64());
|
||||
|
||||
let rss_after_load_kb = read_rss_kb();
|
||||
eprintln!(
|
||||
"[bench] RSS after model load: {} MB (delta +{} MB)",
|
||||
rss_after_load_kb / 1024,
|
||||
(rss_after_load_kb.saturating_sub(rss_before_load_kb)) / 1024
|
||||
);
|
||||
|
||||
let mut state = ctx.create_state().expect("whisper state");
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
params.set_language(Some("en"));
|
||||
params.set_n_threads(6);
|
||||
params.set_print_special(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
|
||||
// Cold transcription (first run)
|
||||
let cold_start = Instant::now();
|
||||
state.full(params, &samples).expect("transcribe cold");
|
||||
let cold_dur = cold_start.elapsed();
|
||||
|
||||
let n = state.full_n_segments();
|
||||
let mut full_text = String::new();
|
||||
for i in 0..n {
|
||||
let seg = state.get_segment(i).expect("get_segment");
|
||||
full_text.push_str(seg.to_str().unwrap_or(""));
|
||||
}
|
||||
eprintln!(
|
||||
"[bench] cold transcribe: {:.2}s ({} segments, RTF={:.3})",
|
||||
cold_dur.as_secs_f64(),
|
||||
n,
|
||||
cold_dur.as_secs_f64() / audio_secs
|
||||
);
|
||||
eprintln!("[bench] transcript: {}", full_text.trim());
|
||||
let rss_after_cold_kb = read_rss_kb();
|
||||
eprintln!("[bench] RSS after cold xc: {} MB", rss_after_cold_kb / 1024);
|
||||
|
||||
// Warm transcription (second run, same state)
|
||||
let mut state2 = ctx.create_state().expect("whisper state 2");
|
||||
let mut params2 = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
params2.set_language(Some("en"));
|
||||
params2.set_n_threads(6);
|
||||
params2.set_print_special(false);
|
||||
params2.set_print_progress(false);
|
||||
params2.set_print_realtime(false);
|
||||
let warm_start = Instant::now();
|
||||
state2.full(params2, &samples).expect("transcribe warm");
|
||||
let warm_dur = warm_start.elapsed();
|
||||
eprintln!(
|
||||
"[bench] warm transcribe: {:.2}s (RTF={:.3})",
|
||||
warm_dur.as_secs_f64(),
|
||||
warm_dur.as_secs_f64() / audio_secs
|
||||
);
|
||||
|
||||
let rss_final_kb = read_rss_kb();
|
||||
eprintln!("[bench] RSS final: {} MB", rss_final_kb / 1024);
|
||||
|
||||
eprintln!();
|
||||
eprintln!("=== SUMMARY ===");
|
||||
eprintln!("audio: {:.2}s", audio_secs);
|
||||
eprintln!("model_load: {:.2}s", load_dur.as_secs_f64());
|
||||
eprintln!(
|
||||
"cold xc: {:.2}s RTF={:.3}",
|
||||
cold_dur.as_secs_f64(),
|
||||
cold_dur.as_secs_f64() / audio_secs
|
||||
);
|
||||
eprintln!(
|
||||
"warm xc: {:.2}s RTF={:.3}",
|
||||
warm_dur.as_secs_f64(),
|
||||
warm_dur.as_secs_f64() / audio_secs
|
||||
);
|
||||
eprintln!("RSS peak: {} MB", rss_final_kb / 1024);
|
||||
}
|
||||
|
||||
fn read_rss_kb() -> u64 {
|
||||
let pid = std::process::id();
|
||||
let s = std::fs::read_to_string(format!("/proc/{pid}/status")).unwrap_or_default();
|
||||
for line in s.lines() {
|
||||
if let Some(rest) = line.strip_prefix("VmRSS:") {
|
||||
return rest
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|n| n.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
131
crates/transcription/tests/thread_sweep.rs
Normal file
131
crates/transcription/tests/thread_sweep.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! Thread-count scaling sweep for Whisper Tiny.
|
||||
//! Runs the JFK clip at n_threads = 1, 2, 4, 6, 8, 12, prints RTF tables.
|
||||
//! Env-gated by `MAGNOTIA_WHISPER_TEST_MODEL` + `MAGNOTIA_WHISPER_TEST_AUDIO`.
|
||||
//!
|
||||
//! Now prints multiple panels driven by `MAGNOTIA_POWER_STATE_OVERRIDE` so
|
||||
//! the helper's predicted thread count for each (power, GPU) combination
|
||||
//! can be compared against the empirical RTF data.
|
||||
|
||||
use std::env;
|
||||
use std::time::Instant;
|
||||
|
||||
use magnotia_core::hardware::vulkan_loader_available;
|
||||
use magnotia_core::tuning::{inference_thread_count, Workload};
|
||||
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
|
||||
|
||||
#[test]
|
||||
fn whisper_thread_count_sweep() {
|
||||
let Ok(model_path) = env::var("MAGNOTIA_WHISPER_TEST_MODEL") else {
|
||||
return;
|
||||
};
|
||||
let Ok(audio_path) = env::var("MAGNOTIA_WHISPER_TEST_AUDIO") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let bytes = std::fs::read(&audio_path).expect("read wav");
|
||||
let sample_rate = u32::from_le_bytes(bytes[24..28].try_into().unwrap());
|
||||
let pcm = &bytes[44..];
|
||||
let samples: Vec<f32> = pcm
|
||||
.chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
|
||||
.collect();
|
||||
let audio_secs = samples.len() as f64 / sample_rate as f64;
|
||||
eprintln!("[sweep] audio: {:.2}s @ {} Hz", audio_secs, sample_rate);
|
||||
|
||||
let logical = num_cpus::get();
|
||||
let physical = num_cpus::get_physical();
|
||||
eprintln!("[sweep] CPU: physical={}, logical={}", physical, logical);
|
||||
|
||||
let ctx = WhisperContext::new_with_params(&model_path, WhisperContextParameters::default())
|
||||
.expect("model load");
|
||||
|
||||
// Warm-up pass to prime caches.
|
||||
{
|
||||
let mut state = ctx.create_state().expect("state");
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
params.set_language(Some("en"));
|
||||
params.set_n_threads(physical as i32);
|
||||
params.set_print_special(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
state.full(params, &samples).expect("warmup");
|
||||
}
|
||||
|
||||
let mut targets: Vec<i32> = vec![1, 2, 4, physical as i32, logical as i32];
|
||||
if logical >= 8 && !targets.contains(&8) {
|
||||
targets.push(8);
|
||||
}
|
||||
targets.sort();
|
||||
targets.dedup();
|
||||
|
||||
// Snapshot the runtime Vulkan loader status once. The actual whisper
|
||||
// context above already initialised whichever backend it could; the
|
||||
// GPU panels below differ only in label and predicted-helper-pick.
|
||||
// The runtime RTF rows are produced by the same backend the warm-up
|
||||
// used.
|
||||
let vulkan_runtime_ok = cfg!(feature = "whisper-vulkan") && vulkan_loader_available();
|
||||
eprintln!(
|
||||
"[sweep] whisper-vulkan feature: {}, libvulkan resolvable at runtime: {}",
|
||||
cfg!(feature = "whisper-vulkan"),
|
||||
vulkan_runtime_ok
|
||||
);
|
||||
|
||||
// Four panels: CPU and GPU axes for the predicted-helper-pick column,
|
||||
// crossed with AC and battery via MAGNOTIA_POWER_STATE_OVERRIDE.
|
||||
let panels = [
|
||||
("AC, CPU", "ac", false),
|
||||
("AC, GPU (Vulkan)", "ac", true),
|
||||
("battery, CPU", "battery", false),
|
||||
("battery, GPU (Vulkan)", "battery", true),
|
||||
];
|
||||
|
||||
for (label, power, gpu_offloaded_for_helper) in panels {
|
||||
env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", power);
|
||||
let helper_pick = inference_thread_count(Workload::Whisper, gpu_offloaded_for_helper);
|
||||
run_sweep_panel(label, helper_pick, &ctx, &samples, audio_secs, &targets);
|
||||
}
|
||||
env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE");
|
||||
}
|
||||
|
||||
fn run_sweep_panel(
|
||||
label: &str,
|
||||
helper_pick: usize,
|
||||
ctx: &WhisperContext,
|
||||
samples: &[f32],
|
||||
audio_secs: f64,
|
||||
targets: &[i32],
|
||||
) {
|
||||
eprintln!();
|
||||
eprintln!("=== n_threads scaling: {label} (helper picks: {helper_pick}) ===");
|
||||
eprintln!("n_threads | xc_time | RTF | speedup_vs_1");
|
||||
eprintln!("----------|---------|--------|-------------");
|
||||
let mut baseline_dur: Option<f64> = None;
|
||||
for n in targets {
|
||||
// Two runs, take the min — best-case after L2/L3 warm.
|
||||
let mut best = f64::MAX;
|
||||
for _ in 0..2 {
|
||||
let mut state = ctx.create_state().expect("state");
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
params.set_language(Some("en"));
|
||||
params.set_n_threads(*n);
|
||||
params.set_print_special(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
let t = Instant::now();
|
||||
state.full(params, samples).expect("transcribe");
|
||||
let dur = t.elapsed().as_secs_f64();
|
||||
if dur < best {
|
||||
best = dur;
|
||||
}
|
||||
}
|
||||
let rtf = best / audio_secs;
|
||||
let speedup = baseline_dur.map(|b| b / best).unwrap_or(1.0);
|
||||
if baseline_dur.is_none() {
|
||||
baseline_dur = Some(best);
|
||||
}
|
||||
eprintln!(
|
||||
"{:>9} | {:>6.2}s | {:>6.3} | {:>6.2}x",
|
||||
n, best, rtf, speedup
|
||||
);
|
||||
}
|
||||
}
|
||||
90
docs/architecture-map/01-frontend/README.md
Normal file
90
docs/architecture-map/01-frontend/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: Frontend slice (Svelte 5 + SvelteKit + Tauri webview)
|
||||
type: architecture-map-slice-index
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Frontend (Slice 01)
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → Frontend
|
||||
|
||||
**Plain English summary.** This slice is everything the user sees. It is a Svelte 5 single page app, served by SvelteKit in SPA mode, hosted inside Tauri's webview. It owns four windows (main, tasks float, transcript viewer, transcription preview overlay), seven page modules switched by an in memory router store, around thirty reusable components, ten reactive stores, and the design system tokens. The frontend never touches the filesystem, audio, models or the LLM directly. Everything that crosses the WebView boundary goes through `invoke()` (calling Rust commands) or Tauri events. If you delete this slice, you delete the entire user surface but keep the engine.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Path:** `src/`
|
||||
- **Total LOC counted:** about 17 200 (583 `app.css` + 2 740 routes + 6 230 pages + 3 174 components + 4 085 stores/utils/types + ~ 2 360 design system + 130 misc).
|
||||
- **File counts:** 7 pages, 25 components, 10 stores, 1 action, 16 utils, 1 type module, 3 locales, 4 routes (root + float/viewer/preview), 20 design system preview HTMLs, 3 design system JSX kits.
|
||||
- **Frameworks:** Svelte 5 (runes mode), SvelteKit 2.58, `@sveltejs/adapter-static` with `index.html` fallback (SPA), Vite 6, Tailwind v4 via `@tailwindcss/vite`, svelte-i18n 4, lucide-svelte for icons.
|
||||
- **Tauri SDK touchpoints:** `@tauri-apps/api` (core invoke, event, window) plus `plugin-autostart`, `plugin-dialog`, `plugin-global-shortcut`, `plugin-notification`, `plugin-opener`. SSR is disabled (`src/routes/+layout.js`) so the whole tree is client side only.
|
||||
- **Persistence used directly by frontend:** `localStorage` for `magnotia_settings`, `magnotia_profiles`, `magnotia_task_lists`, `magnotia_templates`, `magnotia_locale`, plus a small handoff key for the viewer window. Preferences additionally persist via Tauri (`save_preferences`).
|
||||
- **Build commands:** `npm run dev` (`svelte-kit sync && vite dev`), `npm run build`, `npm run check` (svelte-check using `jsconfig.json`).
|
||||
|
||||
## Map of this slice
|
||||
|
||||
- [Windows and routes](windows-and-routes.md). The four Tauri windows, the SvelteKit routes that back them, the `+layout@.svelte` break, and how the shell decides between custom chrome and native decorations.
|
||||
- [Pages overview](pages-overview.md). Index of the seven pages routed by `page.current`, and the `/float`, `/viewer`, `/preview` route pages.
|
||||
- [Pages: dictation](pages/dictation.md). The hero recording surface.
|
||||
- [Pages: settings](pages/settings.md). The 2 484 line settings panel.
|
||||
- [Pages: history](pages/history.md). FTS5 backed transcript browser.
|
||||
- [Pages: tasks](pages/tasks.md). Inbox, today, soon, later board.
|
||||
- [Pages: files](pages/files.md). Drag and drop file transcription.
|
||||
- [Pages: first run](pages/first-run.md). Hardware probe and model bootstrap.
|
||||
- [Components](components.md). All 25 reusable components grouped by role.
|
||||
- [Stores](stores.md). Ten Svelte 5 `$state` stores, plus what each owns and what events they react to.
|
||||
- [Actions, utils, types](actions-utils-types.md). The single Svelte action, the 16 utility modules, and the `app.ts` type bible.
|
||||
- [Internationalisation](i18n.md). svelte-i18n setup, locale persistence, current coverage.
|
||||
- [Design system](design-system.md). Reference material under `src/design-system/` (preview HTML, JSX UI kits). Note: this is reference, not live code.
|
||||
- [Frontend ↔ Tauri bridge](frontend-tauri-bridge.md). Every `invoke()` command name and every event listened for or emitted, with their callers.
|
||||
- [App shell and styling](app-shell-and-styling.md). `app.css`, `app.html`, fonts, Tailwind v4 configuration, accessibility CSS variables.
|
||||
|
||||
## How this slice connects to others
|
||||
|
||||
**In (frontend depends on Tauri runtime, slice 02).**
|
||||
|
||||
- Sixty plus distinct `invoke()` commands. Full list with caller in [frontend-tauri-bridge.md](frontend-tauri-bridge.md).
|
||||
- Tauri events listened to: `model-download-progress`, `parakeet-download-progress`, `magnotia:llm-download-progress`, `magnotia:hotkey-pressed`, `magnotia:open-wind-down`, `magnotia:preferences-changed`, `task-window-focus`, `preview-listening`, `preview-cleanup`, `preview-hide`, plus drag drop (`tauri://drag-drop`, `tauri://drag-enter`, `tauri://drag-leave`).
|
||||
- Window APIs: `getCurrentWindow().minimize() / toggleMaximize() / setPosition() / label`.
|
||||
- Plugin imports loaded lazily: `@tauri-apps/plugin-global-shortcut`, `@tauri-apps/plugin-dialog`.
|
||||
|
||||
**Out (frontend triggers behaviour back into the runtime).**
|
||||
|
||||
- DOM `CustomEvent` bus on `window` carries internal traffic (`magnotia:start-timer`, `magnotia:toggle-recording`, `magnotia:task-completed`, etc). The Rust side does not listen to these; they are intra frontend.
|
||||
- Tauri `emit()` is used for `magnotia:preferences-changed` only, to fan preference updates across windows.
|
||||
- Multi window orchestration: pages call `open_task_window`, `open_viewer_window`, `open_preview_window` to ask Rust to spawn secondary webviews.
|
||||
|
||||
**Other slices that read frontend conventions.**
|
||||
|
||||
- Slice 02 (Tauri runtime) emits the events listed above and registers commands the frontend invokes.
|
||||
- Slice 03 (audio + transcription) ships partial results via a `Channel` (Tauri 2 typed channel) opened in `DictationPage`.
|
||||
- Slice 04 (LLM, formatting, MCP) emits `magnotia:llm-download-progress` and `cleanup_transcript_text_cmd` results consumed by Dictation.
|
||||
- Slice 05 (storage, hotkey, build) supplies `add_transcript`, `delete_transcript`, profiles, tasks and the evdev hotkey backend.
|
||||
|
||||
## Existing in repo docs (do not duplicate)
|
||||
|
||||
- `docs/brief/` and `docs/whisper-ecosystem/brief.md`. Product brief and feature set.
|
||||
- `docs/icon-mapping.md`. Lucide icon migration audit.
|
||||
- `docs/code-review-2026-04-22.md`. Last code review snapshot.
|
||||
- `docs/handovers/`. Ship logs (e.g. Phase 9c is the most recent settings related one).
|
||||
- `docs/audit/`. UX and accessibility audits.
|
||||
- `docs/superpowers/`. Process artefacts.
|
||||
- `src/design-system/README.md`, `src/design-system/SKILL.md`. Brand and design ground truth.
|
||||
|
||||
This slice index is the navigation hub. The map files referenced above carry the detail.
|
||||
|
||||
## Open questions, debt, drift, dead code
|
||||
|
||||
1. **`src/lib/Sidebar.svelte` lives outside `components/`.** Every other reusable Svelte module is under `src/lib/components/`. The sidebar is the only sibling of those folders. Cosmetic but it surprises contributors.
|
||||
2. **Two parallel theme systems.** `settings.theme` (string `"Light"`/`"Dark"`/`"System"`) coexists with `preferences.theme` (`"light"`/`"dark"`/`"system"`). `+layout.svelte:61-68` and `float/+layout@.svelte` both run a "legacy → new" migration `$effect` every time. The legacy pathway should be retired.
|
||||
3. **`@ts-nocheck` is widespread.** `+layout.svelte`, `DictationPage.svelte`, `FilesPage.svelte`, `FirstRunPage.svelte` and the float/viewer/preview layouts all opt out of TypeScript. Type safety stops at the page boundary.
|
||||
4. **`SettingsPage.svelte` is 2 484 lines.** Phase 9c handover already flagged this. `SettingsGroup.svelte` exists but the deeper restructure into seven progressive disclosure groups plus search has been deferred.
|
||||
5. **`profiles` redirect is a dead route.** `+page.svelte:13` still rewrites `page.current === "profiles"` to `"settings"`, suggesting the old profiles page was removed but call sites may persist. Worth grepping and deleting the redirect after a release.
|
||||
6. **Cross window settings sync uses `localStorage` `storage` events.** `float/+layout@.svelte` listens for `storage` to apply settings, while preferences use the dedicated `magnotia:preferences-changed` Tauri event. Inconsistent. Settings sync via `storage` is fragile because it only fires on cross document writes.
|
||||
7. **`shims.d.ts` next to the lib root.** Single shim file at `src/lib/shims.d.ts`. Worth verifying it is still needed (Svelte 5 + SvelteKit ship most ambient types now).
|
||||
8. **`design-system/ui_kits/`** contains JSX components and an HTML index. They are reference, not live, and should not import from the runtime tree. Confirm the build excludes them.
|
||||
9. **`speaker.svelte.ts` is ten lines.** A near empty store. Used by `SpeakerButton.svelte`. Consider folding into a util if it never grows.
|
||||
10. **CSS variable `--font-size-transcript` is set on `body` from `+layout.svelte` but `preferences` already sets `--font-size-body` and `--transcript-font-size`.** Three knobs for transcript text size in different stores. Drift between `settings.fontSize` and `preferences.accessibility.transcriptSize` is likely.
|
||||
11. **i18n coverage is thin.** Each locale has 19 lines. svelte-i18n is wired but most strings remain hardcoded. Treat as a stub.
|
||||
12. **Tailwind v4 has no standalone config file.** All theming lives in `app.css` `@theme`. There is no `tailwind.config.{js,ts}`. Mention this so contributors do not waste time looking.
|
||||
13. **`docs/architecture-map/01-frontend/` was empty before this pass.** Reciprocal slice indexes (02 to 05) need updating to point here.
|
||||
125
docs/architecture-map/01-frontend/actions-utils-types.md
Normal file
125
docs/architecture-map/01-frontend/actions-utils-types.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: Actions, utils, types
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Actions, utils, types
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Actions, utils, types
|
||||
|
||||
**Plain English summary.** Helpers that are not components and not state. The single Svelte action (`bionicReading`), 16 utility modules under `src/lib/utils/`, and the `app.ts` type module that everyone imports.
|
||||
|
||||
## Actions
|
||||
|
||||
### `src/lib/actions/bionicReading.ts` (74 LOC)
|
||||
|
||||
Svelte action that toggles "bionic reading" rendering on a node. Walks the text nodes via `TreeWalker`, replaces them with bolded prefix + plain suffix spans. A `MutationObserver` reapplies on text changes (disconnects while mutating to avoid infinite loop). `stripBionic` undoes by unwrapping `<b>` tags and normalising adjacent text nodes.
|
||||
|
||||
Used in DictationPage and the viewer. Wired to `preferences.accessibility.bionicReading`.
|
||||
|
||||
## Utils
|
||||
|
||||
### `runtime.ts` (45 LOC)
|
||||
|
||||
Exports `hasTauriRuntime()`. Probes for `window.__TAURI_INTERNALS__` or `window.isTauri` to short circuit Tauri calls in browser preview mode. Used everywhere as the gate before `invoke()`.
|
||||
|
||||
### `osInfo.ts` (110 LOC)
|
||||
|
||||
Caches OS info via `invoke("os_info")` (or similar; check `lib.rs`). Exposes `loadOsInfo`, `isMac`, `isLinux`, `modKeyLabel` synchronously after `loadOsInfo` resolves. Drives whether the shell renders custom or native chrome.
|
||||
|
||||
### `errors.ts` (8 LOC)
|
||||
|
||||
`errorMessage(e)` returns a string from `Error | unknown`. Tiny.
|
||||
|
||||
### `storage.ts` (8 LOC)
|
||||
|
||||
`parseStoredJson<T>(key, fallback)` wraps `JSON.parse(localStorage.getItem(key))` with try/catch. Used by `settingsMigrations`, `page.svelte.ts`, and the viewer handoff.
|
||||
|
||||
### `settingsMigrations.ts` (134 LOC)
|
||||
|
||||
Versioned migrations for `magnotia_settings`. Holds `CURRENT_SETTINGS_VERSION = 1`. Envelope shape: `{ version: number, data: T }`. Reads bare unversioned blobs as v0. `loadSettingsWithMigration(key, defaults)` walks the chain and toasts on corruption.
|
||||
|
||||
### `frontmatter.ts` (148 LOC)
|
||||
|
||||
Builds YAML frontmatter for transcript markdown export.
|
||||
|
||||
- `deriveAutoTags(text, profile)`. Heuristic auto tags.
|
||||
- `buildFrontmatter(transcript)`. Returns the YAML block.
|
||||
- `buildMarkdown(transcript)`. Joins frontmatter + body.
|
||||
- `normaliseTag(s)`. Lowercase, replace whitespace.
|
||||
|
||||
### `saveMarkdown.ts` (132 LOC)
|
||||
|
||||
Save dialog + write file dance. `suggestedFilename(item)` builds `<slug>-<YYYY-MM-DD>.md`. `saveTranscriptAsMarkdown(item)` opens `@tauri-apps/plugin-dialog`, then writes via `invoke`. `exportTranscriptsToDir(items)` for bulk export.
|
||||
|
||||
### `export.ts` (125 LOC)
|
||||
|
||||
Generic export helpers used by DictationPage and FilesPage. Format choice: plain text, markdown, JSON. Falls through to clipboard or save dialog.
|
||||
|
||||
### `taskExtractor.ts` (224 LOC)
|
||||
|
||||
`extractTasks(text)` uses regex heuristics for "TODO:", "Action:", "I need to ...", numbered lists, etc. Falls back when LLM extraction is unavailable.
|
||||
|
||||
### `textMeasure.ts` (166 LOC)
|
||||
|
||||
Canvas-based pre-wrap text measurement. `measurePreWrap(text, font, lineHeight, width)` returns the rendered height. `clampTextLines(text, n)` trims to N lines plus ellipsis. Used by HistoryPage virtual scroll and DictationPage textarea sizing.
|
||||
|
||||
### `accessibilityTypography.ts` (41 LOC)
|
||||
|
||||
Reads `--font-family-body`, `--font-size-body`, `--line-height-body`, `--letter-spacing-body` from `<html>` and returns numeric values. `pretextFontShorthand`, `bodyPretextLineHeight`, `transcriptPretextFont`, `transcriptPretextLineHeight` are the public helpers.
|
||||
|
||||
### `virtualList.ts` (49 LOC)
|
||||
|
||||
`buildCumulativeOffsets(heights)` and `findVisibleRange(offsets, scrollTop, viewportHeight, overscan)`. Pure. Used by HistoryPage and `VirtualSegmentList`.
|
||||
|
||||
### `time.ts` (46 LOC)
|
||||
|
||||
`pad(n)`, `formatTime(seconds)`, `formatDuration(seconds)`, `formatTimestamp(iso)`. Display helpers.
|
||||
|
||||
### `sounds.ts` (101 LOC)
|
||||
|
||||
Loads the start/stop/complete cue sounds via `<audio>` and exposes `playStartCue()`, `playStopCue()`, `playCompleteCue()`. Volume from `settings.soundCueVolume`. Honours `settings.soundCues` flag.
|
||||
|
||||
### `hotkeyValidity.ts` (149 LOC)
|
||||
|
||||
Validates a hotkey combo against per-OS rules (no Cmd alone on macOS, no single letters, etc). Used by `HotkeyRecorder.svelte`.
|
||||
|
||||
### `constants.js` (30 LOC)
|
||||
|
||||
The shared scalars:
|
||||
- Timing: `FEEDBACK_TIMEOUT_MS`, `HOTKEY_FEEDBACK_MS`, `HISTORY_MAX_ENTRIES = 100`, `MAX_PCM_SAMPLES = 4_800_000` (5 min @ 16 kHz), `SIDEBAR_MAX_TASKS = 30`, `CHUNK_INTERVAL_MS = 3000`, `MIN_CHUNK_SAMPLES = 8000`.
|
||||
- Buckets: `BUCKET_COLORS`, `BUCKET_DOT_COLORS` (`inbox` / `today` / `soon` / `later`).
|
||||
- Effort: `EFFORT_LABELS`, `EFFORT_ORDER`.
|
||||
- Playback: `PLAYBACK_SPEEDS = [0.5, 1, 1.5, 2, 3, 5]`.
|
||||
|
||||
Note: this is `.js`, not `.ts`. Other utils are `.ts`.
|
||||
|
||||
## Types
|
||||
|
||||
### `src/lib/types/app.ts` (408 LOC)
|
||||
|
||||
Single source of truth for shared shapes. Contains:
|
||||
- `PageState`, `SettingsState`, `Preferences`, `AccessibilityPreferences`.
|
||||
- `Profile`, `Template`, `TaskList`, `TaskBucket`, `TaskEntry`, `TaskDraft`, `TaskUpdate`, `TaskDto`, `EnergyLevel`.
|
||||
- `TranscriptDto`, `TranscriptEntry`, `TranscriptWriteEntry`, `TranscriptMetaPatch`, `Segment`, `ViewerSegment`.
|
||||
- `DailyCompletionCount`, `ToastSeverity`, `FontFamily`, `ReduceMotion`.
|
||||
|
||||
### `src/lib/shims.d.ts`
|
||||
|
||||
Single ambient declaration file. Worth verifying the contents are still needed (Svelte 5 + SvelteKit ship most ambient types now). README debt note 7.
|
||||
|
||||
## Watch outs
|
||||
|
||||
- `constants.js` is JS, not TS. If you migrate, the JSDoc shapes in `BUCKET_COLORS` need to align with Tailwind class strings; type-only objects still need a runtime export.
|
||||
- `taskExtractor.ts` is a regex heuristic. The LLM path (`extract_tasks_from_transcript_cmd`) is the better signal; the heuristic is the offline fallback.
|
||||
- `osInfo.ts` caches forever. If you need to handle a hot OS detection retry (you should not), invalidate the cache yourself.
|
||||
- `textMeasure.ts` uses an offscreen `<canvas>`. Costs are real for long transcripts; results are cached per (text, font, line-height, width) tuple in HistoryPage.
|
||||
- `frontmatter.ts` and `saveMarkdown.ts` overlap. The former produces the body; the latter handles the I/O. Keep them split.
|
||||
|
||||
## See also
|
||||
|
||||
- [Stores](stores.md). Many utils are imported by stores.
|
||||
- [Components](components.md). The components that consume these helpers.
|
||||
- [App shell and styling](app-shell-and-styling.md). CSS variables consumed by the typography utils.
|
||||
110
docs/architecture-map/01-frontend/app-shell-and-styling.md
Normal file
110
docs/architecture-map/01-frontend/app-shell-and-styling.md
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: App shell and styling
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# App shell and styling
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → App shell and styling
|
||||
|
||||
**Plain English summary.** Where the visual chrome and CSS plumbing live. `app.html` is the SvelteKit document. `app.css` carries the Tailwind v4 import, the `@theme` token block, the brand `@font-face` declarations, the sensory zones, and the accessibility CSS variables. There is no separate `tailwind.config.{js,ts}`; Tailwind v4 is configured entirely in `app.css`. Fonts ship from `src/fonts/` (bundled by Vite) and `static/fonts/` (served as-is).
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Path:** `src/app.html`, `src/app.css`, `src/app.d.ts`, `src/fonts/`, `src/assets/`, `static/`.
|
||||
- **LOC:** 13 (`app.html`) + 583 (`app.css`) + 8 (`app.d.ts`).
|
||||
- **Frameworks:** Tailwind v4 via `@tailwindcss/vite`. No PostCSS config. No standalone Tailwind config file.
|
||||
- **Adapter:** `@sveltejs/adapter-static` with `index.html` fallback (SPA, `svelte.config.js`).
|
||||
|
||||
## What's in here
|
||||
|
||||
### `src/app.html` (13 LOC)
|
||||
|
||||
Standard SvelteKit document. Sets `lang="en"`, charset, viewport, favicon, title (`Magnotia`), and applies `data-sveltekit-preload-data="hover"` on `<body>`. The body content is wrapped in `<div style="display: contents">` so the SvelteKit-injected children render inline.
|
||||
|
||||
### `src/app.d.ts` (8 LOC)
|
||||
|
||||
Ambient declaration extending `Window` with `__TAURI_INTERNALS__` and `isTauri` so `utils/runtime.ts` typechecks.
|
||||
|
||||
### `src/app.css` (583 LOC)
|
||||
|
||||
Layered:
|
||||
1. `@import "tailwindcss"` (Tailwind v4 entry).
|
||||
2. `@font-face` for Lexend, Atkinson Hyperlegible Next, OpenDyslexic, JetBrains Mono, Instrument Serif Italic. URLs use root-absolute paths (`/fonts/...`) served from `static/fonts/` by Vite.
|
||||
3. `@theme` token block. Sets the design tokens that Tailwind v4 picks up as utility classes:
|
||||
- Colour tokens: `--color-bg`, `--color-bg-raised`, `--color-text`, `--color-text-secondary`, `--color-text-tertiary`, `--color-accent`, `--color-warning`, `--color-success`, `--color-border`, `--color-border-subtle`, `--color-hover`, `--color-nav-active`, plus a sensory-zone palette family.
|
||||
- Typography tokens: `--font-display`, `--font-body`, `--font-mono`, plus `--font-size-*` and `--line-height-*`.
|
||||
- Motion tokens: `--duration-ui`, `--duration-fast`, `--duration-slow`. Easing tokens for `cubic-bezier` curves.
|
||||
- Shadow tokens: `--shadow-accent-md`, `--shadow-accent-glow`, etc.
|
||||
4. Sensory-zone overrides keyed on `[data-zone="..."]` on `<html>`. Switches token values to dim, focus, etc.
|
||||
5. Theme overrides keyed on `[data-theme="dark"]` and `[data-theme="light"]`. The `preferences` store writes both `data-theme` and `data-zone`.
|
||||
6. Accessibility variables on `<html>`: `--font-family-body`, `--font-size-body`, `--letter-spacing-body`, `--line-height-body`. Set by the `preferences` store via `applyToDOM()`.
|
||||
7. Base styles: `body` background, font, body font-family bound to the variable, scrollbar styling, focus ring.
|
||||
8. Utility classes for grain texture (`.grain` uses the noise asset under `assets/grain.svg`), CRT-style transcript surface, etc.
|
||||
9. Animations: `@keyframes fade-in`, `@keyframes pulse`, etc. Reduced motion guard at the bottom (`@media (prefers-reduced-motion: reduce)`).
|
||||
|
||||
### `src/fonts/` (bundled woff2)
|
||||
|
||||
- `atkinson-hyperlegible-next.woff2`
|
||||
- `instrument-serif-italic.woff2`
|
||||
- `jetbrains-mono.woff2`
|
||||
- `lexend-variable.woff2`
|
||||
- `opendyslexic.woff2`
|
||||
|
||||
Bundled by Vite (referenced from `app.css` via `/fonts/...` paths that Vite resolves). The same files live in `static/fonts/` for the static-served path. Confirm whether both paths are required; if Vite handles font copying, `static/fonts/` may be redundant.
|
||||
|
||||
### `src/assets/`
|
||||
|
||||
- `grain.svg`. Noise texture used by the `.grain` utility class.
|
||||
- `waveform-mark.svg`. Brand glyph.
|
||||
- `wordmark.svg`. Brand wordmark.
|
||||
|
||||
### `static/`
|
||||
|
||||
Served as-is from the webview root.
|
||||
|
||||
- `favicon.png`. Site favicon.
|
||||
- `fonts/`. Same five woff2 files as `src/fonts/`. Likely the source of `app.css /fonts/...` URLs.
|
||||
- `pcm-processor.js`. AudioWorklet processor (slice 02 owns the integration). 32-line file that converts microphone input to int16 PCM frames and posts them up.
|
||||
- `textures/grain.png`. PNG version of the grain texture.
|
||||
- `svelte.svg`, `tauri.svg`, `vite.svg`. SvelteKit defaults; technically unused. Candidate for removal.
|
||||
|
||||
## How preferences map to the DOM
|
||||
|
||||
| Preference | DOM target |
|
||||
|---|---|
|
||||
| `theme` ("light" / "dark" / "system") | `data-theme` on `<html>`. `system` resolves to OS preference at apply time. |
|
||||
| `zone` ("default" / ...) | `data-zone` on `<html>`. |
|
||||
| `accessibility.fontFamily` ("lexend" / "atkinson" / "opendyslexic") | `--font-family-body` CSS variable on `<html>`. |
|
||||
| `accessibility.fontSize` | `--font-size-body` (pixels). |
|
||||
| `accessibility.letterSpacing` | `--letter-spacing-body` (em). |
|
||||
| `accessibility.lineHeight` | `--line-height-body` (unitless). |
|
||||
| `accessibility.bionicReading` | `<html data-bionic-reading="true|false">`. The `bionicReading` action reads this. |
|
||||
| `accessibility.reduceMotion` | `<html data-reduce-motion="reduce|no-preference|system">`. Pairs with the `prefers-reduced-motion` media query. |
|
||||
|
||||
Plus the legacy `settings.fontSize` writes `--font-size-transcript` on `<body>` directly (`+layout.svelte:204`). That is separate from `accessibility.fontSize`.
|
||||
|
||||
## Tailwind v4 setup
|
||||
|
||||
- Installed via `@tailwindcss/vite` in `vite.config.js:3`.
|
||||
- Entry: `src/app.css` line 1, `@import "tailwindcss"`.
|
||||
- Tokens declared inline via `@theme` blocks in `app.css`.
|
||||
- No `tailwind.config.{js,ts}` exists. Do not look for one.
|
||||
- Class scanning: Tailwind v4 scans the source tree by default. Custom paths can be configured with `@source` directives if needed.
|
||||
|
||||
## Watch outs
|
||||
|
||||
- **Two font sources.** `src/fonts/` (Vite bundled) and `static/fonts/` (raw served). Confirm whether both are needed; redundancy bloats the bundle.
|
||||
- **Mirror file `src/design-system/colors_and_type.css`** must be updated whenever `app.css` `@theme` changes. There is no automated check.
|
||||
- **`prefers-reduced-motion` honoured by CSS** but JS animations (e.g. `CompletionSparkline`'s stagger) are guarded in component code, not via the media query alone.
|
||||
- **Tailwind v4 `@theme` is class-scanning sensitive.** If you put utility class strings inside conditional template literals that Tailwind cannot see at scan time, they will not be generated.
|
||||
- **Removing default SvelteKit assets** (`static/svelte.svg`, `vite.svg`, `tauri.svg`) requires confirming nothing references them in `app.html` or `README.md`.
|
||||
- **`pcm-processor.js`** is a static asset because AudioWorklet processors must be served from a same-origin URL. Bundling it through Vite would break the worklet registration. Leave it in `static/`.
|
||||
|
||||
## See also
|
||||
|
||||
- [Design system](design-system.md). The reference mirror of `app.css` tokens.
|
||||
- [Stores](stores.md). The `preferences` store that writes the DOM.
|
||||
- [Components](components.md). Where the utility classes are consumed.
|
||||
103
docs/architecture-map/01-frontend/components.md
Normal file
103
docs/architecture-map/01-frontend/components.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: Components
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Components
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Components
|
||||
|
||||
**Plain English summary.** All 25 reusable Svelte 5 components, plus `Sidebar.svelte` which is the only component that lives at `src/lib/Sidebar.svelte` rather than under `src/lib/components/`. Grouped by what they do. Where a component is mounted directly by the shell (`+layout.svelte`), that is called out.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Path:** `src/lib/components/` (25 files), plus `src/lib/Sidebar.svelte`.
|
||||
- **LOC:** 3 174 (components) + 178 (sidebar) = 3 352.
|
||||
- **Conventions:** Svelte 5 runes (`$state`, `$props`, `$derived`, `$effect`). Tailwind v4 utility classes. CSS variables for design tokens. Lucide icons at 16/20/24px with `aria-label` next to or instead of the glyph.
|
||||
|
||||
## Shell mounts (always present)
|
||||
|
||||
| Component | LOC | Path | Purpose |
|
||||
|---|---|---|---|
|
||||
| `Sidebar` | 178 | `src/lib/Sidebar.svelte` | Left nav. Switches `page.current`. Collapsed mode shows tooltips. Renders task badge from `tasks.length`. |
|
||||
| `Titlebar` | 80 | `src/lib/components/Titlebar.svelte` | Custom window chrome for non Linux (frameless windows). Min/max/close + drag area + sidebar aligned spacer. |
|
||||
| `ToastViewport` | 143 | `src/lib/components/ToastViewport.svelte` | Bottom right toast stack. Reads from the global `toasts` store. Honours `prefers-reduced-motion`. |
|
||||
| `ResizeHandles` | 67 | `src/lib/components/ResizeHandles.svelte` | Invisible 5 px margins for frameless resize. Linux uses native, so `+layout.svelte` suppresses this. |
|
||||
| `FocusTimer` | 298 | `src/lib/components/FocusTimer.svelte` | Floating top right SVG progress ring. Listens for `magnotia:start-timer` window events and delegates to the focus timer store. Cancel hover, success flourish. Hidden on float and viewer windows by URL probe. |
|
||||
| `MorningTriageModal` | 356 | `src/lib/components/MorningTriageModal.svelte` | Phase 5 modal. Self gated on `settings.ritualsMorning` and time of day. Mounted globally so it appears over any page. |
|
||||
| `TaskSidebar` | 97 | `src/lib/components/TaskSidebar.svelte` | Optional right side dock that appears when `page.taskSidebarOpen`. Quick add input, top tasks, link out to the full Tasks page. |
|
||||
|
||||
## Settings building blocks
|
||||
|
||||
| Component | LOC | Purpose |
|
||||
|---|---|---|
|
||||
| `SettingsGroup` | 86 | Native `<details>` based progressive disclosure with animated chevron. Handler hooks (`onopen`) are used by SettingsPage to lazy probe expensive state (model lists, paste backends, etc). |
|
||||
| `Toggle` | 98 | iOS style toggle. ARIA switch role. |
|
||||
| `SegmentedButton` | 19 | Tiny segmented switch with ARIA radio role. |
|
||||
| `HotkeyRecorder` | 217 | Captures a key combo. Uses `utils/hotkeyValidity.ts` to check the combo before persisting. |
|
||||
| `ZonePicker` | 36 | Sensory zone (default / focus / dim / etc) picker that maps to a `data-zone` attribute on `<html>`. |
|
||||
| `AccessibilityControls` | 112 | Font family, font size, letter spacing, line height, reduce motion, bionic reading. Writes via `updateAccessibility`. |
|
||||
| `ImplementationRulesEditor` | 266 | Edits the implementation intentions list ("when X happens, do Y"). Persists via the `implementationIntentions` store. |
|
||||
| `ModelDownloader` | 112 | Standalone download panel used inside Dictation when no model is loaded. Subscribes to `model-download-progress`. |
|
||||
|
||||
## Card chrome and empty states
|
||||
|
||||
| Component | LOC | Purpose |
|
||||
|---|---|---|
|
||||
| `Card` | 35 | Generic container. Border, padding, optional header. The page level "block" primitive. |
|
||||
| `EmptyState` | 22 | Centred icon + title + body slot. Used widely. |
|
||||
| `UnicodeSpinner` | 30 | ASCII spinner used while probing or downloading. |
|
||||
|
||||
## Tasks
|
||||
|
||||
| Component | LOC | Purpose |
|
||||
|---|---|---|
|
||||
| `WipTaskList` | 153 | Renders task rows with energy chips, completion checkbox, expansion to show micro steps, and a "start focus timer" button (dispatches `magnotia:start-timer`). |
|
||||
| `MicroSteps` | 310 | Per task expansion: nudge bus integration, micro step suggestions from the LLM (`magnotia:microstep-generated`), step completion (`magnotia:step-completed`), per task implementation rules. |
|
||||
| `EnergyChip` | 106 | Low/medium/high energy selector. Used on task rows and in TasksPage quick add. |
|
||||
| `CompletionSparkline` | 92 | 7 day completion bar chart. Animated entrance with 30 ms stagger, respects `prefers-reduced-motion`. Reads from `recentCompletions` store. |
|
||||
| `VisualTimer` | 33 | Compact visual timer pill used inside MicroSteps and the float window. |
|
||||
|
||||
## Transcripts
|
||||
|
||||
| Component | LOC | Purpose |
|
||||
|---|---|---|
|
||||
| `VirtualSegmentList` | 234 | Virtualised scroll for transcript segments in the viewer window. Uses `utils/virtualList.ts` and `utils/textMeasure.ts` to compute per row heights from current preferences. |
|
||||
| `SpeakerButton` | 112 | Trigger TTS playback for a chunk of text. Uses `tts_speak`. Reads from the `speaker` store to debounce concurrent requests. |
|
||||
| `LlmStatusChip` | 60 | Pill in the sidebar/dictation surface showing LLM state (idle, generating, downloading, unavailable). Reads `llmStatus` store. |
|
||||
|
||||
## Where each component is used
|
||||
|
||||
- `Sidebar`: `+layout.svelte` shell only.
|
||||
- `Titlebar`: `+layout.svelte`, `float/+layout@.svelte`, `viewer/+layout@.svelte` (when `useCustomChrome`).
|
||||
- `ToastViewport`: `+layout.svelte`, `viewer/+layout@.svelte`.
|
||||
- `ResizeHandles`: `+layout.svelte` (when `useCustomChrome`).
|
||||
- `FocusTimer`: `+layout.svelte`, `float/+layout@.svelte`.
|
||||
- `MorningTriageModal`: `+layout.svelte`.
|
||||
- `TaskSidebar`: `+layout.svelte` (when `page.taskSidebarOpen`).
|
||||
- `Card`, `EmptyState`, `Toggle`, `SegmentedButton`: SettingsPage, DictationPage, HistoryPage, TasksPage, FilesPage.
|
||||
- `SettingsGroup`, `HotkeyRecorder`, `ImplementationRulesEditor`, `ZonePicker`, `AccessibilityControls`: SettingsPage.
|
||||
- `ModelDownloader`: DictationPage.
|
||||
- `WipTaskList`, `MicroSteps`, `EnergyChip`, `CompletionSparkline`: TasksPage and `WipTaskList` reused inside the float window.
|
||||
- `VirtualSegmentList`: viewer/+page.svelte.
|
||||
- `SpeakerButton`: DictationPage, viewer.
|
||||
- `LlmStatusChip`: Sidebar, DictationPage.
|
||||
- `UnicodeSpinner`: FirstRunPage, SettingsPage.
|
||||
- `VisualTimer`: MicroSteps, float window.
|
||||
|
||||
## Watch outs
|
||||
|
||||
- `Sidebar.svelte` lives outside `components/`. README debt note 1.
|
||||
- `Titlebar` reads `settings.sidebarCollapsed` to mirror the spacer width. Animation timing is driven by `--duration-ui`.
|
||||
- `MorningTriageModal` is heavy (356 LOC). Self gates internally; do not move the gate elsewhere or you will pay its cost on every paint.
|
||||
- `FocusTimer` detects "secondary window" by URL prefix. If you add a fifth window, update the probe.
|
||||
- `CompletionSparkline` animations are the only place that uses CSS keyframes for entrance. Honour reduced motion.
|
||||
- Many components receive props with `let { foo = default } = $props();` (Svelte 5 idiom). The minimal `Card`, `EmptyState`, `Toggle`, `SegmentedButton` are good reading order for understanding the runes idiom on this codebase.
|
||||
|
||||
## See also
|
||||
|
||||
- [Stores](stores.md). The state these components consume.
|
||||
- [Actions, utils, types](actions-utils-types.md). The bionic reading action and the typography utilities most components use.
|
||||
- [Design system](design-system.md). The brand reference these components target.
|
||||
67
docs/architecture-map/01-frontend/design-system.md
Normal file
67
docs/architecture-map/01-frontend/design-system.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: Design system (reference, not live)
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Design system
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Design system
|
||||
|
||||
**Plain English summary.** Reference material. The runtime styles live in `src/app.css`. Everything under `src/design-system/` is brand documentation, preview pages, JSX UI kits, and ground truth source for the magnotia-design Claude skill. None of it is imported by the runtime SvelteKit app.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Path:** `src/design-system/`
|
||||
- **Files:**
|
||||
- `README.md`. Brand and content rules. (Magnotia by CORBEL.)
|
||||
- `SKILL.md`. magnotia-design skill manifest (`user-invocable: true`). The skill copies assets out and writes throwaway HTML or production code on demand.
|
||||
- `colors_and_type.css`. Mirror of the runtime `@theme` tokens for buildless preview pages. Intentional duplication, kept in sync by hand.
|
||||
- `preview/`. 20 buildless HTML spec cards.
|
||||
- `ui_kits/`. JSX recreations (`DictationPage.jsx`, `OtherPages.jsx`, `Sidebar.jsx`) plus an `index.html` and a README.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `colors_and_type.css`
|
||||
|
||||
Mirrors `src/app.css` `@font-face` declarations and (typically) the `@theme` token block, with relative `fonts/...` URLs so preview HTML can render without going through Tailwind/Vite. The file's banner comment is explicit:
|
||||
|
||||
> When app.css @theme changes, mirror the change here. There is no automated sync; intentional duplication keeps the preview pages buildless.
|
||||
|
||||
### `preview/` (20 HTML files)
|
||||
|
||||
Each renders one slice of the system:
|
||||
- Brand: `brand-icons.html`, `brand-wordmark.html`.
|
||||
- Colour: `colors-accent.html`, `colors-semantic.html`, `colors-surfaces.html`, `colors-text.html`, `colors-zones.html`.
|
||||
- Components: `components-buttons.html`, `components-cards.html`, `components-empty-states.html`, `components-inputs.html`, `components-nav.html`, `components-toasts.html`.
|
||||
- Spacing and motion: `spacing-motion.html`, `spacing-radii.html`, `spacing-scale.html`, `spacing-shadows.html`.
|
||||
- Type: `type-body.html`, `type-headings.html`, `type-transcript-mono.html`.
|
||||
|
||||
These are static. Open in any browser.
|
||||
|
||||
### `ui_kits/` (JSX, reference only)
|
||||
|
||||
Three `.jsx` files plus `index.html` and a README. Reproduce the desktop pages in JSX so prototypes can be built outside the Tauri shell. The README inside ui_kits explains how to use them. They are not imported anywhere from the SvelteKit app.
|
||||
|
||||
### Brand language (from README.md)
|
||||
|
||||
- Built for the noise allergic. Solarpunk-as-posture, AI-minimisation, ground truth + warmth.
|
||||
- Iconography: Lucide, 2 px stroke. 16 px nav, 20 px features, 24 px primary actions. Always paired with a label except OS titlebar controls.
|
||||
- Transparency + blur used sparingly: collapsed-sidebar tooltips, float window backdrop, accent-subtle 6% tint. No glassmorphism.
|
||||
|
||||
## Why it lives in `src/`
|
||||
|
||||
The design system files sit under `src/` so the magnotia-design skill (which runs from this repo) can read ground-truth Svelte source from `magnotia-source/` (a sibling import). The runtime build does not include this folder; it is a reference root.
|
||||
|
||||
## Watch outs
|
||||
|
||||
- Tokens in `colors_and_type.css` and `app.css` `@theme` can drift. There is no CI check. When you change a token in one, change it in both.
|
||||
- `ui_kits/` JSX is not used by the app. Treat it as documentation, not as a future framework migration plan. There is no plan to switch from Svelte to React.
|
||||
- Vite (with `sveltekit()` plugin) ignores `.html` files outside `static/` and `.jsx` files outside imports. If anyone adds an `import './design-system/...'` somewhere, the build will start picking up reference material. Do not.
|
||||
- The skill is user invocable (`SKILL.md`). External agents may write into `src/design-system/` based on user invocation. Treat the contents as agent-shaped, not human-only.
|
||||
|
||||
## See also
|
||||
|
||||
- [App shell and styling](app-shell-and-styling.md). The runtime `app.css` that this folder mirrors.
|
||||
- [Components](components.md). The Svelte versions of the JSX kits.
|
||||
163
docs/architecture-map/01-frontend/frontend-tauri-bridge.md
Normal file
163
docs/architecture-map/01-frontend/frontend-tauri-bridge.md
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: Frontend ↔ Tauri bridge
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Frontend ↔ Tauri bridge
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Frontend ↔ Tauri bridge
|
||||
|
||||
**Plain English summary.** The complete surface where the Svelte frontend talks to the Rust runtime. Two channels: synchronous request/response via `invoke()`, and asynchronous events via `listen()` / `emit()`. Every command name and every event name the frontend touches is listed here so slice 02 can verify reciprocal mentions.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Direction in (Rust → frontend):** events listed under "Tauri events listened to".
|
||||
- **Direction out (frontend → Rust):** all `invoke()` commands listed under "Tauri commands invoked".
|
||||
- **DOM-only events:** `magnotia:*` `CustomEvent`s on `window`. These never cross the Rust boundary.
|
||||
- **Cross-window event:** `magnotia:preferences-changed` is the only one that goes through `emit()`.
|
||||
|
||||
## Tauri commands invoked (alphabetical)
|
||||
|
||||
Discovered via `grep -rho 'invoke([\"\\']\\([a-zA-Z_][a-zA-Z0-9_]*\\)' src/`. Each name appears at least once in the frontend.
|
||||
|
||||
| Command | Caller(s) |
|
||||
|---|---|
|
||||
| `add_transcript` | `stores/page.svelte.ts:234` (`addToHistory`) |
|
||||
| `check_engine` | `pages/SettingsPage.svelte:826` |
|
||||
| `check_for_update` | `routes/+layout.svelte:356` |
|
||||
| `check_hotkey_access` | `routes/+layout.svelte:86` |
|
||||
| `check_llm_model` | `pages/DictationPage.svelte:241`, `pages/SettingsPage.svelte:597` |
|
||||
| `check_parakeet_engine` | `pages/SettingsPage.svelte:857` |
|
||||
| `check_parakeet_model` | `pages/SettingsPage.svelte:858` |
|
||||
| `cleanup_transcript_text_cmd` | `pages/DictationPage.svelte:472` |
|
||||
| `complete_task_cmd` | `stores/page.svelte.ts:474` |
|
||||
| `copy_to_clipboard` | DictationPage, FilesPage, HistoryPage, viewer/+page, preview/+page |
|
||||
| `delete_implementation_rule` | `stores/implementationIntentions.svelte.ts:82` |
|
||||
| `delete_llm_model` | `pages/SettingsPage.svelte:660` |
|
||||
| `delete_profile_cmd` | `stores/profiles.svelte.ts:93` |
|
||||
| `delete_profile_term_cmd` | `stores/profiles.svelte.ts:121` |
|
||||
| `delete_task_cmd` | `stores/page.svelte.ts:456` |
|
||||
| `delete_transcript` | `stores/page.svelte.ts:320`, `pages/HistoryPage.svelte:285` |
|
||||
| `deliver_nudge` | `stores/nudgeBus.svelte.ts:112` |
|
||||
| `detect_meeting_processes` | `routes/+layout.svelte:399` |
|
||||
| `detect_paste_backends` | `pages/SettingsPage.svelte:850` |
|
||||
| `download_llm_model` | `pages/SettingsPage.svelte:619` |
|
||||
| `download_model` | `pages/SettingsPage.svelte:929`, `pages/FirstRunPage.svelte:59` |
|
||||
| `download_parakeet_model` | `pages/SettingsPage.svelte:988`, `pages/FirstRunPage.svelte:73` |
|
||||
| `extract_content_tags_cmd` | `pages/HistoryPage.svelte:433, 470` |
|
||||
| `extract_tasks_from_transcript_cmd` | `pages/DictationPage.svelte:491` |
|
||||
| `generate_diagnostic_report` | `pages/SettingsPage.svelte:377` |
|
||||
| `get_llm_status` | DictationPage, SettingsPage, layout (refresh path) |
|
||||
| `get_runtime_capabilities` | `pages/DictationPage.svelte:134`, `pages/SettingsPage.svelte:543` |
|
||||
| `is_wayland_session` | `routes/+layout.svelte:82` |
|
||||
| `list_audio_devices` | `pages/SettingsPage.svelte:187` |
|
||||
| `list_models` | `routes/+layout.svelte:346`, `pages/SettingsPage.svelte:846, 930` |
|
||||
| `list_parakeet_models` | `routes/+layout.svelte:347` |
|
||||
| `load_llm_model` | `pages/DictationPage.svelte:243`, `pages/SettingsPage.svelte:634` |
|
||||
| `load_model` | `pages/DictationPage.svelte:272`, `pages/SettingsPage.svelte:944`, `pages/FirstRunPage.svelte:60` |
|
||||
| `load_parakeet_model` | `pages/DictationPage.svelte:270`, `pages/SettingsPage.svelte:1003`, `pages/FirstRunPage.svelte:74` |
|
||||
| `log_frontend_error` | `routes/+layout.svelte:282` |
|
||||
| `open_preview_window` | `pages/DictationPage.svelte:385` |
|
||||
| `open_task_window` | `pages/TasksPage.svelte:231` |
|
||||
| `open_viewer_window` | `pages/HistoryPage.svelte:392, 556` |
|
||||
| `paste_text` | `pages/DictationPage.svelte:544` |
|
||||
| `paste_text_replacing` | `routes/preview/+page.svelte:92` |
|
||||
| `prewarm_default_model_cmd` | `routes/+layout.svelte:371` |
|
||||
| `probe_system` | `pages/SettingsPage.svelte:822`, `pages/FirstRunPage.svelte:31` |
|
||||
| `rank_models` | `pages/FirstRunPage.svelte:32` |
|
||||
| `recommend_llm_tier` | `pages/SettingsPage.svelte:587` |
|
||||
| `save_diagnostic_report` | `pages/SettingsPage.svelte:405` |
|
||||
| `save_preferences` | `stores/preferences.svelte.ts:109` |
|
||||
| `start_evdev_hotkey` | `routes/+layout.svelte:129` |
|
||||
| `start_live_transcription_session` | `pages/DictationPage.svelte:344` |
|
||||
| `stop_evdev_hotkey` | `routes/+layout.svelte:424` |
|
||||
| `stop_live_transcription_session` | `pages/DictationPage.svelte:415` |
|
||||
| `test_llm_model` | `pages/SettingsPage.svelte:684` |
|
||||
| `transcribe_file` | `pages/FilesPage.svelte:76` |
|
||||
| `tts_list_voices` | `pages/SettingsPage.svelte:750` |
|
||||
| `tts_speak` | `pages/SettingsPage.svelte:769`, `stores/implementationIntentions.svelte.ts:170`, `stores/nudgeBus.svelte.ts:119` |
|
||||
| `tts_stop` | `components/SpeakerButton.svelte` (cancel current TTS) |
|
||||
| `uncomplete_task_cmd` | `stores/page.svelte.ts:491` |
|
||||
| `unload_llm_model` | `pages/SettingsPage.svelte:648` |
|
||||
| `update_evdev_hotkey` | `routes/+layout.svelte:127` |
|
||||
| `update_profile_cmd` | `stores/profiles.svelte.ts:77` |
|
||||
| `update_transcript` | `stores/page.svelte.ts:272`, `routes/viewer/+page.svelte:326` |
|
||||
|
||||
Plus any commands invoked by `saveMarkdown.ts` via the dialog/file-write plugin imports rather than direct `invoke`. (Verified the truncated `tts_s...` resolves to `tts_speak` and `tts_stop`; both are listed.)
|
||||
|
||||
## Tauri events listened to (Rust → frontend)
|
||||
|
||||
| Event | Listener |
|
||||
|---|---|
|
||||
| `magnotia:hotkey-pressed` | `routes/+layout.svelte:177` (evdev backend) |
|
||||
| `magnotia:llm-download-progress` | `pages/SettingsPage.svelte:873` |
|
||||
| `magnotia:open-wind-down` | `routes/+layout.svelte:251` (tray menu hook) |
|
||||
| `magnotia:preferences-changed` | `routes/+layout.svelte:263`, `routes/float/+layout@.svelte`, `routes/viewer/+layout@.svelte`, `routes/preview/+layout@.svelte:41` |
|
||||
| `model-download-progress` | `pages/SettingsPage.svelte:864`, `pages/FirstRunPage.svelte:46`, `components/ModelDownloader.svelte:31` |
|
||||
| `parakeet-download-progress` | `pages/SettingsPage.svelte:880`, `pages/FirstRunPage.svelte:50` |
|
||||
| `preview-cleanup` | `routes/preview/+page.svelte:141` |
|
||||
| `preview-hide` | `routes/preview/+page.svelte:161` |
|
||||
| `preview-listening` | `routes/preview/+page.svelte:113` |
|
||||
| `task-window-focus` | `routes/float/+layout@.svelte` |
|
||||
| `tauri://drag-drop` | `pages/FilesPage.svelte:28` |
|
||||
| `tauri://drag-enter` | `pages/FilesPage.svelte:34` |
|
||||
| `tauri://drag-leave` | `pages/FilesPage.svelte:35` |
|
||||
|
||||
## Tauri events emitted (frontend → Rust or other windows)
|
||||
|
||||
| Event | Emitter | Purpose |
|
||||
|---|---|---|
|
||||
| `magnotia:preferences-changed` | `stores/preferences.svelte.ts` (`broadcastPreferences`) | Cross-window preference sync. |
|
||||
| `preview-append` | `pages/DictationPage.svelte:165` | Stream partial text to overlay window. |
|
||||
| `preview-listening` | `pages/DictationPage.svelte:381` | Tell overlay to enter listening state. |
|
||||
| `preview-cleanup` | `pages/DictationPage.svelte:531` | Tell overlay to enter cleanup state. |
|
||||
| `preview-final` | `pages/DictationPage.svelte:539` | Tell overlay to render final text. |
|
||||
| `preview-hide` | `pages/DictationPage.svelte:606` | Tell overlay to dismiss. |
|
||||
|
||||
## DOM-only `magnotia:*` window events (intra-frontend bus)
|
||||
|
||||
| Event | Dispatcher | Listener(s) |
|
||||
|---|---|---|
|
||||
| `magnotia:focus-timer-cancelled` | `stores/focusTimer.svelte.ts` | `completionStats` (indirectly), components subscribed to focus timer. |
|
||||
| `magnotia:focus-timer-complete` | `stores/focusTimer.svelte.ts` | Same as above. |
|
||||
| `magnotia:implementation-rules-changed` | `stores/implementationIntentions.svelte.ts` | `ImplementationRulesEditor`. |
|
||||
| `magnotia:microstep-generated` | `stores/nudgeBus.svelte.ts` (around micro step generation) | `MicroSteps.svelte`. |
|
||||
| `magnotia:morning-triage-finished` | `MorningTriageModal.svelte` | `nudgeBus`. |
|
||||
| `magnotia:start-timer` | `WipTaskList.svelte`, `MicroSteps.svelte` | `FocusTimer.svelte` + `focusTimer` store. |
|
||||
| `magnotia:step-completed` | `MicroSteps.svelte` | `completionStats` refresh. |
|
||||
| `magnotia:task-completed` | `stores/page.svelte.ts` (`completeTask`) | `completionStats`. |
|
||||
| `magnotia:task-deleted` | `stores/page.svelte.ts` (`deleteTask`) | `completionStats`. |
|
||||
| `magnotia:task-uncompleted` | `stores/page.svelte.ts` (`uncompleteTask`) | `completionStats`. |
|
||||
| `magnotia:toggle-recording` | `routes/+layout.svelte` (after hotkey debounce, both backends) | `pages/DictationPage.svelte`. |
|
||||
|
||||
## Window APIs
|
||||
|
||||
- `getCurrentWindow().minimize()` and `.toggleMaximize()`. `Titlebar.svelte`.
|
||||
- `getCurrentWindow().label`. Used to guard hotkey registration to the main window only and to filter own-source preference echoes.
|
||||
- `convertFileSrc(absolutePath)`. HistoryPage and viewer use this for `<audio>` elements.
|
||||
|
||||
## Plugins
|
||||
|
||||
| Plugin | Used by |
|
||||
|---|---|
|
||||
| `@tauri-apps/plugin-dialog` | `utils/saveMarkdown.ts`, `pages/FilesPage.svelte:handleBrowse`. |
|
||||
| `@tauri-apps/plugin-global-shortcut` | `routes/+layout.svelte` (X11 / macOS / Windows hotkey backend). |
|
||||
| `@tauri-apps/plugin-autostart` | SettingsPage launch-at-login toggle. |
|
||||
| `@tauri-apps/plugin-notification` | Available, used by nudge bus or a related path. Confirm specific call sites. |
|
||||
| `@tauri-apps/plugin-opener` | Available, used to open external URLs. Confirm specific call sites. |
|
||||
|
||||
## Watch outs
|
||||
|
||||
- The truncated grep returned `tts_s...` as a unique prefix. Verify every TTS command name (`tts_speak`, possibly `tts_stop`) against `lib.rs` to make sure the table above is exhaustive.
|
||||
- The "preview-*" events have two flavours: `magnotia:` namespaced (`preferences-changed`) and bare (`preview-listening`, `preview-cleanup`, `preview-hide`, `preview-append`, `preview-final`). The bare names are scoped to the overlay window's two-way handshake. Do not rename.
|
||||
- `magnotia:open-wind-down` listener is in `+layout.svelte` so the page opens regardless of which window the tray click came from. If you ever isolate windows further, this assumption breaks.
|
||||
- `task-window-focus` is the only event sent specifically to the float window.
|
||||
- DOM `CustomEvent` handlers do not survive across windows (they are window-local). The tasks list refresh trick on focus relies on this.
|
||||
|
||||
## See also
|
||||
|
||||
- [Windows and routes](windows-and-routes.md). For the listener mounting points.
|
||||
- [Stores](stores.md). For the dispatch points of intra-frontend events.
|
||||
- [../02-tauri-runtime/README.md](../02-tauri-runtime/README.md). For the matching Rust handlers (slice 02 will mirror this table from the other side).
|
||||
75
docs/architecture-map/01-frontend/i18n.md
Normal file
75
docs/architecture-map/01-frontend/i18n.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: Internationalisation
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Internationalisation
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Internationalisation
|
||||
|
||||
**Plain English summary.** svelte-i18n is wired but coverage is intentionally minimal. The infrastructure (loader, persisted choice, Settings selector) is in place so strings can migrate incrementally. Three locales: English (source of truth), Spanish, German. Most strings still render hardcoded.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Path:** `src/lib/i18n/`
|
||||
- **LOC:** 73 (`index.ts`) + 19 lines per locale JSON.
|
||||
- **Key files:**
|
||||
- `src/lib/i18n/index.ts`. Setup, locale detection, public exports.
|
||||
- `src/lib/i18n/locales/en.json`. 19 lines.
|
||||
- `src/lib/i18n/locales/es.json`. 19 lines.
|
||||
- `src/lib/i18n/locales/de.json`. 19 lines.
|
||||
- **Library:** `svelte-i18n` 4.0.1.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `index.ts`
|
||||
|
||||
```ts
|
||||
import { init, register, locale as svelteLocale } from "svelte-i18n";
|
||||
import { derived, get } from "svelte/store";
|
||||
|
||||
export type Locale = "en" | "es" | "de";
|
||||
|
||||
export const SUPPORTED_LOCALES: { code: Locale; label: string }[] = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "es", label: "Español" },
|
||||
{ code: "de", label: "Deutsch" },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "magnotia_locale";
|
||||
|
||||
register("en", () => import("./locales/en.json"));
|
||||
register("es", () => import("./locales/es.json"));
|
||||
register("de", () => import("./locales/de.json"));
|
||||
|
||||
function detectInitialLocale(): Locale { /* localStorage → navigator.language → "en" */ }
|
||||
```
|
||||
|
||||
Public exports:
|
||||
- `initI18n()`. Idempotent. Called from every layout's `onMount`-ish path (`+layout.svelte:38` and the float/viewer/preview break layouts).
|
||||
- `setLocale(code)`. Writes to `magnotia_locale` and updates the svelte-i18n store.
|
||||
- `currentLocale`. A derived store that components subscribe to via `$currentLocale`.
|
||||
- `SUPPORTED_LOCALES` constant for the picker.
|
||||
|
||||
### Locale JSON shape
|
||||
|
||||
19 lines per file means roughly a dozen translated strings. Treat the locales as a stub. Most page text is still hardcoded.
|
||||
|
||||
## Where it is consumed
|
||||
|
||||
- `SettingsPage.svelte:22` imports `_` (the translation function) and `SUPPORTED_LOCALES`, `setLocale`, `currentLocale`. The locale picker UI lives in Settings.
|
||||
- A handful of strings inside SettingsPage use `$_('key')`.
|
||||
|
||||
## Watch outs
|
||||
|
||||
- Adding a new locale is one `register("xx", () => import("./locales/xx.json"))` call plus a `SUPPORTED_LOCALES` entry.
|
||||
- Cross window: each window initialises i18n independently via its layout. Locale change persists to `localStorage["magnotia_locale"]`. Float and viewer pick up the new locale on the next mount, not live. Worth wiring a Tauri event if live cross-window translation is needed.
|
||||
- The "British English" toggle in `settings.britishEnglish` is a transcription post-processing flag (slice 04 territory), not a UI locale. Not wired through svelte-i18n.
|
||||
- Default locale is detected from `navigator.language`. In Tauri, this is the OS locale.
|
||||
|
||||
## See also
|
||||
|
||||
- [Pages: settings](pages/settings.md). The locale picker UI.
|
||||
- [Stores](stores.md). The `currentLocale` derived store.
|
||||
65
docs/architecture-map/01-frontend/pages-overview.md
Normal file
65
docs/architecture-map/01-frontend/pages-overview.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: Pages overview
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Pages overview
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Pages overview
|
||||
|
||||
**Plain English summary.** Magnotia has seven main window pages and three secondary window pages. The main window picks which to show by reading `page.current` (a string in the `page` store) and rendering the matching component. The secondary windows are routed by URL (`/float`, `/viewer`, `/preview`).
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Main window pages live at** `src/lib/pages/*.svelte` (7 files, 6 232 LOC).
|
||||
- **Secondary window pages live at** `src/routes/{float,viewer,preview}/+page.svelte` (3 files, 1 361 LOC).
|
||||
- **Switch site:** `src/routes/+page.svelte:18-32`.
|
||||
- **Driver store:** `src/lib/stores/page.svelte.ts:24-33` (`page.current`).
|
||||
- Possible values of `page.current`: `"first-run" | "dictation" | "files" | "tasks" | "history" | "settings" | "shutdown"`. Plus the legacy `"profiles"` which is rewritten to `"settings"` (see README debt note 5).
|
||||
|
||||
## Map of pages
|
||||
|
||||
### Main window
|
||||
|
||||
| Page | File | LOC | One line hook |
|
||||
|---|---|---|---|
|
||||
| Dictation | `src/lib/pages/DictationPage.svelte` | 1 100 | The hero recording surface. Streams partial transcripts via a Tauri `Channel`, runs the AI cleanup pipeline, paste/copy/export, extracts tasks, fills a template. |
|
||||
| Settings | `src/lib/pages/SettingsPage.svelte` | 2 484 | The configuration panel. Audio devices, vocabulary, profiles, templates, model management (Whisper + Parakeet + LLM), hotkey, rituals, nudges, accessibility, diagnostics. |
|
||||
| History | `src/lib/pages/HistoryPage.svelte` | 974 | Browse, search, play, edit, star, tag, export saved transcripts. Backed by SQLite FTS5. |
|
||||
| Tasks | `src/lib/pages/TasksPage.svelte` | 725 | Inbox/today/soon/later board with energy chips, lists, completion sparkline. |
|
||||
| Files | `src/lib/pages/FilesPage.svelte` | 263 | Drop or browse audio/video files. Calls `transcribe_file`. |
|
||||
| First run | `src/lib/pages/FirstRunPage.svelte` | 337 | Hardware probe (`probe_system`), model recommendation, model download. Auto exits to dictation. |
|
||||
| Shutdown ritual | `src/lib/pages/ShutdownRitualPage.svelte` | 169 | Evening wind down ritual. Triggered from the tray menu via `magnotia:open-wind-down`. |
|
||||
|
||||
### Secondary windows (URL routed)
|
||||
|
||||
| Window label | URL | File | LOC | One line hook |
|
||||
|---|---|---|---|---|
|
||||
| `tasks-float` | `/float` | `src/routes/float/+page.svelte` | 481 | Detached pinned tasks window with quick add and list management. |
|
||||
| `transcript-viewer` | `/viewer` | `src/routes/viewer/+page.svelte` | 606 | Transcript editor with synced audio playback and per segment editing. |
|
||||
| `transcription-preview` | `/preview` | `src/routes/preview/+page.svelte` | 274 | Borderless overlay showing live transcription with copy and revert. |
|
||||
|
||||
## How navigation actually happens
|
||||
|
||||
- Sidebar buttons set `page.current` directly (`src/lib/Sidebar.svelte:24`).
|
||||
- Some flows reach into `page.current` from outside the sidebar:
|
||||
- Hotkey press forces dictation: `+layout.svelte:139, 181`.
|
||||
- First run gate on mount: `+layout.svelte:350`.
|
||||
- Settings page "open evening wind down" button: `SettingsPage.svelte:816`.
|
||||
- Tray menu wind down event: `+layout.svelte:251-254`.
|
||||
- First run completion paths back to dictation: `FirstRunPage.svelte:83, 139, 146, 155`.
|
||||
- Implementation intentions store can route to tasks (`implementationIntentions.svelte.ts:125, 136, 142`).
|
||||
- Shutdown ritual exit: `ShutdownRitualPage.svelte:67`.
|
||||
- TaskSidebar "open tasks page" link: `TaskSidebar.svelte:48`.
|
||||
|
||||
## Where the shell tucks pages in
|
||||
|
||||
The shell renders sidebar plus a single main slot: `<div class="flex-1 overflow-hidden bg-bg">{@render children()}</div>`. The optional task sidebar (`page.taskSidebarOpen`) can dock a `TaskSidebar` panel beside any main page that is not first run.
|
||||
|
||||
## See also
|
||||
|
||||
- [Pages: dictation](pages/dictation.md). [settings](pages/settings.md). [history](pages/history.md). [tasks](pages/tasks.md). [files](pages/files.md). [first run](pages/first-run.md).
|
||||
- [Windows and routes](windows-and-routes.md). For the route file structure and the secondary windows.
|
||||
- [Stores](stores.md). The `page` store that drives the switch.
|
||||
77
docs/architecture-map/01-frontend/pages/dictation.md
Normal file
77
docs/architecture-map/01-frontend/pages/dictation.md
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: Dictation page
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Dictation page
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Frontend](../README.md) → [Pages overview](../pages-overview.md) → Dictation
|
||||
|
||||
**Plain English summary.** This is the recording surface. Press the hotkey or the mic button, watch live partial text stream into a textarea, then on stop run the AI cleanup, extract tasks, copy to clipboard or paste into the focused application. The page handles model loading, the live transcription session, the preview overlay, optional template insertion, and task extraction.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Path:** `src/lib/pages/DictationPage.svelte`
|
||||
- **LOC:** 1 100
|
||||
- **Imports (selected):**
|
||||
- Tauri: `Channel`, `invoke` from `@tauri-apps/api/core`. `emit` from `@tauri-apps/api/event`. `getCurrentWindow` from `@tauri-apps/api/window`.
|
||||
- Stores: `page`, `settings`, `templates`, `profiles`, `addToHistory`, `addTask`, `tasks` from `page.svelte.ts`. `markGenerating`, `markGenerationDone` from `llmStatus.svelte.ts`. `profilesStore` from `profiles.svelte.ts`. `toasts`. `getPreferences` from `preferences.svelte.ts`.
|
||||
- Components: `Card`, `ModelDownloader`, `EmptyState`, `SpeakerButton`.
|
||||
- Utils: `exportTranscript`, `extractTasks`, `pad`, `FEEDBACK_TIMEOUT_MS`, `bionicReading` action, `measurePreWrap`, `transcriptPretextFont`, `transcriptPretextLineHeight`, `playStartCue`, `playStopCue`, `playCompleteCue`.
|
||||
- External: `lucide-svelte` icons (`Mic`, `Loader2`, `SquareCheck`, `AlertTriangle`).
|
||||
- **Used by:** `src/routes/+page.svelte:20` when `page.current === "dictation"`. Also forced by global hotkey and first run path.
|
||||
|
||||
## What's in here
|
||||
|
||||
### Local state (selected)
|
||||
|
||||
- `transcript` (string), `segments` (array of `{start, end, text}`).
|
||||
- `recording` is on the global `page` store; this page mirrors it.
|
||||
- `timerInterval`, `startTime`, `timerText` (mm:ss).
|
||||
- Model lifecycle flags: `modelReady`, `modelLoading`, `needsDownload`.
|
||||
- AI flags: `aiProcessing`, `aiStatus`, `extractedCount`.
|
||||
- Live session: `sessionId`, `drainingSessionId`, `lastResultAt`, `lastLiveActivityAt`, `liveWarning`.
|
||||
- Tauri channels (typed): `resultChannel`, `statusChannel` opened by `new Channel(...)`.
|
||||
- UI: `showExportMenu`, `saved`, `insertPos` (cursor-based insertion), `activeTemplate`.
|
||||
- `runtimeCapabilities` (from `get_runtime_capabilities`, used to decide engine availability and CUDA presence).
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- `onMount`. Loads `runtimeCapabilities` (`DictationPage.svelte:134`). Sets up the global hotkey custom event listener (`magnotia:toggle-recording`, dispatched from the `+layout.svelte` hotkey path).
|
||||
- `onDestroy`. Tears down listeners and timers.
|
||||
|
||||
### Recording flow (high level)
|
||||
|
||||
1. Toggle from the mic button or the `magnotia:toggle-recording` window event.
|
||||
2. If model not ready, ensure model: check `check_engine`, `check_parakeet_engine`, `check_llm_model`, then load via `load_model` / `load_parakeet_model` / `load_llm_model` as required (`DictationPage.svelte:241-272`).
|
||||
3. Open two `Channel<message>` instances (`DictationPage.svelte:341-342`) and call `start_live_transcription_session` with the channel handles.
|
||||
4. As the backend pushes status and partial result messages, append text to `transcript`, update `segments`, and broadcast `preview-append` to the preview overlay window (`DictationPage.svelte:165, 381, 385`). If the preview is enabled and not already open, `open_preview_window` is invoked.
|
||||
5. On stop, call `stop_live_transcription_session`, then run AI cleanup (`cleanup_transcript_text_cmd`) gated on `get_llm_status` (`DictationPage.svelte:464-472`).
|
||||
6. Optionally extract tasks via `extract_tasks_from_transcript_cmd` (`DictationPage.svelte:487-491`) and add them via the `addTask` store helper.
|
||||
7. Push to history with `addToHistory` (which calls `add_transcript`).
|
||||
8. Auto copy and/or auto paste based on `settings.autoCopy` / `settings.autoPaste`. Paste path uses `paste_text` (`DictationPage.svelte:544-554`); fallback to `copy_to_clipboard`.
|
||||
9. Emit `preview-cleanup`, `preview-final`, `preview-hide` to drive the overlay state machine (`DictationPage.svelte:531, 539, 606`).
|
||||
|
||||
## Tauri command surface
|
||||
|
||||
`get_runtime_capabilities`, `check_llm_model`, `load_llm_model`, `load_parakeet_model`, `load_model`, `start_live_transcription_session`, `stop_live_transcription_session`, `open_preview_window`, `get_llm_status`, `cleanup_transcript_text_cmd`, `extract_tasks_from_transcript_cmd`, `paste_text`, `copy_to_clipboard`.
|
||||
|
||||
Plus `emit("preview-append" | "preview-listening" | "preview-cleanup" | "preview-final" | "preview-hide")`.
|
||||
|
||||
## Watch outs
|
||||
|
||||
- The page uses `// @ts-nocheck`. Adding type safety here would catch a class of regressions, especially around the `Channel<T>` payload shape.
|
||||
- The cursor based insertion (`insertPos`) is fragile. Editing the textarea while a live session is running can corrupt the segment offset map. Tests around `extractTasks` only cover the post stop path.
|
||||
- Multiple paths can flip `recording` (button, hotkey custom event, error early-out). Treat the page as a state machine with a single source of truth and you will save yourself.
|
||||
- `extractedCount` is purely cosmetic. It is reset on recording start.
|
||||
- The "live activity" stalled detector is timer based (`lastLiveActivityAt`). On a stalled session the user sees `liveWarning`. The recovery path is "stop and start again".
|
||||
|
||||
## See also
|
||||
|
||||
- [Stores](../stores.md). `page`, `settings`, `llmStatus` reactivity.
|
||||
- [Frontend ↔ Tauri bridge](../frontend-tauri-bridge.md). All commands and events.
|
||||
- [Components](../components.md). `Card`, `ModelDownloader`, `SpeakerButton`, `EmptyState`.
|
||||
- [../03-audio-transcription/README.md](../../03-audio-transcription/README.md). Live session plumbing on the Rust side.
|
||||
- [../04-llm-formatting-mcp/README.md](../../04-llm-formatting-mcp/README.md). Cleanup and task extraction commands.
|
||||
63
docs/architecture-map/01-frontend/pages/files.md
Normal file
63
docs/architecture-map/01-frontend/pages/files.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: Files page
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Files page
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Frontend](../README.md) → [Pages overview](../pages-overview.md) → Files
|
||||
|
||||
**Plain English summary.** Drag and drop or browse local audio/video files for transcription. Same engine as live dictation, just file mode. On finish, results land in the page (preview text, segments) and into `addToHistory`.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Path:** `src/lib/pages/FilesPage.svelte`
|
||||
- **LOC:** 263
|
||||
- **Imports (selected):**
|
||||
- Tauri: `invoke` (core), `listen` (event).
|
||||
- Stores: `settings`, `addToHistory` from `page.svelte.ts`. `profilesStore`. `toasts` (transitive via store helpers).
|
||||
- Components: `Card`, `EmptyState`.
|
||||
- Utils: `exportTranscript`.
|
||||
- External: `Upload` icon from `lucide-svelte`. `@tauri-apps/plugin-dialog` (lazy import).
|
||||
|
||||
## What's in here
|
||||
|
||||
### Drag and drop
|
||||
|
||||
- `onMount` registers three Tauri events:
|
||||
- `tauri://drag-drop` (`FilesPage.svelte:28`). Filters the dropped paths to supported extensions and starts transcription.
|
||||
- `tauri://drag-enter` (`FilesPage.svelte:34`). Sets `isDragOver = true`.
|
||||
- `tauri://drag-leave` (`FilesPage.svelte:35`). Sets `isDragOver = false`.
|
||||
- `onDestroy` calls each unlisten.
|
||||
|
||||
### Browse
|
||||
|
||||
- `handleBrowse()` lazy imports `@tauri-apps/plugin-dialog` and opens with the audio/video filter (`mp3, wav, m4a, mp4, flac, ogg, webm, mov, mkv`).
|
||||
|
||||
### Transcription
|
||||
|
||||
- Calls `invoke("transcribe_file", { ... })` (`FilesPage.svelte:76`). Receives transcript text + segments.
|
||||
- Optional `copy_to_clipboard` on completion.
|
||||
- Pushes result into history via the store helper.
|
||||
|
||||
### State
|
||||
|
||||
- `fileTranscript`, `segments`, `isDragOver`, `progress`, `progressText`, `fileName`, `error`, `transcribing`.
|
||||
|
||||
## Tauri command surface
|
||||
|
||||
`transcribe_file`, `copy_to_clipboard`. Plus dialog plugin.
|
||||
|
||||
## Watch outs
|
||||
|
||||
- The drag and drop events require the Tauri config to declare drag drop on the relevant window. If the events do not fire, check `tauri.conf.json` and slice 02.
|
||||
- Long files block the UI of this page until `transcribe_file` resolves. There is no abort.
|
||||
- The supported extensions list is duplicated between the file dialog filter and (presumably) the Rust side. Drift risk.
|
||||
- Multi file drag drop iterates serially. No queue UI.
|
||||
|
||||
## See also
|
||||
|
||||
- [Pages: dictation](dictation.md). The live counterpart.
|
||||
- [Frontend ↔ Tauri bridge](../frontend-tauri-bridge.md).
|
||||
59
docs/architecture-map/01-frontend/pages/first-run.md
Normal file
59
docs/architecture-map/01-frontend/pages/first-run.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: First run page
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# First run page
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Frontend](../README.md) → [Pages overview](../pages-overview.md) → First run
|
||||
|
||||
**Plain English summary.** What the user sees the first time they launch Magnotia, before any model is on disk. Probes hardware, recommends a Whisper model size, and downloads the chosen model (Whisper or Parakeet). On success, transitions to the dictation page.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Path:** `src/lib/pages/FirstRunPage.svelte`
|
||||
- **LOC:** 337
|
||||
- **Imports:**
|
||||
- Tauri: `invoke`, `listen`.
|
||||
- Stores: `page`, `settings`, `saveSettings` from `page.svelte.ts`. `toasts`.
|
||||
- Components: `UnicodeSpinner`.
|
||||
- External: `lucide-svelte` (`Download`, `CheckCircle`, `Sunrise`, `Moon`, `Play`).
|
||||
- **Includes:** also `ShutdownRitualPage` is imported here (suggesting the "evening ritual" preview lives in this same flow). Verify on read; treat as a coupled flow.
|
||||
|
||||
## What's in here
|
||||
|
||||
### Probe
|
||||
|
||||
- `onMount` calls `probe_system` and `rank_models` (`FirstRunPage.svelte:31-32`) to compute recommended models given the user's RAM, GPU, OS.
|
||||
- Sets `probing = false` and renders the recommendation UI.
|
||||
|
||||
### Download
|
||||
|
||||
- `downloadAndGo(modelId)` (`FirstRunPage.svelte:55-95`):
|
||||
- Subscribes to `model-download-progress` and `parakeet-download-progress` events.
|
||||
- Calls `download_model` then `load_model` (Whisper) or `download_parakeet_model` then `load_parakeet_model`.
|
||||
- On success, sets `ready = true` and routes to dictation (`page.current = "dictation"`).
|
||||
- Computes `estimatedMinutes` from elapsed and progress percent (`derived.by`).
|
||||
|
||||
### Triggering
|
||||
|
||||
- The shell auto sets `page.current = "first-run"` if `list_models` and `list_parakeet_models` are both empty (`+layout.svelte:346-353`).
|
||||
- Skip path: clicking "Get started" without a model lets the user opt out (still routes to dictation).
|
||||
|
||||
## Tauri command surface
|
||||
|
||||
`probe_system`, `rank_models`, `download_model`, `load_model`, `download_parakeet_model`, `load_parakeet_model`. Events: `model-download-progress`, `parakeet-download-progress`.
|
||||
|
||||
## Watch outs
|
||||
|
||||
- The download is a single shot. There is no resume on cancel; if the user closes the app mid download, they restart from zero. Slice 02/05 may improve this later.
|
||||
- `estimatedMinutes` is a linear extrapolation. Network speed is not constant; expect the value to wiggle.
|
||||
- The page imports `ShutdownRitualPage` but the value of doing so should be confirmed (potential dead import).
|
||||
|
||||
## See also
|
||||
|
||||
- [Pages overview](../pages-overview.md). When this page is forced.
|
||||
- [Frontend ↔ Tauri bridge](../frontend-tauri-bridge.md). Probe and download commands.
|
||||
- [Windows and routes](../windows-and-routes.md). Shell triggers.
|
||||
71
docs/architecture-map/01-frontend/pages/history.md
Normal file
71
docs/architecture-map/01-frontend/pages/history.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: History page
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# History page
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Frontend](../README.md) → [Pages overview](../pages-overview.md) → History
|
||||
|
||||
**Plain English summary.** The transcript browser. Lists everything saved to SQLite, full text searchable via FTS5, with inline expansion to read the full body, audio playback, starring, manual + auto tagging, copy, delete, export to Markdown, and "open in viewer" handoff to the dedicated transcript editor window.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Path:** `src/lib/pages/HistoryPage.svelte`
|
||||
- **LOC:** 974
|
||||
- **Imports (selected):**
|
||||
- Tauri: `invoke` and `convertFileSrc` from `@tauri-apps/api/core`.
|
||||
- Stores: `historyStore` helpers from `page.svelte.ts` (`history`, `loadHistory`, `deleteFromHistoryById`, `renameHistoryEntry`), `toasts`, `getPreferences`.
|
||||
- Utils: `deriveAutoTags`, `buildFrontmatter`, `buildMarkdown`, `normaliseTag` (frontmatter). `saveTranscriptAsMarkdown`, `exportTranscriptsToDir` (saveMarkdown). `clampTextLines`, `measurePreWrap` (textMeasure). `bodyPretextLineHeight`, `pretextFontShorthand` (accessibilityTypography). `buildCumulativeOffsets`, `findVisibleRange` (virtualList). `formatTime`, `formatDuration` (time). `PLAYBACK_SPEEDS` (constants).
|
||||
- Components: `Card`, `EmptyState`.
|
||||
- External: `lucide-svelte` (`Search`, `Clock`, `Play`, `Pause`, `FileText`, `Mic`, `ChevronDown`, `ExternalLink`, `Star`, `Tag`).
|
||||
|
||||
## What's in here
|
||||
|
||||
### List + search
|
||||
|
||||
- Loads via `loadHistory` (which calls `list_transcripts` in Rust).
|
||||
- Search input filters client side (FTS happens server side via `search_transcripts` for some flows; check `page.svelte.ts`).
|
||||
- Virtualised scrolling using `buildCumulativeOffsets` and `findVisibleRange` from `utils/virtualList.ts`. Row heights use `measurePreWrap` to compute pre wrap heights from the live preferences font.
|
||||
- Constants: `COLLAPSED_ROW_MIN_HEIGHT`, `COLLAPSED_ROW_VERTICAL_PADDING`, `EXPANDED_BASE_HEIGHT`, `EXPANDED_PADDING`, etc.
|
||||
|
||||
### Audio playback
|
||||
|
||||
- `convertFileSrc()` maps the saved audio path to a webview URL.
|
||||
- Inline `<audio>` element. Speed control uses `PLAYBACK_SPEEDS` (`[0.5, 1, 1.5, 2, 3, 5]`).
|
||||
- A `requestAnimationFrame` loop advances `currentTime`. `cancelAnimationFrame` on stop.
|
||||
|
||||
### Per row actions
|
||||
|
||||
- Star (toggles `starred` flag, persisted via `update_transcript`).
|
||||
- Copy (`copy_to_clipboard`).
|
||||
- Delete (confirms, then calls `delete_transcript`).
|
||||
- Open in viewer window (`open_viewer_window`, then handoff via `localStorage`).
|
||||
- Auto tag (calls `extract_content_tags_cmd`, returns suggested tags merged into `tags`).
|
||||
- Rename via `renameHistoryEntry` store helper.
|
||||
- Export to markdown via `saveTranscriptAsMarkdown`.
|
||||
|
||||
### Bulk
|
||||
|
||||
- Bulk auto tag walks the visible list and calls `extract_content_tags_cmd` per row.
|
||||
- Bulk export to directory via `exportTranscriptsToDir`.
|
||||
|
||||
## Tauri command surface
|
||||
|
||||
`delete_transcript`, `copy_to_clipboard`, `open_viewer_window`, `extract_content_tags_cmd`. Plus indirect commands from store helpers (`add_transcript`, `update_transcript`, etc) and the dialog plugin used by `saveMarkdown.ts`.
|
||||
|
||||
## Watch outs
|
||||
|
||||
- Virtual scrolling assumes stable row heights once measured. Toggling preferences (font size, line height) invalidates the measure. The page recomputes on `getPreferences()` change but watch for stutter on rapid changes.
|
||||
- The viewer handoff writes the transcript ID to `localStorage`. The viewer window then loads from SQLite. Do not put transcript text in `localStorage`.
|
||||
- Auto tag is rate limited only by user clicks. Bulk over a large library can hammer the LLM. Consider a guard if scaling.
|
||||
- `convertFileSrc` paths are valid only inside the Tauri webview. If the audio path is missing or moved, `<audio>` will silently fail; show an error state.
|
||||
|
||||
## See also
|
||||
|
||||
- [Pages: dictation](dictation.md). Where transcripts originate.
|
||||
- [Stores](../stores.md). `page.svelte.ts` history helpers.
|
||||
- [Frontend ↔ Tauri bridge](../frontend-tauri-bridge.md). Commands referenced.
|
||||
- [Windows and routes](../windows-and-routes.md). The viewer window handoff.
|
||||
72
docs/architecture-map/01-frontend/pages/settings.md
Normal file
72
docs/architecture-map/01-frontend/pages/settings.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: Settings page
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Settings page
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Frontend](../README.md) → [Pages overview](../pages-overview.md) → Settings
|
||||
|
||||
**Plain English summary.** The configuration panel. Owns audio device selection, vocabulary, profiles, templates, transcription engine choice and model management (Whisper + Parakeet + LLM), the global hotkey, rituals, nudges, accessibility, text-to-speech, diagnostics, and locale selection. It is by far the largest single file in the frontend at 2 484 lines.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Path:** `src/lib/pages/SettingsPage.svelte`
|
||||
- **LOC:** 2 484
|
||||
- **Imports (selected):**
|
||||
- Stores: `settings`, `saveSettings`, `profiles`, `saveProfiles`, `templates`, `saveTemplates`, `page`, `addProfileTaskList`, `removeProfileTaskList` from `page.svelte.ts`. `getPreferences`, `updatePreferences`. `profilesStore`, `DEFAULT_PROFILE_ID`. `toasts`. `refreshLlmStatus`.
|
||||
- Components: `Card`, `Toggle`, `SegmentedButton`, `HotkeyRecorder`, `ImplementationRulesEditor`, `SettingsGroup`, `ZonePicker`, `AccessibilityControls`.
|
||||
- Utils: `clampTextLines`, `bodyPretextLineHeight`, `pretextFontShorthand`, `formatDuration`.
|
||||
- i18n: `_`, `SUPPORTED_LOCALES`, `setLocale`, `currentLocale`.
|
||||
- External: `lucide-svelte` (`Check`, `Search`, `X`, `Mic`, `BookOpen`, `Type`, `Sparkles`, `SquareCheck`, `Clipboard`, `Sliders`).
|
||||
|
||||
## Sections (top level)
|
||||
|
||||
Built from `SettingsGroup` accordions. Top level groups (line refs are anchors in the file):
|
||||
|
||||
1. **Audio** (`SettingsPage.svelte:1141`). Device list (from `list_audio_devices`), microphone selection.
|
||||
2. **Vocabulary** (`SettingsPage.svelte:1191`). Two nested groups:
|
||||
- **Terms and profiles** (`SettingsPage.svelte:1197`). Vocabulary terms attached to the active profile, plus the active profile selector.
|
||||
- **Profiles and templates** (`SettingsPage.svelte:1415`). Two further nested groups:
|
||||
- **Profiles** (`SettingsPage.svelte:1423`). Create, rename, delete; default profile cannot be deleted.
|
||||
- **Templates** (`SettingsPage.svelte:1481`). Per profile templates injected at recording start.
|
||||
3. **Processing** (also referred to in file as the engine + AI tier section). Phase 9c style group with `onopen` hook that probes `check_engine`, `check_parakeet_engine`, `list_models`, `detect_paste_backends`, etc.
|
||||
4. **Hotkey**. Wraps `HotkeyRecorder`. Search navigation jumps here when the user clicks the hotkey chip in another section (`SettingsPage.svelte:477`).
|
||||
5. **Rituals**. Morning triage, evening wind down toggles.
|
||||
6. **Nudges and implementation intentions**. Wraps `ImplementationRulesEditor`.
|
||||
7. **Accessibility**. Wraps `AccessibilityControls`. Theme, zone (`ZonePicker`), font family, sizes, line height, letter spacing, bionic reading, reduce motion.
|
||||
8. **Read aloud (TTS)** (`SettingsPage.svelte:759`). Voice selection from `tts_list_voices`, rate, sample play.
|
||||
9. **Diagnostics** (`SettingsPage.svelte:377-405`). Generates and saves a redacted diagnostic report.
|
||||
10. **Tasks** (sparkline toggle, relocated in Phase 9c).
|
||||
11. **Launch at login**. Wires `@tauri-apps/plugin-autostart`.
|
||||
12. **Locale**. svelte-i18n locale picker.
|
||||
|
||||
## Key state
|
||||
|
||||
- `audioDevices`, `downloadedModels`, `parakeetOk`, `parakeetDownloaded`, `pasteBackends`, `systemInfo`, `runtimeCapabilities`, `llmLoaded`, `llmModels`, `llmStatuses`, `ttsVoices`.
|
||||
- Search query (`X`/`Search` icons) drives a force open mode for `SettingsGroup` (`SettingsPage.svelte:432-477`).
|
||||
- Three concurrent download progress listeners: Whisper (`model-download-progress`), Parakeet (`parakeet-download-progress`), LLM (`magnotia:llm-download-progress`) (`SettingsPage.svelte:864-880`).
|
||||
|
||||
## Tauri command surface
|
||||
|
||||
Audio: `list_audio_devices`. Models (Whisper): `list_models`, `download_model`, `load_model`, `check_engine`. Models (Parakeet): `check_parakeet_engine`, `check_parakeet_model`, `download_parakeet_model`, `load_parakeet_model`. Models (LLM): `recommend_llm_tier`, `check_llm_model`, `download_llm_model`, `load_llm_model`, `unload_llm_model`, `delete_llm_model`, `test_llm_model`, `get_llm_status`. Capabilities: `get_runtime_capabilities`, `probe_system`, `detect_paste_backends`. TTS: `tts_list_voices`, `tts_speak`. Diagnostics: `generate_diagnostic_report`, `save_diagnostic_report`. Plus the implicit `save_preferences` invoked by the preferences store on every accessibility toggle.
|
||||
|
||||
Events listened to: `model-download-progress`, `parakeet-download-progress`, `magnotia:llm-download-progress`.
|
||||
|
||||
## Watch outs
|
||||
|
||||
- 2 484 lines, hand rolled accordion, Phase 9c handover already deferred the deeper restructure. New options should be added inside the existing `SettingsGroup` topology rather than threaded into the global header.
|
||||
- `settings.theme` ("Light"/"Dark"/"System") is the legacy field; `preferences.theme` is the new one. `+layout.svelte:61-68` re maps every effect cycle. Adding new theme options needs both stores.
|
||||
- `page.current = "shutdown"` button at line 816 is the only entrance from settings into the wind down ritual (other than the tray).
|
||||
- The `model-download-progress` event payload is shared across Whisper and Parakeet listeners. Take care that progress UI does not cross wires when both downloads run concurrently (rare but possible if `+layout.svelte` triggers a prewarm while Settings is open).
|
||||
- The diagnostics path strips secrets in Rust; do not re add raw paths or env values to the report payload here.
|
||||
- Accessibility writes go through `preferences` (Tauri persisted) but `settings.fontSize` writes through `localStorage`. Two persistence channels for similar surfaces.
|
||||
|
||||
## See also
|
||||
|
||||
- [Components](../components.md). `SettingsGroup`, `HotkeyRecorder`, `ImplementationRulesEditor`, `ZonePicker`, `AccessibilityControls`, `Toggle`, `SegmentedButton`.
|
||||
- [Stores](../stores.md). `page` settings + profiles + templates. `preferences`. `llmStatus`.
|
||||
- [Frontend ↔ Tauri bridge](../frontend-tauri-bridge.md). All command names referenced here.
|
||||
- [Internationalisation](../i18n.md). Locale picker.
|
||||
65
docs/architecture-map/01-frontend/pages/tasks.md
Normal file
65
docs/architecture-map/01-frontend/pages/tasks.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: Tasks page
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Tasks page
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Frontend](../README.md) → [Pages overview](../pages-overview.md) → Tasks
|
||||
|
||||
**Plain English summary.** The full screen tasks board. Inbox, today, soon, later buckets. Per task energy chips (low/medium/high), per task lists, micro steps, completion sparkline, search, sort, drag to reorder, and a "pop out" button that opens the same tasks in a small persistent floating window via `open_task_window`.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Path:** `src/lib/pages/TasksPage.svelte`
|
||||
- **LOC:** 725
|
||||
- **Imports (selected):**
|
||||
- Stores: from `page.svelte.ts`: `tasks`, `addTask`, `completeTask`, `uncompleteTask`, `deleteTask`, `updateTask`, `setTaskEnergy`, `taskLists`, `addTaskList`, `renameTaskList`, `deleteTaskList`, `settings`, `saveSettings`. From `completionStats.svelte.ts`: `recentCompletions`, `todayCount`.
|
||||
- Components: `WipTaskList`, `EmptyState`, `CompletionSparkline`, `EnergyChip`, `Card`.
|
||||
- Utils: `formatTimestamp` (time). `BUCKET_COLORS`, `EFFORT_LABELS`, `EFFORT_ORDER` (constants).
|
||||
- External: `lucide-svelte` (`SquareCheck`, `Search`, `ExternalLink`, `ChevronLeft`, `ArrowUpDown`, `Plus`, `X`, `ChevronRight`, `Zap`).
|
||||
|
||||
## What's in here
|
||||
|
||||
### Local state
|
||||
|
||||
- `activeBucket` (`"all" | "inbox" | "today" | "soon" | "later"`).
|
||||
- `activeListId` (`"all" | "inbox" | <listId>`).
|
||||
- `showCompleted`, `quickInput`, `searchQuery`, `sidebarCollapsed`.
|
||||
|
||||
### Render
|
||||
|
||||
- Quick add input (top). Sets bucket via segmented choice, energy via `EnergyChip`.
|
||||
- Filter / sort header. Sort modes use `EFFORT_ORDER` for effort comparisons.
|
||||
- Task list body uses `WipTaskList` (which renders rows with `MicroSteps` expansion).
|
||||
- "Pop out" icon → `invoke("open_task_window")` (`TasksPage.svelte:231`).
|
||||
- Sparkline panel renders `CompletionSparkline` if `settings.showMomentumSparkline` is true.
|
||||
|
||||
### Stores it depends on
|
||||
|
||||
- `tasks` (live array). `taskLists`. `recentCompletions` for the sparkline.
|
||||
|
||||
### Events
|
||||
|
||||
- Listens implicitly to: `magnotia:task-completed`, `magnotia:task-uncompleted`, `magnotia:task-deleted`, `magnotia:step-completed` are emitted from store helpers and consumed by `completionStats.svelte.ts` to refresh the sparkline.
|
||||
|
||||
## Tauri command surface (direct)
|
||||
|
||||
- `open_task_window` (page action).
|
||||
|
||||
The store helpers used here call into Rust through `add_task_cmd`, `complete_task_cmd`, `uncomplete_task_cmd`, `delete_task_cmd`, plus list management commands (`*_task_list_cmd`).
|
||||
|
||||
## Watch outs
|
||||
|
||||
- Drag and drop is heavy on this page. Reorder mutations should always go via the store helpers; do not manipulate `tasks` directly or you will desync the SQLite source of truth.
|
||||
- The sparkline is gated on `settings.showMomentumSparkline` (true by default). It re renders on the four `magnotia:task-*` events, plus window focus for date rollover.
|
||||
- "Pop out" deliberately does not pass any state. The float window reads the same store, which is shared via store hydration on mount and `localStorage` for `settings`.
|
||||
- Sort, filter, search are not persisted. Reload returns to defaults.
|
||||
|
||||
## See also
|
||||
|
||||
- [Components](../components.md). `WipTaskList`, `MicroSteps`, `EnergyChip`, `CompletionSparkline`.
|
||||
- [Stores](../stores.md). `page` task helpers, `completionStats`.
|
||||
- [Windows and routes](../windows-and-routes.md). The float window equivalent.
|
||||
130
docs/architecture-map/01-frontend/stores.md
Normal file
130
docs/architecture-map/01-frontend/stores.md
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: Stores
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Stores
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Stores
|
||||
|
||||
**Plain English summary.** Reactive state. Magnotia uses Svelte 5 runes (`$state`, `$derived`, `$effect`) instead of legacy stores. Each file under `src/lib/stores/` exports one (or more) `$state` objects plus the helper functions that read or mutate them. Components import the state directly and Svelte tracks reactivity automatically. Two persistence channels: `localStorage` for settings, profiles, task lists, templates; Tauri (`save_preferences`) for accessibility preferences.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Path:** `src/lib/stores/`
|
||||
- **Files:** 10 (4 085 LOC across stores + utils + types).
|
||||
- **Cross window sync:**
|
||||
- `localStorage` `storage` event in the float window for settings.
|
||||
- Tauri `emit("magnotia:preferences-changed")` for preferences (handled by every layout).
|
||||
- **Persistence keys:** `magnotia_settings`, `magnotia_profiles`, `magnotia_task_lists`, `magnotia_templates`, `magnotia_locale`, plus a viewer handoff key.
|
||||
|
||||
## The stores
|
||||
|
||||
### `page.svelte.ts` (697 LOC). The big one.
|
||||
|
||||
Owns:
|
||||
- `page` (`PageState`): `current`, `status`, `statusColor`, `activeProfile`, `recording`, `timerText`, `handedness`, `taskSidebarOpen`. Initial: `current = "dictation"`.
|
||||
- `settings` (`SettingsState`). Persisted to `localStorage["magnotia_settings"]` via `utils/settingsMigrations.ts`. Defaults at line 60 onward. Includes engine choice, model size, language, device, format mode, fillers, anti hallucination, British English, auto copy/paste, transcription preview, meeting auto capture (+ apps list), sound cues + volume, timestamps, theme, font size, AI tier, LLM model id and prompt preset, GPU concurrency, prewarm flag, save audio, output folder, global hotkey, sidebar collapsed, microphone device, energy state, match my energy, TTS voice + rate, rituals (morning/evening + time), launch at login, ritualsPromptSeen, nudges (enabled/muted/speakAloud), showMomentumSparkline.
|
||||
- `profiles`, `templates`, `taskLists` arrays.
|
||||
- `tasks` and `history` arrays.
|
||||
|
||||
Helpers (selected, with line refs):
|
||||
- `addToHistory` → `add_transcript` (`page.svelte.ts:234`).
|
||||
- `mapTranscriptRow`, `saveTranscriptMeta` → `update_transcript` (`page.svelte.ts:272`).
|
||||
- `deleteFromHistoryById` → `delete_transcript` (`page.svelte.ts:320`).
|
||||
- `addTask`, `completeTask`, `uncompleteTask`, `deleteTask`, `setTaskEnergy`, `updateTask` → matching `*_task_cmd` Rust commands. `completeTask` and friends emit `magnotia:task-completed | -uncompleted | -deleted` window events (line 470 onward).
|
||||
- Task lists: `addTaskList`, `renameTaskList`, `deleteTaskList`, `moveTaskList`, `sortTaskLists`, `moveTaskToList`, `moveTaskListToProfile`.
|
||||
- Profile mutators: `addProfileTaskList`, `removeProfileTaskList`.
|
||||
- `saveSettings`, `saveProfiles`, `saveTemplates` write to `localStorage`.
|
||||
|
||||
### `preferences.svelte.ts` (172 LOC).
|
||||
|
||||
Owns the `Preferences` shape: `theme` ("light" | "dark" | "system"), `zone` ("default" | ...), `accessibility` (`fontFamily`, `fontSize`, `letterSpacing`, `lineHeight`, `transcriptSize`, `bionicReading`, `reduceMotion`).
|
||||
|
||||
- DOM is the source of truth at runtime: `readFromDOM()` reads `<html>` data attributes and CSS variables; `applyToDOM(prefs)` writes them.
|
||||
- Persists via `invoke("save_preferences", { preferences: JSON.stringify(prefs) })` with a debounce. Failure shows a single toast per process (`preferences.svelte.ts:101-120`).
|
||||
- Cross window: `broadcastPreferences` calls `emit("magnotia:preferences-changed", { source, prefs })`. Receivers in `+layout.svelte` and the secondary `+layout@.svelte` files apply external prefs after a label check.
|
||||
- Exposes `getPreferences`, `updatePreferences`, `updateAccessibility`, `applyExternalPreferences`, `PREFERENCES_CHANGED_EVENT` constant.
|
||||
- Font families resolved from a constant map (Lexend, Atkinson, OpenDyslexic).
|
||||
|
||||
### `profiles.svelte.ts` (123 LOC).
|
||||
|
||||
The vocabulary profile store (separate from the `profiles` array on `page.svelte.ts`). Holds:
|
||||
- `activeProfileId`, list of profiles, vocabulary terms.
|
||||
|
||||
Tauri commands: `load_profiles_cmd` (implicit on load), `update_profile_cmd`, `delete_profile_cmd`, `delete_profile_term_cmd`, plus add term commands.
|
||||
Exposes `DEFAULT_PROFILE_ID`.
|
||||
|
||||
### `toasts.svelte.ts` (99 LOC).
|
||||
|
||||
Toast notifications. State is an array of `{id, severity, title, body, duration}`.
|
||||
|
||||
Helpers: `toasts.info(title, body?)`, `.warn`, `.error`, `.success`, plus `dismiss(id)`. Auto dismiss via `setTimeout`. Read by `ToastViewport.svelte`.
|
||||
|
||||
### `llmStatus.svelte.ts` (64 LOC).
|
||||
|
||||
Tracks the LLM lifecycle pill. State is one of `idle | loading | generating | downloading | unavailable`.
|
||||
|
||||
- `refreshLlmStatus(aiTier)` calls `get_llm_status` and updates the chip.
|
||||
- `markGenerating()`, `markGenerationDone()` are called around `cleanup_transcript_text_cmd` in `DictationPage.svelte`.
|
||||
|
||||
### `completionStats.svelte.ts` (59 LOC).
|
||||
|
||||
Phase 8 gamification. Owns `recentCompletions` (`DailyCompletionCount[]`) and a `todayCount` derivation.
|
||||
|
||||
Refresh triggers (no polling): module load, `magnotia:task-completed`, `magnotia:step-completed`, `magnotia:task-uncompleted`, `magnotia:task-deleted`, window focus (for midnight rollover).
|
||||
|
||||
### `focusTimer.svelte.ts` (238 LOC).
|
||||
|
||||
Owns the floating focus timer. State: target ms, started at, label, taskId, paused.
|
||||
|
||||
- `setInterval` at 250 ms (`TICK_INTERVAL_MS`).
|
||||
- Triggered by `magnotia:start-timer` window events. Emits `magnotia:focus-timer-cancelled` and `magnotia:focus-timer-complete` on transitions.
|
||||
|
||||
### `implementationIntentions.svelte.ts` (260 LOC).
|
||||
|
||||
"When X happens, do Y" rules engine. Owns the rules array.
|
||||
|
||||
- 30 second `setInterval` (`TIME_RULE_POLL_MS`) checks time based rules.
|
||||
- On match, can route `page.current = "tasks"` (lines 125, 136, 142) and call `tts_speak` if speak aloud is enabled (line 170).
|
||||
- Uses `delete_implementation_rule` to remove a rule (line 82).
|
||||
- Emits `magnotia:implementation-rules-changed` after writes.
|
||||
- Lifecycle: `startImplementationIntentions`, `stopImplementationIntentions` from `+layout.svelte`.
|
||||
|
||||
### `nudgeBus.svelte.ts` (292 LOC).
|
||||
|
||||
Owns the nudge engine. Two `setInterval` handles:
|
||||
- `blurCheckHandle` for window blur detection.
|
||||
- `triagePollHandle` at 5 minute cadence checking morning triage triggers.
|
||||
- Plus a `microStepTimers` Map for per task delayed notifications.
|
||||
|
||||
Key commands: `deliver_nudge` (line 112), `tts_speak` (line 119).
|
||||
Emits `magnotia:morning-triage-finished` after the modal closes.
|
||||
Lifecycle: `startNudgeBus`, `stopNudgeBus` from `+layout.svelte`.
|
||||
|
||||
### `speaker.svelte.ts` (10 LOC).
|
||||
|
||||
Tiny. Holds `speakingId` so `SpeakerButton` instances can debounce / cancel each other. Candidate for collapsing into a util (README debt note 9).
|
||||
|
||||
## Cross store traffic
|
||||
|
||||
- `DictationPage.completeRecording()` → `addToHistory` (page) → `add_transcript` Rust → push to history.
|
||||
- `MicroSteps` "start timer" button → `magnotia:start-timer` → `focusTimer` store transitions → `FocusTimer` component renders ring.
|
||||
- `TasksPage.addTask` → `tasks` store → emits `magnotia:task-completed/...` → `completionStats` refreshes → `CompletionSparkline` re renders.
|
||||
- `SettingsPage` accessibility toggle → `updatePreferences` → DOM write + `save_preferences` invoke + `magnotia:preferences-changed` emit → secondary windows mirror.
|
||||
- Float window settings sync uses `localStorage` `storage` event, not the Tauri preference event. Drift candidate.
|
||||
|
||||
## Watch outs
|
||||
|
||||
- `page.svelte.ts` is doing a lot. Splitting transcripts, tasks, task lists, settings, profiles into separate stores would help but is outside this map's job.
|
||||
- `settings` and `preferences` overlap (theme, font size, `transcriptSize`). Settings are localStorage only; preferences are Tauri persisted. Race possible during the migration `$effect`.
|
||||
- `nudgeBus` and `implementationIntentions` both run timers. They are torn down in `+layout.svelte` `onDestroy`. Only the main window starts them.
|
||||
- `completionStats` listens on the DOM `window` for events; it does not subscribe to `tasks` directly. Adding a new task mutation that does not emit one of the `magnotia:task-*` events will silently break the sparkline.
|
||||
|
||||
## See also
|
||||
|
||||
- [Components](components.md). Consumers.
|
||||
- [Frontend ↔ Tauri bridge](frontend-tauri-bridge.md). The `invoke` and `emit` calls per store.
|
||||
- [Actions, utils, types](actions-utils-types.md). `settingsMigrations`, `errors`, `storage`, `time`, `osInfo`, `runtime` helpers used by stores.
|
||||
119
docs/architecture-map/01-frontend/windows-and-routes.md
Normal file
119
docs/architecture-map/01-frontend/windows-and-routes.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: Windows and SvelteKit routes
|
||||
type: architecture-map-page
|
||||
slice: 01-frontend
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Windows and routes
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Windows and routes
|
||||
|
||||
**Plain English summary.** Magnotia is a single SvelteKit build that runs as multiple desktop windows. The Rust side opens four webviews (main, tasks float, transcript viewer, transcription preview) and points each one at a different SvelteKit route. The frontend uses SvelteKit's `+layout@.svelte` "break" trick so secondary windows skip the main shell (sidebar, titlebar, toast viewport) and render a focused, single purpose UI.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Path:** `src/routes/`
|
||||
- **LOC:** 2 740 across the route tree.
|
||||
- **Key files:**
|
||||
- `src/routes/+layout.js:5`. Disables SSR. Adapter is `adapter-static` with `index.html` fallback (`svelte.config.js:6`). The whole tree is SPA only.
|
||||
- `src/routes/+layout.svelte`. The shell. Sidebar, custom titlebar (non Linux only), toast viewport, focus timer overlay, morning triage modal, resize handles, global hotkey wiring, OS detection, profile load, first run gate, error capture.
|
||||
- `src/routes/+page.svelte`. The main window's page switch (`page.current` → 7 page modules).
|
||||
- `src/routes/float/+layout@.svelte`. "Break" layout for the tasks float window.
|
||||
- `src/routes/float/+page.svelte`. Tasks float window UI.
|
||||
- `src/routes/viewer/+layout@.svelte`. Break layout for the transcript editor window.
|
||||
- `src/routes/viewer/+page.svelte`. Transcript editor with audio playback.
|
||||
- `src/routes/preview/+layout@.svelte`. Break layout for the live transcription preview overlay.
|
||||
- `src/routes/preview/+page.svelte`. Preview state machine: listening → live → cleanup → final.
|
||||
- **Imports:**
|
||||
- Internal: every store, plus shell components (`Sidebar`, `TaskSidebar`, `Titlebar`, `ToastViewport`, `ResizeHandles`, `FocusTimer`, `MorningTriageModal`).
|
||||
- External: `@tauri-apps/api/core` (invoke, Channel, convertFileSrc), `@tauri-apps/api/event` (listen, emit), `@tauri-apps/api/window` (getCurrentWindow), `@tauri-apps/plugin-global-shortcut`, `@tauri-apps/plugin-dialog`, `lucide-svelte`.
|
||||
- **Used by:** Tauri (slice 02). Window labels are `main`, `tasks-float`, `transcript-viewer`, `transcription-preview`. Each is created with the matching route URL.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `src/routes/+layout.js` (5 LOC)
|
||||
|
||||
Single line: `export const ssr = false;`. Tauri has no Node server, so SvelteKit must run as a SPA.
|
||||
|
||||
### `src/routes/+layout.svelte` (493 LOC)
|
||||
|
||||
The shell. Mounts on every window (the secondary windows then render only `{@render children()}` and suppress the chrome).
|
||||
|
||||
Responsibilities:
|
||||
- Initialise svelte-i18n (`initI18n()` is idempotent across windows, `+layout.svelte:38`).
|
||||
- Detect Tauri runtime (`hasTauriRuntime`) and OS (`loadOsInfo`). Linux uses native KWin/Mutter decorations; macOS and Windows render the custom `Titlebar` plus invisible `ResizeHandles`. Default `useCustomChrome = false` to avoid a flash on Linux (`+layout.svelte:49`).
|
||||
- Hotkey backend selection. On Wayland, attempt the evdev backend (`check_hotkey_access`). Otherwise fall back to `tauri-plugin-global-shortcut`. Falls back to "unavailable" if neither path works (`+layout.svelte:76-102`).
|
||||
- Hotkey registration. Only the `main` window owns the global shortcut. The function debounces evdev autorepeat at 120 ms (Handy issue #1143 referenced in comments, `+layout.svelte:171-186`).
|
||||
- Cross window listeners: `magnotia:hotkey-pressed` (evdev path), `magnotia:open-wind-down` (tray menu), `magnotia:preferences-changed` (sync prefs across windows). Apply external preferences if the source label differs from our own.
|
||||
- Theme migration `$effect`. Reads `settings.theme` (legacy "Light" / "Dark" / "System") and writes `preferences.theme` ("light" / "dark" / "system"). See README debt note 2.
|
||||
- Font size CSS variable `--font-size-transcript` set on `<body>` from `settings.fontSize`.
|
||||
- Global error capture. `window.onerror` and `unhandledrejection` forward to `log_frontend_error` Rust command. Best effort, swallow throws.
|
||||
- First run check on mount. If `list_models` and `list_parakeet_models` both return empty arrays, switch `page.current` to `"first-run"`.
|
||||
- Background update check via `check_for_update`. Toast on result.
|
||||
- Pre warm default model on mount if `settings.prewarmModelOnStartup` (calls `prewarm_default_model_cmd`).
|
||||
- Meeting auto capture poller (`$effect`). When `settings.meetingAutoCapture` is true, polls `detect_meeting_processes` every 15 s with the configured patterns (default `["zoom", "teams"]`). Edge triggered (toast only on first match per session). Tear down on effect re run.
|
||||
- Sidebar collapse heuristic. `[` toggles, narrow viewport collapses automatically.
|
||||
- Render branches: `isSecondaryWindow` (URL starts with `/float` or `/viewer`) renders only children. Otherwise: optional Titlebar, sidebar (hidden on first run), main slot, optional task sidebar (when `page.taskSidebarOpen`).
|
||||
- Always rendered alongside: `<ToastViewport />`, `<FocusTimer />`, `<MorningTriageModal />`, `<ResizeHandles />` (custom chrome only).
|
||||
|
||||
### `src/routes/+page.svelte` (33 LOC)
|
||||
|
||||
Pure dispatcher. Switches the seven page modules from `page.current`:
|
||||
- `first-run` → `FirstRunPage`
|
||||
- `dictation` → `DictationPage`
|
||||
- `files` → `FilesPage`
|
||||
- `tasks` → `TasksPage`
|
||||
- `history` → `HistoryPage`
|
||||
- `settings` → `SettingsPage`
|
||||
- `shutdown` → `ShutdownRitualPage`
|
||||
|
||||
Also redirects the legacy `page.current === "profiles"` to `"settings"` via `$effect` (debt note 5).
|
||||
|
||||
### `src/routes/float/+layout@.svelte` (99 LOC)
|
||||
|
||||
The `@.svelte` suffix tells SvelteKit to skip parent layouts. Reimports `app.css`, mounts `Titlebar` (non Linux) and `FocusTimer`, applies the same theme migration `$effect`, listens on the browser `storage` event (not Tauri events) to sync `settings` from main window writes, and handles the `task-window-focus` Tauri event to glow and auto focus the quick add input.
|
||||
|
||||
### `src/routes/float/+page.svelte` (481 LOC)
|
||||
|
||||
Tasks float window. Self contained quick add, list filter, sort menu, completed toggle, list management (rename, delete, reorder, move to profile), drag and drop reordering. Reads tasks straight from the `page.svelte.ts` store helpers. No `invoke()` calls of its own; mutations go via `addTask`, `completeTask`, etc, which then call Rust.
|
||||
|
||||
### `src/routes/viewer/+layout@.svelte` (77 LOC)
|
||||
|
||||
Same break layout pattern. Mounts `Titlebar`, `ToastViewport`, applies preferences sync via `magnotia:preferences-changed`, applies theme.
|
||||
|
||||
### `src/routes/viewer/+page.svelte` (606 LOC)
|
||||
|
||||
Transcript editor. Hydrates from a transcript ID handed off via `localStorage` (`magnotia:viewer-handoff` style key, see file). Fetches the full row from SQLite via `get_transcript`. Renders an `<audio>` element using `convertFileSrc()` to map the saved audio path to a webview URL. Provides per segment editing, debounced autosave through `saveTranscriptMeta`, virtual scrolling via `VirtualSegmentList`, playback speed control (`PLAYBACK_SPEEDS`), starring and tagging.
|
||||
|
||||
### `src/routes/preview/+layout@.svelte` (68 LOC)
|
||||
|
||||
Mounts only what the overlay needs: theme, preferences sync. No sidebar, no titlebar, no toast viewport.
|
||||
|
||||
### `src/routes/preview/+page.svelte` (274 LOC)
|
||||
|
||||
Live transcription overlay. Listens for `preview-listening`, `preview-cleanup`, `preview-hide` (and reads partial text via the same `Channel` pattern as Dictation, in some flows). State machine: `listening → live → cleanup → final → auto hide`. Provides a copy button (uses `navigator.clipboard.writeText` first, falls back to `copy_to_clipboard` invoke) and a revert to raw button. Auto hide timer constants live at the top of the file.
|
||||
|
||||
## Data flow
|
||||
|
||||
- Window creation: Rust opens four webviews and routes them to `/`, `/float`, `/viewer`, `/preview`. The same JS bundle loads in each.
|
||||
- Cross window state:
|
||||
- `localStorage` carries `magnotia_settings` and the viewer handoff payload. Float window listens on the browser `storage` event to mirror settings.
|
||||
- Tauri events carry preferences (`magnotia:preferences-changed`), tray driven navigation (`magnotia:open-wind-down`), and float window focus (`task-window-focus`).
|
||||
- Hotkey: only the main window registers, but every window's layout includes the migration `$effect`. The label guard at `+layout.svelte:115-121` prevents secondary windows from re registering.
|
||||
- First run gating: only the main window evaluates and sets `page.current = "first-run"`. The float/viewer/preview windows skip the shell altogether.
|
||||
|
||||
## Watch outs
|
||||
|
||||
- The `useCustomChrome` flash is mitigated by defaulting to `false` on Linux. If you ever flip the default, expect a one frame flash of custom chrome before `loadOsInfo()` resolves.
|
||||
- The break layouts duplicate the theme migration `$effect`. Touch one, touch all. Same for `applyExternalPreferences` listeners.
|
||||
- `transcription-preview` is a borderless overlay with `WindowTypeHint = Utility`. Layout assumptions about chrome height do not apply.
|
||||
- `+layout.svelte` calls `applyExternalPreferences` only after a payload `source` check. If you ever add a fifth window, its label must be propagated correctly or the window will echo its own preference writes.
|
||||
- Drag drop on `FilesPage.svelte` listens on `tauri://drag-drop` events, which require the window to declare drag drop in `tauri.conf.json`. Slice 02 owns that surface.
|
||||
|
||||
## See also
|
||||
|
||||
- [Pages overview](pages-overview.md). What `page.current` ends up rendering.
|
||||
- [Frontend ↔ Tauri bridge](frontend-tauri-bridge.md). Every command and event referenced above.
|
||||
- [App shell and styling](app-shell-and-styling.md). The CSS plumbing that the shell relies on.
|
||||
- [../02-tauri-runtime/README.md](../02-tauri-runtime/README.md). Window creation, tray, and the matching event surface.
|
||||
70
docs/architecture-map/02-tauri-runtime/README.md
Normal file
70
docs/architecture-map/02-tauri-runtime/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: Slice 02 — Tauri runtime
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/12
|
||||
---
|
||||
|
||||
# Slice 02 — Tauri runtime
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → Tauri runtime
|
||||
|
||||
**Plain English summary.** The Tauri runtime is the bridge between Magnotia's Svelte frontend and the Rust workspace crates. It owns app startup (database init, panic hook, plugin wiring, preferences injection), the system tray, the secondary windows (task float, transcript viewer, transcription preview), and the entire `#[tauri::command]` surface that the frontend invokes for audio capture, transcription, LLM cleanup, task and transcript CRUD, paste, TTS, hotkeys, diagnostics, and more. Everything that runs on the host process but is not pure crate logic lives here.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/`
|
||||
- Total LOC (Rust + Cargo + JSON, excluding `gen/`): ~8,690.
|
||||
- Tauri version: `2` (see `src-tauri/Cargo.toml:44`).
|
||||
- Bundle identifier: `uk.co.corbel.magnotia` (`src-tauri/tauri.conf.json:5`).
|
||||
- Plugins (always-on): `tauri-plugin-opener`, `tauri-plugin-dialog`, `tauri-plugin-notification`.
|
||||
- Plugins (desktop-only, gated `cfg(not(target_os = "android"))`): `tauri-plugin-global-shortcut`, `tauri-plugin-autostart` (LaunchAgent), `tauri-plugin-window-state`. The `tray-icon` Tauri feature is also desktop-only.
|
||||
- Workspace crates pulled in: `magnotia-core`, `magnotia-audio`, `magnotia-transcription` (default-features off, `whisper` re-enabled here), `magnotia-ai-formatting`, `magnotia-storage`, `magnotia-cloud-providers`, `magnotia-hotkey`, `magnotia-llm`.
|
||||
- Tauri commands exposed via `invoke_handler` in `src-tauri/src/lib.rs:321`: 71 commands.
|
||||
- Capability files: `src-tauri/capabilities/main.json` (main window) and `src-tauri/capabilities/secondary-windows.json` (task float, transcript viewer, transcription preview).
|
||||
- Command modules: 22 `#[tauri::command]` modules plus 3 utility modules (`mod.rs`, `power.rs`, `security.rs`).
|
||||
- Integration tests: `src-tauri/tests/config_hardening.rs` (3 tests guarding CSP, updater signing, secondary-window permissions).
|
||||
|
||||
## Map of this slice
|
||||
|
||||
App boot, config, tests:
|
||||
|
||||
- [App lifecycle](app-lifecycle.md). `src-tauri/src/main.rs` and `src-tauri/src/lib.rs`. Run entry, AppState construction, plugin wiring, preferences injection, Linux launcher-env warning, panic hook, error-log pruning, command registration.
|
||||
- [System tray](system-tray.md). Desktop-only tray icon and menu (`src-tauri/src/tray.rs`).
|
||||
- [Tauri config](tauri-config.md). `tauri.conf.json` plus the Linux native-decorations overlay; CSP, window defaults, bundle settings.
|
||||
- [Capabilities and ACL](capabilities-and-acl.md). The two ACL files in `capabilities/`, what each scopes, and the Phase 9 high-risk-permission firewall.
|
||||
- [Cargo and features](cargo-and-features.md). `Cargo.toml`, the `whisper` cargo feature, the per-target dependency blocks, `build.rs`, `.cargo/config.toml`.
|
||||
- [Tests](tests.md). Config-hardening regression tests.
|
||||
|
||||
Command modules (entry index):
|
||||
|
||||
- [Commands index](commands/README.md). Every command file with a one-liner.
|
||||
|
||||
Individual command pages (linked from the commands index):
|
||||
|
||||
- [Audio capture](commands/audio.md), [Live transcription](commands/live.md), [Paste at cursor](commands/paste.md), [Models registry and runtime](commands/models.md), [Transcription](commands/transcription.md), [Local LLM](commands/llm.md), [Text to speech](commands/tts.md), [Tasks and decomposition](commands/tasks.md), [Transcripts CRUD](commands/transcripts.md), [Diagnostics and reports](commands/diagnostics.md), [Implementation intentions](commands/intentions.md), [Profiles](commands/profiles.md), [Window management](commands/windows.md), [Hotkey bridge](commands/hotkey.md), [Feedback capture](commands/feedback.md), [Power assertions and security](commands/power-and-security.md), [Small commands](commands/small-commands.md), [mod.rs registration](commands/mod.md).
|
||||
|
||||
## How this slice connects to others
|
||||
|
||||
- **Frontend (slice 01).** Every `#[tauri::command]` is invoked from Svelte via `@tauri-apps/api/core` `invoke()`. Events emitted via `app.emit(...)` are consumed via `listen()` in the frontend. Live transcription uses typed `tauri::ipc::Channel` instead of plain events; the channel pair is created on the JS side and passed in as command args.
|
||||
- **Audio + transcription (slice 03).** `commands::audio` calls `magnotia_audio::{MicrophoneCapture, WavWriter, decode_audio_file_limited, resample_to_16khz, probe_audio_duration_secs}`. `commands::transcription` and `commands::live` call `magnotia_transcription::LocalEngine` plus `magnotia_audio::StreamingResampler`. `commands::models` calls `magnotia_transcription::{model_manager, load_whisper, load_parakeet}`.
|
||||
- **LLM + formatting + MCP (slice 04).** `commands::llm` calls `magnotia_llm::{LlmEngine, model_manager, ContentTags, LlmModelId}` and `magnotia_ai_formatting::{llm_cleanup_text, LlmPromptPreset}`. `commands::tasks` calls `magnotia_llm::prompts::FeedbackExample` and the engine's `decompose_task_with_feedback` / `extract_tasks_with_feedback` / `extract_content_tags`. `commands::transcription` and `commands::live` call `magnotia_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}`. `commands::profiles` calls `magnotia_ai_formatting::extract_corrections` for auto-learned vocabulary.
|
||||
- **Core + storage + hotkey + build (slice 05).** Everything DB-touching goes through `magnotia_storage` (`init`, `database_path`, `get_setting`, `set_setting`, `prune_error_log`, the full set of CRUD helpers, `app_data_dir`, `crashes_dir`, `logs_dir`, `list_recent_errors`, `log_error`). `commands::hardware` and `commands::models` call `magnotia_core::{hardware, model_registry, recommendation, types, constants}`. `commands::meeting` calls `magnotia_core::process_watch`. `commands::hotkey` is a thin Tauri wrapper around `magnotia_hotkey::{EvdevHotkeyListener, HotkeyCombo, HotkeyEvent}`. `build.rs` is the build-system half of the slice (CSP regression guard plus ggml multi-definition link arg).
|
||||
|
||||
## Open questions / debt
|
||||
|
||||
- `commands/live.rs` is 1,737 LOC. The runtime, loop state, speech gate, dedup, and chunking logic share the file. Splitting per concern would track against the broader refactor pass already mooted in the in-repo code review (`docs/code-review-2026-04-22.md`). See [`commands/live.md`](commands/live.md) for the breakdown.
|
||||
- `commands::update::install_update` returns a hard-coded "Updates are disabled until release signing is configured." (`src-tauri/src/commands/update.rs:15`). The integration test `updater_is_signed_or_absent` (`src-tauri/tests/config_hardening.rs:35`) only asserts that *if* an updater config ships, it carries a non-empty pubkey. There is currently no updater config in `tauri.conf.json`, so the test is an empty no-op.
|
||||
- `commands::power::PowerAssertion` is a no-op on Linux and Windows (`src-tauri/src/commands/power.rs:90`). Only macOS has a real implementation via `objc2`. The doc comment promises `SetThreadExecutionState` (Windows) and logind inhibitors (Linux), but neither is wired up. Symptom: long live-dictation sessions on Linux can be idled by the compositor.
|
||||
- `src-tauri/.cargo/config.toml` hard-codes `LIBCLANG_PATH = "C:\\Program Files\\LLVM\\bin"` (Windows). It is harmless on non-Windows hosts (env var is just unused) but it is a foot-gun if someone moves Clang elsewhere on Windows. See [Cargo and features](cargo-and-features.md).
|
||||
- The Linux rendering workaround in `lib.rs` (`warn_if_x11_env_unset_on_wayland`) now warns when the launcher has not set the expected env vars; it no longer mutates the process environment. In development, `run.sh` / `npm run dev:tauri` owns the defaults (`WEBKIT_DISABLE_DMABUF_RENDERER=1` on Linux; `GDK_BACKEND=x11` and `WINIT_UNIX_BACKEND=x11` on Wayland). The user opt-out remains `WEBKIT_DISABLE_DMABUF_RENDERER=0`, set before launching.
|
||||
- `commands::diagnostics::install_panic_hook` writes a "minimal text dump" without backtraces unless the user has `RUST_BACKTRACE=1` set (`src-tauri/src/commands/diagnostics.rs:42`). The packaged binary does not set it, so production crash dumps will lack stack traces. Document or default-enable.
|
||||
- `commands::audio::list_audio_devices` is gated `ensure_main_window` but the `secondary-windows` capability does not invoke it, which is correct. The `clipboard::copy_to_clipboard` command, by contrast, has no main-window guard and any window with the `core:default` permission can call it; intentional, but worth flagging.
|
||||
|
||||
## Existing in-repo docs
|
||||
|
||||
- `docs/code-review-2026-04-22.md`. The MAJOR / MINOR review that motivated several of the safety helpers seen here (the parallel-mode loopback CSP guard in `build.rs`, secondary-window high-risk-permission test, RB-06 worker-join in `commands::audio`, RB-07 `compose_accelerators` in `commands::models`, RB-08 macOS App Nap power assertion in `commands::power`).
|
||||
- `docs/issues/`. Open issue threads, several of which the diagnostic-report bundler exists to feed.
|
||||
- `docs/whisper-ecosystem/brief.md`. Source of the numbered "brief items" cited in code comments (item #2 = loopback LLM CSP, item #9 = App Nap, items #10/#17 = paste / replace flows, item #19 = progressive WAV writer, item #28 = sequential-GPU mode).
|
||||
- `docs/handovers/`. Daily handover docs that cite specific command surfaces; useful when the in-source comments cite a "RB-NN review point".
|
||||
- `docs/dev-setup.md`. Linux + Windows + macOS toolchain setup, including the Vulkan loader install required for GPU-backed whisper.cpp.
|
||||
95
docs/architecture-map/02-tauri-runtime/app-lifecycle.md
Normal file
95
docs/architecture-map/02-tauri-runtime/app-lifecycle.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: App lifecycle
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/12
|
||||
---
|
||||
|
||||
# App lifecycle
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Tauri runtime](README.md) → App lifecycle
|
||||
|
||||
**Plain English summary.** This is the entry point. `main.rs` calls `magnotia_lib::run()` and `lib.rs::run` does everything that has to happen before the user sees a window: initialises tracing, checks the Linux launcher env-var contract, installs the Rust panic hook, registers Tauri plugins, opens the SQLite database, prunes the error log, builds a JS preferences-injection script, configures the WebKit media-permission grant on Linux, wires close-to-tray, populates `AppState` and the per-domain managed states, emits any runtime warnings, sets up the system tray, and finally registers all 71 Tauri commands.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/main.rs`, `src-tauri/src/lib.rs`.
|
||||
- LOC: 5 (main) + 491 (lib).
|
||||
- Tauri commands exposed directly here: `save_preferences` (string preferences -> SQLite settings table). All other commands live under `commands::*` and are registered via `tauri::generate_handler!`.
|
||||
- Events emitted directly here: none (runtime warnings are emitted by `commands::models::emit_runtime_warnings`, called from setup).
|
||||
- Depends on: `tauri`, `sqlx::SqlitePool`, `magnotia_core::types::EngineName`, `magnotia_llm::LlmEngine`, `magnotia_storage::{init, database_path, get_setting, set_setting, prune_error_log}`, `magnotia_transcription::LocalEngine`, plus the `commands::*` and `tray` modules.
|
||||
- Called from frontend at: every `invoke()` site in slice 01 lands in the handler list registered here.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `main.rs`
|
||||
|
||||
Single function. Sets `windows_subsystem = "windows"` for release builds (no console window) and calls `magnotia_lib::run()`. (`src-tauri/src/main.rs:1`).
|
||||
|
||||
### `lib.rs`
|
||||
|
||||
Module declarations (`src-tauri/src/lib.rs:1`):
|
||||
|
||||
- `mod commands;`
|
||||
- `#[cfg(not(target_os = "android"))] mod tray;` — tray uses Tauri's `tray-icon` feature which is desktop-only.
|
||||
|
||||
Constants:
|
||||
|
||||
- `ERROR_LOG_RETENTION_DAYS: i64 = 90` (`src-tauri/src/lib.rs:22`). Used by the startup prune.
|
||||
|
||||
Types managed in Tauri state:
|
||||
|
||||
- `AppState` (`src-tauri/src/lib.rs:26`). Holds `Arc<LocalEngine>` for whisper and parakeet, the `SqlitePool`, and an `Arc<LlmEngine>`. This is the central state that almost every command queries.
|
||||
- `PreferencesScript(pub String)` (`src-tauri/src/lib.rs:34`). Cached preferences-injection JS used when secondary windows are built (so they do not flash unstyled before Svelte mounts).
|
||||
|
||||
Functions:
|
||||
|
||||
- `build_preferences_script(prefs_json: Option<String>) -> String` (`src-tauri/src/lib.rs:38`). Builds an IIFE that reads saved preferences (theme, zone, accessibility settings: font family, font size, letter spacing, line height, transcript size, bionic reading, reduce motion) and applies them to `<html>` before the rest of the document loads. Embeds the JSON via `serde_json::to_string` to keep it safe.
|
||||
- `save_preferences(state, preferences) -> Result<(), String>` (`src-tauri/src/lib.rs:73`). The single command in `lib.rs`. Persists the preferences blob to the SQLite settings table under key `magnotia_preferences`.
|
||||
- `init_tracing()` (Linux/macOS/Windows). Initialises the process-wide tracing subscriber once, honours `RUST_LOG`, and writes structured startup/runtime logs to stderr.
|
||||
- `warn_if_x11_env_unset_on_wayland()` (Linux only). Emits a `magnotia_startup` warning when the launcher has not pre-set `WEBKIT_DISABLE_DMABUF_RENDERER` (always expected on Linux), or `GDK_BACKEND=x11` / `WINIT_UNIX_BACKEND=x11` when `XDG_SESSION_TYPE=wayland`. It does not mutate the process environment; `run.sh` owns the dev-time contract and package wrappers/.desktop files must own the distribution-time contract.
|
||||
- `run()`. The Tauri builder pipeline.
|
||||
|
||||
### `run()` step-by-step
|
||||
|
||||
1. **Tracing init.** Calls `init_tracing()` before any startup warnings/logs are emitted.
|
||||
2. **Linux launcher contract check.** Calls `warn_if_x11_env_unset_on_wayland()` on Linux. Missing env vars produce warnings only; runtime env-var mutation was removed because Rust 2024 treats environment mutation in multi-threaded programs as unsafe.
|
||||
3. **Panic hook.** Calls `commands::diagnostics::install_panic_hook()` to dump panic info to `crashes_dir()`.
|
||||
4. **Plugin wiring (always-on).** `tauri_plugin_opener`, `tauri_plugin_dialog`, `tauri_plugin_notification` (`src-tauri/src/lib.rs:144`).
|
||||
5. **Plugin wiring (desktop-only).** `tauri_plugin_global_shortcut`, `tauri_plugin_autostart` (LaunchAgent on macOS), `tauri_plugin_window_state` (`src-tauri/src/lib.rs:158`).
|
||||
6. **Setup hook.** This is where the bulk of startup work lives:
|
||||
- Initialise SQLite via `magnotia_storage::init(&database_path()).await` using `tauri::async_runtime::block_on` (`src-tauri/src/lib.rs:180`). The `Instant::now()` timing is logged.
|
||||
- Prune `error_log` rows older than 90 days (`src-tauri/src/lib.rs:189`). Best-effort: a failure logs but does not block startup.
|
||||
- Load saved preferences from the settings table; build the JS injection script (`src-tauri/src/lib.rs:204`).
|
||||
- Apply the injection script to the main window via `WebviewWindow.eval()` (`src-tauri/src/lib.rs:215`).
|
||||
- On Linux, configure `webkit2gtk` permission requests: enable `media_stream` and `media_capabilities` settings; auto-grant audio capture but deny everything else (camera, geolocation, pointer lock, etc.) (`src-tauri/src/lib.rs:222`). This is the critical piece that makes `getUserMedia` work on Linux without a permission dialog (because WebKitGTK has no dialog, it just silently denies by default).
|
||||
- Wire close-to-tray on desktop: intercept `WindowEvent::CloseRequested` and call `window.hide()` instead of letting the platform exit (`src-tauri/src/lib.rs:281`).
|
||||
- Stash the `PreferencesScript` and the per-domain managed states (`HotkeyState`, `NativeCaptureState`, `LiveTranscriptionState`, `TtsState`, `MeetingState`) (`src-tauri/src/lib.rs:294`).
|
||||
- Build the `AppState` itself: fresh `LocalEngine`s for whisper and parakeet, the open `SqlitePool`, a fresh `LlmEngine` (`src-tauri/src/lib.rs:302`).
|
||||
- Emit runtime warnings (CPU baseline, Vulkan loader) via `commands::models::emit_runtime_warnings` (`src-tauri/src/lib.rs:312`).
|
||||
- Setup the system tray on desktop (`src-tauri/src/lib.rs:314`).
|
||||
7. **Command registration.** `tauri::generate_handler![...]` lists 71 commands (`src-tauri/src/lib.rs:321`). The order in the macro is grouped by domain (preferences, models, LLM, transcription, audio, tasks, feedback, TTS, rituals, nudges, intentions, profiles, transcripts, diagnostics, live, windows, clipboard, fs, paste, meeting, hardware, hotkey, updater).
|
||||
8. **Run.** `.run(tauri::generate_context!())` blocks the main thread until the app exits. Panics are wrapped with `expect("error while running Magnotia")`.
|
||||
|
||||
## Data flow
|
||||
|
||||
- **Frontend bootstrap:** the webview is built per `tauri.conf.json` window config. Tauri's `eval()` hook fires the preferences script before the SvelteKit bundle parses, so the user never sees a flash of unstyled content.
|
||||
- **Database:** opened once, owned by `AppState`, cloned by `Arc` semantics into every `state.db` borrow.
|
||||
- **Engine state:** the two transcription `LocalEngine`s and the `LlmEngine` are shared `Arc`s; commands clone them and run inference inside `tokio::task::spawn_blocking` so the async runtime stays responsive.
|
||||
- **Per-domain state:** `HotkeyState`, `NativeCaptureState`, `LiveTranscriptionState`, `TtsState`, `MeetingState` are all stashed via `app.manage(...)` and retrieved by their command files via `tauri::State<'_, T>`.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- `tauri::async_runtime::block_on` inside `setup` blocks startup. The DB init and prefs read are explicitly timed and logged so regressions show up. Adding more synchronous async work here directly pushes the time-to-first-paint up.
|
||||
- The Linux media-permission wire-up is non-fatal: if `with_webview` fails the app still boots, but `getUserMedia` will be silently denied or fall back to a prompt the user cannot answer (no UI). The error path logs a `magnotia_startup` warning.
|
||||
- Linux rendering env vars are a launcher contract, not a runtime mutation. In development, use `npm run dev:tauri` / `./run.sh`; packaged Linux builds need an equivalent wrapper or `.desktop` `Exec=env` policy. `WEBKIT_DISABLE_DMABUF_RENDERER=0` remains the user opt-out for the DMA-BUF workaround.
|
||||
- Close-to-tray works only on desktop (the `cfg!(not(target_os = "android"))` block). On Android, closing the activity terminates the process, which is the expected platform behaviour.
|
||||
- The `prewarm_default_model` call is *not* wired here. `commands::models::prewarm_default_model` exists, but `setup` does not invoke it. The frontend invokes the matching `prewarm_default_model_cmd` command after the main page mounts. If you ever want to shift pre-warm into setup, watch the spawn_blocking ordering against the engine `Arc` clones.
|
||||
|
||||
## See also
|
||||
|
||||
- [Commands index](commands/README.md) — every command registered by `lib.rs::run`.
|
||||
- [System tray](system-tray.md) — what `tray::setup(app)` builds.
|
||||
- [Tauri config](tauri-config.md) — the window config that drives the `get_webview_window("main")` retrieval.
|
||||
- [Capabilities and ACL](capabilities-and-acl.md) — the permission set that decides which commands each window can call.
|
||||
- [Cargo and features](cargo-and-features.md) — the dependency block that determines which plugins compile in.
|
||||
127
docs/architecture-map/02-tauri-runtime/capabilities-and-acl.md
Normal file
127
docs/architecture-map/02-tauri-runtime/capabilities-and-acl.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: Capabilities and ACL
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Capabilities and ACL
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Tauri runtime](README.md) → Capabilities and ACL
|
||||
|
||||
**Plain English summary.** Tauri 2 ships a permission-and-capability ACL: every plugin and core API ships permissions, capabilities bind permissions to specific window labels. Magnotia ships two capability files. The main window gets the broad set (dialog, opener, autostart, global shortcuts, notifications, full window control). The three secondary windows (task float, transcript viewer, transcription preview) get a narrow set with no plugin permissions at all and no destructive window APIs. The split is the firewall that prevents a compromised secondary window from launching an installer or registering a global hotkey.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/capabilities/main.json`, `src-tauri/capabilities/secondary-windows.json`.
|
||||
- Auto-generated schemas: `src-tauri/gen/schemas/` (`desktop-schema.json`, `linux-schema.json`, `macOS-schema.json`, `windows-schema.json`, `acl-manifests.json`, plus per-plugin globals). Do not edit by hand — they regenerate from the installed plugin set.
|
||||
- Tauri commands exposed: none (declarative config).
|
||||
- Events emitted: none.
|
||||
- Depends on: the runtime plugin set declared in `src-tauri/Cargo.toml` and wired in `src-tauri/src/lib.rs::run`. If a capability references a permission whose plugin is not enabled, the build fails.
|
||||
- Called from frontend at: every `invoke()` is run through this ACL — the capability for the calling window must include the permission for the command being called, otherwise Tauri rejects the IPC call before it reaches the Rust handler.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `main.json` (`src-tauri/capabilities/main.json:1`)
|
||||
|
||||
```
|
||||
identifier: "main"
|
||||
description: "Main window capability for user-initiated app control."
|
||||
windows: ["main"]
|
||||
permissions:
|
||||
- core:default
|
||||
- core:window:allow-start-dragging
|
||||
- core:window:allow-set-always-on-top
|
||||
- core:window:allow-minimize
|
||||
- core:window:allow-toggle-maximize
|
||||
- core:window:allow-is-maximized
|
||||
- core:window:allow-close
|
||||
- core:window:allow-hide
|
||||
- core:window:allow-show
|
||||
- core:window:allow-set-focus
|
||||
- opener:default
|
||||
- dialog:default
|
||||
- global-shortcut:allow-register
|
||||
- global-shortcut:allow-unregister
|
||||
- autostart:allow-enable
|
||||
- autostart:allow-disable
|
||||
- autostart:allow-is-enabled
|
||||
- notification:allow-is-permission-granted
|
||||
- notification:allow-request-permission
|
||||
- notification:allow-notify
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `core:default` brings in the entire `core:event` set, allowing the main window to listen and emit on all event channels.
|
||||
- The window-control set is granular: there is no `core:window:default`, every action is opted in. `allow-start-dragging` is what the custom Titlebar uses to drag a frameless window.
|
||||
- `opener:default` lets the frontend open external URLs via `tauri-plugin-opener`. This is how the Settings → About links route out.
|
||||
- `dialog:default` enables the file/save dialogs used by the import-audio and save-diagnostic-report flows.
|
||||
- `global-shortcut:allow-register` and `allow-unregister` let the main window manage the dictation hotkey via the cross-platform plugin path. On Linux Magnotia uses the bespoke evdev backend (see `commands::hotkey`), but Settings still talks to the global-shortcut plugin so the same UI works on macOS / Windows.
|
||||
- `autostart:*` lets Settings toggle login-time autostart.
|
||||
- `notification:*` is what the Phase 6 nudge bus uses; the Rust-side `commands::nudges::deliver_nudge` adds the main-window-only firewall.
|
||||
|
||||
### `secondary-windows.json` (`src-tauri/capabilities/secondary-windows.json:1`)
|
||||
|
||||
```
|
||||
identifier: "secondary-windows"
|
||||
description: "Narrow capability for passive secondary windows."
|
||||
windows: ["tasks-float", "transcript-viewer", "transcription-preview"]
|
||||
permissions:
|
||||
- core:event:default
|
||||
- core:window:allow-start-dragging
|
||||
- core:window:allow-close
|
||||
- core:window:allow-hide
|
||||
- core:window:allow-show
|
||||
- core:window:allow-set-focus
|
||||
- core:window:allow-set-always-on-top
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- No `core:default`. Only `core:event:default`. Secondary windows can listen to events emitted by the backend or other windows but can't reach the broader core surface (e.g. shell, fs).
|
||||
- No plugin permissions at all. No `dialog`, no `opener`, no `global-shortcut`, no `autostart`, no `notification`, no `updater`. This is enforced as a regression test (see below).
|
||||
- The window-control set is the bare minimum to drag (titleless overlay), close, hide, show, focus, and pin always-on-top. No maximise, no minimise, no toggle-fullscreen.
|
||||
|
||||
### Regression test
|
||||
|
||||
`src-tauri/tests/config_hardening.rs::secondary_windows_do_not_get_high_risk_plugin_permissions` (`src-tauri/tests/config_hardening.rs:50`) walks every JSON file in `capabilities/`, finds the ones whose `windows` array contains any of the three secondary labels, and asserts none of their permissions start with `dialog:`, `autostart:`, `global-shortcut:`, `opener:`, or `updater:`. The test fails the moment someone adds, for example, `dialog:default` to the secondary-windows file.
|
||||
|
||||
### `gen/schemas/`
|
||||
|
||||
Auto-generated by `tauri-build` from the plugin set. Files:
|
||||
|
||||
```
|
||||
acl-manifests.json
|
||||
capabilities.json
|
||||
desktop-schema.json
|
||||
linux-schema.json
|
||||
macOS-schema.json
|
||||
windows-schema.json
|
||||
plugin-global-scope-schema.json
|
||||
plugin-default-permissions.json
|
||||
```
|
||||
|
||||
These are committed to source control so a fresh checkout has a working ACL even before the first build. Do not edit by hand. If you add a plugin, `cargo tauri build` regenerates them. If a capability file references a permission absent from these schemas, the IDE's JSON-schema validation will flag it before the build even runs.
|
||||
|
||||
## Data flow
|
||||
|
||||
The ACL is enforced in two places:
|
||||
|
||||
1. **Webview IPC layer.** Every `invoke()` call from a window is checked against the capability bound to that window's label. A permission miss returns an IPC error before the Rust handler runs.
|
||||
2. **Tauri-side guards.** Several command handlers add their own `ensure_main_window` check on top of the ACL (see [Power assertions and security](commands/power-and-security.md)). This is defence in depth: even if the ACL ever drifted, a secondary window calling, say, `delete_implementation_rule` would still be rejected by the Rust guard.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- Adding a permission to either capability is a security decision. The "secondary windows" set in particular has been pruned deliberately — the in-repo tests and the brief item #B.2 review trace the path that closed each gap.
|
||||
- The Phase 9 LLM-content-tags command (`commands::llm::extract_content_tags_cmd`) does NOT check the capability; the History page lives in the main window so the main capability already gates it. If you ever expose this command to a secondary window, add an `ensure_main_window` guard and an explicit ACL allowlist entry.
|
||||
- `notification:allow-notify` is on the main capability. Secondary windows cannot fire notifications. The frontend's nudge bus already uses the main-window guard via `commands::nudges::deliver_nudge`, but if you ever invoke the plugin's JS-side `sendNotification` directly from a secondary window, it will fail with an ACL error.
|
||||
- The schema files in `gen/schemas/` are updated when the dependency graph changes. If you bump a plugin version, expect a diff there too. Commit them in the same PR as the dependency bump.
|
||||
|
||||
## See also
|
||||
|
||||
- [App lifecycle](app-lifecycle.md) — plugins are wired in `lib.rs::run`, gated by the same `cfg(not(target_os = "android"))` block that gates the desktop-only permissions.
|
||||
- [Cargo and features](cargo-and-features.md) — the dependency graph that `gen/schemas/` is generated from.
|
||||
- [Tauri config](tauri-config.md) — the CSP works alongside the ACL: CSP guards the webview, ACL guards the IPC.
|
||||
- [Tests](tests.md) — `secondary_windows_do_not_get_high_risk_plugin_permissions` is the regression net.
|
||||
- [Power assertions and security](commands/power-and-security.md) — `ensure_main_window` is the defence-in-depth pair.
|
||||
152
docs/architecture-map/02-tauri-runtime/cargo-and-features.md
Normal file
152
docs/architecture-map/02-tauri-runtime/cargo-and-features.md
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: Cargo and features
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Cargo and features
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Tauri runtime](README.md) → Cargo and features
|
||||
|
||||
**Plain English summary.** This page walks the Tauri crate's `Cargo.toml`, its `build.rs`, and its `.cargo/config.toml`. The crate is the binary entry for the desktop app and the library entry for the Android target. The `whisper` cargo feature is the kill-switch for the whisper.cpp backend. Per-target dependency blocks gate macOS objc2 bindings, Linux GTK / WebKitGTK, and Windows base64 (used by the TTS PowerShell shim). `build.rs` enforces the CSP regression guard documented in `docs/whisper-ecosystem/brief.md` item #2.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/Cargo.toml` (108 LOC), `src-tauri/build.rs` (81 LOC), `src-tauri/.cargo/config.toml` (3 LOC).
|
||||
- Tauri commands exposed: none. Build-system files only.
|
||||
- Events emitted: none.
|
||||
- Depends on: every workspace crate (slices 03–05) plus the Tauri ecosystem.
|
||||
- Called from frontend at: nothing direct. The build outputs land in the dev / packaged binary.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `Cargo.toml`
|
||||
|
||||
Package metadata: `name = "magnotia"`, `version = "0.1.0"`, `description = "Magnotia — Think out loud"`, `authors = ["CORBEL Ltd"]`, `edition = "2021"`.
|
||||
|
||||
Lib stanza (`src-tauri/Cargo.toml:8`):
|
||||
|
||||
```
|
||||
name = "magnotia_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
```
|
||||
|
||||
`staticlib` and `cdylib` are needed for the Android target where Tauri builds an `.aar` from a `cdylib`. `rlib` makes it consumable from `main.rs`.
|
||||
|
||||
#### `[features]`
|
||||
|
||||
```
|
||||
default = ["whisper"]
|
||||
whisper = ["magnotia-transcription/whisper"]
|
||||
```
|
||||
|
||||
The `whisper` feature transitively enables `magnotia-transcription/whisper`. The crate-level `default-features = false` on `magnotia-transcription` (`src-tauri/Cargo.toml:33`) means a `--no-default-features` workspace build drops `whisper-rs-sys` entirely; Parakeet still works. `commands::models::load_model_from_disk` returns a clear runtime error for `Engine::Whisper` when the feature is off.
|
||||
|
||||
#### `[build-dependencies]`
|
||||
|
||||
`tauri-build = "2"`, plus `serde_json = "1"` for the CSP regression guard in `build.rs`.
|
||||
|
||||
#### `[dependencies]` — workspace crates
|
||||
|
||||
```
|
||||
magnotia-core
|
||||
magnotia-audio
|
||||
magnotia-transcription { default-features = false }
|
||||
magnotia-ai-formatting
|
||||
magnotia-storage
|
||||
magnotia-cloud-providers
|
||||
magnotia-hotkey
|
||||
magnotia-llm
|
||||
```
|
||||
|
||||
The `cloud-providers` crate is referenced here even though no command file currently imports it. Likely reserved for the Phase-N upload flow that the diagnostic-report bundler hints at.
|
||||
|
||||
#### `[dependencies]` — Tauri
|
||||
|
||||
```
|
||||
tauri = { version = "2" }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
```
|
||||
|
||||
These three plugins are unconditional. `notification` supports Android natively (Phase 6 nudges) so it stays out of the desktop-only block.
|
||||
|
||||
#### `[dependencies]` — runtime
|
||||
|
||||
```
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
arboard = "3.6.1"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
```
|
||||
|
||||
The `sqlx` block has `default-features = false` and only opts into `runtime-tokio` and `sqlite`. `magnotia-storage` already pulls the macros / migrate / any / json features via its own re-export, so duplicating them here would just bloat compile time. `sqlx` is named directly because `AppState` types it as `SqlitePool`; naming a transitive dep type still requires the dep be listed.
|
||||
|
||||
#### `[dev-dependencies]`
|
||||
|
||||
`tempfile = "3"` for the Phase 9 `commands::fs::write_text_file_cmd` tests.
|
||||
|
||||
#### `[target.'cfg(not(target_os = "android"))'.dependencies]`
|
||||
|
||||
```
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
```
|
||||
|
||||
Each is justified inline in `src-tauri/Cargo.toml:74`. Tray icons, global shortcuts, multi-window state, and autostart all either fail to compile against the Android NDK or are structurally meaningless on a single-window mobile activity. The Cargo gate matches the `#[cfg(not(target_os = "android"))]` blocks in `lib.rs` and `commands/windows.rs`.
|
||||
|
||||
#### Per-OS dependency blocks
|
||||
|
||||
```
|
||||
linux: webkit2gtk = "2.0", gtk = "0.18", gdk = "0.18"
|
||||
macos: objc2 = "0.6.4", objc2-foundation = { ... features = ["std", "NSString", "NSProcessInfo"] }
|
||||
windows: base64 = "0.22"
|
||||
```
|
||||
|
||||
- `webkit2gtk`/`gtk`/`gdk` are used in `lib.rs` (auto-grant audio capture) and `commands/windows.rs` (set GTK `WindowTypeHint::Utility` on the preview overlay). Versions track what `webkit2gtk` 2.0 transitively depends on.
|
||||
- `objc2` + `objc2-foundation` power the macOS App Nap power assertion (`commands/power.rs::objc_bridge`).
|
||||
- `base64` is the Windows TTS encoder. PowerShell `-EncodedCommand` requires UTF-16-LE base64.
|
||||
|
||||
### `build.rs`
|
||||
|
||||
Two responsibilities:
|
||||
|
||||
1. **Linker workaround.** `--allow-multiple-definition` is passed to GNU ld / lld on Linux because `llama-cpp-sys-2` and `whisper-rs-sys` both statically link their own copy of `ggml`, leading to duplicate symbols (`src-tauri/build.rs:8`). Documented as INTERIM; the intended fix is system-ggml shared-lib linking. Only emitted on Linux.
|
||||
2. **CSP regression guard.** `assert_loopback_llm_csp` (`src-tauri/build.rs:30`) parses `tauri.conf.json` with `serde_json`, finds the `connect-src` directive (full-name match, not prefix), and asserts the four required tokens: `http://127.0.0.1:*` and `ws://127.0.0.1:*` must be present, `http://localhost:*` and `ws://localhost:*` must be absent. Fails compilation with a brief-item-#2 reference if any of those break. Re-runs when `tauri.conf.json` changes.
|
||||
|
||||
`tauri_build::build()` is called last. Order matters: the CSP guard fails fast, before tauri-build does any of its own work.
|
||||
|
||||
### `.cargo/config.toml`
|
||||
|
||||
```
|
||||
[env]
|
||||
LIBCLANG_PATH = "C:\\Program Files\\LLVM\\bin"
|
||||
```
|
||||
|
||||
Hard-coded Windows path so `bindgen` (used by Tauri 2's macros and a couple of dependencies) can find clang during a Windows build. Harmless on non-Windows hosts — the env var is just unused. Foot-gun: a Windows developer with Clang in a non-default path will have to override or remove this.
|
||||
|
||||
## Data flow
|
||||
|
||||
- `cargo build` reads `Cargo.toml`, runs `build.rs`, then compiles `src/lib.rs` and `src/main.rs`. The CSP regression guard fires before any source compiles.
|
||||
- The default features include `whisper`. A workspace-wide `cargo build --no-default-features` drops both whisper.cpp and the Tauri default features (which would also drop tray-icon, etc., so this is a niche build).
|
||||
- Plugin crates land in the dependency graph and contribute permission manifests that `gen/schemas/` is regenerated from at build time.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- Adding a workspace crate dependency here also requires that crate to be in the root `Cargo.toml`'s workspace members (or referenced by `path =` here, which is what is done now). All eight workspace crates appear in this `Cargo.toml`.
|
||||
- The `--allow-multiple-definition` link arg is the kind of thing that hides bugs. If both ggml copies ever drift, the binary picks "the first definition" and the second copy's slightly-different symbol is silently shadowed. Track the `whisper-rs-sys` and `llama-cpp-sys-2` ggml pins together.
|
||||
- Bumping any of `objc2`, `objc2-foundation`, `webkit2gtk`, `gtk`, `gdk` is an ABI move. Test on each platform.
|
||||
- The hard-coded `LIBCLANG_PATH` should ideally come from an environment variable or a build-script probe. Until then, document it in `docs/dev-setup.md` for Windows contributors.
|
||||
|
||||
## See also
|
||||
|
||||
- [App lifecycle](app-lifecycle.md) — `lib.rs::run` is what consumes everything declared here.
|
||||
- [Tauri config](tauri-config.md) — `build.rs` reads `tauri.conf.json` and pins the CSP shape.
|
||||
- [Tests](tests.md) — runtime regression for the same CSP property.
|
||||
- [Capabilities and ACL](capabilities-and-acl.md) — `gen/schemas/` files are regenerated when a plugin in this Cargo manifest changes.
|
||||
68
docs/architecture-map/02-tauri-runtime/commands/README.md
Normal file
68
docs/architecture-map/02-tauri-runtime/commands/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: Commands index
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Commands index
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → Commands
|
||||
|
||||
**Plain English summary.** This is the registry of every Tauri command file. Each `.rs` file under `src-tauri/src/commands/` either declares one or more `#[tauri::command]` functions invoked from the frontend, or is a utility (state, security, power) that the command files share. The full list of commands actually exposed to the frontend is registered via the `tauri::generate_handler!` macro in `src-tauri/src/lib.rs:321`.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/`.
|
||||
- 22 command modules (declare `#[tauri::command]`s) plus 3 utility modules (`mod.rs`, `power.rs`, `security.rs`).
|
||||
- 71 `#[tauri::command]` functions registered in `lib.rs`. A handful of additional `#[tauri::command]` functions exist in source but are NOT registered (notably the small `clipboard::copy_to_clipboard` IS registered; nothing meaningful is unregistered as of last verification).
|
||||
- Total LOC across `commands/`: 6,939 (Rust source, excluding tests folder).
|
||||
|
||||
## Module list
|
||||
|
||||
| Module | LOC | Registered commands | One-liner |
|
||||
|---|---|---|---|
|
||||
| [audio](audio.md) | 540 | 4 | Native cpal mic capture, list devices, save WAV |
|
||||
| [clipboard](small-commands.md#clipboardrs) | 11 | 1 | Copy text to system clipboard via arboard |
|
||||
| [diagnostics](diagnostics.md) | 534 | 6 | Panic hook, frontend error log, crash files, diagnostic-report bundler, OS info |
|
||||
| [feedback](feedback.md) | 110 | 2 | HITL feedback capture and retrieval (Phase 2) |
|
||||
| [fs](small-commands.md#fsrs) | 44 | 1 | Write UTF-8 text to a user-chosen path (Phase 9) |
|
||||
| [hardware](small-commands.md#hardwarers) | 69 | 2 | Probe RAM/CPU/GPU, rank models for current hardware |
|
||||
| [hotkey](hotkey.md) | 105 | 5 | evdev hotkey listener bridge (Linux Wayland-compatible) |
|
||||
| [intentions](intentions.md) | 308 | 5 | Phase 7 implementation-intention rules CRUD |
|
||||
| [live](live.md) | 1,737 | 2 | Live transcription session orchestration (the big one) |
|
||||
| [llm](llm.md) | 423 | 10 | LLM model lifecycle, transcript cleanup, content tags |
|
||||
| [meeting](small-commands.md#meetingrs) | 50 | 1 | Process-list poll for known meeting apps (Phase 8) |
|
||||
| [models](models.md) | 691 | 13 | Whisper / Parakeet model registry, download, load, runtime capabilities |
|
||||
| [mod](mod.md) | 114 | n/a | `build_initial_prompt` helper + module re-exports |
|
||||
| [nudges](small-commands.md#nudgesrs) | 63 | 1 | Phase 6 nudge delivery via tauri-plugin-notification |
|
||||
| [paste](paste.md) | 790 | 3 | Auto-insert at cursor across Linux/macOS/Windows |
|
||||
| [power](power-and-security.md#powerrs) | 208 | 0 | macOS App Nap power assertion (no commands) |
|
||||
| [profiles](profiles.md) | 185 | 9 | Profile + profile-term CRUD, auto-learn from edits |
|
||||
| [rituals](small-commands.md#ritualsrs) | 43 | 2 | Morning triage last-shown sentinel (Phase 5) |
|
||||
| [security](power-and-security.md#securityrs) | 30 | 0 | `ensure_main_window` defence-in-depth helper |
|
||||
| [tasks](tasks.md) | 402 | 11 | Task CRUD, decompose-and-store, extract-from-transcript |
|
||||
| [transcription](transcription.md) | 413 | 3 | Transcribe PCM (Whisper / Parakeet), transcribe file |
|
||||
| [transcripts](transcripts.md) | 253 | 8 | Transcripts CRUD + FTS5 search + meta patches |
|
||||
| [tts](tts.md) | 444 | 3 | Read aloud via spd-say / say / PowerShell SAPI (Phase 4) |
|
||||
| [update](small-commands.md#updaters) | 16 | 2 | Updater placeholder (returns "disabled" until signing wired) |
|
||||
| [windows](windows.md) | 197 | 4 | Open / close the secondary windows (preview, viewer, task float) |
|
||||
|
||||
## Per-command page assignments
|
||||
|
||||
- Big files have their own page: [audio](audio.md), [live](live.md), [paste](paste.md), [models](models.md), [diagnostics](diagnostics.md), [llm](llm.md), [tts](tts.md), [transcription](transcription.md), [tasks](tasks.md), [transcripts](transcripts.md), [intentions](intentions.md), [profiles](profiles.md), [windows](windows.md), [hotkey](hotkey.md), [feedback](feedback.md).
|
||||
- Utilities collapsed into one page: [Power assertions and security](power-and-security.md) covers `power.rs` and `security.rs`.
|
||||
- Small modules grouped: [Small commands](small-commands.md) covers `clipboard.rs`, `fs.rs`, `hardware.rs`, `meeting.rs`, `nudges.rs`, `rituals.rs`, `update.rs`.
|
||||
- Module entry: [mod.md](mod.md) covers `mod.rs` (re-exports + `build_initial_prompt` helper).
|
||||
|
||||
## How to navigate
|
||||
|
||||
- Looking for a frontend `invoke('foo', ...)` call? Search for `pub async fn foo` or `pub fn foo` under `commands/`. The page for that file lists all its commands with arg + return signatures.
|
||||
- Looking for an event the frontend listens for? See the slice README's "events emitted" section, or grep for the event name across `commands/`.
|
||||
- Looking for ACL gotchas? Pages flag the commands that include an `ensure_main_window` guard.
|
||||
|
||||
## See also
|
||||
|
||||
- [Slice README](../README.md) — back to the top of slice 02.
|
||||
- [App lifecycle](../app-lifecycle.md) — `lib.rs::run` is what registers everything listed here.
|
||||
- [Capabilities and ACL](../capabilities-and-acl.md) — the permission set that gates every invocation.
|
||||
103
docs/architecture-map/02-tauri-runtime/commands/audio.md
Normal file
103
docs/architecture-map/02-tauri-runtime/commands/audio.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: Audio capture commands
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::audio`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Audio
|
||||
|
||||
**Plain English summary.** Owns the native cpal microphone capture path used outside live transcription. Exposes "list input devices", "start native capture", "stop native capture", and "save audio to WAV". Streams 16 kHz mono PCM chunks to the frontend via the `native-pcm` event. Also owns the deterministic recording-filename generator and the helper that resolves a destination WAV path so the live-transcription module can share it.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/audio.rs`.
|
||||
- LOC: 540.
|
||||
- Tauri commands exposed:
|
||||
- `list_audio_devices(window: WebviewWindow) -> Result<Vec<DeviceInfo>, String>` — main-window only. Wraps `MicrophoneCapture::list_devices` in `spawn_blocking`.
|
||||
- `start_native_capture(window, app, state, device_name: Option<String>) -> Result<(), String>` — main-window only. Starts a cpal stream, downsamples to 16 kHz mono, streams chunks via `native-pcm` events, also writes to a temp WAV.
|
||||
- `stop_native_capture(window, state) -> Result<Vec<f32>, String>` — main-window only. Awaits the worker join barrier (RB-06) and returns the accumulated samples.
|
||||
- `save_audio(window, app, samples, output_folder: Option<String>) -> Result<String, String>` — main-window only. Writes a fresh WAV to `app_local_data_dir/recordings/` (or a user-chosen folder) and returns its absolute path.
|
||||
- Events emitted: `native-pcm` (payload `{ samples: Vec<f32> }`, ~0.5 s of 16 kHz mono per emit) — `src-tauri/src/commands/audio.rs:249` and `:274`.
|
||||
- Depends on: `magnotia_audio::{DeviceInfo, MicrophoneCapture, WavWriter, write_wav}`, `magnotia_core::types::AudioSamples`, `magnotia_core::constants::WHISPER_SAMPLE_RATE`. Plus `tokio::{mpsc, Mutex, JoinHandle, spawn_blocking, sleep}` and `std::sync::{Arc, Mutex, atomic::AtomicBool}`.
|
||||
- Called from frontend at: dictation page (start / stop), Settings → Audio (device list), file-import flow (save_audio).
|
||||
|
||||
## What's in here
|
||||
|
||||
### Constants
|
||||
|
||||
- `MAX_NATIVE_CAPTURE_RETURN_SAMPLES = WHISPER_SAMPLE_RATE * 60 * 10` (`src-tauri/src/commands/audio.rs:14`). 10 minutes of 16 kHz mono. Caps the in-memory return buffer; the temp WAV on disk stays authoritative beyond this.
|
||||
|
||||
### `CaptureWorker` (`src-tauri/src/commands/audio.rs:34`)
|
||||
|
||||
Internal struct holding `stop_tx: tokio_mpsc::Sender<()>` and `join: JoinHandle<()>`. `stop_worker(worker).await` (`src-tauri/src/commands/audio.rs:44`) sends the stop signal and awaits the join. Consumes the worker because `stop_tx` and `join` are single-use. Errors from the join are logged and swallowed.
|
||||
|
||||
### `NativeCaptureState` (`src-tauri/src/commands/audio.rs:53`)
|
||||
|
||||
Tauri-managed state. Fields:
|
||||
|
||||
- `worker: AsyncMutex<Option<CaptureWorker>>` — `tokio::sync::Mutex` because `stop_worker` awaits while holding the lock.
|
||||
- `all_samples: Arc<Mutex<Vec<f32>>>` — capped compatibility buffer returned by `stop_native_capture`.
|
||||
- `wav_writer: Arc<Mutex<Option<WavWriter>>>` — temp WAV writer, finalised when the worker exits.
|
||||
- `temp_audio_path: Arc<Mutex<Option<PathBuf>>>` — destination for the temp WAV.
|
||||
- `capture_truncated: Arc<AtomicBool>` — set when `all_samples` was capped (the temp WAV still has the full capture).
|
||||
|
||||
### `append_recorded_chunk` (`src-tauri/src/commands/audio.rs:79`)
|
||||
|
||||
Helper that pushes a downsampled chunk into both the WAV writer and the in-memory buffer, respecting the buffer cap and flipping the `capture_truncated` flag when it bumps the ceiling.
|
||||
|
||||
### `start_native_capture` (`src-tauri/src/commands/audio.rs:113`)
|
||||
|
||||
Step-by-step:
|
||||
|
||||
1. `ensure_main_window(&window)` — secondary windows can't start a capture.
|
||||
2. Stop any in-flight worker and `await` its termination (RB-06 — without the join, `all_samples.clear()` below would race a draining worker and a rapid start→stop→start could leak old-session samples into the new session).
|
||||
3. Call `MicrophoneCapture::start` (or `start_with_device(name)`) inside `spawn_blocking` — the underlying cpal probe spends up to `DEVICE_VALIDATION_MS * N` per pass and would block the async runtime otherwise (Codex review 2026/04/17 D2).
|
||||
4. Open a temp WAV via `WavWriter::create(&temp_path, WHISPER_SAMPLE_RATE, 1)`.
|
||||
5. Spawn the worker. The worker loop:
|
||||
- Polls the stop channel non-blocking.
|
||||
- Drains `cpal::AudioChunk`s from the capture mpsc, distinguishing `Empty` (try again) from `Disconnected` (stream is dead, exit) — Codex review 2026/04/17 M3.
|
||||
- Downmixes to mono if the source is stereo (`chunk.samples.chunks(channels).map(|frame| sum / channels)`).
|
||||
- Decimates to 16 kHz (simple ratio-based decimation, matches the frontend pcm-processor.js pattern).
|
||||
- When the buffer ≥ 8000 samples (~0.5 s), drains, calls `append_recorded_chunk`, and emits `native-pcm`.
|
||||
- On exit, flushes any remaining samples, drops the cpal capture, finalises the WAV.
|
||||
6. Stash the worker in `state.worker`.
|
||||
|
||||
### `stop_native_capture` (`src-tauri/src/commands/audio.rs:306`)
|
||||
|
||||
Takes the worker out of state, calls `stop_worker(worker).await`, then `mem::take`s `all_samples` and returns it. Logs a warning if the buffer was truncated.
|
||||
|
||||
### `resolve_recording_path` (`src-tauri/src/commands/audio.rs:333`)
|
||||
|
||||
Public helper used by `commands::live::start_live_transcription_session`. Resolves the recordings dir (user-supplied folder if non-empty, else `app_local_data_dir/recordings/`), creates it, and returns `dir.join(recording_filename())`.
|
||||
|
||||
### `recording_filename` (`src-tauri/src/commands/audio.rs:365`)
|
||||
|
||||
Deterministic filename: `magnotia-<unix_secs>-<nanos:09>-<counter:04>.wav`. The counter is a process-lifetime `AtomicU64` (`RECORDING_COUNTER`) bumped on each call. The combination defeats wall-clock collisions both across launches (secs change) and within the same nanosecond (counter changes).
|
||||
|
||||
### `persist_audio_samples` and `save_audio` (`src-tauri/src/commands/audio.rs:512`, `:531`)
|
||||
|
||||
`save_audio` is the public command. Calls `persist_audio_samples`, which resolves the recording path, then calls `magnotia_audio::write_wav` inside `spawn_blocking`. Returns the absolute path as a string.
|
||||
|
||||
## Data flow
|
||||
|
||||
- **Start path:** `start_native_capture(deviceName)` → cpal opens stream → worker downmixes/downsamples → emits `native-pcm` events at 0.5 s cadence → frontend dictation page accumulates samples or feeds them to a transcription command.
|
||||
- **Stop path:** frontend invokes `stop_native_capture` → worker stop channel signalled → join awaited → samples returned (capped at 10 minutes) → frontend can request `save_audio` to persist.
|
||||
- **Live transcription path:** `commands::live` does not use `start_native_capture` directly; it owns its own `MicrophoneCapture::start` invocation but reuses `resolve_recording_path` and `recording_filename` from this file.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- The in-memory buffer is capped at 10 minutes. Anything longer relies on the temp WAV; the frontend cannot just `stop_native_capture` and treat the returned vec as ground truth for long sessions. Today's UI assumes short captures, but the cap should be surfaced if you build a long-form recorder.
|
||||
- The downsampler is naive decimation. Acceptable for speech but not for music. If you ever extend Magnotia to general audio, swap in `magnotia_audio::StreamingResampler` (which is what `commands::live` uses).
|
||||
- `start_native_capture` does *not* engage `PowerAssertion` (App Nap pinning). `commands::live::run_live_session` does. If you build a long-form non-live recorder on top of this, add the assertion.
|
||||
- The worker emits `native-pcm` to the entire app handle (not a specific window). Every webview that listens will receive the chunks. If you ever route audio to a non-main window, double-check this is what you want.
|
||||
- `stop_worker_awaits_full_termination_no_writes_after_join` (`src-tauri/src/commands/audio.rs:454`) is the regression test for RB-06. Do not change `stop_worker` to a fire-and-forget pattern.
|
||||
|
||||
## See also
|
||||
|
||||
- [Live transcription](live.md) — uses `resolve_recording_path` and `recording_filename` from this file.
|
||||
- [Transcription](transcription.md) — receives `Vec<f32>` from `stop_native_capture` and runs Whisper / Parakeet on it.
|
||||
- [Models](models.md) — `ensure_model_loaded` is invoked by the transcription commands before they consume audio.
|
||||
- [Cargo and features](../cargo-and-features.md) — `arboard` and the audio crate dependency live there.
|
||||
111
docs/architecture-map/02-tauri-runtime/commands/diagnostics.md
Normal file
111
docs/architecture-map/02-tauri-runtime/commands/diagnostics.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: Diagnostics and reports
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::diagnostics`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Diagnostics
|
||||
|
||||
**Plain English summary.** The local-only diagnostic plumbing. Installs the Rust panic hook (writes per-panic dumps to `crashes_dir`). Captures frontend errors via a global `window.onerror` handler. Lists recent error-log rows and crash files. Bundles a markdown diagnostic report (settings, recent errors, active power assertions, crash dumps, log tail) so the user can paste it into an email or GitHub issue. Privacy posture: nothing is transmitted; the user reviews exactly what would be shared before sharing it.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/diagnostics.rs`.
|
||||
- LOC: 534.
|
||||
- Tauri commands exposed (6 total):
|
||||
- `log_frontend_error(state, context, message, stack: Option<String>) -> Result<(), String>`. No window guard — fires from any window's global error handler.
|
||||
- `list_recent_errors_command(window, state, limit) -> Result<Vec<ErrorLogDto>, String>`. Main-window only.
|
||||
- `list_crash_files(window) -> Result<Vec<CrashFile>, String>`. Main-window only.
|
||||
- `generate_diagnostic_report(window, state, options: Option<ReportOptions>) -> Result<String, String>`. Main-window only.
|
||||
- `save_diagnostic_report(window, app, state, options) -> Result<String, String>`. Main-window only. Returns the absolute file path it wrote.
|
||||
- `get_os_info() -> OsInfo`.
|
||||
- Public Rust helper used by `lib.rs::run`: `install_panic_hook()`.
|
||||
- Events emitted: none.
|
||||
- Depends on: `magnotia_storage::{app_data_dir, crashes_dir, list_recent_errors, log_error, logs_dir, ErrorLogRow}`, `commands::power::active_assertions_snapshot`, `commands::security::ensure_main_window`. Plus `std::fs`, `std::panic`, `std::time`.
|
||||
- Called from frontend at: global `window.onerror` / `window.unhandledrejection` (`log_frontend_error`); Settings → About → Diagnostics (the bundle commands and `list_*` commands); top-of-app Cmd-vs-Ctrl labelling (`get_os_info`).
|
||||
|
||||
## What's in here
|
||||
|
||||
### Constants (`src-tauri/src/commands/diagnostics.rs:26`)
|
||||
|
||||
- `DEFAULT_RECENT_ERRORS = 50`.
|
||||
- `MAGNOTIA_VERSION = env!("CARGO_PKG_VERSION")`.
|
||||
|
||||
### `install_panic_hook` (`:34`)
|
||||
|
||||
Creates `crashes_dir()` if missing. Replaces the default panic hook with one that writes a minimal text dump to `crashes_dir/<unix_secs>-<short_hash>.crash` and then chains to the original hook so the panic still hits stderr. Dump includes version, timestamp, thread name, panic message, OS, arch, RUST_BACKTRACE env. Backtraces require `RUST_BACKTRACE=1` and `std::backtrace` plumbing — neither set up today, so production crashes will lack stack traces.
|
||||
|
||||
### `log_frontend_error` (`:86`)
|
||||
|
||||
Truncates the stack to 2,000 chars and calls `magnotia_storage::log_error` with context `"frontend"` (or the caller-supplied context), error code `"FRONTEND_ERROR"`, the message, and the stack as metadata.
|
||||
|
||||
### `ErrorLogDto` (`:114`) and `list_recent_errors_command` (`:138`)
|
||||
|
||||
`ErrorLogDto` is a camelCase shape over `ErrorLogRow`. The list command clamps `limit` to `[1, 1000]` and defaults to 50.
|
||||
|
||||
### `CrashFile` and `list_crash_files` (`:152`, `:162`)
|
||||
|
||||
`CrashFile` exposes filename, mtime_secs, size_bytes, plus a 600-char preview. `list_crash_files_inner` reads the crashes directory, builds the structs, sorts by mtime descending.
|
||||
|
||||
### Report options and assembler
|
||||
|
||||
- `ReportOptions` has four boolean flags: `include_settings`, `include_recent_errors`, `include_crashes`, `include_log_tail` (each defaulting true).
|
||||
- `redact_home(input)` replaces `home_dir().display()` substrings with `~`.
|
||||
- `looks_sensitive_key(key)` flags keys containing `apikey` / `token` / `secret` / `password` / `bearer`.
|
||||
- `redact_json_value(value, key_hint)` recursively walks a JSON value redacting strings under sensitive keys.
|
||||
- `sanitise_preferences_json(raw)` parses, redacts, re-emits.
|
||||
- `redact_line(input)` does line-level redaction for the error-message field.
|
||||
|
||||
### `generate_diagnostic_report_inner` (`:307`)
|
||||
|
||||
Builds a markdown document with sections:
|
||||
|
||||
1. Header (version, OS / arch, app data dir, generated timestamp, "local-only until you choose to share" notice).
|
||||
2. Settings (sanitised JSON via `sanitise_preferences_json`).
|
||||
3. Recent errors (50 rows, redacted).
|
||||
4. Power assertions (snapshots from `commands::power::active_assertions_snapshot`).
|
||||
5. Crash dumps (up to 5 most-recent, plus a count of older ones).
|
||||
6. Log tail (8 KB tail of `logs_dir/magnotia.log`, home-redacted).
|
||||
|
||||
### `save_diagnostic_report` (`:513`)
|
||||
|
||||
Generates the report, then writes it to `app_data_dir/diagnostic-reports/magnotia-diagnostic-<unix_secs>.md`. Returns the absolute path.
|
||||
|
||||
### `OsInfo` and `get_os_info` (`:449`, `:474`)
|
||||
|
||||
OS family label, arch, `uses_cmd` boolean (macOS only), `is_wayland` boolean (Linux only), `custom_hotkey_backend` (Linux only — true because evdev is the primary path on Linux), and `primary_modifier_label` (`"Cmd"` on macOS, `"Ctrl"` elsewhere). Frontend reads this once at boot to set the hotkey labels and "Open file location" terminology.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
panic occurs -> install_panic_hook writes crashes_dir/<ts>.crash
|
||||
window.onerror fires in JS -> invoke('log_frontend_error', context, message, stack)
|
||||
-> magnotia_storage::log_error
|
||||
-> error_log table
|
||||
|
||||
Settings -> About -> Diagnostics
|
||||
list_crash_files -> [CrashFile, ...]
|
||||
list_recent_errors_command -> [ErrorLogDto, ...]
|
||||
generate_diagnostic_report(opts) -> markdown string
|
||||
user reviews, then optionally:
|
||||
save_diagnostic_report(opts) -> absolute path
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **No backtraces in production crashes.** `RUST_BACKTRACE` is not set in the packaged binary. Either set it in a launcher script or call `std::backtrace::Backtrace::capture()` inside the panic hook.
|
||||
- **`log_frontend_error` is unguarded by ACL or window-label check.** Any window can log a frontend error. That is fine, but the storage layer should enforce a row-rate limiter before this surface ever ships to users with adversarial workloads.
|
||||
- **Crash file prefixes are unix-seconds-and-a-16-bit-hash.** Two panics in the same second with the same low-16-bits of seconds will collide. Vanishingly unlikely in practice. If you want bulletproof, use the same counter pattern as `commands::audio::recording_filename`.
|
||||
- **Diagnostic report is markdown by design** so it can be pasted anywhere. If you ever want a structured upload format, keep this command and add a sibling that returns JSON.
|
||||
- **Sensitive-key redaction is a heuristic.** It catches the obvious names (`api_key`, `apiKey`, `token`, `Bearer`) but won't catch a user-named key like `mySecretToken: "abc"` if the key string is normalised to lowercase first. Fine for "user reviews before sharing"; not fine if you ever auto-upload.
|
||||
- **`ensure_main_window` is missing on `log_frontend_error` and `get_os_info`.** Intentional — both should be callable from any window. But document this so future tightening attempts know to leave them alone.
|
||||
|
||||
## See also
|
||||
|
||||
- [App lifecycle](../app-lifecycle.md) — `install_panic_hook` is called from setup.
|
||||
- [Power assertions and security](power-and-security.md) — `active_assertions_snapshot` feeds the report.
|
||||
- [Capabilities and ACL](../capabilities-and-acl.md) — the report is local until the user chooses to share.
|
||||
- [Tests](../tests.md) — no specific test for the report itself, but the redactor helpers could use unit coverage.
|
||||
78
docs/architecture-map/02-tauri-runtime/commands/feedback.md
Normal file
78
docs/architecture-map/02-tauri-runtime/commands/feedback.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: HITL feedback
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::feedback`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Feedback
|
||||
|
||||
**Plain English summary.** Phase 2: thumbs + correction capture on AI-generated output (microsteps from a task decomposition, task lines extracted from a transcript, or LLM cleanup). The captured rows feed a few-shot loop: subsequent prompts are conditioned on the user's preferred style by injecting the (input, preferred-output) pairs as exemplars.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/feedback.rs`.
|
||||
- LOC: 110.
|
||||
- Tauri commands exposed:
|
||||
- `record_feedback(state, input: RecordFeedbackInput) -> Result<i64, String>` — returns the new row id.
|
||||
- `list_feedback_examples_cmd(state, target_type, limit, min_rating, profile_id) -> Result<Vec<FeedbackDto>, String>`.
|
||||
- Events emitted: none.
|
||||
- Depends on: `magnotia_storage::{record_feedback, list_feedback_examples, FeedbackRow, FeedbackTargetType, RecordFeedbackParams}`.
|
||||
- Called from frontend at: dictation result panel (thumb up/down + correction-text on cleanup); Tasks page (thumb on extracted tasks and decomposed microsteps).
|
||||
|
||||
## What's in here
|
||||
|
||||
### `RecordFeedbackInput` (`src-tauri/src/commands/feedback.rs:15`)
|
||||
|
||||
Frontend-supplied shape:
|
||||
|
||||
- `targetType`: `"microstep" | "task_extraction" | "cleanup"`. Parsed via `FeedbackTargetType::parse`.
|
||||
- `targetId`: optional surface-specific id (subtask id, task id, transcript id).
|
||||
- `rating`: `-1` (thumbs down), `0` (correction, neutral), `+1` (thumbs up).
|
||||
- `originalText`: the AI-generated text the user is rating.
|
||||
- `correctedText`: the user's preferred text (when they corrected it).
|
||||
- `contextJson`: freeform JSON used by the prompt builder later to reconstruct the (input, preferred-output) pair.
|
||||
- `profileId`: scopes the row.
|
||||
|
||||
### `FeedbackDto` (`:38`)
|
||||
|
||||
camelCase mirror of `FeedbackRow`. Note `rating` widens to `i64` in the DTO (storage uses `i64`).
|
||||
|
||||
### `parse_target_type` (`:68`)
|
||||
|
||||
Wraps `FeedbackTargetType::parse(raw)`, returning `"unknown feedback target_type: <raw>"` on miss.
|
||||
|
||||
### `record_feedback` (`:73`)
|
||||
|
||||
`parse_target_type` then `db_record_feedback`. Returns the row id.
|
||||
|
||||
### `list_feedback_examples_cmd` (`:95`)
|
||||
|
||||
Clamps `limit` to `[1, 64]`, defaults 8. Clamps `min_rating` to `[-1, 1]`, default 0. Calls `db_list_feedback_examples`. Returns `FeedbackDto`s. Used by the `commands::tasks` few-shot exemplar pull and would be used by the equivalent in `commands::llm` if/when cleanup gets its own exemplar path.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
dictation result thumbs-up -> invoke('record_feedback', { targetType: 'cleanup', rating: +1, originalText, correctedText, contextJson, profileId })
|
||||
-> magnotia_storage::record_feedback -> row id
|
||||
|
||||
decomposition thumbs-down + correction -> record_feedback({ targetType: 'microstep', rating: 0, originalText, correctedText: "user's preferred wording", contextJson: {parent_text}, profileId })
|
||||
|
||||
next decompose call -> list_feedback_examples_cmd('microstep', 5, 0, profile_id)
|
||||
-> [FeedbackDto, ...] -> few-shot exemplars
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **No `ensure_main_window` guard.** Tasks float and History viewer secondary windows can also fire feedback. Intentional. If you ever want to lock down feedback writes, this is where to add the guard.
|
||||
- **`contextJson` is freeform.** Storage stores the raw string. `commands::tasks::to_llm_examples` parses it and skips rows that are malformed. Bad data therefore degrades gracefully but doesn't surface to the user. The `eprintln!` in `to_llm_examples` is the only visibility.
|
||||
- **`min_rating` clamp is `[-1, 1]`.** Pass `1` to get only thumbs-up examples, `0` for thumbs-up + corrections, `-1` for everything. The default of `0` is what `commands::tasks` picks.
|
||||
- **No deduplication.** A user thumbs-upping the same output twice creates two rows. The exemplar trim in `commands::tasks` does not dedupe by `originalText`. If two identical exemplars steal slots, that's just lost prompt budget.
|
||||
|
||||
## See also
|
||||
|
||||
- [Tasks](tasks.md) — the consumer of `list_feedback_examples_cmd`.
|
||||
- [LLM](llm.md) — the cleanup path that produces the text that thumbs-up/down feedback rates.
|
||||
- [Profiles](profiles.md) — `profileId` is the scoping key.
|
||||
87
docs/architecture-map/02-tauri-runtime/commands/hotkey.md
Normal file
87
docs/architecture-map/02-tauri-runtime/commands/hotkey.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: Hotkey bridge
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::hotkey`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Hotkey bridge
|
||||
|
||||
**Plain English summary.** The Linux Wayland-compatible global hotkey backend. Tauri's `tauri-plugin-global-shortcut` works on macOS / Windows and on X11 Linux but fails silently on Wayland (the protocol forbids unprivileged keystroke grabs). Magnotia's bespoke evdev backend reads `/dev/input/event*` directly, parses key combos, and emits `magnotia:hotkey-pressed` / `magnotia:hotkey-released` events. Settings on Linux uses these commands instead of the global-shortcut plugin; everywhere else the plugin path is canonical.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/hotkey.rs`.
|
||||
- LOC: 105.
|
||||
- Tauri commands exposed (5 total):
|
||||
- `is_wayland_session() -> bool`. Pure env probe.
|
||||
- `check_hotkey_access() -> Result<(), String>`. Probes evdev access (user in `input` group, etc.).
|
||||
- `start_evdev_hotkey(app, state, hotkey: String) -> Result<(), String>`. Parses the Tauri-style combo string, stops any existing listener, starts a new one, spawns a forwarder task that converts evdev events to Tauri events.
|
||||
- `update_evdev_hotkey(state, hotkey: String) -> Result<(), String>`. Updates the combo on a running listener.
|
||||
- `stop_evdev_hotkey(state) -> Result<(), String>`. Stops cleanly.
|
||||
- Events emitted: `magnotia:hotkey-pressed` (no payload), `magnotia:hotkey-released` (no payload). Fired from the forwarder task in `start_evdev_hotkey` (`src-tauri/src/commands/hotkey.rs:67`, `:70`).
|
||||
- Depends on: `magnotia_hotkey::{EvdevHotkeyListener, HotkeyCombo, HotkeyEvent, check_evdev_access}`. Plus `tokio::sync::{mpsc, Mutex}`.
|
||||
- Called from frontend at: Settings → Hotkey on Linux. Other platforms call the `tauri-plugin-global-shortcut` plugin's JS API directly.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `HotkeyState` (`src-tauri/src/commands/hotkey.rs:9`)
|
||||
|
||||
Tauri-managed: `listener: Arc<Mutex<Option<EvdevHotkeyListener>>>`. `tokio::sync::Mutex` because operations await across `.lock()`.
|
||||
|
||||
### `is_wayland_session` (`:23`)
|
||||
|
||||
Returns true if `WAYLAND_DISPLAY` is set or `XDG_SESSION_TYPE=wayland`. Used by Settings to decide whether to display the Linux-only hotkey UI.
|
||||
|
||||
### `check_hotkey_access` (`:32`)
|
||||
|
||||
Defers to `magnotia_hotkey::check_evdev_access` — that helper checks the user can read `/dev/input/event*` (typically requires being in the `input` group, or a udev rule). On failure, returns the actionable error string.
|
||||
|
||||
### `start_evdev_hotkey` (`:41`)
|
||||
|
||||
1. Parse the hotkey string (e.g. `"Shift+Cmd+Space"`) via `HotkeyCombo::from_tauri_str`. Errors become `"Cannot parse hotkey: ..."`.
|
||||
2. Lock the listener mutex; if a listener is already running, stop it and await termination.
|
||||
3. Build a 64-deep `tokio::sync::mpsc` channel for `HotkeyEvent`s.
|
||||
4. Call `EvdevHotkeyListener::start(combo, event_tx)`, stash the listener.
|
||||
5. Spawn a `tokio::spawn` forwarder that reads from the event_rx and emits `magnotia:hotkey-pressed` / `magnotia:hotkey-released` per event variant.
|
||||
|
||||
### `update_evdev_hotkey` (`:81`)
|
||||
|
||||
Re-parses the combo and calls `listener.set_hotkey(combo)` if a listener is running. Returns `"Hotkey listener not running"` otherwise. Avoids the cost of stopping and restarting the evdev thread.
|
||||
|
||||
### `stop_evdev_hotkey` (`:99`)
|
||||
|
||||
Take the listener out, call `listener.stop().await`. Idempotent — calling stop with no listener returns Ok silently.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
Settings -> is_wayland_session() -> bool
|
||||
Settings -> check_hotkey_access() -> Ok or "join the input group / install udev rule"
|
||||
Settings -> start_evdev_hotkey("Shift+Cmd+Space")
|
||||
-> parse combo
|
||||
-> stop any prior listener
|
||||
-> EvdevHotkeyListener spawns its own thread reading /dev/input/event*
|
||||
-> forwarder spawn: HotkeyEvent -> Tauri event
|
||||
frontend listens on 'magnotia:hotkey-pressed' / '...-released' and starts/stops dictation
|
||||
Settings -> update_evdev_hotkey("Ctrl+Alt+M") (when user changes binding)
|
||||
Settings -> stop_evdev_hotkey() (when user disables)
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **No `ensure_main_window` guard.** Settings is in the main window. If you ever expose hotkey re-binding in a secondary window, add the guard.
|
||||
- **Linux only.** macOS and Windows code paths in the frontend talk to `tauri-plugin-global-shortcut` directly — no Rust commands needed because the plugin handles register / unregister via JS. This module compiles and runs on every desktop OS but only *works* on Linux because evdev is Linux-specific (the workspace crate `magnotia_hotkey` has the platform shim).
|
||||
- **`/dev/input/event*` access requires either:** (a) the user is in the `input` group, or (b) a udev rule grants Magnotia explicit access. The Settings UI walks the user through option (a) on first run; option (b) is documented in `docs/dev-setup.md` for power users.
|
||||
- **Channel size of 64** is enough for a key smash burst. If a worker stalls and the channel fills, evdev events would be dropped silently. Consider logging a warning when the channel is at capacity.
|
||||
- **The forwarder spawn is detached.** No way to stop the spawn task explicitly; it exits when `event_rx.recv().await` returns None (which happens when the listener is dropped). Acceptable.
|
||||
- **No power assertion.** The evdev listener thread is pinned by the kernel via the file handle; idle CPU is near zero.
|
||||
|
||||
## See also
|
||||
|
||||
- [App lifecycle](../app-lifecycle.md) — `HotkeyState::new()` is stashed in setup.
|
||||
- [Diagnostics](diagnostics.md) — `OsInfo.custom_hotkey_backend` flags Linux as the evdev path.
|
||||
- [Paste](paste.md) — the consumer of "user pressed dictation hotkey": holds focus on the target window for the eventual paste.
|
||||
- [Live transcription](live.md) — the dictation flow that the hotkey starts.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: Implementation intentions
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::intentions`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Implementation intentions
|
||||
|
||||
**Plain English summary.** Phase 7. Tiny "if-then" automation rules: "at 09:00 each day, surface my Inbox", "when the morning triage finishes, start a 5-minute timer", "when task X is completed, speak 'Nice'". Frontend owns scheduling and execution because v1 triggers are app-local events plus a local clock check; Rust owns durable storage, validation, and a main-window-only firewall.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/intentions.rs`.
|
||||
- LOC: 308.
|
||||
- Tauri commands exposed (5 total), all main-window only:
|
||||
- `list_implementation_rules(window, state) -> Result<Vec<ImplementationRuleDto>, String>`.
|
||||
- `create_implementation_rule(window, state, request: CreateImplementationRuleRequest) -> Result<ImplementationRuleDto, String>`.
|
||||
- `set_implementation_rule_enabled(window, state, id, enabled) -> Result<ImplementationRuleDto, String>`.
|
||||
- `mark_implementation_rule_fired(window, state, id, fired_key) -> Result<ImplementationRuleDto, String>`.
|
||||
- `delete_implementation_rule(window, state, id) -> Result<(), String>`.
|
||||
- Events emitted: none.
|
||||
- Depends on: `magnotia_storage::{insert_implementation_rule, list_implementation_rules, set_implementation_rule_enabled, mark_implementation_rule_fired, delete_implementation_rule, get_task_by_id, ImplementationRuleRow}`, `uuid::Uuid`, `serde_json`. Plus `commands::security::ensure_main_window`.
|
||||
- Called from frontend at: Settings → Implementation intentions (CRUD); the rule-runner module (`mark_implementation_rule_fired` after firing).
|
||||
|
||||
## What's in here
|
||||
|
||||
### Trigger and action enums (`src-tauri/src/commands/intentions.rs:21`)
|
||||
|
||||
- `RuleTriggerKind`: `TimeOfDay`, `TaskCompleted`, `MorningTriageFinished`. Serialises as `"time_of_day"`, `"task_completed"`, `"morning_triage_finished"`.
|
||||
- `SurfaceTarget`: `Inbox`, `Today`, `Tasks`, `Task`. The `Task` variant requires a `taskId`.
|
||||
- `RuleAction` (tagged enum, `kind` discriminator): `Surface { target, taskId, label }`, `StartTimer { seconds, taskId, label }`, `SpeakLine { text }`.
|
||||
|
||||
### `CreateImplementationRuleRequest` (`:72`)
|
||||
|
||||
`triggerKind`, `triggerValue: String`, `actions: Vec<RuleAction>`, `enabled` (default true), `lastFiredKey: Option<String>`.
|
||||
|
||||
### `ImplementationRuleDto` (`:88`) and `TryFrom` (`:100`)
|
||||
|
||||
The DTO unpacks the storage row's `actions_json` back into typed `Vec<RuleAction>`. Returns Err on unknown `trigger_kind` strings or invalid JSON — surfaces drift instead of silently swallowing.
|
||||
|
||||
### Validators
|
||||
|
||||
- `validate_hhmm(value)` (`:125`) — strict HH:MM, two digits each, hours 0..=23, minutes 0..=59. Tested.
|
||||
- `validate_actions(state, actions)` (`:144`):
|
||||
- At least one action.
|
||||
- `Surface { target: Task }` requires a `taskId` and the task must exist (via `get_task_by_id`).
|
||||
- `StartTimer` rejects anything other than 300 seconds (v1 fixed at 5 minutes).
|
||||
- `SpeakLine` requires non-empty text and ≤ 240 chars.
|
||||
- `validate_request(state, request)` (`:192`) — checks the trigger value matches the trigger kind (`TimeOfDay` requires HH:MM, the others reject any non-empty trigger_value).
|
||||
|
||||
### Commands (`:207` onwards)
|
||||
|
||||
All five commands `ensure_main_window`. CRUD is otherwise straight wrapping. `create_implementation_rule` generates a UUIDv4, serialises actions to JSON, normalises `trigger_value` (empty for non-time triggers), inserts, and returns the freshly-fetched DTO. `mark_implementation_rule_fired` requires a non-empty `fired_key` (the frontend uses ISO date strings to dedupe daily fires).
|
||||
|
||||
### Tests (`:296`)
|
||||
|
||||
`hhmm_validation_accepts_and_rejects_expected_shapes` covers a handful of valid and invalid clock strings.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
Settings -> create_implementation_rule(req)
|
||||
-> ensure_main_window
|
||||
-> validate_request (HH:MM if applicable, action shape, task existence for surface-task)
|
||||
-> uuid::v4 for id
|
||||
-> serde_json serialise actions
|
||||
-> magnotia_storage::insert_implementation_rule
|
||||
-> list/return DTO
|
||||
|
||||
frontend rule-runner cron -> evaluates triggers in JS
|
||||
-> when a rule fires: invoke('mark_implementation_rule_fired', id, "2026-05-09")
|
||||
-> stamp the dedupe key in DB
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **Action timer is hard-capped to 300 seconds.** The frontend has to surface this constraint. Future versions will need to widen the validator to accept arbitrary durations.
|
||||
- **Rule execution lives entirely in the frontend.** If the user closes Magnotia at 08:55 with a 09:00 rule, the rule does not fire. There is no daemon. This is documented in the module header.
|
||||
- **Validation is sync against the DB.** `validate_actions` calls `get_task_by_id` for every surface-task action. Multiple actions in one rule = multiple round-trips. Acceptable today; consider batching if a rule grows huge.
|
||||
- **`mark_implementation_rule_fired` requires a non-empty `fired_key`.** The frontend should always pass an ISO date for daily rules and a per-event key for task-completed rules. Empty key = command rejection.
|
||||
- **No `PowerAssertion`.** None of these actions run inference. No need.
|
||||
|
||||
## See also
|
||||
|
||||
- [Tasks](tasks.md) — `Surface { target: Task }` and `StartTimer { taskId }` reference task ids.
|
||||
- [TTS](tts.md) — `SpeakLine` actions are dispatched by the frontend through `tts_speak`.
|
||||
- [Capabilities and ACL](../capabilities-and-acl.md) — main-window-only is the firewall here.
|
||||
185
docs/architecture-map/02-tauri-runtime/commands/live.md
Normal file
185
docs/architecture-map/02-tauri-runtime/commands/live.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
name: Live transcription session
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::live`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Live transcription
|
||||
|
||||
**Plain English summary.** The 1,737-line beast that runs a live dictation session end-to-end. Captures audio via a dedicated `MicrophoneCapture`, streams chunks through a `StreamingResampler`, runs a speech gate to skip near-silent chunks, dispatches 2-second windows with 0.25-second overlap to whisper or parakeet, polls inference results on a background thread, dedupes overlapping segments against a recent-history buffer, post-processes with the formatting pipeline, and emits typed messages back to the frontend on two `tauri::ipc::Channel`s (one for results, one for status). Holds a macOS App Nap power assertion for the duration of the session. Writes audio progressively to a WAV file so a crash mid-session leaves a playable recording.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/live.rs`.
|
||||
- LOC: 1,737. Largest file in the slice.
|
||||
- Tauri commands exposed:
|
||||
- `start_live_transcription_session(window, app, state, live_state, config: StartLiveTranscriptionConfig, result_channel: Channel<LiveResultMessage>, status_channel: Channel<LiveStatusMessage>) -> Result<StartLiveTranscriptionResponse, String>` — main-window only.
|
||||
- `stop_live_transcription_session(window, app, live_state, session_id: u64) -> Result<StopLiveTranscriptionResponse, String>` — main-window only.
|
||||
- Events emitted: NONE in the conventional `app.emit(...)` sense. This module uses Tauri 2's typed `tauri::ipc::Channel<T>` API instead. The frontend creates the channel pair on the JS side via `new Channel<T>()`, passes it as a command argument, and Magnotia sends typed messages on it from the worker. Two channels:
|
||||
- `Channel<LiveResultMessage>` — per-chunk transcription results (segments, language, duration, raw_text, inference_ms, chunk_id, chunk_start_secs).
|
||||
- `Channel<LiveStatusMessage>` — tagged enum: `Warning { message }`, `Overload { dropped_audio_ms, message }`, `Error { message }`, `Finished { audio_path, dropped_audio_ms }`.
|
||||
- Depends on: `magnotia_audio::{AudioChunk, CaptureRuntimeError, MicrophoneCapture, StreamingResampler, WavWriter}`, `magnotia_core::constants::WHISPER_SAMPLE_RATE`, `magnotia_core::types::{AudioSamples, Segment, TranscriptionOptions}`, `magnotia_transcription::LocalEngine`, `magnotia_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}`, `magnotia_storage::{database::get_profile, database::list_profile_terms, DEFAULT_PROFILE_ID}`. Plus `commands::audio::resolve_recording_path`, `commands::build_initial_prompt`, `commands::models::{default_model_id_for_engine, ensure_model_loaded}`, `commands::power::PowerAssertion`, `commands::security::ensure_main_window`.
|
||||
- Called from frontend at: dictation page (when the user starts and stops a live session — most common entry).
|
||||
|
||||
## What's in here
|
||||
|
||||
### Constants (`src-tauri/src/commands/live.rs:30`)
|
||||
|
||||
Speech-gate, dedup, and chunking parameters. The headline numbers: `CHUNK_SAMPLES = 32_000` (2 s at 16 kHz), `OVERLAP_SAMPLES = 4_000` (0.25 s), `FINAL_CHUNK_MIN_SAMPLES = 4_000`, `MAX_PENDING_SAMPLES = CHUNK_SAMPLES`. Speech-gate thresholds (RMS / peak / consecutive-window counts) follow.
|
||||
|
||||
### State
|
||||
|
||||
- `LiveTranscriptionState` (`src-tauri/src/commands/live.rs:62`) — the Tauri-managed struct stashed by `lib.rs::run`. Fields:
|
||||
- `next_session_id: AtomicU64` — monotonic session-id generator.
|
||||
- `lifecycle: AsyncMutex<()>` — start/stop barrier.
|
||||
- `running: Mutex<Option<RunningLiveSession>>` — the currently-running session, if any.
|
||||
- `RunningLiveSession` (`src-tauri/src/commands/live.rs:68`) — id, stop_flag, JoinHandle for the blocking worker, the status channel.
|
||||
|
||||
### Public payload types
|
||||
|
||||
- `StartLiveTranscriptionConfig` (`src-tauri/src/commands/live.rs:77`) — engine, model_id, language, initial_prompt, save_audio, output_folder, post-processing flags, format_mode, microphone_device, profile_id.
|
||||
- `StartLiveTranscriptionResponse` — `{ session_id }`.
|
||||
- `StopLiveTranscriptionResponse` — `{ session_id, audio_path: Option<String>, dropped_audio_ms: u64 }`.
|
||||
- `LiveResultMessage` — per-chunk result.
|
||||
- `LiveStatusMessage` — tagged enum (4 variants).
|
||||
|
||||
### `ActiveCapture` (`src-tauri/src/commands/live.rs:166`)
|
||||
|
||||
Wraps `MicrophoneCapture` plus its `cpal` chunk receiver and the optional runtime-error receiver. `drain_runtime_errors` posts `LiveStatusMessage::Warning` for each cpal-side error.
|
||||
|
||||
### `LiveLoopState` (`src-tauri/src/commands/live.rs:208`)
|
||||
|
||||
Per-session mutable state: resampler, capture buffer, WAV writer, buffer start sample index, dropped-audio counter, chunk id, in-flight inference task, resampler-flushed flag, result-listener-lost flag, recent-segments dedup history.
|
||||
|
||||
### `LiveSessionRuntime` (`src-tauri/src/commands/live.rs:231`)
|
||||
|
||||
Owns everything for one session. Constructor opens the WAV writer. `run()` is the main loop:
|
||||
|
||||
```
|
||||
loop {
|
||||
poll_inference()?;
|
||||
capture.drain_runtime_errors();
|
||||
if let Some(chunk) = recv_audio()? { process_audio_chunk(chunk)?; }
|
||||
drop_pending_overflow(); // bounded buffer; emits Overload status
|
||||
flush_tail_if_stopping()?;
|
||||
if dispatch_inference_if_ready() { continue; }
|
||||
if should_exit_loop() { break; }
|
||||
}
|
||||
drain_inference()?;
|
||||
finish()
|
||||
```
|
||||
|
||||
Methods:
|
||||
|
||||
- `process_audio_chunk` — downmix, lazy-init `StreamingResampler`, push samples, append to capture buffer + WAV (`src-tauri/src/commands/live.rs:323`).
|
||||
- `drop_pending_overflow` — when the inflight inference is busy and the buffer exceeds `MAX_PENDING_SAMPLES`, drop the oldest samples and emit `LiveStatusMessage::Overload` with the cumulative dropped-audio counter (`:344`).
|
||||
- `flush_tail_if_stopping` — flush the resampler and the WAV header on shutdown (`:365`).
|
||||
- `dispatch_inference_if_ready` — wraps `maybe_dispatch_chunk` (the chunking + speech-gate + thread-spawn function) (`:396`).
|
||||
- `drain_inference` — busy-loops with 10 ms sleeps until the in-flight inference completes after stop (`:425`).
|
||||
- `finish` — finalise WAV, return `LiveSessionSummary` (`:433`).
|
||||
|
||||
### `start_live_transcription_session` (`src-tauri/src/commands/live.rs:484`)
|
||||
|
||||
1. `ensure_main_window`.
|
||||
2. `lifecycle.lock().await` — barrier against concurrent start/stop.
|
||||
3. Reject if a session is already running.
|
||||
4. Resolve profile_id, fetch profile + profile_terms from `magnotia_storage`.
|
||||
5. Collapse the effective `initial_prompt` via `build_initial_prompt` (so the worker doesn't have to know about profile fallback).
|
||||
6. Resolve model_id via `default_model_id_for_engine` if absent.
|
||||
7. `ensure_model_loaded(state, engine, model_id, None)` — `None` means don't enforce sequential-GPU mode (Settings owns that toggle).
|
||||
8. Resolve audio_path via `commands::audio::resolve_recording_path` if `save_audio` is true.
|
||||
9. `tokio::task::spawn_blocking(move || run_live_session(...))` — the real worker runs on a dedicated blocking thread, not the Tokio runtime, because Whisper inference itself spawns its own threads and the work is CPU-bound.
|
||||
10. Stash the new `RunningLiveSession`. Return the session_id.
|
||||
|
||||
### `stop_live_transcription_session` (`src-tauri/src/commands/live.rs:591`)
|
||||
|
||||
1. `ensure_main_window`, lifecycle lock.
|
||||
2. Take the running session out of state.
|
||||
3. Validate `session_id` matches; on mismatch, restore the session and return an error.
|
||||
4. Set the stop flag and await the worker `JoinHandle`.
|
||||
5. Read the summary, send `LiveStatusMessage::Finished` on the status channel, return the response.
|
||||
|
||||
### `run_live_session` (`src-tauri/src/commands/live.rs:646`)
|
||||
|
||||
The blocking entry. Holds a `PowerAssertion::begin("magnotia live dictation session")` for the entire scope. Constructs and runs `LiveSessionRuntime`. The drop on the power assertion ends the macOS App Nap pin.
|
||||
|
||||
### `maybe_dispatch_chunk` (`src-tauri/src/commands/live.rs:753`)
|
||||
|
||||
The brain of the chunking pipeline. Decides whether to dispatch a chunk now, based on capture buffer size and the stopping flag:
|
||||
|
||||
- Full chunk path: `target_len = CHUNK_SAMPLES`, with `OVERLAP_SAMPLES` of trim against the previous chunk to dedupe.
|
||||
- Stopping path: dispatch any partial chunk ≥ `FINAL_CHUNK_MIN_SAMPLES`.
|
||||
- Speech gate: `evaluate_speech_gate(speech_window)` (`:1305`) returns a decision based on per-frame RMS / peak amplitude / consecutive-speech-window counts. If skipped, drop those samples and emit a Warning.
|
||||
- On dispatch: spawn a `std::thread` that calls `engine.transcribe_sync` and posts the result back via a `std::sync::mpsc` channel. The 2025 version of this code used a Tokio task; switching to a plain thread keeps inference off the blocking pool entirely.
|
||||
|
||||
### `poll_inference` (`src-tauri/src/commands/live.rs:864`)
|
||||
|
||||
Polls the in-flight `InferenceTask`'s mpsc receiver. On result:
|
||||
|
||||
- Trim overlap segments against the previous chunk via `trim_overlap_segments`.
|
||||
- Run dedup vs the `recent_segments` history via `filter_duplicate_boundary_segments`.
|
||||
- Post-process with `post_process_segments` (using the dictionary terms and `PostProcessOptions`).
|
||||
- Build a `LiveResultMessage` and `emit_live_result(...)`.
|
||||
|
||||
### `emit_live_result` (`src-tauri/src/commands/live.rs:971`)
|
||||
|
||||
Sends on the result channel. If the listener is dead, sets `result_listener_lost = true` and tries to send a Warning on the status channel. If *that* also fails, self-asserts the stop flag so the worker drains and exits — otherwise the worker would burn CPU + GPU memory polling forever after the user closes the app window without a clean stop call.
|
||||
|
||||
### Dedup helpers (`src-tauri/src/commands/live.rs:1027` onwards)
|
||||
|
||||
- `filter_duplicate_boundary_segments` — drops segments at chunk boundaries that meaningfully overlap the recent-segments history.
|
||||
- `remember_recent_segments` — maintains the rolling window (~`DUPLICATE_HISTORY_RETENTION_SECS = 8.0`).
|
||||
- `build_nearby_transcript_candidates` — collects candidates in the leading-edge window (`DUPLICATE_CHECK_LEADING_SECS = 1.5`).
|
||||
- `transcripts_overlap` and `transcripts_loosely_overlap` — token-coverage / longest-common-subsequence checks against `LOW_SIGNAL_TOKENS` (a stop-word-equivalent list of ~60 high-frequency tokens).
|
||||
|
||||
### Speech gate (`src-tauri/src/commands/live.rs:1251` onwards)
|
||||
|
||||
- `record_speech_window`, `speech_gate_decision`, `evaluate_speech_gate`. Two thresholds: a strong-speech path (high RMS or high peak, or two consecutive speech windows) and a soft-speech path. `FLATLINE_PEAK_THRESHOLD` catches the silent-buffer case (e.g. mic disconnected). The gate keeps Whisper from hallucinating on near-silent audio, which Whisper is famous for doing ("you are watching the show").
|
||||
|
||||
### Other helpers
|
||||
|
||||
- `downmix_chunk` (`:1336`) — same pattern as `commands::audio`.
|
||||
- `pick_engine` (`:638`) — `state.whisper_engine` or `state.parakeet_engine`.
|
||||
- `open_wav_writer`, `finalize_wav_writer`, `append_resampled_audio` — progressive WAV plumbing (brief item #19).
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
frontend invoke('start_live_transcription_session', { config, result_channel, status_channel })
|
||||
-> Rust: validate, fetch profile, build prompt, ensure model loaded, spawn worker
|
||||
worker (blocking thread):
|
||||
loop:
|
||||
cpal -> ActiveCapture -> StreamingResampler -> capture_buffer + WAV
|
||||
when buffer >= 32k samples (or stopping with >= 4k):
|
||||
speech-gate -> if pass: thread::spawn(engine.transcribe_sync)
|
||||
poll inflight: filter overlap, dedup vs history, post_process_segments
|
||||
send LiveResultMessage on result_channel
|
||||
on overflow: drop oldest, send LiveStatusMessage::Overload
|
||||
on stop flag: flush resampler tail, drain inflight, finalise WAV
|
||||
return LiveSessionSummary
|
||||
frontend invoke('stop_live_transcription_session', { session_id })
|
||||
-> Rust: set stop flag, await worker, send LiveStatusMessage::Finished, return response
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **Size.** 1,737 LOC in one file. The runtime + loop + speech gate + dedup + chunker really should be split. The pieces are already modular; pulling each out into its own file under `commands/live/` would make the surface much easier to read and audit.
|
||||
- **`thread::spawn` for inference.** Each chunk spawns a fresh OS thread (`live.rs:841`). Inside Whisper this is fine because whisper.cpp uses its own thread pool, and only one chunk is in flight at a time per session. Two simultaneous live sessions would multiply this; the lifecycle lock forbids that today.
|
||||
- **`poll_inference` busy-loops with 10 ms sleeps in `drain_inference`.** Acceptable because we only enter the drain on stop. Don't reuse this pattern for the main loop.
|
||||
- **Result-listener-lost path is critical.** Without it, closing the main window without a clean stop would leave the worker spinning forever, holding the GPU memory and the WAV file handle until process exit. The self-asserted stop flag is the safety net.
|
||||
- **Power assertion only does work on macOS.** On Linux the function is a no-op (see [Power assertions and security](power-and-security.md)). A long live-dictation session on Linux can still be idled by the compositor.
|
||||
- **The recent-segments history is bounded by time, not count.** A high chunk rate could grow it more than expected; the retention is `DUPLICATE_HISTORY_RETENTION_SECS = 8.0`.
|
||||
- **Channel back-pressure.** The result channel is the JS-side `Channel<T>` queue. If the frontend stops reading, the queue grows. Magnotia's overload-signalling currently uses the in-buffer `MAX_PENDING_SAMPLES` cap; it does NOT detect a JS-side stalled listener except via the `emit_live_result`-failure path.
|
||||
- **`ensure_model_loaded(state, engine, model_id, None)`** intentionally passes `None` for `concurrent`, so live sessions never trigger the sequential-GPU guard in `commands::models`. If you ever ship a tight-VRAM machine and the user has switched to sequential mode, this could OOM. Today's hardware survey indicates this is uncommon; flag this when revisiting Phase A.4.
|
||||
|
||||
## See also
|
||||
|
||||
- [Audio capture](audio.md) — `resolve_recording_path` and `recording_filename` are shared.
|
||||
- [Models](models.md) — `default_model_id_for_engine` and `ensure_model_loaded` are called from start.
|
||||
- [Transcription](transcription.md) — the non-live transcription path that shares the post-processing pipeline.
|
||||
- [Profiles](profiles.md) — the profile + profile-terms fetch happens before the worker spawns.
|
||||
- [Power assertions and security](power-and-security.md) — the App Nap pin and `ensure_main_window` guard.
|
||||
- [`commands::mod`](mod.md) — `build_initial_prompt` is the prompt assembler used here.
|
||||
118
docs/architecture-map/02-tauri-runtime/commands/llm.md
Normal file
118
docs/architecture-map/02-tauri-runtime/commands/llm.md
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: Local LLM commands
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::llm`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → LLM
|
||||
|
||||
**Plain English summary.** Owns the local LLM lifecycle (recommend tier, check, download with progress events, load with sequential-GPU coordination, unload, delete, status, test) plus two inference commands the frontend uses on demand: cleanup-transcript-text (the "fix this" pass that runs after a transcription) and extract-content-tags (Phase 9, surface topic / intent tags on the History page). Diagnostic test command classifies load failures into VRAM / corrupt / permission / other so Settings shows actionable hints rather than raw llama.cpp exceptions.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/llm.rs`.
|
||||
- LOC: 423.
|
||||
- Tauri commands exposed (10 total):
|
||||
- `recommend_llm_tier() -> Result<String, String>`. No window guard — pure hardware probe.
|
||||
- `check_llm_model(state, model_id) -> Result<LlmModelStatusDto, String>`.
|
||||
- `download_llm_model(window, app, model_id) -> Result<(), String>`. Main-window only. Emits `magnotia:llm-download-progress`.
|
||||
- `load_llm_model(window, state, model_id, use_gpu, concurrent) -> Result<(), String>`. Main-window only.
|
||||
- `unload_llm_model(window, state) -> Result<(), String>`. Main-window only.
|
||||
- `delete_llm_model(window, state, model_id) -> Result<(), String>`. Main-window only.
|
||||
- `get_llm_status(state) -> Result<bool, String>`.
|
||||
- `test_llm_model(window, state, model_id) -> Result<LlmTestResult, String>`. Main-window only.
|
||||
- `cleanup_transcript_text_cmd(window, state, transcript, profile_id, preset) -> Result<String, String>`. Main-window only.
|
||||
- `extract_content_tags_cmd(state, transcript) -> Result<ContentTags, String>`.
|
||||
- Events emitted: `magnotia:llm-download-progress` (`{ modelId, done, total, percent }`) — `src-tauri/src/commands/llm.rs:76`.
|
||||
- Depends on: `magnotia_llm::{LlmEngine, LlmModelId, ContentTags, model_manager::{download_model, model_path, model_info, recommend_tier, is_downloaded, delete_model}}`, `magnotia_ai_formatting::{llm_cleanup_text, LlmPromptPreset}`, `magnotia_core::hardware`, `magnotia_storage::database::list_profile_terms`. Plus `commands::power::PowerAssertion`, `commands::security::ensure_main_window`.
|
||||
- Called from frontend at: Settings → AI page (download / load / unload / delete / test), dictation result panel (`cleanup_transcript_text_cmd`), History page (`extract_content_tags_cmd`).
|
||||
|
||||
## What's in here
|
||||
|
||||
### `LlmModelStatusDto` (`src-tauri/src/commands/llm.rs:11`)
|
||||
|
||||
Frontend-facing status DTO: id, displayName, downloaded, loaded, sizeBytes, description, minimumRamBytes, recommendedVramBytes.
|
||||
|
||||
### `parse_model_id` (`:24`)
|
||||
|
||||
Wraps `model_id.parse()` (which goes through the `LlmModelId` parser).
|
||||
|
||||
### `recommend_llm_tier` (`:28`)
|
||||
|
||||
Probes RAM via `magnotia_core::hardware::probe_system`, multiplies the MB to bytes, then defers to `magnotia_llm::model_manager::recommend_tier(ram, vram)`. No window guard — Settings calls this at first paint to populate the default tier suggestion.
|
||||
|
||||
### `check_llm_model` (`:40`)
|
||||
|
||||
Combines `model_info(id)` (static metadata), `model_manager::is_downloaded(id)`, and `state.llm_engine.loaded_model_id()` into a `LlmModelStatusDto`. Used by Settings to render per-tier status chips.
|
||||
|
||||
### `download_llm_model` (`:61`)
|
||||
|
||||
`ensure_main_window`. Calls `model_manager::download_model(id, progress_cb)` with a closure that emits `magnotia:llm-download-progress` on each chunk. The percent calculation rounds against `total > 0` (avoids division by zero on a server that doesn't return a content-length).
|
||||
|
||||
### `load_llm_model` (`:90`)
|
||||
|
||||
`ensure_main_window`, `parse_model_id`, check the file exists. Implements the inverse Phase A.1 sequential-GPU guard: when `concurrent == Some(false)`, unloads BOTH `state.whisper_engine` and `state.parakeet_engine` before loading the LLM. Default `use_gpu = true`. Loads inside `spawn_blocking`.
|
||||
|
||||
### `unload_llm_model` (`:122`), `delete_llm_model` (`:131`), `get_llm_status` (`:145`)
|
||||
|
||||
Thin wrappers. `delete_llm_model` unloads first if the model is currently loaded.
|
||||
|
||||
### `LlmTestResult` (`:155`) and `test_llm_model` (`:186`)
|
||||
|
||||
Settings → AI's "Test LLM" button. Brief item B.1 #27. Decision tree:
|
||||
|
||||
1. File missing → `not-downloaded` with a "Click Download" hint.
|
||||
2. File present but ≤ 90% of expected size → `incomplete` (truncated download) with a re-download hint. 10% tolerance because `info.size_bytes` is rounded.
|
||||
3. Same model already loaded → `ready` without disturbing the engine.
|
||||
4. Otherwise attempt `engine.load_model(id, &path, use_gpu=true)` inside `spawn_blocking` and pass any error string to `classify_llm_load_error`.
|
||||
|
||||
### `classify_llm_load_error` (`:272`)
|
||||
|
||||
Pure string classifier. Tested independently. Buckets: `load-failed-vram` (looks for `out of memory`, `oom`, `allocation failed`, `vram`, `cudamalloc`), `load-failed-corrupt` (looks for `magic`, `invalid gguf`, `unsupported file format`, `tensor shape`), `load-failed-permission` (looks for `permission denied`, `access is denied`), and `load-failed-other` for everything else. Each bucket pairs with a user-facing hint string.
|
||||
|
||||
### `cleanup_transcript_text_cmd` (`:362`)
|
||||
|
||||
`ensure_main_window`. Resolves `profile_id`. Fetches profile_terms from storage. Resolves the `preset` (Brief item B.1 #15: `Default`, `Email`, `Notes`, `Code`). `spawn_blocking` runs `llm_cleanup_text(&engine, &transcript, &profile_terms, resolved_preset)` inside a `PowerAssertion::begin("magnotia LLM cleanup")` so macOS App Nap doesn't throttle mid-token.
|
||||
|
||||
### `extract_content_tags_cmd` (`:407`)
|
||||
|
||||
Phase 9 LLM tagging. NO window guard (the History page lives in main but the command would be safe even without it; if you ever expose the History page in a secondary window, add the guard). Errors out cleanly if no LLM is loaded. `spawn_blocking` runs `engine.extract_content_tags(&transcript)` under the same App Nap power-assertion pattern.
|
||||
|
||||
### Tests (`:306`)
|
||||
|
||||
Eight `classify_llm_load_error` cases cover the four classification buckets and edge cases (cudaMalloc, OOM, generic allocation failure, GGUF magic mismatch, unsupported file format, permission denied, Windows access denied, unknown error).
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
Settings -> recommend_llm_tier() -> hardware probe -> tier string
|
||||
Settings -> download_llm_model(model_id) -> model_manager::download_model
|
||||
-> per-chunk progress events
|
||||
-> file lands in ~/.magnotia/models/llm/
|
||||
Settings -> load_llm_model(model_id, use_gpu, concurrent)
|
||||
-> if concurrent=false: unload whisper + parakeet
|
||||
-> spawn_blocking(engine.load_model)
|
||||
Settings -> test_llm_model(model_id) -> tiered checks -> LlmTestResult
|
||||
History page -> extract_content_tags_cmd(transcript) -> ContentTags { topics, intents }
|
||||
Dictation result -> cleanup_transcript_text_cmd(transcript, profile_id, preset) -> cleaned text
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **Sequential-GPU guard is the inverse here.** `load_llm_model` unloads transcription engines when `concurrent=false`. `commands::models::ensure_model_loaded` unloads the LLM. Settings owns the toggle; live transcription explicitly bypasses it (passes `None`). Confirm both halves stay in sync if you ever change the toggle's semantics.
|
||||
- **`extract_content_tags_cmd` lacks `ensure_main_window`.** Intentional today, but if you change the ACL to expose this to a non-main window, add the guard.
|
||||
- **`PowerAssertion` is a no-op outside macOS.** Long LLM cleanup on Linux can be idled by the compositor. See [Power assertions and security](power-and-security.md).
|
||||
- **Partial-download detection uses 10% tolerance.** A model that ships exactly 90% of expected size will be incorrectly flagged as incomplete. The expected size is rounded by `model_manager`, so empirically this is fine; if you tighten the rounding, also tighten the threshold.
|
||||
- **`download_llm_model` emits a percent rounded to `u8`.** A frontend that wants smoother progress bars needs to use the raw `done / total` fields.
|
||||
- **The cleanup command silently passes an unrecognised preset as `Default`** (`LlmPromptPreset::parse` returns `Default` for unknown strings). Consider erroring out instead so frontend bugs surface faster.
|
||||
|
||||
## See also
|
||||
|
||||
- [Models](models.md) — the inverse sequential-GPU guard.
|
||||
- [Tasks](tasks.md) — also calls `engine.decompose_task_with_feedback` / `extract_tasks_with_feedback` under the same App Nap pattern.
|
||||
- [Power assertions and security](power-and-security.md) — `PowerAssertion::begin` is shared.
|
||||
- [Diagnostics](diagnostics.md) — the test_llm_model failure messages cite "see Settings → About → Diagnostics bundle".
|
||||
- [Profiles](profiles.md) — `cleanup_transcript_text_cmd` reads profile_terms from storage.
|
||||
67
docs/architecture-map/02-tauri-runtime/commands/mod.md
Normal file
67
docs/architecture-map/02-tauri-runtime/commands/mod.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: commands/mod.rs — module roots and prompt builder
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::mod`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → mod.rs
|
||||
|
||||
**Plain English summary.** `mod.rs` declares all 25 child modules (22 command modules, 3 utility modules) and exports one shared helper: `build_initial_prompt`. The helper is the precedence rule that decides what the Whisper `initial_prompt` actually is when a command receives caller-supplied prompt text plus a profile prompt plus a list of profile vocabulary terms.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/mod.rs`.
|
||||
- LOC: 114 (60 of those are tests).
|
||||
- Tauri commands exposed: none.
|
||||
- Events emitted: none.
|
||||
- Depends on: nothing crate-external.
|
||||
- Called from: `commands::transcription` (whisper PCM path and file path), `commands::live::start_live_transcription_session`. Both call `build_initial_prompt(&request_prompt, &profile.initial_prompt, &profile_terms)` to assemble the final Whisper prompt before passing it into `TranscriptionOptions`.
|
||||
|
||||
## What's in here
|
||||
|
||||
### Module declarations (`src-tauri/src/commands/mod.rs:1`)
|
||||
|
||||
```
|
||||
audio, clipboard, diagnostics, feedback, fs, hardware,
|
||||
hotkey, intentions, live, llm, meeting, models,
|
||||
mod (this file), nudges, paste, power, profiles,
|
||||
rituals, security, tasks, transcription, transcripts,
|
||||
tts, update, windows
|
||||
```
|
||||
|
||||
All declared `pub mod`, so any sibling module can `use crate::commands::name`.
|
||||
|
||||
### `build_initial_prompt(request_prompt, profile_prompt, profile_terms) -> Option<String>` (`src-tauri/src/commands/mod.rs:39`)
|
||||
|
||||
Precedence:
|
||||
|
||||
1. Caller-supplied `request_prompt` wins outright when non-empty (the caller has already made the decision).
|
||||
2. Else: profile's stored prompt + profile terms (joined as `"<profile prompt> Vocabulary: term1, term2."`). The vocabulary line is the OpenWhispr pattern: feeding domain terms into Whisper's `initial_prompt` biases the decoder toward the correct spelling at decode time, before any LLM cleanup pass.
|
||||
3. Else: profile prompt alone, or `"Vocabulary: term1, term2."` alone if only terms are present.
|
||||
4. Else: `None`.
|
||||
|
||||
Whitespace-only terms are skipped. Whitespace-only prompts are treated as empty. The returned `Option<String>` is what every Whisper-side command stuffs into `TranscriptionOptions::initial_prompt`.
|
||||
|
||||
### Tests (`src-tauri/src/commands/mod.rs:65`)
|
||||
|
||||
Six test cases cover each branch of the precedence rule plus whitespace handling. These are pure-function tests, no DB.
|
||||
|
||||
## Data flow
|
||||
|
||||
The helper is called *after* the calling command has read the relevant `ProfileRow` and `ProfileTermRow`s from `magnotia_storage`. The DB I/O lives in the calling command (so the tests in this file stay pure).
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- The helper does not de-duplicate terms. If the same term appears twice in `profile_terms`, it lands twice in the vocabulary sentence. The storage layer's `add_profile_term` is what enforces uniqueness, but if the terms list ever comes from somewhere else, dedup at the call site.
|
||||
- Vocabulary length is not capped here. A profile with hundreds of terms will produce a very long `initial_prompt`, and Whisper has a context-window limit that depends on the model. If you ever ship a UI that lets users add unlimited terms, add a cap in the calling commands or here.
|
||||
- The whisper.cpp `initial_prompt` is best-effort only. It biases decoding but does not guarantee a particular word will be produced. Profile-edit-derived corrections (`commands::profiles::learn_profile_terms_from_edit_cmd`) feed back into this path on the next session.
|
||||
|
||||
## See also
|
||||
|
||||
- [Profiles](profiles.md) — `learn_profile_terms_from_edit_cmd` is what populates the `profile_terms` list this helper consumes.
|
||||
- [Transcription](transcription.md) — both whisper paths call this helper.
|
||||
- [Live transcription](live.md) — `start_live_transcription_session` calls this helper once at startup and stashes the result on the config struct.
|
||||
- [Commands index](README.md) — back to the index.
|
||||
113
docs/architecture-map/02-tauri-runtime/commands/models.md
Normal file
113
docs/architecture-map/02-tauri-runtime/commands/models.md
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: Model registry and runtime
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::models`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Models
|
||||
|
||||
**Plain English summary.** Owns the Whisper and Parakeet model lifecycle: download with progress events, presence checks, list, load (with optional sequential-GPU coordination against the LLM), and the runtime-capabilities snapshot that Settings consumes (accelerators list, active compute device, CPU features, parallel-mode availability). Also owns the silent warm-up pass that pre-allocates the Whisper context window so the user's first transcription is fast. Emits runtime warnings on startup when the CPU lacks AVX2 / FMA or the Vulkan loader is absent.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/models.rs`.
|
||||
- LOC: 691.
|
||||
- Tauri commands exposed (13 total):
|
||||
- Whisper: `download_model(size)`, `check_model(size)`, `list_models()`, `load_model(size, concurrent)`, `prewarm_default_model_cmd()`, `check_engine()`, `get_runtime_capabilities()`.
|
||||
- Parakeet: `download_parakeet_model(name)`, `check_parakeet_model(name)`, `list_parakeet_models()`, `load_parakeet_model(name, concurrent)`, `check_parakeet_engine()`.
|
||||
- Public Rust helpers (used by other command modules): `default_model_id_for_engine`, `ensure_model_loaded`, `prewarm_default_model`, `load_model_from_disk`, `detect_active_compute_device`, `emit_runtime_warnings`.
|
||||
- Events emitted: `model-download-progress` (whisper), `parakeet-download-progress`, `runtime-warning` (tagged enum: `Avx2Missing`, `VulkanLoaderMissing`, future `CudaFallback`).
|
||||
- Depends on: `magnotia_transcription::{model_manager, load_whisper, load_parakeet, LocalEngine, Transcriber}`, `magnotia_core::{model_registry, hardware, constants, types}`.
|
||||
- Called from frontend at: Settings → Models page (download, load, status), the dictation page boot path (`prewarm_default_model_cmd`), Settings → About (runtime capabilities).
|
||||
|
||||
## What's in here
|
||||
|
||||
### Model id mapping
|
||||
|
||||
- `whisper_model_id(size)` (`src-tauri/src/commands/models.rs:18`) — maps legacy size strings (`"tiny"`, `"base"`, `"small"`, `"distil-small"`, `"medium"`, `"distil-large-v3"`) to canonical `ModelId`s used by `model_registry`.
|
||||
- `parakeet_model_id(name)` (`src-tauri/src/commands/models.rs:32`) — maps `"ctc-int8"` to `parakeet-ctc-0.6b-int8`.
|
||||
|
||||
### `load_model_from_disk` (`src-tauri/src/commands/models.rs:77`)
|
||||
|
||||
Looks up the registry entry, picks the engine path:
|
||||
|
||||
- `Engine::Whisper` (gated on `feature = "whisper"`) calls `load_whisper(&model_file)`. Without the feature, returns a clear error explaining the feature is off.
|
||||
- `Engine::Parakeet` calls `load_parakeet(&dir)`.
|
||||
- `Engine::Moonshine` returns "not yet supported".
|
||||
|
||||
Returns a `Box<dyn Transcriber + Send>`.
|
||||
|
||||
### `default_model_id_for_engine` (`src-tauri/src/commands/models.rs:111`)
|
||||
|
||||
Returns `parakeet-ctc-0.6b-int8` for `"parakeet"`, `whisper-base-en` for everything else. Used by `commands::transcription`, `commands::live`, and `prewarm_default_model`.
|
||||
|
||||
### `ensure_model_loaded` (`src-tauri/src/commands/models.rs:118`)
|
||||
|
||||
The shared model-load gateway used by both whisper and parakeet command paths. Validates the engine matches the model, checks the model is downloaded, and short-circuits if the engine already has the right model loaded. Implements the Phase A.1 sequential-GPU guard (brief item #28): when `concurrent == Some(false)` and the LLM is loaded, unloads the LLM before loading the transcription engine. The inverse guard lives in `commands::llm::load_llm_model`. Loads run inside `spawn_blocking`.
|
||||
|
||||
### `prewarm_default_model` (`src-tauri/src/commands/models.rs:180`)
|
||||
|
||||
Loads the default Whisper model (if downloaded and not already loaded) plus runs a 1-second silence pass through `transcribe_sync`. The silence run pre-allocates the Whisper context window and warms GPU shader caches so the first real user transcription completes in ≤ 1.5× steady-state latency rather than the 4–5 s cold-start path documented in `ufal/whisper_streaming` issues #96 and #135. All errors are logged, never panic.
|
||||
|
||||
### `prewarm_default_model_cmd` (`src-tauri/src/commands/models.rs:225`)
|
||||
|
||||
Tauri command wrapper. `ensure_main_window`. Triggers the warm-up. The frontend invokes this after the dictation page mounts, so warm-up runs in parallel with the first paint.
|
||||
|
||||
### Runtime-capabilities reporting
|
||||
|
||||
- `RuntimeCapabilities` (`src-tauri/src/commands/models.rs:237`) — frontend-facing struct. Contains `accelerators: Vec<String>`, `engines: Vec<EngineRuntimeCapabilities>`, `active_compute_device`, `cpu_features`, `parallel_mode_available`.
|
||||
- `ActiveComputeDevice` (`:259`) — `kind`, `label`, optional `reason` (set when we fell back from a richer backend).
|
||||
- `CpuFeaturesInfo` (`:270`) — avx2 / avx512f / fma / sse4_2 / neon plus a `has_ggml_baseline` shortcut.
|
||||
- `EngineRuntimeCapabilities` and `ModelRuntimeCapabilities` carry per-engine and per-model status (downloaded, loaded, language support, default model id).
|
||||
- `compose_accelerators` (`:337`) — pure helper combining whisper-feature flag + Vulkan-loader-availability + target family. Always starts with `"cpu"`, appends `"metal"` (macOS) or `"vulkan"` (other) only when whisper is compiled in AND the loader resolves. Replaces the old hard-coded `["cpu", "vulkan"]` (RB-07).
|
||||
- `detect_active_compute_device` (`:369`) — returns the `ActiveComputeDevice` snapshot. macOS gets `"metal"` (via MoltenVK) or a CPU-fallback message; everything else gets `"vulkan"` or a CPU-fallback message naming the missing loader package.
|
||||
- `emit_runtime_warnings` (`:428`) — called from `lib.rs::run` setup. Emits `runtime-warning` events for the `Avx2Missing` and `VulkanLoaderMissing` cases. The frontend renders a one-line banner.
|
||||
- `get_runtime_capabilities` (`:454`) — assembles and returns the `RuntimeCapabilities` snapshot. Called by Settings.
|
||||
|
||||
### Whisper commands (`src-tauri/src/commands/models.rs:515`)
|
||||
|
||||
- `download_model(size) -> Result<String, String>` — main-window only. Calls `model_manager::download` with a closure that emits `model-download-progress`.
|
||||
- `check_model(size) -> Result<bool, String>` — `model_manager::is_downloaded`.
|
||||
- `list_models() -> Result<Vec<String>, String>` — returns human-readable size names of currently-downloaded whisper models.
|
||||
- `load_model(size, concurrent) -> Result<String, String>` — main-window only. Calls `ensure_model_loaded`.
|
||||
- `check_engine(state) -> Result<bool, String>` — returns whether the whisper engine has any model loaded.
|
||||
|
||||
### Parakeet commands (`src-tauri/src/commands/models.rs:576`)
|
||||
|
||||
Same shape as whisper, prefixed `parakeet_` and emitting `parakeet-download-progress`.
|
||||
|
||||
### Tests (`src-tauri/src/commands/models.rs:631`)
|
||||
|
||||
Five `compose_accelerators` permutations cover the RB-07 regression. Confirm:
|
||||
- Whisper disabled + loader present → `["cpu"]`.
|
||||
- Whisper enabled + loader missing → `["cpu"]`.
|
||||
- Whisper enabled + loader present + macOS → `["cpu", "metal"]`.
|
||||
- Whisper enabled + loader present + Linux/Windows → `["cpu", "vulkan"]`.
|
||||
- `"cpu"` is always first (frontend index-0 contract).
|
||||
|
||||
## Data flow
|
||||
|
||||
- **Download:** frontend invokes `download_model(size)` → `model_manager::download` streams bytes → progress events fire on each chunk → frontend renders progress bar.
|
||||
- **Load:** frontend invokes `load_model(size, concurrent)` → `ensure_model_loaded` validates and unloads LLM if `concurrent=false` → `load_model_from_disk` runs in `spawn_blocking` → `LocalEngine.load(model, model_id)` stashes the model on the engine.
|
||||
- **Transcribe:** every transcription path calls `ensure_model_loaded` first. Idempotent when the right model is already loaded.
|
||||
- **Runtime warnings:** fired once at startup via `lib.rs::run` setup → frontend listens for `runtime-warning` and shows a banner in Settings.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **The `concurrent` flag is a tri-state.** `None` and `Some(true)` keep the legacy parallel-residency behaviour. Only `Some(false)` triggers the unload-the-other-engine guard. Live transcription explicitly passes `None`. If you ever ship a 4 GB-VRAM Settings preset that flips `concurrent=false` by default, also test the live transcription path.
|
||||
- **`parallel_mode_available` is hard-wired to `false` until Phase A.4 lands a real GPU VRAM probe** (`src-tauri/src/commands/models.rs:509`). Today's frontend reads this and disables the toggle. Flag for follow-up.
|
||||
- **`prewarm_default_model` only runs whisper.** Parakeet has no warm-up. The Parakeet model is also smaller and loads faster, so the cold-start gap is less obvious. If you swap Magnotia's default to Parakeet, add an equivalent warm-up.
|
||||
- **Vulkan loader detection is per-platform** (`magnotia_core::hardware::vulkan_loader_available`). The CPU-fallback `reason` strings are user-visible; keep them actionable (the Linux one names `libvulkan1`, the macOS one names the Vulkan SDK runtime).
|
||||
- **Whisper feature gating.** `--no-default-features` builds compile but `load_model_from_disk` returns a runtime error when the user requests a Whisper model. `list_models` will still show whisper models that happened to be downloaded already; consider hiding them when the feature is off if a no-whisper build ever ships to users.
|
||||
- **Download progress events fire from a closure inside `model_manager::download`.** That closure is called from inside an async `spawn_blocking`-equivalent. The `let _ = app_clone.emit(...)` pattern silently drops emit errors; if a window has been closed mid-download the progress events stop landing but the download itself completes.
|
||||
|
||||
## See also
|
||||
|
||||
- [Transcription](transcription.md) — the consumer of `ensure_model_loaded`.
|
||||
- [Live transcription](live.md) — also calls `ensure_model_loaded` (with `concurrent=None`).
|
||||
- [LLM](llm.md) — the inverse sequential-GPU guard lives in `commands::llm::load_llm_model`.
|
||||
- [App lifecycle](../app-lifecycle.md) — `emit_runtime_warnings` is called from setup.
|
||||
- [Cargo and features](../cargo-and-features.md) — `feature = "whisper"` is what gates `load_model_from_disk`.
|
||||
113
docs/architecture-map/02-tauri-runtime/commands/paste.md
Normal file
113
docs/architecture-map/02-tauri-runtime/commands/paste.md
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: Paste at cursor
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::paste`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Paste
|
||||
|
||||
**Plain English summary.** Auto-insert-at-cursor: copy the transcript onto the clipboard and synthesise the platform's paste keystroke (Ctrl+V / Cmd+V) so the text lands in whatever app the user was already focused on. Adds a replace-with-raw flow that fires undo first. Skips the keystroke when the focused window is a terminal emulator (terminals duplicate the keystroke through the PTY). Hides the always-on-top preview overlay before the keystroke so a Wayland compositor doesn't accidentally route the paste back into Magnotia. Restores the user's prior clipboard 300 ms after the paste to honour the "never silently clobber the user's clipboard" contract.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/paste.rs`.
|
||||
- LOC: 790.
|
||||
- Tauri commands exposed:
|
||||
- `paste_text(app, text: String) -> Result<PasteOutcome, String>`. Copy + paste at cursor. Returns a `PasteOutcome { backend, pasted, copied, message }`.
|
||||
- `paste_text_replacing(app, text: String) -> Result<PasteOutcome, String>`. Replace flow: undo → 60 ms gap → paste. Same return shape.
|
||||
- `detect_paste_backends() -> Vec<String>`. Pure probe used by Settings: returns the names of available backends on the current OS / session.
|
||||
- Events emitted: none.
|
||||
- Depends on: `arboard::Clipboard`, `tauri::Manager` (to find and hide the preview window), `std::process::Command` (every backend shells out).
|
||||
- Called from frontend at: dictation result toast / replace-with-raw button (the two main user-facing actions).
|
||||
|
||||
## What's in here
|
||||
|
||||
### Constants (`src-tauri/src/commands/paste.rs:29`)
|
||||
|
||||
- `CLIPBOARD_RESTORE_MS = 300` — window after paste before restoring the user's prior clipboard.
|
||||
- `PREVIEW_HIDE_SETTLE_MS = 80` — compositor settle time after hiding the preview overlay.
|
||||
- `UNDO_PASTE_GAP_MS = 60` — gap between undo and follow-up paste in the replace flow (brief item #17).
|
||||
|
||||
### `PasteOutcome` (`src-tauri/src/commands/paste.rs:43`)
|
||||
|
||||
Frontend-facing struct: `backend: Option<String>` (e.g. `"wtype"`, `"xdotool"`, `"ydotool"`, `"osascript"`, `"sendkeys"`), `pasted: bool`, `copied: bool`, `message: Option<String>`.
|
||||
|
||||
### `paste_text` (`src-tauri/src/commands/paste.rs:67`)
|
||||
|
||||
Step-by-step:
|
||||
|
||||
1. Snapshot the current clipboard text via `arboard::Clipboard::get_text` (or `None` for non-text content).
|
||||
2. Write the transcript onto the clipboard. If this fails, return immediately with `copied=false` and the error.
|
||||
3. Probe the focused window via `detect_focused_terminal()`. If a known terminal class hits, return with a "Terminal detected" message; the user can finish manually with Ctrl+Shift+V or right-click. Note: prior clipboard is intentionally NOT restored here, because the user's recovery path needs the transcript on the clipboard.
|
||||
4. `hide_preview_overlay_for_paste(&app).await` — find the `transcription-preview` window if it exists and is visible, hide it, sleep 80 ms.
|
||||
5. `trigger_paste_keystroke()` — per-OS dispatch.
|
||||
6. If the keystroke fired, schedule `restore_prior_clipboard` 300 ms later via a detached `tokio::spawn`.
|
||||
|
||||
### `paste_text_replacing` (`src-tauri/src/commands/paste.rs:185`)
|
||||
|
||||
Same shape as `paste_text` but with an extra step: after copying and hiding the preview, fire `trigger_undo_keystroke()`, sleep 60 ms, then paste. The undo removes whatever text Magnotia already inserted (the cleaned-up transcript), the paste inserts the raw text. Used by the "replace with raw" frontend button per brief item #17.
|
||||
|
||||
### `detect_paste_backends` (`src-tauri/src/commands/paste.rs:258`)
|
||||
|
||||
Pure probe: Linux returns the subset of `["wtype", "xdotool", "ydotool"]` that resolve via `command -v`; macOS always returns `["osascript"]`; Windows always returns `["sendkeys"]`. Settings uses this to tell the user "install wtype" when Linux is empty.
|
||||
|
||||
### Terminal classifier
|
||||
|
||||
- `KNOWN_TERMINAL_CLASSES` (`src-tauri/src/commands/paste.rs:292`) — substring list ordered longest-first so `windowsterminal` wins over `terminal`. Covers Windows (PowerShell, conhost, console, pwsh, cmd, WindowsTerminal), macOS (Terminal, iTerm, iTerm2), Linux (alacritty, konsole, gnome-terminal-server, kitty, wezterm, foot, st, urxvt, xterm, hyper, tilix).
|
||||
- `classify_terminal(raw)` (`src-tauri/src/commands/paste.rs:331`) — case-insensitive substring search over the list.
|
||||
- `detect_focused_window_class()` plus per-OS implementations (`:345`).
|
||||
|
||||
### Per-OS focused-window probes
|
||||
|
||||
- Linux X11: `xdotool getactivewindow getwindowclassname` (`:365`).
|
||||
- Linux Wayland: returns `None`. No reliable probe from an unprivileged client. Documented as "by design".
|
||||
- macOS: `osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true'` (`:386`).
|
||||
- Windows: PowerShell P/Invoke wrapper around `GetForegroundWindow` + `GetWindowThreadProcessId` + `Get-Process` (`:407`).
|
||||
|
||||
### Per-OS paste / undo backends
|
||||
|
||||
- Linux: `linux_paste` / `linux_undo` walk `pick_linux_backend_order` (`:502`), which flips the preference order based on session type (`wtype, ydotool, xdotool` for Wayland; `xdotool, ydotool, wtype` for X11). Each tool is invoked via `Command::new`; `wtype` uses `-M ctrl v -m ctrl`, `xdotool` uses `key ctrl+v`, `ydotool` uses raw linux input keycodes (`29:1 47:1 47:0 29:0`). Undo replaces V (47) with Z (44).
|
||||
- macOS: `osascript -e 'tell ... to keystroke "v" using command down'` (`:594`). Undo swaps in `"z"`.
|
||||
- Windows: `powershell -Command "(New-Object -ComObject WScript.Shell).SendKeys('^v')"` (`:634`). Undo uses `'^z'`.
|
||||
|
||||
### Helpers
|
||||
|
||||
- `snapshot_clipboard_text` (`:125`) — wraps arboard.
|
||||
- `schedule_clipboard_restore` (`:137`) — fires a `tokio::spawn` that sleeps `CLIPBOARD_RESTORE_MS` and calls `restore_prior_clipboard`.
|
||||
- `restore_prior_clipboard` (`:152`) — reads the current clipboard, calls `should_restore` (`:171`), and writes back the prior content if and only if the clipboard still holds the transcript we wrote (i.e. the user has not copied something else in the interim).
|
||||
- `hide_preview_overlay_for_paste` (`:240`) — find the preview window, check visibility, hide, sleep 80 ms.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
frontend invoke('paste_text', { text })
|
||||
-> snapshot clipboard
|
||||
-> write transcript to clipboard
|
||||
-> detect terminal: yes -> early return with "manual paste needed" message
|
||||
-> hide preview overlay (Wayland focus quirk)
|
||||
-> trigger paste keystroke per OS
|
||||
-> spawn 300 ms timer -> restore prior clipboard if clipboard still holds the transcript
|
||||
-> return PasteOutcome
|
||||
```
|
||||
|
||||
Replace flow inserts an `undo` keystroke and a 60 ms gap between hide and paste.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **Focus must already be on the target window when the keystroke fires.** The global hotkey flow preserves this naturally; clicking Magnotia's own UI does not. The frontend Settings page surfaces the caveat next to the toggle.
|
||||
- **Wayland focused-window probe is intentionally absent.** No way to do this from an unprivileged Wayland client. Result: terminal detection on Wayland-Kitty users will miss; they fall back to the manual Ctrl+Shift+V they already use.
|
||||
- **Clipboard restore is best-effort.** If the user copies something else within the 300 ms window, `should_restore` will (correctly) decline to write back. If a slow Wayland compositor delays the keystroke past 300 ms, we restore too early and the synthesised Ctrl+V pastes the user's old clipboard. Tradeoff documented in Handy #921.
|
||||
- **Linux backend order is session-aware.** A user with `XDG_SESSION_TYPE=wayland` and only xdotool installed will fall through to xdotool with a warning; their X11-app focus on a Wayland session works fine but Wayland-native apps will not see the keystroke.
|
||||
- **No backend = clipboard-only.** If `trigger_paste_keystroke` fails on Linux (no tools installed), the user still has the transcript on the clipboard; Settings shows the install-wtype hint via `detect_paste_backends`.
|
||||
- **PowerShell process spawn cost on Windows.** Each paste spawns a fresh PowerShell. Acceptable for one-off paste invocations; if you ever build a streaming-paste mode, switch to native `SendInput` via the `windows` crate.
|
||||
- **Permissions.** The macOS path requires the user to have granted Accessibility permissions to Magnotia (System Settings → Privacy → Accessibility). Without that, `osascript` returns success but the keystroke is silently dropped. There is no probe today; flag this in onboarding.
|
||||
|
||||
## See also
|
||||
|
||||
- [Window management](windows.md) — `transcription-preview` is the window that gets hidden by `hide_preview_overlay_for_paste`.
|
||||
- [Small commands → clipboard](small-commands.md#clipboardrs) — the bare `copy_to_clipboard` command, used when the user explicitly wants clipboard-only.
|
||||
- [Live transcription](live.md) — the upstream of the dictation result that gets pasted.
|
||||
- [Hotkey bridge](hotkey.md) — the global hotkey path that preserves focus on the target window.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: Power assertions and security helpers
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::power` and `commands::security`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Power and security
|
||||
|
||||
**Plain English summary.** Two utility modules that the rest of the command files lean on. `power.rs` is the macOS App Nap power-assertion guard (no-op on Linux and Windows today). `security.rs` is `ensure_main_window`, the one-liner defence-in-depth check that several commands stamp at the top to reject calls from secondary windows even if the ACL ever drifted.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Paths: `src-tauri/src/commands/power.rs` (208 LOC), `src-tauri/src/commands/security.rs` (30 LOC).
|
||||
- Tauri commands exposed: none. Both are pure Rust utilities.
|
||||
- Events emitted: none.
|
||||
- Depends on:
|
||||
- `power.rs`: macOS only — `objc2::rc::Retained`, `objc2::runtime::ProtocolObject`, `objc2_foundation::{NSActivityOptions, NSObjectProtocol, NSProcessInfo, NSString}`.
|
||||
- `security.rs`: nothing.
|
||||
- Called from: every long-running command that wants to keep the OS awake (live transcription, LLM cleanup, content-tag extraction, task decomposition); every command that wants the extra firewall (intentions CRUD, nudges, updater stub, lots of Settings-only commands).
|
||||
|
||||
## `commands::power`
|
||||
|
||||
### `PowerAssertionSnapshot` (`src-tauri/src/commands/power.rs:28`)
|
||||
|
||||
Cloneable snapshot for the diagnostics report: `id, reason, backend, acquired`.
|
||||
|
||||
### `PowerAssertion` (`src-tauri/src/commands/power.rs:41`)
|
||||
|
||||
A `#[must_use]` guard. Holding it keeps the macOS App Nap pin alive; dropping it ends the assertion. Fields: `id`, `reason`, `backend`, `acquired`, plus a macOS-only `Option<ActivityHandle>`.
|
||||
|
||||
### `PowerAssertion::begin(reason)` (`src-tauri/src/commands/power.rs:73`)
|
||||
|
||||
Allocates a new id from a `NEXT_ID: AtomicUsize`, calls into `objc_bridge::begin_activity` on macOS, registers a snapshot in the global `assertion_registry()`. Returns the guard. Errors are logged; failure to acquire still returns a "no-op" guard (so the caller's code path is unchanged) with `acquired = false`.
|
||||
|
||||
On non-macOS, this is a no-op. The doc comment promises Linux logind inhibitors and Windows `SetThreadExecutionState`, but neither is wired up. See debt note in the slice README.
|
||||
|
||||
### `Drop for PowerAssertion` (`src-tauri/src/commands/power.rs:124`)
|
||||
|
||||
End the macOS activity if present, remove the registry entry. Logs the end on macOS so the diagnostics bundle has a breadcrumb.
|
||||
|
||||
### `assertion_registry()` (`src-tauri/src/commands/power.rs:53`)
|
||||
|
||||
Process-global `Mutex<HashMap<usize, PowerAssertionSnapshot>>` using `OnceLock`. `active_assertions_snapshot()` (`:58`) returns a sorted vector of clones for the diagnostic-report bundler.
|
||||
|
||||
### `objc_bridge` module (`src-tauri/src/commands/power.rs:140`)
|
||||
|
||||
macOS-only. Wraps `NSProcessInfo::processInfo().beginActivityWithOptions_reason(options, reason)` and `endActivity(handle)`. Options: `NSActivityOptions::UserInitiated | NSActivityOptions::LatencyCritical`. The activity object is `Retained<ProtocolObject<dyn NSObjectProtocol>>` and is `unsafe impl Send` (the activity is a process-wide pin and thread-safe).
|
||||
|
||||
### Tests (`:168`)
|
||||
|
||||
`power_assertion_is_a_no_op_drop` confirms the registry entry is added on `begin` and removed on `drop`. `multiple_assertions_get_unique_ids` confirms the id allocator works.
|
||||
|
||||
### Where `PowerAssertion::begin` is called
|
||||
|
||||
- `commands::live::run_live_session` (`live.rs:660`) — `"magnotia live dictation session"`.
|
||||
- `commands::llm::cleanup_transcript_text_cmd` (`llm.rs:394`) — `"magnotia LLM cleanup"`.
|
||||
- `commands::llm::extract_content_tags_cmd` (`llm.rs:417`) — `"magnotia LLM content-tag extraction"`.
|
||||
|
||||
NOT called from `commands::tasks::decompose_and_store` or `commands::tasks::extract_tasks_from_transcript_cmd`, even though both run multi-second LLM inference. Flag for follow-up.
|
||||
|
||||
## `commands::security`
|
||||
|
||||
### `ensure_main_window(window)` (`src-tauri/src/commands/security.rs:1`)
|
||||
|
||||
One-liner: `ensure_main_window_label(window.label())`.
|
||||
|
||||
### `ensure_main_window_label(label)` (`src-tauri/src/commands/security.rs:5`)
|
||||
|
||||
Returns `Ok(())` when label is `"main"`, `Err("This command is only available from the main window (got <label>).")` otherwise.
|
||||
|
||||
### Tests (`:15`)
|
||||
|
||||
`accepts_main_window` and `rejects_secondary_windows` (the latter checks `tasks-float`, `transcript-viewer`, and `transcription-preview` are all rejected).
|
||||
|
||||
### Where `ensure_main_window` is called
|
||||
|
||||
Approximately 30 commands. The Settings flows (model loads, LLM lifecycle, hotkey wiring, intentions CRUD), most of the diagnostics report flows, the transcription / live commands, the windows-builder commands, the audio-capture commands, the prefs save, the updater stubs.
|
||||
|
||||
NOT called from: profiles, transcripts, tasks (most), feedback, hardware, meeting, fs, clipboard, hardware, tts, hotkey-status probe, llm-status probe, llm content-tag extraction, get_os_info. Each omission is intentional today (secondary windows need to call them) but document the choice when adding new commands.
|
||||
|
||||
## Data flow
|
||||
|
||||
Both modules are passive utilities. `PowerAssertion` is RAII: hold it for the duration of the work, drop it (explicitly or by leaving scope) to end the pin. `ensure_main_window` is a synchronous gate at the top of a command body.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **`PowerAssertion` is a no-op on Linux and Windows.** Long sessions on those platforms can be idled. Document this in the user-facing Settings → AI page if you ship without a Linux logind / Windows `SetThreadExecutionState` implementation. The macOS objc2 binding requires `objc2 = "0.6.4"` and `objc2-foundation = "0.3.2"` — bumping them is an ABI move.
|
||||
- **The registry is process-wide and locked behind a `Mutex`.** Acquiring or releasing an assertion takes the mutex. Acceptable for single-digit assertions per session; if you ever spam-create assertions, this will contend.
|
||||
- **`ensure_main_window` rejects with a static message containing the offending label.** Include the label so debugging via the frontend toast is obvious; do not log the full window or any secret data.
|
||||
- **The ACL plus `ensure_main_window` is two layers.** Removing the second layer is "defence-in-depth removal", which the in-repo code review (`docs/code-review-2026-04-22.md`) flags as a non-trivial security regression. Don't.
|
||||
|
||||
## See also
|
||||
|
||||
- [App lifecycle](../app-lifecycle.md) — the panic hook and runtime warnings live alongside power.
|
||||
- [Diagnostics](diagnostics.md) — the report bundler reads `active_assertions_snapshot()`.
|
||||
- [Capabilities and ACL](../capabilities-and-acl.md) — the ACL is the first layer; this is the second.
|
||||
- [Live](live.md), [LLM](llm.md) — the main consumers of `PowerAssertion`.
|
||||
- [Tests](../tests.md) — `accepts_main_window` and `rejects_secondary_windows` plus `power_assertion_is_a_no_op_drop`.
|
||||
91
docs/architecture-map/02-tauri-runtime/commands/profiles.md
Normal file
91
docs/architecture-map/02-tauri-runtime/commands/profiles.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: Profiles
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::profiles`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Profiles
|
||||
|
||||
**Plain English summary.** Task 12 storage-backed profiles + per-profile vocabulary terms. Profiles are the unit that scopes a Whisper initial_prompt, a vocabulary list (biases the decoder toward correct spellings), HITL feedback exemplars, and transcripts. Nine commands: profile CRUD (5), profile-term CRUD (3), and the auto-learn pass that diffs an original transcript against an edited transcript and persists likely vocabulary corrections.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/profiles.rs`.
|
||||
- LOC: 185.
|
||||
- Tauri commands exposed (9 total):
|
||||
- `list_profiles_cmd(state) -> Result<Vec<ProfileDto>, String>`.
|
||||
- `get_profile_cmd(state, id) -> Result<Option<ProfileDto>, String>`.
|
||||
- `create_profile_cmd(state, name, initial_prompt) -> Result<ProfileDto, String>`.
|
||||
- `update_profile_cmd(state, id, name, initial_prompt) -> Result<(), String>`.
|
||||
- `delete_profile_cmd(state, id) -> Result<(), String>`. The Default profile is guarded at the storage layer (SQLite triggers + Rust pre-checks).
|
||||
- `list_profile_terms_cmd(state, profile_id) -> Result<Vec<ProfileTermDto>, String>`.
|
||||
- `add_profile_term_cmd(state, profile_id, term, note) -> Result<ProfileTermDto, String>`.
|
||||
- `learn_profile_terms_from_edit_cmd(state, profile_id, original_text, edited_text) -> Result<Vec<ProfileTermDto>, String>`.
|
||||
- `delete_profile_term_cmd(state, id) -> Result<(), String>`.
|
||||
- Events emitted: none.
|
||||
- Depends on: `magnotia_storage::{create_profile, update_profile, delete_profile, list_profiles, get_profile, add_profile_term, list_profile_terms, delete_profile_term, ProfileRow, ProfileTermRow}`, `magnotia_ai_formatting::extract_corrections`.
|
||||
- Called from frontend at: Settings → Profiles (full CRUD), profile picker, History viewer (the auto-learn flow runs after the user saves an edit).
|
||||
|
||||
## What's in here
|
||||
|
||||
### `ProfileDto` and `ProfileTermDto` (`src-tauri/src/commands/profiles.rs:31`, `:51`)
|
||||
|
||||
camelCase mirrors of the storage rows. `ProfileDto.initialPrompt` is the saved Whisper prompt; `ProfileTermDto.term` is the vocabulary entry, `note` is a freeform note (used to mark `"Auto-learned from transcript edit"` for the auto-learn flow).
|
||||
|
||||
### `AUTO_LEARNED_NOTE` (`:25`)
|
||||
|
||||
Constant so the auto-learn rows are uniformly tagged.
|
||||
|
||||
### CRUD wrappers
|
||||
|
||||
- `list_profiles_cmd` (`:72`), `get_profile_cmd` (`:82`), `create_profile_cmd` (`:93`), `update_profile_cmd` (`:105`), `delete_profile_cmd` (`:117`). Pure passthroughs to storage.
|
||||
- `list_profile_terms_cmd` (`:127`), `add_profile_term_cmd` (`:138`), `delete_profile_term_cmd` (`:177`). Same pattern.
|
||||
|
||||
### `learn_profile_terms_from_edit_cmd` (`:151`)
|
||||
|
||||
1. Pull the existing terms (so the diff doesn't propose duplicates).
|
||||
2. Call `magnotia_ai_formatting::extract_corrections(&original, &edited, &existing_terms)`.
|
||||
3. Persist each new term via `add_profile_term` with the `AUTO_LEARNED_NOTE`.
|
||||
4. Return the freshly-inserted DTOs.
|
||||
|
||||
The actual diff heuristic lives in the formatting crate; the command file is just wiring.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
Settings Profiles tab -> list_profiles_cmd -> [ProfileDto, ...]
|
||||
profile picker -> get_profile_cmd(id)
|
||||
add profile -> create_profile_cmd(name, prompt) -> ProfileDto
|
||||
edit profile -> update_profile_cmd(id, name, prompt) -> ()
|
||||
delete profile -> delete_profile_cmd(id) -> () (Default is rejected at storage)
|
||||
|
||||
Profile terms tab -> list_profile_terms_cmd(profile_id)
|
||||
add term -> add_profile_term_cmd(profile_id, term, note) -> ProfileTermDto
|
||||
delete term -> delete_profile_term_cmd(id) -> ()
|
||||
|
||||
History viewer save edit:
|
||||
-> learn_profile_terms_from_edit_cmd(profile_id, original, edited)
|
||||
-> existing terms from DB
|
||||
-> extract_corrections(original, edited, existing) -> [String]
|
||||
-> add each as a profile term tagged "Auto-learned from transcript edit"
|
||||
-> [ProfileTermDto, ...]
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **No `ensure_main_window` guard.** The History viewer is a secondary window (`transcript-viewer`) and needs to call `learn_profile_terms_from_edit_cmd` after a save. So the whole module is callable from anywhere with the secondary-windows capability. If you want to lock down profile *creation* / *deletion*, add `ensure_main_window` to the destructive ones.
|
||||
- **No `PowerAssertion`.** No inference here.
|
||||
- **`extract_corrections` runs synchronously (in the async fn).** Acceptable for the small-text shape of a single transcript edit.
|
||||
- **The Default profile is unkillable.** SQLite trigger rejects the delete. Frontend should grey out the delete button when `profile.id == DEFAULT_PROFILE_ID`.
|
||||
- **Auto-learned terms are recorded one at a time inside a loop** (`:166`). Each iteration is a separate insert. Acceptable; if a future heuristic produces dozens of terms per edit, batch.
|
||||
|
||||
## See also
|
||||
|
||||
- [`commands::mod`](mod.md) — `build_initial_prompt` consumes profile prompt + terms.
|
||||
- [Transcription](transcription.md) — fetches profile + terms before transcribing.
|
||||
- [Live transcription](live.md) — same upstream.
|
||||
- [Tasks](tasks.md) — feedback exemplars are profile-scoped.
|
||||
- [Transcripts](transcripts.md) — every transcript carries a `profileId`.
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: Small command modules
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Small command modules
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Small commands
|
||||
|
||||
**Plain English summary.** Seven short modules that each declare one or two commands. Grouped here to keep the slice navigable. Covers clipboard, filesystem write, hardware probe, meeting auto-detect poll, nudges, rituals (morning triage), and the updater stub.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Modules covered:
|
||||
- `clipboard.rs` (11 LOC, 1 command).
|
||||
- `fs.rs` (44 LOC, 1 command).
|
||||
- `hardware.rs` (69 LOC, 2 commands).
|
||||
- `meeting.rs` (50 LOC, 1 command).
|
||||
- `nudges.rs` (63 LOC, 1 command).
|
||||
- `rituals.rs` (43 LOC, 2 commands).
|
||||
- `update.rs` (16 LOC, 2 commands).
|
||||
- Total: 11 commands across 7 small files.
|
||||
|
||||
## `clipboard.rs`
|
||||
|
||||
### `copy_to_clipboard(text: String) -> Result<(), String>` (`src-tauri/src/commands/clipboard.rs:5`)
|
||||
|
||||
Wraps `arboard::Clipboard::set_text`. No window guard. The frontend dictation path calls this when the user wants clipboard-only (no auto-paste), and the History viewer uses it for the copy button. Pairs with `commands::paste::paste_text` for the auto-paste flow.
|
||||
|
||||
Watch-out: arboard initialisation can fail on Linux Wayland environments without an X11 selection daemon; the error is propagated as `"Clipboard init failed: ..."`.
|
||||
|
||||
## `fs.rs`
|
||||
|
||||
### `write_text_file_cmd(path: String, contents: String) -> Result<(), String>` (`src-tauri/src/commands/fs.rs:13`)
|
||||
|
||||
Phase 9. Thin filesystem write for the save-dialog path. `tokio::fs::write(&path, contents)` with the path attached to the error message so the frontend toast is actionable. The caller is expected to obtain `path` via the OS save dialog (`tauri-plugin-dialog`); no traversal validation here because the dialog already constrains the user's choice.
|
||||
|
||||
Tests (`fs.rs:19`): `write_text_file_roundtrips_utf8` covers UTF-8 round-trip including non-ASCII; `write_text_file_errors_on_bad_parent` covers a non-existent parent directory.
|
||||
|
||||
## `hardware.rs`
|
||||
|
||||
### `SystemInfo` and `ModelRecommendation`
|
||||
|
||||
Frontend-facing structs. `SystemInfo` carries `ram_mb`, `cpu_brand`, `cpu_cores`, `os`, `gpu`. `ModelRecommendation` carries id / display_name / disk_size_mb / ram_required_mb / description / score / reason / is_downloaded.
|
||||
|
||||
### `probe_system() -> Result<SystemInfo, String>` (`src-tauri/src/commands/hardware.rs:29`)
|
||||
|
||||
Wraps `magnotia_core::hardware::probe_system`. Maps the OS enum to a string and the GPU vendor (if probed) to its `Debug` form.
|
||||
|
||||
### `rank_models() -> Result<Vec<ModelRecommendation>, String>` (`src-tauri/src/commands/hardware.rs:49`)
|
||||
|
||||
Calls `magnotia_core::recommendation::rank_recommendations` and decorates each entry with `magnotia_transcription::is_downloaded`. Used by Settings → Models for the "recommended for your hardware" list.
|
||||
|
||||
Both commands are unguarded (any window can call). Pure read-only probes.
|
||||
|
||||
## `meeting.rs`
|
||||
|
||||
### `MeetingState` (`src-tauri/src/commands/meeting.rs:16`)
|
||||
|
||||
Tauri-managed: `lister: Mutex<ProcessLister>`. Holds a long-lived `ProcessLister` so each poll refreshes the existing `sysinfo::System` in place rather than rebuilding the process table from scratch every 15 seconds (the previous implementation did).
|
||||
|
||||
### `detect_meeting_processes(state, patterns: Vec<String>) -> Result<Vec<String>, String>` (`src-tauri/src/commands/meeting.rs:34`)
|
||||
|
||||
Phase 8 meeting auto-capture (single-signal variant). Frontend polls this on an interval with the user's app patterns. On a positive hit, the frontend surfaces a non-modal toast that reminds the user to start recording with their hotkey. We do NOT start recording from this signal — the user decides.
|
||||
|
||||
If `patterns` is empty, returns an empty Vec without locking. Otherwise locks the `lister`, snapshots the process list, and runs `magnotia_core::process_watch::match_meeting_patterns` to filter. Returns the matched process names.
|
||||
|
||||
Watch-out: the `ProcessLister` lock is `std::sync::Mutex`. If the snapshot ever takes meaningful time, switch to async.
|
||||
|
||||
## `nudges.rs`
|
||||
|
||||
### `DeliverNudgeInput` (`src-tauri/src/commands/nudges.rs:28`)
|
||||
|
||||
`{ title: String, body: String }`.
|
||||
|
||||
### `deliver_nudge(app, window, input: DeliverNudgeInput) -> Result<(), String>` (`src-tauri/src/commands/nudges.rs:42`)
|
||||
|
||||
Phase 6. Main-window only via `ensure_main_window`. Trims title and body; if both are empty, return Ok silently (a blank nudge is worse than no nudge). Defaults the title to `"Magnotia"` if only the body is present. Calls `tauri_plugin_notification::NotificationExt::notification().builder().title(...).body(...).show()`.
|
||||
|
||||
The frontend nudge bus (`nudgeBus.svelte.ts`) owns cadence, suppression, and the hourly cap. This command is a blunt "push it now" primitive — no rate limiting at the Rust layer. Errors propagate verbatim so the bus can log + swallow.
|
||||
|
||||
## `rituals.rs`
|
||||
|
||||
### Morning-triage sentinel
|
||||
|
||||
Frontend owns rendering and logic; this module only persists the "last date the morning triage modal was shown" sentinel under SQLite settings key `magnotia_morning_triage_last_shown`.
|
||||
|
||||
- `get_last_morning_triage(state) -> Result<Option<String>, String>` (`src-tauri/src/commands/rituals.rs:23`).
|
||||
- `mark_morning_triage_shown(state, date: String) -> Result<(), String>` (`src-tauri/src/commands/rituals.rs:36`). Caller passes a YYYY-MM-DD string in the user's local timezone — Rust deliberately stays timezone-agnostic.
|
||||
|
||||
No `ensure_main_window` guard. Acceptable: the morning triage UI lives in the main window, but the data is harmless if a secondary window ever queries.
|
||||
|
||||
## `update.rs`
|
||||
|
||||
Updater stubs.
|
||||
|
||||
- `check_for_update(window) -> Result<Option<String>, String>` (`src-tauri/src/commands/update.rs:6`). Main-window only. Currently always returns `Ok(None)` ("up to date").
|
||||
- `install_update(window) -> Result<(), String>` (`src-tauri/src/commands/update.rs:13`). Main-window only. Currently returns `Err("Updates are disabled until release signing is configured.")`.
|
||||
|
||||
The matching `tauri.conf.json` `plugins.updater` entry is absent. The integration test `updater_is_signed_or_absent` (`src-tauri/tests/config_hardening.rs:35`) gates a future release: any updater config that ships must carry a non-empty `pubkey`.
|
||||
|
||||
## See also
|
||||
|
||||
- [Paste](paste.md) — the auto-paste sibling of `clipboard::copy_to_clipboard`.
|
||||
- [Diagnostics](diagnostics.md) — the report bundler that consumes the same OS info `hardware::probe_system` reports.
|
||||
- [Tauri config](../tauri-config.md) — the absent `plugins.updater` block that the integration test guards.
|
||||
- [Capabilities and ACL](../capabilities-and-acl.md) — the notification permission set that `nudges::deliver_nudge` relies on.
|
||||
- [Power assertions and security](power-and-security.md) — `ensure_main_window` guards live here.
|
||||
119
docs/architecture-map/02-tauri-runtime/commands/tasks.md
Normal file
119
docs/architecture-map/02-tauri-runtime/commands/tasks.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: Tasks and decomposition
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::tasks`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Tasks
|
||||
|
||||
**Plain English summary.** Eleven commands wrapping the `magnotia_storage` task CRUD plus two LLM-driven actions. Standard CRUD: create / list / update / complete / uncomplete / delete a task, plus subtask CRUD (insert / list / complete) and a daily-completion-counts query for the Phase 8 Tasks-page momentum sparkline. The two LLM actions: `decompose_and_store` (break a parent task into subtasks via the local LLM with HITL feedback as few-shot exemplars) and `extract_tasks_from_transcript_cmd` (extract task lines from a recently-finished transcript, again with feedback exemplars).
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/tasks.rs`.
|
||||
- LOC: 402.
|
||||
- Tauri commands exposed (11 total):
|
||||
- `create_task_cmd(state, request: CreateTaskRequest) -> Result<TaskDto, String>`.
|
||||
- `list_tasks_cmd(state) -> Result<Vec<TaskDto>, String>`.
|
||||
- `update_task_cmd(state, id, patch: UpdateTaskRequest) -> Result<TaskDto, String>`. Patch shape (text / bucket / list_id / effort / notes).
|
||||
- `complete_task_cmd(state, id) -> Result<(), String>`. Stamps server-side `done_at`.
|
||||
- `delete_task_cmd(state, id) -> Result<(), String>`.
|
||||
- `uncomplete_task_cmd(state, id) -> Result<(), String>`. Clears `done_at`.
|
||||
- `set_task_energy_cmd(state, id, energy: Option<String>) -> Result<TaskDto, String>`. Always writes; passes `None` to clear.
|
||||
- `decompose_and_store(state, parent_task_id, profile_id) -> Result<Vec<TaskDto>, String>`.
|
||||
- `extract_tasks_from_transcript_cmd(state, transcript, profile_id) -> Result<Vec<String>, String>`.
|
||||
- `list_subtasks_cmd(state, parent_task_id) -> Result<Vec<TaskDto>, String>`.
|
||||
- `complete_subtask_cmd(state, subtask_id) -> Result<(), String>`.
|
||||
- `list_recent_completions_cmd(state, days) -> Result<Vec<DailyCompletionCount>, String>`.
|
||||
- Events emitted: none.
|
||||
- Depends on: `magnotia_storage::{insert_task, list_tasks, update_task, complete_task, uncomplete_task, delete_task, set_task_energy, get_task_by_id, insert_subtask, list_subtasks, complete_subtask_and_check_parent, list_recent_completions, list_feedback_examples, FeedbackTargetType, TaskRow, DailyCompletionCount, FeedbackRow}`, `magnotia_llm::prompts::FeedbackExample`, `uuid::Uuid`.
|
||||
- Called from frontend at: Tasks page (CRUD, subtasks, sparkline), dictation result panel ("Extract tasks" button), parent-task expand UI ("Decompose").
|
||||
|
||||
## What's in here
|
||||
|
||||
### `TaskDto` (`src-tauri/src/commands/tasks.rs:24`)
|
||||
|
||||
camelCase frontend shape with id, text, bucket, listId, effort, notes, done, doneAt, createdAt, sourceTranscriptId, parentTaskId, energy.
|
||||
|
||||
### Energy validation (`:60`)
|
||||
|
||||
`ENERGY_LEVELS = ["high", "medium", "brain_dead"]` — kept as a const so frontend and storage validate against the same set. SQLite migration v11 enforces the same set with a CHECK constraint.
|
||||
|
||||
### CRUD wrappers
|
||||
|
||||
- `create_task_cmd` (`:92`) inserts and re-fetches so the frontend gets the canonical row (server-side timestamps, defaulted columns).
|
||||
- `update_task_cmd` (`:140`) — patch shape, COALESCE-style updates in storage. `done` / `doneAt` are deliberately absent — those flow through `complete_task_cmd` / `uncomplete_task_cmd`.
|
||||
- `set_task_energy_cmd` (`:200`) — separate from update because update uses `COALESCE` semantics where `None` means "preserve". This command always writes, so passing `None` actually clears the column.
|
||||
|
||||
### `to_llm_examples` (`:222`)
|
||||
|
||||
Converts feedback rows from storage into the few-shot exemplar shape the LLM crate consumes. Reconstructs `input` from `context_json` (parent task text or transcript chunk). Rows with malformed `context_json` are logged and dropped instead of silently filtered, so data-integrity regressions surface.
|
||||
|
||||
### `example_char_cost` (`:263`) and `trim_to_budget` (`:276`)
|
||||
|
||||
Char budget for the few-shot exemplars: keeps the prompt under token budget regardless of how many feedback rows the user has accumulated. Cap at 5 examples AND a char budget.
|
||||
|
||||
### `decompose_and_store` (`:290`)
|
||||
|
||||
1. Fetch the parent task or 404.
|
||||
2. Pull up to 5 micro-step feedback exemplars filtered by profile, char-trimmed.
|
||||
3. `spawn_blocking` runs `engine.decompose_task_with_feedback(parent_text, &examples)`.
|
||||
4. Insert each generated step as a subtask (`uuid::v4()` for the id), re-fetch, accumulate.
|
||||
5. Return the list.
|
||||
|
||||
NO `PowerAssertion::begin` here — the App Nap pattern is consistent in `commands::llm` but not yet uniformly applied here. Flag for follow-up.
|
||||
|
||||
### `extract_tasks_from_transcript_cmd` (`:345`)
|
||||
|
||||
Same shape as `decompose_and_store` but with `FeedbackTargetType::TaskExtraction` and the engine's `extract_tasks_with_feedback`. Returns the raw task strings — the frontend decides whether to insert them.
|
||||
|
||||
### `list_subtasks_cmd` / `complete_subtask_cmd` (`:370`, `:381`)
|
||||
|
||||
`complete_subtask_cmd` calls `complete_subtask_and_check_parent` which is the storage helper that stamps the subtask done and, if all siblings are done, also stamps the parent (Phase 8 micro-completion bookkeeping).
|
||||
|
||||
### `list_recent_completions_cmd` (`:394`)
|
||||
|
||||
Fixed-length oldest-first daily counts. Empty days are explicit zeros. The Tasks-page sparkline reads this directly.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
Tasks page mount -> list_tasks_cmd -> [TaskDto, ...]
|
||||
Tasks page create -> create_task_cmd(req) -> TaskDto
|
||||
Tasks page edit -> update_task_cmd(id, patch) -> TaskDto
|
||||
Tasks page check -> complete_task_cmd(id) -> ()
|
||||
Tasks page reopen -> uncomplete_task_cmd(id) -> ()
|
||||
Tasks page energy chip -> set_task_energy_cmd(id, energy) -> TaskDto
|
||||
Tasks page sparkline -> list_recent_completions_cmd(days) -> [{date, count}, ...]
|
||||
|
||||
Tasks page Decompose button -> decompose_and_store(parent_id, profile_id)
|
||||
-> get parent task
|
||||
-> list 5 micro-step feedback rows (profile-scoped)
|
||||
-> trim_to_budget
|
||||
-> spawn_blocking(engine.decompose_task_with_feedback)
|
||||
-> insert subtasks
|
||||
-> [TaskDto, ...]
|
||||
|
||||
Dictation panel Extract tasks -> extract_tasks_from_transcript_cmd(text, profile_id)
|
||||
-> list 5 task-extraction feedback rows
|
||||
-> spawn_blocking(engine.extract_tasks_with_feedback)
|
||||
-> [String, ...]
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **No `ensure_main_window` guard.** Tasks UI lives in the main window AND in the always-on-top task float (which has the secondary-windows capability). The float can call list / complete / set-energy / list-recent-completions etc. — that's intentional — but it can also fire `decompose_and_store` and `extract_tasks_from_transcript_cmd`, which spend LLM tokens. If you want to lock this down, add the guard to the LLM-spending commands.
|
||||
- **No `PowerAssertion`.** `decompose_and_store` and `extract_tasks_from_transcript_cmd` both run synchronous LLM inference for several seconds. macOS can App Nap them. Add `PowerAssertion::begin("magnotia LLM task decomposition")` and similar.
|
||||
- **The patch shape passes `Option<String>` for every column.** Currently the storage layer's `update_task` uses COALESCE: `Some` overwrites, `None` preserves. This means there's no way to clear `notes` or `effort` to empty via `update_task_cmd` — you can only set them to a non-empty string. If a user wants to clear a field, they'd need a fresh task or a dedicated clear command.
|
||||
- **Decompose stores subtasks one-by-one in a loop** (`:329`). Each iteration is a separate DB transaction. Acceptable for typical 3–7-step decompositions; if a future LLM produces 50 steps, batch the inserts.
|
||||
- **`extract_tasks_from_transcript_cmd` returns just `Vec<String>` and does NOT insert.** Frontend chooses what to insert. Confusing because the sibling `decompose_and_store` *does* insert. Convention is dictated by UX (decomposition is one-click, extraction reviews-then-inserts).
|
||||
|
||||
## See also
|
||||
|
||||
- [LLM](llm.md) — the engine that runs the decomposition / extraction.
|
||||
- [Feedback](feedback.md) — the table that feeds the few-shot exemplars.
|
||||
- [Profiles](profiles.md) — `profile_id` is the scoping key for feedback rows.
|
||||
- [Window management](windows.md) — the task-float window (`tasks-float`) that consumes `list_tasks_cmd`.
|
||||
111
docs/architecture-map/02-tauri-runtime/commands/transcription.md
Normal file
111
docs/architecture-map/02-tauri-runtime/commands/transcription.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: Transcription commands
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::transcription`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Transcription
|
||||
|
||||
**Plain English summary.** The non-live transcription paths. Three commands: transcribe a Vec<f32> of PCM samples through Whisper, transcribe the same shape through Parakeet, and transcribe a file from disk by decoding + resampling to 16 kHz first. Both PCM commands emit `transcription-result` events; the file command returns the result inline. All three run inference on a blocking thread, run the formatting pipeline against the output, and respect profile prompt + vocabulary precedence via the shared `build_initial_prompt` helper.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/transcription.rs`.
|
||||
- LOC: 413.
|
||||
- Tauri commands exposed:
|
||||
- `transcribe_pcm(window, state, app, samples: Vec<f32>, chunk_id: u32, language, initial_prompt, remove_fillers, british_english, anti_hallucination, format_mode, profile_id) -> Result<(), String>` — main-window only. Whisper PCM. Emits `transcription-result`.
|
||||
- `transcribe_file(window, state, path, engine: Option<String>, model_id: Option<String>, language, initial_prompt, remove_fillers, british_english, anti_hallucination, format_mode, profile_id) -> Result<serde_json::Value, String>` — main-window only. Decodes the file, picks engine (default whisper), returns the result inline.
|
||||
- `transcribe_pcm_parakeet(window, state, app, samples, chunk_id, remove_fillers, british_english, anti_hallucination, format_mode, profile_id) -> Result<(), String>` — main-window only. Parakeet PCM. Emits `transcription-result`.
|
||||
- Events emitted: `transcription-result` (payload: `{ status: "transcription", segments, language, duration, chunk_id, inference_ms, raw_text }`) — fires from `transcribe_pcm` (`src-tauri/src/commands/transcription.rs:208`) and `transcribe_pcm_parakeet` (`:398`).
|
||||
- Depends on: `magnotia_audio::{decode_audio_file_limited, resample_to_16khz, probe_audio_duration_secs}`, `magnotia_core::types::{AudioSamples, Segment, Transcript, TranscriptionOptions}`, `magnotia_transcription::{LocalEngine, TimedTranscript}`, `magnotia_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}`, `magnotia_storage::{database, DEFAULT_PROFILE_ID}`. Plus `commands::build_initial_prompt`, `commands::models::{default_model_id_for_engine, ensure_model_loaded}`, `commands::security::ensure_main_window`.
|
||||
- Called from frontend at: dictation page (PCM commands when not live), file-import flow (transcribe_file), Settings test page (file command for QA).
|
||||
|
||||
## What's in here
|
||||
|
||||
### Constants (`src-tauri/src/commands/transcription.rs:18`)
|
||||
|
||||
Chunking thresholds:
|
||||
|
||||
- Parakeet: chunk anything over 18 s into 15 s windows with 1 s overlap.
|
||||
- Generic file: chunk anything over 8 minutes into 3-minute windows with 2 s overlap.
|
||||
- Hard cap: `MAX_FILE_TRANSCRIPTION_SECS = 2 * 60 * 60` (2 hours).
|
||||
|
||||
### `pick_engine` (`:31`)
|
||||
|
||||
Maps `"whisper"` and `"parakeet"` to the relevant `Arc<LocalEngine>` from `AppState`.
|
||||
|
||||
### `pick_chunking_strategy` (`:42`)
|
||||
|
||||
Returns the `ChunkingStrategy` to use based on engine + sample count, or `None` for "no chunking needed".
|
||||
|
||||
### `trim_overlap_segments` (`:61`)
|
||||
|
||||
For chunks past the first, drops segments that end before `trim_before_secs` and clamps remaining starts. The same logic appears in `commands::live` for the live-mode path.
|
||||
|
||||
### `transcribe_samples_sync` (`:74`)
|
||||
|
||||
The shared inner that the file path uses. If no chunking is needed, calls `engine.transcribe_sync` once. Otherwise loops over chunks, runs each through inference, trims overlap, offsets timestamps by the chunk start, accumulates segments and inference_ms. Returns a synthesised `TimedTranscript`.
|
||||
|
||||
### `transcribe_pcm` (`:142`)
|
||||
|
||||
Whisper-specific PCM path (no chunking — frontend is expected to keep the buffer reasonable, e.g. ≤ 30 s for the Whisper context window):
|
||||
|
||||
1. `ensure_main_window`.
|
||||
2. Resolve `profile_id` (default `DEFAULT_PROFILE_ID`).
|
||||
3. Fetch `ProfileRow` and profile term list from `magnotia_storage::database`.
|
||||
4. Build effective Whisper prompt via `build_initial_prompt(&caller_prompt, &profile.initial_prompt, &profile_terms)`.
|
||||
5. `spawn_blocking` runs `engine.transcribe_sync` on the samples.
|
||||
6. Run `post_process_segments` (filler removal, British English conversion, anti-hallucination, format mode, dictionary terms, optional LLM cleanup via `state.llm_engine`).
|
||||
7. `app.emit("transcription-result", ...)` with raw_text (pre-post-process) plus the post-processed segments.
|
||||
|
||||
### `transcribe_file` (`:236`)
|
||||
|
||||
1. `ensure_main_window`.
|
||||
2. Resolve profile + terms (same as PCM path).
|
||||
3. Default engine to `"whisper"`, default model id to `default_model_id_for_engine(&engine_name)`.
|
||||
4. `ensure_model_loaded(state, engine, model_id, None)` — None = no sequential-GPU guard.
|
||||
5. Probe audio duration via `magnotia_audio::probe_audio_duration_secs`. If > 2 hours, return a friendly error.
|
||||
6. `spawn_blocking` decodes the file (`decode_audio_file_limited(path, Some(MAX_FILE_TRANSCRIPTION_SECS))`), resamples to 16 kHz mono, then runs `transcribe_samples_sync`.
|
||||
7. Run `post_process_segments`.
|
||||
8. Return a JSON value with `engine`, `modelId`, `segments`, `language`, `duration`, `inference_ms`, `raw_text`.
|
||||
|
||||
### `transcribe_pcm_parakeet` (`:342`)
|
||||
|
||||
Parakeet PCM path. Skips the `initial_prompt` construction (Parakeet doesn't have a Whisper-style prompt) but still validates the profile exists and gathers the dictionary terms for post-processing. Calls `transcribe_samples_sync(parakeet_engine, "parakeet", samples, options)` so the > 18 s chunking kicks in if relevant. Emits `transcription-result` like the Whisper path.
|
||||
|
||||
### `join_segment_text` (`:225`)
|
||||
|
||||
Concatenates segment text with whitespace stripping for the `raw_text` field on the emitted event.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
frontend invoke('transcribe_pcm', { samples, chunk_id, ..., profile_id })
|
||||
-> ensure_main_window
|
||||
-> get_profile + list_profile_terms (DB)
|
||||
-> build_initial_prompt
|
||||
-> spawn_blocking(engine.transcribe_sync(samples, options)) -> TimedTranscript
|
||||
-> post_process_segments(segments, opts, llm_engine) -- in-place
|
||||
-> app.emit("transcription-result", { segments, raw_text, ... })
|
||||
```
|
||||
|
||||
`transcribe_file` is the same shape with `decode_audio_file_limited` + `resample_to_16khz` + `transcribe_samples_sync` substituted for the inline `transcribe_sync`.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **`transcribe_pcm` does NOT call `ensure_model_loaded`.** It assumes the model is already loaded (which is true when the frontend has driven the Settings → load flow, and is true after `prewarm_default_model_cmd`). If a third caller invokes `transcribe_pcm` without a load step, you'll get a runtime panic from `engine.transcribe_sync` with no clear message. The file path *does* call `ensure_model_loaded`. Consider adding the same guard to `transcribe_pcm` for symmetry.
|
||||
- **`transcribe_file` returns a `serde_json::Value` rather than a typed DTO.** The shape is fully ad-hoc (`engine`, `modelId`, `segments`, `language`, `duration`, `inference_ms`, `raw_text`). The frontend has to keep this shape in sync by hand. Consider promoting it to a typed struct.
|
||||
- **Chunking does not emit per-chunk events from this command.** The file path returns the entire concatenated result at the end. If a 90-minute file fails three quarters of the way through, the user gets nothing. Live mode (`commands::live`) is the streaming alternative.
|
||||
- **The 2-hour cap is a hard constant.** Document it in the frontend "import audio" flow.
|
||||
- **The Parakeet PCM path does not respect `language` or `initial_prompt`** because Parakeet has no equivalent. The frontend should disable those Settings widgets when the engine is Parakeet, or this command will silently ignore them.
|
||||
|
||||
## See also
|
||||
|
||||
- [Models](models.md) — `ensure_model_loaded`, `default_model_id_for_engine`, the `LocalEngine` cache that `state.whisper_engine` and `state.parakeet_engine` wrap.
|
||||
- [Live transcription](live.md) — the streaming sibling.
|
||||
- [Profiles](profiles.md) — feeds `profile.initial_prompt` and the term list.
|
||||
- [`commands::mod`](mod.md) — `build_initial_prompt` is the prompt assembler.
|
||||
- [Audio capture](audio.md) — supplies the `Vec<f32>` that `transcribe_pcm` consumes.
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: Transcripts CRUD and search
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::transcripts`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Transcripts
|
||||
|
||||
**Plain English summary.** SQLite-backed transcripts: insert, paginated list, count, get, update text/title, patch metadata (starred, manual_tags, template, language, segments_json, llm_tags), delete, and FTS5 search. Replaces the old localStorage cache. Day 4 of the upgrade plan; the History page rename flow had a TODO waiting on `update_transcript` to exist, and now it does. The legacy global-dictionary commands (`list_dictionary_command` and friends) were removed — profile-scoped `profile_terms` is now canonical.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/transcripts.rs`.
|
||||
- LOC: 253.
|
||||
- Tauri commands exposed (8 total):
|
||||
- `add_transcript(state, transcript: CreateTranscriptRequest) -> Result<(), String>`.
|
||||
- `list_transcripts(state, limit, offset) -> Result<Vec<TranscriptDto>, String>` — defaults 50 rows, clamp 1..=500.
|
||||
- `count_transcripts_command(state) -> Result<i64, String>`.
|
||||
- `get_transcript(state, id) -> Result<Option<TranscriptDto>, String>`.
|
||||
- `update_transcript(state, id, text: Option<String>, title: Option<String>) -> Result<u64, String>` — returns affected row count (0 if id not found).
|
||||
- `update_transcript_meta_cmd(state, id, patch: UpdateTranscriptMetaRequest) -> Result<TranscriptDto, String>` — patch shape with COALESCE semantics.
|
||||
- `delete_transcript(state, id) -> Result<(), String>`.
|
||||
- `search_transcripts(state, query) -> Result<Vec<TranscriptDto>, String>` — FTS5; up to 50 best-rank.
|
||||
- Events emitted: none.
|
||||
- Depends on: `magnotia_storage::{insert_transcript, list_transcripts_paged, count_transcripts, get_transcript, update_transcript, update_transcript_meta, delete_transcript, search_transcripts, InsertTranscriptParams, TranscriptRow, DEFAULT_PROFILE_ID}`.
|
||||
- Called from frontend at: History page (list / search / get / update / delete), dictation result panel (`add_transcript`), Settings → About (`count_transcripts_command`), History viewer window (`update_transcript_meta_cmd` for star / template / language / llm_tags).
|
||||
|
||||
## What's in here
|
||||
|
||||
### `TranscriptDto` (`src-tauri/src/commands/transcripts.rs:35`)
|
||||
|
||||
camelCase frontend shape. The Task 2.5 fields (`starred`, `manualTags`, `template`, `language`, `segmentsJson`) were added when localStorage was retired. Phase 9 added `llmTags`.
|
||||
|
||||
### `CreateTranscriptRequest` (`:79`)
|
||||
|
||||
Wide shape covering everything an insert needs: id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, plus the post-processing flags (remove_fillers, british_english, anti_hallucination).
|
||||
|
||||
### `UpdateTranscriptMetaRequest` (`:215`)
|
||||
|
||||
Patch shape for Task 2.5 / Phase 9 metadata: each field is `Option`. `None` preserves via COALESCE; `Some` overwrites. `Some("")` explicitly clears (relevant for `manual_tags` and `llm_tags`).
|
||||
|
||||
### Commands
|
||||
|
||||
- `add_transcript` (`:103`) builds an `InsertTranscriptParams` from the wide request and calls `magnotia_storage::insert_transcript`. The FTS5 index is updated automatically by an SQLite trigger inside the storage crate.
|
||||
- `list_transcripts` (`:135`) — paginated with sane defaults.
|
||||
- `count_transcripts_command` (`:150`).
|
||||
- `get_transcript` (`:157`).
|
||||
- `update_transcript` (`:172`) — fixes the long-standing "rename in History never persists" bug per architecture-review.md §13.
|
||||
- `delete_transcript` (`:184`).
|
||||
- `search_transcripts` (`:196`) — empty / whitespace queries return empty results without hitting the DB. FTS5 query syntax (bare words AND together, quoted phrases, OR / NOT / prefix `*`) flows through verbatim.
|
||||
- `update_transcript_meta_cmd` (`:235`) — patch the meta columns and return the updated row.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
dictation result panel -> add_transcript(req) -> insert_transcript -> FTS5 trigger updates
|
||||
History page mount -> list_transcripts(50, 0)
|
||||
-> count_transcripts_command (for "showing 50 of 1273")
|
||||
History page rename -> update_transcript(id, text=None, title=Some(new))
|
||||
History page delete -> delete_transcript(id)
|
||||
History page search box -> search_transcripts(q)
|
||||
Viewer window star toggle -> update_transcript_meta_cmd(id, { starred: Some(true) })
|
||||
Viewer window LLM tag pass -> update_transcript_meta_cmd(id, { llm_tags: Some(joined) })
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **No `ensure_main_window` guard.** Both the main window AND the `transcript-viewer` secondary window need to call these commands, and the secondary-windows capability does include `core:event:default`. The DB layer is the only enforcement. If you ever want to hide certain transcripts (private profile?), add a profile-id check at the command layer.
|
||||
- **`segmentsJson` is a JSON string, not a parsed array.** The shape is whatever `commands::transcription` and `commands::live` chose to write at insert time. The frontend re-parses. If you ever change the segment shape, write a migration that touches the existing rows.
|
||||
- **`update_transcript` returns the affected row count (`u64`), not the updated row.** Callers that need the new shape have to follow up with `get_transcript`. `update_transcript_meta_cmd` returns the row directly. Consider unifying.
|
||||
- **FTS5 search caps at 50 results.** History page would need pagination over search if a transcripts library grows beyond a few hundred rows.
|
||||
- **`profile_id` defaults to `DEFAULT_PROFILE_ID` at insert time** if the request omits it. The frontend should be passing the active profile; if it ever silently omits this, all transcripts pile into the default profile.
|
||||
|
||||
## See also
|
||||
|
||||
- [Transcription](transcription.md) — the upstream that produces the segments + raw_text payload that lands in `add_transcript`.
|
||||
- [Live transcription](live.md) — same upstream.
|
||||
- [LLM](llm.md) — `extract_content_tags_cmd` produces the value that goes into `llm_tags`.
|
||||
- [Profiles](profiles.md) — `profile_id` scoping.
|
||||
- [Diagnostics](diagnostics.md) — `count_transcripts_command` is referenced from the Settings → About counts row.
|
||||
95
docs/architecture-map/02-tauri-runtime/commands/tts.md
Normal file
95
docs/architecture-map/02-tauri-runtime/commands/tts.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: Text-to-speech
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::tts`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → TTS
|
||||
|
||||
**Plain English summary.** Phase 4: Read Page Aloud. Shells out to the platform's built-in TTS binary (`spd-say` with espeak-ng fallback on Linux, `say` on macOS, PowerShell `System.Speech.Synthesis.SpeechSynthesizer` via `-EncodedCommand` on Windows). Stores the spawned child process so a second tap stops in-flight speech cleanly. User text is never interpolated into a shell string — every backend passes it via argv (or, on Windows, inside a here-string delivered as base64 UTF-16-LE).
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/tts.rs`.
|
||||
- LOC: 444.
|
||||
- Tauri commands exposed:
|
||||
- `tts_speak(state, text: String, rate: f32, voice: Option<String>) -> Result<(), String>`. No window guard (any window can request TTS; the read-aloud feature lives in the main window today but secondary windows are cheap to allowlist for).
|
||||
- `tts_stop(state) -> Result<(), String>`. No window guard.
|
||||
- `tts_list_voices() -> Result<Vec<TtsVoice>, String>`.
|
||||
- Events emitted: none.
|
||||
- Depends on: per-platform TTS binaries (no Rust dependencies beyond `std::process::Command` and `serde`). Windows path uses `base64 = "0.22"` to encode UTF-16-LE for `-EncodedCommand`.
|
||||
- Called from frontend at: dictation result panel ("Read aloud" toggle), Settings → Accessibility → TTS voice picker.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `TtsState` (`src-tauri/src/commands/tts.rs:20`)
|
||||
|
||||
Tauri-managed: `child: Mutex<Option<Child>>`. On Linux `spd-say` returns immediately so the slot is usually empty; macOS `say` and Windows PowerShell speak synchronously, so the handle is retained.
|
||||
|
||||
### `TtsVoice` (`:32`)
|
||||
|
||||
`{ id, name, language: Option<String> }`. Frontend renders the list as a dropdown.
|
||||
|
||||
### Rate clamping and per-platform mapping
|
||||
|
||||
- `clamp_rate(rate)` (`:40`) — clamps to `[0.5, 2.0]`, returns 1.0 for NaN.
|
||||
- Linux: `spd_rate(rate)` maps to `[-100, 100]` (asymmetric: 1.0→0, 2.0→100, 0.5→-50). `espeak_rate(rate)` maps to words-per-minute in `[80, 450]`.
|
||||
- macOS: `say_rate(rate)` maps to wpm.
|
||||
- Windows: `win_rate(rate)` maps to `[-10, 10]` (the SAPI rate scale).
|
||||
|
||||
### Per-platform spawners
|
||||
|
||||
- `spawn_linux` (`:68`) — tries `spd-say -r <rate> [-t voice] -- <text>`. On `NotFound` falls back to `espeak-ng -s <wpm> [-v voice] -- <text>`. Returns `Some(child)` only for espeak-ng (which speaks synchronously); spd-say returns `None`.
|
||||
- `spawn_macos` (`:125`) — `say -r <wpm> [-v voice] -- <text>`. Returns the child.
|
||||
- `spawn_windows` (`:158`) — assembles a PowerShell here-string with the user's text inside, base64-encodes UTF-16-LE, invokes `powershell -NoProfile -EncodedCommand <b64>`. Uses `escape_ps_herestring` (`:153`) to defuse the `'@` here-string terminator if the user's text contains it.
|
||||
|
||||
### Voice listing
|
||||
|
||||
`tts_list_voices` is a thin wrapper. Per-platform implementations (`:197`, `:206`, `:237`, `:296`):
|
||||
|
||||
- Linux: queries `spd-say -L` (or returns `[]` if missing), parses output into `TtsVoice`s.
|
||||
- macOS: runs `say -v ?`, parses each line via `parse_macos_voices` (testable pure helper at `:220`).
|
||||
- Windows: queries the .NET `[System.Speech.Synthesis.SpeechSynthesizer]::new().GetInstalledVoices()` via PowerShell, parses CSV.
|
||||
- Other platforms: returns `[]` with a message.
|
||||
|
||||
### `tts_speak`, `tts_stop`, `tts_list_voices` (`:302`, `:345`, `:362`)
|
||||
|
||||
`tts_speak` trims the text, kills any in-flight child via `kill_child`, dispatches per-platform. Stores a returned child if any. Returns an explicit error on platforms where TTS is not implemented (e.g. Android).
|
||||
|
||||
`tts_stop` calls `kill_child` and additionally calls `stop_linux()` (`:102`) — which fires `spd-say -S` to ask speech-dispatcher to flush its own queue, since spd-say-spawned children are not retained in `state.child`.
|
||||
|
||||
`kill_child` (`:353`) takes the child out of state, kills it, and waits for it. `wait()` is important to avoid zombies.
|
||||
|
||||
### Tests (`:367`)
|
||||
|
||||
Cover rate clamping (NaN, bounds), spd_rate / espeak_rate / say_rate / win_rate per-platform, the PowerShell here-string escape (`ps_herestring_terminator_is_broken`), and the macOS voice parser (`parses_macos_voices`).
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
frontend invoke('tts_speak', { text, rate, voice })
|
||||
-> kill any in-flight child
|
||||
-> per-OS spawn (spd-say / say / powershell)
|
||||
-> stash child handle if backend speaks synchronously
|
||||
frontend invoke('tts_stop')
|
||||
-> kill child
|
||||
-> Linux: also fire spd-say -S
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **No `ensure_main_window` guard.** Today the read-aloud UI lives in the main window, but the ACL allows any window to invoke. If you decide to lock down secondary windows from triggering speech, add the guard.
|
||||
- **Linux `spd-say` is non-blocking** — `tts_stop` cannot kill its synthesis once it has handed off to speech-dispatcher. The `stop_linux` extra call asks speech-dispatcher to flush its own queue, but that's a soft-stop, not a hard kill.
|
||||
- **Windows path is heavy.** Every speak-call spawns a PowerShell. Acceptable for one-off use; for a streaming TTS pattern you'd want to keep a long-lived child or use the `windows` crate's SAPI bindings directly.
|
||||
- **Voice id semantics differ per platform.** macOS uses the voice name; Linux uses an spd-say `-t` token; Windows uses the SAPI registered voice token. Frontend treats them as opaque strings, but a saved-voice in Settings will not survive a platform switch.
|
||||
- **Brand consistency.** `Magnotia` is being renamed to `Lumenote` (per personal memory `project_lumenote_naming.md`). The TTS module currently embeds the string `"magnotia LLM cleanup"` and `"magnotia"`-prefixed temp filenames; rebrand sweep follow-up.
|
||||
- **No power assertion.** Long read-aloud sessions on macOS could be idled by App Nap. Add `PowerAssertion::begin("magnotia TTS")` to `tts_speak` if longer transcripts ever become a primary use case.
|
||||
|
||||
## See also
|
||||
|
||||
- [Power assertions and security](power-and-security.md) — App Nap pattern.
|
||||
- [LLM](llm.md) — the cleanup pipeline that produces the text fed into TTS.
|
||||
- [Cargo and features](../cargo-and-features.md) — the Windows-only `base64` dependency.
|
||||
86
docs/architecture-map/02-tauri-runtime/commands/windows.md
Normal file
86
docs/architecture-map/02-tauri-runtime/commands/windows.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: Window management
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# `commands::windows`
|
||||
|
||||
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Window management
|
||||
|
||||
**Plain English summary.** The imperative builder for the three secondary windows: a floating always-on-top task list, a transcript viewer / editor, and a transient transcription preview overlay. Each command is idempotent: an existing window is shown and focused; otherwise a new one is built. Linux uses native KWin/Mutter decorations; macOS and Windows draw the custom frameless chrome. The preview overlay is configured to follow the user across virtual desktops and stay out of the alt-tab list (KWin sticky + GTK Utility hint). Android stubs return a clear error so the frontend can detect and route to in-window routes instead.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/commands/windows.rs`.
|
||||
- LOC: 197.
|
||||
- Tauri commands exposed (4 total):
|
||||
- `open_task_window(app) -> Result<(), String>`.
|
||||
- `open_preview_window(app) -> Result<(), String>`.
|
||||
- `close_preview_window(app) -> Result<(), String>`.
|
||||
- `open_viewer_window(app) -> Result<(), String>`.
|
||||
- Events emitted: `task-window-focus` (no payload) when `open_task_window` brings an existing task float forward (`src-tauri/src/commands/windows.rs:49`).
|
||||
- Depends on: `tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}` (desktop-only). `gdk::WindowTypeHint`, `gtk::prelude::GtkWindowExt` for the Linux preview hint.
|
||||
- Called from frontend at: dictation page (preview open / close around recording), Tasks page header button (task float), History page row click (viewer).
|
||||
|
||||
## What's in here
|
||||
|
||||
### Android stubs (`src-tauri/src/commands/windows.rs:14`)
|
||||
|
||||
`#[cfg(target_os = "android")]` versions of all four commands return `Err("Multi-window is not supported on Android; this command is desktop-only")`. The frontend detects Android via `isAndroid()` and routes the formerly-secondary content into routes inside the main window.
|
||||
|
||||
### `open_task_window` (`:43`)
|
||||
|
||||
If a `tasks-float` window already exists, show + focus and emit `task-window-focus`. Otherwise build a new one at `/float`, 480×520 (min 360×480), `always_on_top: true`, decorations native on Linux / frameless elsewhere, resizable. Inject the `PreferencesScript` if present so prefs land before Svelte mounts.
|
||||
|
||||
### `open_preview_window` (`:88`)
|
||||
|
||||
If a `transcription-preview` exists, show (NOT focus — the overlay must not steal focus from whatever the user is typing into). Otherwise build a new one at `/preview`, 420×200 (min 360×140, max 520×360), `always_on_top: true`, `skip_taskbar: true`, `visible_on_all_workspaces: true` (KWin sticky), `focused: false`, `visible: false` (built hidden so the GTK type-hint can be set pre-realize). Inject prefs.
|
||||
|
||||
After the build:
|
||||
|
||||
- On Linux, fetch the `gtk_window()` and call `set_type_hint(WindowTypeHint::Utility)`. This signals to Hyprland / Sway / GNOME Mutter compositors that the window is auxiliary and should not show in alt-tab. KWin already obeys SKIP_TASKBAR; this is defence in depth for non-KDE compositors.
|
||||
|
||||
Then call `window.show()` to actually surface it.
|
||||
|
||||
### `close_preview_window` (`:158`)
|
||||
|
||||
If the preview window exists, hide it (don't destroy — the next open is instant). No-op if it doesn't exist. Idempotent return Ok.
|
||||
|
||||
### `open_viewer_window` (`:168`)
|
||||
|
||||
If a `transcript-viewer` exists, show + focus. Otherwise build a new one at `/viewer`, 600×700 (min 560×520), Linux native decorations / frameless elsewhere, resizable. Inject prefs. NOT always-on-top — the viewer is a primary surface, not an overlay.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
Tasks page header button -> invoke('open_task_window')
|
||||
-> if exists: show + focus + emit('task-window-focus')
|
||||
-> else: WebviewWindowBuilder('/float', ..., always_on_top=true)
|
||||
|
||||
Dictation start -> invoke('open_preview_window')
|
||||
-> build hidden, set GTK Utility hint on Linux, show
|
||||
|
||||
Dictation end / paste -> invoke('close_preview_window') -> hide
|
||||
|
||||
History row click -> invoke('open_viewer_window')
|
||||
-> if exists: show + focus
|
||||
-> else: WebviewWindowBuilder('/viewer', ...)
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **No `ensure_main_window` guard, but the ACL guards effectively.** The main capability gates these commands; the secondary windows do not have permission to invoke them. If you ever expand the secondary-windows capability, double-check.
|
||||
- **Preview overlay focus quirks.** Even with `focused: false`, KWin and Mutter Wayland sometimes route the next keystroke to the overlay. `commands::paste::hide_preview_overlay_for_paste` is the partner workaround that hides the preview before firing a paste.
|
||||
- **The GTK type hint is set *before* `show()`** (`:151`). GTK3 only honours type hints pre-realize. Don't reorder.
|
||||
- **Linux native decorations vs frameless.** The `let use_native_decorations = cfg!(target_os = "linux");` call is the cross-cutting decision: Linux wins native via Tauri's frameless-Wayland resize quirk; macOS and Windows draw the custom Titlebar. If you ever ship a Windows build, audit this.
|
||||
- **`open_task_window` emits `task-window-focus` only on the "already exists" path.** A first-build does not emit it. The Tasks page should listen and re-render the float regardless of which path was taken — but if you have logic that fires only on the event, the first-build will silently miss.
|
||||
- **`always_on_top` plus visible-on-all-workspaces** is the right combination for the preview, but it can feel intrusive. If the user's workflow shifts to a single-workspace setup, consider an opt-out toggle in Settings.
|
||||
|
||||
## See also
|
||||
|
||||
- [App lifecycle](../app-lifecycle.md) — `PreferencesScript` is what gets injected.
|
||||
- [Capabilities and ACL](../capabilities-and-acl.md) — the secondary-windows capability is bound to the labels declared here.
|
||||
- [Paste](paste.md) — `hide_preview_overlay_for_paste` is the partner hide call.
|
||||
- [Tauri config](../tauri-config.md) — the main window is declared statically; the three windows here are imperative.
|
||||
56
docs/architecture-map/02-tauri-runtime/system-tray.md
Normal file
56
docs/architecture-map/02-tauri-runtime/system-tray.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: System tray
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# System tray
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Tauri runtime](README.md) → System tray
|
||||
|
||||
**Plain English summary.** Builds the desktop tray icon that lives in the system status area. The tray menu has Show, a disabled status row, an Evening wind-down shortcut (Phase 5 ritual), and Quit. Left-clicking the icon shows and focuses the main window.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/src/tray.rs`.
|
||||
- LOC: 73.
|
||||
- Compile gate: `#[cfg(not(target_os = "android"))]` — Android has no tray surface (declared at the `mod tray` line in `src-tauri/src/lib.rs:5`).
|
||||
- Tauri commands exposed: none. The tray is set up imperatively from `lib.rs::run` setup hook.
|
||||
- Events emitted: `magnotia:open-wind-down` (no payload) when the user clicks the wind-down menu item (`src-tauri/src/tray.rs:51`).
|
||||
- Depends on: `tauri::image::Image`, `tauri::menu::{MenuBuilder, MenuItemBuilder}`, `tauri::tray::TrayIconBuilder`, `tauri::{Emitter, Manager}`. No workspace crates.
|
||||
- Called from frontend at: the frontend layout listens for `magnotia:open-wind-down` and routes to the wind-down page.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `setup(app)` (`src-tauri/src/tray.rs:6`)
|
||||
|
||||
Builds the menu items: `show`, `status` (disabled, label "Ready"), `wind-down`, `quit`. Assembles them into a `MenuBuilder` with separators between groups. Falls back to a 1×1 transparent icon if `default_window_icon()` is None (which would be a packaging failure in production builds).
|
||||
|
||||
Wires three handlers:
|
||||
|
||||
- `on_menu_event` (`src-tauri/src/tray.rs:37`):
|
||||
- `show` → `window.show(); window.set_focus();`
|
||||
- `wind-down` → show + focus + emit `magnotia:open-wind-down`.
|
||||
- `quit` → `app.exit(0)`.
|
||||
- All other ids fall through.
|
||||
- `on_tray_icon_event` (`src-tauri/src/tray.rs:58`): a left-click brings the main window forward. Right-click is left to the platform default (which opens the menu).
|
||||
|
||||
The tray icon's tooltip is `"Magnotia — Ready"`. The status menu item has label `"Ready"` and is disabled (the `enabled(false)` builder call leaves it visible but unclickable).
|
||||
|
||||
## Data flow
|
||||
|
||||
Out only: clicks on the tray menu either hide/show the window directly or fire one event to the frontend (`magnotia:open-wind-down`). The tray does not read any state.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- The status menu item is hard-coded "Ready". There is no wiring to update it dynamically as the engine moves between idle / loading / recording. If you want a live status, you'll need to retain a `TrayIcon` handle in `AppState` (or somewhere similar) so a command can call `set_tooltip` / update the menu item text.
|
||||
- The icon comes from `app.default_window_icon()` which is the packaged bundle icon (set in `tauri.conf.json` `bundle.icon`). Replacing the tray icon means re-running `tauri icon` or shipping a separate tray PNG.
|
||||
- The `magnotia:open-wind-down` event payload is `()` — the frontend just needs to know "navigate to the wind-down page", and the page itself decides whether to render the ritual or a "you have not enabled this yet" stub.
|
||||
- Close-to-tray (intercepting `WindowEvent::CloseRequested`) lives in `lib.rs::run` setup hook (`src-tauri/src/lib.rs:282`), not here. The two halves are split because the close-to-tray handler needs the cloned `WebviewWindow`.
|
||||
|
||||
## See also
|
||||
|
||||
- [App lifecycle](app-lifecycle.md) — `tray::setup(app)` is called at the end of the setup hook (`src-tauri/src/lib.rs:315`), and the close-to-tray handler in setup is what makes the tray useful.
|
||||
- [Tauri config](tauri-config.md) — bundle icon list feeds the tray.
|
||||
- [Capabilities and ACL](capabilities-and-acl.md) — the main capability includes `core:window:allow-show` / `allow-set-focus` / `allow-hide`, which are what the tray's menu actions implicitly rely on through Tauri's IPC layer.
|
||||
94
docs/architecture-map/02-tauri-runtime/tauri-config.md
Normal file
94
docs/architecture-map/02-tauri-runtime/tauri-config.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: Tauri config
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Tauri config
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Tauri runtime](README.md) → Tauri config
|
||||
|
||||
**Plain English summary.** The base `tauri.conf.json` declares the bundle identity, the main window size and chrome, the Content Security Policy, and the icons. The Linux overlay flips the main window from frameless to native-decorations, because Tauri's frameless path on Wayland does not honour diagonal resize reliably and webkit2gtk's drag-region adds latency.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Paths: `src-tauri/tauri.conf.json` (43 LOC), `src-tauri/tauri.linux.conf.json` (17 LOC).
|
||||
- Identifier: `uk.co.corbel.magnotia`.
|
||||
- Tauri version targeted: schema `https://schema.tauri.app/config/2`.
|
||||
- Main window labels: `main` (defined here), plus `tasks-float`, `transcript-viewer`, `transcription-preview` (built imperatively from `commands::windows`).
|
||||
- Frontend: `npm run dev:frontend` for dev (port 1420), `npm run build` produces `../build` for release.
|
||||
- Tauri commands exposed: none (config files only).
|
||||
- Events emitted: none.
|
||||
- Depends on: nothing at runtime; consumed by `tauri-build` in `build.rs` and by `build.rs::assert_loopback_llm_csp` (build-time CSP regression guard).
|
||||
- Called from frontend at: the dev URL is what `vite` ships to during `cargo tauri dev`.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `tauri.conf.json`
|
||||
|
||||
```
|
||||
productName: "Magnotia"
|
||||
version: "0.1.0"
|
||||
identifier: "uk.co.corbel.magnotia"
|
||||
```
|
||||
|
||||
#### `build`
|
||||
|
||||
```
|
||||
beforeDevCommand: "npm run dev:frontend"
|
||||
devUrl: "http://localhost:1420"
|
||||
beforeBuildCommand: "npm run build"
|
||||
frontendDist: "../build"
|
||||
```
|
||||
|
||||
#### `app.windows[0]`
|
||||
|
||||
The main window. Title `"Magnotia"`, 1020×720 (min 960×600), centred, resizable, **frameless** (`decorations: false`). The Linux overlay flips this to `decorations: true`.
|
||||
|
||||
#### `app.security.csp`
|
||||
|
||||
```
|
||||
default-src 'self';
|
||||
script-src 'self';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' asset: https://asset.localhost;
|
||||
connect-src ipc: http://ipc.localhost
|
||||
asset: https://asset.localhost
|
||||
http://127.0.0.1:* ws://127.0.0.1:*;
|
||||
media-src 'self' asset: https://asset.localhost
|
||||
```
|
||||
|
||||
The two `http://127.0.0.1:*` and `ws://127.0.0.1:*` entries are pinned by the build-time guard in `src-tauri/build.rs:30`. They must stay (so the bundled llama.cpp server / a BYO Ollama install can be `fetch()`-ed from the webview), and `localhost:*` must stay forbidden (so endpoints normalise to a single name and are never bypassed by the resolver). See [Tests](tests.md) for the runtime assertion.
|
||||
|
||||
`'unsafe-inline'` is permitted for `style-src` because Svelte injects component-scoped styles; without it, every `<style>` element would need a nonce. `script-src 'self'` keeps inline scripts forbidden.
|
||||
|
||||
#### `bundle`
|
||||
|
||||
`active: true`, `targets: "all"`, icons listed: `icons/32x32.png`, `icons/128x128.png`, `icons/128x128@2x.png`, `icons/icon.icns`, `icons/icon.ico`. `bundle.android.minSdkVersion: 24`.
|
||||
|
||||
### `tauri.linux.conf.json`
|
||||
|
||||
Tauri 2 merges per-platform overlays on top of the base config when the build target is detected. The Linux overlay re-declares `app.windows[0]` with the same dimensions as the base config but with `decorations: true`. This is the only difference. Reasons documented inline in `commands::windows::open_task_window` (`src-tauri/src/commands/windows.rs:58`): on Wayland the frameless path mishandles resize edges and adds drag latency; native KWin / Mutter decorations are reliable.
|
||||
|
||||
## Data flow
|
||||
|
||||
- `tauri-build::build()` consumes the config at compile time to generate the inline runtime context.
|
||||
- `build.rs::assert_loopback_llm_csp` parses `tauri.conf.json` and asserts the `connect-src` directive includes the loopback entries. A regression breaks compilation, not just tests.
|
||||
- `WebviewWindowBuilder` calls in `commands::windows` re-declare the secondary windows imperatively rather than adding them here, because they need preferences-injection, conditional decorations, GTK type-hints, and skip-taskbar / always-on-top settings that the static config can't express. The `secondary-windows` capability (see [Capabilities and ACL](capabilities-and-acl.md)) is what binds them to the same allowlist.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- Don't add `localhost:*` to `connect-src`. The build will fail at compile time. Use `127.0.0.1:*`.
|
||||
- Don't add new `*.localhost` entries unless they pair with a Tauri-managed asset URL. Adding wildcards weakens CSP without giving up much in return.
|
||||
- The `frontendDist` is a relative path resolved from `src-tauri/`. Moving the frontend out of `../build` requires updating both this and `package.json`'s build script.
|
||||
- The bundle identifier is the macOS bundle ID and the Android applicationId. Changing it after a release would orphan installed apps from updates.
|
||||
- The base config has `decorations: false`; macOS and Windows pick that up. macOS gets a custom Titlebar component drawn by Svelte. Windows would too if you ever ship a Windows build (none today).
|
||||
|
||||
## See also
|
||||
|
||||
- [App lifecycle](app-lifecycle.md) — the main window is fetched here via `app.get_webview_window("main")`.
|
||||
- [Capabilities and ACL](capabilities-and-acl.md) — pairs with the CSP to constrain what the JS can call.
|
||||
- [Cargo and features](cargo-and-features.md) — `build.rs` is what enforces the CSP at build time.
|
||||
- [Tests](tests.md) — `csp_keeps_loopback_narrow` regression-tests the same property at runtime.
|
||||
- [Window management](commands/windows.md) — the imperative builder for `tasks-float` / `transcript-viewer` / `transcription-preview`.
|
||||
73
docs/architecture-map/02-tauri-runtime/tests.md
Normal file
73
docs/architecture-map/02-tauri-runtime/tests.md
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: Tests
|
||||
type: architecture-map-page
|
||||
slice: 02-tauri-runtime
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Tests
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Tauri runtime](README.md) → Tests
|
||||
|
||||
**Plain English summary.** A single integration test file that protects three security-relevant config invariants: the loopback LLM CSP must stay narrow, any updater config that ships must carry a non-empty pubkey, and the secondary windows must never get high-risk plugin permissions. Unit tests live alongside the code they test (each command file has its own `#[cfg(test)]` module).
|
||||
|
||||
## At a glance
|
||||
|
||||
- Path: `src-tauri/tests/config_hardening.rs` (93 LOC).
|
||||
- Tauri commands exposed: none.
|
||||
- Events emitted: none.
|
||||
- Depends on: `serde_json` (for parsing the JSON configs), `std::fs` and `std::path::Path` (for walking the capabilities directory).
|
||||
|
||||
## What's in here
|
||||
|
||||
### `csp_keeps_loopback_narrow` (`src-tauri/tests/config_hardening.rs:15`)
|
||||
|
||||
Reads `src-tauri/tauri.conf.json`, finds the `connect-src` directive of `app.security.csp`, asserts:
|
||||
|
||||
- `http://127.0.0.1:*` is present.
|
||||
- `ws://127.0.0.1:*` is present.
|
||||
- `http://localhost:*` is absent.
|
||||
- `ws://localhost:*` is absent.
|
||||
|
||||
This is the runtime sibling of the build-time guard in `src-tauri/build.rs:30`. Both must agree. The build guard is the harder check (it stops compilation) and the integration test is the safety net for someone running `cargo test` without `cargo build` between edits.
|
||||
|
||||
### `updater_is_signed_or_absent` (`src-tauri/tests/config_hardening.rs:35`)
|
||||
|
||||
If `plugins.updater` exists in `tauri.conf.json`, asserts the `pubkey` field is a non-empty string. If `plugins.updater` is absent (which it currently is), the test is a no-op. The intent is that you cannot accidentally ship an updater config with a placeholder / empty key.
|
||||
|
||||
### `secondary_windows_do_not_get_high_risk_plugin_permissions` (`src-tauri/tests/config_hardening.rs:50`)
|
||||
|
||||
Scans `src-tauri/capabilities/*.json`. For each capability whose `windows` array references any of `tasks-float`, `transcript-viewer`, or `transcription-preview`, asserts no permission starts with the prefixes `dialog:`, `autostart:`, `global-shortcut:`, `opener:`, or `updater:`. Catches drift the moment someone adds, say, `dialog:default` to a secondary capability. See [Capabilities and ACL](capabilities-and-acl.md).
|
||||
|
||||
### Unit tests (other locations)
|
||||
|
||||
Each command module has its own `#[cfg(test)] mod tests` block. Notable ones:
|
||||
|
||||
- `commands/audio.rs` — `recording_filenames_are_unique_across_rapid_calls` (RB-06 regression for filename collisions), `stop_worker_awaits_full_termination_no_writes_after_join`, `stop_worker_is_idempotent`.
|
||||
- `commands/models.rs` — `compose_accelerators` matrix (RB-07 regression for the hard-coded `["cpu", "vulkan"]` bug).
|
||||
- `commands/llm.rs` — `classify_llm_load_error` covers VRAM / corrupt-file / permission / unknown classifications.
|
||||
- `commands/paste.rs` — terminal classifier and PowerShell escape-sequence tests.
|
||||
- `commands/intentions.rs` — `validate_hhmm` accepts/rejects.
|
||||
- `commands/power.rs` — `power_assertion_is_a_no_op_drop` (registry hygiene), `multiple_assertions_get_unique_ids`.
|
||||
- `commands/security.rs` — `accepts_main_window` / `rejects_secondary_windows` for `ensure_main_window_label`.
|
||||
- `commands/tts.rs` — rate-clamp, platform-specific rate-mapping, macOS voice parser, Windows here-string escape.
|
||||
- `commands/fs.rs` — `write_text_file_roundtrips_utf8`, `write_text_file_errors_on_bad_parent`.
|
||||
- `commands/mod.rs` — `build_initial_prompt` shape across all combinations of caller/profile/term inputs.
|
||||
|
||||
## Data flow
|
||||
|
||||
- The integration test file loads `tauri.conf.json` and the `capabilities/*.json` files at runtime via `env!("CARGO_MANIFEST_DIR")`. No Tauri runtime is started.
|
||||
- Unit tests stay in the same compilation units as their target code, so they have direct access to private functions.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- The integration test crate does not build with the full Tauri runtime. If you ever need a test that exercises `tauri::Builder` end-to-end, you will need to add a `#[tokio::test]` with a `tauri::test::mock_app()` helper, which is not currently used anywhere.
|
||||
- `csp_keeps_loopback_narrow` uses `directive.starts_with("connect-src ")` (with trailing space) which would match `connect-src-elem` — BUT the build-time guard in `build.rs` does the stricter full-name match. If the CSP ever gains a `connect-src-elem` directive that shouldn't include the loopback entries, this integration test would still pass while the build guard correctly checks the actual `connect-src`. Worth tightening for parity.
|
||||
- The `updater_is_signed_or_absent` test is currently a no-op because no `plugins.updater` block ships. The first time a real updater config lands, the test will run. Document that fact in the PR that adds the updater so reviewers know the previously-passing test now checks something.
|
||||
|
||||
## See also
|
||||
|
||||
- [Tauri config](tauri-config.md) — the file the CSP test reads.
|
||||
- [Capabilities and ACL](capabilities-and-acl.md) — the files the secondary-windows test walks.
|
||||
- [Cargo and features](cargo-and-features.md) — the build-time CSP guard pair.
|
||||
- [Commands README](commands/README.md) — index of the command-level unit tests.
|
||||
123
docs/architecture-map/03-audio-transcription/README.md
Normal file
123
docs/architecture-map/03-audio-transcription/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: Slice 3 — Audio + Transcription
|
||||
type: architecture-map-page
|
||||
slice: 03-audio-transcription
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Slice 3: Audio + Transcription
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → Audio + Transcription
|
||||
|
||||
**Plain English summary.** Two Rust workspace crates form the backbone of Magnotia's speech-to-text path. `magnotia-audio` captures microphone input via `cpal`, decodes audio files via `symphonia`, resamples to 16 kHz mono via `rubato`, and writes WAV files via `hound`. `magnotia-transcription` loads Whisper or Parakeet models, runs inference, and provides streaming primitives (VAD chunking, LocalAgreement-n commit policy, commit-bounded buffer trim) so live captures stay responsive.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Crate | LOC (`src/`) | Purpose |
|
||||
|---|---|---|
|
||||
| `magnotia-audio` | 1,533 | Capture, VAD stub, resample, decode, WAV I/O |
|
||||
| `magnotia-transcription` | 2,266 (incl. tests + build) | Engines, model manager, streaming primitives |
|
||||
|
||||
Public crate surface (re-exports from `lib.rs`):
|
||||
|
||||
```rust
|
||||
// magnotia-audio
|
||||
pub use capture::{AudioChunk, CaptureRuntimeError, DeviceInfo, MicrophoneCapture};
|
||||
pub use concurrency::decode_and_resample;
|
||||
pub use decode::{decode_audio_file, decode_audio_file_limited, probe_audio_duration_secs};
|
||||
pub use resample::resample_to_16khz;
|
||||
pub use streaming_resample::StreamingResampler;
|
||||
pub use vad::SpeechDetector;
|
||||
pub use wav::{read_wav, write_wav, WavWriter};
|
||||
|
||||
// magnotia-transcription
|
||||
pub use concurrency::run_inference;
|
||||
#[cfg(feature = "whisper")]
|
||||
pub use local_engine::load_whisper;
|
||||
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
|
||||
pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir};
|
||||
pub use streaming::{
|
||||
sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy,
|
||||
LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker,
|
||||
};
|
||||
pub use transcribe_rs::SpeechModel;
|
||||
pub use transcriber::{Transcriber, TranscriberCapabilities};
|
||||
```
|
||||
|
||||
External deps that matter:
|
||||
|
||||
- `cpal 0.17` — microphone capture, host enumeration, stream callbacks.
|
||||
- `rubato 0.15` — sinc-interpolating resampler (file + streaming).
|
||||
- `symphonia 0.5` (mp3, aac, flac, pcm, vorbis, wav, ogg, isomp4) — file decode.
|
||||
- `hound 3.5` — WAV reader and crash-safe append writer.
|
||||
- `whisper-rs 0.16` (optional, default) — direct Whisper inference, pipes `initial_prompt`.
|
||||
- `transcribe-rs 0.3` (onnx) — Parakeet wrapper.
|
||||
- `reqwest 0.12` (rustls-tls, stream) + `sha2 0.10` + `futures-util 0.3` — model downloads with Range resume + SHA verify.
|
||||
- `tokio 1` — `spawn_blocking` for inference and decode.
|
||||
- `tracing 0.1` — backend boundary observability.
|
||||
- `voice_activity_detector` / `silero-vad-rust` — **deferred** (ort 2.0.0-rc.10 vs 2.0.0-rc.12 conflict; see `audio-vad.md`).
|
||||
|
||||
Cargo feature matrix (`magnotia-transcription`):
|
||||
|
||||
| Feature | Default | Gates |
|
||||
|---|---|---|
|
||||
| `whisper` | yes | `whisper-rs` dep, `whisper_rs_backend` module, `load_whisper` fn |
|
||||
| `whisper-vulkan` | yes | `whisper-rs/vulkan` (Vulkan GPU offload) |
|
||||
| Parakeet (`transcribe-rs`) | always on | unconditional dep, no feature flag |
|
||||
|
||||
`magnotia-audio` has no Cargo features.
|
||||
|
||||
## Map of this slice
|
||||
|
||||
- [`audio-capture-pipeline.md`](audio-capture-pipeline.md) — `cpal` enumeration, monitor-source detection, RMS validation, hot-unplug error forwarding.
|
||||
- [`audio-resampling.md`](audio-resampling.md) — `resample_to_16khz` (file) and `StreamingResampler` (live).
|
||||
- [`audio-vad.md`](audio-vad.md) — `SpeechDetector` (current stub) and the ort version conflict that blocks Silero.
|
||||
- [`audio-file-decoding.md`](audio-file-decoding.md) — `symphonia` decode, `decode_and_resample` async wrapper, `probe_audio_duration_secs`.
|
||||
- [`audio-wav-io.md`](audio-wav-io.md) — `WavWriter` (crash-safe append) and `read_wav` / `write_wav` round-trip helpers.
|
||||
- [`audio-pcm-bridge.md`](audio-pcm-bridge.md) — `static/pcm-processor.js` AudioWorklet (frontend-side fallback path).
|
||||
- [`transcription-engines-overview.md`](transcription-engines-overview.md) — `Transcriber` trait, `TranscriberCapabilities`, `LocalEngine`.
|
||||
- [`transcription-whisper.md`](transcription-whisper.md) — `WhisperRsBackend`, `WhisperContext`, params, `initial_prompt`, GPU offload.
|
||||
- [`transcription-parakeet.md`](transcription-parakeet.md) — `SpeechModelAdapter` + `ParakeetWordGranularity`.
|
||||
- [`transcription-streaming.md`](transcription-streaming.md) — `VadChunker` trait, `RmsVadChunker`, `LocalAgreement`, `trim_buffer_to_commit_point`.
|
||||
- [`transcription-model-manager.md`](transcription-model-manager.md) — download flow, SHA verify, `.magnotia-verified` manifest, Range resume.
|
||||
- [`transcription-concurrency.md`](transcription-concurrency.md) — `run_inference` async wrapper around `spawn_blocking`.
|
||||
- [`cargo-features.md`](cargo-features.md) — feature matrix, build commands, rationale.
|
||||
- [`build-tokenizers-guard.md`](build-tokenizers-guard.md) — `build.rs` Windows-MSVC-CRT guard against `tokenizers`.
|
||||
- [`tests-and-fixtures.md`](tests-and-fixtures.md) — `thread_sweep.rs`, `whisper_rs_smoke.rs`, `jfk_bench.rs`, env-var gates, in-tree download server fixture.
|
||||
|
||||
## How this slice connects to others
|
||||
|
||||
- **Slice 2 (Tauri runtime)** owns `src-tauri/src/commands/{audio,transcription,live,models}.rs`. Those wrappers call into this slice via the `pub use` exports above. The live command in particular drives `MicrophoneCapture` + `StreamingResampler` + `RmsVadChunker` + `LocalAgreement` + `WavWriter` together. This crate publishes the primitives; slice 2 publishes the orchestrator.
|
||||
- **Slice 4 (LLM + AI formatting)** runs after this slice produces a `Transcript`. It consumes the segment text via the storage layer, never directly. No type traffic flows back into this slice from formatting.
|
||||
- **Slice 5 (core / storage / hotkey / build)** provides the shared types this slice depends on:
|
||||
- `magnotia_core::error::{MagnotiaError, Result}` — error envelope.
|
||||
- `magnotia_core::types::{AudioSamples, Segment, Transcript, TranscriptionOptions, EngineName, ModelId, DownloadProgress, Megabytes}`.
|
||||
- `magnotia_core::constants::{WHISPER_SAMPLE_RATE, VAD_SPEECH_THRESHOLD}`.
|
||||
- `magnotia_core::hardware::vulkan_loader_available` — runtime Vulkan probe used by `WhisperRsBackend`.
|
||||
- `magnotia_core::tuning::{inference_thread_count, Workload}` — power-aware thread count picker.
|
||||
- `magnotia_core::paths::app_paths` — `models_dir()` / `speech_model_dir()` resolution.
|
||||
- `magnotia_core::model_registry::{find_model, all_models, ModelEntry, ModelFile}` — declarative model catalogue.
|
||||
- Storage (slice 5) writes the produced `Transcript` to disk; this slice does not call into it directly.
|
||||
|
||||
## Open questions / debt
|
||||
|
||||
- **Silero VAD blocked on ort version conflict.** `crates/audio/src/vad.rs` is a stub that returns `true` for every input. Both `voice_activity_detector` and `silero-vad-rust` pin `ort 2.0.0-rc.10`; `transcribe-rs` (Parakeet) requires `2.0.0-rc.12`. The `RmsVadChunker` (transcription crate) is the live-capture fallback while this is unresolved.
|
||||
- **VAD lives in two crates.** `audio::vad::SpeechDetector` is a single-frame predicate; `transcription::streaming::RmsVadChunker` is a stateful chunker. They share neither code nor thresholds. When Silero lands, expect consolidation.
|
||||
- **Two resamplers maintained in parallel.** `resample::resample_to_16khz` (file) and `streaming_resample::StreamingResampler` (live) duplicate `SincInterpolationParameters` config. Drift between them will show up as audible mismatch between file imports and live captures.
|
||||
- **Streaming primitives not yet wired into `live.rs`.** Comments in `streaming/mod.rs`, `buffer_trim.rs`, and `commit_policy.rs` flag that the integration into `src-tauri/src/commands/live.rs` ships as follow-up commits. Slice 2 is the place to verify whether that has landed.
|
||||
- **`WhisperRsBackend` builds a fresh `WhisperState` per call.** Comment notes "state can be reused, but fresh-per-call is simpler". Worth measuring once we have the live-streaming path producing many small calls per session.
|
||||
- **Parakeet quantisation is hardcoded to `Int8`** (`local_engine::load_parakeet`). No pathway for selecting a different quant.
|
||||
- **Hardcoded `set_n_threads` in tests** (`jfk_bench.rs` uses 6) versus the production helper (`inference_thread_count(Workload::Whisper, gpu_offloaded)`). Test results are not comparable to runtime numbers without re-running with the helper-picked thread count.
|
||||
- **`build.rs` panics at link time** on Windows if `tokenizers` ever enters the dep graph. This is intentional defence (Whispering v7.11.0 incident referenced in the file) but means a transitive pull of `tokenizers` from any sibling crate is a hard build break — not a soft warning. Worth surfacing in a top-level CONTRIBUTING note.
|
||||
- **Symphonia feature list is fixed.** The `mp3, aac, flac, pcm, vorbis, wav, ogg, isomp4` set covers the import paths in v0.x. AAC requires patent-licensed code paths in some build configs — flagged by the brief.
|
||||
|
||||
## Existing in-repo docs (do not duplicate)
|
||||
|
||||
- `docs/whisper-ecosystem/brief.md` — top-level Whisper-ecosystem audit (the items #6, #8, #13, #19, #21, #24, #25, #26 referenced throughout this slice).
|
||||
- `docs/whisper-ecosystem/workstream-A.md` — VAD / streaming roadmap (the source of `RmsVadChunker` thresholds).
|
||||
- `docs/whisper-ecosystem/workstream-B.md` — UI commit/tentative contract that drives `LocalAgreement`.
|
||||
- `docs/whisper-ecosystem/magnotia-context.md` — context summary for the workstreams.
|
||||
- `docs/code-review-2026-04-22.md` — audit pass that flagged decode.rs RB-09 and `read_wav` filter_map bug (both fixed in tree).
|
||||
- `docs/issues/decoder-partial-audio-on-error.md` — the RB-09 ticket.
|
||||
- `docs/issues/native-capture-worker-join.md`, `c1-live-session-race.md`, `run-live-session-monolith.md` — slice 2's live-session debt; they reference primitives in this slice.
|
||||
- `docs/brief/technology-map.md` — top-level tech map (covers all crates, not just this slice).
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: Audio capture pipeline (cpal)
|
||||
type: architecture-map-page
|
||||
slice: 03-audio-transcription
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Audio capture pipeline (cpal)
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Audio capture pipeline
|
||||
|
||||
**Plain English summary.** The microphone-capture path enumerates host input devices, picks one (default first, then non-monitor sources, monitor sources only as a last resort), validates it produces real audio energy via a 350 ms RMS sniff, then opens a `cpal` stream that converts every supported sample format to `f32` and pushes `AudioChunk`s into a bounded `mpsc` channel. Runtime errors during the stream (mic unplug, audio server crash) are forwarded on a separate channel so the live session can show a toast.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Crate: `magnotia-audio`
|
||||
- Path: `crates/audio/src/capture.rs`
|
||||
- LOC: 583
|
||||
- External deps: `cpal 0.17`, `serde 1` (DeviceInfo wire type)
|
||||
- Internal callers (best effort, slice 2 reconciles): `src-tauri/src/commands/audio.rs` (device list), `src-tauri/src/commands/live.rs` (start/stop, chunk + error channels).
|
||||
|
||||
Public surface:
|
||||
|
||||
- `pub struct AudioChunk` — `crates/audio/src/capture.rs:24` — `samples: Vec<f32>`, `sample_rate: u32`, `channels: u16`.
|
||||
- `pub struct DeviceInfo` — `crates/audio/src/capture.rs:33` — Serde-derived for the Tauri IPC boundary.
|
||||
- `pub struct CaptureRuntimeError` — `crates/audio/src/capture.rs:58` — non-fatal stream error reported after `start()` returned.
|
||||
- `pub struct MicrophoneCapture` — `crates/audio/src/capture.rs:64`.
|
||||
- `pub fn MicrophoneCapture::list_devices() -> Result<Vec<DeviceInfo>>` — `crates/audio/src/capture.rs:94`.
|
||||
- `pub fn MicrophoneCapture::start_with_device(name: &str) -> Result<(Self, mpsc::Receiver<AudioChunk>)>` — `crates/audio/src/capture.rs:137`.
|
||||
- `pub fn MicrophoneCapture::start() -> Result<(Self, mpsc::Receiver<AudioChunk>)>` — `crates/audio/src/capture.rs:166`.
|
||||
- `pub fn MicrophoneCapture::stop(&mut self)` — `crates/audio/src/capture.rs:240`.
|
||||
- `pub fn MicrophoneCapture::dropped_chunks(&self) -> u64` — `crates/audio/src/capture.rs:80`.
|
||||
- `pub fn MicrophoneCapture::take_error_rx(&mut self) -> Option<mpsc::Receiver<CaptureRuntimeError>>` — `crates/audio/src/capture.rs:88`.
|
||||
|
||||
`MicrophoneCapture` implements `Drop` (`crates/audio/src/capture.rs:247`) so a panicked caller still pauses the cpal stream.
|
||||
|
||||
## What's in here
|
||||
|
||||
### Constants and tunables
|
||||
|
||||
- `AUDIO_CHANNEL_CAPACITY = 32` (`capture.rs:11`) — bounded capacity for the chunk channel.
|
||||
- `DEVICE_VALIDATION_MS = 350` (`capture.rs:15`) — how long the sniffer listens before deciding a device is real.
|
||||
- `SILENCE_RMS_FLOOR = 1e-5` (`capture.rs:21`) — lower bound for "produced real audio" during validation.
|
||||
- `DEAD_SILENCE_FLOOR = 1e-7` (`capture.rs:475`) — even fallback (monitor) sources must clear this; pure dead-zero is rejected.
|
||||
|
||||
### Device selection (auto)
|
||||
|
||||
`start()` (`capture.rs:166`) sorts every input device into four tiers, tries each in order, and stops at the first that passes RMS validation:
|
||||
|
||||
1. Default + non-monitor (key 0).
|
||||
2. Any other non-monitor (key 1).
|
||||
3. Default but is a monitor (key 2, very rare).
|
||||
4. Monitor source last resort (key 3).
|
||||
|
||||
The first pass enforces `require_audio = true`. If nothing clears the silence floor the loop does a second pass with `require_audio = false`, which still rejects dead silence but accepts a monitor source as a fallback.
|
||||
|
||||
Monitor detection (`is_monitor_name`, `capture.rs:258`) catches the standard PulseAudio / PipeWire patterns: `.monitor` suffix, `Monitor of ` prefix, anything containing `loopback`.
|
||||
|
||||
### Device selection (explicit)
|
||||
|
||||
`start_with_device()` (`capture.rs:137`) takes an exact device name (matched against `cpal::Device::description().name()`) and refuses to fall back. It still runs the same RMS validation. Returns a clear "open Settings → Audio" message if the requested device is no longer enumerated.
|
||||
|
||||
### ALSA description enrichment (Linux only)
|
||||
|
||||
`load_alsa_card_descriptions()` (`capture.rs:297`) parses `/proc/asound/cards` to map cpal's terse short name (`Microphones`) to the human-readable product string (`Blue Microphones`). Empty map on non-Linux. `extract_card_id()` (`capture.rs:278`) pulls `CARD=...` out of an ALSA device string.
|
||||
|
||||
### Stream construction
|
||||
|
||||
`open_and_validate()` (`capture.rs:351`) is the single entry point that:
|
||||
|
||||
1. Reads `default_input_config()` for sample rate, channel count, sample format.
|
||||
2. Creates a `mpsc::sync_channel::<AudioChunk>(AUDIO_CHANNEL_CAPACITY)`.
|
||||
3. Creates a `mpsc::sync_channel::<CaptureRuntimeError>(32)` for runtime errors.
|
||||
4. Dispatches to `build_input_stream::<T>` for the device's sample format (F32 / I16 / U16). Anything else returns `MagnotiaError::AudioCaptureFailed`.
|
||||
5. Calls `stream.play()`.
|
||||
6. Sniffs samples for `DEVICE_VALIDATION_MS`, sums squared samples, derives RMS.
|
||||
7. Rejects below floor (or dead silence). Otherwise re-queues the validation chunks back into the channel so downstream consumers do not lose the first 350 ms.
|
||||
|
||||
`build_input_stream::<T>` (`capture.rs:505`) is generic over `T: Sample + SizedSample` with `f32: FromSample<T>` so the same body handles all three sample formats. The data callback maps every sample to `f32`, packages an `AudioChunk`, and `try_send`s on the channel; failure increments the `dropped_chunks` atomic. The error callback ships a `CaptureRuntimeError` on `err_tx` (channel-full path increments `dropped_errors` and logs to stderr).
|
||||
|
||||
### Drop counters
|
||||
|
||||
Two atomics give the live session visibility:
|
||||
|
||||
- `dropped_chunks: Arc<AtomicU64>` — chunk channel was full. Diagnostic for downstream backpressure.
|
||||
- `dropped_errors: Arc<AtomicU64>` (private) — runtime-error channel was full (caller stopped draining). Logged to stderr each time.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
cpal default_host()
|
||||
└─ input_devices() (enumerate)
|
||||
└─ open_and_validate(device)
|
||||
├─ build_input_stream::<f32|i16|u16>(...)
|
||||
│ └─ data callback: T → f32 → AudioChunk → mpsc::sync_channel
|
||||
│ └─ error callback: cpal::StreamError → CaptureRuntimeError → err_mpsc
|
||||
└─ stream.play()
|
||||
└─ 350 ms sniff → RMS → accept | reject
|
||||
└─ on accept: re-queue collected chunks, return MicrophoneCapture + Receiver<AudioChunk>
|
||||
```
|
||||
|
||||
Output: one `AudioChunk` per cpal callback period at the device's native rate. The live session is responsible for downmixing channels (if `channels > 1`) and feeding `StreamingResampler` to reach 16 kHz mono. Native rate is *not* normalised here.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **Channel capacity is 32 chunks.** At a 1024-frame cpal buffer at 48 kHz that's roughly 700 ms. A blocked consumer for longer than that means dropped audio. `dropped_chunks()` is the visibility hook; the live-session command must surface it.
|
||||
- **Default-device first works against the safest pick on Linux Pulse setups** where the default sink monitor sneaks in. The four-tier sort handles this, but only because monitor names match the patterns in `is_monitor_name`. New PipeWire schemes that don't include `.monitor` / `loopback` would slip through.
|
||||
- **350 ms validation window adds a startup latency floor.** Slice 2 needs to know about this when wiring "click record".
|
||||
- **`stop()` is `pause`, not `drop`.** The stream object is kept alive until `Drop`. A subsequent `start()` on the same `MicrophoneCapture` is not supported (signature returns a fresh instance).
|
||||
- **Sample format dispatch is closed-set.** Anything not F32 / I16 / U16 is a hard error. cpal can in principle expose I8 / I32 / F64 on exotic devices.
|
||||
- **`device_display_name` swallows errors.** `cpal::Device::description()` errors silently become `None`, then `<unnamed>` downstream. Acceptable for a UI list, surprising for debugging.
|
||||
- **Re-queue uses `try_send` on a channel of capacity 32.** If the sniff produced more than 32 chunks (≈64 ms at 48 kHz 256-frame buffers — uncommon but possible), the early ones are dropped against the same `dropped_chunks` counter. Documented at `capture.rs:486`.
|
||||
|
||||
## See also
|
||||
|
||||
- [Audio resampling](audio-resampling.md) — the live session feeds `AudioChunk.samples` into `StreamingResampler` to get to 16 kHz.
|
||||
- [Audio VAD](audio-vad.md) — what happens to chunks once they're at 16 kHz.
|
||||
- [Audio WAV I/O](audio-wav-io.md) — `WavWriter` is the crash-safe sibling that persists capture audio to disk.
|
||||
- [Audio PCM bridge](audio-pcm-bridge.md) — the static AudioWorklet that exists for browser-side fallback paths.
|
||||
- Tests at `crates/audio/src/capture.rs:567` cover monitor pattern detection. The validation-window logic is exercised end-to-end through the live integration tests in slice 2.
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: Audio file decoding (symphonia)
|
||||
type: architecture-map-page
|
||||
slice: 03-audio-transcription
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Audio file decoding (symphonia)
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Audio file decoding
|
||||
|
||||
**Plain English summary.** When the user imports an audio file, `decode_audio_file` reads it through `symphonia`, downmixes to mono, and returns f32 PCM at the file's native rate. `decode_and_resample` chains this with `resample_to_16khz` on a blocking task so the Tauri command stays async. A short `probe_audio_duration_secs` helper reads the duration without decoding the body, used for "is this longer than the import limit?" preflight.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Crate: `magnotia-audio`
|
||||
- Paths: `crates/audio/src/decode.rs` (283 LOC), `crates/audio/src/concurrency.rs` (19 LOC).
|
||||
- External deps: `symphonia 0.5` with features `mp3, aac, flac, pcm, vorbis, wav, ogg, isomp4`.
|
||||
- Internal callers (best effort, slice 2 reconciles): the import-file Tauri command and the file-mode transcription command call `decode_and_resample`. Standalone `decode_audio_file` is exposed for tests and any path that wants the native rate.
|
||||
|
||||
Public surface:
|
||||
|
||||
- `pub fn decode_audio_file(path: &Path) -> Result<AudioSamples>` — `crates/audio/src/decode.rs:23`.
|
||||
- `pub fn decode_audio_file_limited(path: &Path, max_duration_secs: Option<f64>) -> Result<AudioSamples>` — `crates/audio/src/decode.rs:27`.
|
||||
- `pub fn probe_audio_duration_secs(path: &Path) -> Result<Option<f64>>` — `crates/audio/src/decode.rs:43`.
|
||||
- `pub async fn decode_and_resample(path: &Path) -> Result<AudioSamples>` — `crates/audio/src/concurrency.rs:11`.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `decode_audio_file` and friends
|
||||
|
||||
`decode_audio_file` (`decode.rs:23`) delegates to `decode_audio_file_limited(path, None)`. The `_limited` variant accepts a hard cap in seconds; once the per-track sample count crosses `(max_duration_secs * sample_rate).ceil()` it returns `MagnotiaError::AudioDecodeFailed("Audio is longer than the {:.0} minute import limit", ...)`. Used by the Tauri import command to enforce the configured maximum.
|
||||
|
||||
Both call into `decode_media_stream` (`decode.rs:77`), which is the actual decode loop and the seam tests inject a `MediaSource` into. Steps:
|
||||
|
||||
1. Probe the format (`symphonia::default::get_probe()`).
|
||||
2. Pick the default track; reject if there is none or the codec sample rate is `0`.
|
||||
3. Build a decoder via `symphonia::default::get_codecs()`.
|
||||
4. Loop:
|
||||
- `format.next_packet()` — `UnexpectedEof` is the natural EOF marker; any other error is propagated as `AudioDecodeFailed`. `ResetRequired` is treated as a discontinuity error.
|
||||
- Skip packets that don't belong to the chosen track.
|
||||
- Decode into a `SampleBuffer<f32>`.
|
||||
- Average channels into mono if `channels > 1`; otherwise extend the output verbatim.
|
||||
- Apply the duration cap if one was provided.
|
||||
5. Reject empty output (`No audio data decoded`) and return `AudioSamples::new(samples, sample_rate, 1)`.
|
||||
|
||||
### `probe_audio_duration_secs`
|
||||
|
||||
`decode.rs:43`. Re-runs the probe step only, reads `track.codec_params.n_frames` and `sample_rate`, returns `Some(seconds)` if both are available. Cheaper than a full decode for "is this file too long?" gating.
|
||||
|
||||
### `decode_and_resample`
|
||||
|
||||
`concurrency.rs:11`. Wraps `decode_audio_file` + `resample_to_16khz` in `tokio::task::spawn_blocking`. Used by every async caller that needs ready-to-transcribe samples. Maps `JoinError` to `MagnotiaError::AudioDecodeFailed("Task join error: {e}")`.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
Path
|
||||
└─ File::open
|
||||
└─ MediaSourceStream
|
||||
└─ Hint(extension)
|
||||
└─ probe → format + codec_params
|
||||
└─ Decoder
|
||||
└─ next_packet loop
|
||||
├─ packet → decoded → SampleBuffer<f32>
|
||||
└─ if channels > 1: mean across channels (mono mixdown)
|
||||
→ samples (f32, native rate)
|
||||
```
|
||||
|
||||
`decode_and_resample` chains the above with `resample_to_16khz` so the caller gets `AudioSamples` already at 16 kHz.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **RB-09 regression (2026-04-22 review).** The previous loop did `Err(_) => break` and silently returned partial audio on a mid-stream I/O error. Current code distinguishes EOF, `ResetRequired`, and other errors. Test at `decode.rs:262` injects a flaky `MediaSource` to keep this honest.
|
||||
- **AAC and ISO MP4 are pulled in unconditionally** by the symphonia feature set. Patent-licence implications for AAC are flagged in the brief; this is not a per-build decision today.
|
||||
- **Channels are averaged, not mixed psychoacoustically.** Stereo to mono goes via mean of all channels per sample. Fine for ASR; lossy for any future task that needs spatial cues.
|
||||
- **Whole-file decode loads everything into memory.** The duration cap is the only guard. Long imports exceed RAM long before they exceed disk.
|
||||
- **`Hint` only carries extension.** A `.mp3` named `.bin` will fail to probe even if the bytes are valid MP3. Caller responsibility to keep extensions truthful.
|
||||
- **`decode_and_resample` returns the resampled output.** If a downstream caller wanted the original rate (for a non-ASR purpose), it must call `decode_audio_file` directly.
|
||||
|
||||
## See also
|
||||
|
||||
- [Audio resampling](audio-resampling.md) — `resample_to_16khz` is the second half of `decode_and_resample`.
|
||||
- [Audio WAV I/O](audio-wav-io.md) — `read_wav` is the WAV-only fast path that uses `hound` instead of symphonia.
|
||||
- `magnotia_core::types::AudioSamples` (slice 5) — the value type returned.
|
||||
- Tests at `decode.rs:175` (RB-09 regression among others).
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: PCM bridge (AudioWorklet)
|
||||
type: architecture-map-page
|
||||
slice: 03-audio-transcription
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# PCM bridge (AudioWorklet)
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → PCM bridge
|
||||
|
||||
**Plain English summary.** `static/pcm-processor.js` is a tiny browser-side AudioWorklet. It runs inside the Svelte frontend's `AudioContext`, naively downsamples the device's native rate to 16 kHz, and posts ~0.5 s batches of f32 samples back to the main thread. It only matters on a code path that goes browser-mic → frontend → Tauri command rather than the native cpal capture; today's primary path is `MicrophoneCapture` (Rust). Treat this file as a minimal fallback / web-context stub.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Runtime: browser AudioWorkletProcessor (not a Rust crate).
|
||||
- Path: `static/pcm-processor.js`
|
||||
- LOC: 44
|
||||
- External deps: none (Web Audio API).
|
||||
- Internal callers (best effort, slice 1 reconciles): the Svelte frontend instantiates it with `audioContext.audioWorklet.addModule("/pcm-processor.js")` and listens for `port.message` events. The native cpal path in `magnotia-audio` is the production path; this exists for parity in dev/web contexts.
|
||||
|
||||
Public surface: registers the AudioWorklet processor name `pcm-processor`.
|
||||
|
||||
## What's in here
|
||||
|
||||
The whole file:
|
||||
|
||||
```js
|
||||
class PcmProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.buffer = [];
|
||||
this.ratio = sampleRate / 16000;
|
||||
this.needsResample = Math.abs(this.ratio - 1.0) > 0.01;
|
||||
this.resamplePos = 0;
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (!input || input.length === 0) return true;
|
||||
const samples = input[0]; // First channel (mono)
|
||||
if (!samples) return true;
|
||||
|
||||
if (this.needsResample) {
|
||||
// Simple downsampling to 16kHz
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
this.resamplePos += 1;
|
||||
if (this.resamplePos >= this.ratio) {
|
||||
this.buffer.push(samples[i]);
|
||||
this.resamplePos -= this.ratio;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
this.buffer.push(samples[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.buffer.length >= 8000) {
|
||||
this.port.postMessage({ type: "pcm", samples: this.buffer });
|
||||
this.buffer = [];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor("pcm-processor", PcmProcessor);
|
||||
```
|
||||
|
||||
`sampleRate` is the AudioWorklet global (the context's rate). The file picks the first channel and ignores the rest.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
mediaStream → MediaStreamAudioSourceNode → AudioWorkletNode("pcm-processor")
|
||||
└─ process(inputs)
|
||||
├─ if needsResample (ratio != 1): drop-sample downsample to 16 kHz
|
||||
└─ else: passthrough
|
||||
└─ buffer to ~0.5 s (8000 samples)
|
||||
└─ postMessage({ type: "pcm", samples: f32[] })
|
||||
└─ frontend receiver (slice 1) → Tauri command (slice 2)
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **Drop-sample downsampling, no anti-alias filter.** Above ~8 kHz the result will alias. For ASR this is rarely a problem because Whisper expects 16 kHz inputs (so anything above 8 kHz Nyquist is already discarded), but if anyone repurposes this for non-ASR work it will show.
|
||||
- **Mono only.** Reads channel 0, ignores channels 1+.
|
||||
- **`Math.abs(ratio - 1) > 0.01` decides passthrough.** A 16001 Hz context (rare but legal) would skip the resample loop.
|
||||
- **`port.postMessage` clones the array.** No transferables in this implementation; large session lengths magnify the cost.
|
||||
- **The native cpal path is the primary path.** This worklet matters when the frontend captures audio itself (web build, dev preview without the Tauri shell). Slice 1 will know which routes mount it.
|
||||
- **Build artefacts mirror the source.** Copies appear under `build/` and `.svelte-kit/output/client/` after a build. The source-of-truth file is `static/pcm-processor.js`.
|
||||
|
||||
## See also
|
||||
|
||||
- [Audio capture pipeline](audio-capture-pipeline.md) — the production path using cpal, not this worklet.
|
||||
- [Audio resampling](audio-resampling.md) — the (much higher quality) sinc resampler used on the native path.
|
||||
- Frontend Audio plumbing (slice 1) — the consumer of `port.postMessage`.
|
||||
106
docs/architecture-map/03-audio-transcription/audio-resampling.md
Normal file
106
docs/architecture-map/03-audio-transcription/audio-resampling.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: Audio resampling (rubato)
|
||||
type: architecture-map-page
|
||||
slice: 03-audio-transcription
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Audio resampling (rubato)
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Audio resampling
|
||||
|
||||
**Plain English summary.** Whisper and Parakeet want 16 kHz mono `f32`. Microphones and audio files almost never deliver that natively. Two `rubato`-backed resamplers convert: `resample_to_16khz` for one-shot file conversion, `StreamingResampler` for the live session where samples arrive in arbitrary chunks and a flush at the end drains the resampler's tail.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Crate: `magnotia-audio`
|
||||
- Paths: `crates/audio/src/resample.rs` (100 LOC), `crates/audio/src/streaming_resample.rs` (211 LOC).
|
||||
- External deps: `rubato 0.15` (sinc interpolation).
|
||||
- Internal callers (best effort, slice 2 reconciles): `concurrency::decode_and_resample` (file path used by `audio.rs` and `transcription.rs` Tauri commands), `live.rs` Tauri command (live path).
|
||||
|
||||
Public surface:
|
||||
|
||||
- `pub fn resample_to_16khz(audio: &AudioSamples) -> Result<AudioSamples>` — `crates/audio/src/resample.rs:11`.
|
||||
- `pub enum StreamingResampler` — `crates/audio/src/streaming_resample.rs:37`. Variants: `Passthrough`, `Sinc { resampler, residual, ratio }`.
|
||||
- `pub fn StreamingResampler::new(from_rate: u32) -> Result<Self>` — `crates/audio/src/streaming_resample.rs:52`.
|
||||
- `pub fn StreamingResampler::push_samples(&mut self, mono: &[f32]) -> Result<Vec<f32>>` — `crates/audio/src/streaming_resample.rs:93`.
|
||||
- `pub fn StreamingResampler::flush(&mut self) -> Result<Vec<f32>>` — `crates/audio/src/streaming_resample.rs:127`.
|
||||
|
||||
## What's in here
|
||||
|
||||
### Shared sinc parameters
|
||||
|
||||
Both files use the same `SincInterpolationParameters`:
|
||||
|
||||
```rust
|
||||
sinc_len: 256,
|
||||
f_cutoff: 0.95,
|
||||
oversampling_factor: 128,
|
||||
interpolation: SincInterpolationType::Cubic,
|
||||
window: WindowFunction::Blackman,
|
||||
```
|
||||
|
||||
`max_relative_jitter = 1.1`, `chunks_in = 1024`, channels = 1.
|
||||
|
||||
### `resample_to_16khz` (file)
|
||||
|
||||
`crates/audio/src/resample.rs:11`. Pure function over an immutable `AudioSamples`. Short-circuits the passthrough case (already 16 kHz). Iterates the input in 1024-sample chunks; pads the trailing partial chunk to `chunk_size` zeros; truncates the output to `(input_len * ratio) as usize` to discard the padding's residue.
|
||||
|
||||
Errors map to `MagnotiaError::AudioDecodeFailed` (one variant for ratio init failure, one for per-chunk process failure).
|
||||
|
||||
### `StreamingResampler` (live)
|
||||
|
||||
`crates/audio/src/streaming_resample.rs`. Two states:
|
||||
|
||||
- `Passthrough` — emitted when `from_rate == WHISPER_SAMPLE_RATE`. `push_samples` returns input verbatim; `flush` returns empty.
|
||||
- `Sinc { resampler, residual, ratio }` — wraps a `SincFixedIn::<f32>`. `residual` buffers samples that don't yet form a complete `INPUT_CHUNK = 1024`. Each `push_samples` call drains as many full chunks as it can.
|
||||
|
||||
`flush()` is the load-bearing tail handler:
|
||||
|
||||
1. If `residual` is empty, return empty (no flush work pending).
|
||||
2. Pad `residual` up to `INPUT_CHUNK` zeros.
|
||||
3. Run `resampler.process()` once over the padded chunk.
|
||||
4. Truncate the output to `(leftover_real_samples * ratio).round() as usize` so the silence introduced by padding does not leak into the saved WAV.
|
||||
|
||||
The `flush` correctness math is verified by the `streaming_48k_to_16k_preserves_duration` test (`streaming_resample.rs:183`).
|
||||
|
||||
## Data flow
|
||||
|
||||
File path:
|
||||
|
||||
```
|
||||
AudioSamples (any rate, mono)
|
||||
└─ resample_to_16khz()
|
||||
├─ if rate == 16k → AudioSamples::mono_16khz(clone)
|
||||
└─ else: rubato SincFixedIn loop, padded final chunk, truncate to expected_len
|
||||
→ AudioSamples (16k, mono)
|
||||
```
|
||||
|
||||
Live path:
|
||||
|
||||
```
|
||||
cpal AudioChunk (native rate, possibly stereo, downmixed by caller)
|
||||
└─ StreamingResampler::push_samples(&mono_f32)
|
||||
├─ residual.extend(mono)
|
||||
├─ while residual.len() >= 1024: resampler.process(chunk) → out
|
||||
└─ return out (zero or more 16 kHz samples)
|
||||
...session ends...
|
||||
└─ StreamingResampler::flush()
|
||||
└─ pad residual to 1024, process, trim padding-induced output
|
||||
→ 16 kHz mono Vec<f32>
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **Two implementations, one parameter set.** Drift between `resample.rs` and `streaming_resample.rs` is currently held by convention only. Any change to `SincInterpolationParameters` must touch both files.
|
||||
- **Caller is responsible for downmixing.** Both functions assume mono input. `decode.rs` averages channels for files; the live session must do the same before pushing to `StreamingResampler`.
|
||||
- **`from_rate = 0` is rejected** in both functions (matches a corrupt WAV header path).
|
||||
- **Truncation policy differs slightly.** `resample_to_16khz` uses `(samples.len() as f64 * ratio) as usize` (truncating cast), `StreamingResampler::flush` uses `.round() as usize`. Off by one sample at most; matters for WAV file length parity if you ever compare them.
|
||||
- **No streaming for file path.** `resample_to_16khz` materialises the whole resampled vec in memory. A 90-minute meeting at 48 kHz pre-resample is ~250 MB of `f32`; the post-resample buffer is another 350 MB. Acceptable today, brittle for very long imports.
|
||||
- **Passthrough still calls `to_vec()`** on the input samples (`streaming_resample.rs:95`). Cheap but not free; if push frequency is high consider returning `Cow`.
|
||||
|
||||
## See also
|
||||
|
||||
- [Audio capture pipeline](audio-capture-pipeline.md) — produces the `AudioChunk`s that feed `StreamingResampler`.
|
||||
- [Audio file decoding](audio-file-decoding.md) — `decode_and_resample` chains decode + `resample_to_16khz`.
|
||||
- `magnotia_core::constants::WHISPER_SAMPLE_RATE` (slice 5) — single source of truth for the target rate.
|
||||
55
docs/architecture-map/03-audio-transcription/audio-vad.md
Normal file
55
docs/architecture-map/03-audio-transcription/audio-vad.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: Audio VAD (currently a stub)
|
||||
type: architecture-map-page
|
||||
slice: 03-audio-transcription
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Audio VAD (currently a stub)
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Audio VAD
|
||||
|
||||
**Plain English summary.** `magnotia-audio` exposes a `SpeechDetector` type, but right now its `is_speech()` returns `true` for every input. This is a deliberate stub: both candidate Silero VAD bindings pin an older `ort` version than `transcribe-rs` (Parakeet) requires, so adding either today would force a workspace-wide downgrade. Live capture uses the energy-based `RmsVadChunker` from the transcription crate as the working fallback until the `ort` ecosystem aligns.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Crate: `magnotia-audio`
|
||||
- Path: `crates/audio/src/vad.rs`
|
||||
- LOC: 35
|
||||
- External deps: none (pulls `VAD_SPEECH_THRESHOLD` from `magnotia_core::constants`).
|
||||
- Internal callers (best effort): unused in production today. The threshold is read but never compared, because `is_speech` always returns `true`.
|
||||
|
||||
Public surface:
|
||||
|
||||
- `pub struct SpeechDetector` — `crates/audio/src/vad.rs:14`, derives `Default`.
|
||||
- `pub fn SpeechDetector::new() -> Self` — `crates/audio/src/vad.rs:19`.
|
||||
- `pub fn SpeechDetector::is_speech(&self, _samples: &[f32]) -> bool` — `crates/audio/src/vad.rs:26`.
|
||||
- `pub fn SpeechDetector::threshold(&self) -> f64` — `crates/audio/src/vad.rs:30`.
|
||||
- `pub fn SpeechDetector::reset(&mut self)` — `crates/audio/src/vad.rs:34`.
|
||||
|
||||
## What's in here
|
||||
|
||||
The whole file is the stub plus an explanatory header:
|
||||
|
||||
> Both `voice_activity_detector` and `silero-vad-rust` pin ort 2.0.0-rc.10 which conflicts with transcribe-rs requiring ort 2.0.0-rc.12. When the ort ecosystem aligns (likely at 2.0.0 stable), add Silero VAD here.
|
||||
>
|
||||
> For now, all audio is treated as speech. This matches v0.2 behaviour (no VAD) and doesn't affect core functionality.
|
||||
|
||||
The threshold comes from `VAD_SPEECH_THRESHOLD` (slice 5) and is currently dead weight.
|
||||
|
||||
## Data flow
|
||||
|
||||
`SpeechDetector::is_speech(samples) -> true`. There is no flow.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **The other VAD lives in the transcription crate.** `RmsVadChunker` (see [transcription-streaming.md](transcription-streaming.md)) is the live-capture chunker that actually decides what gets transcribed. When Silero lands here, expect to consolidate the two: the chunker becomes a thin shell around a real VAD predicate.
|
||||
- **`is_speech` returning `true` is a feature, not a bug.** v0.2 had no VAD at all. The stub keeps the interface stable so callers can switch to a real Silero impl without a code change.
|
||||
- **Do not reach for `voice_activity_detector` 0.x or `silero-vad-rust` 6.x without a workspace-wide ort audit.** The conflict is documented, has bitten before, and will bite again until upstream `ort` reaches a stable that both crates target.
|
||||
- **`reset()` is a no-op.** The trait shape exists for future Silero state.
|
||||
|
||||
## See also
|
||||
|
||||
- [Transcription streaming](transcription-streaming.md) — `RmsVadChunker`, the actual gating today.
|
||||
- `magnotia_core::constants::VAD_SPEECH_THRESHOLD` (slice 5) — wired but unused.
|
||||
- `Cargo.toml` of `magnotia-audio` — the commented-out `silero-vad-rust = "6"` line documents the deferred dep.
|
||||
84
docs/architecture-map/03-audio-transcription/audio-wav-io.md
Normal file
84
docs/architecture-map/03-audio-transcription/audio-wav-io.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: Audio WAV I/O (hound)
|
||||
type: architecture-map-page
|
||||
slice: 03-audio-transcription
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Audio WAV I/O (hound)
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Audio WAV I/O
|
||||
|
||||
**Plain English summary.** Two helpers cover the simple round-trip case: `write_wav` writes f32 samples to a 16-bit PCM WAV; `read_wav` reads them back, converting either int or float WAV payloads to f32. A third type, `WavWriter`, is the crash-safe append writer used during long live captures: it flushes the WAV header every 8000 samples (≈500 ms at 16 kHz), so a process kill leaves a valid, playable file on disk up to the last flush.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Crate: `magnotia-audio`
|
||||
- Path: `crates/audio/src/wav.rs`
|
||||
- LOC: 287
|
||||
- External deps: `hound 3.5`.
|
||||
- Internal callers (best effort, slice 2 reconciles): `WavWriter` is consumed by the live-session command in `src-tauri/src/commands/live.rs` (brief item #19). `read_wav` and `write_wav` are used by the file-import command and by the audio-decode tests.
|
||||
|
||||
Public surface:
|
||||
|
||||
- `pub struct WavWriter` — `crates/audio/src/wav.rs:21`.
|
||||
- `pub fn WavWriter::create(path: &Path, sample_rate: u32, channels: u16) -> Result<Self>` — `crates/audio/src/wav.rs:36`.
|
||||
- `pub fn WavWriter::append(&mut self, samples: &[f32]) -> Result<()>` — `crates/audio/src/wav.rs:58`.
|
||||
- `pub fn WavWriter::flush(&mut self) -> Result<()>` — `crates/audio/src/wav.rs:78`.
|
||||
- `pub fn WavWriter::finalize(self) -> Result<()>` — `crates/audio/src/wav.rs:90`.
|
||||
- `pub fn write_wav(path: &Path, audio: &AudioSamples) -> Result<()>` — `crates/audio/src/wav.rs:99`.
|
||||
- `pub fn read_wav(path: &Path) -> Result<AudioSamples>` — `crates/audio/src/wav.rs:132`.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `WavWriter` (crash-safe live capture)
|
||||
|
||||
Wraps a `hound::WavWriter<BufWriter<File>>` plus a `samples_since_flush` counter and a `flush_every` threshold (`DEFAULT_FLUSH_EVERY_SAMPLES = 8_000`, 500 ms at 16 kHz).
|
||||
|
||||
- `create` constructs a 16-bit PCM `WavSpec` from the supplied `sample_rate` / `channels`. Sample rate and channel count come from `LocalEngine::capabilities()` rather than being hardcoded — this matters once non-Whisper engines (Parakeet, future Moonshine) declare different rates.
|
||||
- `append` clamps each f32 to `[-1.0, 1.0]`, scales to `i16`, writes the sample, and flushes the header every `flush_every` samples.
|
||||
- `flush` calls `hound::WavWriter::flush` (which rewrites the RIFF and data chunk sizes in the header) and resets the counter.
|
||||
- `finalize` writes the terminal header and consumes the writer. A dropped-without-finalize writer leaves a valid file up to the last flush; callers that care about the unflushed tail should always call finalize.
|
||||
|
||||
### `write_wav` and `read_wav`
|
||||
|
||||
`write_wav` is the simple one-shot writer over `AudioSamples`. Same clamp + i16 scale.
|
||||
|
||||
`read_wav` reads either int or float WAV payloads and returns f32 in `[-1, 1]`. For int payloads it scales by `1 << (bits_per_sample - 1)`. Unlike a previous version, **per-sample errors are propagated rather than silently dropped** (see watch-out below).
|
||||
|
||||
## Data flow
|
||||
|
||||
Live capture path (production use):
|
||||
|
||||
```
|
||||
StreamingResampler.push_samples → Vec<f32> 16 kHz mono
|
||||
└─ WavWriter::append(chunk)
|
||||
└─ for each f32:
|
||||
clamp [-1,1] → (i16) write_sample
|
||||
└─ if samples_since_flush >= 8000:
|
||||
hound flush (rewrites size headers)
|
||||
…session ends…
|
||||
└─ WavWriter::finalize()
|
||||
└─ terminal header write + close
|
||||
```
|
||||
|
||||
File round-trip (used in tests + simple imports):
|
||||
|
||||
```
|
||||
AudioSamples → write_wav → 16-bit PCM WAV → read_wav → AudioSamples
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **Crash-safety guarantee is "up to the last flush", not "every sample".** Worst-case loss is `flush_every / sample_rate` seconds (default 500 ms at 16 kHz). The `wav_writer_survives_crash` test (`wav.rs:170`) uses `std::mem::forget` to skip Drop and proves the flushed prefix is recoverable.
|
||||
- **`finalize` is consuming.** Once called you cannot append further. The live-session command must hold the writer until session end and only finalize at the close path.
|
||||
- **i16 quantisation noise.** Both writers downcast f32 → i16. WAV round-trip introduces ~1 / 32768 absolute error per sample, validated by the `wav_roundtrip` test (`wav.rs:265`).
|
||||
- **Truncated WAV bug fixed in tree (2026-04-22 review).** Previous `read_wav` used `filter_map(|s| s.ok())` and silently returned a short `AudioSamples` for corrupt input. Current code surfaces `MagnotiaError::AudioDecodeFailed`. Regression test at `wav.rs:235`.
|
||||
- **Channels and sample rate are taken from `AudioSamples` directly in `write_wav`.** `WavWriter::create` takes them as explicit args because it precedes the data; the caller (live session) reads `LocalEngine::capabilities()` to know what to pass.
|
||||
- **No async wrapper.** `WavWriter` operations are synchronous (Buffered file I/O). Live session must call them on a blocking task or accept the syscall cost on the audio thread.
|
||||
|
||||
## See also
|
||||
|
||||
- [Transcription engines overview](transcription-engines-overview.md) — `LocalEngine::capabilities()` is the source of `sample_rate` / `channels` for `WavWriter::create`.
|
||||
- [Audio file decoding](audio-file-decoding.md) — `decode_audio_file` is the symphonia-based path used for non-WAV files.
|
||||
- Tests at `crates/audio/src/wav.rs:165` (crash-safety, finalize round-trip, truncated-input regression).
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: Build script (Windows tokenizers guard)
|
||||
type: architecture-map-page
|
||||
slice: 03-audio-transcription
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Build script (Windows tokenizers guard)
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Build script
|
||||
|
||||
**Plain English summary.** `magnotia-transcription/build.rs` reads `Cargo.lock` and refuses to compile on Windows if the `tokenizers` crate ever lands in the workspace dependency graph. Linking `whisper-rs-sys` and `tokenizers` together has been a repeated MSVC C-runtime conflict (Whispering v7.11.0 shipped a broken Windows build over exactly this). On non-Windows the check is a `cargo:warning`, not a fatal error, so the failure surfaces at CI build time rather than waiting for a Windows ship.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Crate: `magnotia-transcription`
|
||||
- Path: `crates/transcription/build.rs`
|
||||
- LOC: 73
|
||||
- External deps: stdlib only (`std::env`, `std::fs`).
|
||||
- Internal callers: cargo invokes this automatically because `Cargo.toml` declares `build = "build.rs"`.
|
||||
|
||||
Public surface: none (build scripts have no callable surface).
|
||||
|
||||
## What's in here
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into()));
|
||||
|
||||
// Walk up to workspace root: crates/transcription/ -> crates/ -> root
|
||||
let workspace_root = manifest_dir
|
||||
.ancestors()
|
||||
.find(|p| p.join("Cargo.lock").exists())
|
||||
.map(PathBuf::from);
|
||||
|
||||
let Some(root) = workspace_root else { return; };
|
||||
let lock_path = root.join("Cargo.lock");
|
||||
println!("cargo:rerun-if-changed={}", lock_path.display());
|
||||
|
||||
let lock = match fs::read_to_string(&lock_path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let has_tokenizers = lock
|
||||
.lines()
|
||||
.any(|line| matches!(line.trim(), "name = \"tokenizers\""));
|
||||
|
||||
if !has_tokenizers { return; }
|
||||
|
||||
if target_os == "windows" {
|
||||
panic!(
|
||||
"magnotia-transcription: the `tokenizers` crate appears in Cargo.lock and this is a \
|
||||
Windows build. ..."
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
"cargo:warning=magnotia-transcription: `tokenizers` crate is in the dependency graph. \
|
||||
This build is non-Windows so the link will succeed, but Windows builds will panic ..."
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
cargo invoke
|
||||
└─ build.rs
|
||||
├─ rerun-if-changed=build.rs
|
||||
├─ ancestors().find(Cargo.lock) → workspace root
|
||||
├─ rerun-if-changed=Cargo.lock
|
||||
├─ scan for `name = "tokenizers"`
|
||||
└─ if found:
|
||||
target_os == windows? → panic!("brief item #6")
|
||||
else → cargo:warning
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **`rerun-if-changed=Cargo.lock` is the trigger.** Adding tokenizers without changing this crate's source still re-runs the script the next build because the lockfile changed.
|
||||
- **The string match is brittle on purpose.** Looking for the literal `name = "tokenizers"` line in `Cargo.lock` is what TOML pretty-prints. A future cargo version that emits the lockfile differently could miss this. Mitigation: keep the test simple and review on cargo upgrade.
|
||||
- **The non-Windows path is a warning, not an error.** A CI matrix job on Linux will pass with a yellow message; Windows will fail at build. Fine for a desktop project that ships from Linux first; surprising if anyone ever assumes "Linux green = ready to ship".
|
||||
- **Brief item #6 reference.** The panic message points at `docs/whisper-ecosystem/brief.md` item #6. Do not lose that pointer in any rewrite — the failure mode is non-obvious without the historical context.
|
||||
- **`workspace_root` walk falls back gracefully.** If no `Cargo.lock` is found in any ancestor (first-ever cargo run), the script returns early. Subsequent builds will pick up the lock.
|
||||
- **No way to override.** A maintainer who *wants* to ship `tokenizers` on Windows must delete this `build.rs`, or carry a sidecar process that links tokenizers in its own binary. There is no escape hatch env var.
|
||||
|
||||
## See also
|
||||
|
||||
- [Transcription whisper](transcription-whisper.md) — `whisper-rs-sys` is the link target this guard protects.
|
||||
- [Cargo features](cargo-features.md) — same brief-item #6 family of decisions.
|
||||
- `docs/whisper-ecosystem/brief.md` — the full incident background.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: Cargo feature matrix
|
||||
type: architecture-map-page
|
||||
slice: 03-audio-transcription
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Cargo feature matrix
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Cargo features
|
||||
|
||||
**Plain English summary.** `magnotia-audio` has no Cargo features. `magnotia-transcription` has two: `whisper` (default on, gates `whisper-rs`, `whisper_rs_backend.rs`, and `load_whisper`) and `whisper-vulkan` (default on, additive Vulkan GPU offload). Parakeet is unconditional. The defaults give a desktop GPU build; non-Vulkan or cloud-only configurations turn features off explicitly.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Crate file: `crates/transcription/Cargo.toml`
|
||||
- Source of `[features]`: `crates/transcription/Cargo.toml:8-21`.
|
||||
- Source of conditionals in code: `#[cfg(feature = "whisper")]` in `crates/transcription/src/lib.rs:6` and `:10`, plus the gated `pub mod whisper_rs_backend;` and `load_whisper` re-export. Vulkan check inside `WhisperRsBackend::transcribe_sync` (`crates/transcription/src/whisper_rs_backend.rs:82`).
|
||||
|
||||
## Feature table
|
||||
|
||||
| Feature | Default | Pulls in | Gates |
|
||||
|---|---|---|---|
|
||||
| `whisper` | yes | `whisper-rs 0.16` (optional dep) | `whisper_rs_backend` module, `load_whisper` re-export, the `set_initial_prompt` path |
|
||||
| `whisper-vulkan` | yes | `whisper-rs?/vulkan` | Compile-time GPU offload. Runtime gated additionally on `vulkan_loader_available()` |
|
||||
|
||||
Other deps are unconditional (always present): `transcribe-rs 0.3` (Parakeet), `magnotia-core`, `tokio`, `reqwest`, `futures-util`, `sha2`, `thiserror`, `tracing`. Dev-only: `tempfile`, `num_cpus` (used by `thread_sweep.rs` to label physical vs logical core counts).
|
||||
|
||||
## Build commands
|
||||
|
||||
Default (Whisper + Vulkan):
|
||||
|
||||
```sh
|
||||
cargo build -p magnotia-transcription
|
||||
```
|
||||
|
||||
Whisper without Vulkan (CPU-only desktop, Android without GPU drivers):
|
||||
|
||||
```sh
|
||||
cargo build -p magnotia-transcription --no-default-features --features whisper
|
||||
```
|
||||
|
||||
No Whisper at all (Parakeet only — useful for shrinking the binary and dropping `whisper-rs-sys`):
|
||||
|
||||
```sh
|
||||
cargo build -p magnotia-transcription --no-default-features
|
||||
```
|
||||
|
||||
The `--no-default-features` form drops the `whisper_rs_backend` module entirely, so any sibling code holding a `WhisperRsBackend` will fail to compile. `LocalEngine`, `SpeechModelAdapter`, `load_parakeet`, and the streaming primitives stay available.
|
||||
|
||||
## Rationale (from Cargo.toml comments)
|
||||
|
||||
- `whisper` exists as a feature so a future Windows non-AVX2 build, or a cloud-only ASR config, can drop `whisper-rs-sys` entirely (brief item #13). Disabling it also drops the `WhisperRsBackend` module and the `load_whisper` entry point.
|
||||
- `whisper-vulkan` is a separate feature so a non-Vulkan target (Android without GPU drivers, a CPU-only Windows build) can pull in `whisper-rs` but skip the Vulkan backend.
|
||||
- `transcribe-rs` is unconditional because today's only second engine (Parakeet) goes through it. Brief item #13's hypothetical "drop both engines" case would need a second feature flag introduced here.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **`whisper-vulkan` is purely additive.** Disabling it does not change `whisper`'s default build except for skipping the Vulkan backend selection. There is no separate "force CPU" or "force GPU" runtime flag; the backend probes `vulkan_loader_available()` and uses Vulkan if present.
|
||||
- **`thiserror` and `tracing` are not feature-gated.** Comments justify keeping them unconditional (small cost, broad usage).
|
||||
- **Reqwest features are pinned.** `default-features = false, features = ["rustls-tls", "stream"]`. There is no `native-tls` fallback path; `--features reqwest/native-tls` would rebuild the dep with the wrong TLS backend on Linux.
|
||||
- **`whisper-rs` is `default-features = false, optional = true`.** Whisper-rs's own default features can re-enable backends we don't want; setting `default-features = false` keeps the dep minimal and lets `whisper-vulkan` add the one feature we care about.
|
||||
- **Switching feature sets between builds invalidates the build cache.** A common slow-down for first-time contributors switching `--no-default-features` builds.
|
||||
|
||||
## See also
|
||||
|
||||
- [Transcription whisper](transcription-whisper.md) — the gated module.
|
||||
- [Transcription parakeet](transcription-parakeet.md) — note Parakeet has no feature flag.
|
||||
- [Build tokenizers guard](build-tokenizers-guard.md) — Windows MSVC defence is part of the same brief item #6 thinking.
|
||||
- `crates/transcription/Cargo.toml` — the source of truth.
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: Tests and fixtures
|
||||
type: architecture-map-page
|
||||
slice: 03-audio-transcription
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Tests and fixtures
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Tests and fixtures
|
||||
|
||||
**Plain English summary.** This slice has three categories of test. **Inline unit tests** under `#[cfg(test)] mod tests` exercise every public type (capture monitor detection, resampler duration preservation, RmsVadChunker state machine, LocalAgreement invariants, buffer trim bounds, model-manager hash and resume paths). **Integration tests** under `crates/transcription/tests/` exercise whisper-rs end-to-end with env-var-gated fixtures so CI without a model checkpoint exits quietly. **Test-only fixtures** include in-tree `tokio::net::TcpListener` HTTP servers that simulate Range / 5xx / no-Range-honour servers for the download stack.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Crate: `magnotia-transcription` (mostly) and `magnotia-audio` (inline only).
|
||||
- Paths:
|
||||
- `crates/audio/src/{capture,decode,wav,resample,streaming_resample}.rs` — inline `#[cfg(test)]` modules.
|
||||
- `crates/transcription/src/{model_manager,local_engine,transcriber,streaming/*}.rs` — inline `#[cfg(test)]` modules.
|
||||
- `crates/transcription/tests/whisper_rs_smoke.rs` — env-gated load + transcribe.
|
||||
- `crates/transcription/tests/jfk_bench.rs` — env-gated benchmark with cold/warm RTF.
|
||||
- `crates/transcription/tests/thread_sweep.rs` — env-gated thread-count scaling sweep.
|
||||
- External deps (dev): `tempfile 3`, `tokio` (rt, sync, net, io-util, macros), `num_cpus 1` (label-only).
|
||||
|
||||
## Inline unit tests (selected highlights)
|
||||
|
||||
### `magnotia-audio`
|
||||
|
||||
- `capture.rs:570` — `monitor_pattern_detection` for the PulseAudio / PipeWire heuristics.
|
||||
- `decode.rs:262` — `mid_stream_io_error_propagates_instead_of_returning_partial_audio`. RB-09 regression: a flaky `MediaSource` (`FlakyCursor`, `decode.rs:203`) injects an I/O error after the probe header and asserts the function errors instead of returning `Ok(partial)`.
|
||||
- `wav.rs:170` — `wav_writer_survives_crash`. Uses `std::mem::forget` to skip the hound finaliser, simulating a process kill, then asserts the flushed prefix is recoverable via `read_wav`.
|
||||
- `wav.rs:235` — `read_wav_surfaces_truncated_sample_stream_errors`. 2026-04-22 regression: the previous `filter_map(|s| s.ok())` swallowed errors silently.
|
||||
- `streaming_resample.rs:182` — `streaming_48k_to_16k_preserves_duration`. Pushes 1 s of 48 kHz audio in 700-sample chunks (forcing the residual-buffer path) and asserts the output is within 50 ms of 1 s at 16 kHz.
|
||||
- `resample.rs:84` — `resample_preserves_approximate_duration`.
|
||||
|
||||
### `magnotia-transcription`
|
||||
|
||||
- `streaming/rms_vad.rs:371`–`:734` — comprehensive state-machine coverage: silence, hysteresis, onset gating, max-chunk continuity (`max_chunk_split_preserves_audio_contiguity`), padded-final-frame regression (`flush_preserves_hit_max_chunk_from_padded_final_frame`, `flush_preserves_end_of_utterance_chunk_from_padded_final_frame` — both 2026-04-22 CRITICAL C2 fixes), idempotent flush.
|
||||
- `streaming/commit_policy.rs:212`–`:402` — LocalAgreement-n correctness: text-only equality, non-shrinkage invariant, defensive shorter-pass handling (`shorter_pass_after_commit_does_not_panic`, regression for the `latest[committed_count..]` panic), n=3 behaviour.
|
||||
- `streaming/buffer_trim.rs:56`–`:206` — defensive non-finite handling, integration test against `LocalAgreement::last_committed_end_secs`, the long-session bound proof (`trim_bounds_buffer_over_long_session`).
|
||||
- `model_manager.rs:339`–`:615` — three TcpListener-backed fixtures (`spawn_range_server`, `spawn_no_range_server`, `spawn_500_server`) drive `download_file` through the resume path, the ResumeUnsupported path (server returns 200 to a Range request), the 5xx rejection path, and the SHA-mismatch cleanup path.
|
||||
- `local_engine.rs:218` — `engine_reports_not_available_before_loading`.
|
||||
- `transcriber.rs:54` — `transcriber_trait_is_object_safe` (compile-time witness).
|
||||
|
||||
## Env-gated integration tests (`crates/transcription/tests/`)
|
||||
|
||||
All three skip silently if the env vars are unset, so `cargo test` on a fresh machine is a no-op for them.
|
||||
|
||||
### `whisper_rs_smoke.rs`
|
||||
|
||||
- Env vars: `MAGNOTIA_WHISPER_TEST_MODEL` (path to a ggml/gguf Whisper model).
|
||||
- Loads the context, builds a state, calls `set_initial_prompt("Wren, CORBEL, ADHD")` (proves the API still exists), runs inference on 1 s of silence, exercises `full_n_segments` and `get_segment(i).to_str()`. Smoke test only; assertion is "did this run without panicking".
|
||||
|
||||
### `jfk_bench.rs`
|
||||
|
||||
- Env vars: `MAGNOTIA_WHISPER_TEST_MODEL`, `MAGNOTIA_WHISPER_TEST_AUDIO` (path to a 16 kHz mono 16-bit PCM WAV — assertions in the test enforce this format on the fixture, not the runtime).
|
||||
- Reports cold-load time, cold-transcribe RTF, warm-transcribe RTF, peak RSS read from `/proc/{pid}/status`. Hardcodes `set_n_threads(6)` for both runs (so a sweep across thread counts is a separate test, not this one).
|
||||
|
||||
### `thread_sweep.rs`
|
||||
|
||||
- Same env vars as `jfk_bench.rs`. Adds `MAGNOTIA_POWER_STATE_OVERRIDE` (set internally per panel to `ac` or `battery`) so `inference_thread_count` returns its predicted pick for each combination of (power state × GPU offload) and the empirical RTF table can be compared against the helper's choice.
|
||||
- Runs the JFK clip at `n_threads = 1, 2, 4, physical, logical` (plus `8` if logical ≥ 8), takes the min of two runs per setting, prints a four-panel table:
|
||||
1. AC, CPU.
|
||||
2. AC, GPU (Vulkan).
|
||||
3. battery, CPU.
|
||||
4. battery, GPU (Vulkan).
|
||||
- Uses `num_cpus::get()` and `num_cpus::get_physical()` purely for the printed labels (`thread_sweep.rs:35`). The runtime helper `inference_thread_count(Workload::Whisper, gpu_offloaded)` does the actual prediction.
|
||||
- `vulkan_runtime_ok` snapshot taken once via `cfg!(feature = "whisper-vulkan") && vulkan_loader_available()`.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **All three integration tests are quiet on missing env vars.** A green `cargo test` does NOT mean the Whisper path works; it means "either the path works or you didn't set the env vars". CI configurations must explicitly set `MAGNOTIA_WHISPER_TEST_MODEL` to exercise these.
|
||||
- **`jfk_bench.rs` hardcodes `n_threads = 6`.** Production code uses `inference_thread_count(Workload::Whisper, gpu_offloaded)` which is power-aware. Bench numbers are not directly comparable to runtime numbers without rerunning with the helper-picked thread count.
|
||||
- **`thread_sweep.rs` mutates `MAGNOTIA_POWER_STATE_OVERRIDE` via `env::set_var`.** Globally process-wide. Concurrent tests in the same `cargo test` invocation that read `MAGNOTIA_POWER_STATE_OVERRIDE` will race. The test removes the var at the end (`thread_sweep.rs:94`).
|
||||
- **`tempfile 3` is a dev-dep, not a runtime dep.** Production code never creates temp files.
|
||||
- **`num_cpus = "1"` (the version, not "one CPU").** Test-only labelling; production uses the slice 5 helper.
|
||||
- **In-tree HTTP fixtures use ephemeral ports (`bind 127.0.0.1:0`) and run on a tokio task.** No port-collision risk, but a paranoid sandbox that blocks raw TCP loopback would break these tests.
|
||||
- **`ModelFile`'s `&'static str` fields force the test helper `leak()` (`model_manager.rs:462`).** Each test leaks a few small strings. Acceptable for one-shot tests; do not copy this pattern into production code.
|
||||
|
||||
## See also
|
||||
|
||||
- [Transcription model manager](transcription-model-manager.md) — the production code these download tests guard.
|
||||
- [Transcription whisper](transcription-whisper.md) — the production code these integration tests guard.
|
||||
- [Transcription streaming](transcription-streaming.md) — exhaustive unit-test coverage lives alongside this code.
|
||||
- `docs/whisper-ecosystem/brief.md` — the source of the items (`#6, #8, #19, #21, #24, #25`) repeatedly referenced in test docstrings.
|
||||
- `docs/code-review-2026-04-22.md` — the audit pass that produced the C2, RB-09, and `read_wav` regressions captured here.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: Inference concurrency (run_inference)
|
||||
type: architecture-map-page
|
||||
slice: 03-audio-transcription
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Inference concurrency
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Inference concurrency
|
||||
|
||||
**Plain English summary.** Whisper and Parakeet inference is synchronous and CPU-bound (or GPU-bound through Vulkan). Running it on the Tokio runtime would block the executor, so every async caller goes through `run_inference`, a thin wrapper that hands the work to `tokio::task::spawn_blocking`. Callers never see the `Arc<LocalEngine>` lock or the spawn boundary; they `await` a `TimedTranscript`.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Crate: `magnotia-transcription`
|
||||
- Path: `crates/transcription/src/concurrency.rs`
|
||||
- LOC: 19
|
||||
- External deps: `tokio` (rt feature for `spawn_blocking`).
|
||||
- Internal callers (best effort, slice 2 reconciles): `src-tauri/src/commands/transcription.rs` (file path), `src-tauri/src/commands/live.rs` (per-chunk during live capture).
|
||||
|
||||
Public surface:
|
||||
|
||||
- `pub async fn run_inference(engine: Arc<LocalEngine>, audio: AudioSamples, options: TranscriptionOptions) -> Result<TimedTranscript>` — `crates/transcription/src/concurrency.rs:11`.
|
||||
|
||||
## What's in here
|
||||
|
||||
The whole file:
|
||||
|
||||
```rust
|
||||
pub async fn run_inference(
|
||||
engine: Arc<LocalEngine>,
|
||||
audio: AudioSamples,
|
||||
options: TranscriptionOptions,
|
||||
) -> Result<TimedTranscript> {
|
||||
tokio::task::spawn_blocking(move || engine.transcribe_sync(&audio, &options))
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::TranscriptionFailed(format!("Task join error: {e}")))?
|
||||
}
|
||||
```
|
||||
|
||||
`engine` is shared via `Arc` (the engine itself uses an internal mutex for inference / load serialisation). `audio` and `options` are moved into the closure. The double `?` flattens the join-error layer with the inference-error layer.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
async caller
|
||||
└─ run_inference(engine, audio, options)
|
||||
└─ tokio::task::spawn_blocking(move || engine.transcribe_sync(...))
|
||||
└─ LocalEngine internal mutex → Transcriber::transcribe_sync
|
||||
→ Vec<Segment> + inference_ms
|
||||
→ Result<TimedTranscript>
|
||||
.await + map_err(JoinError → MagnotiaError::TranscriptionFailed)
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **`spawn_blocking` is the only place inference runs.** Anyone writing a new caller that calls `LocalEngine::transcribe_sync` directly from an async context will block the Tokio executor. Always go through `run_inference`.
|
||||
- **`Arc<LocalEngine>` is the contract.** The function takes an `Arc`, not `&LocalEngine`, because the engine must outlive the spawned task. The shared state in slice 2 holds the engine inside an `Arc`.
|
||||
- **Audio and options are moved.** They are not borrowed across the spawn boundary; the caller hands ownership over.
|
||||
- **JoinError is the only failure mode added by this layer.** `MagnotiaError::TranscriptionFailed("Task join error: ...")` indicates a panic inside `transcribe_sync` or a runtime shutdown. The actual inference errors flow through the inner `Result`.
|
||||
- **No backpressure here.** Callers issuing many concurrent `run_inference` calls each spawn an independent blocking task. Tokio's default blocking thread pool is 512 threads, but the `LocalEngine` mutex serialises them — only one will actually run inference at a time. Worth knowing if you ever look at thread counts in a profiler.
|
||||
|
||||
## See also
|
||||
|
||||
- [Transcription engines overview](transcription-engines-overview.md) — `LocalEngine::transcribe_sync` is what runs inside the blocking task.
|
||||
- [Transcription streaming](transcription-streaming.md) — live capture invokes `run_inference` per chunk.
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: Transcription engines overview
|
||||
type: architecture-map-page
|
||||
slice: 03-audio-transcription
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Transcription engines overview
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Transcription engines overview
|
||||
|
||||
**Plain English summary.** Every speech-to-text backend implements a single `Transcriber` trait. `LocalEngine` owns the currently-loaded backend behind a `Mutex`, exposes `transcribe_sync` for inference, and `load` / `unload` so the sequential-GPU guard can free VRAM before the LLM loads. Two concrete `Transcriber` implementations exist: `WhisperRsBackend` (direct `whisper-rs`, the only one that pipes `initial_prompt`) and `SpeechModelAdapter` (wraps any `transcribe-rs` `SpeechModel`, used today for Parakeet via a thin `ParakeetWordGranularity` shim).
|
||||
|
||||
## At a glance
|
||||
|
||||
- Crate: `magnotia-transcription`
|
||||
- Paths: `crates/transcription/src/transcriber.rs` (62 LOC), `crates/transcription/src/local_engine.rs` (226 LOC).
|
||||
- External deps: `transcribe-rs 0.3` (onnx feature). `whisper-rs 0.16` is feature-gated and lives in the next page.
|
||||
- Internal callers (best effort, slice 2 reconciles): `src-tauri/src/commands/models.rs` calls `load_whisper` / `load_parakeet` and `LocalEngine::load`. `src-tauri/src/commands/transcription.rs` (and `live.rs`) call `LocalEngine::transcribe_sync` indirectly via `concurrency::run_inference`.
|
||||
|
||||
Public surface:
|
||||
|
||||
- `pub trait Transcriber: Send` — `crates/transcription/src/transcriber.rs:35`. Methods: `capabilities()`, `transcribe_sync(&mut self, samples, options)`.
|
||||
- `pub struct TranscriberCapabilities` — `crates/transcription/src/transcriber.rs:23`. Fields: `sample_rate: u32`, `channels: u16`, `supports_initial_prompt: bool`.
|
||||
- `pub struct LocalEngine` — `crates/transcription/src/local_engine.rs:68`.
|
||||
- `pub struct SpeechModelAdapter(pub Box<dyn SpeechModel + Send>)` — `crates/transcription/src/local_engine.rs:26`.
|
||||
- `pub struct TimedTranscript { transcript: Transcript, inference_ms: u64 }` — `crates/transcription/src/local_engine.rs:17`.
|
||||
- `pub fn load_parakeet(model_dir: &Path) -> Result<Box<dyn Transcriber + Send>>` — `crates/transcription/src/local_engine.rs:197`.
|
||||
- `pub fn load_whisper(model_path: &Path) -> Result<Box<dyn Transcriber + Send>>` — `crates/transcription/src/local_engine.rs:208` (gated behind `feature = "whisper"`).
|
||||
- `pub use transcribe_rs::SpeechModel` — re-exported via `lib.rs:18`.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `Transcriber` trait
|
||||
|
||||
`transcriber.rs:35`. `Send` is a supertrait so `Box<dyn Transcriber + Send>` travels through `tokio::task::spawn_blocking` boundaries. Synchronous-only API: async callers wrap a `spawn_blocking`. Inference is `&mut self` so backends with per-call scratch state (whisper-rs `WhisperState`, Parakeet decoder buffers) can mutate without interior-mutability gymnastics.
|
||||
|
||||
`TranscriberCapabilities` carries three fields:
|
||||
|
||||
- `sample_rate: u32` — the rate this backend wants. Live capture's `WavWriter::create` reads this to decide how to format the on-disk file (brief item #19).
|
||||
- `channels: u16` — almost always 1, kept explicit so a stereo-aware backend can declare it.
|
||||
- `supports_initial_prompt: bool` — Whisper says yes, Parakeet says no. Settings UI hides the field accordingly.
|
||||
|
||||
The trait is asserted object-safe by a compile-time witness test (`transcriber.rs:54`).
|
||||
|
||||
### `LocalEngine`
|
||||
|
||||
`local_engine.rs:68`. Holds:
|
||||
|
||||
- `engine: Mutex<Option<Box<dyn Transcriber + Send>>>` — currently-loaded backend or `None`.
|
||||
- `engine_name: EngineName` — display tag (e.g. "whisper", "parakeet"); set at construction.
|
||||
- `loaded_model_id: Mutex<Option<ModelId>>` — what's loaded. Used by the UI and by the model-switch logic.
|
||||
|
||||
API:
|
||||
|
||||
- `new(engine_name)` — create empty.
|
||||
- `load(backend, model_id)` — install a backend; replaces any prior.
|
||||
- `unload()` — drop the backend so its GPU VRAM / mmap'd tensors are freed (sequential-GPU guard, brief item A.1 #28).
|
||||
- `name() -> &EngineName`.
|
||||
- `loaded_model_id() -> Option<ModelId>`.
|
||||
- `is_loaded() -> bool`.
|
||||
- `capabilities() -> Option<TranscriberCapabilities>`.
|
||||
- `transcribe_sync(audio, options) -> Result<TimedTranscript>` — locks the engine mutex, invokes the trait, times it with `Instant::now`, wraps the segments in `Transcript::new(segments, language, duration_secs)`.
|
||||
|
||||
The mutex serialises inference against load / unload. There is no per-call lock contention with the audio thread; all inference is ferried through `concurrency::run_inference` (see [transcription-concurrency.md](transcription-concurrency.md)).
|
||||
|
||||
### `SpeechModelAdapter`
|
||||
|
||||
`local_engine.rs:26`. Adapter from any `transcribe-rs` `SpeechModel` to the `Transcriber` trait. Implements `capabilities()` returning `sample_rate = WHISPER_SAMPLE_RATE`, `channels = 1`, `supports_initial_prompt = false`. `transcribe_sync` constructs a `TranscribeOptions { language, translate: false, leading_silence_ms: None, trailing_silence_ms: None }` and converts the resulting `TranscriptionResult.segments` into `Vec<Segment>`.
|
||||
|
||||
### Loaders
|
||||
|
||||
- `load_whisper` (gated behind `feature = "whisper"`) constructs a `WhisperRsBackend` (see next page).
|
||||
- `load_parakeet` constructs a `ParakeetModel` with `Quantization::Int8`, wraps it in `ParakeetWordGranularity` (so the trait method requests `TimestampGranularity::Word` instead of the per-subword default), then wraps that in `SpeechModelAdapter`.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
audio: AudioSamples (16 kHz mono)
|
||||
options: TranscriptionOptions (language, initial_prompt)
|
||||
└─ run_inference (concurrency.rs)
|
||||
└─ spawn_blocking
|
||||
└─ LocalEngine::transcribe_sync
|
||||
├─ Mutex::lock(engine)
|
||||
├─ backend.transcribe_sync(samples, options)
|
||||
│ └─ WhisperRsBackend or SpeechModelAdapter
|
||||
├─ Instant::now diff → inference_ms
|
||||
└─ Transcript::new(segments, language, duration_secs)
|
||||
→ TimedTranscript
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **`engine_name` is not validated.** `LocalEngine::new(EngineName::new("whisper"))` is opaque to the engine itself. Anything that conditionally branches on engine name (e.g. UI selecting the right model list) must agree on the string value with the loader and Tauri command.
|
||||
- **`Mutex::lock().unwrap_or_else(|e| e.into_inner())` is used everywhere.** A poisoned mutex (panicking call) does not propagate — the engine is read in its post-panic state. Acceptable for a single-process desktop app; document this if anyone moves the type into a daemon.
|
||||
- **`Transcript::new` requires the audio duration.** Computed via `audio.duration_secs()`; assumes `AudioSamples` carries the original sample count and rate (it does). If anyone ever passes a pre-resampled vector that has lost the rate field, the duration will be wrong.
|
||||
- **No streaming surface in this trait.** All inference is one-shot synchronous. Live capture turns this into a streaming experience by chunking the audio (see `RmsVadChunker`) and stitching segments via `LocalAgreement`.
|
||||
- **Parakeet wrapper hardcodes `Word` granularity.** `ParakeetWordGranularity::transcribe_raw` (`local_engine.rs:182`) overrides the default `Token` granularity that produced "T Est Ing , One" output. If a future caller wants character-level timestamps, this is where they'd need a flag.
|
||||
- **Adapter discards `initial_prompt`.** `SpeechModelAdapter::transcribe_sync` does not look at `options.initial_prompt`. Whisper is the only path that pipes it; the capability flag exists so the UI does not pretend otherwise.
|
||||
|
||||
## See also
|
||||
|
||||
- [Transcription whisper](transcription-whisper.md) — the whisper-rs backend.
|
||||
- [Transcription parakeet](transcription-parakeet.md) — the transcribe-rs Parakeet wrapper.
|
||||
- [Transcription concurrency](transcription-concurrency.md) — `run_inference` async wrapper.
|
||||
- [Transcription streaming](transcription-streaming.md) — VAD chunker + LocalAgreement stack that drives live capture.
|
||||
- `magnotia_core::types::{Segment, Transcript, TranscriptionOptions, EngineName, ModelId}` (slice 5).
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
name: Model manager (download, verify, locate)
|
||||
type: architecture-map-page
|
||||
slice: 03-audio-transcription
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# Model manager (download, verify, locate)
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Model manager
|
||||
|
||||
**Plain English summary.** Speech models live on disk under a per-platform path. `model_manager` resolves those paths, checks "is this model fully downloaded", lists what is downloaded, and downloads missing files with HTTP Range resume + per-chunk SHA256 verification. A single in-process `HashSet` reservation prevents concurrent downloads of the same model. After a successful download a `.magnotia-verified` manifest is written so a subsequent process restart can short-circuit re-hashing.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Crate: `magnotia-transcription`
|
||||
- Path: `crates/transcription/src/model_manager.rs`
|
||||
- LOC: 617 (production code + extensive in-tree download server tests)
|
||||
- External deps: `reqwest 0.12` (rustls-tls, stream), `futures-util 0.3` (StreamExt), `sha2 0.10`. Path resolution comes from `magnotia_core::paths::app_paths`. Catalogue from `magnotia_core::model_registry`.
|
||||
- Internal callers (best effort, slice 2 reconciles): the `models` Tauri command consumes all five public functions. The frontend Settings → Models view drives `download` with a progress callback.
|
||||
|
||||
Public surface:
|
||||
|
||||
- `pub fn models_dir() -> PathBuf` — `crates/transcription/src/model_manager.rs:42`. Resolves to `%LOCALAPPDATA%/magnotia/models` on Windows, `~/.magnotia/models` on Unix (delegates to `magnotia_core::paths::app_paths`).
|
||||
- `pub fn model_dir(id: &ModelId) -> PathBuf` — `crates/transcription/src/model_manager.rs:47`.
|
||||
- `pub fn is_downloaded(id: &ModelId) -> bool` — `crates/transcription/src/model_manager.rs:52`.
|
||||
- `pub fn list_downloaded() -> Vec<ModelId>` — `crates/transcription/src/model_manager.rs:63`.
|
||||
- `pub async fn download(id: &ModelId, progress: impl Fn(DownloadProgress) + Send + 'static) -> Result<()>` — `crates/transcription/src/model_manager.rs:78`.
|
||||
|
||||
Private helpers:
|
||||
|
||||
- `DownloadReservation` — RAII guard (`model_manager.rs:12`).
|
||||
- `verified_manifest_path` / `verified_manifest_matches` / `write_verified_manifest` (`model_manager.rs:115`–`:153`).
|
||||
- `sha256_of_file` (`model_manager.rs:157`).
|
||||
- `download_file` (`model_manager.rs:178`).
|
||||
|
||||
## What's in here
|
||||
|
||||
### Path resolution
|
||||
|
||||
`models_dir` and `model_dir` defer entirely to `magnotia_core::paths::app_paths()`. Anything platform-specific lives in slice 5; this crate just consumes the resolution.
|
||||
|
||||
### Download reservation
|
||||
|
||||
`DownloadReservation::acquire(id)` (`model_manager.rs:17`) inserts the model id into a process-wide `HashSet` guarded by a `LazyLock<Mutex<...>>`. Returns `Err(MagnotiaError::DownloadFailed("download already in progress for {id}"))` if the id is already present. Drop removes the entry. This protects against re-entry from the same process; cross-process races are still possible but the atomic `.part → dest` rename closes the most damaging window.
|
||||
|
||||
### `is_downloaded`
|
||||
|
||||
`model_manager.rs:52`. Two-step:
|
||||
|
||||
1. Every file declared in the registry entry must exist at `model_dir(id).join(filename)`.
|
||||
2. The verified-manifest at `model_dir.join(".magnotia-verified")` must record the same `filename\tsha256\tsize` triple per file.
|
||||
|
||||
A missing or stale manifest causes `is_downloaded` to return `false` even if the bytes are correct. The next `download` call will revalidate and rewrite the manifest. A truncated or tampered file fails the existence + size check, so a subsequent `download` redownloads it.
|
||||
|
||||
### `list_downloaded`
|
||||
|
||||
`model_manager.rs:63`. Filters the static `magnotia_core::model_registry::all_models()` catalogue by `is_downloaded`.
|
||||
|
||||
### `download` (the orchestrator)
|
||||
|
||||
`model_manager.rs:78`. For each `ModelFile` in the registry entry:
|
||||
|
||||
1. If `dest` exists, hash it with `sha256_of_file`. Match → skip the download. Mismatch → delete and re-fetch. Hash IO error → propagate as `MagnotiaError::DownloadFailed`.
|
||||
2. Otherwise call `download_file(file, dest, id, &progress)`.
|
||||
|
||||
After all files are present and verified, `write_verified_manifest` writes the `version\t1` header and per-file `filename\tsha256\tsize` lines.
|
||||
|
||||
### `download_file` (single file with resume + hash)
|
||||
|
||||
`model_manager.rs:178`. Implements the Buzz-style resume pattern:
|
||||
|
||||
1. Build a `reqwest::Client` with a 30 s connect timeout.
|
||||
2. If `dest.with_extension("part")` exists, send a `Range: bytes={existing}-` header.
|
||||
3. Inspect the response status:
|
||||
- `206 Partial Content` → server is resuming. Continue appending.
|
||||
- `200 OK` after a Range request → server ignored Range. Discard the stale `.part` and start fresh (regression-tested at `model_manager.rs:494`).
|
||||
- `4xx`/`5xx` (non-resume path) → `MagnotiaError::DownloadFailed("download returned HTTP {status} for {filename}")`. Crucial because `reqwest` does not auto-error on bad status; without this check, a 404 body would be streamed into `.part` and renamed over the destination (regression at `model_manager.rs:556`).
|
||||
- Other status on resume → propagate as unexpected.
|
||||
4. Determine `total_bytes` from `Content-Range` (resume) or `Content-Length` (fresh).
|
||||
5. Hasher state: when resuming, hash the on-disk `.part` first to catch the hash up to where the network stream is about to resume. Otherwise start fresh.
|
||||
6. Stream the body in chunks, writing each to the file, updating the running SHA256, emitting `DownloadProgress` per percent step.
|
||||
7. On stream end, finalise the hash. Mismatch → remove `.part` and `MagnotiaError::DownloadFailed("SHA256 mismatch for {filename}: expected ..., got ...")`.
|
||||
8. `std::fs::rename(.part, dest)` — atomic finalise.
|
||||
|
||||
### `sha256_of_file`
|
||||
|
||||
`model_manager.rs:157`. 8 KiB buffer streaming hash. Used by `download` to validate an existing complete file before trusting it.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
ModelId
|
||||
└─ models_dir / model_dir → on-disk directory
|
||||
└─ is_downloaded:
|
||||
all files exist AND verified-manifest matches
|
||||
→ true / false
|
||||
└─ list_downloaded:
|
||||
filter all_models() by is_downloaded
|
||||
└─ download:
|
||||
acquire reservation
|
||||
for file in entry.files:
|
||||
if dest exists:
|
||||
sha256_of_file → match? skip : delete + re-fetch
|
||||
else:
|
||||
download_file:
|
||||
[.part exists? Range request : full GET]
|
||||
stream body → file + hasher + progress callback
|
||||
match SHA256 → atomic rename
|
||||
else → cleanup, error
|
||||
write_verified_manifest
|
||||
drop reservation
|
||||
```
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **Reservation is per-process.** Two Magnotia instances (dev + release, two cargo runs) racing on the same model dir can collide. The `.part → dest` atomic rename is the only thing standing between them and a torn file.
|
||||
- **`DownloadProgress` is emitted per percent step, not per byte.** A 10 MB file at 4G download speeds emits ~100 events; a 5 GB file emits the same 100 events. UI must not assume tight cadence.
|
||||
- **Manifest schema is `version\t1` + `filename\tsha256\tsize` lines.** Bumping the version is a manual operation; a stale manifest after a registry update will simply trigger a redownload (waste, not corruption).
|
||||
- **Cross-process file locking is not used.** A user opening Settings → Models in two windows of the same desktop session will trip the in-process reservation, but two physical machines syncing the same network home would not be detected.
|
||||
- **Reqwest connect timeout is 30 s, no read timeout.** A stalled mid-stream connection blocks indefinitely. Acceptable for a foreground UI flow today; would need a `tokio::time::timeout` wrap if the download ever became headless.
|
||||
- **`ModelFile::sha256` is `&'static str`.** The `leak` helper in tests reflects that production code reads a static catalogue compiled into the binary. Tampering with the catalogue requires a code change, not a runtime config.
|
||||
- **`reqwest` is configured `default-features = false, features = ["rustls-tls", "stream"]`.** No native-tls fallback. Linux without ca-certificates will fail at connect.
|
||||
- **`models_dir` is the user-data dir, not a cache dir.** A "clear cache" button in the OS would not nuke models. Documented behaviour.
|
||||
|
||||
## See also
|
||||
|
||||
- [Transcription engines overview](transcription-engines-overview.md) — `load_whisper` / `load_parakeet` are called against `model_dir(id)` outputs.
|
||||
- [Tests and fixtures](tests-and-fixtures.md) — in-tree TcpListener-backed servers that exercise the resume + sha-mismatch + 5xx paths.
|
||||
- `magnotia_core::model_registry::{find_model, all_models, ModelEntry, ModelFile}` (slice 5) — the source of URLs and hashes.
|
||||
- `magnotia_core::paths::app_paths` (slice 5) — platform path resolution.
|
||||
- `magnotia_core::types::DownloadProgress` (slice 5) — the progress event shape.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user