Skip to main content

seyk_core/audio/
extraction.rs

1//! Explicit audio extraction; deliberately separate from generic preview dispatch.
2use crate::extractor::{ExtractionProvenance, SpeechProvenance};
3use crate::{
4    ExtractedContent, ResourceId, ResourceLocator, ResourceMetadata, ResourceProvider, Result,
5};
6use std::sync::Arc;
7
8/// A derived transcript. Intentionally has no Debug implementation: text is content.
9pub struct Transcript {
10    pub text: String,
11    pub language: Option<String>,
12    pub no_speech: bool,
13}
14
15/// One bounded mono 16 kHz interval, already verified against the selected hit.
16/// Implementations must stay offline and must not log or persist content/tokens.
17pub trait SpeechToText: Send + Sync {
18    fn implementation(&self) -> &'static str;
19    fn manifest_sha256(&self) -> String;
20    /// `language = None` requests detection. No language is returned for no_speech.
21    fn transcribe(&self, samples: &[f32], language: Option<&str>) -> Result<Transcript>;
22}
23
24/// Verified source slicing followed by on-demand STT; no embedding or cache access.
25#[derive(Clone)]
26pub struct AudioExtractor {
27    speech: Arc<dyn SpeechToText>,
28    language: Option<String>,
29}
30impl AudioExtractor {
31    pub fn new(speech: impl SpeechToText + 'static) -> Self {
32        Self {
33            speech: Arc::new(speech),
34            language: None,
35        }
36    }
37    /// Explicit language code; otherwise detect from the selected interval only.
38    pub fn language(mut self, language: impl Into<String>) -> Self {
39        self.language = Some(language.into());
40        self
41    }
42    pub fn extract(
43        &self,
44        provider: &dyn ResourceProvider,
45        source: &ResourceId,
46        locator: &ResourceLocator,
47        metadata: &ResourceMetadata,
48        max_duration_secs: u64,
49    ) -> Result<ExtractedContent> {
50        // Must precede even lazy model loading. No unverified source reaches STT.
51        let samples =
52            super::extract_samples(provider, source, locator, metadata, max_duration_secs)?;
53        let ResourceLocator::Time { start_ms, .. } = locator else {
54            unreachable!("slice validates locator")
55        };
56        let sample_start = start_ms * 16; // checked by slice_verified
57        let sample_end = sample_start + samples.len() as u64;
58        let result = self.speech.transcribe(&samples, self.language.as_deref())?;
59        let (text, language_detected) = if result.no_speech {
60            (String::new(), None)
61        } else {
62            (result.text, result.language)
63        };
64        Ok(ExtractedContent {
65            text,
66            source: source.clone(),
67            locator: locator.clone(),
68            metadata: metadata.clone(),
69            provenance: Some(ExtractionProvenance {
70                method: "speech_to_text".into(),
71                implementation: self.speech.implementation().into(),
72                speech: Some(SpeechProvenance {
73                    fidelity: "machine_transcription".into(),
74                    requested_locator: locator.clone(),
75                    sample_start,
76                    sample_end,
77                    sample_rate: 16_000,
78                    manifest_sha256: self.speech.manifest_sha256(),
79                    language_requested: self.language.clone(),
80                    language_detected,
81                    no_speech: result.no_speech,
82                }),
83            }),
84        })
85    }
86}