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; mod imessage_scanner;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod mascot_native_window; mod mascot_native_window;
mod meet_audio;
mod meet_call; mod meet_call;
mod meet_scanner; mod meet_scanner;
mod native_notifications; mod native_notifications;
@@ -1367,6 +1368,7 @@ pub fn run() {
let builder = builder.manage(telegram_scanner::ScannerRegistry::new()); let builder = builder.manage(telegram_scanner::ScannerRegistry::new());
let builder = builder.manage(screen_capture::ScreenShareState::new()); let builder = builder.manage(screen_capture::ScreenShareState::new());
let builder = builder.manage(meet_call::MeetCallState::new()); let builder = builder.manage(meet_call::MeetCallState::new());
let builder = builder.manage(meet_audio::MeetAudioState::new());
builder builder
.setup(move |app| { .setup(move |app| {
#[cfg(any(windows, target_os = "linux"))] #[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(), 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 // 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 // up the per-call data directory. The data dir holds an isolated CEF
// profile (cookies, cache) we explicitly want to throw away after // 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!( log::info!(
"[meet-call] window destroyed label={label_for_event} request_id={request_id_for_event}" "[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 // CEF may still be flushing the profile to disk on
// teardown; do the rmdir off the UI thread so any // teardown; do the rmdir off the UI thread so any
// last-second writes don't race the delete. // 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()); controllers.extend(crate::openhuman::notifications::all_notifications_registered_controllers());
// Google Meet call-join request validation (shell handles the webview) // Google Meet call-join request validation (shell handles the webview)
controllers.extend(crate::openhuman::meet::all_meet_registered_controllers()); 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). // Structured WhatsApp Web data — agent-facing read-only controllers (list/search).
// The write-path ingest controller is registered separately in build_internal_only_controllers. // The write-path ingest controller is registered separately in build_internal_only_controllers.
controllers.extend(crate::openhuman::whatsapp_data::all_whatsapp_data_registered_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()); schemas.extend(crate::openhuman::notifications::all_notifications_controller_schemas());
// Google Meet call-join request validation // Google Meet call-join request validation
schemas.extend(crate::openhuman::meet::all_meet_controller_schemas()); 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 // Structured WhatsApp Web data — local SQLite store, agent-queryable
schemas.extend(crate::openhuman::whatsapp_data::all_whatsapp_data_controller_schemas()); schemas.extend(crate::openhuman::whatsapp_data::all_whatsapp_data_controller_schemas());
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 \ "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.", 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( "whatsapp_data" => Some(
"Structured WhatsApp conversation and message store — list chats, read messages, and search across WhatsApp Web data.", "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"], 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 ────────────────────────────────────────────────────────────── // ── Update ──────────────────────────────────────────────────────────────
Capability { Capability {
id: "update.check", id: "update.check",
+1
View File
@@ -79,6 +79,7 @@ fn catalog_includes_additional_user_facing_surfaces() {
"settings.manage_service", "settings.manage_service",
"settings.clear_app_data", "settings.clear_app_data",
"meet.join_call", "meet.join_call",
"meet_agent.live_loop",
] { ] {
assert!( assert!(
ids.contains(expected), 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 learning;
pub mod local_ai; pub mod local_ai;
pub mod meet; pub mod meet;
pub mod meet_agent;
pub mod memory; pub mod memory;
pub mod migration; pub mod migration;
pub mod node_runtime; 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(); 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();
}