feat(transcription): Phase A — engine protocol + registry + orchestrator (clean-room)

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<dyn TranscriptionProvider> 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<LocalEngine>, 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 07:49:24 +01:00
parent d21109ec27
commit 6bc8acccce
9 changed files with 710 additions and 2 deletions

View File

@@ -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,
};

View 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()));
}
}