New crates/transcription/src/transcriber.rs defines a Transcriber trait (Send supertrait for spawn_blocking travel) with TranscriberCapabilities (sample_rate, channels, supports_initial_prompt). TranscriberCapabilities.sample_rate is load-bearing for the upcoming progressive WAV writer (#19). Concrete impls: - SpeechModelAdapter wraps Box<dyn transcribe_rs::SpeechModel + Send> for Parakeet (and any future transcribe-rs-backed engine). - WhisperRsBackend moves its transcribe_sync body into the impl, widening the signature from &self to &mut self so per-call WhisperState can be created cleanly through the trait object. LocalEngine now holds Box<dyn Transcriber + Send>; dispatch in transcribe_sync collapses from a match to a direct call. Adds LocalEngine::capabilities() for the WAV-writer. Cargo feature flag "whisper" (default on) makes whisper-rs, num_cpus, and the whole whisper_rs_backend module optional. cargo check --no-default-features -p kon-transcription now builds without pulling whisper-rs-sys — the escape hatch brief item #6 / #13 called for on Windows / non-AVX2 / cloud-only builds. load_whisper is cfg-gated behind the same feature. src-tauri/src/commands/models.rs load_model_from_disk returns Box<dyn Transcriber + Send> instead of SpeechBackend; caller chain (ensure_model_loaded, prewarm_default_model) is unchanged. transcriber_trait_is_object_safe test lands alongside the trait as a compile-time witness against future Self-returning / generic-method additions.
226 lines
7.7 KiB
Rust
226 lines
7.7 KiB
Rust
use std::path::Path;
|
|
use std::sync::Mutex;
|
|
use std::time::Instant;
|
|
|
|
use transcribe_rs::{SpeechModel, TranscribeOptions, TranscriptionResult};
|
|
|
|
use kon_core::error::{KonError, Result};
|
|
use kon_core::types::{
|
|
AudioSamples, EngineName, ModelId, Segment, Transcript, TranscriptionOptions,
|
|
};
|
|
|
|
use crate::transcriber::{Transcriber, TranscriberCapabilities};
|
|
#[cfg(feature = "whisper")]
|
|
use crate::whisper_rs_backend::WhisperRsBackend;
|
|
|
|
/// Result of a timed transcription: transcript + inference duration.
|
|
pub struct TimedTranscript {
|
|
pub transcript: Transcript,
|
|
pub inference_ms: u64,
|
|
}
|
|
|
|
/// Adapts any `transcribe-rs` `SpeechModel` into the `Transcriber`
|
|
/// trait. Today this is only used for Parakeet (ONNX), but the adapter
|
|
/// is the path any future transcribe-rs-backed engine plugs through —
|
|
/// Moonshine, fine-tuned Parakeet variants, etc.
|
|
pub struct SpeechModelAdapter(pub Box<dyn SpeechModel + Send>);
|
|
|
|
impl Transcriber for SpeechModelAdapter {
|
|
fn capabilities(&self) -> TranscriberCapabilities {
|
|
TranscriberCapabilities {
|
|
sample_rate: kon_core::constants::WHISPER_SAMPLE_RATE,
|
|
channels: 1,
|
|
supports_initial_prompt: false,
|
|
}
|
|
}
|
|
|
|
fn transcribe_sync(
|
|
&mut self,
|
|
samples: &[f32],
|
|
options: &TranscriptionOptions,
|
|
) -> Result<Vec<Segment>> {
|
|
let opts = TranscribeOptions {
|
|
language: options.language.clone(),
|
|
translate: false,
|
|
leading_silence_ms: None,
|
|
trailing_silence_ms: None,
|
|
};
|
|
let result: TranscriptionResult = self
|
|
.0
|
|
.transcribe(samples, &opts)
|
|
.map_err(|e| KonError::TranscriptionFailed(e.to_string()))?;
|
|
Ok(result
|
|
.segments
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|s| Segment {
|
|
start: s.start as f64,
|
|
end: s.end as f64,
|
|
text: s.text,
|
|
})
|
|
.collect())
|
|
}
|
|
}
|
|
|
|
/// Owns the currently-loaded speech backend and serialises inference
|
|
/// against model-swap operations via a `Mutex`. All transcription goes
|
|
/// through this struct; no caller ever holds a raw `Box<dyn Transcriber>`.
|
|
pub struct LocalEngine {
|
|
engine: Mutex<Option<Box<dyn Transcriber + Send>>>,
|
|
engine_name: EngineName,
|
|
loaded_model_id: Mutex<Option<ModelId>>,
|
|
}
|
|
|
|
impl LocalEngine {
|
|
pub fn new(engine_name: EngineName) -> Self {
|
|
Self {
|
|
engine: Mutex::new(None),
|
|
engine_name,
|
|
loaded_model_id: Mutex::new(None),
|
|
}
|
|
}
|
|
|
|
pub fn load(&self, backend: Box<dyn Transcriber + Send>, model_id: ModelId) {
|
|
let mut guard = self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
|
*guard = Some(backend);
|
|
let mut id_guard = self
|
|
.loaded_model_id
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
*id_guard = Some(model_id);
|
|
}
|
|
|
|
/// Drop the loaded model and free its backing resources (GPU VRAM,
|
|
/// CPU memory, mmap'd GGML tensors). Used by the sequential-GPU
|
|
/// guard (brief item A.1 #28) so loading the LLM on a tight-VRAM
|
|
/// system first frees the transcription engine, and vice versa.
|
|
///
|
|
/// No-op when nothing is loaded. Thread-safe — the internal Mutex
|
|
/// serialises against concurrent transcribe_sync calls.
|
|
pub fn unload(&self) {
|
|
let mut guard = self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
|
*guard = None;
|
|
let mut id_guard = self
|
|
.loaded_model_id
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
*id_guard = None;
|
|
}
|
|
|
|
pub fn name(&self) -> &EngineName {
|
|
&self.engine_name
|
|
}
|
|
|
|
pub fn loaded_model_id(&self) -> Option<ModelId> {
|
|
let guard = self
|
|
.loaded_model_id
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
guard.clone()
|
|
}
|
|
|
|
pub fn is_loaded(&self) -> bool {
|
|
let guard = self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
|
guard.is_some()
|
|
}
|
|
|
|
/// Capabilities of the currently-loaded backend. Returns `None`
|
|
/// when nothing is loaded. Callers (live capture WAV writer, #19)
|
|
/// read sample_rate from here.
|
|
pub fn capabilities(&self) -> Option<TranscriberCapabilities> {
|
|
let guard = self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
|
guard.as_ref().map(|b| b.capabilities())
|
|
}
|
|
|
|
/// Run transcription synchronously with timing.
|
|
/// Called from within spawn_blocking.
|
|
pub fn transcribe_sync(
|
|
&self,
|
|
audio: &AudioSamples,
|
|
options: &TranscriptionOptions,
|
|
) -> Result<TimedTranscript> {
|
|
let mut guard = self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
|
let backend = guard.as_mut().ok_or(KonError::EngineNotLoaded)?;
|
|
|
|
let start = Instant::now();
|
|
let segments = backend.transcribe_sync(audio.samples(), options)?;
|
|
let inference_ms = start.elapsed().as_millis() as u64;
|
|
|
|
Ok(TimedTranscript {
|
|
transcript: Transcript::new(
|
|
segments,
|
|
options.language.clone().unwrap_or_else(|| "en".to_string()),
|
|
audio.duration_secs(),
|
|
),
|
|
inference_ms,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Thin wrapper over `ParakeetModel` that overrides `transcribe_raw` to
|
|
/// request word-granularity segments. `transcribe-rs` 0.3's trait impl for
|
|
/// `ParakeetModel::transcribe_raw` ignores `TranscribeOptions` and uses
|
|
/// `TimestampGranularity::Token` (per-subword) — which surfaces in Kon as
|
|
/// "T Est Ing . One , Two , Three" output. The concrete-type method
|
|
/// `ParakeetModel::transcribe_with` accepts `ParakeetParams` with an
|
|
/// explicit granularity; this wrapper exposes that to the trait object.
|
|
struct ParakeetWordGranularity(transcribe_rs::onnx::parakeet::ParakeetModel);
|
|
|
|
impl transcribe_rs::SpeechModel for ParakeetWordGranularity {
|
|
fn capabilities(&self) -> transcribe_rs::ModelCapabilities {
|
|
self.0.capabilities()
|
|
}
|
|
|
|
fn default_leading_silence_ms(&self) -> u32 {
|
|
self.0.default_leading_silence_ms()
|
|
}
|
|
|
|
fn default_trailing_silence_ms(&self) -> u32 {
|
|
self.0.default_trailing_silence_ms()
|
|
}
|
|
|
|
fn transcribe_raw(
|
|
&mut self,
|
|
samples: &[f32],
|
|
options: &TranscribeOptions,
|
|
) -> std::result::Result<TranscriptionResult, transcribe_rs::TranscribeError> {
|
|
use transcribe_rs::onnx::parakeet::{ParakeetParams, TimestampGranularity};
|
|
let params = ParakeetParams {
|
|
language: options.language.clone(),
|
|
timestamp_granularity: Some(TimestampGranularity::Word),
|
|
};
|
|
self.0.transcribe_with(samples, ¶ms)
|
|
}
|
|
}
|
|
|
|
/// Load a Parakeet model from a directory path.
|
|
pub fn load_parakeet(model_dir: &Path) -> Result<Box<dyn Transcriber + Send>> {
|
|
use transcribe_rs::onnx::Quantization;
|
|
let model = transcribe_rs::onnx::parakeet::ParakeetModel::load(model_dir, &Quantization::Int8)
|
|
.map_err(|e| KonError::TranscriptionFailed(format!("Failed to load Parakeet: {e}")))?;
|
|
Ok(Box::new(SpeechModelAdapter(Box::new(
|
|
ParakeetWordGranularity(model),
|
|
))))
|
|
}
|
|
|
|
/// Load a Whisper model from a GGML file path via whisper-rs.
|
|
#[cfg(feature = "whisper")]
|
|
pub fn load_whisper(model_path: &Path) -> Result<Box<dyn Transcriber + Send>> {
|
|
let backend = WhisperRsBackend::load(model_path)
|
|
.map_err(|e| KonError::TranscriptionFailed(format!("Failed to load Whisper: {e}")))?;
|
|
Ok(Box::new(backend))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn engine_reports_not_available_before_loading() {
|
|
let engine = LocalEngine::new(EngineName::new("test"));
|
|
assert!(!engine.is_loaded());
|
|
assert!(engine.loaded_model_id().is_none());
|
|
assert!(engine.capabilities().is_none());
|
|
}
|
|
}
|