Skip to main content

denoize/
target_speaker.rs

1//! Fail-closed offline target-speaker extraction and promotion evidence.
2//!
3//! A target-speaker candidate is never silently treated as ordinary denoising
4//! output. The signed package must expose mixture, enrollment, extracted-audio,
5//! and calibrated three-state presence tensors through the dedicated adapter.
6//! Separately signed evaluation evidence must bind the exact package and pass
7//! protected target-present and target-absent strata. At runtime, audio is
8//! published only for a confidently present target whose candidate also passes
9//! bounded signal-safety checks.
10
11use crate::audio::{estimate_audio_memory_bytes, Audio};
12use crate::execution::{ReceiptPublicKey, ReceiptSecretKey, ReceiptSignature};
13#[cfg(feature = "onnx")]
14use crate::{
15    AcceleratorPreference, AcceleratorSelection, Backend, BackendOptions, RuntimeModelPackage,
16};
17use serde::{Deserialize, Serialize};
18#[cfg(feature = "onnx")]
19use sha2::{Digest as _, Sha256};
20use std::collections::{BTreeMap, BTreeSet};
21use std::io::Read as _;
22use std::path::Path;
23#[cfg(feature = "onnx")]
24use zeroize::{Zeroize, Zeroizing};
25
26pub const TARGET_SPEAKER_REPORT_SCHEMA: &str = "denoize-target-speaker-report-v1";
27pub const TARGET_SPEAKER_PROMOTION_EVIDENCE_SCHEMA: &str =
28    "denoize-target-speaker-promotion-evidence-v1";
29pub const TARGET_SPEAKER_SCHEMA_VERSION: u32 = 1;
30pub const MAX_TARGET_SPEAKER_EVIDENCE_STRATA: usize = 256;
31pub const MAX_TARGET_SPEAKER_EVIDENCE_METRICS: usize = 64;
32pub const MIN_TARGET_SPEAKER_ENROLLMENT_MILLIS: u64 = 500;
33pub const MAX_TARGET_SPEAKER_ENROLLMENT_MILLIS: u64 = 30_000;
34pub const MAX_TARGET_SPEAKER_MIXTURE_SECONDS: u64 = 3_600;
35
36#[cfg(feature = "onnx")]
37const MAX_CHANNELS: usize = 64;
38const MAX_EVIDENCE_JSON_BYTES: u64 = 16 * 1024 * 1024;
39const PROMOTION_SIGNATURE_DOMAIN: &[u8] = b"denoize-target-speaker-promotion-evidence-v1";
40#[cfg(feature = "onnx")]
41const MIXTURE_PCM_DIGEST_DOMAIN: &[u8] = b"denoize-target-speaker-mixture-pcm-v1\0";
42#[cfg(feature = "onnx")]
43const OUTPUT_PCM_DIGEST_DOMAIN: &[u8] = b"denoize-target-speaker-output-pcm-v1\0";
44#[cfg(feature = "onnx")]
45const SILENCE_FLOOR: f64 = 1e-12;
46
47const REQUIRED_STRATA: &[(&str, TargetSpeakerStratumKind)] = &[
48    ("channel-mismatch", TargetSpeakerStratumKind::TargetPresent),
49    ("child-speaker", TargetSpeakerStratumKind::TargetPresent),
50    ("code-switching", TargetSpeakerStratumKind::TargetPresent),
51    ("codec-enrollment", TargetSpeakerStratumKind::TargetPresent),
52    ("different-sex", TargetSpeakerStratumKind::TargetPresent),
53    ("many-interferers", TargetSpeakerStratumKind::TargetPresent),
54    ("noisy-enrollment", TargetSpeakerStratumKind::TargetPresent),
55    ("one-interferer", TargetSpeakerStratumKind::TargetPresent),
56    (
57        "real-t-conversation",
58        TargetSpeakerStratumKind::TargetPresent,
59    ),
60    (
61        "reverberant-enrollment",
62        TargetSpeakerStratumKind::TargetPresent,
63    ),
64    ("same-sex", TargetSpeakerStratumKind::TargetPresent),
65    ("same-words", TargetSpeakerStratumKind::TargetPresent),
66    ("similar-voices", TargetSpeakerStratumKind::TargetPresent),
67    ("singing", TargetSpeakerStratumKind::TargetPresent),
68    ("speech-absent", TargetSpeakerStratumKind::TargetAbsent),
69    ("target-absent", TargetSpeakerStratumKind::TargetAbsent),
70    (
71        "target-absent-same-words",
72        TargetSpeakerStratumKind::TargetAbsent,
73    ),
74    (
75        "target-absent-similar-interferer",
76        TargetSpeakerStratumKind::TargetAbsent,
77    ),
78    (
79        "target-present-clean",
80        TargetSpeakerStratumKind::TargetPresent,
81    ),
82    ("ts-superb", TargetSpeakerStratumKind::TargetPresent),
83    ("unseen-domain", TargetSpeakerStratumKind::TargetPresent),
84    ("whisper", TargetSpeakerStratumKind::TargetPresent),
85];
86
87const PRESENT_METRICS: &[MetricPolicy] = &[
88    MetricPolicy::at_most("content.target-word-error-rate", 0.35),
89    MetricPolicy::at_least("extraction.si-sdr-improvement-db", 3.0),
90    MetricPolicy::at_most("interferer.speaker-similarity", 0.30),
91    MetricPolicy::at_most("interferer.word-leakage-rate", 0.02),
92    MetricPolicy::at_most("output.duration-error-frames", 0.0),
93    MetricPolicy::at_most("output.non-finite-samples", 0.0),
94    MetricPolicy::at_least("perceptual.dnsmos-p808", 3.0),
95    MetricPolicy::at_least("presence.recall", 0.95),
96    MetricPolicy::at_least("speaker.target-similarity", 0.70),
97];
98
99const ABSENT_METRICS: &[MetricPolicy] = &[
100    MetricPolicy::at_most("interferer.speaker-similarity", 0.30),
101    MetricPolicy::at_most("interferer.word-leakage-rate", 0.01),
102    MetricPolicy::at_most("output.duration-error-frames", 0.0),
103    MetricPolicy::at_most("output.non-finite-samples", 0.0),
104    MetricPolicy::at_most("output.rms-dbfs", -60.0),
105    MetricPolicy::at_most("presence.false-positive-rate", 0.01),
106];
107
108#[derive(Clone, Copy)]
109struct MetricPolicy {
110    name: &'static str,
111    operator: TargetSpeakerMetricOperator,
112    hard_limit: f64,
113}
114
115impl MetricPolicy {
116    const fn at_least(name: &'static str, hard_limit: f64) -> Self {
117        Self {
118            name,
119            operator: TargetSpeakerMetricOperator::GreaterOrEqual,
120            hard_limit,
121        }
122    }
123
124    const fn at_most(name: &'static str, hard_limit: f64) -> Self {
125        Self {
126            name,
127            operator: TargetSpeakerMetricOperator::LessOrEqual,
128            hard_limit,
129        }
130    }
131}
132
133#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
134#[serde(rename_all = "kebab-case")]
135pub enum TargetSpeakerStratumKind {
136    TargetPresent,
137    TargetAbsent,
138}
139
140#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
141#[serde(rename_all = "kebab-case")]
142pub enum TargetSpeakerMetricOperator {
143    GreaterOrEqual,
144    LessOrEqual,
145}
146
147#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
148#[serde(deny_unknown_fields)]
149pub struct TargetSpeakerMetricOutcome {
150    pub metric: String,
151    pub value: f64,
152    pub operator: TargetSpeakerMetricOperator,
153    pub limit: f64,
154    pub passed: bool,
155}
156
157#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
158#[serde(deny_unknown_fields)]
159pub struct TargetSpeakerStratumEvidence {
160    pub id: String,
161    pub kind: TargetSpeakerStratumKind,
162    pub cases: u32,
163    pub metrics: Vec<TargetSpeakerMetricOutcome>,
164}
165
166#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
167#[serde(deny_unknown_fields)]
168pub struct TargetSpeakerPromotionEvidencePayload {
169    pub completed_at_unix_seconds: u64,
170    pub model_package_sha256: String,
171    pub source_revision: String,
172    pub source_sha256: String,
173    pub checkpoint_sha256: String,
174    pub corpus_manifest_sha256: String,
175    pub evaluation_result_sha256: String,
176    pub real_t_result_sha256: String,
177    pub ts_superb_result_sha256: String,
178    pub strata: Vec<TargetSpeakerStratumEvidence>,
179    pub target_speaker_count: u32,
180    pub interferer_speaker_count: u32,
181    pub language_count: u32,
182    pub presence_expected_calibration_error: f64,
183    pub presence_expected_calibration_error_limit: f64,
184    pub minimum_listeners: u32,
185    pub listener_count: u32,
186    pub listener_preference: f64,
187    pub listener_preference_limit: f64,
188    pub accepted: bool,
189}
190
191impl TargetSpeakerPromotionEvidencePayload {
192    pub fn validate(&self) -> Result<(), String> {
193        for (label, digest) in [
194            ("model package", self.model_package_sha256.as_str()),
195            ("source", self.source_sha256.as_str()),
196            ("checkpoint", self.checkpoint_sha256.as_str()),
197            ("corpus manifest", self.corpus_manifest_sha256.as_str()),
198            ("evaluation result", self.evaluation_result_sha256.as_str()),
199            ("REAL-T result", self.real_t_result_sha256.as_str()),
200            ("TS-SUPERB result", self.ts_superb_result_sha256.as_str()),
201        ] {
202            validate_sha256(label, digest)?;
203        }
204        validate_identifier("source revision", &self.source_revision)?;
205        if self.completed_at_unix_seconds > (1_u64 << 53) - 1 {
206            return Err(
207                "target-speaker evidence timestamp exceeds the JSON safe-integer limit".into(),
208            );
209        }
210        if self.strata.is_empty() || self.strata.len() > MAX_TARGET_SPEAKER_EVIDENCE_STRATA {
211            return Err(format!(
212                "target-speaker evidence must contain 1..={MAX_TARGET_SPEAKER_EVIDENCE_STRATA} strata"
213            ));
214        }
215        let required: BTreeMap<_, _> = REQUIRED_STRATA.iter().copied().collect();
216        let mut observed_strata = BTreeSet::new();
217        let mut previous = None;
218        let mut all_metrics_passed = true;
219        for stratum in &self.strata {
220            validate_identifier("target-speaker evidence stratum", &stratum.id)?;
221            if previous.is_some_and(|value: &str| value >= stratum.id.as_str()) {
222                return Err(
223                    "target-speaker evidence strata must be unique and strictly sorted".into(),
224                );
225            }
226            previous = Some(&stratum.id);
227            observed_strata.insert(stratum.id.as_str());
228            if required
229                .get(stratum.id.as_str())
230                .is_some_and(|expected| *expected != stratum.kind)
231            {
232                return Err(format!(
233                    "target-speaker evidence stratum {} has the wrong presence kind",
234                    stratum.id
235                ));
236            }
237            if !(10..=1_000_000).contains(&stratum.cases) {
238                return Err("target-speaker evidence stratum cases must be in 10..=1000000".into());
239            }
240            if stratum.metrics.is_empty()
241                || stratum.metrics.len() > MAX_TARGET_SPEAKER_EVIDENCE_METRICS
242            {
243                return Err(format!(
244                    "target-speaker evidence stratum metrics must be in 1..={MAX_TARGET_SPEAKER_EVIDENCE_METRICS}"
245                ));
246            }
247            let policies = match stratum.kind {
248                TargetSpeakerStratumKind::TargetPresent => PRESENT_METRICS,
249                TargetSpeakerStratumKind::TargetAbsent => ABSENT_METRICS,
250            };
251            let policy_by_name: BTreeMap<_, _> = policies
252                .iter()
253                .map(|policy| (policy.name, policy))
254                .collect();
255            let mut observed_metrics = BTreeSet::new();
256            let mut previous_metric = None;
257            for metric in &stratum.metrics {
258                validate_identifier("target-speaker evidence metric", &metric.metric)?;
259                if previous_metric.is_some_and(|value: &str| value >= metric.metric.as_str()) {
260                    return Err(
261                        "target-speaker evidence metrics must be unique and strictly sorted".into(),
262                    );
263                }
264                previous_metric = Some(&metric.metric);
265                observed_metrics.insert(metric.metric.as_str());
266                if !metric.value.is_finite() || !metric.limit.is_finite() {
267                    return Err("target-speaker evidence metric values must be finite".into());
268                }
269                let expected = match metric.operator {
270                    TargetSpeakerMetricOperator::GreaterOrEqual => metric.value >= metric.limit,
271                    TargetSpeakerMetricOperator::LessOrEqual => metric.value <= metric.limit,
272                };
273                if metric.passed != expected {
274                    return Err(format!(
275                        "target-speaker evidence metric {} has an inconsistent passed flag",
276                        metric.metric
277                    ));
278                }
279                if let Some(policy) = policy_by_name.get(metric.metric.as_str()) {
280                    validate_metric_policy(metric, policy)?;
281                }
282                all_metrics_passed &= metric.passed;
283            }
284            for policy in policies {
285                if !observed_metrics.contains(policy.name) {
286                    return Err(format!(
287                        "target-speaker evidence stratum {} omits required metric {}",
288                        stratum.id, policy.name
289                    ));
290                }
291            }
292        }
293        for (id, _) in REQUIRED_STRATA {
294            if !observed_strata.contains(id) {
295                return Err(format!(
296                    "target-speaker evidence omits required stratum {id}"
297                ));
298            }
299        }
300        if self.target_speaker_count < 100
301            || self.target_speaker_count > 1_000_000
302            || self.interferer_speaker_count < 100
303            || self.interferer_speaker_count > 1_000_000
304            || !(2..=1_000).contains(&self.language_count)
305        {
306            return Err(
307                "target-speaker evidence requires at least 100 target and interferer speakers and two languages"
308                    .into(),
309            );
310        }
311        if !self.presence_expected_calibration_error.is_finite()
312            || !self.presence_expected_calibration_error_limit.is_finite()
313            || !(0.0..=1.0).contains(&self.presence_expected_calibration_error)
314            || !(0.0..=0.05).contains(&self.presence_expected_calibration_error_limit)
315        {
316            return Err("target-speaker evidence presence calibration values are invalid".into());
317        }
318        if self.minimum_listeners < 20
319            || self.minimum_listeners > 100_000
320            || self.listener_count < self.minimum_listeners
321            || self.listener_count > 100_000
322            || !self.listener_preference.is_finite()
323            || !self.listener_preference_limit.is_finite()
324            || !(0.0..=1.0).contains(&self.listener_preference)
325            || !(0.5..=1.0).contains(&self.listener_preference_limit)
326        {
327            return Err("target-speaker evidence listening values are invalid".into());
328        }
329        let expected_accepted = all_metrics_passed
330            && self.presence_expected_calibration_error
331                <= self.presence_expected_calibration_error_limit
332            && self.listener_count >= self.minimum_listeners
333            && self.listener_preference >= self.listener_preference_limit;
334        if self.accepted != expected_accepted {
335            return Err("target-speaker evidence accepted flag is inconsistent".into());
336        }
337        let bytes = serde_json::to_vec(self)
338            .map_err(|error| format!("serialize target-speaker evidence payload: {error}"))?;
339        if bytes.len() as u64 >= MAX_EVIDENCE_JSON_BYTES {
340            return Err("target-speaker evidence payload exceeds the bounded JSON limit".into());
341        }
342        Ok(())
343    }
344}
345
346#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
347#[serde(deny_unknown_fields)]
348pub struct SignedTargetSpeakerPromotionEvidence {
349    pub schema: String,
350    pub schema_version: u32,
351    pub payload: TargetSpeakerPromotionEvidencePayload,
352    pub signature: ReceiptSignature,
353}
354
355impl SignedTargetSpeakerPromotionEvidence {
356    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, String> {
357        let path = path.as_ref();
358        let (file, length) =
359            crate::input::open_regular_file(path, "target-speaker promotion evidence")?;
360        if length >= MAX_EVIDENCE_JSON_BYTES {
361            return Err(format!(
362                "target-speaker promotion evidence {} exceeds the {MAX_EVIDENCE_JSON_BYTES}-byte limit",
363                path.display()
364            ));
365        }
366        let mut bytes = Vec::new();
367        bytes
368            .try_reserve_exact(length as usize)
369            .map_err(|_| "unable to reserve target-speaker evidence JSON".to_string())?;
370        file.take(MAX_EVIDENCE_JSON_BYTES)
371            .read_to_end(&mut bytes)
372            .map_err(|error| format!("read target-speaker promotion evidence: {error}"))?;
373        if bytes.len() as u64 != length {
374            return Err("target-speaker promotion evidence changed while reading".into());
375        }
376        let evidence: Self = serde_json::from_slice(&bytes)
377            .map_err(|error| format!("parse target-speaker promotion evidence: {error}"))?;
378        evidence.validate_structure()?;
379        Ok(evidence)
380    }
381
382    pub fn validate_structure(&self) -> Result<(), String> {
383        if self.schema != TARGET_SPEAKER_PROMOTION_EVIDENCE_SCHEMA
384            || self.schema_version != TARGET_SPEAKER_SCHEMA_VERSION
385        {
386            return Err("unsupported target-speaker promotion evidence schema".into());
387        }
388        self.payload.validate()?;
389        if self.signature.algorithm != "ed25519" {
390            return Err("target-speaker promotion evidence signature must use ed25519".into());
391        }
392        validate_sha256("evidence key ID", &self.signature.key_id)?;
393        let bytes = serde_json::to_vec(self)
394            .map_err(|error| format!("serialize target-speaker promotion evidence: {error}"))?;
395        if bytes.len() as u64 >= MAX_EVIDENCE_JSON_BYTES {
396            return Err("target-speaker promotion evidence exceeds the bounded JSON limit".into());
397        }
398        Ok(())
399    }
400
401    pub fn verify_signature(&self, key: &ReceiptPublicKey) -> Result<(), String> {
402        self.validate_structure()?;
403        let document = serde_json::to_vec(&self.payload).map_err(|error| {
404            format!("serialize target-speaker evidence for verification: {error}")
405        })?;
406        key.verify_domain_document(
407            PROMOTION_SIGNATURE_DOMAIN,
408            &document,
409            &self.signature,
410            "target-speaker promotion evidence",
411        )
412    }
413
414    pub fn to_pretty_json(&self) -> Result<String, String> {
415        self.validate_structure()?;
416        serde_json::to_string_pretty(self)
417            .map_err(|error| format!("serialize target-speaker promotion evidence: {error}"))
418    }
419}
420
421pub fn sign_target_speaker_promotion_evidence(
422    payload: TargetSpeakerPromotionEvidencePayload,
423    key: &ReceiptSecretKey,
424) -> Result<SignedTargetSpeakerPromotionEvidence, String> {
425    payload.validate()?;
426    let document = serde_json::to_vec(&payload)
427        .map_err(|error| format!("serialize target-speaker evidence for signing: {error}"))?;
428    let signature = key.sign_domain_document(
429        PROMOTION_SIGNATURE_DOMAIN,
430        &document,
431        "target-speaker promotion evidence",
432    )?;
433    let evidence = SignedTargetSpeakerPromotionEvidence {
434        schema: TARGET_SPEAKER_PROMOTION_EVIDENCE_SCHEMA.into(),
435        schema_version: TARGET_SPEAKER_SCHEMA_VERSION,
436        payload,
437        signature,
438    };
439    evidence.validate_structure()?;
440    Ok(evidence)
441}
442
443#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
444#[serde(rename_all = "kebab-case")]
445pub enum TargetSpeakerPresence {
446    Present,
447    Absent,
448    Uncertain,
449}
450
451#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
452#[serde(rename_all = "kebab-case")]
453pub enum TargetSpeakerDecision {
454    AcceptedPresent,
455    WithheldAbsent,
456    WithheldUncertain,
457    WithheldSafetyGate,
458}
459
460#[derive(Clone, Debug, Deserialize, Serialize)]
461#[serde(deny_unknown_fields)]
462pub struct TargetSpeakerExtractionConfig {
463    pub minimum_present_probability: f64,
464    pub minimum_absent_probability: f64,
465    pub maximum_energy_gain_db: f64,
466    pub maximum_peak_gain_db: f64,
467    pub maximum_new_clipping_ratio: f64,
468}
469
470impl Default for TargetSpeakerExtractionConfig {
471    fn default() -> Self {
472        Self {
473            minimum_present_probability: 0.90,
474            minimum_absent_probability: 0.90,
475            maximum_energy_gain_db: 3.0,
476            maximum_peak_gain_db: 3.0,
477            maximum_new_clipping_ratio: 0.0001,
478        }
479    }
480}
481
482impl TargetSpeakerExtractionConfig {
483    pub fn validate(&self) -> Result<(), String> {
484        validate_range(
485            "minimum_present_probability",
486            self.minimum_present_probability,
487            0.5,
488            1.0,
489        )?;
490        validate_range(
491            "minimum_absent_probability",
492            self.minimum_absent_probability,
493            0.5,
494            1.0,
495        )?;
496        validate_range(
497            "maximum_energy_gain_db",
498            self.maximum_energy_gain_db,
499            0.0,
500            12.0,
501        )?;
502        validate_range("maximum_peak_gain_db", self.maximum_peak_gain_db, 0.0, 12.0)?;
503        validate_range(
504            "maximum_new_clipping_ratio",
505            self.maximum_new_clipping_ratio,
506            0.0,
507            0.01,
508        )
509    }
510}
511
512#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
513#[serde(deny_unknown_fields)]
514pub struct TargetSpeakerModelIdentity {
515    pub package_sha256: String,
516    pub public_key_sha256: String,
517    pub package_id: String,
518    pub package_revision: String,
519    pub precision_profile: String,
520    pub source_revision: String,
521    pub source_sha256: String,
522    pub source_license_spdx: String,
523    pub checkpoint_sha256: String,
524    pub checkpoint_license_spdx: String,
525    pub accelerator: String,
526}
527
528#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
529#[serde(deny_unknown_fields)]
530pub struct TargetSpeakerEvidenceIdentity {
531    pub signing_key_id: String,
532    pub corpus_manifest_sha256: String,
533    pub evaluation_result_sha256: String,
534    pub real_t_result_sha256: String,
535    pub ts_superb_result_sha256: String,
536    pub strata: u32,
537    pub target_speakers: u32,
538    pub interferer_speakers: u32,
539    pub languages: u32,
540    pub accepted: bool,
541}
542
543#[derive(Clone, Debug, Deserialize, Serialize)]
544#[serde(deny_unknown_fields)]
545pub struct TargetSpeakerEnrollmentSummary {
546    pub input_sample_rate: u32,
547    pub input_channels: usize,
548    pub input_frames: usize,
549    pub model_sample_rate: u32,
550    pub model_samples: usize,
551    pub mixdown_policy: String,
552    pub raw_audio_retained: bool,
553    pub embedding_retained: bool,
554    pub digest_recorded: bool,
555}
556
557#[derive(Clone, Debug, Deserialize, Serialize)]
558#[serde(deny_unknown_fields)]
559pub struct TargetSpeakerPresenceAssessment {
560    pub state: TargetSpeakerPresence,
561    pub absent_probability: f64,
562    pub uncertain_probability: f64,
563    pub present_probability: f64,
564    pub minimum_absent_probability: f64,
565    pub minimum_present_probability: f64,
566}
567
568#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
569#[serde(rename_all = "kebab-case")]
570pub enum TargetSpeakerSafetyGateKind {
571    Geometry,
572    FiniteNormalizedSamples,
573    EnergyGain,
574    PeakGain,
575    NewClipping,
576    TargetPresence,
577    PromotionEvidence,
578}
579
580#[derive(Clone, Debug, Deserialize, Serialize)]
581#[serde(deny_unknown_fields)]
582pub struct TargetSpeakerSafetyGate {
583    pub kind: TargetSpeakerSafetyGateKind,
584    pub observed: f64,
585    pub limit: f64,
586    pub passed: bool,
587}
588
589#[derive(Clone, Debug, Deserialize, Serialize)]
590#[serde(deny_unknown_fields)]
591pub struct TargetSpeakerSafetyMeasurements {
592    pub mixture_rms_dbfs: f64,
593    pub candidate_rms_dbfs: f64,
594    pub mixture_peak_dbfs: f64,
595    pub candidate_peak_dbfs: f64,
596    pub energy_delta_db: f64,
597    pub mixture_clipping_ratio: f64,
598    pub candidate_clipping_ratio: f64,
599}
600
601#[derive(Clone, Debug, Deserialize, Serialize)]
602#[serde(deny_unknown_fields)]
603pub struct TargetSpeakerExtractionReport {
604    pub schema: String,
605    pub schema_version: u32,
606    pub denoize_version: String,
607    pub network_accessed: bool,
608    pub deterministic: bool,
609    pub model: TargetSpeakerModelIdentity,
610    pub promotion_evidence: TargetSpeakerEvidenceIdentity,
611    pub decision: TargetSpeakerDecision,
612    pub model_invoked: bool,
613    pub candidate_accepted: bool,
614    pub output_published: bool,
615    pub candidate_retained: bool,
616    pub source_sample_rate: u32,
617    pub source_channels: usize,
618    pub source_frames: usize,
619    pub output_channels: usize,
620    pub output_frames: Option<usize>,
621    pub mixture_mixdown_policy: String,
622    pub mixture_pcm_sha256: String,
623    pub candidate_pcm_sha256: Option<String>,
624    pub output_pcm_sha256: Option<String>,
625    pub enrollment: TargetSpeakerEnrollmentSummary,
626    pub presence: TargetSpeakerPresenceAssessment,
627    pub measurements: TargetSpeakerSafetyMeasurements,
628    pub safety_gates: Vec<TargetSpeakerSafetyGate>,
629    pub runtime_speaker_identity_verified: bool,
630    pub interferer_leakage_measured_at_runtime: bool,
631    pub limitations: Vec<String>,
632    pub warnings: Vec<String>,
633}
634
635impl TargetSpeakerExtractionReport {
636    pub fn to_json(&self) -> Result<String, String> {
637        serde_json::to_string(self)
638            .map_err(|error| format!("serialize target-speaker extraction report: {error}"))
639    }
640
641    pub fn to_pretty_json(&self) -> Result<String, String> {
642        serde_json::to_string_pretty(self)
643            .map_err(|error| format!("serialize target-speaker extraction report: {error}"))
644    }
645}
646
647#[derive(Clone, Debug)]
648pub struct TargetSpeakerExtractionResult {
649    /// `None` means publication was intentionally withheld. A caller must not
650    /// substitute the mixture or the unverified candidate.
651    pub audio: Option<Audio>,
652    pub report: TargetSpeakerExtractionReport,
653}
654
655/// Conservative decoded mixture, enrollment, resampling, model input/output,
656/// and report allowance. Signed model session resources are admitted
657/// separately.
658pub fn estimate_target_speaker_memory_bytes(mixture: &Audio, enrollment: &Audio) -> u64 {
659    estimate_audio_memory_bytes(mixture)
660        .saturating_mul(7)
661        .saturating_add(estimate_audio_memory_bytes(enrollment).saturating_mul(5))
662        .max(1024 * 1024)
663}
664
665#[cfg(feature = "onnx")]
666pub struct TargetSpeakerSession {
667    package: RuntimeModelPackage,
668    model: crate::backend::target_speaker::TargetSpeakerModel,
669    accelerator: AcceleratorSelection,
670    evidence: TargetSpeakerEvidenceIdentity,
671}
672
673#[cfg(feature = "onnx")]
674impl std::fmt::Debug for TargetSpeakerSession {
675    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676        formatter
677            .debug_struct("TargetSpeakerSession")
678            .field("package_sha256", &self.package.package_sha256())
679            .field("accelerator", &self.accelerator)
680            .field("evidence", &self.evidence)
681            .finish_non_exhaustive()
682    }
683}
684
685#[cfg(feature = "onnx")]
686impl TargetSpeakerSession {
687    /// Authenticate evidence, bind it to the package, resolve the runtime, and
688    /// validate the graph plus numerical vectors before user audio is decoded.
689    pub fn prepare(
690        package: RuntimeModelPackage,
691        evidence: &SignedTargetSpeakerPromotionEvidence,
692        evidence_key: &ReceiptPublicKey,
693        requested: AcceleratorPreference,
694    ) -> Result<Self, String> {
695        evidence.verify_signature(evidence_key)?;
696        if !evidence.payload.accepted {
697            return Err(
698                "target-speaker promotion evidence is authentic but does not pass promotion gates"
699                    .into(),
700            );
701        }
702        let manifest = package
703            .manifest_v2()
704            .ok_or("target-speaker extraction rejects runtime model package v1")?;
705        for (label, observed, expected) in [
706            (
707                "model package SHA-256",
708                evidence.payload.model_package_sha256.as_str(),
709                package.package_sha256(),
710            ),
711            (
712                "source revision",
713                evidence.payload.source_revision.as_str(),
714                manifest.provenance.source_revision.as_str(),
715            ),
716            (
717                "source SHA-256",
718                evidence.payload.source_sha256.as_str(),
719                manifest.provenance.source_sha256.as_str(),
720            ),
721            (
722                "checkpoint SHA-256",
723                evidence.payload.checkpoint_sha256.as_str(),
724                manifest.provenance.checkpoint_sha256.as_str(),
725            ),
726        ] {
727            if observed != expected {
728                return Err(format!(
729                    "target-speaker promotion evidence {label} does not match the authenticated package"
730                ));
731            }
732        }
733        let mut options = BackendOptions::default().with_runtime_model_package(package.clone());
734        options.deterministic = true;
735        options.accelerator = requested;
736        let accelerator = crate::select_accelerator_for_options(Backend::Onnx, &options)?;
737        if !package.supports_accelerator(accelerator.effective()) {
738            return Err(format!(
739                "target-speaker package does not permit the {} accelerator",
740                accelerator.effective().name()
741            ));
742        }
743        let model = crate::backend::target_speaker::TargetSpeakerModel::load_runtime_package(
744            &package,
745            accelerator.effective(),
746        )?;
747        let payload = &evidence.payload;
748        Ok(Self {
749            package,
750            model,
751            accelerator,
752            evidence: TargetSpeakerEvidenceIdentity {
753                signing_key_id: evidence.signature.key_id.clone(),
754                corpus_manifest_sha256: payload.corpus_manifest_sha256.clone(),
755                evaluation_result_sha256: payload.evaluation_result_sha256.clone(),
756                real_t_result_sha256: payload.real_t_result_sha256.clone(),
757                ts_superb_result_sha256: payload.ts_superb_result_sha256.clone(),
758                strata: payload.strata.len() as u32,
759                target_speakers: payload.target_speaker_count,
760                interferer_speakers: payload.interferer_speaker_count,
761                languages: payload.language_count,
762                accepted: true,
763            },
764        })
765    }
766
767    #[must_use]
768    pub const fn accelerator(&self) -> AcceleratorSelection {
769        self.accelerator
770    }
771
772    pub fn model_working_set_bytes(&self) -> Result<u64, String> {
773        let profile = self
774            .package
775            .precision_profile_for(self.accelerator.effective())?
776            .expect("target-speaker packages use v2 precision profiles");
777        Ok(profile
778            .resources
779            .max_session_memory_bytes
780            .saturating_add(profile.resources.max_worker_memory_bytes))
781    }
782
783    pub fn extract(
784        &self,
785        mixture: &Audio,
786        enrollment: Audio,
787        config: &TargetSpeakerExtractionConfig,
788    ) -> Result<TargetSpeakerExtractionResult, String> {
789        config.validate()?;
790        validate_audio(mixture, "mixture", false)?;
791        let enrollment = SensitiveEnrollment::new(enrollment);
792        validate_audio(enrollment.audio(), "enrollment", false)?;
793        let model_rate = self
794            .package
795            .manifest_v2()
796            .expect("target-speaker session requires v2")
797            .runtime
798            .sample_rate_hz;
799        let source_frames = mixture.frames();
800        if source_frames as u64
801            > u64::from(mixture.sample_rate).saturating_mul(MAX_TARGET_SPEAKER_MIXTURE_SECONDS)
802        {
803            return Err(format!(
804                "target-speaker mixture exceeds the {MAX_TARGET_SPEAKER_MIXTURE_SECONDS}-second limit"
805            ));
806        }
807        let enrollment_summary_input = (
808            enrollment.audio().sample_rate,
809            enrollment.audio().channels(),
810            enrollment.audio().frames(),
811        );
812        let mixture_mono = mono_mix(mixture, "mixture")?;
813        let enrollment_mono = Zeroizing::new(mono_mix(enrollment.audio(), "enrollment")?);
814        let enrollment_model_f64 = Zeroizing::new(crate::resample::resample(
815            &enrollment_mono,
816            enrollment.audio().sample_rate,
817            model_rate,
818        )?);
819        let enrollment_model = Zeroizing::new(
820            enrollment_model_f64
821                .iter()
822                .map(|sample| *sample as f32)
823                .collect::<Vec<_>>(),
824        );
825        validate_enrollment_duration(enrollment_model.len(), model_rate)?;
826        if let Some(required) = self.model.fixed_enrollment_samples() {
827            if enrollment_model.len() != required {
828                return Err(format!(
829                    "target-speaker package requires exactly {required} enrollment samples at {model_rate} Hz, got {}",
830                    enrollment_model.len()
831                ));
832            }
833        }
834        let enrollment_model_samples = enrollment_model.len();
835        let mixture_model_f64 =
836            crate::resample::resample(&mixture_mono, mixture.sample_rate, model_rate)?;
837        if mixture_model_f64.is_empty() {
838            return Err("target-speaker mixture becomes empty at the model sample rate".into());
839        }
840        let mixture_model = mixture_model_f64
841            .iter()
842            .map(|sample| *sample as f32)
843            .collect::<Vec<_>>();
844        let inference = self.model.process(&mixture_model, &enrollment_model)?;
845        drop(enrollment_model);
846        drop(enrollment_model_f64);
847        drop(enrollment_mono);
848        drop(enrollment);
849        let presence_values = inference.presence_probabilities;
850        let candidate_model = Zeroizing::new(inference.audio);
851        let candidate_resampled = Zeroizing::new(crate::resample::resample(
852            &candidate_model
853                .iter()
854                .map(|sample| f64::from(*sample))
855                .collect::<Vec<_>>(),
856            model_rate,
857            mixture.sample_rate,
858        )?);
859        let mut candidate = Zeroizing::new(Vec::new());
860        candidate
861            .try_reserve_exact(source_frames)
862            .map_err(|_| "unable to reserve target-speaker candidate".to_string())?;
863        candidate.extend(candidate_resampled.iter().copied().take(source_frames));
864        candidate.resize(source_frames, 0.0);
865
866        let presence = classify_presence(presence_values, config);
867        let mixture_measurements = signal_measurements(&mixture_mono);
868        let candidate_measurements = signal_measurements(&candidate);
869        let geometry_passed = candidate.len() == source_frames;
870        let finite_normalized_passed = candidate
871            .iter()
872            .all(|sample| sample.is_finite() && (-1.0..=1.0).contains(sample));
873        let energy_delta_db = candidate_measurements.rms_dbfs - mixture_measurements.rms_dbfs;
874        let peak_delta_db = candidate_measurements.peak_dbfs - mixture_measurements.peak_dbfs;
875        let new_clipping =
876            (candidate_measurements.clipping_ratio - mixture_measurements.clipping_ratio).max(0.0);
877        let gates = vec![
878            safety_gate(
879                TargetSpeakerSafetyGateKind::Geometry,
880                bool_value(geometry_passed),
881                1.0,
882                geometry_passed,
883            ),
884            safety_gate(
885                TargetSpeakerSafetyGateKind::FiniteNormalizedSamples,
886                bool_value(finite_normalized_passed),
887                1.0,
888                finite_normalized_passed,
889            ),
890            safety_gate(
891                TargetSpeakerSafetyGateKind::EnergyGain,
892                energy_delta_db,
893                config.maximum_energy_gain_db,
894                energy_delta_db <= config.maximum_energy_gain_db,
895            ),
896            safety_gate(
897                TargetSpeakerSafetyGateKind::PeakGain,
898                peak_delta_db,
899                config.maximum_peak_gain_db,
900                peak_delta_db <= config.maximum_peak_gain_db,
901            ),
902            safety_gate(
903                TargetSpeakerSafetyGateKind::NewClipping,
904                new_clipping,
905                config.maximum_new_clipping_ratio,
906                new_clipping <= config.maximum_new_clipping_ratio,
907            ),
908            safety_gate(
909                TargetSpeakerSafetyGateKind::TargetPresence,
910                f64::from(presence_values[2]),
911                config.minimum_present_probability,
912                presence == TargetSpeakerPresence::Present,
913            ),
914            safety_gate(
915                TargetSpeakerSafetyGateKind::PromotionEvidence,
916                1.0,
917                1.0,
918                true,
919            ),
920        ];
921        let signal_gates_passed = gates
922            .iter()
923            .filter(|gate| gate.kind != TargetSpeakerSafetyGateKind::TargetPresence)
924            .all(|gate| gate.passed);
925        let decision = match presence {
926            TargetSpeakerPresence::Absent => TargetSpeakerDecision::WithheldAbsent,
927            TargetSpeakerPresence::Uncertain => TargetSpeakerDecision::WithheldUncertain,
928            TargetSpeakerPresence::Present if !signal_gates_passed => {
929                TargetSpeakerDecision::WithheldSafetyGate
930            }
931            TargetSpeakerPresence::Present => TargetSpeakerDecision::AcceptedPresent,
932        };
933        let accepted = decision == TargetSpeakerDecision::AcceptedPresent;
934        let output = if accepted {
935            Some(Audio {
936                sample_rate: mixture.sample_rate,
937                channels: vec![candidate.iter().copied().collect()],
938                bits_per_sample: mixture.bits_per_sample,
939                sample_format: mixture.sample_format,
940                channel_mask: None,
941            })
942        } else {
943            None
944        };
945        let output_digest = output
946            .as_ref()
947            .map(|audio| pcm_digest(audio, OUTPUT_PCM_DIGEST_DOMAIN));
948        let mut warnings = Vec::new();
949        match decision {
950            TargetSpeakerDecision::AcceptedPresent => {}
951            TargetSpeakerDecision::WithheldAbsent => warnings.push(
952                "the calibrated presence head classified the target as absent; no audio was published"
953                    .into(),
954            ),
955            TargetSpeakerDecision::WithheldUncertain => warnings.push(
956                "target presence was uncertain; no mixture or candidate fallback was published"
957                    .into(),
958            ),
959            TargetSpeakerDecision::WithheldSafetyGate => {
960                let failed = gates
961                    .iter()
962                    .filter(|gate| !gate.passed)
963                    .map(|gate| format!("{:?}", gate.kind).to_ascii_lowercase())
964                    .collect::<Vec<_>>()
965                    .join(", ");
966                warnings.push(format!(
967                    "target-speaker candidate failed safety gates ({failed}); no audio was published"
968                ));
969            }
970        }
971        let manifest = self
972            .package
973            .manifest_v2()
974            .expect("target-speaker session requires v2");
975        let profile = self
976            .package
977            .precision_profile_for(self.accelerator.effective())?
978            .expect("target-speaker session selects one v2 profile");
979        let report = TargetSpeakerExtractionReport {
980            schema: TARGET_SPEAKER_REPORT_SCHEMA.into(),
981            schema_version: TARGET_SPEAKER_SCHEMA_VERSION,
982            denoize_version: env!("CARGO_PKG_VERSION").into(),
983            network_accessed: false,
984            deterministic: true,
985            model: TargetSpeakerModelIdentity {
986                package_sha256: self.package.package_sha256().into(),
987                public_key_sha256: self.package.public_key_sha256().into(),
988                package_id: manifest.package_id.clone(),
989                package_revision: manifest.package_revision.clone(),
990                precision_profile: profile.id.clone(),
991                source_revision: manifest.provenance.source_revision.clone(),
992                source_sha256: manifest.provenance.source_sha256.clone(),
993                source_license_spdx: manifest.provenance.source_license_spdx.clone(),
994                checkpoint_sha256: manifest.provenance.checkpoint_sha256.clone(),
995                checkpoint_license_spdx: manifest.provenance.checkpoint_license_spdx.clone(),
996                accelerator: self.accelerator.effective().name().into(),
997            },
998            promotion_evidence: self.evidence.clone(),
999            decision,
1000            model_invoked: true,
1001            candidate_accepted: accepted,
1002            output_published: accepted,
1003            candidate_retained: accepted,
1004            source_sample_rate: mixture.sample_rate,
1005            source_channels: mixture.channels(),
1006            source_frames,
1007            output_channels: 1,
1008            output_frames: accepted.then_some(source_frames),
1009            mixture_mixdown_policy: "arithmetic-mean-mono-v1".into(),
1010            mixture_pcm_sha256: pcm_digest(mixture, MIXTURE_PCM_DIGEST_DOMAIN),
1011            candidate_pcm_sha256: output_digest.clone(),
1012            output_pcm_sha256: output_digest,
1013            enrollment: TargetSpeakerEnrollmentSummary {
1014                input_sample_rate: enrollment_summary_input.0,
1015                input_channels: enrollment_summary_input.1,
1016                input_frames: enrollment_summary_input.2,
1017                model_sample_rate: model_rate,
1018                model_samples: enrollment_model_samples,
1019                mixdown_policy: "arithmetic-mean-mono-v1".into(),
1020                raw_audio_retained: false,
1021                embedding_retained: false,
1022                digest_recorded: false,
1023            },
1024            presence: TargetSpeakerPresenceAssessment {
1025                state: presence,
1026                absent_probability: f64::from(presence_values[0]),
1027                uncertain_probability: f64::from(presence_values[1]),
1028                present_probability: f64::from(presence_values[2]),
1029                minimum_absent_probability: config.minimum_absent_probability,
1030                minimum_present_probability: config.minimum_present_probability,
1031            },
1032            measurements: TargetSpeakerSafetyMeasurements {
1033                mixture_rms_dbfs: mixture_measurements.rms_dbfs,
1034                candidate_rms_dbfs: candidate_measurements.rms_dbfs,
1035                mixture_peak_dbfs: mixture_measurements.peak_dbfs,
1036                candidate_peak_dbfs: candidate_measurements.peak_dbfs,
1037                energy_delta_db,
1038                mixture_clipping_ratio: mixture_measurements.clipping_ratio,
1039                candidate_clipping_ratio: candidate_measurements.clipping_ratio,
1040            },
1041            safety_gates: gates,
1042            runtime_speaker_identity_verified: false,
1043            interferer_leakage_measured_at_runtime: false,
1044            limitations: limitations(),
1045            warnings,
1046        };
1047        Ok(TargetSpeakerExtractionResult {
1048            audio: output,
1049            report,
1050        })
1051    }
1052}
1053
1054#[cfg(feature = "onnx")]
1055struct SensitiveEnrollment(Audio);
1056
1057#[cfg(feature = "onnx")]
1058impl SensitiveEnrollment {
1059    fn new(audio: Audio) -> Self {
1060        Self(audio)
1061    }
1062
1063    fn audio(&self) -> &Audio {
1064        &self.0
1065    }
1066}
1067
1068#[cfg(feature = "onnx")]
1069impl Drop for SensitiveEnrollment {
1070    fn drop(&mut self) {
1071        for channel in &mut self.0.channels {
1072            channel.zeroize();
1073        }
1074    }
1075}
1076
1077#[cfg(feature = "onnx")]
1078fn validate_audio(audio: &Audio, label: &str, allow_empty: bool) -> Result<(), String> {
1079    if audio.sample_rate == 0 {
1080        return Err(format!("target-speaker {label} sample rate is invalid"));
1081    }
1082    if audio.channels.is_empty() || audio.channels.len() > MAX_CHANNELS {
1083        return Err(format!(
1084            "target-speaker {label} channels must be in 1..={MAX_CHANNELS}"
1085        ));
1086    }
1087    let frames = audio.channels[0].len();
1088    if !allow_empty && frames == 0 {
1089        return Err(format!("target-speaker {label} must not be empty"));
1090    }
1091    if audio.channels.iter().any(|channel| channel.len() != frames) {
1092        return Err(format!(
1093            "target-speaker {label} channels must have equal lengths"
1094        ));
1095    }
1096    if audio
1097        .channels
1098        .iter()
1099        .flatten()
1100        .any(|sample| !sample.is_finite() || !(-1.0..=1.0).contains(sample))
1101    {
1102        return Err(format!(
1103            "target-speaker {label} contains an invalid normalized sample"
1104        ));
1105    }
1106    Ok(())
1107}
1108
1109#[cfg(feature = "onnx")]
1110fn mono_mix(audio: &Audio, label: &str) -> Result<Vec<f64>, String> {
1111    let frames = audio.frames();
1112    let mut mono = Vec::new();
1113    mono.try_reserve_exact(frames)
1114        .map_err(|_| format!("unable to reserve target-speaker {label} mono mix"))?;
1115    let scale = 1.0 / audio.channels() as f64;
1116    for frame in 0..frames {
1117        let value = audio
1118            .channels
1119            .iter()
1120            .map(|channel| channel[frame])
1121            .sum::<f64>()
1122            * scale;
1123        mono.push(value);
1124    }
1125    Ok(mono)
1126}
1127
1128#[cfg(feature = "onnx")]
1129fn validate_enrollment_duration(samples: usize, sample_rate: u32) -> Result<(), String> {
1130    let millis = (samples as u64)
1131        .saturating_mul(1000)
1132        .checked_div(u64::from(sample_rate))
1133        .unwrap_or(0);
1134    if !(MIN_TARGET_SPEAKER_ENROLLMENT_MILLIS..=MAX_TARGET_SPEAKER_ENROLLMENT_MILLIS)
1135        .contains(&millis)
1136    {
1137        return Err(format!(
1138            "target-speaker enrollment must be {MIN_TARGET_SPEAKER_ENROLLMENT_MILLIS}..={MAX_TARGET_SPEAKER_ENROLLMENT_MILLIS} ms after resampling, got {millis} ms"
1139        ));
1140    }
1141    Ok(())
1142}
1143
1144#[cfg(feature = "onnx")]
1145fn classify_presence(
1146    probabilities: [f32; 3],
1147    config: &TargetSpeakerExtractionConfig,
1148) -> TargetSpeakerPresence {
1149    let absent = f64::from(probabilities[0]);
1150    let uncertain = f64::from(probabilities[1]);
1151    let present = f64::from(probabilities[2]);
1152    if present >= config.minimum_present_probability && present > absent && present > uncertain {
1153        TargetSpeakerPresence::Present
1154    } else if absent >= config.minimum_absent_probability && absent > present && absent > uncertain
1155    {
1156        TargetSpeakerPresence::Absent
1157    } else {
1158        TargetSpeakerPresence::Uncertain
1159    }
1160}
1161
1162#[cfg(feature = "onnx")]
1163#[derive(Clone, Copy)]
1164struct SignalMeasurements {
1165    rms_dbfs: f64,
1166    peak_dbfs: f64,
1167    clipping_ratio: f64,
1168}
1169
1170#[cfg(feature = "onnx")]
1171fn signal_measurements(samples: &[f64]) -> SignalMeasurements {
1172    if samples.is_empty() {
1173        return SignalMeasurements {
1174            rms_dbfs: -240.0,
1175            peak_dbfs: -240.0,
1176            clipping_ratio: 0.0,
1177        };
1178    }
1179    let energy = samples.iter().fold(0.0, |sum, sample| {
1180        if sample.is_finite() {
1181            sum + sample * sample
1182        } else {
1183            f64::INFINITY
1184        }
1185    });
1186    let rms = (energy / samples.len() as f64).sqrt();
1187    let peak = samples
1188        .iter()
1189        .map(|sample| sample.abs())
1190        .fold(0.0_f64, f64::max);
1191    let clipping =
1192        samples.iter().filter(|sample| sample.abs() >= 1.0).count() as f64 / samples.len() as f64;
1193    SignalMeasurements {
1194        rms_dbfs: amplitude_dbfs(rms),
1195        peak_dbfs: amplitude_dbfs(peak),
1196        clipping_ratio: clipping,
1197    }
1198}
1199
1200#[cfg(feature = "onnx")]
1201fn amplitude_dbfs(amplitude: f64) -> f64 {
1202    if !amplitude.is_finite() {
1203        240.0
1204    } else {
1205        (20.0 * amplitude.max(SILENCE_FLOOR).log10()).clamp(-240.0, 240.0)
1206    }
1207}
1208
1209#[cfg(feature = "onnx")]
1210fn safety_gate(
1211    kind: TargetSpeakerSafetyGateKind,
1212    observed: f64,
1213    limit: f64,
1214    passed: bool,
1215) -> TargetSpeakerSafetyGate {
1216    TargetSpeakerSafetyGate {
1217        kind,
1218        observed: observed.clamp(-240.0, 240.0),
1219        limit: limit.clamp(-240.0, 240.0),
1220        passed,
1221    }
1222}
1223
1224#[cfg(feature = "onnx")]
1225const fn bool_value(value: bool) -> f64 {
1226    if value {
1227        1.0
1228    } else {
1229        0.0
1230    }
1231}
1232
1233#[cfg(feature = "onnx")]
1234fn pcm_digest(audio: &Audio, domain: &[u8]) -> String {
1235    let mut digest = Sha256::new();
1236    digest.update(domain);
1237    digest.update(audio.sample_rate.to_be_bytes());
1238    digest.update((audio.channels() as u64).to_be_bytes());
1239    digest.update((audio.frames() as u64).to_be_bytes());
1240    for channel in &audio.channels {
1241        for sample in channel {
1242            digest.update(sample.to_bits().to_be_bytes());
1243        }
1244    }
1245    format!("{:x}", digest.finalize())
1246}
1247
1248#[cfg(feature = "onnx")]
1249fn limitations() -> Vec<String> {
1250    vec![
1251        "the runtime presence head is not an independent speaker-verification system".into(),
1252        "interferer leakage and target identity are promotion-time measurements, not runtime measurements"
1253            .into(),
1254        "a valid evidence signature authenticates the evaluator's claim but cannot prove the underlying recordings or labels are truthful"
1255            .into(),
1256        "the v1 adapter mixes program channels to mono and does not preserve or infer spatial position"
1257            .into(),
1258        "enrollment buffers are zeroized on ordinary drop, but operating-system caches, allocator copies, swap, and crash dumps are outside this guarantee"
1259            .into(),
1260        "denoize does not bundle a target-speaker checkpoint until artifact-level redistribution and protected-stratum gates are independently satisfied"
1261            .into(),
1262    ]
1263}
1264
1265fn validate_metric_policy(
1266    metric: &TargetSpeakerMetricOutcome,
1267    policy: &MetricPolicy,
1268) -> Result<(), String> {
1269    if metric.operator != policy.operator {
1270        return Err(format!(
1271            "target-speaker evidence metric {} uses the wrong operator",
1272            metric.metric
1273        ));
1274    }
1275    let strong_enough = match policy.operator {
1276        TargetSpeakerMetricOperator::GreaterOrEqual => metric.limit >= policy.hard_limit,
1277        TargetSpeakerMetricOperator::LessOrEqual => metric.limit <= policy.hard_limit,
1278    };
1279    if !strong_enough {
1280        return Err(format!(
1281            "target-speaker evidence metric {} uses a weaker limit than the release policy {}",
1282            metric.metric, policy.hard_limit
1283        ));
1284    }
1285    Ok(())
1286}
1287
1288fn validate_range(label: &str, value: f64, minimum: f64, maximum: f64) -> Result<(), String> {
1289    if !value.is_finite() || !(minimum..=maximum).contains(&value) {
1290        Err(format!(
1291            "target-speaker {label} must be finite and in {minimum}..={maximum}"
1292        ))
1293    } else {
1294        Ok(())
1295    }
1296}
1297
1298fn validate_sha256(label: &str, value: &str) -> Result<(), String> {
1299    if value.len() != 64
1300        || value
1301            .bytes()
1302            .any(|byte| !byte.is_ascii_hexdigit() || byte.is_ascii_uppercase())
1303    {
1304        return Err(format!(
1305            "target-speaker evidence {label} must be lowercase SHA-256"
1306        ));
1307    }
1308    Ok(())
1309}
1310
1311fn validate_identifier(label: &str, value: &str) -> Result<(), String> {
1312    if value.is_empty()
1313        || value.len() > 256
1314        || !value.bytes().all(|byte| {
1315            byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._+-".contains(&byte)
1316        })
1317    {
1318        return Err(format!(
1319            "{label} must use 1..=256 lowercase ASCII identifier characters"
1320        ));
1321    }
1322    Ok(())
1323}
1324
1325#[cfg(test)]
1326mod tests {
1327    use super::*;
1328
1329    #[test]
1330    fn promotion_evidence_enforces_protected_strata_and_hard_limits() {
1331        let payload = passing_payload();
1332        payload.validate().unwrap();
1333
1334        let mut missing = payload.clone();
1335        missing.strata.remove(0);
1336        assert!(missing
1337            .validate()
1338            .unwrap_err()
1339            .contains("omits required stratum"));
1340
1341        let mut weak = payload.clone();
1342        let metric = weak
1343            .strata
1344            .iter_mut()
1345            .find(|stratum| stratum.kind == TargetSpeakerStratumKind::TargetPresent)
1346            .unwrap()
1347            .metrics
1348            .iter_mut()
1349            .find(|metric| metric.metric == "speaker.target-similarity")
1350            .unwrap();
1351        metric.limit = 0.1;
1352        metric.value = 0.1;
1353        assert!(weak.validate().unwrap_err().contains("weaker limit"));
1354
1355        let mut uncalibrated = payload;
1356        uncalibrated.presence_expected_calibration_error = 0.051;
1357        assert!(uncalibrated
1358            .validate()
1359            .unwrap_err()
1360            .contains("accepted flag"));
1361    }
1362
1363    #[test]
1364    fn extraction_config_is_closed_and_conservative() {
1365        let config = TargetSpeakerExtractionConfig::default();
1366        config.validate().unwrap();
1367        assert_eq!(config.minimum_present_probability, 0.90);
1368        assert_eq!(config.minimum_absent_probability, 0.90);
1369        let encoded = serde_json::to_string(&config).unwrap();
1370        let unknown = encoded.replace('{', "{\"unknown\":true,");
1371        assert!(serde_json::from_str::<TargetSpeakerExtractionConfig>(&unknown).is_err());
1372    }
1373
1374    #[cfg(feature = "onnx")]
1375    #[test]
1376    fn three_state_presence_never_promotes_ambiguous_probabilities() {
1377        let config = TargetSpeakerExtractionConfig::default();
1378        assert_eq!(
1379            classify_presence([0.01, 0.01, 0.98], &config),
1380            TargetSpeakerPresence::Present
1381        );
1382        assert_eq!(
1383            classify_presence([0.98, 0.01, 0.01], &config),
1384            TargetSpeakerPresence::Absent
1385        );
1386        assert_eq!(
1387            classify_presence([0.40, 0.20, 0.40], &config),
1388            TargetSpeakerPresence::Uncertain
1389        );
1390    }
1391
1392    fn passing_payload() -> TargetSpeakerPromotionEvidencePayload {
1393        let strata = REQUIRED_STRATA
1394            .iter()
1395            .map(|(id, kind)| TargetSpeakerStratumEvidence {
1396                id: (*id).into(),
1397                kind: *kind,
1398                cases: 10,
1399                metrics: match kind {
1400                    TargetSpeakerStratumKind::TargetPresent => metric_outcomes(PRESENT_METRICS),
1401                    TargetSpeakerStratumKind::TargetAbsent => metric_outcomes(ABSENT_METRICS),
1402                },
1403            })
1404            .collect();
1405        TargetSpeakerPromotionEvidencePayload {
1406            completed_at_unix_seconds: 1_800_000_000,
1407            model_package_sha256: "0".repeat(64),
1408            source_revision: "0123456789abcdef".into(),
1409            source_sha256: "1".repeat(64),
1410            checkpoint_sha256: "2".repeat(64),
1411            corpus_manifest_sha256: "3".repeat(64),
1412            evaluation_result_sha256: "4".repeat(64),
1413            real_t_result_sha256: "5".repeat(64),
1414            ts_superb_result_sha256: "6".repeat(64),
1415            strata,
1416            target_speaker_count: 100,
1417            interferer_speaker_count: 100,
1418            language_count: 2,
1419            presence_expected_calibration_error: 0.05,
1420            presence_expected_calibration_error_limit: 0.05,
1421            minimum_listeners: 20,
1422            listener_count: 20,
1423            listener_preference: 0.5,
1424            listener_preference_limit: 0.5,
1425            accepted: true,
1426        }
1427    }
1428
1429    fn metric_outcomes(policies: &[MetricPolicy]) -> Vec<TargetSpeakerMetricOutcome> {
1430        policies
1431            .iter()
1432            .map(|policy| TargetSpeakerMetricOutcome {
1433                metric: policy.name.into(),
1434                value: policy.hard_limit,
1435                operator: policy.operator,
1436                limit: policy.hard_limit,
1437                passed: true,
1438            })
1439            .collect()
1440    }
1441}