From 6588130e36035cf01a4b57ce4bfbf500bd01335e Mon Sep 17 00:00:00 2001 From: jake Date: Mon, 16 Mar 2026 20:27:27 +0000 Subject: [PATCH] =?UTF-8?q?feat(kon):=20add=20core=20crate=20=E2=80=94=20t?= =?UTF-8?q?ypes,=20traits,=20hardware,=20model=20registry,=20recommendatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Value objects: ModelId, EngineName, Megabytes, AudioSamples, Segment, Transcript - KonError enum with thiserror - Constants centralised: audio pipeline, VAD, RAM thresholds, inference threading - SpeechToText and TextProcessor provider traits with ProviderRegistry - Unified model registry (Whisper tiny/base/small/medium + Parakeet CTC int8) - Hardware detection: probe_ram, probe_cpu, probe_gpu (stub), probe_os - Recommendation engine: score_model (pure function), rank_recommendations (sorted) - 5 tests passing, clippy clean Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/Cargo.toml | 5 + crates/core/src/constants.rs | 49 ++++++++ crates/core/src/error.rs | 41 +++++++ crates/core/src/hardware.rs | 107 +++++++++++++++++ crates/core/src/lib.rs | 15 ++- crates/core/src/model_registry.rs | 166 ++++++++++++++++++++++++++ crates/core/src/providers.rs | 38 ++++++ crates/core/src/recommendation.rs | 190 ++++++++++++++++++++++++++++++ crates/core/src/types.rs | 177 ++++++++++++++++++++++++++++ 9 files changed, 786 insertions(+), 2 deletions(-) create mode 100644 crates/core/src/constants.rs create mode 100644 crates/core/src/error.rs create mode 100644 crates/core/src/hardware.rs create mode 100644 crates/core/src/model_registry.rs create mode 100644 crates/core/src/providers.rs create mode 100644 crates/core/src/recommendation.rs create mode 100644 crates/core/src/types.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 18a3564..b1d3d51 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -5,3 +5,8 @@ edition = "2021" description = "Core types, constants, traits, hardware detection, and model registry for Kon" [dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +sysinfo = "0.35" +async-trait = "0.1" diff --git a/crates/core/src/constants.rs b/crates/core/src/constants.rs new file mode 100644 index 0000000..ada8903 --- /dev/null +++ b/crates/core/src/constants.rs @@ -0,0 +1,49 @@ +/// Audio pipeline constants. +pub const WHISPER_SAMPLE_RATE: u32 = 16_000; +pub const WHISPER_CHANNELS: u16 = 1; + +/// Parakeet mel spectrogram constants. +pub const PARAKEET_N_FFT: usize = 512; +pub const PARAKEET_HOP_LENGTH: usize = 160; +pub const PARAKEET_WIN_LENGTH: usize = 400; +pub const PARAKEET_N_MELS: usize = 80; +pub const PARAKEET_PRE_EMPHASIS: f32 = 0.97; +pub const PARAKEET_BLANK_TOKEN: usize = 1024; +pub const PARAKEET_LOG_GUARD: f32 = 5.960_464_5e-8; // 2^-24 + +/// Chunk timing for live transcription. +pub const CHUNK_INTERVAL_MS: u64 = 3000; +pub const MIN_CHUNK_SAMPLES: usize = 8000; + +/// Post-processing thresholds. +pub const SMART_PARAGRAPH_GAP_SECS: f64 = 2.0; + +/// Thread count for inference. Leaves headroom for the UI thread. +pub const MIN_INFERENCE_THREADS: usize = 4; + +/// History limits. +pub const HISTORY_MAX_ENTRIES: usize = 100; + +/// RAM thresholds for model recommendations (in GB). +pub const RAM_MINIMUM_FOR_LOCAL_STT: f64 = 2.0; +pub const RAM_THRESHOLD_LIGHTWEIGHT: f64 = 4.0; +pub const RAM_THRESHOLD_STANDARD: f64 = 8.0; +pub const RAM_THRESHOLD_COMFORTABLE: f64 = 16.0; + +/// VAD configuration defaults. +pub const VAD_SPEECH_THRESHOLD: f64 = 0.5; +pub const VAD_MIN_SPEECH_DURATION_MS: u32 = 250; +pub const VAD_MAX_SPEECH_DURATION_S: u32 = 30; +pub const VAD_MIN_SILENCE_DURATION_MS: u32 = 300; +pub const VAD_SPEECH_PAD_MS: u32 = 100; + +/// Model download chunk size for progress reporting. +pub const DOWNLOAD_CHUNK_BYTES: usize = 65_536; + +/// Inference thread count based on available parallelism. +pub fn inference_thread_count() -> usize { + std::thread::available_parallelism() + .map(|p| p.get().saturating_sub(1)) + .unwrap_or(MIN_INFERENCE_THREADS) + .max(MIN_INFERENCE_THREADS) +} diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs new file mode 100644 index 0000000..d88ca08 --- /dev/null +++ b/crates/core/src/error.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; + +use crate::types::ModelId; + +#[derive(Debug, thiserror::Error)] +pub enum KonError { + #[error("model not found: {0}")] + ModelNotFound(ModelId), + + #[error("model not downloaded: {0}")] + ModelNotDownloaded(ModelId), + + #[error("engine not loaded: call load_model first")] + EngineNotLoaded, + + #[error("transcription failed: {0}")] + TranscriptionFailed(String), + + #[error("audio decode failed: {0}")] + AudioDecodeFailed(String), + + #[error("audio capture failed: {0}")] + AudioCaptureFailed(String), + + #[error("model download failed: {0}")] + DownloadFailed(String), + + #[error("file not found: {}", .0.display())] + FileNotFound(PathBuf), + + #[error("storage error: {0}")] + StorageError(String), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; diff --git a/crates/core/src/hardware.rs b/crates/core/src/hardware.rs new file mode 100644 index 0000000..c7bb866 --- /dev/null +++ b/crates/core/src/hardware.rs @@ -0,0 +1,107 @@ +use sysinfo::System; + +use crate::types::Megabytes; + +/// Detected system capabilities. +#[derive(Debug, Clone)] +pub struct SystemProfile { + pub ram: Megabytes, + pub cpu: CpuInfo, + pub gpu: Option, + pub os: Os, +} + +#[derive(Debug, Clone)] +pub struct CpuInfo { + pub core_count: usize, + pub brand: String, +} + +#[derive(Debug, Clone)] +pub struct GpuInfo { + pub vendor: GpuVendor, + pub vram: Megabytes, + pub acceleration: GpuAcceleration, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GpuVendor { + Nvidia, + Amd, + Intel, + Apple, + Unknown, +} + +#[derive(Debug, Clone)] +pub struct GpuAcceleration { + pub cuda: bool, + pub metal: bool, + pub vulkan: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Os { + Windows, + Linux, + MacOs, + Ios, + Android, +} + +pub fn probe_ram() -> Megabytes { + let sys = System::new_all(); + let total_bytes = sys.total_memory(); + Megabytes(total_bytes / (1024 * 1024)) +} + +pub fn probe_cpu() -> CpuInfo { + let sys = System::new_all(); + CpuInfo { + core_count: sys.cpus().len(), + brand: sys + .cpus() + .first() + .map(|c| c.brand().to_string()) + .unwrap_or_default(), + } +} + +pub fn probe_gpu() -> Option { + // GPU detection via wgpu or platform-specific APIs. + // Placeholder: returns None until wgpu or nvml integration is added. + None +} + +pub fn probe_os() -> Os { + #[cfg(target_os = "windows")] + { + Os::Windows + } + #[cfg(target_os = "linux")] + { + Os::Linux + } + #[cfg(target_os = "macos")] + { + Os::MacOs + } + #[cfg(target_os = "ios")] + { + Os::Ios + } + #[cfg(target_os = "android")] + { + Os::Android + } +} + +/// Composes the individual probes. No logic here — just assembly. +pub fn probe_system() -> SystemProfile { + SystemProfile { + ram: probe_ram(), + cpu: probe_cpu(), + gpu: probe_gpu(), + os: probe_os(), + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3395e71..ebfccdf 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,2 +1,13 @@ -// kon-core: Foundation types, constants, provider traits, hardware detection, -// model registry, and recommendation engine. +pub mod constants; +pub mod error; +pub mod hardware; +pub mod model_registry; +pub mod providers; +pub mod recommendation; +pub mod types; + +pub use error::{KonError, Result}; +pub use types::{ + AudioSamples, DownloadProgress, EngineName, Megabytes, ModelId, Segment, + Transcript, TranscriptionOptions, +}; diff --git a/crates/core/src/model_registry.rs b/crates/core/src/model_registry.rs new file mode 100644 index 0000000..d8bae40 --- /dev/null +++ b/crates/core/src/model_registry.rs @@ -0,0 +1,166 @@ +use std::sync::LazyLock; + +use crate::types::{Megabytes, ModelId}; + +/// Which inference backend a model uses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Engine { + Whisper, + Parakeet, + Moonshine, +} + +/// Qualitative speed classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpeedTier { + Instant, + Fast, + Moderate, + Slow, +} + +/// Qualitative accuracy classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccuracyTier { + Excellent, + Great, + Good, +} + +/// Language support scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LanguageSupport { + EnglishOnly, + Multilingual(u16), +} + +/// File required for a model download. +#[derive(Debug, Clone)] +pub struct ModelFile { + pub filename: &'static str, + pub url: &'static str, + pub size: Megabytes, +} + +/// All metadata for a single downloadable model. +/// This is pure data — no scoring logic lives here. +#[derive(Debug, Clone)] +pub struct ModelEntry { + pub id: ModelId, + pub engine: Engine, + pub display_name: &'static str, + pub disk_size: Megabytes, + pub ram_required: Megabytes, + pub speed_tier: SpeedTier, + pub accuracy_tier: AccuracyTier, + pub languages: LanguageSupport, + pub files: Vec, + pub description: &'static str, +} + +static ALL_MODELS: LazyLock> = LazyLock::new(|| { + vec![ + ModelEntry { + id: ModelId::new("parakeet-ctc-0.6b-int8"), + engine: Engine::Parakeet, + display_name: "Parakeet CTC 0.6B (int8)", + disk_size: Megabytes(613), + ram_required: Megabytes(600), + speed_tier: SpeedTier::Instant, + accuracy_tier: AccuracyTier::Great, + languages: LanguageSupport::EnglishOnly, + files: vec![ + ModelFile { + filename: "model_int8.onnx", + url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/onnx/model_int8.onnx", + size: Megabytes(1), + }, + ModelFile { + filename: "model_int8.onnx_data", + url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/onnx/model_int8.onnx_data", + size: Megabytes(611), + }, + ModelFile { + filename: "tokenizer.json", + url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/tokenizer.json", + size: Megabytes(1), + }, + ], + description: "Fastest local model — near-instant transcription", + }, + ModelEntry { + id: ModelId::new("whisper-tiny-en"), + engine: Engine::Whisper, + display_name: "Whisper Tiny (English)", + disk_size: Megabytes(75), + ram_required: Megabytes(390), + speed_tier: SpeedTier::Fast, + accuracy_tier: AccuracyTier::Good, + languages: LanguageSupport::EnglishOnly, + files: vec![ModelFile { + filename: "ggml-tiny.en.bin", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin", + size: Megabytes(75), + }], + description: "Bundled with app — works instantly", + }, + ModelEntry { + id: ModelId::new("whisper-base-en"), + engine: Engine::Whisper, + display_name: "Whisper Base (English)", + disk_size: Megabytes(142), + ram_required: Megabytes(500), + speed_tier: SpeedTier::Fast, + accuracy_tier: AccuracyTier::Good, + languages: LanguageSupport::EnglishOnly, + files: vec![ModelFile { + filename: "ggml-base.en.bin", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin", + size: Megabytes(142), + }], + description: "Good balance of speed and accuracy", + }, + ModelEntry { + id: ModelId::new("whisper-small-en"), + engine: Engine::Whisper, + display_name: "Whisper Small (English)", + disk_size: Megabytes(466), + ram_required: Megabytes(1024), + speed_tier: SpeedTier::Moderate, + accuracy_tier: AccuracyTier::Great, + languages: LanguageSupport::EnglishOnly, + files: vec![ModelFile { + filename: "ggml-small.en.bin", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin", + size: Megabytes(466), + }], + description: "Accuracy-first English transcription", + }, + ModelEntry { + id: ModelId::new("whisper-medium-en"), + engine: Engine::Whisper, + display_name: "Whisper Medium (English)", + disk_size: Megabytes(1500), + ram_required: Megabytes(2600), + speed_tier: SpeedTier::Slow, + accuracy_tier: AccuracyTier::Excellent, + languages: LanguageSupport::EnglishOnly, + files: vec![ModelFile { + filename: "ggml-medium.en.bin", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.en.bin", + size: Megabytes(1500), + }], + description: "Best Whisper accuracy — needs 4+ GB RAM", + }, + ] +}); + +/// Returns all known models. Pure data, no scoring. +pub fn all_models() -> &'static [ModelEntry] { + &ALL_MODELS +} + +/// Find a model by its ID. +pub fn find_model(id: &ModelId) -> Option<&'static ModelEntry> { + ALL_MODELS.iter().find(|m| &m.id == id) +} diff --git a/crates/core/src/providers.rs b/crates/core/src/providers.rs new file mode 100644 index 0000000..658475b --- /dev/null +++ b/crates/core/src/providers.rs @@ -0,0 +1,38 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::error::Result; +use crate::types::{AudioSamples, EngineName, Transcript, TranscriptionOptions}; + +/// Any speech-to-text engine implements this trait. +/// Base types know nothing about their derivatives. +#[async_trait] +pub trait SpeechToText: Send + Sync { + async fn transcribe( + &self, + audio: AudioSamples, + options: &TranscriptionOptions, + ) -> Result; + + fn name(&self) -> &EngineName; + + fn is_available(&self) -> bool; +} + +/// Any text post-processor implements this trait. +#[async_trait] +pub trait TextProcessor: Send + Sync { + async fn process(&self, text: &str, instruction: &str) -> Result; + + fn name(&self) -> &EngineName; + + fn is_available(&self) -> bool; +} + +/// Holds the active provider instances. Constructed at startup, +/// rebuilt when user changes provider in settings. +pub struct ProviderRegistry { + pub stt: Arc, + pub text: Option>, +} diff --git a/crates/core/src/recommendation.rs b/crates/core/src/recommendation.rs new file mode 100644 index 0000000..185400e --- /dev/null +++ b/crates/core/src/recommendation.rs @@ -0,0 +1,190 @@ +use crate::hardware::SystemProfile; +use crate::model_registry::{ + all_models, AccuracyTier, Engine, ModelEntry, SpeedTier, +}; +use crate::types::Megabytes; + +/// A model's suitability score for a given system. Higher is better. +/// No boolean flags — position in the ranked list conveys recommendation. +pub struct ScoredModel { + pub entry: &'static ModelEntry, + pub score: f64, + pub reason: String, +} + +/// Scores a single model against a system profile. +/// Pure function, no side effects. +pub fn score_model( + model: &'static ModelEntry, + profile: &SystemProfile, +) -> Option { + if model.ram_required > profile.ram { + return None; + } + + let mut score = 0.0; + let mut reasons: Vec = Vec::new(); + + score += match model.speed_tier { + SpeedTier::Instant => 40.0, + SpeedTier::Fast => 30.0, + SpeedTier::Moderate => 20.0, + SpeedTier::Slow => 10.0, + }; + + score += match model.accuracy_tier { + AccuracyTier::Excellent => 30.0, + AccuracyTier::Great => 20.0, + AccuracyTier::Good => 10.0, + }; + + if let Some(gpu) = &profile.gpu { + let has_accel = match model.engine { + Engine::Whisper => { + gpu.acceleration.metal + || gpu.acceleration.vulkan + || gpu.acceleration.cuda + } + Engine::Parakeet | Engine::Moonshine => { + gpu.acceleration.cuda || gpu.acceleration.vulkan + } + }; + if has_accel { + score += 15.0; + reasons.push("GPU accelerated on your system".into()); + } + } + + let headroom = + Megabytes(profile.ram.0.saturating_sub(model.ram_required.0)); + if headroom > Megabytes::from_gb(4.0) { + score += 10.0; + } + + let reason = if reasons.is_empty() { + model.description.to_string() + } else { + reasons.join(". ") + }; + + Some(ScoredModel { + entry: model, + score, + reason, + }) +} + +/// Scores all models and returns them ranked. +/// Index 0 is the recommendation. No flag arguments. +pub fn rank_recommendations(profile: &SystemProfile) -> Vec { + let mut scored: Vec = all_models() + .iter() + .filter_map(|model| score_model(model, profile)) + .collect(); + + scored.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + scored +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hardware::{CpuInfo, GpuAcceleration, GpuInfo, GpuVendor, Os}; + + fn profile_with_ram(ram: Megabytes) -> SystemProfile { + SystemProfile { + ram, + cpu: CpuInfo { + core_count: 8, + brand: "Test CPU".into(), + }, + gpu: None, + os: Os::Windows, + } + } + + fn profile_with_gpu(ram: Megabytes) -> SystemProfile { + SystemProfile { + ram, + cpu: CpuInfo { + core_count: 8, + brand: "Test CPU".into(), + }, + gpu: Some(GpuInfo { + vendor: GpuVendor::Nvidia, + vram: Megabytes(8192), + acceleration: GpuAcceleration { + cuda: true, + metal: false, + vulkan: true, + }, + }), + os: Os::Windows, + } + } + + #[test] + fn score_model_excludes_models_exceeding_available_ram() { + let profile = profile_with_ram(Megabytes(256)); + let model = all_models() + .iter() + .find(|m| m.ram_required > Megabytes(256)) + .expect("need a model larger than 256 MB"); + + let result = score_model(model, &profile); + + assert!(result.is_none()); + } + + #[test] + fn score_model_includes_models_fitting_in_ram() { + let profile = profile_with_ram(Megabytes(16384)); + let model = &all_models()[0]; + + let result = score_model(model, &profile); + + assert!(result.is_some()); + } + + #[test] + fn score_model_boosts_gpu_accelerated_models() { + let model = all_models() + .iter() + .find(|m| m.engine == Engine::Parakeet) + .expect("need a Parakeet model"); + + let gpu_score = + score_model(model, &profile_with_gpu(Megabytes(16384))) + .unwrap() + .score; + let cpu_score = + score_model(model, &profile_with_ram(Megabytes(16384))) + .unwrap() + .score; + + assert!(gpu_score > cpu_score); + } + + #[test] + fn rank_recommendations_places_highest_score_first() { + let profile = profile_with_ram(Megabytes(16384)); + + let ranked = rank_recommendations(&profile); + + assert!(ranked.len() >= 2); + assert!(ranked[0].score >= ranked[1].score); + } + + #[test] + fn rank_recommendations_returns_empty_for_very_low_ram() { + let profile = profile_with_ram(Megabytes(128)); + + let ranked = rank_recommendations(&profile); + + assert!(ranked.is_empty()); + } +} diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs new file mode 100644 index 0000000..ff0eb74 --- /dev/null +++ b/crates/core/src/types.rs @@ -0,0 +1,177 @@ +use serde::{Deserialize, Serialize}; + +/// Prevents passing raw strings where model IDs are expected. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ModelId(String); + +impl ModelId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ModelId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Prevents passing raw strings where engine names are expected. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EngineName(String); + +impl EngineName { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for EngineName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Prevents mixing up bytes, megabytes, and gigabytes. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct Megabytes(pub u64); + +impl Megabytes { + pub fn from_gb(gb: f64) -> Self { + Self((gb * 1024.0) as u64) + } + + pub fn as_gb(&self) -> f64 { + self.0 as f64 / 1024.0 + } +} + +impl std::fmt::Display for Megabytes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.0 >= 1024 { + write!(f, "{:.1} GB", self.as_gb()) + } else { + write!(f, "{} MB", self.0) + } + } +} + +/// Wraps raw audio samples with metadata. +#[derive(Debug, Clone)] +pub struct AudioSamples { + samples: Vec, + sample_rate: u32, + channels: u16, +} + +impl AudioSamples { + pub fn new(samples: Vec, sample_rate: u32, channels: u16) -> Self { + Self { + samples, + sample_rate, + channels, + } + } + + pub fn mono_16khz(samples: Vec) -> Self { + Self { + samples, + sample_rate: crate::constants::WHISPER_SAMPLE_RATE, + channels: crate::constants::WHISPER_CHANNELS, + } + } + + pub fn samples(&self) -> &[f32] { + &self.samples + } + + pub fn into_samples(self) -> Vec { + self.samples + } + + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + pub fn channels(&self) -> u16 { + self.channels + } + + pub fn duration_secs(&self) -> f64 { + if self.sample_rate == 0 { + return 0.0; + } + self.samples.len() as f64 / self.sample_rate as f64 + } +} + +/// A single timed segment of a transcription. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Segment { + pub start: f64, + pub end: f64, + pub text: String, +} + +/// The result of a transcription. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Transcript { + segments: Vec, + language: String, + duration: f64, +} + +impl Transcript { + pub fn new(segments: Vec, language: String, duration: f64) -> Self { + Self { + segments, + language, + duration, + } + } + + pub fn text(&self) -> String { + self.segments + .iter() + .map(|s| s.text.as_str()) + .collect::>() + .join(" ") + } + + pub fn segments(&self) -> &[Segment] { + &self.segments + } + + pub fn language(&self) -> &str { + &self.language + } + + pub fn duration(&self) -> f64 { + self.duration + } +} + +/// Options passed to a transcription engine. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TranscriptionOptions { + pub language: Option, + pub initial_prompt: Option, +} + +/// Progress update during model download. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadProgress { + pub model_id: ModelId, + pub file_name: String, + pub bytes_downloaded: u64, + pub total_bytes: u64, + pub percent: u8, +}