Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
92ad9dc
refactor(meeting-bots): point modal submit at Flow A (CEF webview)
oxoxDev May 22, 2026
8e9b7b2
feat(human): add join-meeting pill that opens Flow A modal
oxoxDev May 22, 2026
54333fc
fix(meet-call): force off-screen position post-build + minimize
oxoxDev May 22, 2026
0260c16
fix(meet-call): drop minimize, close stale windows, log post-build pos
oxoxDev May 22, 2026
c322dc7
fix(meet-call): hide window post-build instead of positioning off-screen
oxoxDev May 22, 2026
e98dbd1
fix(meet-call): hide window post-join (not post-build) to keep scanne…
oxoxDev May 22, 2026
34bdd11
fix(meet-scanner): flip camera + mic toggles ON before Ask to join
oxoxDev May 22, 2026
7a72279
fix(meet-scanner): widen aria-label matchers + dump on toggle miss
oxoxDev May 22, 2026
ea08f76
fix(meet-scanner): only click 'Turn ON' variants; never click 'Turn off'
oxoxDev May 22, 2026
be8f61b
diag(meet-scanner): drop post-join hide to isolate pipeline regression
oxoxDev May 22, 2026
6b11c96
fix(meet-agent): bare-wake (no tail) replies with greeting ack, not s…
oxoxDev May 22, 2026
2152db9
feat(meet-scanner): scanner-side admission watch + captions force-enable
oxoxDev May 22, 2026
309301a
diag(meet-agent): log every push_caption text + wake_fired outcome
oxoxDev May 22, 2026
62014ab
feat(meet-scanner): cycle mic off→on after Ask-to-join to force fresh…
oxoxDev May 22, 2026
298094c
Merge remote-tracking branch 'upstream/main' into feat/mascot-meet-flowA
oxoxDev May 22, 2026
356f27c
fix(meet-agent): widen wake matcher — accept 'hi/hello openhuman' + b…
oxoxDev May 22, 2026
ea61575
fix(meet-scanner): cycle mic off→on BEFORE Ask-to-join, not after
oxoxDev May 22, 2026
b4897bd
fix(meet-scanner): clear browser cookies pre-join to drop leaked Goog…
oxoxDev May 22, 2026
576173c
fix(meet-agent): filter 'You'-speaker noise, strip <think> blocks, in…
oxoxDev May 22, 2026
9055241
fix(meet-agent): break echo loop — speaking gate, caption dedup, TTS cap
oxoxDev May 22, 2026
96346a8
fix(meet-agent): tighten reply length — 80-token cap + strict spoken-…
oxoxDev May 22, 2026
8f7e371
fix(meet-agent): switch model agentic-v1 → chat-v1 + strip leaked rea…
oxoxDev May 22, 2026
44d1bbe
feat(meet-agent): route bot through orchestrator agent — second brain…
oxoxDev May 22, 2026
71d5036
fix(meet-agent): switch to with_profile builder to pick up connected …
oxoxDev May 22, 2026
ef7e648
fix(meet-agent): per-turn unique definition_name to bypass corrupt tr…
oxoxDev May 22, 2026
5d97de2
fix(meet-agent): rewrite voice directive to encourage tool use
oxoxDev May 22, 2026
d8de1f3
fix(meet-agent): per-speaker caption dedup + turn_in_progress gate + …
oxoxDev May 22, 2026
cb4117b
fix(meet-agent): 60s→90s timeout + drop basic-LLM hallucination fallback
oxoxDev May 22, 2026
3c864a4
feat(meet-agent): inject current date/time into orchestrator system p…
oxoxDev May 22, 2026
b20e466
fix(meet-scanner): broaden CC auto-enable matchers + double attempt b…
oxoxDev May 22, 2026
ded0bfc
feat(meet-agent): pre-roll "On it." ack to bridge 30-60s tool-call si…
oxoxDev May 22, 2026
89b3372
feat(meet-agent): cache orchestrator Agent per meet for cross-turn me…
oxoxDev May 22, 2026
8560d22
feat(meet-agent): barge-in — JS audio flush + relaxed wake gate
oxoxDev May 22, 2026
12bc5e7
fix(meet-agent): force English-only TTS + log reply preview
oxoxDev May 22, 2026
273e390
fix(meet-agent): triple-up dedup — 30s cooldown + 15s min-turn-gap + …
oxoxDev May 22, 2026
cab2e75
chore: apply auto-fixes from pre-push hook
oxoxDev May 22, 2026
26c65fc
fix(meet-agent): skip pre-roll ack for short prompts (greetings / tim…
oxoxDev May 22, 2026
257646c
fix(meet-agent): cooldown + min-turn-gap → 60s each to kill residual-…
oxoxDev May 22, 2026
140f371
test(skills): update MeetingBotsCard tests for Flow A (joinMeetCall)
oxoxDev May 22, 2026
d2fcda7
test(meet-agent): poll for audio up to 30s instead of single 50ms sleep
oxoxDev May 22, 2026
42834af
feat(meet-agent): owner-only wake gate (privacy lock)
oxoxDev May 25, 2026
6b1c7a8
feat(meet-agent): plumb owner+bot display names into start_session RPC
oxoxDev May 25, 2026
994e666
feat(tauri/meet): forward owner display name from shell to core wake …
oxoxDev May 25, 2026
6e44f16
feat(skills): owner-name input + privacy hint on MeetingBotsCard modal
oxoxDev May 25, 2026
683f3e2
feat(tauri/meet-audio): emit speaking-state edges from speak_pump
oxoxDev May 25, 2026
93912b1
feat(meet): animate the in-call mascot's mouth while the bot speaks
oxoxDev May 25, 2026
f26e25b
feat(meet-agent): JSONL store for completed call records
oxoxDev May 25, 2026
a39bf38
feat(meet-agent): capture meet_url + started_at_ms on session start
oxoxDev May 25, 2026
0eebe5b
feat(meet-agent): persist on stop_session + new list_calls RPC
oxoxDev May 25, 2026
0f46ed0
feat(tauri/meet-audio): forward meet_url into start_session
oxoxDev May 25, 2026
b4e1747
feat(meet): listMeetCalls() service + tests
oxoxDev May 25, 2026
2a5014a
feat(skills): inline recent-calls list in MeetingBotsCard modal
oxoxDev May 25, 2026
0e5e2ec
chore: apply auto-fixes
oxoxDev May 25, 2026
18e70f3
feat(meet-agent): CaptionOutcome enum + soft-deny + owner-grant state
oxoxDev May 25, 2026
1c29cff
feat(meet-agent): run_soft_deny_turn + run_grant_turn
oxoxDev May 25, 2026
4e52b0a
feat(meet-agent): dispatch CaptionOutcome to soft-deny / wake / ignore
oxoxDev May 25, 2026
7969b4d
chore: apply auto-fixes
oxoxDev May 25, 2026
62a70b4
fix(skills): drop {label} placeholder from sendTo/comingSoon i18n str…
oxoxDev May 25, 2026
60916bb
fix(meet-agent): declare owner_display_name / bot_display_name / meet…
oxoxDev May 25, 2026
6d1e365
chore: apply auto-fixes
oxoxDev May 25, 2026
62732c7
fix(meet-agent): dedup unauthorized wakes before soft-deny dispatch
oxoxDev May 25, 2026
52a22d3
fix(meet-agent): session-wide soft-deny cooldown + greeting/refusal s…
oxoxDev May 25, 2026
17dd6c9
fix(meet-audio): route bot PCM to local speakers in addition to Meet …
oxoxDev May 25, 2026
9a3c2db
feat(meet-agent): toolless LLM for non-owner non-greeting wakes
oxoxDev May 25, 2026
15df969
chore: apply auto-fixes
oxoxDev May 25, 2026
6e312ce
fix(meet-audio): caption pipeline reliability + echo prevention + re-…
YellowSnnowmann May 27, 2026
d0e719a
chore: apply auto-fixes
YellowSnnowmann May 27, 2026
f11af29
merge: resolve upstream/main conflicts
YellowSnnowmann May 27, 2026
d0ad4b5
fix(meet-video): use RiveMascot from upstream + speaking state
YellowSnnowmann May 27, 2026
9545335
fix(meet-video): position producer at opacity:0 instead of off-screen
YellowSnnowmann May 27, 2026
13d6023
fix(meet-video): match speaking-state payload key casing (requestId)
YellowSnnowmann May 27, 2026
d5e84dd
fix(meet-audio): intercept WebRTC layer to silence incoming call audio
YellowSnnowmann May 27, 2026
41c3723
refactor(meet-scanner): change log level from warn to info for aria-l…
YellowSnnowmann May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3174,6 +3174,10 @@ pub fn run() {
request_id: request_id.clone(),
meet_url: meet_url.clone(),
display_name: "OpenHuman Dev".to_string(),
// Dev-auto launch has no real user identity — the
// wake gate will fail-closed (no wakes fire) which
// is the safe posture for an automated harness.
owner_display_name: String::new(),
};
match meet_call::meet_call_open_window(app_handle.clone(), state, args)
.await
Expand Down
203 changes: 199 additions & 4 deletions app/src-tauri/src/meet_audio/audio_bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@

function ensureContext() {
if (ctx) {
console.log(
"[openhuman-audio-bridge] reuse AudioContext state=" + ctx.state
);
// Resume if suspended (CEF suspends until user gesture; the
// meet_scanner's synthetic clicks count as gestures on some
// builds but not all).
if (ctx.state === "suspended") {
ctx.resume().catch(function () {});
console.log("[openhuman-audio-bridge] resumed suspended AudioContext");
}
return ctx;
}
var requestedRate = SAMPLE_RATE;
Expand Down Expand Up @@ -97,6 +101,29 @@
return out;
}

// Track every scheduled AudioBufferSource so __openhumanFlushAudio
// can stop them on barge-in (user re-asks during a long bot reply).
// Without this list, only the queue tail past `nextStartTime` would
// be cancellable; anything already start()-ed plays to completion.
var activeSources = [];

// Stop in-flight playback and reset the schedule cursor. Called by
// the Rust shell when the brain cancels outbound (new wake fires
// mid-reply). Returns the number of sources that were stopped, so
// the shell can log how much speech got cut.
window.__openhumanFlushAudio = function () {
var stopped = 0;
while (activeSources.length) {
var s = activeSources.pop();
try { s.stop(); stopped++; } catch (_) {}
try { s.disconnect(); } catch (_) {}
}
if (ctx) {
nextStartTime = ctx.currentTime;
}
return stopped;
};

// 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) {
Expand All @@ -110,6 +137,13 @@
var src = ctx.createBufferSource();
src.buffer = buffer;
src.connect(dest);
// Also pipe to the page's default audio output so the bot's
// TTS is audible on the host machine. This does NOT cause echo
// because Meet attributes the bot's own speech to "You" (which
// is filtered in core's note_caption) and the media-element
// mute below silences Meet's INCOMING call audio (other
// participants' voices bouncing back through speakers).
src.connect(ctx.destination);
// 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
Expand All @@ -118,6 +152,11 @@
nextStartTime = ctx.currentTime;
}
src.start(nextStartTime);
activeSources.push(src);
src.onended = function () {
var idx = activeSources.indexOf(src);
if (idx !== -1) activeSources.splice(idx, 1);
};
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
Expand Down Expand Up @@ -231,5 +270,161 @@
});
};
}
console.log("[openhuman-audio-bridge] install complete");
// Mute all inbound call audio on this page. The bot's CEF window is
// a headless participant — it reads captions for input and uses the
// virtual mic for output. Any audio that plays through the local
// speakers gets picked up by the user's mic (they're on the same
// machine) and causes an echo loop. We:
//
// 1. Mute all existing <audio> and <video> elements.
// 2. Observe the DOM for new media elements and mute those too.
// 3. Monkey-patch HTMLMediaElement.prototype.play to auto-mute
// before playing, catching dynamically created elements that
// aren't in the DOM yet.
//
// This does NOT affect the bridge's outbound TTS path — that goes
// through the Web Audio graph's MediaStreamDestination (virtual mic),
// not through any <audio>/<video> element.
function muteMediaElement(el) {
try {
el.muted = true;
el.volume = 0;
} catch (_) {}
}

