Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bc59945
feat(voice): deliver provisional speech before transcription finishes
johnmatthewtennant Oct 29, 2026
8d94889
test(voice): exercise provisional and final call delivery in the rend…
johnmatthewtennant Oct 29, 2026
2be1baa
fix(voice): reconcile recognition while early speech is held
johnmatthewtennant Oct 29, 2026
c9c6d10
fix(voice): release speculative speech after terminal delivery failure
johnmatthewtennant Oct 29, 2026
aa9217a
fix(voice): preserve meaningful signs in final transcript comparison
johnmatthewtennant Oct 29, 2026
93e289f
fix(voice): retain provisional run causality across microphone mute
johnmatthewtennant Oct 29, 2026
986a7f7
fix(voice): keep provisional answers held through microphone mute
johnmatthewtennant Oct 29, 2026
10853ed
fix(voice): preserve case-sensitive transcript corrections
johnmatthewtennant Oct 29, 2026
4101b89
fix(voice): preserve recognition identity through late finals
johnmatthewtennant Oct 29, 2026
d893820
fix(voice): isolate and bound provisional preparation
johnmatthewtennant Oct 29, 2026
c803760
feat(voice): add default-on experimental early delivery setting
johnmatthewtennant Oct 29, 2026
b51cf2c
fix(voice): correlate cumulative Apple recognition segments
johnmatthewtennant Oct 29, 2026
af5c6eb
fix(voice): use parent experiment configuration for early delivery
johnmatthewtennant Oct 29, 2026
5b7e9b5
fix(voice): create draft sessions hidden from the outset
johnmatthewtennant Oct 29, 2026
c866a55
test(voice): describe delayed private session creation
johnmatthewtennant Oct 29, 2026
43dfcf9
refactor(voice): remove unused turn-scoped identity ledger
johnmatthewtennant Oct 29, 2026
a858efe
test(voice): preserve session paths after monitor fixture integration
johnmatthewtennant Oct 29, 2026
757b78a
docs(voice): explain pending transcript delivery in settings
johnmatthewtennant Oct 30, 2026
73f6436
fix(voice): prepare from interim transcripts during active speech
johnmatthewtennant Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src-tauri/crates/berd-call/src/benchmark/stt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ fn observe_event(
tracker.speaking_ended = Some(now);
}
}
Some(VoiceInputEvent::InterimTranscript(_)) => {}
Some(VoiceInputEvent::InterimTranscript { .. }) => {}
Some(VoiceInputEvent::RecognitionPendingChanged(active)) => {
tracker.pending = active;
if active {
Expand All @@ -576,6 +576,7 @@ fn observe_event(
Some(VoiceInputEvent::FinalTranscript {
text,
storage_receipt,
..
}) => {
if tracker.final_text.is_some() {
tracker.error =
Expand Down Expand Up @@ -1044,6 +1045,7 @@ mod tests {
let (first_receipt, first_ack) = crate::input::FinalTranscriptStorageReceipt::test_pair();
observe_event(
Some(VoiceInputEvent::FinalTranscript {
recognition_id: "recognition".into(),
text: "stored words".into(),
storage_receipt: first_receipt,
}),
Expand All @@ -1058,6 +1060,7 @@ mod tests {
crate::input::FinalTranscriptStorageReceipt::test_pair();
assert!(observe_event(
Some(VoiceInputEvent::FinalTranscript {
recognition_id: "recognition".into(),
text: "duplicate words".into(),
storage_receipt: duplicate_receipt,
}),
Expand All @@ -1076,6 +1079,7 @@ mod tests {
fn late_final_is_a_shutdown_failure_and_is_not_acknowledged() {
let (receipt, acknowledgement) = crate::input::FinalTranscriptStorageReceipt::test_pair();
let error = observe_shutdown_event(VoiceInputEvent::FinalTranscript {
recognition_id: "recognition".into(),
text: "late words".into(),
storage_receipt: receipt,
})
Expand Down
175 changes: 159 additions & 16 deletions src-tauri/crates/berd-call/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,13 @@ pub enum VoiceInputEvent {
SpeakingChanged(bool),
/// Agent speech admission hold, which may expire before recognition finishes.
RecognitionPendingChanged(bool),
/// Display only: words Apple is still transcribing, or empty once none are.
/// Never stored, delivered or treated as an utterance.
InterimTranscript(String),
/// Provisional words, identified by their pending recognition rather than its admission hold.
InterimTranscript {
recognition_id: String,
text: String,
},
FinalTranscript {
recognition_id: String,
text: String,
storage_receipt: FinalTranscriptStorageReceipt,
},
Expand Down Expand Up @@ -726,6 +729,10 @@ struct PendingRecognitions {
unfinalized: bool,
/// The interim text last shown, so repeats and clears are sent once.
interim: String,
interim_recognition_id: String,
/// Apple recognition segments can span multiple VAD admission holds.
#[cfg(any(target_os = "macos", test))]
segment_id: Option<String>,
}

impl PendingRecognitions {
Expand Down Expand Up @@ -795,11 +802,43 @@ impl PendingRecognitions {
self.unfinalized = false;
}

/// Apple reports cumulative text until a final, independent of VAD pauses.
#[cfg(any(target_os = "macos", test))]
fn show_segment_interim(&mut self, events: &tokio_mpsc::Sender<VoiceInputEvent>, text: &str) {
let id = self
.segment_id
.get_or_insert_with(|| uuid::Uuid::new_v4().to_string())
.clone();
self.emit_interim(events, text, id);
}

#[cfg(any(target_os = "macos", test))]
fn take_segment_recognition_id(&mut self) -> String {
self.segment_id
.take()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
}

fn clear_interim(&mut self, events: &tokio_mpsc::Sender<VoiceInputEvent>) {
self.emit_interim(events, "", String::new());
}

/// Interim text is best effort: a full event queue drops it rather than stall recognition.
fn show_interim(&mut self, events: &tokio_mpsc::Sender<VoiceInputEvent>, text: &str) {
if self.interim != text {
fn emit_interim(
&mut self,
events: &tokio_mpsc::Sender<VoiceInputEvent>,
text: &str,
recognition_id: String,
) {
if self.interim != text
|| (!text.is_empty() && self.interim_recognition_id != recognition_id)
{
self.interim = text.to_string();
let _ = events.try_send(VoiceInputEvent::InterimTranscript(self.interim.clone()));
self.interim_recognition_id = recognition_id.clone();
let _ = events.try_send(VoiceInputEvent::InterimTranscript {
recognition_id,
text: self.interim.clone(),
});
}
}

Expand Down Expand Up @@ -837,7 +876,7 @@ impl PendingRecognitions {

fn reset(&mut self, events: &tokio_mpsc::Sender<VoiceInputEvent>) -> Result<(), ()> {
self.release_hold(events)?;
self.show_interim(events, "");
self.clear_interim(events);
*self = Self::new();
Ok(())
}
Expand Down Expand Up @@ -887,6 +926,7 @@ fn clamp_vad_frame(samples: &[f32]) -> Vec<f32> {

fn send_final(
events: &tokio_mpsc::Sender<VoiceInputEvent>,
recognition_id: String,
text: String,
storage_deadline: Option<Instant>,
) -> Result<(), ()> {
Expand All @@ -898,6 +938,7 @@ fn send_final(
};
events
.blocking_send(VoiceInputEvent::FinalTranscript {
recognition_id,
text,
storage_receipt: receipt,
})
Expand Down Expand Up @@ -1488,7 +1529,12 @@ fn complete_parakeet_recognition(
storage_deadline: Option<Instant>,
) {
if !text.is_empty() {
let _ = send_final(events, text, storage_deadline);
let _ = send_final(
events,
uuid::Uuid::new_v4().to_string(),
text,
storage_deadline,
);
}
let _ = pending.resolve(events);
}
Expand Down Expand Up @@ -1520,8 +1566,9 @@ fn forward_mac_events(
match event {
crate::mac_speech::MacSpeechRecognitionEvent::Final(text, end) => {
let text = text.trim().to_string();
let recognition_id = pending.take_segment_recognition_id();
if !text.is_empty() {
send_final(events, text, storage_deadline)?;
send_final(events, recognition_id, text, storage_deadline)?;
}
// One final can span several turns split by pauses, and one turn
// can produce several finals, so a final with an end resolves
Expand All @@ -1535,12 +1582,12 @@ fn forward_mac_events(
*settle_deadline = None;
}
if !pending.words_outstanding() {
pending.show_interim(events, "");
pending.clear_interim(events);
}
}
crate::mac_speech::MacSpeechRecognitionEvent::Heard(_, text) => {
pending.unfinalized = true;
pending.show_interim(events, &text);
pending.show_segment_interim(events, &text);
}
crate::mac_speech::MacSpeechRecognitionEvent::Finished => {
if storage_deadline.is_none() {
Expand Down Expand Up @@ -1669,7 +1716,7 @@ fn mac_speech_worker(
return;
}
pending.abandon_heard_words();
pending.show_interim(&events, "");
pending.clear_interim(&events);
settle_deadline = None;
}
let frame = match frames.recv_timeout(Duration::from_millis(50)) {
Expand Down Expand Up @@ -1946,7 +1993,12 @@ fn deliver_openai_turns(
.then_some(final_storage_deadline)
.flatten();
if !text.is_empty() {
let _ = send_final(events, text, storage_deadline);
let _ = send_final(
events,
uuid::Uuid::new_v4().to_string(),
text,
storage_deadline,
);
}
let _ = pending.resolve(events);
}
Expand Down Expand Up @@ -2760,12 +2812,13 @@ mod tests {
VoiceInputEvent::FinalTranscript {
text,
storage_receipt,
..
} => {
assert_eq!(text, "fixture final");
storage_receipt.stored();
observed.push("final");
}
VoiceInputEvent::InterimTranscript(_) => {}
VoiceInputEvent::InterimTranscript { .. } => {}
VoiceInputEvent::Failed(error) => panic!("unexpected input failure: {error}"),
}
}
Expand Down Expand Up @@ -3103,13 +3156,89 @@ mod tests {
);
}

#[test]
fn apple_cumulative_segment_survives_pause_turns() {
let (events, mut receiver) = tokio_mpsc::channel(16);
let mut pending = PendingRecognitions::new();
pending.begin(&events).unwrap();
pending.show_segment_interim(&events, "first words");
pending.end_turn(&events, 100).unwrap();
pending.begin(&events).unwrap();
pending.show_segment_interim(&events, "first words and more");
let mut ids = Vec::new();
while let Ok(event) = receiver.try_recv() {
if let VoiceInputEvent::InterimTranscript { recognition_id, .. } = event {
ids.push(recognition_id);
}
}
assert_eq!(ids[0], ids[1], "one Apple segment across two VAD turns");
pending.release_hold(&events).unwrap();
assert_eq!(pending.take_segment_recognition_id(), ids[1]);
pending.show_segment_interim(&events, "next segment");
assert_ne!(pending.take_segment_recognition_id(), ids[1]);
}

#[test]
fn final_only_backends_generate_distinct_recognition_ids() {
for openai in [false, true] {
let (events, mut receiver) = tokio_mpsc::channel(16);
let mut pending = PendingRecognitions::new();
pending.begin(&events).unwrap();
pending.release_hold(&events).unwrap();
pending.begin(&events).unwrap();
for text in ["old final", "new final"] {
if openai {
let mut committed = VecDeque::from([OpenAiCommittedTurn {
item_id: text.into(),
mute_epoch: 0,
settle_deadline: Instant::now(),
}]);
let mut completed = HashMap::from([(text.into(), text.into())]);
deliver_openai_turns(
&mut committed,
&mut completed,
&events,
&mut pending,
None,
None,
);
} else {
complete_parakeet_recognition(text.into(), &events, &mut pending, None);
}
assert_eq!(pending.blocking, usize::from(text == "old final"));
}
let mut finals = Vec::new();
while let Ok(event) = receiver.try_recv() {
if let VoiceInputEvent::FinalTranscript {
recognition_id,
text,
..
} = event
{
assert_eq!(
uuid::Uuid::parse_str(&recognition_id)
.unwrap()
.get_version_num(),
4
);
finals.push((recognition_id, text));
}
}
assert_eq!(finals.len(), 2);
assert_ne!(finals[0].0, finals[1].0);
assert_eq!(finals[0].1, "old final");
assert_eq!(finals[1].1, "new final");
}
}

#[test]
fn final_storage_receipt_unblocks_the_bounded_sender() {
let (events, mut receiver) = tokio_mpsc::channel(1);
let (done_tx, done_rx) = mpsc::sync_channel(0);
let sender = thread::spawn(move || {
send_final(
&events,
"recognition".into(),
"stored words".to_string(),
Some(Instant::now() + Duration::from_secs(1)),
)
Expand All @@ -3119,6 +3248,7 @@ mod tests {
let VoiceInputEvent::FinalTranscript {
text,
storage_receipt,
..
} = receiver.blocking_recv().expect("final event")
else {
panic!("expected final transcript")
Expand All @@ -3139,6 +3269,7 @@ mod tests {
let sender = thread::spawn(move || {
send_final(
&events,
"recognition".into(),
"unstored words".to_string(),
Some(Instant::now() + Duration::from_secs(1)),
)
Expand Down Expand Up @@ -3166,8 +3297,20 @@ mod tests {
let (done_tx, done_rx) = mpsc::sync_channel(0);
let deadline = Instant::now() + Duration::from_millis(100);
let sender = thread::spawn(move || {
send_final(&events, "first".to_string(), Some(deadline)).unwrap();
send_final(&events, "second".to_string(), Some(deadline)).unwrap();
send_final(
&events,
"first-id".into(),
"first".to_string(),
Some(deadline),
)
.unwrap();
send_final(
&events,
"second-id".into(),
"second".to_string(),
Some(deadline),
)
.unwrap();
done_tx.send(()).unwrap();
});
let VoiceInputEvent::FinalTranscript {
Expand Down
8 changes: 6 additions & 2 deletions src-tauri/crates/berd-call/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4339,7 +4339,7 @@ fn handle_voice_input_event(
)?;
update_provisional_suspension(core, active)?;
}
VoiceInputEvent::InterimTranscript(text) => write_message(
VoiceInputEvent::InterimTranscript { text, .. } => write_message(
writer,
&SessionMessage::UserInterim {
token: *next_token,
Expand All @@ -4349,6 +4349,7 @@ fn handle_voice_input_event(
VoiceInputEvent::FinalTranscript {
text,
storage_receipt,
..
} => store_and_publish_voice_final(
text,
|| storage_receipt.stored(),
Expand Down Expand Up @@ -8182,7 +8183,10 @@ mod tests {
let mut output = Vec::new();
let mut next_token = 7;
handle_voice_input_event(
VoiceInputEvent::InterimTranscript("you sh".into()),
VoiceInputEvent::InterimTranscript {
recognition_id: "recognition".into(),
text: "you sh".into(),
},
&mut core,
&mut active,
&mut next_token,
Expand Down
Loading
Loading