Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion docs/livekit-connection-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ interview ended by the browser: room=interview-local topic=control
```

The browser joined the room, Jim answered turn after turn (`codetrial turn_timing`
with `outcome=audio`), barge-in worked (`Gemini cut its own turn`),
with `outcome=audio`), barge-in worked (`Gemini cut its own turn` at the
cut, then `candidate audio since the cut` once the words are transcribed),
and the session ended on the candidate pressing End interview. Voice runs both
ways: the microphone reaches the agent and Jim's audio reaches the browser.

Expand Down
28 changes: 10 additions & 18 deletions src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -703,27 +703,19 @@ impl SpeakerTurn {
spoken
}

/// Stable for the life of one turn, so the browser patches one row instead
/// of appending a new one per fragment.
/// The tail of what this speaker has said in the turn so far.
/// The start of what this speaker has said in the turn so far.
///
/// For the log only, and bounded: a cut turn is diagnosable from what the
/// candidate was heard saying at the moment it was cut, and a full turn in
/// a log line is not.
pub fn tail(&self, max: usize) -> &str {
let text = self.text.trim();

// No length test beside this. `nth_back` already answers it: it yields
// nothing when there are fewer than `max` characters, and when there
// are exactly `max` it lands on the first one, so the slice is the
// whole string either way. The condition that used to be here could be
// written as `>` or `>=` without changing a single result.
match text.char_indices().nth_back(max.saturating_sub(1)) {
Some((start, _)) => &text[start..],
None => text,
}
/// For the log only, and bounded: a full turn in a log line is not
/// diagnosable, and the start is where the words that caused a cut are.
/// Taken from the cleaned turn: room noise arrives as the transcription
/// artifact, and quoting it would make a cut with no words read as one
/// with words.
pub fn head(&self, max: usize) -> String {
self.text().chars().take(max).collect()
}

/// Stable for the life of one turn, so the browser patches one row instead
/// of appending a new one per fragment.
pub fn segment_id(&self, speaker: &str) -> String {
format!("{speaker}-{}", self.index)
}
Expand Down
8 changes: 8 additions & 0 deletions src/livekit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2932,6 +2932,14 @@ pub(crate) async fn run_room_with_slot(
}
}
.await;

// Every exit from the loop lands here, including the ones that never close
// the turns: the candidate not returning, the billing exits, the event
// stream ending, an error. A cut still waiting loses only its quote there,
// and this is the one place it is still printed, whatever exit is added.
if let Some(line) = turn.turns.take_cut_line(Instant::now(), false) {
eprintln!("{line}");
}
session::finish_turn_timing(
room.name(),
&mut turn.activity,
Expand Down
50 changes: 40 additions & 10 deletions src/livekit/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1777,24 +1777,30 @@ async fn on_interruption(
// activity detection, so a cut with the candidate mid-sentence is barge-in
// working. A cut with nothing transcribed is usually speaker echo or room
// noise, and naming those makes the log actionable without pretending the
// server can tell them apart.
let heard = context.turns.candidate.tail(80);
// server can tell them apart. The words trail the cut, so this line names
// the cut and goes out now, where no exit from the room can lose it, and
// the line quoting them follows once the turn after the cut closes.
let at = log_clock(context.state);
eprintln!(
"timing: Gemini cut its own turn, {:.1}s of it unplayed; candidate audio so far: {}",
unplayed.as_secs_f64(),
if heard.is_empty() {
"(nothing transcribed; check speaker echo or background noise)"
} else {
heard
}
"timing: Gemini cut its own turn at={at}, {:.1}s of it unplayed",
unplayed.as_secs_f64()
);

// A cut still waiting would be replaced below, so it is settled here, with
// the turn that holds its words, whatever the voice state.
if let Some(line) = context.turns.take_cut_line(Instant::now(), false) {
eprintln!("{line}");
}

// A cut-off turn is still over. Without this the next thing either party
// says appends to the abandoned turn under its segment id, so the panel
// would glue two separate utterances into one row and the report prompt
// would read them as one line.
close_turns(room, context);

// After the close: the turn it closed held the answer being replied to.
context.turns.note_cut(at, Instant::now());

set_agent_state(room, context.agent_state, AGENT_STATE_LISTENING);
Ok(())
}
Expand Down Expand Up @@ -2861,13 +2867,21 @@ fn take_stale_playout(
/// Both turns are counted in the ledger before either is published. The count
/// is what the report reads; the publish only tells the panel, and a failed
/// one used to leave the other speaker's closed turn uncounted.
///
/// A cut waiting for its words is settled here too, unless the candidate is
/// still speaking.
pub(super) fn close_turns(room: &RoomLink, context: &mut GeminiEventContext<'_>) {
let closing = close_both_turns(
let (cut_line, closing) = close_turns_settling_cut(
context.turns,
&mut context.state.evidence_ledger,
crate::current_epoch_millis(),
context.media.identity.as_deref(),
Instant::now(),
context.activity.candidate_voice_active,
);
if let Some(line) = cut_line {
eprintln!("{line}");
}
for segment in closing {
context.transcripts.publish(room, segment);
}
Expand All @@ -2887,6 +2901,22 @@ fn close_interviewer_turn(room: &RoomLink, context: &mut GeminiEventContext<'_>)
}
}

/// `close_both_turns`, and the line for the cut that was waiting for these
/// turns to close. The line is read first, because closing the candidate's turn
/// clears the words it quotes.
pub(super) fn close_turns_settling_cut(
turns: &mut SpeakerTurns,
ledger: &mut crate::agent::EvidenceLedger,
receipt_timestamp_ms: u64,
candidate_identity: Option<&str>,
now: Instant,
speaking: bool,
) -> (Option<String>, Vec<Segment>) {
let cut_line = turns.take_cut_line(now, speaking);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
let closing = close_both_turns(turns, ledger, receipt_timestamp_ms, candidate_identity);
Comment thread
Aatroxo1 marked this conversation as resolved.
Resolved
(cut_line, closing)
}

/// The room-free half of `close_turns`: finishes each open turn, counts it in
/// the ledger, and returns its final segment, in publishing order. Only the
/// candidate's own speech is attributed to them; the interviewer publishes as
Expand Down
68 changes: 68 additions & 0 deletions src/livekit/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2015,6 +2015,74 @@ pub(super) enum Interruptible {
pub(super) struct SpeakerTurns {
pub(super) interviewer: SpeakerTurn,
pub(super) candidate: SpeakerTurn,
/// The cut waiting for the words behind it; see [`SpeakerTurns::note_cut`].
cut: Option<WaitingCut>,
Comment thread
Aatroxo1 marked this conversation as resolved.
Resolved
}

/// How much of what the candidate said over a cut the line quotes.
const CUT_QUOTE_CHARS: usize = 80;

/// What the line for a cut needs from the moment of the cut, and the words
/// that closes while the candidate was speaking cleared from the turn since.
#[derive(Debug)]
struct WaitingCut {
at: String,
since: Instant,
heard: String,
}

/// The start of two stretches of speech, one after the other.
fn joined_head(earlier: &str, later: &str) -> String {
let mut words = String::from(earlier);
if !earlier.is_empty() && !later.is_empty() {
words.push(' ');
}
words.push_str(later);
words.chars().take(CUT_QUOTE_CHARS).collect()
}

impl SpeakerTurns {
/// Gemini cut the interviewer's turn, and the line naming the cut is
/// already out. `Interrupted` arrives with the start of the candidate's
/// activity, before the transcription of whatever caused it, so the line
/// quoting the cause waits for the words. Noted after the turn being
/// replied to has been closed, or that answer is what the line quotes. A
/// cut still waiting is replaced, so settle it first.
pub(super) fn note_cut(&mut self, at: String, since: Instant) {
self.cut = Some(WaitingCut {
at,
since,
heard: String::new(),
});
}

/// The line for the waiting cut, once, quoting the start of what the
/// candidate said over it and saying how long after the cut that is. Read
/// before the candidate turn closes, which clears it.
///
/// Not while the candidate is still speaking: a close that lands
/// mid-utterance, such as the late end of the cut generation, would read
/// speech as wordless. The cut keeps waiting, and keeps the words the turn
/// holds, since that close is about to clear them.
pub(super) fn take_cut_line(&mut self, now: Instant, speaking: bool) -> Option<String> {
let cut = self.cut.as_mut()?;
let heard = joined_head(&cut.heard, &self.candidate.head(CUT_QUOTE_CHARS));
if speaking {
cut.heard = heard;
return None;
}
let cut = self.cut.take()?;
Some(format!(
"timing: candidate audio since the cut at={}, settled {:.1}s later: {}",
cut.at,
now.saturating_duration_since(cut.since).as_secs_f64(),
if heard.is_empty() {
"(nothing transcribed; check speaker echo or background noise)"
} else {
&heard
}
))
}
}

/// The two speakers in the order their turns opened.
Expand Down
46 changes: 33 additions & 13 deletions tests/agent/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1660,31 +1660,51 @@ fn a_detail_that_reorders_or_hides_text_is_dropped_but_localized_text_survives()
/// any non-Latin script would otherwise panic on a byte index inside a
/// character, and the failure would land in the middle of an interview.
#[test]
fn a_cut_turn_quotes_a_bounded_tail_of_what_was_heard() {
fn a_cut_turn_quotes_a_bounded_head_of_what_was_heard() {
let mut transcript = Vec::new();
let mut turn = SpeakerTurn::default();
turn.record(
&mut transcript,
"Candidate",
"so the question is to implement a trie",
);
assert_eq!(turn.tail(80), "so the question is to implement a trie");
assert_eq!(turn.head(80), "so the question is to implement a trie");

// The tail, not the head: what was being said as the cut landed.
assert_eq!(turn.tail(10), "ent a trie");
// The head, not the tail: the words that caused a cut come first.
assert_eq!(turn.head(10), "so the que");

// Multi-byte on purpose and deliberately not prose: what this pins is that
// a byte index can fall inside a character, which any non-ASCII transcript
// makes reachable. Two bytes per Greek letter and four for the camera, so a
// byte-counted tail splits one of them.
let mut wide = SpeakerTurn::default();
wide.record(&mut transcript, "Candidate", "αβγδεζηθ\u{1F3A5}");
assert_eq!(wide.tail(4).chars().count(), 4);
assert_eq!(wide.tail(100), "αβγδεζηθ\u{1F3A5}");
assert_eq!(wide.head(4).chars().count(), 4);
assert_eq!(wide.head(100), "αβγδεζηθ\u{1F3A5}");

// Nothing heard is the case that matters most: it separates a candidate
// talking over the interviewer from a microphone hearing the interviewer.
assert_eq!(SpeakerTurn::default().tail(80), "");
assert_eq!(SpeakerTurn::default().head(80), "");
}

/// Room noise arrives as the transcription artifact, and a quote of the raw
/// turn made a cut with no words read as one with words.
#[test]
fn the_head_of_a_cut_turn_leaves_the_transcription_artifact_out() {
let mut transcript = Vec::new();
let mut turn = SpeakerTurn::default();
turn.record(&mut transcript, "Candidate", " #hashtag");
assert_eq!(
turn.head(80),
"",
"nothing but the artifact is nothing heard"
);

// Split across fragments the artifact is still found, and the words around
// it stay.
turn.record(&mut transcript, "Candidate", " wait #hash");
turn.record(&mut transcript, "Candidate", "tag. one more");
assert_eq!(turn.head(80), "wait one more");
}

/// The same bundle, spelled a second time in the browser.
Expand Down Expand Up @@ -1901,28 +1921,28 @@ fn grounding_is_empty_only_when_every_list_is() {
assert!(InterviewGrounding::default().is_empty());
}

/// The transcript tail is the last `max` characters, counted in characters.
/// The transcript head is the first `max` characters, counted in characters.
///
/// It goes into a log line beside a cut turn, and slicing by bytes would panic
/// on the multi-byte text the interview is full of.
#[test]
fn a_turn_tail_is_the_last_characters_and_never_splits_one() {
fn a_turn_head_is_the_first_characters_and_never_splits_one() {
let turn = |text: &str| {
let mut turn = SpeakerTurn::default();
turn.record(&mut Vec::new(), "Candidate", text);
turn
};
assert_eq!(turn(" hello ").tail(80), "hello");
assert_eq!(turn(" hello ").head(80), "hello");
assert_eq!(
turn("abcdef").tail(6),
turn("abcdef").head(6),
"abcdef",
"exactly max is not trimmed"
);
assert_eq!(turn("abcdef").tail(5), "bcdef", "one over max drops one");
assert_eq!(turn("abcdef").head(5), "abcde", "one over max drops one");

// Two bytes each, so a byte slice at the same offset would panic.
let greek = "\u{03b1}\u{03b2}\u{03b3}\u{03b4}";
assert_eq!(turn(greek).tail(2), "\u{03b3}\u{03b4}");
assert_eq!(turn(greek).head(2), "\u{03b1}\u{03b2}");
}

/// The browser's hidden-character set and the agent's, held against each other
Expand Down
Loading