Skip to main content

denoize/
recommendation.rs

1//! Network-free, explainable processing recommendations.
2//!
3//! Recommendations combine a bounded signal summary, the locally compiled
4//! backends, read-only verification of models in the embedded signed catalog,
5//! detected compute runtimes, configured resource ceilings, and optional
6//! on-device calibration. No catalog/cache mutation, model download, or remote
7//! service is used by this module.
8
9use 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
25/// Stable identifier embedded in recommendation reports.
26pub const RECOMMENDATION_SCHEMA: &str = "denoize-recommendation-v1";
27/// Current recommendation report schema version.
28pub 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/// Optimization intent used to rank otherwise runnable candidates.
46#[non_exhaustive]
47#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
48#[serde(rename_all = "kebab-case")]
49pub enum RecommendationGoal {
50    /// Balance expected restoration quality, latency, and retained memory.
51    #[default]
52    Balanced,
53    /// Prefer the strongest suitable locally runnable backend.
54    Quality,
55    /// Prefer low latency and high realtime headroom.
56    Speed,
57    /// Prefer the smallest denoize-owned model/runtime reservation.
58    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/// Coarse material class inferred from a bounded signal prefix.
85#[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/// Configuration for one recommendation operation.
108#[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    /// Enable calibration with the given number of measured runs.
153    #[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    /// Enable or disable the fixed on-device calibration workload.
160    #[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    /// Limit the conservative GPU-side session reservation used for admission.
177    #[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    /// Validate option-only bounds without opening an input or model.
231    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/// Deterministic measurements derived from a bounded input prefix.
250#[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/// Network-free device facts used by the decision.
273#[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/// Reproducible evidence from the fixed local calibration workload.
284#[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/// One stable explanation attached to a candidate score or exclusion.
300#[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/// One compiled backend considered by the recommendation engine.
309#[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/// Effective settings selected from the candidate list.
327#[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/// Stable, explainable report returned by file and decoded-audio entry points.
342#[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
369/// Recommend runnable settings for a regular-file input with default options.
370pub fn recommend_file(path: impl AsRef<Path>) -> Result<RecommendationReport, String> {
371    recommend_file_with_options(path, RecommendationOptions::default())
372}
373
374/// Recommend runnable settings without updating a catalog/model cache or
375/// downloading a model.
376pub 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
386/// Recommend settings for already-decoded audio.
387///
388/// When a working-set ceiling is supplied, the caller-owned channel capacities
389/// and recommendation analysis state must fit it before analysis begins.
390pub 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
1262/// Run the fixed, network-free device calibration workload.
1263pub 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        // Integer-only synthesis plus an exact power-of-two conversion keeps
1309        // the fixture bytes stable across libm implementations and targets.
1310        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}