function muteAllMedia() {
var els = document.querySelectorAll("audio, video");
for (var i = 0; i < els.length; i++) {
muteMediaElement(els[i]);
}
}

// Initial sweep (may be empty at document-start; the observer
// and play-patch handle late arrivals).
muteAllMedia();

// Observe DOM for dynamically added media elements.
try {
var mediaObserver = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var added = mutations[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var node = added[j];
if (node.nodeType !== 1) continue;
if (node.tagName === "AUDIO" || node.tagName === "VIDEO") {
muteMediaElement(node);
}
// Also check children (e.g. a div containing a <video>).
if (node.querySelectorAll) {
var inner = node.querySelectorAll("audio, video");
for (var k = 0; k < inner.length; k++) {
muteMediaElement(inner[k]);
}
}
}
}
});
mediaObserver.observe(document.documentElement || document.body || document, {
childList: true,
subtree: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] addEventListener wrapping breaks removeEventListener. The browser's event listener registry stores wrappedListener, so any caller that later does removeEventListener('track', originalHandler) silently fails — the old wrapped listener stays live and fires alongside any new one added after the remove. This is a problem if Meet renegotiates WebRTC mid-call (remove + readd track listeners) or if its teardown path calls removeEventListener for cleanup.

The ontrack setter interception below (which you already have) is the right approach for this. Consider dropping the addEventListener wrap entirely and relying solely on the ontrack property setter — Meet uses ontrack exclusively in the builds I've seen, and you don't need removeEventListener to work with a setter.

});
} catch (e) {
console.warn("[openhuman-audio-bridge] media mute observer failed:", e);
}

