use crate::apple_speech_session::AppleSpeechSession;
use crate::config::Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShadowOutcome {
Usable,
Empty,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShadowError {
WorkerCrashed,
XpcInterrupted,
RuntimeUnsupported,
WorkerUnavailable,
AssetsUnavailable,
SpeechError,
Unknown,
}
impl ShadowError {
pub fn as_str(self) -> &'static str {
match self {
Self::WorkerCrashed => "worker_crashed",
Self::XpcInterrupted => "xpc_interrupted",
Self::RuntimeUnsupported => "runtime_unsupported",
Self::WorkerUnavailable => "worker_unavailable",
Self::AssetsUnavailable => "assets_unavailable",
Self::SpeechError => "speech_error",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ShadowComparison {
pub outcome: ShadowOutcome,
pub whisper_chars: usize,
pub whisper_words: usize,
pub apple_chars: usize,
pub apple_words: usize,
pub similarity: Option<f32>,
pub exact_match: bool,
pub apple_error: Option<ShadowError>,
}
const SIMILARITY_WORD_CAP: usize = 3000;
pub fn compare(whisper: &str, apple: Result<Option<&str>, ShadowError>) -> ShadowComparison {
let whisper_words_vec = normalized_words(whisper);
let whisper_chars = normalized_char_count(whisper);
let whisper_words = whisper_words_vec.len();
let non_usable = |outcome, apple_error| ShadowComparison {
outcome,
whisper_chars,
whisper_words,
apple_chars: 0,
apple_words: 0,
similarity: None,
exact_match: false,
apple_error,
};
match apple {
Ok(Some(apple_text)) => {
let apple_words_vec = normalized_words(apple_text);
if apple_words_vec.is_empty() {
return non_usable(ShadowOutcome::Empty, None);
}
let exact_match = whisper_words_vec == apple_words_vec;
let similarity = if exact_match {
Some(1.0)
} else {
word_similarity(&whisper_words_vec, &apple_words_vec)
};
ShadowComparison {
outcome: ShadowOutcome::Usable,
whisper_chars,
whisper_words,
apple_chars: normalized_char_count(apple_text),
apple_words: apple_words_vec.len(),
similarity,
exact_match,
apple_error: None,
}
}
Ok(None) => non_usable(ShadowOutcome::Empty, None),
Err(category) => non_usable(ShadowOutcome::Failed, Some(category)),
}
}
pub fn log_comparison(source: &str, cmp: &ShadowComparison) -> std::io::Result<()> {
let outcome = match cmp.outcome {
ShadowOutcome::Usable => "usable",
ShadowOutcome::Empty => "empty",
ShadowOutcome::Failed => "failed",
};
let entry = serde_json::json!({
"event": "apple_speech_shadow",
"ts": chrono::Utc::now().to_rfc3339(),
"source": source,
"outcome": outcome,
"whisper_words": cmp.whisper_words,
"apple_words": cmp.apple_words,
"whisper_chars": cmp.whisper_chars,
"apple_chars": cmp.apple_chars,
"similarity": cmp.similarity,
"exact_match": cmp.exact_match,
"apple_error": cmp.apple_error.map(ShadowError::as_str),
});
let result = crate::logging::append_log(&entry);
match &result {
Ok(()) => tracing::debug!(
target: "apple_speech_shadow",
source,
outcome,
"apple-speech shadow comparison persisted"
),
Err(error) => tracing::warn!(
target: "apple_speech_shadow",
source,
outcome,
%error,
"failed to persist apple-speech shadow comparison"
),
}
result
}
pub fn log_shadow_disabled(source: &str, reason: &str) {
let entry = serde_json::json!({
"event": "apple_speech_shadow_disabled",
"ts": chrono::Utc::now().to_rfc3339(),
"source": source,
"reason": reason,
});
let _ = crate::logging::append_log(&entry);
tracing::warn!(target: "apple_speech_shadow", source, reason, "apple-speech shadow disabled for this session");
}
pub fn shadow_enabled(config: &Config) -> bool {
config.transcription.apple_speech_shadow
}
const SHADOW_MIN_SAMPLES: usize = 16_000;
pub fn worker_ok_to_shadow(
runtime_supported: bool,
transcript: &str,
) -> Result<Option<String>, ShadowError> {
if !runtime_supported {
Err(ShadowError::SpeechError)
} else if transcript.trim().is_empty() {
Ok(None)
} else {
Ok(Some(transcript.to_string()))
}
}
pub fn transport_error_category(kind: std::io::ErrorKind) -> ShadowError {
match kind {
std::io::ErrorKind::PermissionDenied => ShadowError::WorkerUnavailable,
std::io::ErrorKind::Other => ShadowError::XpcInterrupted,
_ => ShadowError::Unknown,
}
}
struct ShadowJob {
samples: Vec<f32>,
whisper_text: String,
}
static SHADOW_ADMISSION: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub struct ShadowRunner {
tx: std::sync::mpsc::SyncSender<ShadowJob>,
cancelled: std::sync::Arc<std::sync::atomic::AtomicBool>,
session: std::sync::Arc<AppleSpeechSession>,
}
impl ShadowRunner {
pub fn spawn<F>(source: &'static str, attempt: F) -> Option<Self>
where
F: FnMut(&[f32]) -> Result<Option<String>, ShadowError> + Send + 'static,
{
Self::spawn_with_sink(source, attempt, |src, cmp| {
let _ = log_comparison(src, cmp);
})
}
fn spawn_with_sink<F, S>(source: &'static str, mut attempt: F, mut sink: S) -> Option<Self>
where
F: FnMut(&[f32]) -> Result<Option<String>, ShadowError> + Send + 'static,
S: FnMut(&'static str, &ShadowComparison) + Send + 'static,
{
use std::sync::atomic::{AtomicBool, Ordering};
let (tx, rx) = std::sync::mpsc::sync_channel::<ShadowJob>(1);
let session = std::sync::Arc::new(AppleSpeechSession::new());
let cancelled = std::sync::Arc::new(AtomicBool::new(false));
let thread_session = std::sync::Arc::clone(&session);
let thread_cancelled = std::sync::Arc::clone(&cancelled);
std::thread::Builder::new()
.name("apple-speech-shadow".to_string())
.spawn(move || {
for job in rx {
if !thread_cancelled.load(Ordering::Acquire) && thread_session.should_attempt()
{
let apple = attempt(&job.samples);
let cmp = compare(
&job.whisper_text,
apple.as_ref().map(|opt| opt.as_deref()).map_err(|e| *e),
);
match cmp.outcome {
ShadowOutcome::Usable => thread_session.record_success(),
ShadowOutcome::Failed => thread_session.record_failure(),
ShadowOutcome::Empty => {}
}
sink(source, &cmp);
}
SHADOW_ADMISSION.store(false, Ordering::Release);
}
})
.ok()?;
Some(Self {
tx,
cancelled,
session,
})
}
pub fn try_submit(&self, samples: Vec<f32>, whisper_text: String) -> bool {
use std::sync::atomic::Ordering;
if samples.len() < SHADOW_MIN_SAMPLES || !self.session.should_attempt() {
return false;
}
if SHADOW_ADMISSION.swap(true, Ordering::AcqRel) {
return false;
}
match self.tx.try_send(ShadowJob {
samples,
whisper_text,
}) {
Ok(()) => true,
Err(_) => {
SHADOW_ADMISSION.store(false, Ordering::Release);
false
}
}
}
}
impl Drop for ShadowRunner {
fn drop(&mut self) {
self.cancelled
.store(true, std::sync::atomic::Ordering::Release);
}
}
#[cfg(target_os = "macos")]
pub fn spawn_live_shadow_runner(config: &Config, source: &'static str) -> Option<ShadowRunner> {
let language = config.transcription.language.clone();
ShadowRunner::spawn(source, move |samples| {
let locale = crate::apple_speech::live_locale_hint(language.as_deref());
match crate::apple_speech_worker::transcribe_samples(
samples,
locale.as_deref(),
crate::apple_speech::AppleSpeechMode::Speech,
true,
) {
Ok(result) => worker_ok_to_shadow(result.runtime_supported, &result.transcript),
Err(crate::error::MinutesError::Io(io)) => Err(transport_error_category(io.kind())),
Err(_) => Err(ShadowError::Unknown),
}
})
}
fn normalized_words(text: &str) -> Vec<String> {
text.lines()
.filter_map(crate::pipeline::clean_transcript_line)
.flat_map(|line| {
line.chars()
.filter_map(|c| {
if c.is_alphanumeric() || c.is_whitespace() {
Some(c)
} else if c == '\'' || c == '.' {
None
} else {
Some(' ')
}
})
.collect::<String>()
.split_whitespace()
.map(|w| w.to_lowercase())
.collect::<Vec<_>>()
})
.collect()
}
fn normalized_char_count(text: &str) -> usize {
normalized_words(text).join(" ").chars().count()
}
fn word_similarity(a: &[String], b: &[String]) -> Option<f32> {
let max_len = a.len().max(b.len());
if max_len == 0 {
return Some(1.0);
}
if max_len > SIMILARITY_WORD_CAP {
return None;
}
let distance = word_edit_distance(a, b);
Some(1.0 - (distance as f32 / max_len as f32))
}
fn word_edit_distance(a: &[String], b: &[String]) -> usize {
let (long, short) = if a.len() >= b.len() { (a, b) } else { (b, a) };
if short.is_empty() {
return long.len();
}
let mut prev: Vec<usize> = (0..=short.len()).collect();
let mut curr: Vec<usize> = vec![0; short.len() + 1];
for (i, long_word) in long.iter().enumerate() {
curr[0] = i + 1;
for (j, short_word) in short.iter().enumerate() {
let cost = usize::from(long_word != short_word);
curr[j + 1] = (prev[j + 1] + 1) .min(curr[j] + 1) .min(prev[j] + cost); }
std::mem::swap(&mut prev, &mut curr);
}
prev[short.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn punctuation_and_casing_normalize_away_to_a_perfect_match() {
let cmp = compare("Hello, world!", Ok(Some("hello WORLD")));
assert_eq!(cmp.outcome, ShadowOutcome::Usable);
assert!(
cmp.exact_match,
"punctuation/casing/spacing must normalize away"
);
assert_eq!(cmp.similarity, Some(1.0));
assert_eq!(cmp.whisper_words, 2);
assert_eq!(cmp.apple_words, 2);
assert!(cmp.apple_error.is_none());
}
#[test]
fn timestamped_whisper_lines_are_stripped_before_comparison() {
let cmp = compare("[0:05] hello world", Ok(Some("hello world")));
assert_eq!(cmp.outcome, ShadowOutcome::Usable);
assert!(cmp.exact_match, "timestamp prefix must be stripped");
assert_eq!(cmp.similarity, Some(1.0));
assert_eq!(cmp.whisper_words, 2, "the 0 and 05 must not count as words");
}
#[test]
fn contractions_and_abbreviations_fold_to_a_match() {
let cmp = compare("I can't visit the U.S.", Ok(Some("i cant visit the us")));
assert_eq!(cmp.outcome, ShadowOutcome::Usable);
assert!(cmp.exact_match, "can't==cant and U.S.==us");
assert_eq!(cmp.similarity, Some(1.0));
}
#[test]
fn a_one_word_difference_lowers_similarity_but_stays_usable() {
let cmp = compare("the quick brown fox", Ok(Some("the quick red fox")));
assert_eq!(cmp.outcome, ShadowOutcome::Usable);
assert!(!cmp.exact_match);
let s = cmp.similarity.expect("scored");
assert!((s - 0.75).abs() < 1e-6, "got {s}");
}
#[test]
fn completely_different_text_scores_zero() {
let cmp = compare("alpha beta gamma", Ok(Some("one two three")));
assert_eq!(cmp.outcome, ShadowOutcome::Usable);
assert_eq!(cmp.similarity, Some(0.0));
assert!(!cmp.exact_match);
}
#[test]
fn an_empty_apple_result_is_recorded_without_a_failure() {
let cmp = compare("whisper had text", Ok(None));
assert_eq!(cmp.outcome, ShadowOutcome::Empty);
assert_eq!(cmp.apple_words, 0);
assert_eq!(cmp.whisper_words, 3);
assert_eq!(cmp.similarity, None);
assert!(cmp.apple_error.is_none());
}
#[test]
fn a_blank_or_punctuation_only_some_is_empty_not_usable() {
for blank in ["", " ", " ...!? "] {
let cmp = compare("whisper had text", Ok(Some(blank)));
assert_eq!(
cmp.outcome,
ShadowOutcome::Empty,
"{blank:?} should classify as Empty"
);
assert_eq!(cmp.apple_words, 0);
assert_eq!(cmp.similarity, None);
}
}
#[test]
fn a_failed_attempt_records_the_caller_supplied_category() {
let cmp = compare("whisper had text", Err(ShadowError::XpcInterrupted));
assert_eq!(cmp.outcome, ShadowOutcome::Failed);
assert_eq!(cmp.apple_words, 0);
assert_eq!(cmp.similarity, None);
assert_eq!(cmp.apple_error, Some(ShadowError::XpcInterrupted));
assert_eq!(cmp.whisper_words, 3);
}
#[test]
fn similarity_is_symmetric_in_edit_distance() {
let a = compare("one two three four", Ok(Some("one two three")));
let b = compare("one two three", Ok(Some("one two three four")));
assert_eq!(a.similarity, b.similarity);
let s = a.similarity.expect("scored");
assert!((s - 0.75).abs() < 1e-6, "got {s}");
}
#[test]
fn differing_transcripts_over_the_cap_are_unscored_not_faked() {
let big_a = (0..=SIMILARITY_WORD_CAP)
.map(|i| format!("a{i}"))
.collect::<Vec<_>>()
.join(" ");
let big_b = (0..=SIMILARITY_WORD_CAP)
.map(|i| format!("b{i}"))
.collect::<Vec<_>>()
.join(" ");
let cmp = compare(&big_a, Ok(Some(&big_b)));
assert_eq!(cmp.outcome, ShadowOutcome::Usable);
assert!(!cmp.exact_match);
assert_eq!(cmp.similarity, None);
}
#[test]
fn identical_over_the_cap_still_scores_one() {
let big = (0..=SIMILARITY_WORD_CAP)
.map(|i| format!("w{i}"))
.collect::<Vec<_>>()
.join(" ");
let cmp = compare(&big, Ok(Some(&big)));
assert!(cmp.exact_match);
assert_eq!(
cmp.similarity,
Some(1.0),
"equality is cheap and definitive"
);
}
#[test]
fn shadow_is_off_by_default() {
let config = Config::default();
assert!(
!shadow_enabled(&config),
"shadow mode must be off by default — it is a measurement opt-in"
);
}
#[test]
fn shadow_reads_the_config_flag_when_enabled() {
let config = Config {
transcription: crate::config::TranscriptionConfig {
apple_speech_shadow: true,
..Default::default()
},
..Default::default()
};
assert!(shadow_enabled(&config));
}
#[test]
fn worker_ok_maps_the_real_bridge_contract() {
assert_eq!(
worker_ok_to_shadow(false, "ignored"),
Err(ShadowError::SpeechError)
);
assert_eq!(worker_ok_to_shadow(true, ""), Ok(None));
assert_eq!(worker_ok_to_shadow(true, " "), Ok(None));
assert_eq!(
worker_ok_to_shadow(true, "hello world"),
Ok(Some("hello world".to_string()))
);
}
#[test]
fn transport_errors_are_categorized_by_kind() {
use std::io::ErrorKind;
assert_eq!(
transport_error_category(ErrorKind::Other),
ShadowError::XpcInterrupted
);
assert_eq!(
transport_error_category(ErrorKind::PermissionDenied),
ShadowError::WorkerUnavailable
);
assert_eq!(
transport_error_category(ErrorKind::InvalidData),
ShadowError::Unknown
);
}
fn long_samples() -> Vec<f32> {
vec![0.1_f32; SHADOW_MIN_SAMPLES]
}
fn runner_test_guard() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let guard = LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
SHADOW_ADMISSION.store(false, std::sync::atomic::Ordering::Release);
guard
}
#[test]
fn runner_skips_utterances_shorter_than_the_threshold() {
let _guard = runner_test_guard();
let runner =
ShadowRunner::spawn_with_sink("test", |_| Ok(Some("apple".to_string())), |_, _| {})
.expect("spawn shadow runner");
assert!(
!runner.try_submit(vec![0.1; 10], "whisper".to_string()),
"sub-threshold utterance must be dropped without a worker spawn"
);
}
#[test]
fn runner_compares_a_submitted_utterance_and_persists_via_the_sink() {
use std::sync::mpsc;
let _guard = runner_test_guard();
let (done_tx, done_rx) = mpsc::channel();
let runner = ShadowRunner::spawn_with_sink(
"test",
|_| Ok(Some("the quick brown fox".to_string())),
move |_, cmp| done_tx.send(cmp.clone()).unwrap(),
)
.expect("spawn shadow runner");
assert!(runner.try_submit(long_samples(), "the quick brown fox".to_string()));
let cmp = done_rx.recv().unwrap();
assert_eq!(cmp.outcome, ShadowOutcome::Usable);
assert!(cmp.exact_match);
assert_eq!(cmp.similarity, Some(1.0));
}
#[test]
fn runner_latches_off_after_a_failure_and_stops_attempting() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{mpsc, Arc};
let _guard = runner_test_guard();
let calls = Arc::new(AtomicUsize::new(0));
let thread_calls = Arc::clone(&calls);
let (done_tx, done_rx) = mpsc::channel();
let runner = ShadowRunner::spawn_with_sink(
"test",
move |_| {
thread_calls.fetch_add(1, Ordering::SeqCst);
Err(ShadowError::XpcInterrupted)
},
move |_, cmp| done_tx.send(cmp.outcome).unwrap(),
)
.expect("spawn shadow runner");
assert!(runner.try_submit(long_samples(), "whisper text".to_string()));
assert_eq!(done_rx.recv().unwrap(), ShadowOutcome::Failed);
assert!(!runner.try_submit(long_samples(), "whisper text".to_string()));
drop(runner);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"must not re-attempt after a failure"
);
}
}