feat(kon): add core crate — types, traits, hardware, model registry, recommendation

- 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) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-16 20:27:27 +00:00
parent 9926a42b7a
commit 6588130e36
9 changed files with 786 additions and 2 deletions

View File

@@ -5,3 +5,8 @@ edition = "2021"
description = "Core types, constants, traits, hardware detection, and model registry for Kon" description = "Core types, constants, traits, hardware detection, and model registry for Kon"
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
sysinfo = "0.35"
async-trait = "0.1"

View File

@@ -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)
}

41
crates/core/src/error.rs Normal file
View File

@@ -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<T> = std::result::Result<T, KonError>;

107
crates/core/src/hardware.rs Normal file
View File

@@ -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<GpuInfo>,
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<GpuInfo> {
// 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(),
}
}

View File

@@ -1,2 +1,13 @@
// kon-core: Foundation types, constants, provider traits, hardware detection, pub mod constants;
// model registry, and recommendation engine. 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,
};

View File

@@ -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<ModelFile>,
pub description: &'static str,
}
static ALL_MODELS: LazyLock<Vec<ModelEntry>> = 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)
}

View File

@@ -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<Transcript>;
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<String>;
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<dyn SpeechToText>,
pub text: Option<Arc<dyn TextProcessor>>,
}

View File

@@ -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<ScoredModel> {
if model.ram_required > profile.ram {
return None;
}
let mut score = 0.0;
let mut reasons: Vec<String> = 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<ScoredModel> {
let mut scored: Vec<ScoredModel> = 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());
}
}

177
crates/core/src/types.rs Normal file
View File

@@ -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<String>) -> 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<String>) -> 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<f32>,
sample_rate: u32,
channels: u16,
}
impl AudioSamples {
pub fn new(samples: Vec<f32>, sample_rate: u32, channels: u16) -> Self {
Self {
samples,
sample_rate,
channels,
}
}
pub fn mono_16khz(samples: Vec<f32>) -> 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<f32> {
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<Segment>,
language: String,
duration: f64,
}
impl Transcript {
pub fn new(segments: Vec<Segment>, language: String, duration: f64) -> Self {
Self {
segments,
language,
duration,
}
}
pub fn text(&self) -> String {
self.segments
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.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<String>,
pub initial_prompt: Option<String>,
}
/// 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,
}