// Monkey-patch play() to ensure mute sticks even for elements created
// outside the DOM (Meet sometimes uses detached <audio> for WebRTC).
try {
var origPlay = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function () {
muteMediaElement(this);
return origPlay.apply(this, arguments);
};
} catch (e) {
console.warn("[openhuman-audio-bridge] play() patch failed:", e);
}

// WebRTC-level muting: Meet routes incoming participant audio via
// RTCPeerConnection.ontrack, NOT through <audio> elements. The DOM
// patches above miss this entirely. We intercept at two layers:
//
// 1. RTCPeerConnection.prototype.addTrack — no-op (we don't need
// to touch outbound, only inbound).
// 2. Incoming remote audio tracks — disable them so the browser
// never sends their samples to the speaker.
// 3. AudioContext.createMediaStreamSource — return a silent
// (disconnected) source so any Web Audio routing of remote
// streams produces no output.
//
// Our bridge's TTS path is unaffected because it uses
// AudioBufferSource → dest (MediaStreamDestination) + ctx.destination,
// never an RTCPeerConnection or createMediaStreamSource.
try {
var origRTCPeerConnection = window.RTCPeerConnection;
if (origRTCPeerConnection) {
// Intercept the ontrack setter to disable incoming audio tracks.
var origAddEventListener = origRTCPeerConnection.prototype.addEventListener;
origRTCPeerConnection.prototype.addEventListener = function (type, listener, options) {
if (type === "track") {
var wrappedListener = function (event) {
if (event.track && event.track.kind === "audio") {
event.track.enabled = false;
console.log("[openhuman-audio-bridge] disabled incoming WebRTC audio track");
}
return listener.call(this, event);
};
return origAddEventListener.call(this, type, wrappedListener, options);
}
return origAddEventListener.call(this, type, listener, options);
};

// Also intercept the ontrack property setter.
var ontrackDesc = Object.getOwnPropertyDescriptor(origRTCPeerConnection.prototype, "ontrack");
if (ontrackDesc && ontrackDesc.set) {
var origOntrackSet = ontrackDesc.set;
Object.defineProperty(origRTCPeerConnection.prototype, "ontrack", {
get: ontrackDesc.get,
set: function (handler) {
var wrapped = function (event) {
if (event.track && event.track.kind === "audio") {
event.track.enabled = false;
console.log("[openhuman-audio-bridge] disabled incoming WebRTC audio track (ontrack)");
}
return handler.call(this, event);
};
origOntrackSet.call(this, wrapped);
},
configurable: true,
});
}
}
} catch (e) {
console.warn("[openhuman-audio-bridge] RTCPeerConnection patch failed:", e);
}

