Skip to content

Commit 61cb7ed

Browse files
committed
Play an acknowledgement while a tool round runs
A tool round holds the reply a candidate is waiting for back by a whole model round trip, during which they hear nothing. With CODETRIAL_GEMINI_TOOL_ACK set, off by default, the agent plays a short acknowledgement generated once per interview by Gemini's speech model in the session's voice and accent. It plays at most once per thing the candidate said, a wait kept across an interruption included, never while they are speaking again, the floor is held or a goodbye is due, and only into idle output, so it is never heard over audio. The clip is trimmed of its padding to whole output frames, and the reply's first audio cuts whatever of it is still queued or playing, so the reply never waits for the filler. The output worker now checks for cancellation before capturing a frame, so a frame cut by a barge-in is not published either. The reply's own latency is unchanged in meaning: the trace stamps the acknowledgement as tool_ack_ms, and the analyzer's tool_ack_lead is how much sooner than the reply's captured audio the candidate heard something.
1 parent 41c97e8 commit 61cb7ed

19 files changed

Lines changed: 841 additions & 20 deletions

‎.cargo/mutants.toml‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Functions the mutation gate cannot judge, because `cargo test` cannot reach
22
# them. Matched against the mutant names that `cargo mutants --list` prints.
33
#
4-
# EXCLUSIONS: 71
4+
# EXCLUSIONS: 74
55
#
66
# That number is checked by `scripts/test.sh`, so adding an entry means editing
77
# this line too. The point is not the count, it is that the list only ever grows
@@ -102,7 +102,10 @@
102102
# `NativeAudioSource`, which is libwebrtc: it returns nothing a test can read,
103103
# so replacing the worker with nothing at all looks the same from outside as
104104
# playing the audio. What can be checked is checked, in `OutputAudio::capture`,
105-
# which is where the framing and the playout pacing are decided.
105+
# which is where the framing and the playout pacing are decided. The
106+
# `OutputAudioSink` impl for `NativeAudioSource` is the same libwebrtc call
107+
# under a trait, there so the worker's ordering of capture and cancellation
108+
# can be tested against a sink that records it.
106109
#
107110
# The rest of the room loop's helpers are here for the same reason: every one
108111
# of them takes `&Room` or the live Gemini session and answers by writing to it.
@@ -299,6 +302,16 @@
299302
# builds the Google URL for `generate_phase_judgment_at`, which is tested
300303
# against a local server; reaching the real one needs a live key.
301304
#
305+
# `play_tool_ack` is the room's half of the tool-round acknowledgement: it
306+
# queues the clip on the live `GeminiEventContext` and stamps the trace, so
307+
# `Ok(())` looks the same from outside as the work, for the reason
308+
# `send_model_text` does. Whether it plays is decided by `tool_ack_fits` and
309+
# `RuntimeActivity::take_tool_ack`, and the trace's stamp by
310+
# `ReplyLatency::tool_ack`; all three are tested, and the
311+
# browser check with `CODETRIAL_GEMINI_TOOL_ACK` set is what heard it.
312+
# `tool_ack_clip` only names the Google URL for `tool_ack_clip_at`, which is
313+
# tested against a local server, as `generate_phase_judgment_with_keys` does.
314+
#
302315
# `log` in the latency module and `log_first_frame` beside the output worker
303316
# only write a line to stderr; `()` leaves nothing a test can read. What the
304317
# line holds is built and tested apart from the printing: the trace's own
@@ -336,6 +349,7 @@ exclude_re = [
336349
"send_model_text",
337350
"publish_output_audio",
338351
"output_audio_worker",
352+
"impl OutputAudioSink for NativeAudioSource",
339353
"publish_framework_progress",
340354
"send_wrap_up_and_wait",
341355
"drop_stale_playout",
@@ -376,6 +390,8 @@ exclude_re = [
376390
"settle_phase_judge",
377391
"flush_framework_progress",
378392
"generate_phase_judgment_with_keys",
393+
"play_tool_ack",
394+
"replace tool_ack_clip ->",
379395
"livekit/latency\\.rs:.*replace log with \\(\\)$",
380396
"replace log_first_frame with \\(\\)$",
381397
]

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ The common ones:
282282
| `CODETRIAL_MAX_INTERIM_REVIEWS` | `6` | Quiet-pause report-model reviews per interview; `0` disables them and `72` is the maximum |
283283
| `CODETRIAL_GEMINI_REPLY_TIMEOUT_S` | `45` | Seconds a started interviewer reply may go without further output before the Live socket is replaced (20–120); see [degradation controls](docs/provider-cost-and-degradation.md) |
284284
| `CODETRIAL_GEMINI_FIRST_OUTPUT_S` | `10` | Seconds an owed reply that nothing has started toward may go with nothing back before the Live socket is replaced (5–120, never above the reply timeout) |
285+
| `CODETRIAL_GEMINI_TOOL_ACK` | `false` | Experimental: while a tool call holds back a reply the candidate is waiting for, play a short acknowledgement generated once per interview in the interviewer's voice; see [latency observability](docs/latency-observability.md) |
285286
| `CODETRIAL_GEMINI_CANDIDATE_VIDEO_ENABLED` | `false` | Forward candidate video to Gemini, one low-resolution frame in five seconds; ignored in a whiteboard interview |
286287
| `CODETRIAL_COMPILER_EXPLORER_ENABLED` | `true` | Enable remote C, C++, and Java runs |
287288
| `CODETRIAL_MAX_CONCURRENT_INTERVIEWS` | `16` | Interviews one `web` process hosts agents for |

‎config/codetrial.env.example‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ CODETRIAL_WEB_DIR=web
2323
CODETRIAL_WEB_ADDR=127.0.0.1:3000
2424
CODETRIAL_COMPILER_EXPLORER_ENABLED=true
2525
CODETRIAL_GEMINI_CANDIDATE_VIDEO_ENABLED=false
26+
# Experimental: play a short spoken acknowledgement in the interviewer's voice
27+
# while a tool call holds back the reply; one speech-model call per interview.
28+
# CODETRIAL_GEMINI_TOOL_ACK=false
2629
# Optional pair for Live context compression experiments. Unset keeps the
2730
# provider defaults; smaller windows discard older dialogue. See
2831
# docs/provider-cost-and-degradation.md before choosing production values, and

‎docs/latency-observability.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ barge-in that caused it is the input still waiting for an answer.
5757

5858
## Waterfall
5959

60+
With `CODETRIAL_GEMINI_TOOL_ACK` set, a tool round that holds back the reply a
61+
candidate is waiting for, and has heard nothing of, queues a short
62+
acknowledgement generated once per interview in the session's voice and accent
63+
(`gemini-3.1-flash-tts-preview`, on the report keys). It plays at most once per
64+
thing the candidate said, never while they are speaking again, and never
65+
before a goodbye. It is not the reply: the watchdog and this record still wait
66+
for the reply's own audio. Its first accepted chunk cancels the remaining clip
67+
and clears buffered filler before the reply plays. Audio already transmitted
68+
cannot be recalled. Cancelled queue entries are skipped without capture;
69+
`output_queue_frames` can include them until the worker drains them. The record
70+
stamps it as `tool_ack_ms`, and the summary's `tool_ack_lead` is the reply's native
71+
capture less that stamp: how much sooner the candidate heard something. An
72+
interview whose clip is not ready yet, or failed to generate, plays nothing.
73+
6074
Offsets share the origin and use the agent's monotonic clock. A milestone seen
6175
before an origin that later moved is negative, and one never observed is
6276
`null`. `observed_ms` may be negative when provider receipt precedes the first

‎scripts/analyze-latency.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"candidate_yield_ms",
4343
"first_provider_output_ms",
4444
"first_tool_call_ms",
45+
"tool_ack_ms",
4546
"last_tool_response_ms",
4647
"tool_handler_ms",
4748
"provider_event_queue_ms",
@@ -470,6 +471,17 @@ def ordering(event):
470471
"beyond_window_with_stalls": statistics(
471472
[r["beyond_window_ms"] for r in comparable]
472473
),
474+
# With an acknowledgement played during a tool round, the candidate
475+
# hears something before the reply: how much sooner. The clip went out
476+
# on an empty queue, so its queueing is its capture; the reply's is its
477+
# native capture, which waited behind the clip when the clip was longer.
478+
"tool_ack_lead": statistics(
479+
[
480+
r["server_total_ms"] - r["first_audio"]["tool_ack_ms"]
481+
for r in typical
482+
if r["first_audio"].get("tool_ack_ms") is not None
483+
]
484+
),
473485
"beyond_window_by_kind": {
474486
kind: statistics(
475487
[r["beyond_window_ms"] for r in typical if reply_kind(r) == kind]
@@ -630,6 +642,7 @@ def summary_lines(report):
630642
"beyond_window",
631643
"beyond_window_with_stalls",
632644
"transcript_before_end",
645+
"tool_ack_lead",
633646
):
634647
if report[label]["count"]:
635648
out.append(stat_line(label, report[label]))

‎src/config.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ pub const DEFAULT_WEB_DIR: &str = "web";
6767
pub const DEFAULT_WEB_ADDR: &str = "127.0.0.1:3000";
6868
pub const DEFAULT_COMPILER_EXPLORER_ENABLED: bool = true;
6969
pub const DEFAULT_GEMINI_CANDIDATE_VIDEO_ENABLED: bool = false;
70+
/// Off until measured in real interviews: a short spoken acknowledgement
71+
/// played while a tool round holds the reply back. See `gemini::tool_ack_clip`.
72+
pub const DEFAULT_GEMINI_TOOL_ACK: bool = false;
7073

7174
/// How many interviews one `codetrial web` process will host agents for at
7275
/// once. Each is a LiveKit room plus a metered Gemini Live session, so an
@@ -564,6 +567,7 @@ pub struct AgentConfig {
564567
pub room_prefix: String,
565568
pub default_duration_min: u32,
566569
pub gemini_candidate_video_enabled: bool,
570+
pub gemini_tool_ack: bool,
567571
pub max_interim_reviews: usize,
568572
pub gemini_reply_timeout_s: u32,
569573
pub gemini_first_output_s: u32,
@@ -782,6 +786,10 @@ pub fn load_from_pairs(
782786
.get("CODETRIAL_GEMINI_CANDIDATE_VIDEO_ENABLED")
783787
.map(String::as_str),
784788
),
789+
gemini_tool_ack: parse_bool(
790+
values.get("CODETRIAL_GEMINI_TOOL_ACK").map(String::as_str),
791+
DEFAULT_GEMINI_TOOL_ACK,
792+
),
785793
max_interim_reviews: optional_u32(
786794
&values,
787795
"CODETRIAL_MAX_INTERIM_REVIEWS",

‎src/gemini.rs‎

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1528,6 +1528,25 @@ async fn generate_content_once(
15281528
timeout: Duration,
15291529
what: &str,
15301530
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
1531+
let response = post_generate_content(api_key, url, request, timeout, what).await?;
1532+
gemini_text(&response).ok_or_else(|| {
1533+
io::Error::new(
1534+
io::ErrorKind::InvalidData,
1535+
format!("Gemini {what} response had no text"),
1536+
)
1537+
.into()
1538+
})
1539+
}
1540+
1541+
/// The transport half of `generate_content_once`, for a caller that reads
1542+
/// something other than text out of the answer.
1543+
async fn post_generate_content(
1544+
api_key: &str,
1545+
url: &str,
1546+
request: &Value,
1547+
timeout: Duration,
1548+
what: &str,
1549+
) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
15311550
let started = std::time::Instant::now();
15321551
let response = crate::http_client()
15331552
.post(url)
@@ -1553,13 +1572,7 @@ async fn generate_content_once(
15531572
.map(TokenUsage::from_metadata)
15541573
.unwrap_or_default();
15551574
eprintln!("gemini {what} usage {}", usage.log_fields());
1556-
gemini_text(&response).ok_or_else(|| {
1557-
io::Error::new(
1558-
io::ErrorKind::InvalidData,
1559-
format!("Gemini {what} response had no text"),
1560-
)
1561-
.into()
1562-
})
1575+
Ok(response)
15631576
}
15641577

15651578
async fn generate_report_once(
@@ -1750,6 +1763,86 @@ fn live_websocket_url_at(endpoint: &str, api_key: &str) -> String {
17501763
format!("{endpoint}?key={}", percent_encode_component(api_key))
17511764
}
17521765

1766+
/// The model and words of the acknowledgement a tool round may play. Generated
1767+
/// once per interview in the session's own voice and accent, so it sounds like
1768+
/// Jim.
1769+
const TOOL_ACK_TTS_MODEL: &str = "gemini-3.1-flash-tts-preview";
1770+
const TOOL_ACK_WORDS: &str = "Mm, okay.";
1771+
const TOOL_ACK_TIMEOUT: Duration = Duration::from_secs(20);
1772+
1773+
/// What the speech model is asked to say, in the interview's accent when it
1774+
/// has one.
1775+
fn tool_ack_text(accent: Option<&str>) -> String {
1776+
let accent = accent
1777+
.map(|accent| format!(", with a natural {accent} accent"))
1778+
.unwrap_or_default();
1779+
format!("Say briefly, in a natural, thinking tone{accent}: {TOOL_ACK_WORDS}")
1780+
}
1781+
1782+
/// A short spoken acknowledgement in `voice`, as 16-bit mono PCM at
1783+
/// `sample_rate`, the output track's own format. One attempt on the report
1784+
/// keys: an interview without it only loses a filler.
1785+
pub(crate) async fn tool_ack_clip(
1786+
keys: &GeminiKeys,
1787+
voice: &str,
1788+
accent: Option<&str>,
1789+
sample_rate: u32,
1790+
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
1791+
let url = gemini_generate_content_url(TOOL_ACK_TTS_MODEL);
1792+
tool_ack_clip_at(keys, &url, voice, accent, sample_rate).await
1793+
}
1794+
1795+
async fn tool_ack_clip_at(
1796+
keys: &GeminiKeys,
1797+
url: &str,
1798+
voice: &str,
1799+
accent: Option<&str>,
1800+
sample_rate: u32,
1801+
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
1802+
let api_key = keys.select_report()?;
1803+
let request = json!({
1804+
"contents": [{ "parts": [{ "text": tool_ack_text(accent) }] }],
1805+
"generationConfig": {
1806+
"responseModalities": ["AUDIO"],
1807+
"speechConfig": { "voiceConfig": { "prebuiltVoiceConfig": { "voiceName": voice } } }
1808+
}
1809+
});
1810+
let response = post_generate_content(
1811+
&api_key,
1812+
url,
1813+
&request,
1814+
TOOL_ACK_TIMEOUT,
1815+
"tool acknowledgement",
1816+
)
1817+
.await?;
1818+
tool_ack_pcm(&response, sample_rate)
1819+
}
1820+
1821+
/// The PCM in a speech response, refusing anything but raw 16-bit samples at
1822+
/// the output track's rate: a container or a codec would play as noise.
1823+
fn tool_ack_pcm(
1824+
response: &Value,
1825+
sample_rate: u32,
1826+
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
1827+
let data = &response["candidates"][0]["content"]["parts"][0]["inlineData"];
1828+
1829+
// Media types are case-insensitive, and the speech model answers in lower
1830+
// case: "audio/l16; rate=24000; channels=1".
1831+
let mime = data["mimeType"].as_str().unwrap_or_default();
1832+
let lower = mime.to_ascii_lowercase();
1833+
let raw = lower.starts_with("audio/l16") || lower.starts_with("audio/pcm");
1834+
let rate = format!("rate={sample_rate}");
1835+
if !raw || !lower.split(';').any(|param| param.trim() == rate) {
1836+
return Err(io::Error::new(
1837+
io::ErrorKind::InvalidData,
1838+
format!("acknowledgement audio is {mime:?}, not {sample_rate} Hz PCM"),
1839+
)
1840+
.into());
1841+
}
1842+
let encoded = data["data"].as_str().unwrap_or_default();
1843+
Ok(STANDARD.decode(encoded)?)
1844+
}
1845+
17531846
/// No `?key=` here on purpose. A `reqwest` error Displays the URL it was built
17541847
/// from, and this call's errors reach the candidate's browser in the report
17551848
/// failure note, so the credential travels in a header instead.

‎src/livekit.rs‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1555,6 +1555,31 @@ async fn open_session<'a>(
15551555
boot.room_name, boot.problem.id, boot.duration_min
15561556
);
15571557

1558+
// Generated beside the setup below and picked up whenever it lands: a tool
1559+
// round before it is ready simply plays nothing.
1560+
let tool_ack = Arc::new(std::sync::OnceLock::new());
1561+
let mut tool_ack_task = None;
1562+
if config.gemini_tool_ack {
1563+
let (keys, slot) = (keys.clone(), tool_ack.clone());
1564+
let (voice, accent) = (boot.voice.to_string(), boot.accent.map(str::to_string));
1565+
let task = tokio::spawn(async move {
1566+
match crate::gemini::tool_ack_clip(
1567+
&keys,
1568+
&voice,
1569+
accent.as_deref(),
1570+
GEMINI_OUTPUT_AUDIO_SAMPLE_RATE,
1571+
)
1572+
.await
1573+
{
1574+
Ok(pcm) => {
1575+
let _ = slot.set(media::trim_silence(&pcm, GEMINI_OUTPUT_AUDIO_SAMPLE_RATE));
1576+
}
1577+
Err(error) => eprintln!("tool acknowledgement not generated: {error}"),
1578+
}
1579+
});
1580+
tool_ack_task = Some(media::AbortOnDrop(task.abort_handle()));
1581+
}
1582+
15581583
// Only the Gemini session overlaps, and only because it touches nothing in
15591584
// the room. Isolation and publication cannot: isolation exists so that one
15601585
// agent is in the room when the candidate starts subscribing, and running
@@ -1568,7 +1593,7 @@ async fn open_session<'a>(
15681593
let mut restarts = 0;
15691594
let mut room_ready = Duration::ZERO;
15701595
let mut gemini_ready = Duration::ZERO;
1571-
let (output_audio, mut gemini) = tokio::try_join!(
1596+
let (mut output_audio, mut gemini) = tokio::try_join!(
15721597
async {
15731598
isolate_local_agent(config, room_name, &agent_identity, now_seconds).await?;
15741599
let published = publish_output_audio(&room, GEMINI_OUTPUT_AUDIO_SAMPLE_RATE).await;
@@ -1595,6 +1620,9 @@ async fn open_session<'a>(
15951620
},
15961621
)?;
15971622

1623+
output_audio.tool_ack = tool_ack;
1624+
output_audio.tool_ack_task = tool_ack_task;
1625+
15981626
// Which branch held the greeting back: an earlier Gemini connect can only
15991627
// help when Gemini is the later of the two.
16001628
eprintln!(

‎src/livekit/latency.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ struct Trace {
9595
yielded: Option<Instant>,
9696
first_output: Option<Instant>,
9797
first_tool_call: Option<Instant>,
98+
/// A local acknowledgement queued while a tool round held the reply.
99+
tool_ack: Option<Instant>,
98100
last_tool_response: Option<Instant>,
99101
tool_batches: u64,
100102
tool_handler_ms: u64,
@@ -262,6 +264,7 @@ impl ReplyLatency {
262264
yielded: None,
263265
first_output: None,
264266
first_tool_call: None,
267+
tool_ack: None,
265268
last_tool_response: None,
266269
tool_batches: 0,
267270
tool_handler_ms: 0,
@@ -627,6 +630,16 @@ impl ReplyLatency {
627630
}
628631
}
629632

633+
/// The candidate hears something before the reply itself: the first
634+
/// sound, which the reply's own audio record still measures past.
635+
pub(super) fn tool_ack(&mut self, now: Instant) {
636+
if let Some(trace) = self.trace.as_mut()
637+
&& !trace.audio_accepted
638+
{
639+
trace.tool_ack.get_or_insert(now);
640+
}
641+
}
642+
630643
/// A batch that failed to send is recorded but does not move the
631644
/// continuation's start: nothing reached the model for it to resume on.
632645
pub(super) fn tool_response(&mut self, now: Instant, handler_ms: u64, sent: bool) -> Value {
@@ -736,6 +749,7 @@ impl ReplyLatency {
736749
"candidate_yield_ms": at(trace.yielded),
737750
"first_provider_output_ms": at(trace.first_output),
738751
"first_tool_call_ms": at(trace.first_tool_call),
752+
"tool_ack_ms": at(trace.tool_ack),
739753
"last_tool_response_ms": at(trace.last_tool_response),
740754
"tool_batches": trace.tool_batches,
741755
"tool_handler_ms": trace.tool_handler_ms,

0 commit comments

Comments
 (0)