mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(meet_agent): live note-taking agent for Google Meet (listen + speak) (#1355)
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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,
|
||||||
|
//! 1–2 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
Vendored
+1
-1
Submodule app/src-tauri/vendor/tauri-cef updated: c10bc0f729...a57470231f
@@ -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.
|
||||||
@@ -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.",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::*;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ~5–8 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user