// Intercept AudioContext.createMediaStreamSource so remote streams
// routed through Web Audio produce silence instead of speaker output.
try {
var origCreateMSS = AudioContext.prototype.createMediaStreamSource;
AudioContext.prototype.createMediaStreamSource = function (stream) {
var node = origCreateMSS.call(this, stream);
// Check if this is a remote stream (not our bridge's dest stream).
// Our bridge uses dest.stream which has a known track; anything
// else is remote audio we want to silence.
if (dest && stream === dest.stream) {
return node; // our bridge — pass through
}
// Remote stream — create a gain node set to 0 and interpose it
// so nothing reaches the destination.
var silencer = this.createGain();
silencer.gain.value = 0;
node.connect(silencer);
console.log("[openhuman-audio-bridge] silenced createMediaStreamSource (remote stream)");
return silencer;
};
} catch (e) {
console.warn("[openhuman-audio-bridge] createMediaStreamSource patch failed:", e);
}

console.log("[openhuman-audio-bridge] install complete (local audio + WebRTC muted)");
})();
65 changes: 57 additions & 8 deletions app/src-tauri/src/meet_audio/caption_listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const POLL_INTERVAL: Duration = Duration::from_millis(500);
/// away (call ended) or the renderer crashed.
const MAX_CONSECUTIVE_ERRORS: u32 = 30;

