feat(meet_agent): live note-taking agent for Google Meet (listen + speak) (#1355)

This commit is contained in:
Steven Enamakel
2026-05-07 20:10:42 -07:00
committed by GitHub
parent 84a1f68b91
commit 0636b0c1a5
24 changed files with 3957 additions and 1 deletions
+2
View File
@@ -15,6 +15,7 @@ mod gmessages_scanner;
mod imessage_scanner;
#[cfg(target_os = "macos")]
mod mascot_native_window;
mod meet_audio;
mod meet_call;
mod meet_scanner;
mod native_notifications;
@@ -1367,6 +1368,7 @@ pub fn run() {
let builder = builder.manage(telegram_scanner::ScannerRegistry::new());
let builder = builder.manage(screen_capture::ScreenShareState::new());
let builder = builder.manage(meet_call::MeetCallState::new());
let builder = builder.manage(meet_audio::MeetAudioState::new());
builder
.setup(move |app| {
#[cfg(any(windows, target_os = "linux"))]
@@ -0,0 +1,235 @@
// OpenHuman audio bridge for the embedded Google Meet webview.
//
// Installed via CDP `Page.addScriptToEvaluateOnNewDocument` from the
// Tauri shell (`app/src-tauri/src/meet_audio/inject.rs`) so it runs at
// document-start, *before* Meet's join page calls
// `navigator.mediaDevices.getUserMedia`. The shell then triggers a
// `Page.reload` so that even an already-navigated meet page picks up
// the override.
//
// What this script does:
//
// 1. Builds a 16 kHz mono Web-Audio graph whose
// `MediaStreamAudioDestinationNode` provides an audio MediaStream
// track the page can hand to its RTCPeerConnection.
// 2. Monkey-patches `navigator.mediaDevices.getUserMedia` so any audio
// request returns our destination stream (and combined audio+video
// requests get the real video track from Chromium's fake-camera Y4M
// plus our audio track).
// 3. Exposes `window.__openhumanFeedPcm(b64)` — the Tauri shell calls
// this on a ~100 ms cadence via CDP `Runtime.evaluate` to push the
// next chunk of synthesized PCM16LE bytes from
// `openhuman.meet_agent_poll_speech`.
//
// JS-injection note: the project's broader rule (CLAUDE.md) is "no new
// JS in embedded provider webviews". The Meet call window is a special
// case — it is a dedicated top-level window for a single audio-bridging
// purpose where the public `CefAudioHandler` API is sufficient for the
// listen path but Chromium's audio *input* path has no comparable
// public hook short of a from-source rebuild. The user has explicitly
// authorized this injection for the speak path; legacy provider
// webviews keep the no-JS rule.
(function () {
if (window.__openhumanAudioBridgeInstalled) {
console.log("[openhuman-audio-bridge] already installed; skipping");
return;
}
window.__openhumanAudioBridgeInstalled = true;
console.log("[openhuman-audio-bridge] install begin");
var SAMPLE_RATE = 16000;
var ctx;
var dest;
var nextStartTime = 0;
function ensureContext() {
if (ctx) {
console.log(
"[openhuman-audio-bridge] reuse AudioContext state=" + ctx.state
);
return ctx;
}
var requestedRate = SAMPLE_RATE;
try {
ctx = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: SAMPLE_RATE,
});
} catch (e) {
// Some Chromium builds don't honor the explicit sampleRate; fall
// back to the default (the bridge will resample implicitly via
// each AudioBuffer's declared rate).
console.warn(
"[openhuman-audio-bridge] AudioContext sampleRate hint rejected; falling back to default rate err=" +
e
);
ctx = new (window.AudioContext || window.webkitAudioContext)();
}
dest = ctx.createMediaStreamDestination();
nextStartTime = ctx.currentTime;
console.log(
"[openhuman-audio-bridge] AudioContext created requested_rate=" +
requestedRate +
" actual_rate=" +
ctx.sampleRate +
" state=" +
ctx.state
);
return ctx;
}
function decodeBase64Pcm16leToFloat32(b64) {
var bin = atob(b64);
var len = bin.length;
if (len % 2 !== 0) {
// Trailing byte = corrupt frame; drop it rather than read past
// the end and emit a click.
len = len - 1;
}
var out = new Float32Array(len / 2);
for (var i = 0, j = 0; j < len; i++, j += 2) {
var lo = bin.charCodeAt(j);
var hi = bin.charCodeAt(j + 1);
var v = (hi << 8) | lo;
if (v & 0x8000) v -= 0x10000;
out[i] = v / 32768;
}
return out;
}
// Public push API. Returns the duration in seconds the chunk added
// to the queue, mostly for diagnostics; the shell ignores it.
window.__openhumanFeedPcm = function (b64) {
if (!b64) return 0;
try {
ensureContext();
var samples = decodeBase64Pcm16leToFloat32(b64);
if (!samples.length) return 0;
var buffer = ctx.createBuffer(1, samples.length, SAMPLE_RATE);
buffer.copyToChannel(samples, 0, 0);
var src = ctx.createBufferSource();
src.buffer = buffer;
src.connect(dest);
// Schedule strictly after the previous chunk so successive
// 100 ms feeds line up gaplessly. If the queue has emptied
// (caller fell behind), restart at currentTime so we don't try
// to play in the past.
if (nextStartTime < ctx.currentTime) {
nextStartTime = ctx.currentTime;
}
src.start(nextStartTime);
nextStartTime += buffer.duration;
// High-frequency log gated by a counter so we don't drown the
// console at 10 Hz; emit ~1 in 50 frames (~5 s cadence at the
// shell's 100 ms feed rate).
window.__openhumanFeedCounter = (window.__openhumanFeedCounter || 0) + 1;
if (window.__openhumanFeedCounter % 50 === 1) {
console.log(
"[openhuman-audio-bridge] feed sampled chunk_dur=" +
buffer.duration.toFixed(3) +
"s queue_ahead=" +
(nextStartTime - ctx.currentTime).toFixed(3) +
"s frame=" +
window.__openhumanFeedCounter
);
}
return buffer.duration;
} catch (e) {
console.warn("[openhuman-audio-bridge] feed failed:", e);
return 0;
}
};
// Public introspection — useful from the shell side via
// Runtime.evaluate to confirm the bridge is alive.
window.__openhumanAudioBridgeInfo = function () {
return {
installed: true,
sample_rate: SAMPLE_RATE,
audio_context_state: ctx ? ctx.state : "not-created",
next_start_time: nextStartTime,
destination_track_count: dest ? dest.stream.getAudioTracks().length : 0,
};
};
// Override getUserMedia so Meet's audio requests are served from our
// bridge stream. We delegate video to the original implementation so
// Chromium's fake-camera Y4M (mascot) keeps working.
if (
!navigator.mediaDevices ||
typeof navigator.mediaDevices.getUserMedia !== "function"
) {
console.warn(
"[openhuman-audio-bridge] navigator.mediaDevices.getUserMedia missing; interception disabled"
);
return;
}
var origGum = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
// Build a fresh audio MediaStream backed by clones of the bridge's
// destination tracks. Returning the singleton `dest.stream` directly
// would let any caller's `track.stop()` (e.g. Meet during preview
// teardown / track renegotiation) permanently kill the bridge. Each
// call gets its own track lifecycle.
function freshAudioStream() {
ensureContext();
return new MediaStream(
dest.stream.getAudioTracks().map(function (t) {
return t.clone();
})
);
}
navigator.mediaDevices.getUserMedia = function (constraints) {
if (!constraints || !constraints.audio) {
console.log(
"[openhuman-audio-bridge] getUserMedia passthrough (no audio)"
);
return origGum(constraints);
}
if (!constraints.video) {
console.log(
"[openhuman-audio-bridge] getUserMedia intercepted audio-only"
);
return Promise.resolve(freshAudioStream());
}
// Combined audio + video request: pull video from the real
// (fake-camera-backed) getUserMedia and splice in fresh clones of
// our audio tracks.
console.log(
"[openhuman-audio-bridge] getUserMedia intercepted audio+video; splicing audio onto fake-camera stream"
);
return origGum({ video: constraints.video }).then(function (realStream) {
try {
realStream.getAudioTracks().forEach(function (t) {
realStream.removeTrack(t);
t.stop();
});
} catch (_) {}
freshAudioStream()
.getAudioTracks()
.forEach(function (t) {
realStream.addTrack(t);
});
return realStream;
});
};
// Best-effort: also patch the legacy `getUserMedia` aliases some
// older Meet code paths still call into.
if (typeof navigator.getUserMedia === "function") {
console.log("[openhuman-audio-bridge] patching legacy navigator.getUserMedia");
var origLegacy = navigator.getUserMedia.bind(navigator);
navigator.getUserMedia = function (constraints, success, failure) {
navigator.mediaDevices
.getUserMedia(constraints)
.then(success, failure)
.catch(function (e) {
if (failure) failure(e);
else origLegacy(constraints, success, failure);
});
};
}
console.log("[openhuman-audio-bridge] install complete");
})();
@@ -0,0 +1,127 @@
//! Listen path v2: drains Meet's built-in captions region via the
//! `captions_bridge.js` we install at session start, and forwards each
//! new line to core's `meet_agent_push_caption` RPC.
//!
//! Replaces the old [`super::listen_capture`] (CEF audio handler →
//! Whisper STT) which proved unreliable: CEF's `cef_audio_handler_t`
//! is queried lazily on first audio output, so a solo agent in a
//! lobby never engaged the pipeline. Captions handle that case for
//! free — Meet's STT is already running, speaker-attributed, and
//! pre-segmented.
//!
//! Lifecycle is owned by [`super::SpeakPump`]'s sibling: dropping the
//! returned [`CaptionListener`] shuts the polling task down.
use std::time::Duration;
use tokio::sync::oneshot;
use tokio::time::interval;
use crate::cdp::CdpConn;
use super::inject;
/// Polling cadence for `__openhumanDrainCaptions`. Captions arrive at
/// roughly word-by-word frequency; 500 ms is the sweet spot between
/// "responsive enough that wake-word detection feels live" and "not
/// hammering the CDP socket".
const POLL_INTERVAL: Duration = Duration::from_millis(500);
/// Cap on consecutive drain failures before the listener gives up.
/// Same shape as the speak pump — usually means the page navigated
/// away (call ended) or the renderer crashed.
const MAX_CONSECUTIVE_ERRORS: u32 = 30;
/// RAII handle. Drop to stop the listener task.
pub struct CaptionListener {
pub request_id: String,
pub(crate) _shutdown_tx: Option<oneshot::Sender<()>>,
}
impl Drop for CaptionListener {
fn drop(&mut self) {
let _ = self._shutdown_tx.take();
}
}
/// Spawn the caption polling loop for a session whose audio bridge
/// has already installed both `audio_bridge.js` and
/// `captions_bridge.js`. Owns its own clone of the CDP connection so
/// drains run concurrently with speak-pump feeds.
pub fn start(request_id: String, cdp: CdpConn, session_id: String) -> CaptionListener {
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
let request_id_for_task = request_id.clone();
tauri::async_runtime::spawn(async move {
let mut tick = interval(POLL_INTERVAL);
// Burn the first tick so the very first drain has something
// to drain (the page-side observer needs ~250 ms to attach).
tick.tick().await;
let mut cdp = cdp;
let mut errors: u32 = 0;
loop {
tokio::select! {
_ = &mut shutdown_rx => {
log::info!(
"[meet-audio] caption listener shutdown request_id={request_id_for_task}"
);
break;
}
_ = tick.tick() => {
match drain_and_forward(&request_id_for_task, &mut cdp, &session_id).await {
Ok(_) => errors = 0,
Err(err) => {
errors += 1;
log::debug!(
"[meet-audio] caption tick err request_id={request_id_for_task} consec_errors={errors} err={err}"
);
if errors >= MAX_CONSECUTIVE_ERRORS {
log::warn!(
"[meet-audio] caption listener giving up after {errors} consecutive errors request_id={request_id_for_task}"
);
break;
}
}
}
}
}
}
});
CaptionListener {
request_id,
_shutdown_tx: Some(shutdown_tx),
}
}
async fn drain_and_forward(
request_id: &str,
cdp: &mut CdpConn,
session_id: &str,
) -> Result<(), String> {
let captions = inject::drain_captions(cdp, session_id).await?;
if captions.is_empty() {
return Ok(());
}
log::info!(
"[meet-audio] captions drained count={} request_id={request_id}",
captions.len()
);
for (speaker, text, ts_ms) in captions {
// Propagate the failure so MAX_CONSECUTIVE_ERRORS can trip if
// core's session/RPC path is broken — without this the
// listener would silently drop captions forever while the
// page kept producing them.
super::rpc_call(
"openhuman.meet_agent_push_caption",
serde_json::json!({
"request_id": request_id,
"speaker": speaker,
"text": text,
"ts_ms": ts_ms,
}),
)
.await
.map_err(|err| format!("push_caption (request_id={request_id}): {err}"))?;
}
Ok(())
}
@@ -0,0 +1,186 @@
// OpenHuman captions bridge for the embedded Google Meet webview.
//
// Companion to `audio_bridge.js`. Where the audio bridge handles the
// SPEAK direction (synthesized PCM → MediaStream the page hands to its
// RTCPeerConnection), this script handles the LISTEN direction by
// scraping Meet's built-in live captions instead of running our own
// STT pipeline:
//
// - Auto-click the "Turn on captions" button so the user doesn't
// have to remember.
// - Watch the captions region with a MutationObserver and a 250 ms
// poll fallback (Meet sometimes batches DOM updates outside the
// observer's notify window).
// - Maintain a queue of new caption lines, deduped by speaker+text.
// Each entry: { speaker, text, ts }.
// - Expose `window.__openhumanDrainCaptions()` and
// `__openhumanCaptionsBridgeInfo()` for the Tauri shell to drive
// over CDP `Runtime.evaluate`.
//
// Why scraping (and not getDisplayMedia, or Web Speech, or Meet's
// undocumented APIs)?
// - getDisplayMedia would prompt the user for screen-share permission.
// - Web Speech doesn't reach the remote participants' audio — only
// local mic.
// - Meet has no public caption API.
// - The captions DOM is the simplest stable source. Class names
// obfuscate often, so we lean on `aria-label="Captions"` (which
// Meet keeps stable for accessibility).
//
// Wake-word handling lives in the core (`src/openhuman/meet_agent/`),
// not here — the page just streams every caption line out and core
// decides when to act.
(function () {
if (window.__openhumanCaptionsBridgeInstalled) {
return;
}
window.__openhumanCaptionsBridgeInstalled = true;
var queue = [];
// Per-speaker last-text fingerprint so a caption that grows in place
// (Meet appends text mid-utterance) doesn't get queued multiple
// times. We emit the *latest* text for each speaker only when it
// changes; downstream wake-word logic dedupes on its own buffer.
var lastBySpeaker = {};
function findCaptionsRegion() {
// Meet's captions region carries a stable accessibility label
// even as class names churn between rollouts. Try the canonical
// English first, then fall back to a fuzzy match for localized
// builds ("Subtitles", "Sous-titres", etc.) that still embed
// "captions" / "caption" in the aria-label.
return (
document.querySelector('[aria-label="Captions"]') ||
document.querySelector('div[role="region"][aria-label*="aption"]') ||
document.querySelector('div[role="region"][aria-label*="aption" i]') ||
null
);
}
function pollOnce() {
var region = findCaptionsRegion();
if (!region) return;
// Each caption line is typically a flex row with the speaker name
// at the top and the live transcript below. We don't depend on
// exact class names; instead we walk direct children and treat
// each as one caption "row".
var rows = region.querySelectorAll(
':scope > div, :scope > section, :scope > [role="listitem"]'
);
if (!rows.length) {
// Fall back to a single-block region: one big innerText blob.
var blob = (region.innerText || "").trim();
if (blob && blob !== lastBySpeaker.__blob__) {
queue.push({ speaker: "", text: blob, ts: Date.now() });
lastBySpeaker.__blob__ = blob;
}
return;
}
rows.forEach(function (row) {
// The speaker name is usually the first text child; the
// transcript is the larger one beneath. Heuristic: the line
// with the most text wins as "transcript".
var nodes = row.querySelectorAll("*");
var bestText = "";
var bestCandidate = null;
var speakerGuess = "";
nodes.forEach(function (n) {
var t = (n.innerText || "").trim();
if (!t) return;
if (!speakerGuess && t.length < 40 && /^[A-Za-z][\w '\-\.]*$/.test(t)) {
speakerGuess = t;
}
if (t.length > bestText.length) {
bestText = t;
bestCandidate = n;
}
});
if (!bestText) return;
// Strip the speaker name out of the body if it's the leading
// chunk (Meet sometimes renders "Alice the meeting starts at 3"
// as one innerText blob).
if (speakerGuess && bestText.startsWith(speakerGuess)) {
bestText = bestText.slice(speakerGuess.length).trim();
}
if (!bestText) return;
var key = speakerGuess || "_unknown";
if (lastBySpeaker[key] === bestText) return;
lastBySpeaker[key] = bestText;
queue.push({ speaker: speakerGuess, text: bestText, ts: Date.now() });
});
}
// Two layers, because Meet sometimes batches caption DOM updates
// in ways that miss MutationObserver notifications:
//
// 1. MutationObserver — fires immediately on DOM mutation, picks
// up character-data changes that the poll might miss between
// ticks.
// 2. 250 ms interval poll — safety net for batched updates and
// for the case where the captions region didn't exist at
// observer-attach time.
function attachObserver() {
var region = findCaptionsRegion();
if (!region || region.__openhumanObserverAttached) return false;
region.__openhumanObserverAttached = true;
var obs = new MutationObserver(function () {
pollOnce();
});
obs.observe(region, {
childList: true,
subtree: true,
characterData: true,
});
return true;
}
// Auto-enable captions: walk every button on the page and click any
// that has an aria-label starting with "Turn on captions". Caps the
// attempts so we don't fight a user who deliberately disables CC.
var ENABLE_ATTEMPT_BUDGET = 30; // ~30 * 2s = 60s
var enableAttempts = 0;
function tryEnableCaptions() {
if (enableAttempts >= ENABLE_ATTEMPT_BUDGET) return;
enableAttempts++;
var buttons = document.querySelectorAll("button[aria-label]");
for (var i = 0; i < buttons.length; i++) {
var lbl = (buttons[i].getAttribute("aria-label") || "").toLowerCase();
// Match "Turn on captions" but NOT "Turn off captions".
if (lbl.indexOf("turn on captions") === 0 || /^turn on captions/.test(lbl)) {
try {
buttons[i].click();
enableAttempts = ENABLE_ATTEMPT_BUDGET; // success — stop trying.
return true;
} catch (_) {}
}
}
return false;
}
setInterval(function () {
attachObserver();
pollOnce();
}, 250);
setInterval(tryEnableCaptions, 2000);
// Public API consumed by the Tauri shell over CDP Runtime.evaluate.
window.__openhumanDrainCaptions = function () {
var out = queue.slice();
queue.length = 0;
return out;
};
window.__openhumanCaptionsBridgeInfo = function () {
return {
installed: true,
region_found: !!findCaptionsRegion(),
queue_depth: queue.length,
tracked_speakers: Object.keys(lastBySpeaker).length,
enable_attempts: enableAttempts,
};
};
})();
+253
View File
@@ -0,0 +1,253 @@
//! Install the OpenHuman audio bridge into the Meet webview via CDP.
//!
//! ## Why this can't live in the runtime
//!
//! The listen path uses CEF's public `cef_audio_handler_t` API and
//! needs no Chromium changes. The speak path is the opposite: there is
//! no public API for *writing* PCM into a renderer's audio input, and
//! the Chromium-internal `FileSource` that backs
//! `--use-file-for-fake-audio-capture` only reads a static WAV. Our
//! options are:
//!
//! - Patch Chromium and rebuild from source (multi-day; we don't
//! maintain a CEF source build pipeline yet).
//! - Inject a tiny Web Audio bridge into the Meet page over CDP.
//!
//! This module implements the second path. It runs once per call,
//! after the meet-call window opens but before [`crate::meet_scanner`]
//! starts driving the join page:
//!
//! 1. Attach a CDP session to the Meet target (or about:blank — see
//! note on initial URL below).
//! 2. `Page.addScriptToEvaluateOnNewDocument` with
//! [`AUDIO_BRIDGE_JS`] so it runs at document-start of the *next*
//! document load.
//! 3. `Page.reload` so even an already-navigated Meet page picks up
//! the override before its first `getUserMedia` call.
//!
//! ## Why a reload (rather than starting at about:blank)
//!
//! `meet_call_open_window` builds the WebviewWindow with the Meet URL
//! directly. Refactoring it to navigate via CDP would change the
//! lifecycle for every other code path that watches the meet window,
//! including `meet_scanner`'s target-URL prefix matching. A one-time
//! reload is surgical: meet_scanner already polls for the meet target
//! and tolerates re-navigation.
use std::time::Duration;
use serde_json::{json, Value};
use crate::cdp::{self, CdpConn};
/// JS bundled at build time — the actual Web Audio bridge lives in the
/// sibling `audio_bridge.js`. `include_str!` bakes it into the binary
/// so there's nothing to copy at install.
pub const AUDIO_BRIDGE_JS: &str = include_str!("audio_bridge.js");
/// Captions bridge — DOM observer over Meet's live captions region
/// plus auto-enable for the CC button. Installed alongside the audio
/// bridge so a single `Page.reload` boots both.
pub const CAPTIONS_BRIDGE_JS: &str = include_str!("captions_bridge.js");
/// How long we wait for CDP to surface the meet target after the
/// window builds. Mirrors [`crate::meet_scanner::TARGET_DISCOVERY_BUDGET`]
/// so the two scanners share a budget shape.
const TARGET_DISCOVERY_BUDGET: Duration = Duration::from_secs(20);
const TARGET_DISCOVERY_INTERVAL: Duration = Duration::from_millis(500);
/// Run the inject + reload sequence. Returns the attached CDP
/// connection + session id so the caller (the speak pump) can keep
/// using it for `Runtime.evaluate` calls — opening one CDP session
/// per call rather than per pump tick saves ~5 ms per push.
pub async fn install_audio_bridge(meet_url: &str) -> Result<(CdpConn, String), String> {
let (mut cdp, session) = wait_for_meet_target(meet_url).await?;
log::info!(
"[meet-audio] inject attached session={} meet_url_chars={}",
session,
meet_url.chars().count()
);
// Page.enable is required before some build's reload events fire
// ordering callbacks; harmless on builds where it isn't.
let _ = cdp.call("Page.enable", json!({}), Some(&session)).await;
let _ = cdp.call("Runtime.enable", json!({}), Some(&session)).await;
cdp.call(
"Page.addScriptToEvaluateOnNewDocument",
json!({ "source": AUDIO_BRIDGE_JS }),
Some(&session),
)
.await
.map_err(|e| format!("addScriptToEvaluateOnNewDocument(audio): {e}"))?;
cdp.call(
"Page.addScriptToEvaluateOnNewDocument",
json!({ "source": CAPTIONS_BRIDGE_JS }),
Some(&session),
)
.await
.map_err(|e| format!("addScriptToEvaluateOnNewDocument(captions): {e}"))?;
// Reload so the script applies to the (already-loaded) meet page.
// `ignoreCache: true` defeats the bfcache so we get a real
// document-start hook for the bridge.
cdp.call(
"Page.reload",
json!({ "ignoreCache": true }),
Some(&session),
)
.await
.map_err(|e| format!("Page.reload: {e}"))?;
log::info!("[meet-audio] inject reload requested session={session}");
// Confirm the bridge is live before we return — saves the speak
// pump from sending its first chunk into a void if the script
// failed to run for any reason. Best-effort: a missing bridge
// logs and we still return Ok so the listen path keeps working.
confirm_bridge_alive(&mut cdp, &session).await;
Ok((cdp, session))
}
async fn wait_for_meet_target(meet_url: &str) -> Result<(CdpConn, String), String> {
let deadline = tokio::time::Instant::now() + TARGET_DISCOVERY_BUDGET;
let mut last_err = String::new();
while tokio::time::Instant::now() < deadline {
match cdp::connect_and_attach_matching(|t| t.url.starts_with(meet_url)).await {
Ok(pair) => return Ok(pair),
Err(err) => {
last_err = err;
tokio::time::sleep(TARGET_DISCOVERY_INTERVAL).await;
}
}
}
Err(format!(
"[meet-audio] timeout waiting for meet target: {last_err}"
))
}
/// Poll `window.__openhumanAudioBridgeInfo()` for up to ~5 s. Logs the
/// outcome but never returns an error — the speak pump will rediscover
/// the bridge on the next push if it shows up late.
async fn confirm_bridge_alive(cdp: &mut CdpConn, session: &str) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while tokio::time::Instant::now() < deadline {
let res = cdp
.call(
"Runtime.evaluate",
json!({
"expression": "(typeof window.__openhumanAudioBridgeInfo === 'function') \
? JSON.stringify(window.__openhumanAudioBridgeInfo()) \
: null",
"returnByValue": true,
}),
Some(session),
)
.await;
if let Ok(v) = res {
let value = v
.get("result")
.and_then(|r| r.get("value"))
.cloned()
.unwrap_or(Value::Null);
if let Some(s) = value.as_str() {
log::info!("[meet-audio] bridge alive info={s}");
return;
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
log::warn!("[meet-audio] bridge readiness probe timed out — speak pump will retry");
}
/// Drain the page-side caption queue. Returns 0 or more `(speaker,
/// text, ts_ms)` triples accumulated since the last drain. The caller
/// (the caption listener loop) calls this every ~500 ms.
pub async fn drain_captions(
cdp: &mut CdpConn,
session: &str,
) -> Result<Vec<(String, String, u64)>, String> {
let res = cdp
.call(
"Runtime.evaluate",
json!({
"expression": "(typeof window.__openhumanDrainCaptions === 'function') \
? JSON.stringify(window.__openhumanDrainCaptions()) \
: '[]'",
"returnByValue": true,
}),
Some(session),
)
.await
.map_err(|e| format!("Runtime.evaluate drain_captions: {e}"))?;
let json_str = res
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("[]")
.to_string();
let parsed: Vec<Value> =
serde_json::from_str(&json_str).map_err(|e| format!("parse captions json: {e}"))?;
let mut out = Vec::with_capacity(parsed.len());
for entry in parsed {
let speaker = entry
.get("speaker")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let text = entry
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let ts_ms = entry.get("ts").and_then(|v| v.as_u64()).unwrap_or(0);
if text.is_empty() {
continue;
}
out.push((speaker, text, ts_ms));
}
Ok(out)
}
/// Dispatch one PCM chunk into the page's bridge. Called on every
/// poll-speech tick by [`crate::meet_audio::speak_pump`].
///
/// Errors are returned (rather than logged inline) so the pump can
/// decide whether to back off — repeated failures usually mean the
/// page navigated away (e.g. "you've been removed from the call"),
/// which the meet-call lifecycle handles by tearing the whole session
/// down anyway.
pub async fn feed_pcm_chunk(cdp: &mut CdpConn, session: &str, pcm_b64: &str) -> Result<(), String> {
if pcm_b64.is_empty() {
return Ok(());
}
// Build the call as a string literal so a long base64 payload
// travels as a JS source argument (CDP's Runtime.callFunctionOn
// would be cleaner but requires the bridge function's objectId,
// and Runtime.evaluate keeps the wire shape one round-trip).
//
// The b64 alphabet has no quote / backslash characters so a plain
// single-quoted literal is safe — but defensively escape just in
// case some future encoder produces padding-edge weirdness.
let escaped = pcm_b64.replace('\\', "\\\\").replace('\'', "\\'");
let expression = format!(
"(typeof window.__openhumanFeedPcm === 'function') ? window.__openhumanFeedPcm('{escaped}') : -1"
);
let res = cdp
.call(
"Runtime.evaluate",
json!({
"expression": expression,
"returnByValue": true,
}),
Some(session),
)
.await
.map_err(|e| format!("Runtime.evaluate feed: {e}"))?;
if let Some(exception) = res.get("exceptionDetails") {
return Err(format!("page exception: {exception}"));
}
Ok(())
}
@@ -0,0 +1,327 @@
//! Capture the embedded Meet webview's audio output and forward it to
//! the core meet_agent loop.
//!
//! ## Pipeline
//!
//! 1. `tauri_runtime_cef::audio::register_audio_handler` taps the
//! per-browser `cef_audio_handler_t`. CEF delivers planar
//! float32 PCM at the renderer's native rate (typically 48 kHz,
//! 12 channels) directly from the audio output device path —
//! *before* it hits the OS speaker. No system permission needed.
//!
//! 2. Downsample-to-mono runs inline on the CEF audio thread:
//! - average across channels → mono float32
//! - linear-interpolate down to 16 kHz (the rate `voice::streaming`
//! and the smoke test in `meet_agent::session` expect)
//! - clamp + scale to PCM16LE
//!
//! 3. Accumulate ~100 ms per chunk (1 600 samples @ 16 kHz). We push
//! via the core RPC on every flush boundary; smaller pushes would
//! overload the JSON envelope, larger ones would slow VAD.
//!
//! 4. RPC pushes are spawned on the tokio runtime so the audio
//! callback never blocks on network IO. A bounded channel
//! backpressures: if core is wedged, we drop the oldest queued
//! chunk rather than holding CEF's audio thread.
use std::sync::{Arc, Mutex};
use tauri_runtime_cef::audio::{
register_audio_handler, AudioHandlerRegistration, AudioStreamEvent,
};
use tokio::sync::mpsc;
const TARGET_SAMPLE_RATE: u32 = 16_000;
/// 100 ms @ 16 kHz mono. `meet_agent::ops::Vad` pushes hangover counts
/// based on per-frame cadence, so changing this changes the VAD wall
/// time too. 100 ms feels responsive without burning RPC.
const FLUSH_SAMPLES: usize = (TARGET_SAMPLE_RATE as usize) / 10;
/// Bounded channel between the CEF callback (producer) and the
/// async-runtime forwarder (consumer). 32 chunks ≈ 3.2 s at the flush
/// cadence — generous slack for transient core latency, but bounded
/// so a wedged core can't OOM us.
const FORWARD_CHANNEL_CAPACITY: usize = 32;
/// RAII handle. Drop to release the CEF audio registration and shut
/// down the forwarder task. Both happen synchronously — the channel
/// closes first, the task exits its recv loop, and the registration
/// drop unhooks CEF in the same tick.
pub struct ListenSession {
pub request_id: String,
_registration: AudioHandlerRegistration,
/// Held so `Drop` closes the channel even if there are no in-flight
/// chunks. The forwarder task observes the close and exits.
_shutdown_tx: mpsc::Sender<Vec<u8>>,
}
/// Opens the audio capture for `meet_url`. The same exact URL must
/// have been used to build the CEF window — `register_audio_handler`
/// matches by prefix.
pub fn start(meet_url: &str, request_id: String) -> Result<ListenSession, String> {
let (tx, rx) = mpsc::channel::<Vec<u8>>(FORWARD_CHANNEL_CAPACITY);
let resampler = Arc::new(Mutex::new(Resampler::new()));
let resampler_for_handler = resampler.clone();
let tx_for_handler = tx.clone();
let request_id_for_log = request_id.clone();
let registration = register_audio_handler(meet_url.to_string(), move |event| {
on_audio_event(
&request_id_for_log,
event,
&resampler_for_handler,
&tx_for_handler,
);
});
spawn_forwarder(request_id.clone(), rx);
log::info!(
"[meet-audio] listen registered request_id={} url_chars={}",
request_id,
meet_url.chars().count()
);
Ok(ListenSession {
request_id,
_registration: registration,
_shutdown_tx: tx,
})
}
/// Process one CEF audio event. Speech/Stopped/Error all flow through
/// here; only `Packet` produces RPC traffic, but the others are logged
/// at info so an aborted call leaves a breadcrumb in the file logs.
fn on_audio_event(
request_id: &str,
event: AudioStreamEvent,
resampler: &Arc<Mutex<Resampler>>,
tx: &mpsc::Sender<Vec<u8>>,
) {
match event {
AudioStreamEvent::Started {
sample_rate_hz,
channels,
frames_per_buffer,
} => {
log::info!(
"[meet-audio] cef stream start request_id={request_id} hz={sample_rate_hz} channels={channels} frames_per_buffer={frames_per_buffer}"
);
if let Ok(mut r) = resampler.lock() {
r.reset(sample_rate_hz as u32);
}
}
AudioStreamEvent::Packet {
channels: planes,
pts_ms: _,
} => {
let pcm_bytes = match resampler.lock() {
Ok(mut r) => r.feed_and_drain(&planes),
Err(_) => return,
};
for chunk in pcm_bytes.chunks(FLUSH_SAMPLES * 2) {
// `try_send` drops the chunk on a full channel rather
// than blocking the CEF audio thread. Better to lose
// a frame than to stall the renderer.
if tx.try_send(chunk.to_vec()).is_err() {
log::warn!(
"[meet-audio] forward channel full; dropping {} bytes request_id={request_id}",
chunk.len()
);
}
}
}
AudioStreamEvent::Stopped => {
log::info!("[meet-audio] cef stream stopped request_id={request_id}");
if let Ok(mut r) = resampler.lock() {
r.reset(0);
}
}
AudioStreamEvent::Error(msg) => {
log::warn!("[meet-audio] cef stream error request_id={request_id} msg={msg}");
}
}
}
/// Pull chunks off the bounded channel and POST each to core. Lives in
/// its own task so the CEF callback never blocks on HTTP.
fn spawn_forwarder(request_id: String, mut rx: mpsc::Receiver<Vec<u8>>) {
tauri::async_runtime::spawn(async move {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
while let Some(chunk) = rx.recv().await {
let pcm_b64 = B64.encode(&chunk);
let res = super::rpc_call(
"openhuman.meet_agent_push_listen_pcm",
serde_json::json!({
"request_id": request_id,
"pcm_base64": pcm_b64,
}),
)
.await;
if let Err(err) = res {
log::debug!(
"[meet-audio] push_listen_pcm err request_id={request_id} bytes={} err={err}",
chunk.len()
);
}
}
log::info!("[meet-audio] forwarder exiting request_id={request_id}");
});
}
/// Stateful float32-planar → PCM16LE mono @ 16 kHz resampler.
///
/// Uses linear interpolation, which is good enough for speech (the
/// downstream STT does not care about ultrasonics or pristine high
/// frequencies). Carry the previous sample across `feed_and_drain`
/// calls so we don't introduce a tick at every CEF buffer boundary.
/// Pick a source sample by signed index. Negative indices return the
/// carry sample from the previous call (so phase < 0 keeps the
/// interpolation continuous across buffer boundaries); past-the-end
/// indices clamp to the last sample (which is what the next call will
/// install as its own carry, so the output stays smooth even if a
/// caller stops feeding mid-stream).
fn sample_at(mono: &[f32], carry: f32, idx: i64) -> f32 {
if idx < 0 {
carry
} else if (idx as usize) < mono.len() {
mono[idx as usize]
} else {
*mono.last().unwrap_or(&0.0)
}
}
struct Resampler {
source_rate_hz: u32,
/// Fractional position into the source buffer between calls.
/// 0.0 means "start cleanly with the next sample". Negative is
/// not used — the source rate is always known before we feed.
phase: f64,
/// Last source sample of the previous call, used as the "left"
/// neighbour when we interpolate the first sample of the next call.
last_sample: f32,
}
impl Resampler {
fn new() -> Self {
Self {
source_rate_hz: 0,
phase: 0.0,
last_sample: 0.0,
}
}
fn reset(&mut self, source_rate_hz: u32) {
self.source_rate_hz = source_rate_hz;
self.phase = 0.0;
self.last_sample = 0.0;
}
fn feed_and_drain(&mut self, planes: &[Vec<f32>]) -> Vec<u8> {
if planes.is_empty() || self.source_rate_hz == 0 {
return Vec::new();
}
let frames = planes[0].len();
if frames == 0 {
return Vec::new();
}
// Mono mix.
let mono: Vec<f32> = (0..frames)
.map(|i| {
let mut sum = 0.0_f32;
for plane in planes {
if let Some(v) = plane.get(i) {
sum += *v;
}
}
sum / planes.len() as f32
})
.collect();
let ratio = self.source_rate_hz as f64 / TARGET_SAMPLE_RATE as f64;
let mut out = Vec::with_capacity((mono.len() as f64 / ratio).ceil() as usize * 2);
// `pos` floats through `mono` indices. `pos < 0` means "still
// sampling the carry sample from the previous call"; `pos = 0`
// means "right at mono[0]".
let mut pos = self.phase;
while pos < mono.len() as f64 {
let idx_f = pos.floor();
let frac = pos - idx_f;
let idx = idx_f as i64;
let s_left = sample_at(mono.as_slice(), self.last_sample, idx);
let s_right = sample_at(mono.as_slice(), self.last_sample, idx + 1);
let sample = s_left as f64 + (s_right as f64 - s_left as f64) * frac;
// Float32 [-1.0, 1.0] → i16. Clamp because Chromium can
// overshoot a touch on heavy compression.
let s_i16 = (sample.clamp(-1.0, 1.0) * i16::MAX as f64) as i16;
out.extend_from_slice(&s_i16.to_le_bytes());
pos += ratio;
}
// Carry the trailing fractional position into the next call.
// It will be negative when we overshot (next call resumes
// mid-source-sample), so the next call interpolates between
// `last_sample` and the new mono[0].
self.phase = pos - mono.len() as f64;
self.last_sample = *mono.last().unwrap_or(&0.0);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resampler_with_no_source_rate_yields_nothing() {
let mut r = Resampler::new();
let out = r.feed_and_drain(&[vec![0.5; 100]]);
assert!(out.is_empty(), "no source rate set, must produce nothing");
}
#[test]
fn resampler_48k_to_16k_mono_drops_samples_3to1() {
let mut r = Resampler::new();
r.reset(48_000);
let plane = vec![0.5_f32; 4_800]; // 100ms @ 48k
let bytes = r.feed_and_drain(&[plane]);
// 100ms @ 16k = 1600 samples * 2 bytes. Allow ±2 samples slop
// from the fractional phase carry.
let samples = bytes.len() / 2;
assert!(
(1598..=1602).contains(&samples),
"expected ~1600 samples, got {samples}"
);
}
#[test]
fn resampler_stereo_to_mono_averages_channels() {
let mut r = Resampler::new();
r.reset(16_000);
let left = vec![0.8_f32; 1600];
let right = vec![-0.2_f32; 1600];
let bytes = r.feed_and_drain(&[left, right]);
// Avg = 0.3 → ~9830 in i16. First two bytes are LE i16.
let first = i16::from_le_bytes([bytes[0], bytes[1]]);
assert!(
(9000..11000).contains(&first),
"expected mid-amplitude i16, got {first}"
);
}
#[test]
fn resampler_clamps_out_of_range_floats() {
let mut r = Resampler::new();
r.reset(16_000);
let bytes = r.feed_and_drain(&[vec![5.0_f32; 100]]);
let first = i16::from_le_bytes([bytes[0], bytes[1]]);
assert_eq!(first, i16::MAX);
}
#[test]
fn resampler_passthrough_when_rates_match() {
let mut r = Resampler::new();
r.reset(16_000);
let plane = vec![0.5_f32; 1600];
let bytes = r.feed_and_drain(&[plane]);
assert_eq!(bytes.len(), 1600 * 2);
}
}
+309
View File
@@ -0,0 +1,309 @@
//! Shell-side audio plumbing for the live meet-agent loop.
//!
//! ## Pieces
//!
//! - [`listen_capture`] — taps the embedded Meet webview's audio output
//! via the per-browser `CefAudioHandler` exposed by our vendored
//! `tauri-runtime-cef::audio` extension, downsamples to 16 kHz mono
//! PCM16LE, batches into ~100 ms chunks, and posts them to core via
//! `openhuman.meet_agent_push_listen_pcm`. Zero OS-level audio
//! permission needed: we read frames straight out of the renderer.
//!
//! - [`speak_pump`] — drains synthesized PCM the brain enqueued (via
//! `openhuman.meet_agent_poll_speech`) and writes it into the
//! Chromium `pipe://openhuman/<request_id>` fake-audio source we
//! patch in the vendored CEF subtree. PR1 ships the pump scaffolding;
//! the Chromium-side patch lands in a follow-up slice.
//!
//! ## Lifecycle
//!
//! [`start`] is invoked once the meet-call window has been built (in
//! `meet_call::meet_call_open_window`). It opens the core session,
//! registers the audio handler keyed by the call's URL, and spawns the
//! poll-speech loop. [`stop`] runs from the window-destroyed handler:
//! it drops the audio handler registration (which silences capture
//! immediately), stops the speak pump, and tells core to close the
//! session and report counters.
pub mod caption_listener;
pub mod inject;
pub mod listen_capture;
pub mod speak_pump;
use std::collections::HashMap;
use std::sync::Mutex;
use serde::Serialize;
use tauri::{AppHandle, Manager, Runtime};
/// Process-wide registry of active meet-agent sessions, keyed by
/// `request_id`. Mirrors the shape of `meet_call::MeetCallState` so
/// the two registries stay symmetric.
#[derive(Default)]
pub struct MeetAudioState {
inner: Mutex<HashMap<String, MeetAudioSession>>,
}
impl MeetAudioState {
pub fn new() -> Self {
Self::default()
}
}
/// Held while a session is live. Dropping it runs the listen + speak
/// teardown synchronously — no async drop needed because the caption
/// listener and speak pump both shut down on signal/drop.
///
/// The legacy CEF-audio `listen_capture::ListenSession` is kept as an
/// optional field so the pre-register flow still has somewhere to
/// hand the registration off if a future build re-enables it. In the
/// caption-driven path it stays `None`.
pub struct MeetAudioSession {
pub request_id: String,
_captions: caption_listener::CaptionListener,
_legacy_listen: Option<listen_capture::ListenSession>,
_speak: speak_pump::SpeakPump,
}
#[derive(Debug, Clone, Serialize)]
pub struct StopSummary {
pub request_id: String,
pub listened_seconds: f32,
pub spoken_seconds: f32,
pub turn_count: u32,
}
/// Open a meet-agent audio session.
///
/// Listen path goes via the captions bridge (`captions_bridge.js`) +
/// [`caption_listener`]. Speak path goes via the audio bridge
/// (`audio_bridge.js`) + [`speak_pump`]. Both are installed by
/// [`inject::install_audio_bridge`].
///
/// `meet_url` must be the *exact* URL the CEF window was built with —
/// the inject path uses it as the CDP target prefix so two concurrent
/// calls each attach to their own browser.
pub async fn start<R: Runtime>(
app: AppHandle<R>,
request_id: String,
meet_url: String,
) -> Result<(), String> {
log::info!(
"[meet-audio] start request_id={request_id} url_prefix={}",
truncate_for_log(&meet_url, 64)
);
if let Some(state) = app.try_state::<MeetAudioState>() {
let mut guard = state.inner.lock().unwrap();
if guard.contains_key(&request_id) {
// Idempotent restart: drop the previous session before
// overwriting so its registration is released.
guard.remove(&request_id);
log::info!("[meet-audio] replaced existing session request_id={request_id}");
}
}
// Tell core to open its session first so the very first PCM push
// doesn't race the start RPC.
rpc_call(
"openhuman.meet_agent_start_session",
serde_json::json!({
"request_id": request_id,
"sample_rate_hz": 16_000,
}),
)
.await?;
// Install the page-side audio + captions bridges in one go. The
// returned CDP session is shared by the speak pump and caption
// listener — we open a second session for the listener so the
// two run concurrently without serialising on a single CDP
// mailbox.
let (speak, captions) = match inject::install_audio_bridge(&meet_url).await {
Ok((cdp, session)) => {
// Spawn the caption listener on its own CDP attach so a
// long Runtime.evaluate from one side never starves the
// other. The second attach reuses the same CDP target.
let captions = match crate::cdp::connect_and_attach_matching(|t| {
t.url.starts_with(&meet_url)
})
.await
{
Ok((cdp_for_captions, session_for_captions)) => caption_listener::start(
request_id.clone(),
cdp_for_captions,
session_for_captions,
),
Err(err) => {
log::warn!(
"[meet-audio] caption listener cdp attach failed request_id={request_id} err={err}"
);
caption_listener_disabled(request_id.clone())
}
};
let speak = speak_pump::start(request_id.clone(), cdp, session);
(speak, captions)
}
Err(err) => {
log::warn!(
"[meet-audio] audio bridge install failed request_id={request_id} err={err} — speak + caption paths disabled for this call"
);
(
speak_pump::start_disabled(request_id.clone()),
caption_listener_disabled(request_id.clone()),
)
}
};
if let Some(state) = app.try_state::<MeetAudioState>() {
state.inner.lock().unwrap().insert(
request_id.clone(),
MeetAudioSession {
request_id: request_id.clone(),
_captions: captions,
_legacy_listen: None,
_speak: speak,
},
);
} else {
log::warn!(
"[meet-audio] MeetAudioState missing from app — session will be ungoverned request_id={request_id}"
);
}
Ok(())
}
/// Stop a meet-agent audio session. Best-effort: errors from individual
/// shutdown steps are logged but never propagated, because window
/// destruction must finish even if e.g. core is unreachable.
pub async fn stop<R: Runtime>(
app: AppHandle<R>,
request_id: String,
) -> Result<Option<StopSummary>, String> {
let session = if let Some(state) = app.try_state::<MeetAudioState>() {
state.inner.lock().unwrap().remove(&request_id)
} else {
None
};
let Some(session) = session else {
log::debug!("[meet-audio] stop: no session for request_id={request_id}");
return Ok(None);
};
// Dropping `session` first releases the audio handler registration
// (so CEF stops feeding us frames) and signals the pump to exit.
drop(session);
match rpc_call(
"openhuman.meet_agent_stop_session",
serde_json::json!({ "request_id": request_id }),
)
.await
{
Ok(v) => {
let listened = v
.get("listened_seconds")
.and_then(|x| x.as_f64())
.unwrap_or(0.0) as f32;
let spoken = v
.get("spoken_seconds")
.and_then(|x| x.as_f64())
.unwrap_or(0.0) as f32;
let turns = v.get("turn_count").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
log::info!(
"[meet-audio] stop ok request_id={request_id} listened={listened:.2}s spoken={spoken:.2}s turns={turns}"
);
Ok(Some(StopSummary {
request_id,
listened_seconds: listened,
spoken_seconds: spoken,
turn_count: turns,
}))
}
Err(err) => {
log::warn!("[meet-audio] stop_session rpc failed request_id={request_id} err={err}");
Ok(None)
}
}
}
/// Minimal JSON-RPC helper used by both this module and the speak pump
/// loop. Mirrors the call shape used by other shell scanners (see
/// `telegram_scanner::mod.rs`).
pub(crate) async fn rpc_call(
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, String> {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
});
let url = crate::core_rpc::core_rpc_url_value();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("http client: {e}"))?;
let req = crate::core_rpc::apply_auth(client.post(&url))
.map_err(|e| format!("prepare {url}: {e}"))?;
let resp = req
.json(&body)
.send()
.await
.map_err(|e| format!("POST {url}: {e}"))?;
let status = resp.status();
let v: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("decode {status}: {e}"))?;
if !status.is_success() {
return Err(format!("{status}: {v}"));
}
if let Some(err) = v.get("error") {
return Err(format!("rpc error: {err}"));
}
Ok(v.get("result").cloned().unwrap_or(serde_json::Value::Null))
}
/// No-op caption listener used when CDP attach failed at session
/// start. Lets the rest of the lifecycle hold a uniform value.
fn caption_listener_disabled(request_id: String) -> caption_listener::CaptionListener {
caption_listener::CaptionListener {
request_id,
_shutdown_tx: None,
}
}
/// Trim a string for logging without panicking on multi-byte chars.
fn truncate_for_log(s: &str, max_chars: usize) -> String {
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if i >= max_chars {
out.push('…');
break;
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_handles_short_strings() {
assert_eq!(truncate_for_log("hi", 10), "hi");
}
#[test]
fn truncate_caps_long_strings() {
let long = "a".repeat(100);
let trimmed = truncate_for_log(&long, 10);
assert!(trimmed.ends_with('…'));
assert_eq!(trimmed.chars().count(), 11);
}
}
+138
View File
@@ -0,0 +1,138 @@
//! Speak path: poll synthesized PCM out of core and feed it into the
//! Meet page's audio bridge over CDP.
//!
//! Design lives in [`super::inject`]: the bridge is installed once at
//! session start by `install_audio_bridge`, which returns the open CDP
//! connection + session id. The pump owns those for the lifetime of
//! the call so each tick is a single `Runtime.evaluate` round-trip
//! rather than fresh attach + detach.
use std::time::Duration;
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use tokio::sync::oneshot;
use tokio::time::interval;
use crate::cdp::CdpConn;
use super::inject;
/// Polling cadence. Same as the listen path's flush boundary so the
/// loop stays in lockstep — every ~100 ms we push captured audio in
/// (listen) and pull synthesized audio out (speak).
const POLL_INTERVAL: Duration = Duration::from_millis(100);
/// Cap on consecutive feed failures before we give up and stop
/// pumping. Hitting this usually means the page navigated away
/// (Meet's "you've been removed" / network drop) — the meet-call
/// window-destroyed handler will tear the rest of the session down
/// either way.
const MAX_CONSECUTIVE_FEED_ERRORS: u32 = 30;
/// RAII handle. Drop to stop the pump task. The shutdown channel
/// causes the spawned loop to exit on the next select tick.
pub struct SpeakPump {
pub request_id: String,
_shutdown_tx: Option<oneshot::Sender<()>>,
}
impl Drop for SpeakPump {
fn drop(&mut self) {
let _ = self._shutdown_tx.take();
}
}
/// Spawn the speak pump for a session that already has the audio
/// bridge installed. `cdp` and `session_id` come from
/// [`inject::install_audio_bridge`] and are owned by the pump task
/// from this point on.
pub fn start(request_id: String, cdp: CdpConn, session_id: String) -> SpeakPump {
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
let request_id_for_task = request_id.clone();
tauri::async_runtime::spawn(async move {
let mut tick = interval(POLL_INTERVAL);
// Burn the first tick (`interval` fires immediately) so we
// don't poll before the listen path has had a chance to push.
tick.tick().await;
let mut cdp = cdp;
let mut feed_errors: u32 = 0;
loop {
tokio::select! {
_ = &mut shutdown_rx => {
log::info!(
"[meet-audio] speak pump shutdown request_id={request_id_for_task}"
);
break;
}
_ = tick.tick() => {
match poll_and_feed(&request_id_for_task, &mut cdp, &session_id).await {
Ok(_) => feed_errors = 0,
Err(err) => {
feed_errors += 1;
log::debug!(
"[meet-audio] speak pump tick err request_id={request_id_for_task} consec_errors={feed_errors} err={err}"
);
if feed_errors >= MAX_CONSECUTIVE_FEED_ERRORS {
log::warn!(
"[meet-audio] speak pump giving up after {feed_errors} consecutive errors request_id={request_id_for_task}"
);
break;
}
}
}
}
}
}
});
SpeakPump {
request_id,
_shutdown_tx: Some(shutdown_tx),
}
}
/// No-op pump used when bridge install failed at session start. Keeps
/// the rest of the session lifecycle uniform — `MeetAudioSession` can
/// still hold a `SpeakPump` regardless of speak-path readiness.
pub fn start_disabled(request_id: String) -> SpeakPump {
SpeakPump {
request_id,
_shutdown_tx: None,
}
}
async fn poll_and_feed(
request_id: &str,
cdp: &mut CdpConn,
session_id: &str,
) -> Result<(), String> {
let v = super::rpc_call(
"openhuman.meet_agent_poll_speech",
serde_json::json!({ "request_id": request_id }),
)
.await?;
let pcm_b64 = v
.get("pcm_base64")
.and_then(|x| x.as_str())
.unwrap_or_default();
let utterance_done = v
.get("utterance_done")
.and_then(|x| x.as_bool())
.unwrap_or(false);
if !pcm_b64.is_empty() {
// Validate decode locally before pushing — saves a round-trip
// when the brain enqueues a malformed batch.
let bytes = B64
.decode(pcm_b64.as_bytes())
.map_err(|e| format!("base64: {e}"))?;
log::debug!(
"[meet-audio] speak pump feeding request_id={request_id} bytes={} done={utterance_done}",
bytes.len()
);
inject::feed_pcm_chunk(cdp, session_id, pcm_b64).await?;
} else if utterance_done {
log::info!("[meet-audio] speak pump utterance complete request_id={request_id}");
}
Ok(())
}
+40
View File
@@ -124,6 +124,27 @@ pub async fn meet_call_open_window<R: Runtime>(
args.display_name.clone(),
);
// Start the live meet-agent audio loop: registers a CEF audio
// handler keyed by the meet URL, opens a core session, and spawns
// the speak-pump poller. Fire-and-forget — failures here must not
// prevent the user from at least seeing the join page, so we log
// and continue. The teardown below mirrors this on window close.
{
let app_for_audio = app.clone();
let request_id_for_audio = request_id.clone();
let url_for_audio = parsed.to_string();
tauri::async_runtime::spawn(async move {
if let Err(err) =
crate::meet_audio::start(app_for_audio, request_id_for_audio.clone(), url_for_audio)
.await
{
log::warn!(
"[meet-call] meet_audio start failed request_id={request_id_for_audio} err={err}"
);
}
});
}
// Emit a `closed` event when the user dismisses the window AND clean
// up the per-call data directory. The data dir holds an isolated CEF
// profile (cookies, cache) we explicitly want to throw away after
@@ -151,6 +172,25 @@ pub async fn meet_call_open_window<R: Runtime>(
log::info!(
"[meet-call] window destroyed label={label_for_event} request_id={request_id_for_event}"
);
// Tear down the meet-agent audio loop *before* the
// data dir wipe so the audio handler registration
// releases CEF cleanly while the browser is still
// shutting down.
{
let app_for_audio = app_for_event.clone();
let request_id_for_audio = request_id_for_event.clone();
tauri::async_runtime::spawn(async move {
if let Err(err) =
crate::meet_audio::stop(app_for_audio, request_id_for_audio.clone())
.await
{
log::debug!(
"[meet-call] meet_audio stop err request_id={request_id_for_audio} err={err}"
);
}
});
}
// CEF may still be flushing the profile to disk on
// teardown; do the rmdir off the UI thread so any
// last-second writes don't race the delete.
+113
View File
@@ -0,0 +1,113 @@
# Meet-agent live loop — smoke test runbook
End-to-end validation that the agent hears, thinks, and speaks on a
real Google Meet call. Two laptops are easiest (Laptop A runs OpenHuman
+ joins the Meet as the agent; Laptop B is the human host who creates
the call and listens to the agent's voice).
## Pre-flight
1. Sign in to OpenHuman so a backend session token exists. Without
it, all three brain stages (STT/LLM/TTS) silently fall back to
stubs and you'll only hear a 200 ms tone — useful for plumbing
smoke but not the real loop.
2. Ensure the vendored `tauri-cef` submodule is on
`feat/openhuman-audio-handler` (or whatever branch carries the
`audio` module — see `app/src-tauri/vendor/tauri-cef`).
3. `pnpm tauri dev` in the repo root.
## Steps
1. **Laptop B**: create a Meet call at <https://meet.google.com/new>,
stay in the lobby.
2. **Laptop A**:
- Open OpenHuman → Intelligence → Calls.
- Paste the Meet URL, set display name (e.g. "OpenHuman Agent").
- Click *Join*.
- A dedicated CEF window opens. The window title bar reads
"Meet — OpenHuman Agent".
3. **Laptop B**: admit the agent from the lobby.
4. Confirm Meet's live captions are on. The captions bridge auto-clicks
"Turn on captions" up to ~30 times over the first minute; if the
button isn't found (Meet UI rolls), enable CC manually.
5. Speak a wake-word phrase into the call mic. Examples:
- "Hey, OpenHuman — remember to email Bob about the launch."
- "Hey OpenHuman, follow up with the design team next week."
The agent should reply with a short canned ack ("Got it.",
"Noted.", "Adding that.", "On it.", or "Captured.") routed back
into Meet's audio.
## What to watch for
### Listen path (Meet captions → agent)
- The CEF audio handler / Whisper STT path is **not** the live-call
listen path; do not expect `cef stream start` or `push_listen_pcm`
log lines (those modules are kept in tree as `_legacy_listen` for a
future opt-in).
- Tail the file logs (`~/Library/Application Support/OpenHuman/logs/`):
```text
[meet-audio] inject reload requested session=…
[meet-audio] bridge alive info={"installed":true,"sample_rate":16000,…}
[meet-audio] captions drained count=N request_id=…
[meet-agent-rpc] wake word fired request_id=… speaker=…
[meet-agent] caption turn start request_id=… prompt_chars=…
[meet-agent] caption turn done request_id=… reply_chars=… synth_samples=…
```
- If `captions drained` never logs, the captions bridge didn't find
Meet's caption region — either CC is off (auto-enable failed) or
Meet rolled the DOM and the `aria-label="Captions"` selector
needs updating in `captions_bridge.js`. Confirm via the embedded
page console: `window.__openhumanCaptionsBridgeInfo()` — the
`region_found` field should be `true` once captions are on.
### Speak path (agent → Meet)
- Inspect the embedded Meet page's console (right-click → Inspect; or
attach via the CDP port 19222 on Laptop A): you should see
`[openhuman-audio-bridge] feed failed: …` only on errors.
- Run `window.__openhumanAudioBridgeInfo()` in the console:
```json
{ "installed": true, "sample_rate": 16000, "audio_context_state": "running",
"next_start_time": 12.3, "destination_track_count": 1 }
```
- **Laptop B**: you should hear the agent's reply through Meet, with
the agent's tile lighting up the "speaking" indicator.
### Mascot webcam
- Laptop B sees the OpenHuman mascot SVG in the agent's tile.
Confirms `--use-file-for-fake-video-capture` is still active (the
speak path doesn't break it).
## Things that should NOT happen
- macOS prompt for screen recording / microphone permission.
- macOS prompt for installing a system audio driver / kext.
- The OpenHuman main window's mic indicator turning on (we tap CEF's
audio at the renderer level, not via the OS mic).
## Common failure modes
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Heard event empty / "STT failure" | No backend session | Sign in |
| Spoke event present, no audio on Laptop B | Bridge install failed | Check `Page.reload` errored — devtools network |
| 1× turn fires, then nothing | VAD `in_utterance` flag stuck | Look for `EndOfUtterance` events; may need a longer hangover |
| Audio "robot voice" | Sample-rate mismatch — bridge says 16000 but TTS gave another rate | Confirm `output_format=pcm_16000` request was honored |
| `cef stream error` repeated | Renderer crashed | Check Chromium logs in the meet-call data dir |
## Cleanup
- Close the meet-call window. The window-destroyed handler tears down
`meet_audio` (drops the audio handler registration → silences
capture immediately, signals the speak pump → exits) and calls
`openhuman.meet_agent_stop_session` which logs the listened/spoken
totals.
- Per-call data dir is wiped automatically.
+8
View File
@@ -211,6 +211,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::notifications::all_notifications_registered_controllers());
// Google Meet call-join request validation (shell handles the webview)
controllers.extend(crate::openhuman::meet::all_meet_registered_controllers());
// Live meet-agent loop: STT/LLM/TTS over the open call's audio.
controllers.extend(crate::openhuman::meet_agent::all_meet_agent_registered_controllers());
// Structured WhatsApp Web data — agent-facing read-only controllers (list/search).
// The write-path ingest controller is registered separately in build_internal_only_controllers.
controllers.extend(crate::openhuman::whatsapp_data::all_whatsapp_data_registered_controllers());
@@ -293,6 +295,8 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::notifications::all_notifications_controller_schemas());
// Google Meet call-join request validation
schemas.extend(crate::openhuman::meet::all_meet_controller_schemas());
// Live meet-agent listening + speaking loop
schemas.extend(crate::openhuman::meet_agent::all_meet_agent_controller_schemas());
// Structured WhatsApp Web data — local SQLite store, agent-queryable
schemas.extend(crate::openhuman::whatsapp_data::all_whatsapp_data_controller_schemas());
schemas
@@ -385,6 +389,10 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"Validate Google Meet call-join requests and mint a request_id; the desktop \
shell opens the embedded CEF webview that joins the call as an anonymous guest.",
),
"meet_agent" => Some(
"Live agent loop for an open Google Meet call: shell streams inbound PCM, \
core runs VAD-segmented STT → LLM → TTS, shell pulls synthesized PCM back.",
),
"whatsapp_data" => Some(
"Structured WhatsApp conversation and message store — list chats, read messages, and search across WhatsApp Web data.",
),
+19
View File
@@ -877,6 +877,25 @@ const CAPABILITIES: &[Capability] = &[
destinations: &["Google Meet"],
}),
},
Capability {
id: "meet_agent.live_loop",
name: "Live Meet Agent — Listen + Speak",
domain: "meet_agent",
category: CapabilityCategory::Automation,
description: "While the agent is in a Google Meet call, it listens to the other \
participants by tapping the embedded webview's audio output, runs \
VAD-segmented speech-to-text, decides whether to respond, and speaks \
back through a virtual microphone the embedded Chromium reads as if \
it were a real input device. No system audio permission required — \
capture and playback both stay inside the CEF process.",
how_to: "Automatic once a Meet call is open via Intelligence > Calls.",
status: CapabilityStatus::Beta,
privacy: Some(CapabilityPrivacy {
leaves_device: true,
data_kind: PrivacyDataKind::Derived,
destinations: &["Google Meet", "ElevenLabs (STT/TTS via hosted backend)"],
}),
},
// ── Update ──────────────────────────────────────────────────────────────
Capability {
id: "update.check",
+1
View File
@@ -79,6 +79,7 @@ fn catalog_includes_additional_user_facing_surfaces() {
"settings.manage_service",
"settings.clear_app_data",
"meet.join_call",
"meet_agent.live_loop",
] {
assert!(
ids.contains(expected),
+448
View File
@@ -0,0 +1,448 @@
//! Turn orchestration: STT → LLM → TTS.
//!
//! ## Pipeline
//!
//! When [`session::Vad`] reports `EndOfUtterance`, [`run_turn`] drains
//! the inbound buffer and runs three serial stages:
//!
//! 1. **STT** — wrap the PCM16LE samples in a WAV container and post
//! to [`crate::openhuman::voice::cloud_transcribe`]. Returns the
//! transcribed text (or `Err` on transport / auth failure).
//!
//! 2. **LLM** — send a tiny chat-completions request through
//! [`crate::api::BackendOAuthClient`] with a "live meeting agent"
//! system prompt and the transcript as the user message. Returns a
//! short reply (or empty string when the agent decides to stay
//! silent).
//!
//! 3. **TTS** — feed the reply text into
//! [`crate::openhuman::voice::reply_speech`] requesting
//! `output_format = "pcm_16000"`. Decode the base64 PCM bytes back
//! into `Vec<i16>` and enqueue on the session's outbound queue.
//!
//! ## Fallback
//!
//! When the backend session token is missing (the most common reason
//! a stage fails outside production: tests, no-network smoke runs),
//! we fall back to deterministic stubs so the loop still produces an
//! audible blip and the unit tests stay network-free. Real
//! transport / 5xx errors are *not* swallowed — they surface as
//! `Note` events so a real-call failure is visible in the transcript
//! log, not silently degraded to a stub.
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use serde_json::{json, Value};
use super::session::registry;
use super::types::SessionEventKind;
use super::wav;
/// Minimum samples below which we skip the brain turn entirely.
/// 250 ms @ 16 kHz — under this, VAD almost certainly fired on a
/// transient (cough, click) rather than real speech.
const MIN_TURN_SAMPLES: usize = 4_000;
/// Re-exported from `ops` so any drift (if we ever loosen the
/// boundary check) immediately breaks the WAV / duration math here
/// at compile time. Today the same constant is used in both places —
/// the ops boundary check rejects anything else outright.
const SAMPLE_RATE_HZ: u32 = super::ops::REQUIRED_SAMPLE_RATE;
/// Caption-driven turn. Drains the session's pending wake-word prompt
/// (assembled by `session::note_caption`) and runs LLM → TTS → enqueue
/// outbound. Skips STT entirely — the captions are already text.
///
/// We give the user a short window (`CAPTION_TURN_DELAY_MS`) after the
/// wake word fires so multi-caption utterances ("hey openhuman …
/// what's the weather like in paris") have a chance to assemble
/// before we hit the LLM. The shell calls this on every caption
/// push that flagged the wake word; subsequent calls before the
/// delay expires are coalesced via the session's `wake_active` flag.
pub async fn run_caption_turn(request_id: &str) -> Result<bool, String> {
// Wait briefly so a multi-fragment wake utterance ("hey openhuman
// what's the weather like in paris" arriving as 2-3 captions) has
// a chance to assemble before we drain the prompt.
tokio::time::sleep(std::time::Duration::from_millis(CAPTION_TURN_DELAY_MS)).await;
let prompt = match registry().with_session(request_id, |s| s.take_pending_prompt())? {
Some(p) => p,
None => return Ok(false),
};
log::info!(
"[meet-agent] caption turn start request_id={request_id} prompt_chars={}",
prompt.chars().count()
);
// We deliberately skip the LLM in the hot path. The note itself
// is already stored verbatim in the session transcript (a `Heard`
// event). The verbal ack should always be brief, deterministic,
// and free of model latency / chain-of-thought leakage — so we
// pick a short canned phrase. Post-meeting summarisation (which
// can take its time and run a real LLM) lives in a follow-up.
let reply_text = pick_ack_phrase(&prompt).to_string();
let synthesized = if reply_text.trim().is_empty() {
Vec::new()
} else {
match tts(&reply_text).await {
Ok(samples) => samples,
Err(err) => {
log::warn!(
"[meet-agent] caption-turn TTS failed request_id={request_id} err={err}"
);
let _ = registry().with_session(request_id, |s| {
s.record_event(
SessionEventKind::Note,
format!("TTS failure (using stub): {err}"),
);
});
stub_tts(&reply_text).await
}
}
};
registry().with_session(request_id, |s| {
s.record_event(SessionEventKind::Heard, prompt.clone());
if !reply_text.is_empty() {
s.record_event(SessionEventKind::Spoke, reply_text.clone());
if !synthesized.is_empty() {
s.enqueue_outbound_pcm(&synthesized, true);
}
} else {
s.record_event(
SessionEventKind::Note,
"agent declined to respond".to_string(),
);
}
s.turn_count += 1;
})?;
log::info!(
"[meet-agent] caption turn done request_id={request_id} reply_chars={} synth_samples={}",
reply_text.chars().count(),
synthesized.len()
);
Ok(true)
}
/// Delay between wake-word match and prompt drain. Long enough that
/// 2-3 caption fragments can join up; short enough that the user
/// doesn't experience awkward silence after they stop talking.
const CAPTION_TURN_DELAY_MS: u64 = 1_500;
/// Canned acknowledgements the agent speaks out loud after capturing
/// a note. Short, varied so consecutive notes don't sound robotic.
/// Selected by hashing the prompt so the same dictation reliably
/// produces the same ack (helpful for tests + debugging) while still
/// rotating across the set in a normal conversation.
const ACK_PHRASES: &[&str] = &["Got it.", "Noted.", "Adding that.", "On it.", "Captured."];
fn pick_ack_phrase(prompt: &str) -> &'static str {
if prompt.trim().is_empty() {
return "";
}
let h: u32 = prompt.bytes().fold(0u32, |a, b| a.wrapping_add(b as u32));
ACK_PHRASES[(h as usize) % ACK_PHRASES.len()]
}
/// Fire one brain turn for the named session. Returns `Ok(true)` when a
/// turn actually ran, `Ok(false)` when the inbound buffer was below the
/// floor.
pub async fn run_turn(request_id: &str) -> Result<bool, String> {
let drained = registry().with_session(request_id, |s| s.drain_inbound())?;
if drained.len() < MIN_TURN_SAMPLES {
log::debug!(
"[meet-agent] skipping turn request_id={request_id} samples={}",
drained.len()
);
return Ok(false);
}
log::info!(
"[meet-agent] turn start request_id={request_id} samples={}",
drained.len()
);
// ─── STT ────────────────────────────────────────────────────────
let heard = match stt(&drained).await {
Ok(text) if text.trim().is_empty() => {
log::info!("[meet-agent] STT empty, skipping turn request_id={request_id}");
return Ok(false);
}
Ok(text) => text,
Err(err) => {
log::warn!("[meet-agent] STT failed request_id={request_id} err={err}");
// Record a Note so the transcript log makes the failure
// visible to whoever's looking at logs.
let _ = registry().with_session(request_id, |s| {
s.record_event(
SessionEventKind::Note,
format!("STT failure (using stub): {err}"),
);
});
stub_stt(&drained).await
}
};
log::info!(
"[meet-agent] STT request_id={request_id} text_chars={}",
heard.chars().count()
);
// ─── LLM ────────────────────────────────────────────────────────
let reply_text = match llm(&heard).await {
Ok(text) => text,
Err(err) => {
log::warn!("[meet-agent] LLM failed request_id={request_id} err={err}");
let _ = registry().with_session(request_id, |s| {
s.record_event(
SessionEventKind::Note,
format!("LLM failure (using stub): {err}"),
);
});
stub_llm(&heard).await
}
};
// ─── TTS ────────────────────────────────────────────────────────
let synthesized = if reply_text.trim().is_empty() {
Vec::new()
} else {
match tts(&reply_text).await {
Ok(samples) => samples,
Err(err) => {
log::warn!("[meet-agent] TTS failed request_id={request_id} err={err}");
let _ = registry().with_session(request_id, |s| {
s.record_event(
SessionEventKind::Note,
format!("TTS failure (using stub): {err}"),
);
});
stub_tts(&reply_text).await
}
}
};
registry().with_session(request_id, |s| {
s.record_event(SessionEventKind::Heard, heard.clone());
if !reply_text.is_empty() {
s.record_event(SessionEventKind::Spoke, reply_text.clone());
if !synthesized.is_empty() {
s.enqueue_outbound_pcm(&synthesized, true);
}
} else {
s.record_event(
SessionEventKind::Note,
"agent declined to respond".to_string(),
);
}
s.turn_count += 1;
})?;
log::info!(
"[meet-agent] turn done request_id={request_id} reply_chars={} synth_samples={}",
reply_text.chars().count(),
synthesized.len()
);
Ok(true)
}
// ─── Real adapters ──────────────────────────────────────────────────
async fn stt(samples: &[i16]) -> Result<String, String> {
use crate::openhuman::voice::cloud_transcribe::{transcribe_cloud, CloudTranscribeOptions};
let config = crate::openhuman::config::ops::load_config_with_timeout().await?;
let wav_bytes = wav::pack_pcm16le_mono_wav(samples, SAMPLE_RATE_HZ);
let audio_b64 = B64.encode(&wav_bytes);
let opts = CloudTranscribeOptions {
mime_type: Some("audio/wav".to_string()),
file_name: Some("meet-agent.wav".to_string()),
..Default::default()
};
let outcome = transcribe_cloud(&config, &audio_b64, &opts).await?;
let text = outcome.value.text.clone();
Ok(text)
}
async fn llm(heard: &str) -> Result<String, String> {
use crate::api::config::effective_api_url;
use crate::api::jwt::get_session_token;
use crate::api::BackendOAuthClient;
use reqwest::Method;
let config = crate::openhuman::config::ops::load_config_with_timeout().await?;
let token = get_session_token(&config)
.map_err(|e| e.to_string())?
.filter(|t| !t.trim().is_empty())
.ok_or_else(|| "no backend session token".to_string())?;
let api_url = effective_api_url(&config.api_url);
let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?;
let body = json!({
"model": "agentic-v1",
"temperature": 0.2,
// Hard cap to force a terse acknowledgement. agentic-v1
// tends to elaborate (200+ chars) when given more headroom.
"max_tokens": 20,
"messages": [
{
"role": "system",
"content": "You are OpenHuman, a note-taking AI participating in a live Google \
Meet call. Each user message you receive is a single dictation — a \
note, action item, or follow-up the user wants captured for after \
the meeting. The transcript log stores the note verbatim, so your \
job is only to acknowledge briefly out loud that you got it. \
\
Reply with one short sentence, ideally three to six words \
(\"Got it.\", \"Noted.\", \"Adding that to your follow-ups.\"). \
Never repeat the user's note back, never speculate or expand on \
it, never engage in side conversation. \
\
If the message is empty, unclear, or just filler (\"uh\", \"hmm\", \
a name), reply with an empty string."
},
{ "role": "user", "content": heard }
]
});
let raw = client
.authed_json(
&token,
Method::POST,
"/openai/v1/chat/completions",
Some(body),
)
.await
.map_err(|e| e.to_string())?;
let text = extract_chat_completion_text(&raw)
.ok_or_else(|| format!("unexpected chat completions response: {raw}"))?;
Ok(text)
}
async fn tts(text: &str) -> Result<Vec<i16>, String> {
use crate::openhuman::voice::reply_speech::{synthesize_reply, ReplySpeechOptions};
let config = crate::openhuman::config::ops::load_config_with_timeout().await?;
let opts = ReplySpeechOptions {
// Ask ElevenLabs (via the hosted backend) for raw PCM16LE @
// 16 kHz so we can feed the result straight into the
// shell-side bridge with no transcoding.
output_format: Some("pcm_16000".to_string()),
..Default::default()
};
let outcome = synthesize_reply(&config, text, &opts).await?;
let result = outcome.value;
let pcm_bytes = B64
.decode(result.audio_base64.as_bytes())
.map_err(|e| format!("decode tts base64: {e}"))?;
if !pcm_bytes.len().is_multiple_of(2) {
return Err(format!("odd byte length from tts: {}", pcm_bytes.len()));
}
Ok(pcm_bytes
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect())
}
fn extract_chat_completion_text(raw: &Value) -> Option<String> {
raw.get("choices")
.and_then(|c| c.as_array())
.and_then(|arr| arr.first())
.and_then(|first| first.get("message"))
.and_then(|m| m.get("content"))
.and_then(|s| s.as_str())
.map(|s| s.trim().to_string())
}
// ─── Stubs (fallback for tests / no-backend) ────────────────────────
async fn stub_stt(samples: &[i16]) -> String {
let secs = samples.len() as f32 / SAMPLE_RATE_HZ as f32;
format!("(heard ~{secs:.1}s of audio)")
}
async fn stub_llm(_heard: &str) -> String {
"I'm listening.".to_string()
}
async fn stub_tts(text: &str) -> Vec<i16> {
if text.is_empty() {
return Vec::new();
}
let sample_rate = SAMPLE_RATE_HZ as f32;
let freq = 440.0_f32;
let duration_secs = 0.2_f32;
let count = (sample_rate * duration_secs) as usize;
(0..count)
.map(|i| {
let t = i as f32 / sample_rate;
(((2.0 * std::f32::consts::PI * freq * t).sin()) * (i16::MAX as f32 * 0.3)) as i16
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::meet_agent::session::registry;
#[tokio::test]
async fn run_turn_skips_short_buffers() {
registry().start("brain-skip", 16_000).unwrap();
registry()
.with_session("brain-skip", |s| {
s.push_inbound_pcm(&vec![0; 800]); // 50ms — under floor
})
.unwrap();
assert_eq!(run_turn("brain-skip").await.unwrap(), false);
let _ = registry().stop("brain-skip");
}
#[tokio::test]
async fn run_turn_falls_back_to_stub_without_backend() {
// No backend session in test env → STT/LLM/TTS all fail and
// each stage falls back to its stub. The turn still produces
// a Heard event, a Spoke event, and synthesized PCM, so the
// smoke-test contract holds.
registry().start("brain-fallback", 16_000).unwrap();
registry()
.with_session("brain-fallback", |s| {
s.push_inbound_pcm(&vec![1000; 16_000]); // 1s
})
.unwrap();
assert_eq!(run_turn("brain-fallback").await.unwrap(), true);
registry()
.with_session("brain-fallback", |s| {
let kinds: Vec<_> = s.events().iter().map(|e| format!("{:?}", e.kind)).collect();
assert!(kinds.contains(&"Heard".to_string()));
assert!(kinds.contains(&"Spoke".to_string()));
assert_eq!(s.turn_count, 1);
assert!(s.spoken_seconds() > 0.0);
})
.unwrap();
let _ = registry().stop("brain-fallback");
}
#[test]
fn extract_chat_completion_text_pulls_first_choice() {
let raw = json!({
"choices": [
{ "message": { "content": " hello world " } }
]
});
assert_eq!(
extract_chat_completion_text(&raw),
Some("hello world".to_string())
);
}
#[test]
fn extract_chat_completion_text_returns_none_on_malformed() {
assert_eq!(extract_chat_completion_text(&json!({})), None);
assert_eq!(
extract_chat_completion_text(&json!({ "choices": [] })),
None
);
}
}
+50
View File
@@ -0,0 +1,50 @@
//! Meet-agent domain — listening + speaking loop for a live Google Meet
//! call.
//!
//! Sits *next to* `meet/` (which only validates a URL and mints a
//! `request_id`) and reuses `voice/` for STT/TTS. Where `meet/` is
//! single-shot ("here is a request_id, shell goes off and opens a
//! window"), `meet_agent/` is a long-lived session: while the call is
//! open, the Tauri shell streams PCM frames from the CEF audio handler
//! into the core; the core runs VAD-segmented STT, decides whether to
//! reply, runs TTS, and streams synthesized PCM back out to the shell's
//! virtual-mic pump.
//!
//! ## Why a separate domain (not just more functions on `meet/`)?
//!
//! `meet/` is intentionally pure-validation — no state, no streams, no
//! audio. A live agentic loop is the opposite shape: a session registry,
//! per-session ring buffers, VAD/turn state, transcript log, and a TTS
//! pipeline. Bolting that onto `meet/` would force the validation surface
//! to drag in audio dependencies. Splitting keeps each domain small.
//!
//! ## Module layout
//!
//! - [`types`] — request/response types, public session events
//! - [`ops`] — VAD, ring-buffer, transcript helpers (pure, testable)
//! - [`session`] — `MeetAgentSession` and the per-session registry
//! - [`brain`] — turn orchestration: STT → LLM → TTS (stub in PR1)
//! - [`rpc`] — JSON-RPC handlers
//! - [`schemas`] — controller schema definitions
//!
//! ## RPC surface
//!
//! - `openhuman.meet_agent_start_session` — open a session for a `request_id`
//! - `openhuman.meet_agent_push_listen_pcm` — shell pushes captured PCM frames
//! - `openhuman.meet_agent_poll_speech` — shell pulls synthesized PCM frames
//! - `openhuman.meet_agent_stop_session` — close session, flush pending audio
pub mod brain;
pub mod ops;
pub mod rpc;
pub mod schemas;
pub mod session;
pub mod types;
pub mod wav;
pub use schemas::{
all_controller_schemas as all_meet_agent_controller_schemas,
all_registered_controllers as all_meet_agent_registered_controllers,
};
pub use session::{MeetAgentSession, MeetAgentSessionRegistry, SESSION_REGISTRY};
pub use types::*;
+204
View File
@@ -0,0 +1,204 @@
//! Pure helpers for the `meet_agent` domain: VAD-style end-of-utterance
//! detection, sample-rate sanity, request_id sanitization. Kept out of
//! `session.rs` so they can be unit-tested without a tokio runtime.
/// The only sample rate the meet-agent loop currently supports.
/// `brain.rs` packs WAVs, computes durations, and sizes the turn
/// floor against this constant; until we plumb the per-session rate
/// all the way through, every helper assumes 16 kHz. The shell's
/// listen path resamples to this rate before pushing.
pub const REQUIRED_SAMPLE_RATE: u32 = 16_000;
/// Validate a sample rate handed in from the shell. Locked to a
/// single value at the boundary instead of accepting a range — see
/// `REQUIRED_SAMPLE_RATE` for the rationale.
pub fn validate_sample_rate(hz: u32) -> Result<u32, String> {
if hz != REQUIRED_SAMPLE_RATE {
return Err(format!(
"sample_rate_hz {hz} unsupported; only {REQUIRED_SAMPLE_RATE} is allowed"
));
}
Ok(hz)
}
/// Same shape as `meet_call::sanitize_request_id` in the shell — keeping
/// the rule symmetric on both sides means a session key the shell minted
/// is always accepted by core and vice-versa.
pub fn sanitize_request_id(raw: &str) -> Result<String, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("request_id must not be empty".into());
}
if trimmed.len() > 64 {
return Err("request_id exceeds 64 characters".into());
}
if !trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err("request_id contains forbidden characters".into());
}
Ok(trimmed.to_string())
}
/// Crude energy-based VAD. Computes RMS over the supplied PCM16LE samples
/// and reports whether they are above a "speech-y" threshold. The brain
/// uses this in combination with a hangover counter to decide when an
/// utterance has ended (see `Vad::feed`).
///
/// Crude on purpose: a real model-based VAD (Silero, webrtcvad) is the
/// follow-up; for the MVP the goal is "did somebody just stop talking
/// for ~600ms?", which RMS handles fine.
pub fn frame_rms(samples: &[i16]) -> f32 {
if samples.is_empty() {
return 0.0;
}
let sum_sq: f64 = samples.iter().map(|&s| (s as f64).powi(2)).sum();
let mean = sum_sq / samples.len() as f64;
(mean.sqrt() / i16::MAX as f64) as f32
}
/// RMS above this is "voice-ish". Picked empirically against
/// `voice::streaming` test fixtures — anything below this is room tone.
pub const VAD_RMS_THRESHOLD: f32 = 0.015;
/// Number of consecutive sub-threshold frames that mean the speaker has
/// stopped. At ~100ms-per-frame (the cadence the shell pushes), 6 frames
/// ≈ 600ms of silence — comfortable end-of-utterance marker without
/// chopping mid-thought.
pub const VAD_HANGOVER_FRAMES: u32 = 6;
/// Stateful VAD wrapper. Owned by the session.
#[derive(Debug, Default)]
pub struct Vad {
/// True once we've seen at least one speech-y frame for the current
/// utterance — prevents firing "end of utterance" on a freshly-opened
/// session that has never seen audio.
in_utterance: bool,
/// Consecutive silent frames since the last speech-y one.
silence_run: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VadEvent {
/// Speech-y frame; ignore.
Speech,
/// Silent frame, but not enough to close the utterance yet.
Silence,
/// `VAD_HANGOVER_FRAMES` of silence after speech — turn ends now.
EndOfUtterance,
/// Silence with no preceding speech this session — caller can skip
/// any buffer-flush work.
Idle,
}
impl Vad {
pub fn new() -> Self {
Self::default()
}
/// Feed a single PCM frame and learn whether it ended the utterance.
pub fn feed(&mut self, samples: &[i16]) -> VadEvent {
let rms = frame_rms(samples);
if rms >= VAD_RMS_THRESHOLD {
self.in_utterance = true;
self.silence_run = 0;
VadEvent::Speech
} else if !self.in_utterance {
VadEvent::Idle
} else {
self.silence_run += 1;
if self.silence_run >= VAD_HANGOVER_FRAMES {
self.in_utterance = false;
self.silence_run = 0;
VadEvent::EndOfUtterance
} else {
VadEvent::Silence
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_sample_rate_accepts_only_required_rate() {
validate_sample_rate(16_000).unwrap();
}
#[test]
fn validate_sample_rate_rejects_anything_else() {
assert!(validate_sample_rate(8_000).is_err());
assert!(validate_sample_rate(48_000).is_err());
assert!(validate_sample_rate(96_000).is_err());
}
#[test]
fn sanitize_request_id_matches_shell_rules() {
sanitize_request_id("550e8400-e29b-41d4-a716-446655440000").unwrap();
assert!(sanitize_request_id("").is_err());
assert!(sanitize_request_id("a/b").is_err());
assert!(sanitize_request_id(&"x".repeat(65)).is_err());
}
#[test]
fn frame_rms_is_zero_for_silence() {
assert_eq!(frame_rms(&[0; 320]), 0.0);
}
#[test]
fn frame_rms_grows_with_amplitude() {
let quiet: Vec<i16> = (0..320)
.map(|i| if i % 2 == 0 { 1000i16 } else { -1000 })
.collect();
let loud: Vec<i16> = (0..320)
.map(|i| if i % 2 == 0 { 8000i16 } else { -8000 })
.collect();
assert!(frame_rms(&loud) > frame_rms(&quiet));
}
/// Build a frame that's deterministically above the VAD threshold.
fn loud_frame() -> Vec<i16> {
// Half-amplitude square wave — comfortably above VAD_RMS_THRESHOLD
// without saturating clamps in downstream tests.
(0..1600)
.map(|i| if i % 2 == 0 { 8000 } else { -8000 })
.collect()
}
#[test]
fn vad_idle_until_first_speech() {
let mut vad = Vad::new();
for _ in 0..10 {
assert_eq!(vad.feed(&[0; 320]), VadEvent::Idle);
}
}
#[test]
fn vad_emits_end_of_utterance_after_hangover() {
let mut vad = Vad::new();
assert_eq!(vad.feed(&loud_frame()), VadEvent::Speech);
for i in 0..VAD_HANGOVER_FRAMES - 1 {
assert_eq!(
vad.feed(&[0; 320]),
VadEvent::Silence,
"frame #{i} of silence run"
);
}
assert_eq!(vad.feed(&[0; 320]), VadEvent::EndOfUtterance);
}
#[test]
fn vad_resets_after_utterance() {
let mut vad = Vad::new();
vad.feed(&loud_frame());
for _ in 0..VAD_HANGOVER_FRAMES {
vad.feed(&[0; 320]);
}
// Next silent frame after end-of-utterance should be Idle, not
// a fresh Silence run.
assert_eq!(vad.feed(&[0; 320]), VadEvent::Idle);
}
}
+264
View File
@@ -0,0 +1,264 @@
//! JSON-RPC handlers for the `meet_agent` domain.
//!
//! Four endpoints, all keyed by `request_id`:
//!
//! - `start_session` — open a session (idempotent restart on dup id)
//! - `push_listen_pcm` — feed PCM frames in; may trigger a brain turn
//! - `poll_speech` — pull synthesized PCM out
//! - `stop_session` — close + return summary counters
//!
//! Each handler is intentionally short — heavy lifting lives in
//! `session.rs` (state) and `brain.rs` (behavior). RPC code is
//! deserialize-validate-dispatch only.
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use serde_json::{json, Map, Value};
use crate::rpc::RpcOutcome;
use super::brain;
use super::ops::VadEvent;
use super::session::registry;
use super::types::{
PollSpeechRequest, PushCaptionRequest, PushListenPcmRequest, StartSessionRequest,
StopSessionRequest,
};
const LOG_PREFIX: &str = "[meet-agent-rpc]";
pub async fn handle_start_session(params: Map<String, Value>) -> Result<Value, String> {
let req: StartSessionRequest = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("{LOG_PREFIX} invalid start_session params: {e}"))?;
registry().start(&req.request_id, req.sample_rate_hz)?;
log::info!(
"{LOG_PREFIX} start_session request_id={} sample_rate_hz={}",
req.request_id,
req.sample_rate_hz
);
RpcOutcome::new(
json!({
"ok": true,
"request_id": req.request_id,
"sample_rate_hz": req.sample_rate_hz,
}),
vec![],
)
.into_cli_compatible_json()
}
pub async fn handle_push_listen_pcm(params: Map<String, Value>) -> Result<Value, String> {
let req: PushListenPcmRequest = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("{LOG_PREFIX} invalid push_listen_pcm params: {e}"))?;
let samples =
decode_pcm16le_b64(&req.pcm_base64).map_err(|e| format!("{LOG_PREFIX} pcm decode: {e}"))?;
let event = registry().with_session(&req.request_id, |s| s.push_inbound_pcm(&samples))?;
let turn_started = matches!(event, VadEvent::EndOfUtterance);
if turn_started {
// Spawn the turn so the RPC reply doesn't have to wait for STT
// + TTS to finish — the shell will drain audio via poll_speech.
let request_id = req.request_id.clone();
tokio::spawn(async move {
if let Err(err) = brain::run_turn(&request_id).await {
log::warn!("{LOG_PREFIX} brain turn failed request_id={request_id} err={err}");
}
});
}
RpcOutcome::new(
json!({
"ok": true,
"turn_started": turn_started,
}),
vec![],
)
.into_cli_compatible_json()
}
pub async fn handle_push_caption(params: Map<String, Value>) -> Result<Value, String> {
let req: PushCaptionRequest = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("{LOG_PREFIX} invalid push_caption params: {e}"))?;
let wake_fired = registry().with_session(&req.request_id, |s| {
s.note_caption(&req.speaker, &req.text, req.ts_ms)
})?;
if wake_fired {
log::info!(
"{LOG_PREFIX} wake word fired request_id={} speaker={}",
req.request_id,
req.speaker
);
let request_id = req.request_id.clone();
tokio::spawn(async move {
if let Err(err) = brain::run_caption_turn(&request_id).await {
log::warn!("{LOG_PREFIX} caption-turn failed request_id={request_id} err={err}");
}
});
}
RpcOutcome::new(
json!({
"ok": true,
"turn_started": wake_fired,
}),
vec![],
)
.into_cli_compatible_json()
}
pub async fn handle_poll_speech(params: Map<String, Value>) -> Result<Value, String> {
let req: PollSpeechRequest = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("{LOG_PREFIX} invalid poll_speech params: {e}"))?;
let (pcm_base64, utterance_done) =
registry().with_session(&req.request_id, |s| s.poll_outbound())?;
RpcOutcome::new(
json!({
"ok": true,
"pcm_base64": pcm_base64,
"utterance_done": utterance_done,
}),
vec![],
)
.into_cli_compatible_json()
}
pub async fn handle_stop_session(params: Map<String, Value>) -> Result<Value, String> {
let req: StopSessionRequest = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("{LOG_PREFIX} invalid stop_session params: {e}"))?;
let session = registry().stop(&req.request_id)?;
log::info!(
"{LOG_PREFIX} stop_session request_id={} listened={:.2}s spoken={:.2}s turns={}",
session.request_id,
session.listened_seconds(),
session.spoken_seconds(),
session.turn_count
);
RpcOutcome::new(
json!({
"ok": true,
"request_id": session.request_id,
"listened_seconds": session.listened_seconds(),
"spoken_seconds": session.spoken_seconds(),
"turn_count": session.turn_count,
}),
vec![],
)
.into_cli_compatible_json()
}
/// Decode a base64 string of PCM16LE bytes into samples. Empty input is
/// a "heartbeat" push (no audio this tick) and yields an empty Vec.
fn decode_pcm16le_b64(b64: &str) -> Result<Vec<i16>, String> {
if b64.is_empty() {
return Ok(Vec::new());
}
let bytes = B64
.decode(b64.as_bytes())
.map_err(|e| format!("base64: {e}"))?;
if !bytes.len().is_multiple_of(2) {
return Err(format!("odd byte length {}", bytes.len()));
}
Ok(bytes
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn b64_pcm(samples: &[i16]) -> String {
let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
B64.encode(bytes)
}
#[tokio::test]
async fn start_then_stop_round_trip() {
let mut params = Map::new();
params.insert("request_id".into(), json!("rpc-roundtrip"));
params.insert("sample_rate_hz".into(), json!(16_000));
let out = handle_start_session(params).await.unwrap();
assert_eq!(out.get("ok"), Some(&json!(true)));
let mut stop = Map::new();
stop.insert("request_id".into(), json!("rpc-roundtrip"));
let out = handle_stop_session(stop).await.unwrap();
assert_eq!(out.get("turn_count"), Some(&json!(0)));
}
#[tokio::test]
async fn push_then_poll_returns_audio_after_brain_turn() {
let mut start = Map::new();
start.insert("request_id".into(), json!("rpc-push"));
start.insert("sample_rate_hz".into(), json!(16_000));
handle_start_session(start).await.unwrap();
// Push a loud frame, then enough silent frames to cross the
// VAD hangover and trigger a turn.
let loud: Vec<i16> = (0..1600)
.map(|i| if i % 2 == 0 { 8000i16 } else { -8000 })
.collect();
let mut p = Map::new();
p.insert("request_id".into(), json!("rpc-push"));
p.insert("pcm_base64".into(), json!(b64_pcm(&loud)));
handle_push_listen_pcm(p).await.unwrap();
// ~1s of speech-like content so the brain turn doesn't skip.
for _ in 0..10 {
let mut p = Map::new();
p.insert("request_id".into(), json!("rpc-push"));
p.insert("pcm_base64".into(), json!(b64_pcm(&loud)));
handle_push_listen_pcm(p).await.unwrap();
}
// Now silence frames to trigger end-of-utterance.
let silence = vec![0i16; 1600];
let mut last = json!(false);
for _ in 0..10 {
let mut p = Map::new();
p.insert("request_id".into(), json!("rpc-push"));
p.insert("pcm_base64".into(), json!(b64_pcm(&silence)));
let out = handle_push_listen_pcm(p).await.unwrap();
if out.get("turn_started") == Some(&json!(true)) {
last = json!(true);
break;
}
}
assert_eq!(last, json!(true), "expected a turn_started=true reply");
// Give the spawned turn a moment to enqueue audio.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let mut poll = Map::new();
poll.insert("request_id".into(), json!("rpc-push"));
let out = handle_poll_speech(poll).await.unwrap();
let pcm = out.get("pcm_base64").and_then(|v| v.as_str()).unwrap_or("");
assert!(!pcm.is_empty(), "expected synthesized audio after turn");
let mut stop = Map::new();
stop.insert("request_id".into(), json!("rpc-push"));
handle_stop_session(stop).await.unwrap();
}
#[test]
fn decode_pcm16le_b64_handles_empty() {
assert!(decode_pcm16le_b64("").unwrap().is_empty());
}
#[test]
fn decode_pcm16le_b64_rejects_odd_length() {
// Three bytes -> odd number of bytes -> reject.
let odd = B64.encode([0u8, 1, 2]);
assert!(decode_pcm16le_b64(&odd).is_err());
}
}
+357
View File
@@ -0,0 +1,357 @@
//! Controller schemas for the `meet_agent` domain.
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
type SchemaBuilder = fn() -> ControllerSchema;
type ControllerHandler = fn(Map<String, Value>) -> ControllerFuture;
struct Def {
function: &'static str,
schema: SchemaBuilder,
handler: ControllerHandler,
}
const DEFS: &[Def] = &[
Def {
function: "start_session",
schema: schema_start_session,
handler: handle_start_session,
},
Def {
function: "push_listen_pcm",
schema: schema_push_listen_pcm,
handler: handle_push_listen_pcm,
},
Def {
function: "push_caption",
schema: schema_push_caption,
handler: handle_push_caption,
},
Def {
function: "poll_speech",
schema: schema_poll_speech,
handler: handle_poll_speech,
},
Def {
function: "stop_session",
schema: schema_stop_session,
handler: handle_stop_session,
},
];
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
DEFS.iter().map(|d| (d.schema)()).collect()
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
DEFS.iter()
.map(|d| RegisteredController {
schema: (d.schema)(),
handler: d.handler,
})
.collect()
}
pub fn schemas(function: &str) -> ControllerSchema {
if let Some(d) = DEFS.iter().find(|d| d.function == function) {
return (d.schema)();
}
schema_unknown()
}
fn schema_start_session() -> ControllerSchema {
ControllerSchema {
namespace: "meet_agent",
function: "start_session",
description:
"Open a meet-agent session keyed by request_id. The Tauri shell calls this after \
the meet-call window opens and before pushing PCM frames.",
inputs: vec![
FieldSchema {
name: "request_id",
ty: TypeSchema::String,
comment: "UUID minted by openhuman.meet_join_call.",
required: true,
},
FieldSchema {
name: "sample_rate_hz",
ty: TypeSchema::F64,
comment: "Sample rate of inbound/outbound PCM. Default 16000.",
required: false,
},
],
outputs: vec![
FieldSchema {
name: "ok",
ty: TypeSchema::Bool,
comment: "True when the session was opened.",
required: true,
},
FieldSchema {
name: "request_id",
ty: TypeSchema::String,
comment: "Echoed session key.",
required: true,
},
FieldSchema {
name: "sample_rate_hz",
ty: TypeSchema::F64,
comment: "Echoed sample rate the session was opened with.",
required: true,
},
],
}
}
fn schema_push_listen_pcm() -> ControllerSchema {
ControllerSchema {
namespace: "meet_agent",
function: "push_listen_pcm",
description:
"Push a chunk of inbound PCM (Meet → agent) into the session. May trigger a brain \
turn when VAD detects end-of-utterance.",
inputs: vec![
FieldSchema {
name: "request_id",
ty: TypeSchema::String,
comment: "Session key from start_session.",
required: true,
},
FieldSchema {
name: "pcm_base64",
ty: TypeSchema::String,
comment:
"Base64-encoded PCM16LE samples at the session's sample rate. Empty allowed.",
required: true,
},
],
outputs: vec![
FieldSchema {
name: "ok",
ty: TypeSchema::Bool,
comment: "True when the chunk was accepted.",
required: true,
},
FieldSchema {
name: "turn_started",
ty: TypeSchema::Bool,
comment: "True when this push closed an utterance and the brain ran a turn.",
required: true,
},
],
}
}
fn schema_push_caption() -> ControllerSchema {
ControllerSchema {
namespace: "meet_agent",
function: "push_caption",
description: "Push a caption line scraped from Meet's live captions DOM. The wake-word \
gate (\"hey openhuman\") triggers an LLM/TTS turn when fired.",
inputs: vec![
FieldSchema {
name: "request_id",
ty: TypeSchema::String,
comment: "Session key from start_session.",
required: true,
},
FieldSchema {
name: "speaker",
ty: TypeSchema::String,
comment: "Speaker label scraped from Meet (display name); may be empty.",
required: false,
},
FieldSchema {
name: "text",
ty: TypeSchema::String,
comment: "Caption transcript (already trimmed by the page-side bridge).",
required: true,
},
FieldSchema {
name: "ts_ms",
ty: TypeSchema::F64,
comment: "Page-side Date.now() when the caption was queued.",
required: false,
},
],
outputs: vec![
FieldSchema {
name: "ok",
ty: TypeSchema::Bool,
comment: "True when the caption was accepted.",
required: true,
},
FieldSchema {
name: "turn_started",
ty: TypeSchema::Bool,
comment:
"True when this caption tripped the wake word and a brain turn dispatched.",
required: true,
},
],
}
}
fn schema_poll_speech() -> ControllerSchema {
ControllerSchema {
namespace: "meet_agent",
function: "poll_speech",
description: "Drain any synthesized outbound PCM (agent → Meet) the session has queued.",
inputs: vec![FieldSchema {
name: "request_id",
ty: TypeSchema::String,
comment: "Session key from start_session.",
required: true,
}],
outputs: vec![
FieldSchema {
name: "ok",
ty: TypeSchema::Bool,
comment: "True when the poll succeeded (even if no audio was queued).",
required: true,
},
FieldSchema {
name: "pcm_base64",
ty: TypeSchema::String,
comment: "Base64 PCM16LE since the last poll. Empty when nothing is queued.",
required: true,
},
FieldSchema {
name: "utterance_done",
ty: TypeSchema::Bool,
comment: "True when the current outbound utterance is complete.",
required: true,
},
],
}
}
fn schema_stop_session() -> ControllerSchema {
ControllerSchema {
namespace: "meet_agent",
function: "stop_session",
description: "Close the named session and return summary counters.",
inputs: vec![FieldSchema {
name: "request_id",
ty: TypeSchema::String,
comment: "Session key.",
required: true,
}],
outputs: vec![
FieldSchema {
name: "ok",
ty: TypeSchema::Bool,
comment: "True when the session existed and was closed.",
required: true,
},
FieldSchema {
name: "request_id",
ty: TypeSchema::String,
comment: "Echoed session key.",
required: true,
},
FieldSchema {
name: "listened_seconds",
ty: TypeSchema::F64,
comment: "Total seconds of inbound audio processed.",
required: true,
},
FieldSchema {
name: "spoken_seconds",
ty: TypeSchema::F64,
comment: "Total seconds of outbound audio synthesized.",
required: true,
},
FieldSchema {
name: "turn_count",
ty: TypeSchema::F64,
comment: "Number of completed agent turns.",
required: true,
},
],
}
}
fn schema_unknown() -> ControllerSchema {
ControllerSchema {
namespace: "meet_agent",
function: "unknown",
description: "Unknown meet_agent controller function.",
inputs: vec![FieldSchema {
name: "function",
ty: TypeSchema::String,
comment: "Unknown function requested.",
required: true,
}],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
}
}
fn handle_start_session(p: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::rpc::handle_start_session(p).await })
}
fn handle_push_listen_pcm(p: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::rpc::handle_push_listen_pcm(p).await })
}
fn handle_push_caption(p: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::rpc::handle_push_caption(p).await })
}
fn handle_poll_speech(p: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::rpc::handle_poll_speech(p).await })
}
fn handle_stop_session(p: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::rpc::handle_stop_session(p).await })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registered_handlers_match_schemas() {
let schema_fns: Vec<_> = all_controller_schemas()
.into_iter()
.map(|s| s.function)
.collect();
let handler_fns: Vec<_> = all_registered_controllers()
.into_iter()
.map(|c| c.schema.function)
.collect();
assert_eq!(schema_fns, handler_fns);
assert_eq!(
schema_fns,
vec![
"start_session",
"push_listen_pcm",
"push_caption",
"poll_speech",
"stop_session"
]
);
}
#[test]
fn lookup_returns_unknown_for_missing_function() {
assert_eq!(schemas("nope").function, "unknown");
}
#[test]
fn start_session_requires_request_id() {
let s = schema_start_session();
let required: Vec<_> = s
.inputs
.iter()
.filter(|f| f.required)
.map(|f| f.name)
.collect();
assert_eq!(required, vec!["request_id"]);
}
}
+478
View File
@@ -0,0 +1,478 @@
//! Per-call session state for the meet-agent loop.
//!
//! A `MeetAgentSession` holds the state that has to live for the
//! duration of a Google Meet call: the inbound PCM ring buffer (kept
//! short — VAD chops it into utterances), the outbound TTS queue (PCM
//! the brain has produced and the shell hasn't drained yet), VAD state,
//! transcript log, and counters for the smoke test.
//!
//! Sessions are keyed by `request_id` (the same UUID `meet/` mints) and
//! live in a process-wide `OnceLock<Mutex<HashMap<...>>>`. The locking
//! pattern matches `meet_call::MeetCallState` on the shell side.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use super::ops::{self, Vad, VadEvent};
use super::types::{SessionEvent, SessionEventKind};
/// Cap on the inbound buffer so a runaway shell push (e.g. shell never
/// stops, brain never drains) can't grow memory unboundedly. 30s @ 16kHz
/// mono = 960 KB per session — generous for any reasonable utterance.
const MAX_INBOUND_SAMPLES: usize = 30 * 16_000;
/// Same idea for outbound: cap synthesized backlog at 30s. Brain trims
/// older audio if the shell hasn't polled fast enough.
const MAX_OUTBOUND_SAMPLES: usize = 30 * 16_000;
/// Keep the most recent N session events. Bounded so a noisy call
/// can't grow the log forever.
const MAX_EVENTS: usize = 256;
#[derive(Debug)]
pub struct MeetAgentSession {
pub request_id: String,
pub sample_rate_hz: u32,
/// Wall-clock start. Used by the smoke-test response and to stamp
/// session events.
pub started_at: Instant,
/// PCM samples awaiting brain processing. Drained per utterance.
inbound: Vec<i16>,
/// PCM samples the brain has synthesized but the shell hasn't
/// pulled yet. Front-of-vec is "next bytes the shell will consume".
outbound: Vec<i16>,
/// True when the *current* outbound batch represents a complete
/// utterance — the shell uses this to flush + drop back to silence.
outbound_done: bool,
vad: Vad,
events: Vec<SessionEvent>,
/// Total samples ever pushed in. Counter, not a buffer length —
/// the inbound vec is drained per utterance, so we track separately
/// for the smoke-test seconds-listened metric.
total_inbound_samples: u64,
total_outbound_samples: u64,
pub turn_count: u32,
/// Buffer of post-wake-word caption text waiting for the brain
/// turn to fire. Populated by `note_caption` once a wake word is
/// observed; flushed by `take_pending_prompt`.
pending_prompt: String,
/// True between "wake word matched" and "brain turn dispatched".
/// Used to avoid firing a second turn on every subsequent caption
/// line while the prompt is still being assembled.
pub wake_active: bool,
/// `ts_ms` of the last caption that contributed to
/// `pending_prompt`. The brain uses this + the current time to
/// decide whether the user has stopped talking.
pub last_caption_ts_ms: u64,
/// Page-side `Date.now()` of the most recent caption that fired
/// the wake word. Suppresses re-firing while Meet's caption
/// region keeps the same utterance visible (Meet shows captions
/// for ~58 s after speaking ends, and our dedupe is per-exact-
/// text — a single character growth re-queues the line). Without
/// this gate the brain spam-fires on every caption growth.
wake_cooldown_until_ts_ms: u64,
}
impl MeetAgentSession {
pub fn new(request_id: String, sample_rate_hz: u32) -> Self {
Self {
request_id,
sample_rate_hz,
started_at: Instant::now(),
inbound: Vec::new(),
outbound: Vec::new(),
outbound_done: false,
vad: Vad::new(),
events: Vec::new(),
total_inbound_samples: 0,
total_outbound_samples: 0,
turn_count: 0,
pending_prompt: String::new(),
wake_active: false,
last_caption_ts_ms: 0,
wake_cooldown_until_ts_ms: 0,
}
}
/// Caption-driven listen path. Returns `true` when this caption
/// just tripped the wake word (caller should kick a turn).
///
/// The wake-word match is intentionally permissive: case-folded
/// substring on `"hey openhuman"` (and `"hey open human"` to
/// tolerate Meet's STT splitting the brand name). Any text after
/// the match in the same caption is treated as the start of the
/// prompt; subsequent captions append until `take_pending_prompt`
/// drains.
pub fn note_caption(&mut self, speaker: &str, text: &str, ts_ms: u64) -> bool {
if text.trim().is_empty() {
return false;
}
self.last_caption_ts_ms = ts_ms;
// Already collecting after a previous wake word: just append
// the new caption. No second fire — the brain is already
// scheduled and will drain the prompt in ~1.5 s. Without this
// gate, a slowly-growing caption fires the wake word on
// every dedupe-then-grow cycle.
if self.wake_active {
if !self.pending_prompt.is_empty() {
self.pending_prompt.push(' ');
}
self.pending_prompt.push_str(text.trim());
return false;
}
// In cooldown after a recent turn — Meet keeps the same
// utterance visible for several seconds, so without this
// gate the brain re-fires on every caption growth. Continue
// recording the caption to the transcript log (below) but
// skip wake-word matching.
if ts_ms != 0 && ts_ms < self.wake_cooldown_until_ts_ms {
self.record_event(
SessionEventKind::Heard,
if speaker.is_empty() {
text.to_string()
} else {
format!("{speaker}: {text}")
},
);
return false;
}
// Normalize before matching: Meet's STT punctuates the wake
// phrase ("hey, openhuman"), capitalizes mid-sentence, and
// sometimes collapses the brand to two words. Folding to
// lowercase + replacing punctuation with spaces + collapsing
// whitespace gives us a single canonical form to substring
// against. The tail (the dictation after the wake phrase) is
// returned in normalized form too — that's fine for the LLM
// and the transcript log; the user's punctuation isn't load-
// bearing for note-taking.
let normalized = normalize_for_wake(text);
let wake_idx = normalized
.find("hey openhuman")
.or_else(|| normalized.find("hey open human"));
if let Some(idx) = wake_idx {
let after = if normalized[idx..].starts_with("hey openhuman") {
idx + "hey openhuman".len()
} else {
idx + "hey open human".len()
};
let tail = normalized.get(after..).unwrap_or("").trim().to_string();
self.pending_prompt = tail;
self.wake_active = true;
self.record_event(
SessionEventKind::Note,
format!("wake word from speaker={speaker}"),
);
return true;
}
// Outside a wake context, just record the line for the
// transcript log. Useful for debugging "why didn't the agent
// respond". (The wake-active branch is handled by the
// early-return above.)
self.record_event(
SessionEventKind::Heard,
if speaker.is_empty() {
text.to_string()
} else {
format!("{speaker}: {text}")
},
);
false
}
/// Drain the assembled wake-word prompt and clear the active
/// flag. The brain calls this once it's ready to dispatch the
/// turn so subsequent captions start a fresh wake-word cycle.
///
/// Sets a cooldown window keyed off `last_caption_ts_ms` so any
/// subsequent caption push for the same lingering utterance
/// doesn't re-fire the wake-word state machine. 8s is a comfortable
/// upper bound on how long Meet keeps a finalised caption visible.
pub fn take_pending_prompt(&mut self) -> Option<String> {
if !self.wake_active {
return None;
}
self.wake_active = false;
// 8s grace beyond the most recent caption's page timestamp.
// `last_caption_ts_ms` is whatever Date.now() was page-side
// when the line landed — same clock as future caption pushes.
const COOLDOWN_MS: u64 = 8_000;
self.wake_cooldown_until_ts_ms = self.last_caption_ts_ms.saturating_add(COOLDOWN_MS);
let prompt = std::mem::take(&mut self.pending_prompt);
let trimmed = prompt.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
/// Append PCM samples to the inbound buffer. Returns the VAD verdict
/// for *this* batch — caller consults it to decide whether to fire
/// a brain turn.
pub fn push_inbound_pcm(&mut self, samples: &[i16]) -> VadEvent {
self.total_inbound_samples += samples.len() as u64;
self.inbound.extend_from_slice(samples);
if self.inbound.len() > MAX_INBOUND_SAMPLES {
// Drop oldest; the in-progress utterance is what matters.
let drop = self.inbound.len() - MAX_INBOUND_SAMPLES;
self.inbound.drain(..drop);
}
self.vad.feed(samples)
}
/// Take ownership of the accumulated utterance for STT. The session
/// keeps the VAD state — the next push_inbound_pcm starts a fresh
/// utterance.
pub fn drain_inbound(&mut self) -> Vec<i16> {
std::mem::take(&mut self.inbound)
}
/// Brain hands synthesized PCM back to the session. `done` flips
/// `outbound_done` so the next poll surfaces "utterance over".
pub fn enqueue_outbound_pcm(&mut self, samples: &[i16], done: bool) {
self.total_outbound_samples += samples.len() as u64;
self.outbound.extend_from_slice(samples);
if self.outbound.len() > MAX_OUTBOUND_SAMPLES {
let drop = self.outbound.len() - MAX_OUTBOUND_SAMPLES;
self.outbound.drain(..drop);
}
if done {
self.outbound_done = true;
}
}
/// Drain everything currently queued for the shell. Returns
/// `(pcm_base64, utterance_done)`.
pub fn poll_outbound(&mut self) -> (String, bool) {
if self.outbound.is_empty() {
let done = std::mem::take(&mut self.outbound_done);
return (String::new(), done);
}
let bytes: Vec<u8> = self
.outbound
.drain(..)
.flat_map(|s| s.to_le_bytes())
.collect();
let done = std::mem::take(&mut self.outbound_done);
(B64.encode(bytes), done)
}
pub fn record_event(&mut self, kind: SessionEventKind, text: String) {
let timestamp_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
self.events.push(SessionEvent {
kind,
text,
timestamp_ms,
});
if self.events.len() > MAX_EVENTS {
let drop = self.events.len() - MAX_EVENTS;
self.events.drain(..drop);
}
}
pub fn events(&self) -> &[SessionEvent] {
&self.events
}
pub fn listened_seconds(&self) -> f32 {
self.total_inbound_samples as f32 / self.sample_rate_hz as f32
}
pub fn spoken_seconds(&self) -> f32 {
self.total_outbound_samples as f32 / self.sample_rate_hz as f32
}
}
/// Lowercase + drop punctuation + collapse whitespace, so the wake
/// phrase matches regardless of how Meet's STT punctuated or cased
/// it ("Hey, OpenHuman", "hey open-human", etc).
fn normalize_for_wake(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut prev_space = true;
for c in text.chars() {
let lc = c.to_ascii_lowercase();
if lc.is_ascii_alphanumeric() {
out.push(lc);
prev_space = false;
} else if !prev_space {
out.push(' ');
prev_space = true;
}
}
out.trim_end().to_string()
}
/// Process-wide session registry. Sessions are keyed by `request_id`.
#[derive(Default)]
pub struct MeetAgentSessionRegistry {
inner: Mutex<HashMap<String, MeetAgentSession>>,
}
impl MeetAgentSessionRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn start(&self, request_id: &str, sample_rate_hz: u32) -> Result<(), String> {
let request_id = ops::sanitize_request_id(request_id)?;
let sample_rate_hz = ops::validate_sample_rate(sample_rate_hz)?;
let mut guard = self.inner.lock().unwrap();
if guard.contains_key(&request_id) {
// Idempotent restart: replace the old session so a shell
// crash + reconnect doesn't wedge the registry.
log::info!("[meet-agent] replacing existing session request_id={request_id}");
}
guard.insert(
request_id.clone(),
MeetAgentSession::new(request_id, sample_rate_hz),
);
Ok(())
}
pub fn stop(&self, request_id: &str) -> Result<MeetAgentSession, String> {
let request_id = ops::sanitize_request_id(request_id)?;
let mut guard = self.inner.lock().unwrap();
guard
.remove(&request_id)
.ok_or_else(|| format!("[meet-agent] no session for request_id={request_id}"))
}
/// Run a closure with mutable access to the named session. Returns
/// `Err` when the session is unknown.
pub fn with_session<R>(
&self,
request_id: &str,
f: impl FnOnce(&mut MeetAgentSession) -> R,
) -> Result<R, String> {
let request_id = ops::sanitize_request_id(request_id)?;
let mut guard = self.inner.lock().unwrap();
let session = guard
.get_mut(&request_id)
.ok_or_else(|| format!("[meet-agent] no session for request_id={request_id}"))?;
Ok(f(session))
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.inner.lock().unwrap().len()
}
}
/// Process-wide singleton. Lazy-initialized so tests can use a fresh
/// registry where they want to.
pub static SESSION_REGISTRY: OnceLock<MeetAgentSessionRegistry> = OnceLock::new();
pub fn registry() -> &'static MeetAgentSessionRegistry {
SESSION_REGISTRY.get_or_init(MeetAgentSessionRegistry::new)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn start_and_stop_round_trip() {
let reg = MeetAgentSessionRegistry::new();
reg.start("abc-123", 16_000).unwrap();
assert_eq!(reg.len(), 1);
let session = reg.stop("abc-123").unwrap();
assert_eq!(session.request_id, "abc-123");
assert_eq!(reg.len(), 0);
}
#[test]
fn start_rejects_bad_inputs() {
let reg = MeetAgentSessionRegistry::new();
assert!(reg.start("", 16_000).is_err());
assert!(reg.start("abc", 1_000).is_err());
}
#[test]
fn stop_unknown_session_errors() {
let reg = MeetAgentSessionRegistry::new();
assert!(reg.stop("never-started").is_err());
}
#[test]
fn push_inbound_accumulates_samples() {
let reg = MeetAgentSessionRegistry::new();
reg.start("s1", 16_000).unwrap();
reg.with_session("s1", |s| {
s.push_inbound_pcm(&vec![1000; 320]);
s.push_inbound_pcm(&vec![1000; 320]);
assert_eq!(s.inbound.len(), 640);
})
.unwrap();
}
#[test]
fn poll_outbound_returns_done_flag_once() {
let reg = MeetAgentSessionRegistry::new();
reg.start("s2", 16_000).unwrap();
reg.with_session("s2", |s| {
s.enqueue_outbound_pcm(&vec![0; 100], true);
let (b64, done) = s.poll_outbound();
assert!(!b64.is_empty());
assert!(done);
// Second poll: no audio, no `done` (we already consumed it).
let (b64, done) = s.poll_outbound();
assert!(b64.is_empty());
assert!(!done);
})
.unwrap();
}
#[test]
fn note_caption_handles_punctuated_wake() {
let mut s = MeetAgentSession::new("p".into(), 16_000);
// Meet often inserts a comma after "hey".
let fired = s.note_caption("Alice", "Hey, OpenHuman remember the launch", 1);
assert!(fired, "punctuated wake phrase should still fire");
let prompt = s.take_pending_prompt().expect("prompt drained");
assert_eq!(prompt, "remember the launch");
}
#[test]
fn note_caption_handles_split_brand() {
let mut s = MeetAgentSession::new("p".into(), 16_000);
let fired = s.note_caption("Alice", "hey open-human, send the report", 1);
assert!(fired);
let prompt = s.take_pending_prompt().expect("prompt drained");
assert_eq!(prompt, "send the report");
}
#[test]
fn note_caption_does_not_double_fire_on_growing_caption() {
let mut s = MeetAgentSession::new("p".into(), 16_000);
let first = s.note_caption("Alice", "hey openhuman take notes", 1);
assert!(first);
let second = s.note_caption("Alice", "hey openhuman take notes about the launch", 2);
assert!(!second, "second caption while wake_active must not refire");
let prompt = s.take_pending_prompt().expect("prompt drained");
// First wake stripped "hey openhuman"; the continuation
// appended the WHOLE growing caption (still containing "hey
// openhuman" because we don't re-strip), separated by a
// space. That's fine — the LLM ignores the prefix and the
// transcript log still records the verbatim dictation.
assert!(
prompt.contains("take notes about the launch"),
"got prompt: {prompt}"
);
}
#[test]
fn listened_seconds_tracks_total_inbound() {
let reg = MeetAgentSessionRegistry::new();
reg.start("s3", 16_000).unwrap();
reg.with_session("s3", |s| {
s.push_inbound_pcm(&vec![0; 16_000]); // 1.0s
s.push_inbound_pcm(&vec![0; 8_000]); // 0.5s
assert!((s.listened_seconds() - 1.5).abs() < 1e-3);
})
.unwrap();
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Request / response types for the `meet_agent` domain.
//!
//! Audio frames cross the RPC boundary as base64-encoded PCM16LE @ 16kHz
//! mono. Base64 (rather than raw bytes) because JSON-RPC transports the
//! envelope as JSON and binary bytes don't survive the trip — the shell
//! decodes/encodes at the `core_rpc` boundary, mirroring how the existing
//! `voice::streaming` WebSocket path moves audio.
use serde::{Deserialize, Serialize};
/// Inputs to `openhuman.meet_agent_start_session`.
#[derive(Debug, Clone, Deserialize)]
pub struct StartSessionRequest {
/// `request_id` minted by `openhuman.meet_join_call`. Used as the
/// session key so the shell's existing per-call book-keeping (window
/// label, data dir) lines up with the agent loop's session.
pub request_id: String,
/// Sample rate of the PCM frames the shell will push. Must match
/// what `voice::streaming` expects (16000) — the shell is responsible
/// for resampling the CEF audio handler's native rate down before
/// sending. Validated on entry.
#[serde(default = "default_sample_rate")]
pub sample_rate_hz: u32,
}
fn default_sample_rate() -> u32 {
16_000
}
/// Outputs from `openhuman.meet_agent_start_session`.
#[derive(Debug, Clone, Serialize)]
pub struct StartSessionResponse {
pub ok: bool,
pub request_id: String,
/// Echoed sample rate the session was opened with — the shell pins
/// its resampler to this.
pub sample_rate_hz: u32,
}
/// Inputs to `openhuman.meet_agent_push_listen_pcm`.
///
/// Sent every ~100ms while the call is open. Small frames keep VAD
/// responsive without overloading the JSON envelope.
#[derive(Debug, Clone, Deserialize)]
pub struct PushListenPcmRequest {
pub request_id: String,
/// Base64-encoded PCM16LE samples at the session's `sample_rate_hz`.
/// Empty string is allowed and treated as "no audio this tick"
/// (used by the shell to keep the keep-alive heartbeat without a
/// payload when CEF reports silence).
pub pcm_base64: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PushListenPcmResponse {
pub ok: bool,
/// True when this push triggered a VAD-detected end-of-utterance and
/// the brain ran a turn. The shell can use this as a UI hint
/// ("agent is thinking…").
pub turn_started: bool,
}
/// Inputs to `openhuman.meet_agent_poll_speech`.
///
/// Pull-style: the shell calls this periodically and gets any PCM the
/// brain has synthesized since the last poll. Pull beats push here
/// because the shell is the side that knows whether the virtual mic is
/// actually draining (back-pressure lives there, not in core).
#[derive(Debug, Clone, Deserialize)]
pub struct PollSpeechRequest {
pub request_id: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PollSpeechResponse {
pub ok: bool,
/// Base64-encoded PCM16LE @ session sample rate, or empty when there
/// is nothing queued. The shell appends this to its UDS feed.
pub pcm_base64: String,
/// True when the brain has finished synthesizing the current
/// utterance and the shell can flush + drop back to silence.
pub utterance_done: bool,
}
/// Inputs to `openhuman.meet_agent_push_caption`.
///
/// One row per new line scraped from Meet's captions DOM. Sent by the
/// shell's `caption_listener` every ~500 ms. The wake-word state
/// machine in the brain (see `brain::on_caption`) decides whether to
/// fire a turn.
#[derive(Debug, Clone, Deserialize)]
pub struct PushCaptionRequest {
pub request_id: String,
/// Speaker label scraped from Meet (the participant's display
/// name); empty when the captions row didn't expose one.
#[serde(default)]
pub speaker: String,
/// Caption transcript. Already trimmed by the page-side bridge.
pub text: String,
/// `Date.now()` from the page when the line was queued. Used
/// only for ordering / staleness — the brain treats it as opaque.
#[serde(default)]
pub ts_ms: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct PushCaptionResponse {
pub ok: bool,
/// True when this caption tripped the wake-word and a brain turn
/// is now in flight.
pub turn_started: bool,
}
/// Inputs to `openhuman.meet_agent_stop_session`.
#[derive(Debug, Clone, Deserialize)]
pub struct StopSessionRequest {
pub request_id: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct StopSessionResponse {
pub ok: bool,
pub request_id: String,
/// Total seconds of inbound audio the session processed — useful
/// for telemetry and the smoke test in [`crate::openhuman::meet_agent`].
pub listened_seconds: f32,
/// Total seconds of outbound audio the session synthesized.
pub spoken_seconds: f32,
/// Number of completed agent turns (one transcript + one TTS reply).
pub turn_count: u32,
}
/// Lightweight transcript / event record kept per session. Exposed so
/// the shell can render a live captions overlay and so the json_rpc_e2e
/// test can assert turn boundaries.
#[derive(Debug, Clone, Serialize)]
pub struct SessionEvent {
pub kind: SessionEventKind,
pub text: String,
pub timestamp_ms: u64,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionEventKind {
/// Final STT transcript for an inbound utterance.
Heard,
/// Outbound text the agent decided to speak.
Spoke,
/// Internal note (errors, "agent declined to respond", etc).
Note,
}
+79
View File
@@ -0,0 +1,79 @@
//! Tiny PCM16LE → WAV-container wrapper used to ship audio batches to
//! the backend Whisper endpoint.
//!
//! `voice::cloud_transcribe` takes whatever the desktop UI captured
//! (typically `audio/webm`) and forwards bytes to the backend. Our
//! call buffers are raw PCM16LE @ 16 kHz mono — Whisper accepts WAV
//! natively, so we wrap the bytes in a minimal RIFF/WAVE header and
//! mark the upload as `audio/wav`. No other transcoding needed.
const WAV_HEADER_LEN: usize = 44;
/// Produce a complete WAV file (header + interleaved PCM16LE samples).
/// Caller passes the raw `i16` slice and the sample rate; mono is
/// hard-coded because that's what the meet-agent loop uses end-to-end.
pub fn pack_pcm16le_mono_wav(samples: &[i16], sample_rate_hz: u32) -> Vec<u8> {
let data_bytes = samples.len() * 2;
let mut out = Vec::with_capacity(WAV_HEADER_LEN + data_bytes);
// RIFF chunk descriptor
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&((36 + data_bytes) as u32).to_le_bytes());
out.extend_from_slice(b"WAVE");
// fmt sub-chunk
out.extend_from_slice(b"fmt ");
out.extend_from_slice(&16u32.to_le_bytes()); // PCM header size
out.extend_from_slice(&1u16.to_le_bytes()); // audio format = PCM
out.extend_from_slice(&1u16.to_le_bytes()); // num channels = 1
out.extend_from_slice(&sample_rate_hz.to_le_bytes());
out.extend_from_slice(&(sample_rate_hz * 2).to_le_bytes()); // byte rate
out.extend_from_slice(&2u16.to_le_bytes()); // block align
out.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
// data sub-chunk
out.extend_from_slice(b"data");
out.extend_from_slice(&(data_bytes as u32).to_le_bytes());
for s in samples {
out.extend_from_slice(&s.to_le_bytes());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_bytes_match_riff_wave_layout() {
let bytes = pack_pcm16le_mono_wav(&[0; 8000], 16_000);
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WAVE");
assert_eq!(&bytes[12..16], b"fmt ");
assert_eq!(&bytes[36..40], b"data");
// RIFF size = 36 + data_bytes (8000 samples * 2 bytes = 16000).
let riff_size = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
assert_eq!(riff_size, 36 + 16_000);
// Sample rate field at offset 24.
let rate = u32::from_le_bytes([bytes[24], bytes[25], bytes[26], bytes[27]]);
assert_eq!(rate, 16_000);
}
#[test]
fn empty_input_still_produces_valid_header() {
let bytes = pack_pcm16le_mono_wav(&[], 16_000);
assert_eq!(bytes.len(), WAV_HEADER_LEN);
assert_eq!(&bytes[0..4], b"RIFF");
}
#[test]
fn samples_are_appended_little_endian() {
let bytes = pack_pcm16le_mono_wav(&[0x1234, -1], 16_000);
// First sample 0x1234 → LE bytes 0x34, 0x12 starting at offset 44.
assert_eq!(bytes[44], 0x34);
assert_eq!(bytes[45], 0x12);
// -1 in i16 LE → 0xFF, 0xFF.
assert_eq!(bytes[46], 0xFF);
assert_eq!(bytes[47], 0xFF);
}
}
+1
View File
@@ -38,6 +38,7 @@ pub mod integrations;
pub mod learning;
pub mod local_ai;
pub mod meet;
pub mod meet_agent;
pub mod memory;
pub mod migration;
pub mod node_runtime;
+165
View File
@@ -3927,3 +3927,168 @@ async fn json_rpc_meet_join_call_validates_and_returns_request_id() {
rpc_join.abort();
}
/// Walks the full meet_agent session lifecycle:
/// start_session → push silent frame → push loud frame ×N → push
/// silent frames until VAD fires a turn → poll_speech (expects
/// non-empty PCM from the brain stub) → stop_session.
///
/// Pins behavior the shell relies on: the RPC surface accepts
/// base64-PCM16LE frames, fires a turn on VAD silence after speech,
/// the brain stub enqueues outbound audio synchronously enough for a
/// 250 ms-budget poll to see it, and stop_session returns sane
/// counters. STT / TTS adapters are stubbed in PR1 so this stays
/// network-free.
#[tokio::test]
async fn json_rpc_meet_agent_session_lifecycle() {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(50)).await;
let request_id = "e2e-meet-agent";
// 1) start_session — opens registry slot, defaults sample_rate to 16000.
let start = post_json_rpc(
&rpc_base,
9101,
"openhuman.meet_agent_start_session",
json!({ "request_id": request_id, "sample_rate_hz": 16_000 }),
)
.await;
let start_result = assert_no_jsonrpc_error(&start, "start_session ok");
let start_body = start_result.get("result").unwrap_or(start_result);
assert_eq!(start_body.get("ok"), Some(&json!(true)));
assert_eq!(
start_body.get("request_id").and_then(|v| v.as_str()),
Some(request_id)
);
// 2) Push ~1s of "loud" PCM (square wave well above VAD threshold)
// so the brain has enough material to NOT skip the turn.
let loud_frame: Vec<i16> = (0..1600)
.map(|i| if i % 2 == 0 { 8000i16 } else { -8000 })
.collect();
let loud_b64 = {
let bytes: Vec<u8> = loud_frame.iter().flat_map(|s| s.to_le_bytes()).collect();
B64.encode(bytes)
};
for i in 0..10 {
let r = post_json_rpc(
&rpc_base,
9110 + i,
"openhuman.meet_agent_push_listen_pcm",
json!({ "request_id": request_id, "pcm_base64": loud_b64 }),
)
.await;
let body = assert_no_jsonrpc_error(&r, "push_listen_pcm loud");
let body = body.get("result").unwrap_or(body);
assert_eq!(
body.get("turn_started"),
Some(&json!(false)),
"VAD must not fire while still hearing speech"
);
}
// 3) Push silent frames until turn_started flips. With
// VAD_HANGOVER_FRAMES=6 the turn should fire within at most
// ~7 silent pushes (allow 12 for slop).
let silent_frame = vec![0i16; 1600];
let silent_b64 = {
let bytes: Vec<u8> = silent_frame.iter().flat_map(|s| s.to_le_bytes()).collect();
B64.encode(bytes)
};
let mut turn_fired = false;
for i in 0..12 {
let r = post_json_rpc(
&rpc_base,
9130 + i,
"openhuman.meet_agent_push_listen_pcm",
json!({ "request_id": request_id, "pcm_base64": silent_b64 }),
)
.await;
let body = assert_no_jsonrpc_error(&r, "push_listen_pcm silent");
let body = body.get("result").unwrap_or(body);
if body.get("turn_started") == Some(&json!(true)) {
turn_fired = true;
break;
}
}
assert!(turn_fired, "VAD silence run failed to close utterance");
// 4) Give the spawned brain turn a chance to finish, then poll for
// synthesized PCM. The stub TTS produces 200 ms of 440 Hz tone
// which encodes to ~6.4 KB of base64.
let mut got_audio = false;
for _ in 0..20 {
tokio::time::sleep(Duration::from_millis(50)).await;
let r = post_json_rpc(
&rpc_base,
9150,
"openhuman.meet_agent_poll_speech",
json!({ "request_id": request_id }),
)
.await;
let body = assert_no_jsonrpc_error(&r, "poll_speech ok");
let body = body.get("result").unwrap_or(body);
let b64 = body
.get("pcm_base64")
.and_then(|v| v.as_str())
.unwrap_or_default();
if !b64.is_empty() {
got_audio = true;
assert!(
b64.len() > 1000,
"stub TTS should produce a multi-KB base64 payload"
);
break;
}
}
assert!(got_audio, "expected synthesized audio after VAD-fired turn");
// 5) stop_session returns counters. listened_seconds should be
// > 0 (we pushed >1s of audio); turn_count should be exactly 1.
let stop = post_json_rpc(
&rpc_base,
9160,
"openhuman.meet_agent_stop_session",
json!({ "request_id": request_id }),
)
.await;
let stop_result = assert_no_jsonrpc_error(&stop, "stop_session ok");
let stop_body = stop_result.get("result").unwrap_or(stop_result);
assert_eq!(stop_body.get("ok"), Some(&json!(true)));
let listened = stop_body
.get("listened_seconds")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
assert!(listened > 1.0, "expected >1s listened, got {listened:.2}");
let turns = stop_body
.get("turn_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
assert_eq!(turns, 1, "expected exactly one brain turn");
// 6) Stopping a non-existent session is an error (not silent).
let bogus = post_json_rpc(
&rpc_base,
9161,
"openhuman.meet_agent_stop_session",
json!({ "request_id": "never-started" }),
)
.await;
assert_jsonrpc_error(&bogus, "stop_session unknown");
rpc_join.abort();
}