The trait's default implementation of transcribe_sync_with_abort fell
through to plain transcribe_sync, silently dropping the abort flag for
any backend that did not explicitly override it. That re-introduced the
5725836 wedge for SpeechModelAdapter (Parakeet today, any future
cloud STT or transformer adapter tomorrow): the live session's
drain_inference timeout would set the flag, the backend would ignore
it, the orphan inference thread would keep the engine Mutex held, and
the next start/stop would deadlock on it.
Removing the default impl makes the method required, so any backend
that does not implement it fails to compile. Compile-time enforcement
of cancellation completeness.
SpeechModelAdapter now implements the method explicitly with a
pre-decode short-circuit: if the abort flag is already set before we
dispatch into transcribe-rs (which owns the Parakeet decoder behind an
opaque call that has no cancellation hook), return an error
immediately. The in-flight decode itself is still uncancellable, but
that is documented with a SAFETY comment and bounded by the live
session's drain timeout dropping the receiver — the orphan exits the
instant it tries to send.
Regression tests:
- transcriber::tests::transcribe_sync_with_abort_is_required_and_flag_is_observed —
fake backend records the abort flag at dispatch time; proves the
trait method is required (file would not compile without it) and the
flag is actually piped through.
- local_engine::tests::speech_model_adapter_short_circuits_when_abort_set_pre_dispatch —
SpeechModelAdapter must NOT call the underlying decoder when the
abort flag was set before dispatch.
- local_engine::tests::speech_model_adapter_dispatches_decoder_when_abort_clear —
companion: clear flag must still dispatch (we did not kill
transcription entirely).
cargo test -p lumotia-transcription --lib: 64 passed, 0 failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
6.0 KiB
Rust
154 lines
6.0 KiB
Rust
//! Engine-abstraction trait for speech-to-text backends.
|
|
//!
|
|
//! Replaces the previous `SpeechBackend` enum so new backends
|
|
//! (Moonshine, whisper-rs forks, cloud ASR shims, Windows non-AVX2
|
|
//! fallbacks) can drop in without adding a match arm in `LocalEngine`.
|
|
//!
|
|
//! Concrete implementers today: `SpeechModelAdapter` (wraps any
|
|
//! `transcribe-rs` model, currently used for Parakeet) and — behind the
|
|
//! `whisper` feature — `WhisperRsBackend` (direct whisper-rs, the only
|
|
//! path that pipes `initial_prompt`).
|
|
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::Arc;
|
|
|
|
use lumotia_core::error::Result;
|
|
use lumotia_core::types::{Segment, TranscriptionOptions};
|
|
|
|
/// Static capabilities a `Transcriber` advertises to callers.
|
|
///
|
|
/// `sample_rate` is load-bearing for the progressive WAV writer (#19)
|
|
/// which writes live capture samples to disk at the transcriber's
|
|
/// native rate. `supports_initial_prompt` lets the Settings surface
|
|
/// hide the initial-prompt field for backends that ignore it (Parakeet
|
|
/// today).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct TranscriberCapabilities {
|
|
pub sample_rate: u32,
|
|
pub channels: u16,
|
|
pub supports_initial_prompt: bool,
|
|
}
|
|
|
|
/// Unified interface for speech-to-text backends.
|
|
///
|
|
/// `Send` is a supertrait so `Box<dyn Transcriber + Send>` travels
|
|
/// across `spawn_blocking` boundaries without a per-site bound. All
|
|
/// inference is synchronous — async callers wrap a `tokio::spawn_blocking`
|
|
/// around `transcribe_sync`.
|
|
pub trait Transcriber: Send {
|
|
fn capabilities(&self) -> TranscriberCapabilities;
|
|
|
|
/// Synchronously transcribe 16 kHz mono f32 PCM (or whatever the
|
|
/// backend's `capabilities().sample_rate` declares). `&mut self` so
|
|
/// backends that keep per-call scratch state (whisper-rs's
|
|
/// `WhisperState`, Parakeet's decoder buffers) can mutate them
|
|
/// without interior-mutability gymnastics.
|
|
fn transcribe_sync(
|
|
&mut self,
|
|
samples: &[f32],
|
|
options: &TranscriptionOptions,
|
|
) -> Result<Vec<Segment>>;
|
|
|
|
/// Variant of `transcribe_sync` that accepts an external abort flag.
|
|
///
|
|
/// REQUIRED so every backend opts in to a cancellation story at
|
|
/// compile time. Without this requirement a default impl that
|
|
/// simply forwarded to `transcribe_sync` would silently re-introduce
|
|
/// the wedge for any non-whisper backend: the live session's
|
|
/// `drain_inference` timeout would set the flag, but the backend
|
|
/// would ignore it, the orphan inference thread would keep holding
|
|
/// the engine `Mutex`, and the next start/stop would deadlock on it.
|
|
///
|
|
/// Implementer guidance:
|
|
/// * Backends that can react to mid-inference cancellation
|
|
/// (whisper-rs via `set_abort_callback_safe`) wire the flag into
|
|
/// their decoder's abort hook so the wedged inference can be
|
|
/// unstuck by the live session's drain timeout.
|
|
/// * Backends that genuinely cannot honour the flag mid-call
|
|
/// (synchronous external API calls, transcribe-rs adapters that
|
|
/// own opaque decoder state) MUST still implement this method —
|
|
/// even if the implementation is to check the flag at the safest
|
|
/// available boundary and otherwise dispatch to `transcribe_sync`.
|
|
/// Document the uncancellability with a `// SAFETY:` comment so a
|
|
/// future audit can find it.
|
|
fn transcribe_sync_with_abort(
|
|
&mut self,
|
|
samples: &[f32],
|
|
options: &TranscriptionOptions,
|
|
abort_flag: Arc<AtomicBool>,
|
|
) -> Result<Vec<Segment>>;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
#[test]
|
|
fn transcriber_trait_is_object_safe() {
|
|
// Compile-time witness: if the trait stops being object-safe
|
|
// (e.g. someone adds a generic method or a Self-returning
|
|
// method) this declaration fails to build. No runtime work.
|
|
let _: Option<Box<dyn Transcriber + Send>> = None;
|
|
}
|
|
|
|
/// Fake backend that records whether the abort flag was observed
|
|
/// at dispatch time. Proves the compile-time-required
|
|
/// `transcribe_sync_with_abort` actually receives the flag instead
|
|
/// of silently falling back to a default impl that drops it on the
|
|
/// floor (the Lifecycle-2 wedge).
|
|
struct FlagSnoopingBackend {
|
|
observed_abort: bool,
|
|
}
|
|
|
|
impl Transcriber for FlagSnoopingBackend {
|
|
fn capabilities(&self) -> TranscriberCapabilities {
|
|
TranscriberCapabilities {
|
|
sample_rate: 16_000,
|
|
channels: 1,
|
|
supports_initial_prompt: false,
|
|
}
|
|
}
|
|
|
|
fn transcribe_sync(
|
|
&mut self,
|
|
_samples: &[f32],
|
|
_options: &TranscriptionOptions,
|
|
) -> Result<Vec<Segment>> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
fn transcribe_sync_with_abort(
|
|
&mut self,
|
|
_samples: &[f32],
|
|
_options: &TranscriptionOptions,
|
|
abort_flag: Arc<AtomicBool>,
|
|
) -> Result<Vec<Segment>> {
|
|
self.observed_abort = abort_flag.load(Ordering::Relaxed);
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn transcribe_sync_with_abort_is_required_and_flag_is_observed() {
|
|
// Lifecycle-2 regression: removing the trait's default impl
|
|
// forces every backend to receive the abort flag at the call
|
|
// site. Without an explicit method on this fake backend the
|
|
// file wouldn't compile; with it, asserting the snoop is the
|
|
// runtime witness that the flag is actually piped through.
|
|
let mut backend = FlagSnoopingBackend {
|
|
observed_abort: false,
|
|
};
|
|
let flag = Arc::new(AtomicBool::new(true));
|
|
let options = TranscriptionOptions::default();
|
|
let segs = backend
|
|
.transcribe_sync_with_abort(&[], &options, flag.clone())
|
|
.expect("fake backend never errors");
|
|
assert!(segs.is_empty());
|
|
assert!(
|
|
backend.observed_abort,
|
|
"trait must hand the abort flag to the backend, not drop it via a default impl"
|
|
);
|
|
}
|
|
}
|