From 82de68b4fb23c74cb4c36a93d744c05f431ba1f9 Mon Sep 17 00:00:00 2001 From: Julian Ostarek Date: Fri, 16 Oct 2026 04:52:08 -0400 Subject: [PATCH] Accept `hyper-voice` for noise suppression hyper.video's noise suppression setting takes `none` and `hyper-voice`. The simulator checks `--noise-suppression` and the `noise_suppression` config key against engines hyper.video no longer offers, so it rejects `hyper-voice` and can't select it. - `NoiseSuppression` holds exactly `none` and `hyper-voice`. The default stays `none`. Local and AWS Device Farm participants pass the value to hyper.video as before. - A config file that names an old engine loads it as `none`, with a warning, as hyper.video drops stored values that it doesn't take. The CLI and `--participant` JSON reject an old engine. - The Cloudflare worker's schema takes `none` and 16 old engines, but not `hyper-voice`. Its backend sends `none` in place of `hyper-voice`, with a warning, and refuses a runtime change to it. When the worker reports an old engine, the backend reports `none`, with a warning. --- .codex/skills/run-headless-simulator/SKILL.md | 19 +- browser/src/participant/cloudflare/mod.rs | 389 ++++++++++-------- browser/src/participant/frontend/lite.rs | 2 +- browser/src/participant/shared/spec.rs | 4 +- browser/tests/cloudflare_driver.rs | 4 +- config/src/client_config.rs | 71 ++-- config/src/lib.rs | 67 ++- docs/browser-driver.md | 3 +- src/headless.rs | 1 + 9 files changed, 315 insertions(+), 245 deletions(-) diff --git a/.codex/skills/run-headless-simulator/SKILL.md b/.codex/skills/run-headless-simulator/SKILL.md index c619cad..9717e22 100644 --- a/.codex/skills/run-headless-simulator/SKILL.md +++ b/.codex/skills/run-headless-simulator/SKILL.md @@ -221,21 +221,8 @@ Video constraint: Noise suppression: - `none` -- `deepfilternet` -- `rnnoise` -- `iris-carthy` -- `krisp-high` -- `krisp-medium` -- `krisp-low` -- `krisp-high-with-bvc` -- `krisp-medium-with-bvc` -- `ai-coustics-sparrow-xxs` -- `ai-coustics-sparrow-xs` -- `ai-coustics-sparrow-s` -- `ai-coustics-sparrow-l` -- `ai-coustics-sparrow-xxs-48khz` -- `ai-coustics-sparrow-xs-48khz` -- `ai-coustics-rook-s-48khz` -- `ai-coustics-rook-l-48khz` +- `hyper-voice` + +Cloudflare participants run with `none` only. Run `just dev --logging info headless --help` or `hyper-client-simulator headless --help` if a flag fails. diff --git a/browser/src/participant/cloudflare/mod.rs b/browser/src/participant/cloudflare/mod.rs index fa6dcfe..9338948 100644 --- a/browser/src/participant/cloudflare/mod.rs +++ b/browser/src/participant/cloudflare/mod.rs @@ -18,6 +18,7 @@ use crate::participant::shared::{ use client_simulator_config::{ media::FakeMedia, CloudflareConfig, + NoiseSuppression, TransportMode, }; use cloudflare_worker_client::{ @@ -116,8 +117,12 @@ fn map_worker_browser_entry(participant_name: &str, entry: &types::BrowserLogEnt ) } -fn store_cached_state(cached_state: &Arc>, state: &types::ParticipantState) { - *cached_state.lock().unwrap() = map_state(state); +fn store_cached_state( + participant_name: &str, + cached_state: &Arc>, + state: &types::ParticipantState, +) { + *cached_state.lock().unwrap() = map_state(participant_name, state); } impl CloudflareSession { @@ -224,6 +229,20 @@ impl CloudflareSession { settings.transport = TransportMode::WebRTC; } + match settings.noise_suppression { + NoiseSuppression::Disabled => {} + NoiseSuppression::HyperVoice => { + self.log_message( + "warn", + format!( + "Cloudflare backend only supports noise suppression none; normalizing configured {} to none", + settings.noise_suppression + ), + ); + settings.noise_suppression = NoiseSuppression::Disabled; + } + } + settings } @@ -245,8 +264,8 @@ impl CloudflareSession { }) } - fn command_request(message: ParticipantMessage) -> types::SessionCommandRequest { - match message { + fn command_request(message: ParticipantMessage) -> Result { + Ok(match message { ParticipantMessage::Join => types::SessionCommandRequest::Join, ParticipantMessage::Leave => types::SessionCommandRequest::Leave, ParticipantMessage::Close => types::SessionCommandRequest::Leave, @@ -255,7 +274,7 @@ impl CloudflareSession { ParticipantMessage::ToggleScreenshare => types::SessionCommandRequest::ToggleScreenshare, ParticipantMessage::ToggleAutoGainControl => types::SessionCommandRequest::ToggleAutoGainControl, ParticipantMessage::SetNoiseSuppression(value) => types::SessionCommandRequest::SetNoiseSuppression { - noise_suppression: map_command_noise_suppression(value), + noise_suppression: map_command_noise_suppression(value)?, }, ParticipantMessage::SetVideoConstraintPublishWebcam(value) => { types::SessionCommandRequest::SetVideoConstraintPublishWebcam { @@ -273,7 +292,7 @@ impl CloudflareSession { } } ParticipantMessage::ToggleBackgroundBlur => types::SessionCommandRequest::ToggleBackgroundBlur, - } + }) } fn cached_state(&self) -> ParticipantState { @@ -281,7 +300,7 @@ impl CloudflareSession { } fn update_cached_state(&self, state: &types::ParticipantState) { - store_cached_state(&self.cached_state, state); + store_cached_state(&self.launch_spec.username, &self.cached_state, state); } fn effective_health_poll_interval(&self) -> Duration { @@ -334,7 +353,7 @@ impl CloudflareSession { forward_worker_entries(&participant_name, &response.log); forward_worker_browser_entries(&participant_name, &response.browser_log); - store_cached_state(&cached_state, &response.state); + store_cached_state(&participant_name, &cached_state, &response.state); if !response.state.running { let _ = termination_tx.send(Some(DriverTermination::new( @@ -434,7 +453,7 @@ impl CloudflareSession { .session_id .clone() .ok_or_else(|| eyre!("Cloudflare session is not started"))?; - let request = Self::command_request(message); + let request = Self::command_request(message)?; let response = self.worker_client()?.command_session(&session_id, &request).await?; self.log_worker_entries(&response.log); self.log_worker_browser_entries(&response.browser_log); @@ -488,56 +507,17 @@ fn map_frontend_kind(frontend_kind: ResolvedFrontendKind) -> types::SessionCreat } } +/// Maps the settings that [`CloudflareSession::normalized_settings`] returns onto the worker's. +/// +/// The worker's schema lacks `hyper-voice`, which `normalized_settings` replaces with `none`, so it maps to `none`. fn map_settings(settings: &crate::participant::shared::ParticipantSettings) -> types::ParticipantSettings { types::ParticipantSettings { audio_enabled: settings.audio_enabled, auto_gain_control: settings.auto_gain_control, blur: settings.blur, noise_suppression: match settings.noise_suppression { - client_simulator_config::NoiseSuppression::Disabled => types::ParticipantSettingsNoiseSuppression::None, - client_simulator_config::NoiseSuppression::Deepfilternet => { - types::ParticipantSettingsNoiseSuppression::Deepfilternet - } - client_simulator_config::NoiseSuppression::RNNoise => types::ParticipantSettingsNoiseSuppression::Rnnoise, - client_simulator_config::NoiseSuppression::IRISCarthy => { - types::ParticipantSettingsNoiseSuppression::IrisCarthy - } - client_simulator_config::NoiseSuppression::KrispHigh => { - types::ParticipantSettingsNoiseSuppression::KrispHigh - } - client_simulator_config::NoiseSuppression::KrispMedium => { - types::ParticipantSettingsNoiseSuppression::KrispMedium - } - client_simulator_config::NoiseSuppression::KrispLow => types::ParticipantSettingsNoiseSuppression::KrispLow, - client_simulator_config::NoiseSuppression::KrispHighWithBVC => { - types::ParticipantSettingsNoiseSuppression::KrispHighWithBvc - } - client_simulator_config::NoiseSuppression::KrispMediumWithBVC => { - types::ParticipantSettingsNoiseSuppression::KrispMediumWithBvc - } - client_simulator_config::NoiseSuppression::AiCousticsSparrowXxs => { - types::ParticipantSettingsNoiseSuppression::AiCousticsSparrowXxs - } - client_simulator_config::NoiseSuppression::AiCousticsSparrowXs => { - types::ParticipantSettingsNoiseSuppression::AiCousticsSparrowXs - } - client_simulator_config::NoiseSuppression::AiCousticsSparrowS => { - types::ParticipantSettingsNoiseSuppression::AiCousticsSparrowS - } - client_simulator_config::NoiseSuppression::AiCousticsSparrowL => { - types::ParticipantSettingsNoiseSuppression::AiCousticsSparrowL - } - client_simulator_config::NoiseSuppression::AiCousticsSparrowXxs48khz => { - types::ParticipantSettingsNoiseSuppression::AiCousticsSparrowXxs48khz - } - client_simulator_config::NoiseSuppression::AiCousticsSparrowXs48khz => { - types::ParticipantSettingsNoiseSuppression::AiCousticsSparrowXs48khz - } - client_simulator_config::NoiseSuppression::AiCousticsRookS48khz => { - types::ParticipantSettingsNoiseSuppression::AiCousticsRookS48khz - } - client_simulator_config::NoiseSuppression::AiCousticsRookL48khz => { - types::ParticipantSettingsNoiseSuppression::AiCousticsRookL48khz + NoiseSuppression::Disabled | NoiseSuppression::HyperVoice => { + types::ParticipantSettingsNoiseSuppression::None } }, screenshare_enabled: settings.screenshare_enabled, @@ -552,7 +532,7 @@ fn map_settings(settings: &crate::participant::shared::ParticipantSettings) -> t } } -fn map_state(state: &types::ParticipantState) -> ParticipantState { +fn map_state(participant_name: &str, state: &types::ParticipantState) -> ParticipantState { ParticipantState { username: String::new(), running: state.running, @@ -561,51 +541,7 @@ fn map_state(state: &types::ParticipantState) -> ParticipantState { muted: state.muted, video_activated: state.video_activated, auto_gain_control: state.auto_gain_control, - noise_suppression: match state.noise_suppression { - types::ParticipantStateNoiseSuppression::None => client_simulator_config::NoiseSuppression::Disabled, - types::ParticipantStateNoiseSuppression::Deepfilternet => { - client_simulator_config::NoiseSuppression::Deepfilternet - } - types::ParticipantStateNoiseSuppression::Rnnoise => client_simulator_config::NoiseSuppression::RNNoise, - types::ParticipantStateNoiseSuppression::IrisCarthy => { - client_simulator_config::NoiseSuppression::IRISCarthy - } - types::ParticipantStateNoiseSuppression::KrispHigh => client_simulator_config::NoiseSuppression::KrispHigh, - types::ParticipantStateNoiseSuppression::KrispMedium => { - client_simulator_config::NoiseSuppression::KrispMedium - } - types::ParticipantStateNoiseSuppression::KrispLow => client_simulator_config::NoiseSuppression::KrispLow, - types::ParticipantStateNoiseSuppression::KrispHighWithBvc => { - client_simulator_config::NoiseSuppression::KrispHighWithBVC - } - types::ParticipantStateNoiseSuppression::KrispMediumWithBvc => { - client_simulator_config::NoiseSuppression::KrispMediumWithBVC - } - types::ParticipantStateNoiseSuppression::AiCousticsSparrowXxs => { - client_simulator_config::NoiseSuppression::AiCousticsSparrowXxs - } - types::ParticipantStateNoiseSuppression::AiCousticsSparrowXs => { - client_simulator_config::NoiseSuppression::AiCousticsSparrowXs - } - types::ParticipantStateNoiseSuppression::AiCousticsSparrowS => { - client_simulator_config::NoiseSuppression::AiCousticsSparrowS - } - types::ParticipantStateNoiseSuppression::AiCousticsSparrowL => { - client_simulator_config::NoiseSuppression::AiCousticsSparrowL - } - types::ParticipantStateNoiseSuppression::AiCousticsSparrowXxs48khz => { - client_simulator_config::NoiseSuppression::AiCousticsSparrowXxs48khz - } - types::ParticipantStateNoiseSuppression::AiCousticsSparrowXs48khz => { - client_simulator_config::NoiseSuppression::AiCousticsSparrowXs48khz - } - types::ParticipantStateNoiseSuppression::AiCousticsRookS48khz => { - client_simulator_config::NoiseSuppression::AiCousticsRookS48khz - } - types::ParticipantStateNoiseSuppression::AiCousticsRookL48khz => { - client_simulator_config::NoiseSuppression::AiCousticsRookL48khz - } - }, + noise_suppression: map_state_noise_suppression(participant_name, state.noise_suppression), transport_mode: match state.transport_mode { types::ParticipantStateTransportMode::Webrtc => client_simulator_config::TransportMode::WebRTC, types::ParticipantStateTransportMode::Webtransport => client_simulator_config::TransportMode::WebTransport, @@ -620,52 +556,55 @@ fn map_state(state: &types::ParticipantState) -> ParticipantState { } } -fn map_command_noise_suppression( - noise_suppression: client_simulator_config::NoiseSuppression, -) -> types::SessionCommandRequestNoiseSuppression { - match noise_suppression { - client_simulator_config::NoiseSuppression::Disabled => types::SessionCommandRequestNoiseSuppression::None, - client_simulator_config::NoiseSuppression::Deepfilternet => { - types::SessionCommandRequestNoiseSuppression::Deepfilternet - } - client_simulator_config::NoiseSuppression::RNNoise => types::SessionCommandRequestNoiseSuppression::Rnnoise, - client_simulator_config::NoiseSuppression::IRISCarthy => { - types::SessionCommandRequestNoiseSuppression::IrisCarthy - } - client_simulator_config::NoiseSuppression::KrispHigh => types::SessionCommandRequestNoiseSuppression::KrispHigh, - client_simulator_config::NoiseSuppression::KrispMedium => { - types::SessionCommandRequestNoiseSuppression::KrispMedium - } - client_simulator_config::NoiseSuppression::KrispLow => types::SessionCommandRequestNoiseSuppression::KrispLow, - client_simulator_config::NoiseSuppression::KrispHighWithBVC => { - types::SessionCommandRequestNoiseSuppression::KrispHighWithBvc - } - client_simulator_config::NoiseSuppression::KrispMediumWithBVC => { - types::SessionCommandRequestNoiseSuppression::KrispMediumWithBvc - } - client_simulator_config::NoiseSuppression::AiCousticsSparrowXxs => { - types::SessionCommandRequestNoiseSuppression::AiCousticsSparrowXxs - } - client_simulator_config::NoiseSuppression::AiCousticsSparrowXs => { - types::SessionCommandRequestNoiseSuppression::AiCousticsSparrowXs - } - client_simulator_config::NoiseSuppression::AiCousticsSparrowS => { - types::SessionCommandRequestNoiseSuppression::AiCousticsSparrowS - } - client_simulator_config::NoiseSuppression::AiCousticsSparrowL => { - types::SessionCommandRequestNoiseSuppression::AiCousticsSparrowL - } - client_simulator_config::NoiseSuppression::AiCousticsSparrowXxs48khz => { - types::SessionCommandRequestNoiseSuppression::AiCousticsSparrowXxs48khz - } - client_simulator_config::NoiseSuppression::AiCousticsSparrowXs48khz => { - types::SessionCommandRequestNoiseSuppression::AiCousticsSparrowXs48khz - } - client_simulator_config::NoiseSuppression::AiCousticsRookS48khz => { - types::SessionCommandRequestNoiseSuppression::AiCousticsRookS48khz - } - client_simulator_config::NoiseSuppression::AiCousticsRookL48khz => { - types::SessionCommandRequestNoiseSuppression::AiCousticsRookL48khz +/// Maps a runtime noise suppression change onto the worker's, refusing a value that the worker can't select. +/// +/// The worker's schema takes `none` and 16 engines that hyper.video no longer offers, but not `hyper-voice`, so +/// Cloudflare participants run without noise suppression until the worker takes `hyper-voice`. +fn map_command_noise_suppression(value: NoiseSuppression) -> Result { + match value { + NoiseSuppression::Disabled => Ok(types::SessionCommandRequestNoiseSuppression::None), + NoiseSuppression::HyperVoice => Err(eyre!( + "Cloudflare backend only supports noise suppression none, not {value}" + )), + } +} + +/// Maps the worker's reported noise suppression, reporting `none`, with a warning, for an engine that hyper.video no +/// longer offers. +/// +/// The backend only requests `none` (see [`map_command_noise_suppression`]), so the worker reports such an engine only +/// if the page runs one that the simulator did not select. +fn map_state_noise_suppression( + participant_name: &str, + value: types::ParticipantStateNoiseSuppression, +) -> NoiseSuppression { + match value { + types::ParticipantStateNoiseSuppression::None => NoiseSuppression::Disabled, + types::ParticipantStateNoiseSuppression::Deepfilternet + | types::ParticipantStateNoiseSuppression::Rnnoise + | types::ParticipantStateNoiseSuppression::IrisCarthy + | types::ParticipantStateNoiseSuppression::KrispHigh + | types::ParticipantStateNoiseSuppression::KrispMedium + | types::ParticipantStateNoiseSuppression::KrispLow + | types::ParticipantStateNoiseSuppression::KrispHighWithBvc + | types::ParticipantStateNoiseSuppression::KrispMediumWithBvc + | types::ParticipantStateNoiseSuppression::AiCousticsSparrowXxs + | types::ParticipantStateNoiseSuppression::AiCousticsSparrowXs + | types::ParticipantStateNoiseSuppression::AiCousticsSparrowS + | types::ParticipantStateNoiseSuppression::AiCousticsSparrowL + | types::ParticipantStateNoiseSuppression::AiCousticsSparrowXxs48khz + | types::ParticipantStateNoiseSuppression::AiCousticsSparrowXs48khz + | types::ParticipantStateNoiseSuppression::AiCousticsRookS48khz + | types::ParticipantStateNoiseSuppression::AiCousticsRookL48khz => { + emit_log_message( + participant_name, + "warn", + format!( + "Cloudflare worker reports noise suppression {value}, which hyper.video no longer offers; reporting {}", + NoiseSuppression::Disabled + ), + ); + NoiseSuppression::Disabled } } } @@ -934,7 +873,7 @@ mod tests { "videoActivated": true, "screenshareActivated": false, "autoGainControl": true, - "noiseSuppression": "rnnoise", + "noiseSuppression": "none", "transportMode": "webrtc", "videoConstraintPublishWebcam": "720p", "videoConstraintSubscribe": "none", @@ -983,7 +922,7 @@ mod tests { let state = session.refresh_state().await.unwrap(); assert!(state.running); assert!(state.joined); - assert_eq!(state.noise_suppression, NoiseSuppression::RNNoise); + assert_eq!(state.noise_suppression, NoiseSuppression::Disabled); assert_eq!(state.transport_mode, TransportMode::WebRTC); assert_eq!(state.video_constraint_publish_webcam, VideoConstraint::P720); assert_eq!(state.video_constraint_subscribe, VideoConstraint::None); @@ -1014,7 +953,7 @@ mod tests { "audioEnabled": true, "autoGainControl": true, "blur": true, - "noiseSuppression": "rnnoise", + "noiseSuppression": "none", "screenshareEnabled": false, "transport": "webrtc", "videoConstraintPublishWebcam": "720p", @@ -1029,14 +968,16 @@ mod tests { assert_eq!(requests[1].path, "/sessions/cf-session-123/close"); } + /// The backend reports `none`, with a warning, when the worker reports an engine that hyper.video no longer offers. #[tokio::test] - async fn start_accepts_ai_coustics_noise_suppression_from_worker_state() { + async fn start_reports_none_for_old_worker_engine() { + let captured_logs = CapturedLogs::new(); let responses = VecDeque::from(vec![ MockResponse::json( 200, json!({ "ok": true, - "sessionId": "cf-session-ai-coustics", + "sessionId": "cf-session-old-engine", "state": { "running": true, "joined": true, @@ -1058,7 +999,7 @@ mod tests { 200, json!({ "ok": true, - "sessionId": "cf-session-ai-coustics", + "sessionId": "cf-session-old-engine", "log": [], }), ), @@ -1082,7 +1023,10 @@ mod tests { let state = session.refresh_state().await.unwrap(); assert!(state.auto_gain_control); - assert_eq!(state.noise_suppression, NoiseSuppression::AiCousticsRookS48khz); + assert_eq!(state.noise_suppression, NoiseSuppression::Disabled); + assert!(captured_logs.output().contains( + "reports noise suppression ai-coustics-rook-s-48khz, which hyper.video no longer offers; reporting none" + )); session.close().await.unwrap(); server.abort(); @@ -1269,7 +1213,7 @@ mod tests { json!({ "ok": true, "sessionId": "cf-session-commands", - "state": worker_state_json(true, true, true, true, false, "deepfilternet", "none", false), + "state": worker_state_json(true, true, true, true, false, "none", "none", false), "log": [], }), ), @@ -1278,7 +1222,7 @@ mod tests { json!({ "ok": true, "sessionId": "cf-session-commands", - "state": worker_state_json(true, true, true, true, false, "deepfilternet", "1080p", false), + "state": worker_state_json(true, true, true, true, false, "none", "1080p", false), "log": [], }), ), @@ -1287,7 +1231,7 @@ mod tests { json!({ "ok": true, "sessionId": "cf-session-commands", - "state": worker_state_json(true, true, true, true, false, "deepfilternet", "1080p", true), + "state": worker_state_json(true, true, true, true, false, "none", "1080p", true), "log": [], }), ), @@ -1296,7 +1240,7 @@ mod tests { json!({ "ok": true, "sessionId": "cf-session-commands", - "state": worker_state_json(false, true, true, false, false, "deepfilternet", "1080p", true), + "state": worker_state_json(false, true, true, false, false, "none", "1080p", true), "log": [], }), ), @@ -1398,15 +1342,15 @@ mod tests { ), ), ( - ParticipantMessage::SetNoiseSuppression(NoiseSuppression::Deepfilternet), - json!({ "type": "set-noise-suppression", "noiseSuppression": "deepfilternet" }), + ParticipantMessage::SetNoiseSuppression(NoiseSuppression::Disabled), + json!({ "type": "set-noise-suppression", "noiseSuppression": "none" }), expected_state( true, true, true, true, false, - NoiseSuppression::Deepfilternet, + NoiseSuppression::Disabled, VideoConstraint::None, false, ), @@ -1420,7 +1364,7 @@ mod tests { true, true, false, - NoiseSuppression::Deepfilternet, + NoiseSuppression::Disabled, VideoConstraint::P1080, false, ), @@ -1434,7 +1378,7 @@ mod tests { true, true, false, - NoiseSuppression::Deepfilternet, + NoiseSuppression::Disabled, VideoConstraint::P1080, true, ), @@ -1448,7 +1392,7 @@ mod tests { true, false, false, - NoiseSuppression::Deepfilternet, + NoiseSuppression::Disabled, VideoConstraint::P1080, true, ), @@ -1490,7 +1434,7 @@ mod tests { json!({ "ok": true, "sessionId": "cf-session-webrtc", - "state": worker_state_json(false, false, false, false, true, "rnnoise", "720p", true), + "state": worker_state_json(false, false, false, false, true, "none", "720p", true), "log": [], }), ), @@ -1541,6 +1485,121 @@ mod tests { assert!(logs.contains("normalizing configured")); } + #[tokio::test] + async fn start_normalizes_noise_suppression_to_none_for_cloudflare() { + let captured_logs = CapturedLogs::new(); + let responses = VecDeque::from(vec![ + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-noise-suppression", + "state": worker_state_json(false, false, false, false, true, "none", "720p", true), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-noise-suppression", + "log": [], + }), + ), + ]); + let (base_url, requests, server) = spawn_http_server(responses).await; + let mut spec = launch_spec(ResolvedFrontendKind::HyperCore, &format!("{base_url}/room/demo")); + spec.settings.noise_suppression = NoiseSuppression::HyperVoice; + let mut session = CloudflareSession::new_for_test( + spec, + launch_options(false, FakeMedia::None), + CloudflareConfig { + base_url: Url::parse(&base_url).unwrap(), + request_timeout_seconds: 5, + session_timeout_ms: 120_000, + navigation_timeout_ms: 30_000, + selector_timeout_ms: 10_000, + debug: false, + health_poll_interval_ms: 60_000, + }, + ); + + session.start().await.unwrap(); + + let state = session.refresh_state().await.unwrap(); + assert_eq!(state.noise_suppression, NoiseSuppression::Disabled); + + session.close().await.unwrap(); + server.abort(); + + let requests = requests.lock().unwrap().clone(); + assert_eq!(requests[0].path, "/sessions"); + assert_eq!( + serde_json::from_str::(&requests[0].body).unwrap()["settings"]["noiseSuppression"], + json!("none") + ); + + let logs = captured_logs.output(); + assert!(logs.contains("only supports noise suppression none; normalizing configured hyper-voice to none")); + } + + #[tokio::test] + async fn set_noise_suppression_refuses_engines_for_cloudflare() { + let responses = VecDeque::from(vec![ + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-set-noise-suppression", + "state": worker_state_json(true, false, false, false, true, "none", "720p", true), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-set-noise-suppression", + "log": [], + }), + ), + ]); + let (base_url, requests, server) = spawn_http_server(responses).await; + let mut session = CloudflareSession::new_for_test( + launch_spec(ResolvedFrontendKind::HyperCore, &format!("{base_url}/room/demo")), + launch_options(false, FakeMedia::None), + CloudflareConfig { + base_url: Url::parse(&base_url).unwrap(), + request_timeout_seconds: 5, + session_timeout_ms: 120_000, + navigation_timeout_ms: 30_000, + selector_timeout_ms: 10_000, + debug: false, + health_poll_interval_ms: 60_000, + }, + ); + + session.start().await.unwrap(); + + let error = session + .handle_command(ParticipantMessage::SetNoiseSuppression(NoiseSuppression::HyperVoice)) + .await + .unwrap_err(); + assert_eq!( + error.to_string(), + "Cloudflare backend only supports noise suppression none, not hyper-voice" + ); + + session.close().await.unwrap(); + server.abort(); + + // Only the create and close requests reach the worker. + let requests = requests.lock().unwrap().clone(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].path, "/sessions"); + assert_eq!(requests[1].path, "/sessions/cf-session-set-noise-suppression/close"); + } + #[tokio::test] async fn start_logs_ignored_headless_and_fake_media_settings_for_cloudflare() { let captured_logs = CapturedLogs::new(); @@ -1550,7 +1609,7 @@ mod tests { json!({ "ok": true, "sessionId": "cf-session-limitations", - "state": worker_state_json(false, false, false, false, true, "rnnoise", "720p", true), + "state": worker_state_json(false, false, false, false, true, "none", "720p", true), "log": [], }), ), @@ -1664,7 +1723,7 @@ mod tests { video_enabled: true, screenshare_enabled: false, auto_gain_control: true, - noise_suppression: NoiseSuppression::RNNoise, + noise_suppression: NoiseSuppression::Disabled, transport: TransportMode::WebRTC, video_constraint_publish_webcam: VideoConstraint::P720, video_constraint_subscribe: VideoConstraint::None, diff --git a/browser/src/participant/frontend/lite.rs b/browser/src/participant/frontend/lite.rs index 4e01276..4a104a2 100644 --- a/browser/src/participant/frontend/lite.rs +++ b/browser/src/participant/frontend/lite.rs @@ -342,7 +342,7 @@ impl ParticipantInnerLite { video_constraint_publish_webcam: VideoConstraint::default(), video_constraint_subscribe: VideoConstraint::default(), video_max_concurrent_tracks: None, - noise_suppression: NoiseSuppression::default(), + noise_suppression: NoiseSuppression::Disabled, muted: !self.context.launch_spec.settings.audio_enabled, video_activated: self.context.launch_spec.settings.video_enabled, ..Default::default() diff --git a/browser/src/participant/shared/spec.rs b/browser/src/participant/shared/spec.rs index 9b80857..0890640 100644 --- a/browser/src/participant/shared/spec.rs +++ b/browser/src/participant/shared/spec.rs @@ -107,7 +107,7 @@ mod tests { video_enabled: false, screenshare_enabled: true, auto_gain_control: true, - noise_suppression: NoiseSuppression::RNNoise, + noise_suppression: NoiseSuppression::HyperVoice, transport: TransportMode::WebRTC, video_constraint_publish_webcam: VideoConstraint::P480, video_constraint_subscribe: VideoConstraint::P720, @@ -126,7 +126,7 @@ mod tests { assert!(!spec.settings.video_enabled); assert!(spec.settings.screenshare_enabled); assert!(spec.settings.auto_gain_control); - assert_eq!(spec.settings.noise_suppression, NoiseSuppression::RNNoise); + assert_eq!(spec.settings.noise_suppression, NoiseSuppression::HyperVoice); assert_eq!(spec.settings.transport, TransportMode::WebRTC); assert_eq!(spec.settings.video_constraint_publish_webcam, VideoConstraint::P480); assert_eq!(spec.settings.video_constraint_subscribe, VideoConstraint::P720); diff --git a/browser/tests/cloudflare_driver.rs b/browser/tests/cloudflare_driver.rs index 1342569..411fc97 100644 --- a/browser/tests/cloudflare_driver.rs +++ b/browser/tests/cloudflare_driver.rs @@ -269,7 +269,7 @@ async fn cloudflare_runtime_delegates_hyper_core_authentication_to_the_worker() "videoActivated": true, "screenshareActivated": false, "autoGainControl": true, - "noiseSuppression": "ai-coustics-rook-s-48khz", + "noiseSuppression": "none", "transportMode": "webrtc", "videoConstraintPublishWebcam": "1080p", "videoConstraintSubscribe": "none", @@ -317,7 +317,7 @@ async fn cloudflare_runtime_delegates_hyper_core_authentication_to_the_worker() assert!(started.joined); assert_eq!( started.noise_suppression, - client_simulator_config::NoiseSuppression::AiCousticsRookS48khz + client_simulator_config::NoiseSuppression::Disabled ); participant.close().await; diff --git a/config/src/client_config.rs b/config/src/client_config.rs index 6930110..fb8c30b 100644 --- a/config/src/client_config.rs +++ b/config/src/client_config.rs @@ -92,60 +92,16 @@ impl VideoMaxConcurrentTracksPreset { } } +/// A value of hyper.video's noise suppression setting. #[derive(Debug, Default, Clone, Copy, Display, EnumIter, EnumString, Serialize, Deserialize, PartialEq, PartialOrd)] pub enum NoiseSuppression { #[default] #[strum(to_string = "none")] #[serde(rename = "none")] Disabled, - #[strum(to_string = "deepfilternet")] - #[serde(rename = "deepfilternet")] - Deepfilternet, - #[strum(to_string = "rnnoise")] - #[serde(rename = "rnnoise")] - RNNoise, - #[strum(to_string = "iris-carthy")] - #[serde(rename = "iris-carthy")] - IRISCarthy, - #[strum(to_string = "krisp-high")] - #[serde(rename = "krisp-high")] - KrispHigh, - #[strum(to_string = "krisp-medium")] - #[serde(rename = "krisp-medium")] - KrispMedium, - #[strum(to_string = "krisp-low")] - #[serde(rename = "krisp-low")] - KrispLow, - #[strum(to_string = "krisp-high-with-bvc")] - #[serde(rename = "krisp-high-with-bvc")] - KrispHighWithBVC, - #[strum(to_string = "krisp-medium-with-bvc")] - #[serde(rename = "krisp-medium-with-bvc")] - KrispMediumWithBVC, - #[strum(to_string = "ai-coustics-sparrow-xxs")] - #[serde(rename = "ai-coustics-sparrow-xxs")] - AiCousticsSparrowXxs, - #[strum(to_string = "ai-coustics-sparrow-xs")] - #[serde(rename = "ai-coustics-sparrow-xs")] - AiCousticsSparrowXs, - #[strum(to_string = "ai-coustics-sparrow-s")] - #[serde(rename = "ai-coustics-sparrow-s")] - AiCousticsSparrowS, - #[strum(to_string = "ai-coustics-sparrow-l")] - #[serde(rename = "ai-coustics-sparrow-l")] - AiCousticsSparrowL, - #[strum(to_string = "ai-coustics-sparrow-xxs-48khz")] - #[serde(rename = "ai-coustics-sparrow-xxs-48khz")] - AiCousticsSparrowXxs48khz, - #[strum(to_string = "ai-coustics-sparrow-xs-48khz")] - #[serde(rename = "ai-coustics-sparrow-xs-48khz")] - AiCousticsSparrowXs48khz, - #[strum(to_string = "ai-coustics-rook-s-48khz")] - #[serde(rename = "ai-coustics-rook-s-48khz")] - AiCousticsRookS48khz, - #[strum(to_string = "ai-coustics-rook-l-48khz")] - #[serde(rename = "ai-coustics-rook-l-48khz")] - AiCousticsRookL48khz, + #[strum(to_string = "hyper-voice")] + #[serde(rename = "hyper-voice")] + HyperVoice, } #[derive(Debug, Default, Clone, Copy, Display, EnumIter, EnumString, Serialize, Deserialize, PartialEq, Eq)] @@ -168,6 +124,7 @@ impl ParticipantBackendKind { #[cfg(test)] mod tests { use super::{ + NoiseSuppression, ParticipantBackendKind, VideoConstraint, VideoMaxConcurrentTracksPreset, @@ -197,6 +154,24 @@ mod tests { } } + #[test] + fn noise_suppression_round_trips_hyper_media_setting_names() { + let values = [ + ("none", NoiseSuppression::Disabled), + ("hyper-voice", NoiseSuppression::HyperVoice), + ]; + + for (raw, expected) in values { + assert_eq!(NoiseSuppression::from_str(raw).unwrap(), expected); + assert_eq!(expected.to_string(), raw); + assert_eq!(serde_json::to_value(expected).unwrap(), raw); + assert_eq!( + serde_json::from_value::(raw.into()).unwrap(), + expected + ); + } + } + #[test] fn video_track_limit_presets_convert_to_nullable_track_counts() { assert_eq!(VideoMaxConcurrentTracksPreset::Unlimited.to_option(), None); diff --git a/config/src/lib.rs b/config/src/lib.rs index f292811..256277e 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -35,6 +35,10 @@ pub use client_config::{ }; pub use cloudflare_config::CloudflareConfig; use color_eyre::Result; +use config::{ + builder::DefaultState, + ConfigBuilder, +}; pub use device_farm_config::{ default_device_farm_project_arn, default_device_farm_region, @@ -59,6 +63,7 @@ use serde::{ use std::{ collections::HashMap, path::Path, + str::FromStr as _, }; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -238,20 +243,40 @@ impl Config { builder = builder.add_source(Config::default()); - let config_files = [("config.yaml", config::FileFormat::Yaml)]; - - for (file, format) in &config_files { - let source = config::File::from(config_dir.join(file)) - .format(*format) - .required(false); - builder = builder.add_source(source); - } + let config_file = config_dir.join("config.yaml"); + builder = builder.add_source( + config::File::from(config_file.as_path()) + .format(config::FileFormat::Yaml) + .required(false), + ); builder = builder.add_source(args); - let cfg: Self = builder.build()?.try_deserialize()?; + Self::from_builder(builder, &config_file) + } - Ok(cfg) + /// Builds the configuration from `builder`, reading a noise suppression that hyper.video doesn't take as `none`. + /// + /// [`Config::save`] writes the noise suppression, so `config_file` can name an engine that an older simulator + /// offered. hyper.video drops stored values that it doesn't take, and the simulator does the same, with a warning, + /// so that every subcommand still starts. The CLI and `--participant` JSON reject an unknown value instead, because + /// there it is a typo. + fn from_builder(builder: ConfigBuilder, config_file: &Path) -> Result { + let config = builder.build_cloned()?; + match config.get_string("noise_suppression") { + Ok(value) if NoiseSuppression::from_str(&value).is_err() => { + let fallback = NoiseSuppression::Disabled; + warn!( + "{} names noise suppression {value}, which hyper.video doesn't take; using {fallback}", + config_file.display() + ); + builder + .set_override("noise_suppression", fallback.to_string())? + .build()? + .try_deserialize() + } + _ => config.try_deserialize(), + } } pub fn fake_media_with_description(&self) -> FakeMediaWithDescription { @@ -372,6 +397,28 @@ remote_url_options: assert_eq!(config.backend, ParticipantBackendKind::Local); } + /// A config file's noise suppression loads as `none` when it names an engine that hyper.video no longer offers. + #[test] + fn config_file_noise_suppression_loads_old_engine_as_none() { + let values = [ + ("krisp-high", NoiseSuppression::Disabled), + ("hyper-voice", NoiseSuppression::HyperVoice), + ]; + + for (raw, expected) in values { + let builder = config::Config::builder() + .add_source(Config::default()) + .add_source(config::File::from_str( + &format!("noise_suppression: {raw}"), + config::FileFormat::Yaml, + )); + + let config = Config::from_builder(builder, Path::new("config.yaml")).expect("config should load"); + + assert_eq!(config.noise_suppression, expected, "{raw}"); + } + } + #[test] fn parses_cloudflare_backend_and_nested_cloudflare_config() { let config: Config = config::Config::builder() diff --git a/docs/browser-driver.md b/docs/browser-driver.md index 2e4e0b7..f608727 100644 --- a/docs/browser-driver.md +++ b/docs/browser-driver.md @@ -43,7 +43,7 @@ The current command set is: | `ToggleAudio` | Toggle microphone mute state. | [`browser/src/participant/mod.rs`](../browser/src/participant/mod.rs) `:242-244`, [`tui/src/tui/components/participants.rs`](../tui/src/tui/components/participants.rs) `:264-270` | | `ToggleVideo` | Toggle camera on/off. | [`browser/src/participant/mod.rs`](../browser/src/participant/mod.rs) `:246-248`, [`tui/src/tui/components/participants.rs`](../tui/src/tui/components/participants.rs) `:272-277` | | `ToggleScreenshare` | Toggle screen sharing on/off. | [`browser/src/participant/mod.rs`](../browser/src/participant/mod.rs) `:250-256`, [`tui/src/tui/components/participants.rs`](../tui/src/tui/components/participants.rs) `:279-284` | -| `SetNoiseSuppression(NoiseSuppression)` | Set the noise suppression model. | [`browser/src/participant/mod.rs`](../browser/src/participant/mod.rs) `:258-260`, [`tui/src/tui/components/participants.rs`](../tui/src/tui/components/participants.rs) `:160-166`, `:187-195` | +| `SetNoiseSuppression(NoiseSuppression)` | Set the noise suppression model: `none` or `hyper-voice`. | [`browser/src/participant/mod.rs`](../browser/src/participant/mod.rs) `:258-260`, [`tui/src/tui/components/participants.rs`](../tui/src/tui/components/participants.rs) `:160-166`, `:187-195` | | `SetWebcamResolutions(WebcamResolution)` | Set outgoing camera resolution. The enum variant name is pluralized in code today. | [`browser/src/participant/mod.rs`](../browser/src/participant/mod.rs) `:262-268`, [`tui/src/tui/components/participants.rs`](../tui/src/tui/components/participants.rs) `:170-176`, `:209-217` | | `ToggleBackgroundBlur` | Toggle background blur. | [`browser/src/participant/mod.rs`](../browser/src/participant/mod.rs) `:270-272`, [`tui/src/tui/components/participants.rs`](../tui/src/tui/components/participants.rs) `:294-299` | @@ -93,6 +93,7 @@ For parity with the current local backend, `start()` should apply those launch s - Hyper Core applies noise suppression, blur, camera resolution, and transport before join, then applies audio/video in the lobby, and applies initial screenshare after joining. See [`browser/src/participant/local/core.rs`](../browser/src/participant/local/core.rs) `:143-171`. - Hyper Lite currently only applies audio/video/screenshare and ignores advanced media settings. See [`browser/src/participant/local/lite.rs`](../browser/src/participant/local/lite.rs) `:90-106`, `:153-175`. +- The Cloudflare backend applies noise suppression `none` only, because the worker accepts no other value of hyper.video's setting. It sends `none` in place of `hyper-voice`, with a warning, and refuses `SetNoiseSuppression` with it. See [`browser/src/participant/cloudflare/mod.rs`](../browser/src/participant/cloudflare/mod.rs). There is currently no runtime command for: diff --git a/src/headless.rs b/src/headless.rs index 6252a17..426dbf6 100644 --- a/src/headless.rs +++ b/src/headless.rs @@ -52,6 +52,7 @@ pub struct HeadlessArgs { #[clap(long = "auto-gain-control", value_parser = clap::builder::BoolishValueParser::new())] pub auto_gain_control: Option, + /// Noise suppression model: none or hyper-voice. Cloudflare participants always run with none. #[clap(long = "noise-suppression", value_name = "MODEL")] pub noise_suppression: Option,