1use crate::decode::DecodeBudget;
10use crate::hardware::{
11 hardware_capabilities_read_only, select_accelerator_from_capabilities, HardwareCapabilities,
12};
13use crate::service::requires_external_model;
14use crate::{
15 AcceleratorPreference, AcceleratorRuntime, Audio, AudioCodec, AudioFormat, AudioInputSession,
16 AudioStreamReader, Backend, BackendOptions, DecodeLimits, OnnxModelConfig, Preset,
17 ProcessingMode,
18};
19use serde::Serialize;
20use sha2::{Digest as _, Sha256};
21use std::cmp::Ordering;
22use std::path::Path;
23use std::time::Instant;
24
25pub const RECOMMENDATION_SCHEMA: &str = "denoize-recommendation-v1";
27pub const RECOMMENDATION_SCHEMA_VERSION: u32 = 1;
29
30const DEFAULT_ANALYSIS_SECONDS: u32 = 12;
31const MAX_ANALYSIS_SECONDS: u32 = 60;
32const ANALYSIS_BLOCK_FRAMES: usize = 4_096;
33const DEFAULT_CALIBRATION_RUNS: u8 = 3;
34const MAX_CALIBRATION_RUNS: u8 = 9;
35const CALIBRATION_SAMPLE_RATE: u32 = 48_000;
36const CALIBRATION_FRAMES: usize = 24_000;
37const CALIBRATION_SCRATCH_BYTES: u64 = 1024 * 1024;
38const CALIBRATION_WORKLOAD: &str = "classical-hifi-v1";
39const CALIBRATION_DOMAIN: &[u8] = b"denoize-device-calibration-v1\0";
40const ANALYSIS_DOMAIN: &[u8] = b"denoize-recommendation-analysis-v1\0";
41#[cfg(test)]
42const CALIBRATION_FIXTURE_SHA256: &str =
43 "5f64cb9074291ee8688f2f8d432dfb926ca37a0be33e41e3875d71d468a1e479";
44
45#[non_exhaustive]
47#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
48#[serde(rename_all = "kebab-case")]
49pub enum RecommendationGoal {
50 #[default]
52 Balanced,
53 Quality,
55 Speed,
57 LowMemory,
59}
60
61impl RecommendationGoal {
62 #[must_use]
63 pub fn parse(value: &str) -> Option<Self> {
64 match value.to_ascii_lowercase().as_str() {
65 "balanced" | "default" => Some(Self::Balanced),
66 "quality" | "best" | "highest" => Some(Self::Quality),
67 "speed" | "fast" | "realtime" => Some(Self::Speed),
68 "low-memory" | "low_memory" | "memory" => Some(Self::LowMemory),
69 _ => None,
70 }
71 }
72
73 #[must_use]
74 pub const fn name(self) -> &'static str {
75 match self {
76 Self::Balanced => "balanced",
77 Self::Quality => "quality",
78 Self::Speed => "speed",
79 Self::LowMemory => "low-memory",
80 }
81 }
82}
83
84#[non_exhaustive]
86#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum RecommendationMaterial {
89 Speech,
90 Music,
91 Mixed,
92 Quiet,
93}
94
95impl RecommendationMaterial {
96 #[must_use]
97 pub const fn name(self) -> &'static str {
98 match self {
99 Self::Speech => "speech",
100 Self::Music => "music",
101 Self::Mixed => "mixed",
102 Self::Quiet => "quiet",
103 }
104 }
105}
106
107#[non_exhaustive]
109#[derive(Clone, Copy, Debug, Eq, PartialEq)]
110pub struct RecommendationOptions {
111 goal: RecommendationGoal,
112 analysis_seconds: u32,
113 calibration_runs: Option<u8>,
114 decode_limits: DecodeLimits,
115 max_gpu_memory_bytes: Option<u64>,
116 accelerator: AcceleratorPreference,
117 deterministic: bool,
118}
119
120impl Default for RecommendationOptions {
121 fn default() -> Self {
122 Self {
123 goal: RecommendationGoal::Balanced,
124 analysis_seconds: DEFAULT_ANALYSIS_SECONDS,
125 calibration_runs: None,
126 decode_limits: DecodeLimits::default(),
127 max_gpu_memory_bytes: None,
128 accelerator: AcceleratorPreference::Auto,
129 deterministic: false,
130 }
131 }
132}
133
134impl RecommendationOptions {
135 #[must_use]
136 pub fn new() -> Self {
137 Self::default()
138 }
139
140 #[must_use]
141 pub const fn with_goal(mut self, goal: RecommendationGoal) -> Self {
142 self.goal = goal;
143 self
144 }
145
146 #[must_use]
147 pub const fn with_analysis_seconds(mut self, seconds: u32) -> Self {
148 self.analysis_seconds = seconds;
149 self
150 }
151
152 #[must_use]
154 pub const fn with_calibration_runs(mut self, runs: Option<u8>) -> Self {
155 self.calibration_runs = runs;
156 self
157 }
158
159 #[must_use]
161 pub const fn with_calibration(mut self, enabled: bool) -> Self {
162 self.calibration_runs = if enabled {
163 Some(DEFAULT_CALIBRATION_RUNS)
164 } else {
165 None
166 };
167 self
168 }
169
170 #[must_use]
171 pub const fn with_decode_limits(mut self, limits: DecodeLimits) -> Self {
172 self.decode_limits = limits;
173 self
174 }
175
176 #[must_use]
178 pub const fn with_max_gpu_memory_bytes(mut self, limit: Option<u64>) -> Self {
179 self.max_gpu_memory_bytes = limit;
180 self
181 }
182
183 #[must_use]
184 pub const fn with_accelerator(mut self, accelerator: AcceleratorPreference) -> Self {
185 self.accelerator = accelerator;
186 self
187 }
188
189 #[must_use]
190 pub const fn with_deterministic(mut self, deterministic: bool) -> Self {
191 self.deterministic = deterministic;
192 self
193 }
194
195 #[must_use]
196 pub const fn goal(self) -> RecommendationGoal {
197 self.goal
198 }
199
200 #[must_use]
201 pub const fn analysis_seconds(self) -> u32 {
202 self.analysis_seconds
203 }
204
205 #[must_use]
206 pub const fn calibration_runs(self) -> Option<u8> {
207 self.calibration_runs
208 }
209
210 #[must_use]
211 pub const fn decode_limits(self) -> DecodeLimits {
212 self.decode_limits
213 }
214
215 #[must_use]
216 pub const fn max_gpu_memory_bytes(self) -> Option<u64> {
217 self.max_gpu_memory_bytes
218 }
219
220 #[must_use]
221 pub const fn accelerator(self) -> AcceleratorPreference {
222 self.accelerator
223 }
224
225 #[must_use]
226 pub const fn deterministic(self) -> bool {
227 self.deterministic
228 }
229
230 pub fn validate(self) -> Result<(), String> {
232 if !(1..=MAX_ANALYSIS_SECONDS).contains(&self.analysis_seconds) {
233 return Err(format!(
234 "recommendation analysis duration must be between 1 and {MAX_ANALYSIS_SECONDS} seconds"
235 ));
236 }
237 if self
238 .calibration_runs
239 .is_some_and(|runs| !(1..=MAX_CALIBRATION_RUNS).contains(&runs))
240 {
241 return Err(format!(
242 "recommendation calibration runs must be between 1 and {MAX_CALIBRATION_RUNS}"
243 ));
244 }
245 Ok(())
246 }
247}
248
249#[non_exhaustive]
251#[derive(Clone, Debug, PartialEq, Serialize)]
252pub struct RecommendationInput {
253 pub format: String,
254 pub codec: String,
255 pub sample_rate: u32,
256 pub channels: usize,
257 pub total_frames: Option<u64>,
258 pub analyzed_frames: usize,
259 pub analysis_mode: String,
260 pub analysis_sha256: String,
261 pub rms_dbfs: f64,
262 pub peak_dbfs: f64,
263 pub crest_db: f64,
264 pub active_ratio: f64,
265 pub zero_crossing_rate: f64,
266 pub transient_ratio: f64,
267 pub stereo_correlation: Option<f64>,
268 pub material: RecommendationMaterial,
269 pub material_confidence: f64,
270}
271
272#[non_exhaustive]
274#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
275pub struct RecommendationDevice {
276 pub os: String,
277 pub architecture: String,
278 pub logical_cpus: usize,
279 pub requested_accelerator: String,
280 pub available_runtimes: Vec<String>,
281}
282
283#[non_exhaustive]
285#[derive(Clone, Debug, PartialEq, Serialize)]
286pub struct CalibrationEvidence {
287 pub workload: String,
288 pub fixture_sha256: String,
289 pub sample_rate: u32,
290 pub channels: usize,
291 pub frames: usize,
292 pub warmup_runs: u8,
293 pub measured_runs: u8,
294 pub elapsed_ms: Vec<f64>,
295 pub median_elapsed_ms: f64,
296 pub baseline_realtime_headroom: f64,
297}
298
299#[non_exhaustive]
301#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
302pub struct RecommendationReason {
303 pub code: String,
304 pub impact: i16,
305 pub detail: String,
306}
307
308#[non_exhaustive]
310#[derive(Clone, Debug, PartialEq, Serialize)]
311pub struct RecommendationCandidate {
312 pub backend: String,
313 pub preset: String,
314 pub model: Option<String>,
315 pub eligible: bool,
316 pub score: u16,
317 pub requested_accelerator: String,
318 pub effective_accelerator: Option<String>,
319 pub accelerator_fallback: Option<String>,
320 pub estimated_memory_bytes: Option<u64>,
321 pub estimated_gpu_memory_bytes: Option<u64>,
322 pub calibrated_realtime_headroom: Option<f64>,
323 pub reasons: Vec<RecommendationReason>,
324}
325
326#[non_exhaustive]
328#[derive(Clone, Debug, PartialEq, Serialize)]
329pub struct RecommendationDecision {
330 pub backend: String,
331 pub preset: String,
332 pub processing_mode: String,
333 pub strength: f64,
334 pub adaptive_noise: bool,
335 pub vad: bool,
336 pub accelerator: String,
337 pub model: Option<String>,
338 pub arguments: Vec<String>,
339}
340
341#[non_exhaustive]
343#[derive(Clone, Debug, PartialEq, Serialize)]
344pub struct RecommendationReport {
345 pub schema: String,
346 pub schema_version: u32,
347 pub denoize_version: String,
348 pub network_accessed: bool,
349 pub goal: RecommendationGoal,
350 pub input: RecommendationInput,
351 pub device: RecommendationDevice,
352 pub calibration: Option<CalibrationEvidence>,
353 pub decision: RecommendationDecision,
354 pub candidates: Vec<RecommendationCandidate>,
355}
356
357impl RecommendationReport {
358 pub fn to_json(&self) -> Result<String, String> {
359 serde_json::to_string(self)
360 .map_err(|error| format!("serialize recommendation report: {error}"))
361 }
362
363 pub fn to_pretty_json(&self) -> Result<String, String> {
364 serde_json::to_string_pretty(self)
365 .map_err(|error| format!("serialize recommendation report: {error}"))
366 }
367}
368
369pub fn recommend_file(path: impl AsRef<Path>) -> Result<RecommendationReport, String> {
371 recommend_file_with_options(path, RecommendationOptions::default())
372}
373
374pub fn recommend_file_with_options(
377 path: impl AsRef<Path>,
378 options: RecommendationOptions,
379) -> Result<RecommendationReport, String> {
380 options.validate()?;
381 let session = AudioInputSession::open(path)?;
382 let input = analyze_session(session, options)?;
383 recommend_from_input(input, options)
384}
385
386pub fn recommend_audio(
391 audio: &Audio,
392 options: RecommendationOptions,
393) -> Result<RecommendationReport, String> {
394 options.validate()?;
395 validate_audio(audio)?;
396 let limit = analysis_frame_limit(audio.sample_rate, options.analysis_seconds)?;
397 let frames = audio.frames().min(limit);
398 let scratch = analysis_scratch_bytes(audio.channels.len())?;
399 DecodeBudget::new(options.decode_limits).check_planar_capacities(
400 &audio.channels,
401 scratch,
402 "recommendation decoded-audio analysis",
403 )?;
404 let mut accumulator = SignalAccumulator::try_new(audio.sample_rate, audio.channels.len())?;
405 accumulator.ingest(&audio.channels, frames)?;
406 let input = accumulator.finish(
407 "decoded-audio",
408 "pcm",
409 Some(audio.frames() as u64),
410 "decoded-audio",
411 )?;
412 recommend_from_input(input, options)
413}
414
415fn analyze_session(
416 mut session: AudioInputSession,
417 options: RecommendationOptions,
418) -> Result<RecommendationInput, String> {
419 let probe = crate::probe_file_from_session_with_limits(&mut session, options.decode_limits)?;
420 if matches!(
421 probe.format,
422 AudioFormat::Wav
423 | AudioFormat::Flac
424 | AudioFormat::OggVorbis
425 | AudioFormat::OggOpus
426 | AudioFormat::Mp3
427 | AudioFormat::AacAdts
428 | AudioFormat::M4a
429 ) {
430 let mut reader = AudioStreamReader::from_session(session, options.decode_limits)?;
431 let info = reader.info();
432 let frame_limit = analysis_frame_limit(info.sample_rate(), options.analysis_seconds)?;
433 let block_frames = ANALYSIS_BLOCK_FRAMES.min(frame_limit.max(1));
434 let analysis_scratch = analysis_scratch_bytes(info.channels())?;
435 let temporary_bytes = info
436 .decoder_additional_bytes
437 .checked_add(analysis_scratch)
438 .ok_or_else(|| "recommendation stream scratch byte count overflows".to_string())?;
439 DecodeBudget::new(options.decode_limits).check_planar_frames(
440 info.channels(),
441 block_frames,
442 temporary_bytes,
443 "recommendation stream analysis",
444 )?;
445 let mut accumulator = SignalAccumulator::try_new(info.sample_rate(), info.channels())?;
446 while accumulator.frames < frame_limit {
447 let remaining = frame_limit - accumulator.frames;
448 let Some(block) = reader.next_block(block_frames.min(remaining))? else {
449 break;
450 };
451 let frames = block.first().map_or(0, Vec::len);
452 accumulator.ingest(&block, frames)?;
453 }
454 return accumulator.finish(
455 format_name(info.format),
456 codec_name(info.codec),
457 info.total_frames,
458 "bounded-stream",
459 );
460 }
461
462 let audio = crate::read_audio_from_session_with_limits(&mut session, options.decode_limits)?;
463 validate_audio(&audio)?;
464 let frame_limit = analysis_frame_limit(audio.sample_rate, options.analysis_seconds)?;
465 let frames = audio.frames().min(frame_limit);
466 let scratch = analysis_scratch_bytes(audio.channels.len())?;
467 DecodeBudget::new(options.decode_limits).check_planar_capacities(
468 &audio.channels,
469 scratch,
470 "recommendation whole-file analysis",
471 )?;
472 let mut accumulator = SignalAccumulator::try_new(audio.sample_rate, audio.channels.len())?;
473 accumulator.ingest(&audio.channels, frames)?;
474 accumulator.finish(
475 format_name(probe.format),
476 codec_name(probe.codec),
477 Some(audio.frames() as u64),
478 "whole-file-fallback",
479 )
480}
481
482fn validate_audio(audio: &Audio) -> Result<(), String> {
483 if audio.sample_rate == 0 {
484 return Err("recommendation input sample rate is zero".into());
485 }
486 if audio.channels.is_empty() {
487 return Err("recommendation input has no channels".into());
488 }
489 let frames = audio.channels[0].len();
490 if frames == 0 {
491 return Err("recommendation input has no frames".into());
492 }
493 if let Some((index, channel)) = audio
494 .channels
495 .iter()
496 .enumerate()
497 .find(|(_, channel)| channel.len() != frames)
498 {
499 return Err(format!(
500 "recommendation input channel {index} has {} frames but channel 0 has {frames}",
501 channel.len()
502 ));
503 }
504 Ok(())
505}
506
507fn analysis_frame_limit(sample_rate: u32, seconds: u32) -> Result<usize, String> {
508 u64::from(sample_rate)
509 .checked_mul(u64::from(seconds))
510 .and_then(|frames| usize::try_from(frames).ok())
511 .ok_or_else(|| "recommendation analysis frame limit overflows".to_string())
512}
513
514struct SignalAccumulator {
515 sample_rate: u32,
516 channels: usize,
517 frames: usize,
518 sample_count: u64,
519 sum_squares: f64,
520 peak: f64,
521 active: u64,
522 zero_crossings: u64,
523 differences: u64,
524 transients: u64,
525 previous: Vec<Option<f64>>,
526 stereo_count: u64,
527 stereo_x: f64,
528 stereo_y: f64,
529 stereo_x2: f64,
530 stereo_y2: f64,
531 stereo_xy: f64,
532 hash: Sha256,
533}
534
535impl SignalAccumulator {
536 fn try_new(sample_rate: u32, channels: usize) -> Result<Self, String> {
537 let mut hash = Sha256::new();
538 hash.update(ANALYSIS_DOMAIN);
539 hash.update(sample_rate.to_le_bytes());
540 hash.update((channels as u64).to_le_bytes());
541 let mut previous = Vec::new();
542 previous
543 .try_reserve_exact(channels)
544 .map_err(|error| format!("recommendation analysis state reserve: {error}"))?;
545 previous.resize(channels, None);
546 Ok(Self {
547 sample_rate,
548 channels,
549 frames: 0,
550 sample_count: 0,
551 sum_squares: 0.0,
552 peak: 0.0,
553 active: 0,
554 zero_crossings: 0,
555 differences: 0,
556 transients: 0,
557 previous,
558 stereo_count: 0,
559 stereo_x: 0.0,
560 stereo_y: 0.0,
561 stereo_x2: 0.0,
562 stereo_y2: 0.0,
563 stereo_xy: 0.0,
564 hash,
565 })
566 }
567
568 fn ingest(&mut self, channels: &[Vec<f64>], frames: usize) -> Result<(), String> {
569 if channels.len() != self.channels {
570 return Err("recommendation input channel count changed during analysis".into());
571 }
572 if channels.iter().any(|channel| channel.len() < frames) {
573 return Err("recommendation input block has inconsistent channel lengths".into());
574 }
575 for frame in 0..frames {
576 for (index, channel) in channels.iter().enumerate() {
577 let sample = crate::sanitize_sample(channel[frame]);
578 self.hash.update(sample.to_bits().to_le_bytes());
579 let absolute = sample.abs();
580 self.sum_squares += sample * sample;
581 self.peak = self.peak.max(absolute);
582 self.active += u64::from(absolute >= 0.001);
583 if let Some(previous) = self.previous[index] {
584 self.differences += 1;
585 self.zero_crossings += u64::from(
586 (previous < 0.0 && sample >= 0.0) || (previous >= 0.0 && sample < 0.0),
587 );
588 self.transients += u64::from((sample - previous).abs() >= 0.15);
589 }
590 self.previous[index] = Some(sample);
591 self.sample_count += 1;
592 }
593 if self.channels >= 2 {
594 let left = crate::sanitize_sample(channels[0][frame]);
595 let right = crate::sanitize_sample(channels[1][frame]);
596 self.stereo_x += left;
597 self.stereo_y += right;
598 self.stereo_x2 += left * left;
599 self.stereo_y2 += right * right;
600 self.stereo_xy += left * right;
601 self.stereo_count += 1;
602 }
603 }
604 self.frames = self
605 .frames
606 .checked_add(frames)
607 .ok_or_else(|| "recommendation analyzed frame count overflows".to_string())?;
608 Ok(())
609 }
610
611 fn finish(
612 self,
613 format: impl Into<String>,
614 codec: impl Into<String>,
615 total_frames: Option<u64>,
616 analysis_mode: impl Into<String>,
617 ) -> Result<RecommendationInput, String> {
618 if self.frames == 0 || self.sample_count == 0 {
619 return Err("recommendation input has no decodable frames".into());
620 }
621 let rms = (self.sum_squares / self.sample_count as f64).sqrt();
622 let rms_dbfs = amplitude_db(rms);
623 let peak_dbfs = amplitude_db(self.peak);
624 let crest_db = if rms > 0.0 {
625 20.0 * (self.peak.max(rms) / rms).log10()
626 } else {
627 0.0
628 };
629 let active_ratio = self.active as f64 / self.sample_count as f64;
630 let zero_crossing_rate = ratio(self.zero_crossings, self.differences);
631 let transient_ratio = ratio(self.transients, self.differences);
632 let stereo_correlation = correlation(&self);
633 let (material, material_confidence) = classify_material(
634 self.channels,
635 rms_dbfs,
636 crest_db,
637 active_ratio,
638 zero_crossing_rate,
639 transient_ratio,
640 stereo_correlation,
641 );
642 Ok(RecommendationInput {
643 format: format.into(),
644 codec: codec.into(),
645 sample_rate: self.sample_rate,
646 channels: self.channels,
647 total_frames,
648 analyzed_frames: self.frames,
649 analysis_mode: analysis_mode.into(),
650 analysis_sha256: format!("{:x}", self.hash.finalize()),
651 rms_dbfs: round_metric(rms_dbfs),
652 peak_dbfs: round_metric(peak_dbfs),
653 crest_db: round_metric(crest_db),
654 active_ratio: round_metric(active_ratio),
655 zero_crossing_rate: round_metric(zero_crossing_rate),
656 transient_ratio: round_metric(transient_ratio),
657 stereo_correlation: stereo_correlation.map(round_metric),
658 material,
659 material_confidence: round_metric(material_confidence),
660 })
661 }
662}
663
664fn analysis_scratch_bytes(channels: usize) -> Result<u64, String> {
665 let channel_state = u64::try_from(channels)
666 .ok()
667 .and_then(|channels| channels.checked_mul(std::mem::size_of::<Option<f64>>() as u64))
668 .ok_or_else(|| "recommendation analysis state byte count overflows".to_string())?;
669 channel_state
670 .checked_add(std::mem::size_of::<SignalAccumulator>() as u64)
671 .ok_or_else(|| "recommendation analysis scratch byte count overflows".to_string())
672}
673
674fn ratio(numerator: u64, denominator: u64) -> f64 {
675 if denominator == 0 {
676 0.0
677 } else {
678 numerator as f64 / denominator as f64
679 }
680}
681
682fn amplitude_db(amplitude: f64) -> f64 {
683 if amplitude > 0.0 {
684 (20.0 * amplitude.log10()).max(-120.0)
685 } else {
686 -120.0
687 }
688}
689
690fn correlation(accumulator: &SignalAccumulator) -> Option<f64> {
691 if accumulator.stereo_count < 2 {
692 return None;
693 }
694 let count = accumulator.stereo_count as f64;
695 let covariance = accumulator.stereo_xy - accumulator.stereo_x * accumulator.stereo_y / count;
696 let variance_x = accumulator.stereo_x2 - accumulator.stereo_x * accumulator.stereo_x / count;
697 let variance_y = accumulator.stereo_y2 - accumulator.stereo_y * accumulator.stereo_y / count;
698 let denominator = (variance_x.max(0.0) * variance_y.max(0.0)).sqrt();
699 if denominator <= f64::EPSILON {
700 None
701 } else {
702 Some((covariance / denominator).clamp(-1.0, 1.0))
703 }
704}
705
706fn classify_material(
707 channels: usize,
708 rms_dbfs: f64,
709 crest_db: f64,
710 active_ratio: f64,
711 zero_crossing_rate: f64,
712 transient_ratio: f64,
713 stereo_correlation: Option<f64>,
714) -> (RecommendationMaterial, f64) {
715 if rms_dbfs <= -55.0 || active_ratio <= 0.03 {
716 return (RecommendationMaterial::Quiet, 0.9);
717 }
718 let mono_bias = if channels == 1 { 0.18 } else { 0.0 };
719 let speech_zcr = triangular(zero_crossing_rate, 0.015, 0.09, 0.28);
720 let speech_crest = triangular(crest_db, 4.0, 12.0, 26.0);
721 let speech_activity = triangular(active_ratio, 0.08, 0.58, 1.0);
722 let speech_score =
723 (0.36 * speech_zcr + 0.26 * speech_crest + 0.20 * speech_activity + mono_bias)
724 .clamp(0.0, 1.0);
725
726 let stereo_width = stereo_correlation.map_or(0.0, |value| (1.0 - value.abs()).clamp(0.0, 1.0));
727 let music_zcr = triangular(zero_crossing_rate, 0.0, 0.035, 0.18);
728 let music_crest = triangular(crest_db, 3.0, 10.0, 24.0);
729 let music_transients = triangular(transient_ratio, 0.0, 0.035, 0.22);
730 let music_score =
731 (0.30 * music_zcr + 0.25 * music_crest + 0.20 * music_transients + 0.25 * stereo_width)
732 .clamp(0.0, 1.0);
733 let difference = (speech_score - music_score).abs();
734 if difference < 0.14 {
735 (RecommendationMaterial::Mixed, 1.0 - difference / 0.14)
736 } else if speech_score > music_score {
737 (RecommendationMaterial::Speech, difference.clamp(0.0, 1.0))
738 } else {
739 (RecommendationMaterial::Music, difference.clamp(0.0, 1.0))
740 }
741}
742
743fn triangular(value: f64, low: f64, center: f64, high: f64) -> f64 {
744 if value <= low || value >= high {
745 0.0
746 } else if value <= center {
747 (value - low) / (center - low)
748 } else {
749 (high - value) / (high - center)
750 }
751}
752
753fn round_metric(value: f64) -> f64 {
754 if !value.is_finite() {
755 return 0.0;
756 }
757 (value * 1_000_000.0).round() / 1_000_000.0
758}
759
760fn recommend_from_input(
761 input: RecommendationInput,
762 options: RecommendationOptions,
763) -> Result<RecommendationReport, String> {
764 let hardware = hardware_capabilities_read_only();
765 let mut available_runtimes = hardware
766 .runtimes()
767 .iter()
768 .filter(|runtime| runtime.available())
769 .map(|runtime| runtime.runtime().name().to_string())
770 .collect::<Vec<_>>();
771 available_runtimes.sort();
772 let device = RecommendationDevice {
773 os: hardware.os().into(),
774 architecture: hardware.architecture().into(),
775 logical_cpus: hardware.logical_cpus(),
776 requested_accelerator: options.accelerator.name().into(),
777 available_runtimes,
778 };
779 let calibration = if let Some(runs) = options.calibration_runs {
780 let temporary_bytes = CALIBRATION_SCRATCH_BYTES
781 .checked_add(analysis_scratch_bytes(1)?)
782 .ok_or_else(|| "recommendation calibration scratch byte count overflows".to_string())?;
783 DecodeBudget::new(options.decode_limits).check_planar_frames(
784 1,
785 CALIBRATION_FRAMES,
786 temporary_bytes,
787 "recommendation device calibration",
788 )?;
789 Some(run_device_calibration(runs)?)
790 } else {
791 None
792 };
793 let preset = recommended_preset(input.material, options.goal);
794 let mode = recommended_mode(input.material);
795 let mut denoiser = preset.config(input.sample_rate);
796 ProcessingMode::parse(mode)
797 .expect("recommended processing mode must parse")
798 .apply(&mut denoiser);
799 let mut candidates =
800 build_candidates(&input, preset, options, calibration.as_ref(), &hardware)?;
801 candidates.sort_by(candidate_order);
802 let selected = candidates
803 .iter()
804 .find(|candidate| candidate.eligible)
805 .ok_or_else(|| {
806 "no compiled backend satisfies the requested recommendation constraints".to_string()
807 })?;
808 let accelerator = selected
809 .effective_accelerator
810 .as_deref()
811 .unwrap_or("cpu")
812 .to_string();
813 let mut arguments = vec![
814 "--backend".into(),
815 selected.backend.clone(),
816 "--preset".into(),
817 selected.preset.clone(),
818 "--mode".into(),
819 mode.into(),
820 "--strength".into(),
821 denoiser.strength.to_string(),
822 "--accelerator".into(),
823 accelerator.clone(),
824 ];
825 if options.deterministic {
826 arguments.push("--deterministic".into());
827 }
828 let decision = RecommendationDecision {
829 backend: selected.backend.clone(),
830 preset: selected.preset.clone(),
831 processing_mode: mode.into(),
832 strength: denoiser.strength,
833 adaptive_noise: denoiser.adaptive_noise,
834 vad: denoiser.vad,
835 accelerator,
836 model: selected.model.clone(),
837 arguments,
838 };
839 Ok(RecommendationReport {
840 schema: RECOMMENDATION_SCHEMA.into(),
841 schema_version: RECOMMENDATION_SCHEMA_VERSION,
842 denoize_version: env!("CARGO_PKG_VERSION").into(),
843 network_accessed: false,
844 goal: options.goal,
845 input,
846 device,
847 calibration,
848 decision,
849 candidates,
850 })
851}
852
853fn build_candidates(
854 input: &RecommendationInput,
855 preset: Preset,
856 options: RecommendationOptions,
857 calibration: Option<&CalibrationEvidence>,
858 hardware: &HardwareCapabilities,
859) -> Result<Vec<RecommendationCandidate>, String> {
860 let managed_catalog = Backend::available_names()
861 .contains(&"gtcrn")
862 .then(crate::models::embedded_catalog);
863 let mut candidates = Vec::new();
864 for &name in Backend::available_names() {
865 let backend = Backend::parse(name).expect("available backend name must parse");
866 let traits = backend_traits(name);
867 let mut reasons = vec![reason(
868 "compiled",
869 0,
870 "backend is compiled into this binary",
871 )];
872 let mut backend_options = BackendOptions {
873 accelerator: options.accelerator,
874 deterministic: options.deterministic,
875 ..BackendOptions::default()
876 };
877 let mut model_name = None;
878 let mut eligible = true;
879 let mut model_size = 0_u64;
880 if requires_external_model(backend) {
881 eligible = false;
882 reasons.push(reason(
883 "explicit-model-required",
884 -100,
885 "backend requires a caller-supplied model path, which recommendation reports intentionally do not serialize",
886 ));
887 } else if name == "gtcrn" {
888 let catalog = managed_catalog
889 .as_ref()
890 .expect("GTCRN availability created an embedded catalog");
891 match catalog.find(name) {
892 Some(model) => {
893 model_name = Some(model.name().to_string());
894 model_size = model.size_bytes();
895 match crate::models::verify_catalog_model_read_only(model) {
896 Ok(path) => {
897 backend_options.onnx = Some(OnnxModelConfig {
898 path,
899 sample_rate: model.sample_rate(),
900 });
901 reasons.push(reason(
902 "verified-model",
903 5,
904 format!("verified managed model {} is installed", model.name()),
905 ));
906 }
907 Err(_) => {
908 eligible = false;
909 reasons.push(reason(
910 "model-unavailable",
911 -100,
912 format!(
913 "managed model {} is not installed or failed read-only integrity verification; run denoize models doctor",
914 model.name()
915 ),
916 ));
917 }
918 }
919 }
920 None => {
921 eligible = false;
922 reasons.push(reason(
923 "model-unavailable",
924 -100,
925 "no unambiguous managed model is present in the embedded signed catalog",
926 ));
927 }
928 }
929 }
930
931 let mut effective_accelerator = None;
932 let mut effective_runtime = None;
933 let mut accelerator_fallback = None;
934 if eligible {
935 if let Err(error) = backend_options.validate_resolved_resources(backend) {
936 eligible = false;
937 reasons.push(reason("invalid-backend-options", -100, error));
938 }
939 }
940 if eligible {
941 match select_accelerator_from_capabilities(
942 backend,
943 backend_options.accelerator,
944 backend_options.deterministic,
945 hardware,
946 ) {
947 Ok(selection) => {
948 effective_runtime = Some(selection.effective());
949 effective_accelerator = Some(selection.effective().name().to_string());
950 accelerator_fallback =
951 selection.fallback().map(|value| value.name().to_string());
952 reasons.push(reason(
953 "runtime",
954 i16::from(selection.effective() != crate::AcceleratorRuntime::Cpu) * 4,
955 format!(
956 "{} resolves to {}{}",
957 selection.requested().name(),
958 selection.effective().name(),
959 selection
960 .fallback()
961 .map(|fallback| format!(" ({})", fallback.name()))
962 .unwrap_or_default()
963 ),
964 ));
965 }
966 Err(error) => {
967 eligible = false;
968 reasons.push(reason("runtime-unavailable", -100, error));
969 }
970 }
971 }
972
973 let estimated_memory = if name == "classical" {
974 Some(0)
975 } else if requires_external_model(backend) {
976 None
977 } else {
978 crate::estimate_model_session_bytes(model_size).ok()
979 };
980 if let (Some(limit), Some(estimate)) = (
981 options.decode_limits.max_working_set_bytes,
982 estimated_memory,
983 ) {
984 if estimate > limit {
985 eligible = false;
986 reasons.push(reason(
987 "memory-limit",
988 -100,
989 format!(
990 "estimated model/runtime reservation {estimate} bytes exceeds the {limit}-byte limit"
991 ),
992 ));
993 }
994 }
995
996 let mut estimated_gpu_memory = None;
997 if effective_runtime.is_some_and(|runtime| runtime != AcceleratorRuntime::Cpu) {
998 match crate::estimate_gpu_session_bytes(model_size) {
999 Ok(estimate) => estimated_gpu_memory = Some(estimate),
1000 Err(error) => {
1001 eligible = false;
1002 reasons.push(reason("gpu-memory-estimate", -100, error));
1003 }
1004 }
1005 }
1006 if let Some(estimate) = estimated_gpu_memory {
1007 let runtime = effective_runtime.expect("GPU estimate requires an effective runtime");
1008 let device_memory_bytes = hardware
1009 .runtimes()
1010 .iter()
1011 .find(|capability| capability.runtime() == runtime)
1012 .and_then(|capability| capability.memory_bytes());
1013 apply_gpu_memory_constraints(
1014 estimate,
1015 options.max_gpu_memory_bytes,
1016 device_memory_bytes,
1017 &mut eligible,
1018 &mut reasons,
1019 );
1020 reasons.push(reason(
1021 "runtime-read-only-probe",
1022 0,
1023 "recommendation does not create or test a runtime cache; processing revalidates cache writability before model preparation",
1024 ));
1025 }
1026
1027 let quality = material_quality(traits, input.material);
1028 let scored_memory =
1029 estimated_memory.map(|bytes| bytes.saturating_add(estimated_gpu_memory.unwrap_or(0)));
1030 let memory_score = scored_memory.map_or(40, |bytes| {
1031 100_i32.saturating_sub((bytes / (16 * 1024 * 1024)).min(80) as i32)
1032 });
1033 let (quality_weight, speed_weight, memory_weight) = goal_weights(options.goal);
1034 let mut score =
1035 (quality * quality_weight + traits.speed * speed_weight + memory_score * memory_weight)
1036 / 100;
1037 let material_adjustment = material_adjustment(name, input.material);
1038 score += material_adjustment;
1039 reasons.push(reason(
1040 "material-fit",
1041 material_adjustment as i16,
1042 format!(
1043 "{} material contributes quality score {quality}",
1044 input.material.name()
1045 ),
1046 ));
1047 let calibrated_headroom = calibration
1048 .map(|evidence| evidence.baseline_realtime_headroom / f64::from(traits.cost_units));
1049 if let Some(headroom) = calibrated_headroom {
1050 let impact = if headroom < 1.0 {
1051 -35
1052 } else if headroom < 1.5 {
1053 -18
1054 } else if headroom >= 8.0 {
1055 5
1056 } else {
1057 0
1058 };
1059 score += impact;
1060 reasons.push(reason(
1061 "calibrated-headroom",
1062 impact as i16,
1063 format!(
1064 "fixed device calibration estimates {:.3}x heuristic realtime headroom for cost class {}",
1065 headroom, traits.cost_units
1066 ),
1067 ));
1068 } else {
1069 reasons.push(reason(
1070 "uncalibrated",
1071 0,
1072 "candidate uses static cost class because on-device calibration was not requested",
1073 ));
1074 }
1075 if !eligible {
1076 score = 0;
1077 }
1078 candidates.push(RecommendationCandidate {
1079 backend: name.into(),
1080 preset: preset_name(preset).into(),
1081 model: model_name,
1082 eligible,
1083 score: score.clamp(0, 100) as u16,
1084 requested_accelerator: options.accelerator.name().into(),
1085 effective_accelerator,
1086 accelerator_fallback,
1087 estimated_memory_bytes: estimated_memory,
1088 estimated_gpu_memory_bytes: estimated_gpu_memory,
1089 calibrated_realtime_headroom: calibrated_headroom.map(round_metric),
1090 reasons,
1091 });
1092 }
1093 Ok(candidates)
1094}
1095
1096fn apply_gpu_memory_constraints(
1097 estimate: u64,
1098 configured_limit: Option<u64>,
1099 device_limit: Option<u64>,
1100 eligible: &mut bool,
1101 reasons: &mut Vec<RecommendationReason>,
1102) {
1103 if let Some(limit) = configured_limit {
1104 if estimate > limit {
1105 *eligible = false;
1106 reasons.push(reason(
1107 "gpu-memory-limit",
1108 -100,
1109 format!(
1110 "estimated GPU session reservation {estimate} bytes exceeds the configured {limit}-byte GPU limit"
1111 ),
1112 ));
1113 }
1114 }
1115 match device_limit {
1116 Some(available) if estimate > available => {
1117 *eligible = false;
1118 reasons.push(reason(
1119 "device-gpu-memory",
1120 -100,
1121 format!(
1122 "estimated GPU session reservation {estimate} bytes exceeds the device-reported {available}-byte limit"
1123 ),
1124 ));
1125 }
1126 Some(available) => reasons.push(reason(
1127 "gpu-memory-fit",
1128 0,
1129 format!(
1130 "estimated GPU session reservation {estimate} bytes fits the device-reported {available}-byte limit"
1131 ),
1132 )),
1133 None => reasons.push(reason(
1134 "gpu-memory-unreported",
1135 0,
1136 "the runtime did not report a GPU memory limit; processing admission will revalidate configured limits",
1137 )),
1138 }
1139}
1140
1141#[derive(Clone, Copy)]
1142struct BackendTraits {
1143 speech_quality: i32,
1144 music_quality: i32,
1145 mixed_quality: i32,
1146 quiet_quality: i32,
1147 speed: i32,
1148 cost_units: u16,
1149}
1150
1151fn backend_traits(name: &str) -> BackendTraits {
1152 match name {
1153 "classical" => traits(62, 82, 78, 92, 96, 1),
1154 "rnnoise" => traits(78, 48, 62, 55, 92, 2),
1155 "deepfilter" => traits(94, 68, 82, 64, 68, 10),
1156 "gtcrn" => traits(91, 58, 76, 60, 76, 7),
1157 "mpsenet" => traits(96, 58, 78, 58, 38, 28),
1158 "bsrnn" => traits(95, 67, 83, 60, 52, 18),
1159 "mossformer2" => traits(97, 62, 82, 58, 30, 45),
1160 "sgmse" => traits(99, 72, 88, 62, 8, 180),
1161 "onnx" => traits(55, 55, 55, 50, 45, 24),
1162 _ => traits(50, 50, 50, 50, 40, 30),
1163 }
1164}
1165
1166const fn traits(
1167 speech_quality: i32,
1168 music_quality: i32,
1169 mixed_quality: i32,
1170 quiet_quality: i32,
1171 speed: i32,
1172 cost_units: u16,
1173) -> BackendTraits {
1174 BackendTraits {
1175 speech_quality,
1176 music_quality,
1177 mixed_quality,
1178 quiet_quality,
1179 speed,
1180 cost_units,
1181 }
1182}
1183
1184const fn material_quality(traits: BackendTraits, material: RecommendationMaterial) -> i32 {
1185 match material {
1186 RecommendationMaterial::Speech => traits.speech_quality,
1187 RecommendationMaterial::Music => traits.music_quality,
1188 RecommendationMaterial::Mixed => traits.mixed_quality,
1189 RecommendationMaterial::Quiet => traits.quiet_quality,
1190 }
1191}
1192
1193const fn goal_weights(goal: RecommendationGoal) -> (i32, i32, i32) {
1194 match goal {
1195 RecommendationGoal::Balanced => (55, 35, 10),
1196 RecommendationGoal::Quality => (78, 12, 10),
1197 RecommendationGoal::Speed => (25, 65, 10),
1198 RecommendationGoal::LowMemory => (25, 15, 60),
1199 }
1200}
1201
1202fn material_adjustment(name: &str, material: RecommendationMaterial) -> i32 {
1203 match (name, material) {
1204 ("classical", RecommendationMaterial::Music | RecommendationMaterial::Quiet) => 8,
1205 (
1206 "rnnoise" | "deepfilter" | "gtcrn" | "mpsenet" | "bsrnn" | "mossformer2" | "sgmse",
1207 RecommendationMaterial::Speech,
1208 ) => 8,
1209 ("rnnoise" | "gtcrn" | "mpsenet" | "mossformer2", RecommendationMaterial::Music) => -12,
1210 ("sgmse", RecommendationMaterial::Quiet) => -10,
1211 _ => 0,
1212 }
1213}
1214
1215fn recommended_preset(material: RecommendationMaterial, goal: RecommendationGoal) -> Preset {
1216 match (material, goal) {
1217 (RecommendationMaterial::Speech, RecommendationGoal::Speed) => Preset::Gentle,
1218 (RecommendationMaterial::Speech, _) => Preset::Speech,
1219 (RecommendationMaterial::Music, RecommendationGoal::Quality) => Preset::HiFi,
1220 (RecommendationMaterial::Music, _) => Preset::Music,
1221 (RecommendationMaterial::Quiet, _) => Preset::Restore,
1222 (RecommendationMaterial::Mixed, RecommendationGoal::Quality) => Preset::HiFi,
1223 (RecommendationMaterial::Mixed, _) => Preset::Gentle,
1224 }
1225}
1226
1227const fn recommended_mode(material: RecommendationMaterial) -> &'static str {
1228 match material {
1229 RecommendationMaterial::Speech => "speech",
1230 RecommendationMaterial::Music => "music",
1231 RecommendationMaterial::Mixed | RecommendationMaterial::Quiet => "ambient",
1232 }
1233}
1234
1235const fn preset_name(preset: Preset) -> &'static str {
1236 match preset {
1237 Preset::Speech => "speech",
1238 Preset::Music => "music",
1239 Preset::Aggressive => "aggressive",
1240 Preset::Gentle => "gentle",
1241 Preset::Restore => "restore",
1242 Preset::HiFi => "hifi",
1243 }
1244}
1245
1246fn reason(code: &str, impact: i16, detail: impl Into<String>) -> RecommendationReason {
1247 RecommendationReason {
1248 code: code.into(),
1249 impact,
1250 detail: detail.into(),
1251 }
1252}
1253
1254fn candidate_order(left: &RecommendationCandidate, right: &RecommendationCandidate) -> Ordering {
1255 right
1256 .eligible
1257 .cmp(&left.eligible)
1258 .then_with(|| right.score.cmp(&left.score))
1259 .then_with(|| left.backend.cmp(&right.backend))
1260}
1261
1262pub fn run_device_calibration(runs: u8) -> Result<CalibrationEvidence, String> {
1264 if !(1..=MAX_CALIBRATION_RUNS).contains(&runs) {
1265 return Err(format!(
1266 "recommendation calibration runs must be between 1 and {MAX_CALIBRATION_RUNS}"
1267 ));
1268 }
1269 let (fixture, fixture_sha256) = calibration_fixture();
1270 let config = Preset::HiFi.config(CALIBRATION_SAMPLE_RATE);
1271 let _ = crate::backend::process_classical(&fixture, &config);
1272 let mut elapsed_ms = Vec::with_capacity(runs as usize);
1273 for _ in 0..runs {
1274 let started = Instant::now();
1275 let output = crate::backend::process_classical(&fixture, &config);
1276 std::hint::black_box(output);
1277 let elapsed = started.elapsed().as_secs_f64().max(1e-9);
1278 elapsed_ms.push(round_metric(elapsed * 1_000.0));
1279 }
1280 let mut ordered = elapsed_ms.clone();
1281 ordered.sort_by(f64::total_cmp);
1282 let median_elapsed_ms = ordered[ordered.len() / 2];
1283 let fixture_seconds = CALIBRATION_FRAMES as f64 / f64::from(CALIBRATION_SAMPLE_RATE);
1284 let baseline_realtime_headroom = fixture_seconds / (median_elapsed_ms / 1_000.0).max(1e-9);
1285 Ok(CalibrationEvidence {
1286 workload: CALIBRATION_WORKLOAD.into(),
1287 fixture_sha256,
1288 sample_rate: CALIBRATION_SAMPLE_RATE,
1289 channels: 1,
1290 frames: CALIBRATION_FRAMES,
1291 warmup_runs: 1,
1292 measured_runs: runs,
1293 elapsed_ms,
1294 median_elapsed_ms,
1295 baseline_realtime_headroom: round_metric(baseline_realtime_headroom),
1296 })
1297}
1298
1299fn calibration_fixture() -> (Vec<Vec<f64>>, String) {
1300 let mut state = 0x6a09_e667_f3bc_c909_u64;
1301 let mut channel = Vec::with_capacity(CALIBRATION_FRAMES);
1302 let mut hash = Sha256::new();
1303 hash.update(CALIBRATION_DOMAIN);
1304 hash.update(CALIBRATION_SAMPLE_RATE.to_le_bytes());
1305 hash.update((CALIBRATION_FRAMES as u64).to_le_bytes());
1306 for frame in 0..CALIBRATION_FRAMES {
1307 state = splitmix64(state);
1308 let phase = (frame % 512) as i32;
1311 let triangle = (if phase < 256 { phase } else { 511 - phase }) * 256 - 32_640;
1312 let noise = i32::from((state >> 48) as u16) - 32_768;
1313 let envelope = if frame % 9_600 < 7_200 { 3 } else { 1 };
1314 let fixed_sample = triangle * envelope + noise / 8;
1315 let sample = f64::from(fixed_sample) / 131_072.0;
1316 hash.update(sample.to_bits().to_le_bytes());
1317 channel.push(sample);
1318 }
1319 (vec![channel], format!("{:x}", hash.finalize()))
1320}
1321
1322const fn splitmix64(mut value: u64) -> u64 {
1323 value = value.wrapping_add(0x9e37_79b9_7f4a_7c15);
1324 value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1325 value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
1326 value ^ (value >> 31)
1327}
1328
1329const fn format_name(format: AudioFormat) -> &'static str {
1330 match format {
1331 AudioFormat::Wav => "wav",
1332 AudioFormat::Rf64 => "rf64",
1333 AudioFormat::Aiff => "aiff",
1334 AudioFormat::Caf => "caf",
1335 AudioFormat::Flac => "flac",
1336 AudioFormat::OggOpus => "ogg-opus",
1337 AudioFormat::OggVorbis => "ogg-vorbis",
1338 AudioFormat::Mp3 => "mp3",
1339 AudioFormat::M4a => "m4a",
1340 AudioFormat::AacAdts => "aac-adts",
1341 AudioFormat::Unknown => "unknown",
1342 }
1343}
1344
1345const fn codec_name(codec: AudioCodec) -> &'static str {
1346 match codec {
1347 AudioCodec::Pcm => "pcm",
1348 AudioCodec::Flac => "flac",
1349 AudioCodec::Opus => "opus",
1350 AudioCodec::Vorbis => "vorbis",
1351 AudioCodec::Mp3 => "mp3",
1352 AudioCodec::Aac => "aac",
1353 AudioCodec::Alac => "alac",
1354 AudioCodec::Unknown => "unknown",
1355 }
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360 use super::*;
1361 use hound::SampleFormat;
1362
1363 fn speech_like() -> Audio {
1364 let frames = 48_000;
1365 let channel = (0..frames)
1366 .map(|frame| {
1367 let time = frame as f64 / 48_000.0;
1368 let envelope = if frame % 9_600 < 7_200 { 1.0 } else { 0.03 };
1369 ((std::f64::consts::TAU * 155.0 * time).sin() * 0.24
1370 + (std::f64::consts::TAU * 2_100.0 * time).sin() * 0.04)
1371 * envelope
1372 })
1373 .collect();
1374 Audio {
1375 sample_rate: 48_000,
1376 channels: vec![channel],
1377 bits_per_sample: 32,
1378 sample_format: SampleFormat::Float,
1379 channel_mask: None,
1380 }
1381 }
1382
1383 fn music_like() -> Audio {
1384 let frames = 48_000;
1385 let left = (0..frames)
1386 .map(|frame| {
1387 let time = frame as f64 / 48_000.0;
1388 (std::f64::consts::TAU * 220.0 * time).sin() * 0.35
1389 + (std::f64::consts::TAU * 440.0 * time).sin() * 0.15
1390 })
1391 .collect();
1392 let right = (0..frames)
1393 .map(|frame| {
1394 let time = frame as f64 / 48_000.0;
1395 (std::f64::consts::TAU * 277.0 * time).sin() * 0.31
1396 + (std::f64::consts::TAU * 554.0 * time).sin() * 0.13
1397 })
1398 .collect();
1399 Audio {
1400 sample_rate: 48_000,
1401 channels: vec![left, right],
1402 bits_per_sample: 32,
1403 sample_format: SampleFormat::Float,
1404 channel_mask: None,
1405 }
1406 }
1407
1408 #[test]
1409 fn options_validate_bounded_analysis_and_calibration() {
1410 assert!(RecommendationOptions::new()
1411 .with_analysis_seconds(0)
1412 .validate()
1413 .is_err());
1414 assert!(RecommendationOptions::new()
1415 .with_analysis_seconds(MAX_ANALYSIS_SECONDS + 1)
1416 .validate()
1417 .is_err());
1418 assert!(RecommendationOptions::new()
1419 .with_calibration_runs(Some(0))
1420 .validate()
1421 .is_err());
1422 assert!(RecommendationOptions::new()
1423 .with_calibration_runs(Some(MAX_CALIBRATION_RUNS + 1))
1424 .validate()
1425 .is_err());
1426 }
1427
1428 #[test]
1429 fn decoded_audio_report_is_stable_and_network_free() {
1430 let report = recommend_audio(
1431 &speech_like(),
1432 RecommendationOptions::new().with_accelerator(AcceleratorPreference::Cpu),
1433 )
1434 .expect("recommend speech");
1435 assert_eq!(report.schema, RECOMMENDATION_SCHEMA);
1436 assert!(!report.network_accessed);
1437 assert_eq!(report.input.analysis_mode, "decoded-audio");
1438 assert_eq!(report.input.analysis_sha256.len(), 64);
1439 assert!(!report.candidates.is_empty());
1440 assert!(report.candidates[0].eligible);
1441 assert!(report
1442 .candidates
1443 .iter()
1444 .filter(|candidate| candidate.effective_accelerator.as_deref() == Some("cpu"))
1445 .all(|candidate| candidate.estimated_gpu_memory_bytes.is_none()));
1446 assert_eq!(report.decision.backend, report.candidates[0].backend);
1447 let mut effective = Preset::parse(&report.decision.preset)
1448 .expect("decision preset parses")
1449 .config(report.input.sample_rate);
1450 ProcessingMode::parse(&report.decision.processing_mode)
1451 .expect("decision mode parses")
1452 .apply(&mut effective);
1453 assert_eq!(report.decision.strength, effective.strength);
1454 assert_eq!(report.decision.adaptive_noise, effective.adaptive_noise);
1455 assert_eq!(report.decision.vad, effective.vad);
1456 let expected_strength = effective.strength.to_string();
1457 assert!(report
1458 .decision
1459 .arguments
1460 .windows(2)
1461 .any(|pair| pair[0] == "--strength" && pair[1] == expected_strength));
1462 assert!(report.candidates.iter().all(|candidate| {
1463 !candidate.eligible
1464 || !requires_external_model(
1465 Backend::parse(&candidate.backend).expect("reported backend parses"),
1466 )
1467 }));
1468 assert!(report
1469 .to_json()
1470 .expect("serialize")
1471 .contains(RECOMMENDATION_SCHEMA));
1472 }
1473
1474 #[test]
1475 fn signal_hash_and_metrics_are_deterministic() {
1476 let options = RecommendationOptions::new().with_accelerator(AcceleratorPreference::Cpu);
1477 let first = recommend_audio(&music_like(), options).expect("first report");
1478 let second = recommend_audio(&music_like(), options).expect("second report");
1479 assert_eq!(first.input, second.input);
1480 assert_eq!(first.decision, second.decision);
1481 assert_eq!(first.input.material, RecommendationMaterial::Music);
1482 assert!(first.input.stereo_correlation.is_some());
1483 }
1484
1485 #[test]
1486 fn mp3_file_recommendation_uses_the_bounded_stream_reader() {
1487 let directory = tempfile::tempdir().expect("create recommendation directory");
1488 let path = directory.path().join("input.mp3");
1489 let audio = speech_like();
1490 crate::encode::write_audio(&path, &audio, crate::EncodeOptions::default())
1491 .expect("encode recommendation MP3");
1492
1493 let report = recommend_file_with_options(
1494 &path,
1495 RecommendationOptions::new().with_accelerator(AcceleratorPreference::Cpu),
1496 )
1497 .expect("recommend MP3");
1498 assert_eq!(report.input.format, "mp3");
1499 assert_eq!(report.input.codec, "mp3");
1500 assert_eq!(report.input.analysis_mode, "bounded-stream");
1501 assert_eq!(report.input.sample_rate, audio.sample_rate);
1502 assert_eq!(report.input.channels, audio.channels());
1503 assert!(report.input.analyzed_frames > 0);
1504 }
1505
1506 #[test]
1507 fn opus_file_recommendation_uses_the_granule_aware_stream_reader() {
1508 let directory = tempfile::tempdir().expect("create recommendation directory");
1509 let path = directory.path().join("input.opus");
1510 let audio = speech_like();
1511 crate::encode::write_audio(&path, &audio, crate::EncodeOptions::default())
1512 .expect("encode recommendation Opus");
1513
1514 let report = recommend_file_with_options(
1515 &path,
1516 RecommendationOptions::new().with_accelerator(AcceleratorPreference::Cpu),
1517 )
1518 .expect("recommend Opus");
1519 assert_eq!(report.input.format, "ogg-opus");
1520 assert_eq!(report.input.codec, "opus");
1521 assert_eq!(report.input.analysis_mode, "bounded-stream");
1522 assert_eq!(report.input.sample_rate, 48_000);
1523 assert_eq!(report.input.channels, audio.channels());
1524 assert!(report.input.analyzed_frames > 0);
1525 }
1526
1527 #[test]
1528 fn adts_aac_file_recommendation_uses_the_frame_aware_stream_reader() {
1529 const SILENT_STEREO_ADTS: [u8; 13] = [
1530 0xff, 0xf1, 0x50, 0x80, 0x01, 0xbf, 0xfc, 0x21, 0x00, 0x00, 0x00, 0x00, 0x1c,
1531 ];
1532 let directory = tempfile::tempdir().expect("create recommendation directory");
1533 let path = directory.path().join("input.aac");
1534 std::fs::write(&path, SILENT_STEREO_ADTS.repeat(3)).expect("write recommendation ADTS AAC");
1535
1536 let report = recommend_file_with_options(
1537 &path,
1538 RecommendationOptions::new().with_accelerator(AcceleratorPreference::Cpu),
1539 )
1540 .expect("recommend ADTS AAC");
1541 assert_eq!(report.input.format, "aac-adts");
1542 assert_eq!(report.input.codec, "aac");
1543 assert_eq!(report.input.analysis_mode, "bounded-stream");
1544 assert_eq!(report.input.sample_rate, 44_100);
1545 assert_eq!(report.input.channels, 2);
1546 assert_eq!(report.input.analyzed_frames, 3 * 1_024);
1547 }
1548
1549 #[test]
1550 fn low_memory_goal_keeps_a_runnable_fallback() {
1551 let limits = DecodeLimits::default().with_max_working_set_bytes(Some(2 * 1024 * 1024));
1552 let report = recommend_audio(
1553 &speech_like(),
1554 RecommendationOptions::new()
1555 .with_goal(RecommendationGoal::LowMemory)
1556 .with_decode_limits(limits)
1557 .with_accelerator(AcceleratorPreference::Cpu),
1558 )
1559 .expect("low-memory recommendation");
1560 assert_eq!(report.decision.backend, "classical");
1561 assert!(report
1562 .candidates
1563 .iter()
1564 .filter(|candidate| candidate.backend != "classical")
1565 .all(|candidate| !candidate.eligible));
1566 }
1567
1568 #[test]
1569 fn fixed_calibration_fixture_has_stable_identity() {
1570 let (first, first_hash) = calibration_fixture();
1571 let (second, second_hash) = calibration_fixture();
1572 assert_eq!(first_hash, second_hash);
1573 assert_eq!(first, second);
1574 assert_eq!(first_hash, CALIBRATION_FIXTURE_SHA256);
1575 }
1576
1577 #[test]
1578 fn calibration_produces_finite_positive_evidence() {
1579 let evidence = run_device_calibration(1).expect("calibrate");
1580 assert_eq!(evidence.workload, CALIBRATION_WORKLOAD);
1581 assert_eq!(evidence.measured_runs, 1);
1582 assert!(evidence.median_elapsed_ms > 0.0);
1583 assert!(evidence.baseline_realtime_headroom > 0.0);
1584 assert!(evidence.median_elapsed_ms.is_finite());
1585 assert!(evidence.baseline_realtime_headroom.is_finite());
1586 }
1587
1588 #[test]
1589 fn calibration_respects_the_decode_working_set_before_running() {
1590 let audio = Audio {
1591 sample_rate: 48_000,
1592 channels: vec![vec![0.0]],
1593 bits_per_sample: 32,
1594 sample_format: SampleFormat::Float,
1595 channel_mask: None,
1596 };
1597 let limits = DecodeLimits::default().with_max_working_set_bytes(Some(1024 * 1024));
1598 let error = recommend_audio(
1599 &audio,
1600 RecommendationOptions::new()
1601 .with_calibration(true)
1602 .with_decode_limits(limits),
1603 )
1604 .unwrap_err();
1605 assert!(
1606 error.contains("recommendation device calibration"),
1607 "{error}"
1608 );
1609 }
1610
1611 #[test]
1612 fn malformed_audio_is_rejected_before_candidate_discovery() {
1613 let mut audio = speech_like();
1614 audio.channels.push(vec![0.0; 7]);
1615 let error = recommend_audio(&audio, RecommendationOptions::new()).unwrap_err();
1616 assert!(error.contains("channel 1"));
1617 }
1618
1619 #[test]
1620 fn gpu_memory_constraints_are_inclusive_and_explain_failures() {
1621 let mut eligible = true;
1622 let mut reasons = Vec::new();
1623 apply_gpu_memory_constraints(128, Some(128), Some(128), &mut eligible, &mut reasons);
1624 assert!(eligible);
1625 assert!(reasons.iter().any(|reason| reason.code == "gpu-memory-fit"));
1626
1627 let mut eligible = true;
1628 let mut reasons = Vec::new();
1629 apply_gpu_memory_constraints(128, Some(127), Some(127), &mut eligible, &mut reasons);
1630 assert!(!eligible);
1631 assert!(reasons
1632 .iter()
1633 .any(|reason| reason.code == "gpu-memory-limit"));
1634 assert!(reasons
1635 .iter()
1636 .any(|reason| reason.code == "device-gpu-memory"));
1637
1638 let mut eligible = true;
1639 let mut reasons = Vec::new();
1640 apply_gpu_memory_constraints(128, None, None, &mut eligible, &mut reasons);
1641 assert!(eligible);
1642 assert!(reasons
1643 .iter()
1644 .any(|reason| reason.code == "gpu-memory-unreported"));
1645 }
1646
1647 #[test]
1648 fn candidate_sort_is_deterministic() {
1649 let mut candidates = vec![
1650 RecommendationCandidate {
1651 backend: "z".into(),
1652 preset: "hifi".into(),
1653 model: None,
1654 eligible: true,
1655 score: 50,
1656 requested_accelerator: "cpu".into(),
1657 effective_accelerator: Some("cpu".into()),
1658 accelerator_fallback: None,
1659 estimated_memory_bytes: Some(0),
1660 estimated_gpu_memory_bytes: None,
1661 calibrated_realtime_headroom: None,
1662 reasons: vec![],
1663 },
1664 RecommendationCandidate {
1665 backend: "a".into(),
1666 preset: "hifi".into(),
1667 model: None,
1668 eligible: true,
1669 score: 50,
1670 requested_accelerator: "cpu".into(),
1671 effective_accelerator: Some("cpu".into()),
1672 accelerator_fallback: None,
1673 estimated_memory_bytes: Some(0),
1674 estimated_gpu_memory_bytes: None,
1675 calibrated_realtime_headroom: None,
1676 reasons: vec![],
1677 },
1678 ];
1679 candidates.sort_by(candidate_order);
1680 assert_eq!(candidates[0].backend, "a");
1681 }
1682}