diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 75d621b..5c8278c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -484,6 +484,7 @@ async function refreshSemanticStatus() { ? "Speech models ready · Spoken passages are waiting to be indexed." : "Local speech passages ready. Recognized words and timings can contain errors." : "Local speech passages are preparing. Filename and existing content search remain available."; + speechStatus.textContent = `Experimental speech search · ${speechStatus.textContent}`; } const retry = element("retry-model"); retry.hidden = diff --git a/crates/searchi-service/src/speech.rs b/crates/searchi-service/src/speech.rs index 43c91ed..d8e6cc5 100644 --- a/crates/searchi-service/src/speech.rs +++ b/crates/searchi-service/src/speech.rs @@ -24,6 +24,10 @@ pub trait SpeechProvider: Send { fn admission_us(&self) -> u64 { 0 } + /// Starts a new source without retrying a failed window from the previous source. + fn begin_source(&mut self) -> Result<()> { + Ok(()) + } fn transcribe(&mut self, pcm: &[f32], sample_rate: u32) -> Result; } @@ -31,6 +35,9 @@ impl SpeechProvider for searchi_speech::SpeechWorker { fn fingerprint(&self) -> &str { searchi_speech::SpeechWorker::fingerprint(self) } + fn begin_source(&mut self) -> Result<()> { + searchi_speech::SpeechWorker::begin_source(self) + } fn transcribe(&mut self, pcm: &[f32], sample_rate: u32) -> Result { searchi_speech::SpeechWorker::transcribe(self, pcm, sample_rate) } @@ -58,6 +65,13 @@ impl SpeechProvider for BudgetedSpeechProvider { fn admission_us(&self) -> u64 { self.admission_us } + fn begin_source(&mut self) -> Result<()> { + let start = std::time::Instant::now(); + let permit = self.budget.acquire(InferenceRole::Background, 1); + self.admission_us = start.elapsed().as_micros().min(u64::MAX as u128) as u64; + let _permit = permit?; + self.inner.begin_source() + } fn transcribe(&mut self, pcm: &[f32], sample_rate: u32) -> Result { let start = std::time::Instant::now(); let permit = self.budget.acquire(InferenceRole::Background, 1); @@ -275,6 +289,10 @@ impl HybridSearch { let filename = path.file_name().unwrap_or_default().to_string_lossy(); let mut ordinal = 0usize; if metadata.has_audio { + self.speech + .as_mut() + .ok_or_else(|| anyhow::anyhow!("Speech provider unavailable"))? + .begin_source()?; for window in speech_windows(metadata.audio_start_ms, metadata.audio_end_ms)? { let pcm = decode_audio_aligned(tools, path, window, limits)?; let provider = self @@ -372,6 +390,49 @@ impl HybridSearch { mod tests { use super::*; #[test] + fn new_source_recovers_provider_without_retrying_failed_window() -> Result<()> { + struct SourceProvider { + sources: usize, + calls: usize, + failed: bool, + } + impl SpeechProvider for SourceProvider { + fn fingerprint(&self) -> &str { + "lifecycle-test" + } + fn begin_source(&mut self) -> Result<()> { + self.sources += 1; + self.failed = false; + Ok(()) + } + fn transcribe(&mut self, _: &[f32], _: u32) -> Result { + self.calls += 1; + if self.sources == 1 || self.failed { + self.failed = true; + anyhow::bail!("first source transport failure"); + } + Ok(SpeechTranscription { + passages: Vec::new(), + metrics: SpeechMetrics::default(), + }) + } + } + let mut provider = SourceProvider { + sources: 0, + calls: 0, + failed: false, + }; + provider.begin_source()?; + let first_failure = provider.transcribe(&[0.], 16000).unwrap_err().to_string(); + assert_eq!(provider.calls, 1, "failed PCM is never retried"); + provider.begin_source()?; + provider.transcribe(&[0.], 16000)?; + assert_eq!(provider.calls, 2); + assert_eq!(provider.sources, 2); + assert_eq!(first_failure, "first source transport failure"); + Ok(()) + } + #[test] fn point_spans_are_legal_and_invalid_native_evidence_is_rejected() -> Result<()> { let mut result = SpeechTranscription { passages: vec![SpeechPassage { diff --git a/crates/searchi-speech/src/lib.rs b/crates/searchi-speech/src/lib.rs index 6282e9a..dc22a33 100644 --- a/crates/searchi-speech/src/lib.rs +++ b/crates/searchi-speech/src/lib.rs @@ -52,6 +52,7 @@ pub struct SpeechWorker { base_path: PathBuf, vad_path: PathBuf, device: Device, + reset_on_next_source: bool, } fn verified_file(path: &Path, bytes: u64, expected: &str) -> Result { @@ -114,11 +115,31 @@ impl SpeechWorker { base_path: base.to_owned(), vad_path: vad.to_owned(), device, + reset_on_next_source: false, }) } pub fn fingerprint(&self) -> &str { &self.fingerprint } + /// Recover a failed transport only at admission of a different source. + /// Never retries the previous source or malformed native output. + pub fn begin_source(&mut self) -> Result<()> { + if !std::mem::take(&mut self.reset_on_next_source) { + return Ok(()); + } + let replacement = Self::load( + &self.worker_path, + &self.base_path, + &self.vad_path, + self.device, + )?; + ensure!( + replacement.fingerprint == self.fingerprint, + "Speech assets changed before source recovery" + ); + *self = replacement; + Ok(()) + } pub fn transcribe(&mut self, pcm: &[f32], sample_rate: u32) -> Result { ensure!( sample_rate == SAMPLE_RATE && !pcm.is_empty() && pcm.len() <= MAX_SAMPLES, @@ -126,7 +147,7 @@ impl SpeechWorker { ); ensure!(pcm.iter().all(|v| v.is_finite()), "Nonfinite speech input"); let fallback_started = Instant::now(); - match self.transcribe_once(pcm) { + let result = (|| match self.transcribe_once(pcm) { Ok(value) => Ok(value), Err(error) if self.device == Device::Auto && process::recoverable(&error) => { // Retry only initialization/compute/transport failures. Reverify @@ -148,7 +169,9 @@ impl SpeechWorker { Ok(result) } Err(error) => Err(error), - } + })(); + self.reset_on_next_source = result.as_ref().err().is_some_and(process::failed_transport); + result } fn transcribe_once(&mut self, pcm: &[f32]) -> Result { let started = Instant::now(); diff --git a/crates/searchi-speech/src/process.rs b/crates/searchi-speech/src/process.rs index c4543e8..b386783 100644 --- a/crates/searchi-speech/src/process.rs +++ b/crates/searchi-speech/src/process.rs @@ -60,6 +60,9 @@ pub(crate) fn recoverable(error: &anyhow::Error) -> bool { .downcast_ref::() .is_some_and(NativeFailure::recoverable) } +pub(crate) fn failed_transport(error: &anyhow::Error) -> bool { + error.is::() +} fn read_response(reader: &mut R) -> Result { let mut prefix = [0; 4]; @@ -259,6 +262,7 @@ mod tests { .exchange(vec![1], Duration::from_millis(50)) .unwrap_err(); assert!(recoverable(&error)); + assert!(failed_transport(&error)); assert!(process.child.try_wait().unwrap().is_some()); assert!(process.failed); let mut command = Command::new(&python); @@ -268,6 +272,7 @@ mod tests { .exchange(vec![1], Duration::from_secs(1)) .unwrap_err(); assert!(!recoverable(&error)); + assert!(!failed_transport(&error)); assert!(process.child.try_wait().unwrap().is_some()); assert!(process.failed); } @@ -279,9 +284,12 @@ mod tests { anyhow::Error::new(ProtocolFailure), ] { assert!(!recoverable(&error)); + assert!(!failed_transport(&error)); } assert!(recoverable(&NativeFailure::Initialization.into())); assert!(recoverable(&NativeFailure::Compute.into())); + assert!(!failed_transport(&NativeFailure::Initialization.into())); + assert!(!failed_transport(&NativeFailure::Compute.into())); assert!(!recoverable( &read_response(&mut &[2, 0, 0, 0, 0xff, 0xff][..]).unwrap_err() )); diff --git a/docs/SPEECH-INTEGRATION.md b/docs/SPEECH-INTEGRATION.md index 1ee794d..9571f35 100644 --- a/docs/SPEECH-INTEGRATION.md +++ b/docs/SPEECH-INTEGRATION.md @@ -88,7 +88,7 @@ Raw log `.searchi/qa/service-speech-google-cpu-v3.log`: 1292 bytes, SHA The first normal auto compilation reached Vulkan setup but failed on Windows nested MSBuild path length. Its log is retained, SHA `59f1820d6ac6adac6f525d8084c442c1ed261753eb34fcce916dc157041f777c`. -An unchanged build in a shorter owned output directory is underway; no CPU-only +An unchanged build in a shorter owned output directory was started; no CPU-only distribution was substituted. The shorter-path normal auto build subsequently completed. Worker SHA @@ -122,3 +122,24 @@ The complete report is 174868 bytes, SHA It uses the internal v3 CPU artifact and does not establish normal auto runtime quality parity or GPU performance. Details and the retained comparison are in [the evaluator](../tools/speech-eval/native-validation/README.md). + +## Longer-source failure and release recovery + +The normal-runtime timing regression retained all five original sources and +all eight queries, but the speech session returned a transport failure on the +first source and later files inherited the unavailable session. Raw Google +indexing completed with 79 chunks and no media failures. Recognized timing +scores remain ineligible; no historical 6/8 score is claimed for this run. + +An isolated native reproduction using the **exact failed PCM hash** +`03ef72d2a4cb926b7d31fbaef757a6cb954a55d6d6961085ea6992728c8d9e37` +completed all four actual VAD intervals, valid recognition responses and exit +zero. The original transport failure is unexplained; no model or memory tuning +was introduced to hide it. + +The release now restarts a verified session once at the next new source after +a terminal recoverable transport failure. The original failed source remains +reported; compatible donors return before restarting and raw search stays +usable. Malformed protocol/text and invalid token points never arm this reset. +Five focused service tests and native transport/lifecycle checks passed with +strict Clippy. Media and spoken passages remain visibly experimental. diff --git a/tools/speech-eval/native-validation/README.md b/tools/speech-eval/native-validation/README.md index 7a3db64..4937c9d 100644 --- a/tools/speech-eval/native-validation/README.md +++ b/tools/speech-eval/native-validation/README.md @@ -152,3 +152,28 @@ Seven Rust tests, strict all-target Clippy and five timing-adapter tests passed. A retry must use a fresh output directory after the corrected evaluator is frozen and the CPU inference slot is granted again; the failed attempt remains preserved and has no quality score. + +The corrected frozen attempt (commit `f15ee07`, session 39812) completed on CPU +at `.searchi/qa/speech-live-timing-v1-coverage`, but is **not timing-eligible**. +All five source checks passed; raw indexing produced 79 chunks (16 audio and +63 image calls), with no media failures, in 377691.6842 ms. The first speech +request for the video's initial 30-second window failed with +`Speech worker transport exited or timed out` after 2382.0169 ms. The four +remaining sources reported `Unavailable speech session or unbounded PCM`. +Five failed calls were recorded against 16 expected windows; zero recognized +speech chunks or citations were published. The public client discards stderr, +so these artifacts cannot identify the native failure cause. The request +deadline is 600 seconds, longer than the observed first-call failure. + +All eight first files were `clip04`, but their actual origins were raw audio or +video, not recognized speech. The unchanged scorer therefore records +`comparison_eligible: false`, `strict_point_score: null` and +`correct_first_file: null`; no historical score carries over. +Immutable report SHA-256: +`7261223ba479ff1c319ded187fcd6e7cf37d351ec9d62437e26b63017d1bccc2`; +scored artifact SHA-256: +`361d5fbaf917363bfedd9c3ba2ea368d1714cf28f1bc34544a9ed324761eb4db`; +preflight SHA-256: +`97c603b3078831ce0fcf19f0ebb9e15160ee2363b488946c992e881d5509ce6b`. +This failed speech lifecycle requires investigation before any live-window +timing claim; no policy, labels or sources were changed after this result. diff --git a/tools/speech-runtime/README.md b/tools/speech-runtime/README.md index 64dcdae..5cf84e0 100644 --- a/tools/speech-runtime/README.md +++ b/tools/speech-runtime/README.md @@ -57,6 +57,11 @@ path; the failed log SHA-256 is An unchanged-source build using a shorter owned output path completed. Prefer a short build output directory on Windows; no CPU-only distribution was substituted. +If a worker transport dies, the failing source retains its error. `begin_source` +permits one verified same-fingerprint restart at the next source boundary, keeping +the current CPU/device choice. Malformed protocol, UTF-8 or alignment outputs do +not authorize recovery or repeated inference of the failing source. + The persistent worker receives binary requests: operation byte (1 live VAD, 2 contiguous recognition), little-endian u32 sample count and mono little-endian f32 PCM, at most 480000 finite samples. Responses have a four-byte little-endian