//! `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 //! Lumotia'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 lumotia_cloud_providers::{ CostClass, EngineProfile, ProviderCapabilities, ProviderId, ProviderKind, ProviderTranscript, TranscriptionProvider, }; use lumotia_core::error::{Error, Result}; use lumotia_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(lumotia_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(Error::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| Error::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(|| Error::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 { 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 lumotia_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 lumotia", 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 lumotia"); 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("Lumotia".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; } }