/// How often (in ticks) to log a diagnostic heartbeat when drains are
/// empty. 60 ticks × 500 ms = every 30 s.
const DIAG_HEARTBEAT_TICKS: u64 = 60;

/// RAII handle. Drop to stop the listener task.
pub struct CaptionListener {
pub request_id: String,
Expand All @@ -58,6 +62,11 @@ pub fn start(request_id: String, cdp: CdpConn, session_id: String) -> CaptionLis
tick.tick().await;
let mut cdp = cdp;
let mut errors: u32 = 0;
let mut tick_count: u64 = 0;
let mut total_captions: u64 = 0;

log::info!("[meet-audio] caption listener started request_id={request_id_for_task}");

loop {
tokio::select! {
_ = &mut shutdown_rx => {
Expand All @@ -67,8 +76,12 @@ pub fn start(request_id: String, cdp: CdpConn, session_id: String) -> CaptionLis
break;
}
_ = tick.tick() => {
tick_count += 1;
match drain_and_forward(&request_id_for_task, &mut cdp, &session_id).await {
Ok(_) => errors = 0,
Ok(count) => {
errors = 0;
total_captions += count;
},
Err(err) => {
errors += 1;
log::debug!(
Expand All @@ -82,6 +95,17 @@ pub fn start(request_id: String, cdp: CdpConn, session_id: String) -> CaptionLis
}
}
}

// Periodic diagnostic heartbeat so silent caption
// listeners are visible in the logs.
if tick_count % DIAG_HEARTBEAT_TICKS == 0 {
let bridge_info = probe_bridge_info(&mut cdp, &session_id).await;
log::info!(
"[meet-audio] caption listener heartbeat request_id={request_id_for_task} \
ticks={tick_count} total_captions={total_captions} \
consec_errors={errors} bridge={bridge_info}"
);
}
}
}
}
Expand All @@ -93,19 +117,18 @@ pub fn start(request_id: String, cdp: CdpConn, session_id: String) -> CaptionLis
}
}

/// Returns the number of captions forwarded to core.
async fn drain_and_forward(
request_id: &str,
cdp: &mut CdpConn,
session_id: &str,
) -> Result<(), String> {
) -> Result<u64, String> {
let captions = inject::drain_captions(cdp, session_id).await?;
if captions.is_empty() {
return Ok(());
return Ok(0);
}
log::info!(
"[meet-audio] captions drained count={} request_id={request_id}",
captions.len()
);
let count = captions.len() as u64;
log::info!("[meet-audio] captions drained count={count} request_id={request_id}",);
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
Expand All @@ -123,5 +146,31 @@ async fn drain_and_forward(
.await
.map_err(|err| format!("push_caption (request_id={request_id}): {err}"))?;
}
Ok(())
Ok(count)
}

/// Query the page-side captions bridge for diagnostic info. Never
/// fails — returns a human-readable string for the heartbeat log.
async fn probe_bridge_info(cdp: &mut CdpConn, session_id: &str) -> String {
let res = cdp
.call(
"Runtime.evaluate",
serde_json::json!({
"expression": "(typeof window.__openhumanCaptionsBridgeInfo === 'function') \
? JSON.stringify(window.__openhumanCaptionsBridgeInfo()) \
: '{\"installed\":false}'",
"returnByValue": true,
}),
Some(session_id),
)
.await;
match res {
Ok(v) => v
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("{\"error\":\"no value\"}")
.to_string(),
Err(err) => format!("{{\"error\":\"{err}\"}}"),
}
}
Loading
Loading