Skip to main content

synapto_interface/
speech_to_text.rs

1use crate::cognitive::CognitiveOutputSpeech;
2use crate::cognitive_output_audio::CognitiveOutputAudio;
3use crate::peer_input_audio::{PEER_INPUT_AUDIO_CHUNK_DURATION, PeerInputAudio};
4use crate::plugin::Plugin;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8
9/// Calculates the discrete audio chunk indices for a given continuous time range.
10///
11/// This method converts real-world time (in seconds) into discrete audio chunk sequence numbers.
12/// To prevent dropping short words and correctly handle boundary overlaps, it deliberately applies:
13/// - `floor` to the start time: ensuring the entire chunk where the word begins is included.
14/// - `ceil` to the end time: ensuring the chunk where the word ends is captured, forming a
15///   strict mathematically exclusive boundary `[start_index, end_index)`.
16pub fn calculate_chunk_indices(base_index: u64, start_secs: f64, end_secs: f64) -> (u64, u64) {
17    let chunk_duration_secs = PEER_INPUT_AUDIO_CHUNK_DURATION.as_secs_f64();
18    let start_index = base_index + (start_secs / chunk_duration_secs).floor() as u64;
19    let end_index = base_index + (end_secs / chunk_duration_secs).ceil() as u64;
20    (start_index, end_index)
21}
22
23/// Signal indicating that speech activity has been detected.
24#[derive(Clone)]
25pub struct SpeechDetected(Arc<tokio::sync::Notify>);
26
27impl SpeechDetected {
28    pub fn new(notify: Arc<tokio::sync::Notify>) -> Self {
29        Self(notify)
30    }
31    pub fn notify(&self) {
32        self.0.notify_waiters();
33    }
34}
35
36/// A unique identifier for a speaker.
37#[derive(
38    Serialize,
39    Deserialize,
40    PartialEq,
41    Eq,
42    Hash,
43    Debug,
44    Clone,
45    JsonSchema,
46    derive_more::Display,
47    derive_more::From,
48    derive_more::Deref,
49)]
50pub struct SpeakerId(pub String);
51
52impl SpeakerId {
53    pub fn new(speaker_id: String) -> Self {
54        Self(speaker_id)
55    }
56}
57
58/// Represents a single word within a transcription.
59#[derive(Deserialize, Serialize, Default, Clone, Debug, JsonSchema)]
60pub struct Word {
61    /// The start index of the audio chunk where this word begins.
62    pub start_index: Option<u64>,
63    /// The end index of the audio chunk where this word ends.
64    pub end_index: Option<u64>,
65    /// The text of the word.
66    pub word: String,
67    /// Optional hint about the speaker identity for this specific word.
68    pub speaker_hint: Option<String>,
69}
70
71/// A transcribed segment of speech.
72#[derive(Deserialize, Serialize, Default, Clone, Debug, JsonSchema)]
73pub struct SpeechTranscript {
74    /// The sequence number of the starting audio chunk.
75    pub start_index: u64,
76    /// The sequence number of the ending audio chunk.
77    pub end_index: u64,
78    /// The complete transcribed text for this segment.
79    pub transcript: String,
80    /// Individual words with their respective timing and metadata.
81    pub words: Vec<Word>,
82}
83
84/// Represents an audio chunk with its associated voice activity status.
85#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
86pub enum InputVoiceAudio {
87    /// The chunk contains active voice.
88    Voice(PeerInputAudioIndexed),
89    /// The chunk contains silence or non-voice background noise.
90    NoVoice(PeerInputAudioIndexed),
91}
92
93/// A raw audio chunk bundled with its sequence sequence index.
94#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
95pub struct PeerInputAudioIndexed {
96    /// The raw audio data.
97    pub audio: PeerInputAudio,
98    /// The monotonically increasing sequence number of this chunk.
99    pub index: u64,
100}
101
102#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
103pub struct CosineSimilarity(pub f32);
104
105#[derive(Clone, Debug)]
106pub struct WordOverlap {
107    pub start_index: u64,
108    pub end_index: u64,
109    pub overlaps: std::collections::HashMap<InternalSpeaker, u64>,
110    pub word: String,
111}
112
113pub type SpeakerHeuristicCallback = std::sync::Arc<
114    dyn Fn(&[WordOverlap], &[SpeakerSegment]) -> Vec<Option<SpeakerId>> + Send + Sync,
115>;
116
117#[derive(Serialize, Deserialize, Debug, Clone)]
118pub enum InternalSpeaker {
119    Unknown(Option<(SpeakerId, CosineSimilarity)>),
120    Recognized(SpeakerId),
121}
122
123impl PartialEq for InternalSpeaker {
124    fn eq(&self, other: &Self) -> bool {
125        match (self, other) {
126            (InternalSpeaker::Unknown(a), InternalSpeaker::Unknown(b)) => match (a, b) {
127                (Some((id_a, score_a)), Some((id_b, score_b))) => {
128                    id_a == id_b && score_a.0 == score_b.0
129                }
130                (None, None) => true,
131                _ => false,
132            },
133            (InternalSpeaker::Recognized(a), InternalSpeaker::Recognized(b)) => a == b,
134            _ => false,
135        }
136    }
137}
138
139impl Eq for InternalSpeaker {}
140
141impl std::hash::Hash for InternalSpeaker {
142    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
143        match self {
144            InternalSpeaker::Unknown(opt) => {
145                0_u8.hash(state);
146                if let Some((id, score)) = opt {
147                    1_u8.hash(state);
148                    id.hash(state);
149                    score.0.to_bits().hash(state);
150                } else {
151                    0_u8.hash(state);
152                }
153            }
154            InternalSpeaker::Recognized(id) => {
155                1_u8.hash(state);
156                id.hash(state);
157            }
158        }
159    }
160}
161
162impl From<InternalSpeaker> for crate::peer_input::Speaker {
163    fn from(val: InternalSpeaker) -> Self {
164        match val {
165            InternalSpeaker::Unknown(None) => crate::peer_input::Speaker::Unknown(None),
166            InternalSpeaker::Unknown(Some((id, _))) => crate::peer_input::Speaker::Unknown(Some(
167                SpeakerId(format!("Maybe {}", id).to_string()),
168            )),
169            InternalSpeaker::Recognized(id) => {
170                crate::peer_input::Speaker::Recognized(SpeakerId(id.to_string()))
171            }
172        }
173    }
174}
175
176#[derive(Clone, Debug, Serialize, Deserialize)]
177pub struct SpeakerSegment {
178    pub speaker: InternalSpeaker,
179    pub start_index: u64,
180    pub end_index: u64,
181}
182
183impl From<InputVoiceAudio> for PeerInputAudio {
184    fn from(input_voice_audio: InputVoiceAudio) -> Self {
185        match input_voice_audio {
186            InputVoiceAudio::Voice(peer_input_audio) => peer_input_audio.audio,
187            InputVoiceAudio::NoVoice(peer_input_audio) => peer_input_audio.audio,
188        }
189    }
190}
191
192impl From<InputVoiceAudio> for [i32; crate::peer_input_audio::PEER_INPUT_AUDIO_CHUNK_SIZE] {
193    fn from(input_voice_audio: InputVoiceAudio) -> Self {
194        let peer_input_audio: PeerInputAudio = input_voice_audio.into();
195        peer_input_audio.into()
196    }
197}
198
199use crate::sync::{broadcast, mpsc};
200use async_trait::async_trait;
201#[async_trait]
202pub trait STTPlugin: Plugin + Send + Sync {
203    async fn start(
204        &self,
205        audio_rx: mpsc::Receiver<InputVoiceAudio>,
206        transcript_tx: mpsc::Sender<SpeechTranscript>,
207        speech_detected: SpeechDetected,
208    ) -> Result<(), String>;
209}
210#[async_trait]
211pub trait TTSPlugin: Plugin + Send + Sync {
212    async fn start(
213        &self,
214        speech_rx: broadcast::Receiver<CognitiveOutputSpeech>,
215        audio_tx: mpsc::Sender<CognitiveOutputAudio>,
216    ) -> Result<(), String>;
217}
218#[async_trait]
219pub trait DiarizationPlugin: Plugin + Send + Sync {
220    async fn start(
221        &self,
222        audio_rx: broadcast::Receiver<InputVoiceAudio>,
223        segment_tx: mpsc::Sender<SpeakerSegment>,
224    ) -> Result<(), String>;
225
226    fn heuristic(&self) -> Option<crate::speech_to_text::SpeakerHeuristicCallback> {
227        None
228    }
229}