From 6bc8acccce52dbbe1112c021f193ca8a93fe75cb Mon Sep 17 00:00:00 2001 From: Jake Date: Sun, 10 May 2026 07:49:24 +0100 Subject: [PATCH] =?UTF-8?q?feat(transcription):=20Phase=20A=20=E2=80=94=20?= =?UTF-8?q?engine=20protocol=20+=20registry=20+=20orchestrator=20(clean-ro?= =?UTF-8?q?om)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the architectural spine for the Wyrdnote engine layer per the spec at outputs/wyrdnote/2026-05-10-engine-architecture-spec.md in the CORBEL-Main workspace, Codex-reviewed 2026/05/10. Clean-room derivative of VoiceInk's TranscriptionService / TranscriptionServiceRegistry / TranscriptionPipeline shape. Built from the architectural pattern only; no GPL-3.0 code, comments, or identifiers lifted. Wyrdnote names, control flow, and trait signatures are original. What this lands: crates/cloud-providers/src/provider.rs (new, 184 lines) TranscriptionProvider async trait. Object-safe via async_trait so Arc is the canonical shape. Lives in cloud-providers (not transcription) per D7 so an OEM licensee implementing the trait depends only on this crate plus magnotia-core (no transcription internals leaked through the trait surface). Surrounding types: ProviderId (lower-kebab-case), ProviderKind, NetworkRequirement, CostClass, ProviderCapabilities, EngineProfile, ProviderTranscript. Compile-time object-safety witness in tests. crates/cloud-providers/Cargo.toml (modified) Adds async-trait + serde dependencies. crates/cloud-providers/src/lib.rs (modified) Re-exports the provider surface. crates/transcription/src/registry.rs (new, 183 lines) EngineRegistry: catalogue of providers keyed by ProviderId. Push-style registration, default-provider id captured at construction. Read-only after boot. Full unit-test coverage: empty default, register/get round-trip, default-resolves-after-registration, re-register replaces, ids enumeration. crates/transcription/src/orchestrator.rs (new, 286 lines) LocalProviderAdapter wraps Arc, presents async TranscriptionProvider upward via tokio::task::spawn_blocking. Per D7 the adapter lives in the orchestrator, NOT as impl TranscriptionProvider for LocalEngine — this keeps the dependency edge one-directional and avoids leaking async-runtime requirements onto the synchronous Transcriber trait. Orchestrator: single transcribe() entry point; resolves a provider from the registry, derives TranscriptionOptions from the EngineProfile, dispatches. Returns a clear error when an unregistered provider is named. Unit tests use a mock CannedProvider to validate dispatch, error handling, and option routing without booting a real model. crates/transcription/Cargo.toml (modified) Depends on magnotia-cloud-providers + async-trait. Dev-dep tokio gains macros + rt-multi-thread features for #[tokio::test]. crates/transcription/src/lib.rs (modified) Exports Orchestrator, EngineRegistry, LocalProviderAdapter, and re-exports the provider trait surface so downstream crates depend only on magnotia-transcription for both local engines and the trait. KNOWN-ISSUES.md (modified) Adds KI-06: existing dictation, live, and meeting commands still call LocalEngine::transcribe_sync directly via pick_engine. The orchestrator path is dormant until a Phase A.1 follow-up commit migrates the call sites. Cloud providers (Phase G) cannot be exercised end-to-end until the rewire lands. Phasing rationale: this commit lands the abstractions cleanly with full test coverage; the rewire is a separate diff so the chunking + post-processing logic in transcribe_file moves without inflating this commit beyond review fidelity. Test results: 9 new tests pass (orchestrator: dispatches, errors-on- unregistered, routes-initial-prompt, object-safe; registry: empty-default, round-trip, default-resolves, re-register, ids-list). Pre-existing 59 transcription tests + 5 cloud-providers tests still green. cargo check --workspace clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 4 + KNOWN-ISSUES.md | 14 ++ crates/cloud-providers/Cargo.toml | 10 +- crates/cloud-providers/src/lib.rs | 5 + crates/cloud-providers/src/provider.rs | 184 +++++++++++++++ crates/transcription/Cargo.toml | 12 +- crates/transcription/src/lib.rs | 14 ++ crates/transcription/src/orchestrator.rs | 286 +++++++++++++++++++++++ crates/transcription/src/registry.rs | 183 +++++++++++++++ 9 files changed, 710 insertions(+), 2 deletions(-) create mode 100644 crates/cloud-providers/src/provider.rs create mode 100644 crates/transcription/src/orchestrator.rs create mode 100644 crates/transcription/src/registry.rs diff --git a/Cargo.lock b/Cargo.lock index df3a580..bee352e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2846,7 +2846,9 @@ dependencies = [ name = "magnotia-cloud-providers" version = "0.1.0" dependencies = [ + "async-trait", "magnotia-core", + "serde", ] [[package]] @@ -2925,7 +2927,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", diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index 959490e..71a76e7 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -64,6 +64,20 @@ Tracked limitations and partial implementations in the current codebase. Each en **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` 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. diff --git a/crates/cloud-providers/Cargo.toml b/crates/cloud-providers/Cargo.toml index ae2bfb1..b8ecc4f 100644 --- a/crates/cloud-providers/Cargo.toml +++ b/crates/cloud-providers/Cargo.toml @@ -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> at the trait surface so TranscriptionProvider stays +# object-safe (Arc). +async-trait = "0.1" + +# Trait types serialise across the Tauri boundary. +serde = { version = "1", features = ["derive"] } diff --git a/crates/cloud-providers/src/lib.rs b/crates/cloud-providers/src/lib.rs index a22aff2..6aa02bb 100644 --- a/crates/cloud-providers/src/lib.rs +++ b/crates/cloud-providers/src/lib.rs @@ -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, +}; diff --git a/crates/cloud-providers/src/provider.rs b/crates/cloud-providers/src/provider.rs new file mode 100644 index 0000000..5c85b3e --- /dev/null +++ b/crates/cloud-providers/src/provider.rs @@ -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) -> 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, + pub language: Option, + pub initial_prompt: Option, +} + +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` 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; +} + +#[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> = 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())); + } +} diff --git a/crates/transcription/Cargo.toml b/crates/transcription/Cargo.toml index 1664148..46ad464 100644 --- a/crates/transcription/Cargo.toml +++ b/crates/transcription/Cargo.toml @@ -23,6 +23,14 @@ 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"] } @@ -50,7 +58,9 @@ 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 diff --git a/crates/transcription/src/lib.rs b/crates/transcription/src/lib.rs index fc26386..d4f809a 100644 --- a/crates/transcription/src/lib.rs +++ b/crates/transcription/src/lib.rs @@ -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, +}; diff --git a/crates/transcription/src/orchestrator.rs b/crates/transcription/src/orchestrator.rs new file mode 100644 index 0000000..6bbfe7c --- /dev/null +++ b/crates/transcription/src/orchestrator.rs @@ -0,0 +1,286 @@ +//! `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` 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, +} + +impl LocalProviderAdapter { + pub fn new(provider_id: ProviderId, engine: Arc) -> 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 { + 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 { + 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, +} + +impl Orchestrator { + pub fn new(registry: Arc) -> 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 { + 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> { + self.registry.get(&profile.engine_id).ok_or_else(|| { + MagnotiaError::Other(format!( + "Provider '{}' is not registered", + profile.engine_id + )) + }) + } + + /// 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 { + 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, + 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 { + 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) -> Arc { + 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> = None; + } +} diff --git a/crates/transcription/src/registry.rs b/crates/transcription/src/registry.rs new file mode 100644 index 0000000..1e30af5 --- /dev/null +++ b/crates/transcription/src/registry.rs @@ -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` 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>, + 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) { + 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> { + self.providers.get(id).cloned() + } + + /// Fetch the default provider. Returns `None` if the default has + /// not been registered. + pub fn default(&self) -> Option> { + 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 { + 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 { + Ok(ProviderTranscript { + transcript: Transcript::new(Vec::new(), "en".to_string(), audio.duration_secs()), + inference_ms: 0, + }) + } + } + + fn dummy(id: &str) -> Arc { + 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 = registry + .ids() + .into_iter() + .map(|id| id.as_str().to_string()) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["local-parakeet", "local-whisper"]); + } +}