diff --git a/crates/wzp-native/cpp/oboe_bridge.cpp b/crates/wzp-native/cpp/oboe_bridge.cpp index 29b9e90..82ba79c 100644 --- a/crates/wzp-native/cpp/oboe_bridge.cpp +++ b/crates/wzp-native/cpp/oboe_bridge.cpp @@ -254,18 +254,28 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) { oboe::AudioStreamBuilder captureBuilder; captureBuilder.setDirection(oboe::Direction::Input) ->setPerformanceMode(oboe::PerformanceMode::LowLatency) - // Shared mode allows Oboe's internal resampler to bridge 48kHz to - // the hardware rate (8/16kHz for BT SCO). Exclusive mode bypasses - // the resampler and fails with "getInputProfile could not find profile". ->setSharingMode(oboe::SharingMode::Shared) ->setFormat(oboe::AudioFormat::I16) ->setChannelCount(config->channel_count) - ->setSampleRate(config->sample_rate) - ->setFramesPerDataCallback(config->frames_per_burst) - ->setInputPreset(oboe::InputPreset::VoiceCommunication) ->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Best) ->setDataCallback(&g_capture_cb); + if (config->bt_active) { + // BT SCO mode: do NOT set sample rate or input preset. + // Requesting 48kHz against a BT SCO device fails with + // "getInputProfile could not find profile". Letting the system + // choose the native rate (8/16kHz) and relying on Oboe's + // resampler (SampleRateConversionQuality::Best) to bridge + // to our 48kHz ring buffer is the only path that works. + // InputPreset::VoiceCommunication can also prevent BT SCO + // routing on some devices — skip it for BT. + LOGI("capture: BT mode — no sample rate or input preset set"); + } else { + captureBuilder.setSampleRate(config->sample_rate) + ->setFramesPerDataCallback(config->frames_per_burst) + ->setInputPreset(oboe::InputPreset::VoiceCommunication); + } + oboe::Result result = captureBuilder.openStream(g_capture_stream); if (result != oboe::Result::OK) { LOGE("Failed to open capture stream: %s", oboe::convertToText(result)); @@ -321,12 +331,20 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) { ->setSharingMode(oboe::SharingMode::Shared) ->setFormat(oboe::AudioFormat::I16) ->setChannelCount(config->channel_count) - ->setSampleRate(config->sample_rate) - ->setFramesPerDataCallback(config->frames_per_burst) - ->setUsage(oboe::Usage::VoiceCommunication) ->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Best) ->setDataCallback(&g_playout_cb); + if (config->bt_active) { + LOGI("playout: BT mode — no sample rate set, using Usage::Media"); + // Usage::Media instead of VoiceCommunication for BT output + // to avoid conflicts with the communication device routing. + playoutBuilder.setUsage(oboe::Usage::Media); + } else { + playoutBuilder.setSampleRate(config->sample_rate) + ->setFramesPerDataCallback(config->frames_per_burst) + ->setUsage(oboe::Usage::VoiceCommunication); + } + result = playoutBuilder.openStream(g_playout_stream); if (result != oboe::Result::OK) { LOGE("Failed to open playout stream: %s", oboe::convertToText(result)); diff --git a/crates/wzp-native/cpp/oboe_bridge.h b/crates/wzp-native/cpp/oboe_bridge.h index 8c2f143..bc342ab 100644 --- a/crates/wzp-native/cpp/oboe_bridge.h +++ b/crates/wzp-native/cpp/oboe_bridge.h @@ -16,6 +16,7 @@ typedef struct { int32_t sample_rate; int32_t frames_per_burst; int32_t channel_count; + int32_t bt_active; /* nonzero = BT SCO mode: skip sample rate + input preset */ } WzpOboeConfig; typedef struct { diff --git a/crates/wzp-native/src/lib.rs b/crates/wzp-native/src/lib.rs index d358941..cec4361 100644 --- a/crates/wzp-native/src/lib.rs +++ b/crates/wzp-native/src/lib.rs @@ -47,6 +47,10 @@ struct WzpOboeConfig { sample_rate: i32, frames_per_burst: i32, channel_count: i32, + /// When nonzero, capture stream skips setSampleRate and setInputPreset + /// so the system can route to BT SCO at its native rate (8/16kHz). + /// Oboe's SampleRateConversionQuality::Best resamples to 48kHz. + bt_active: i32, } #[repr(C)] @@ -204,6 +208,17 @@ fn backend() -> &'static AudioBackend { /// Idempotent — calling while already running is a no-op that returns 0. #[unsafe(no_mangle)] pub extern "C" fn wzp_native_audio_start() -> i32 { + audio_start_inner(false) +} + +/// Start Oboe in Bluetooth SCO mode — skips sample rate and input preset +/// on capture so the system can route to the BT SCO device natively. +#[unsafe(no_mangle)] +pub extern "C" fn wzp_native_audio_start_bt() -> i32 { + audio_start_inner(true) +} + +fn audio_start_inner(bt: bool) -> i32 { let b = backend(); let mut started = match b.started.lock() { Ok(g) => g, @@ -217,6 +232,7 @@ pub extern "C" fn wzp_native_audio_start() -> i32 { sample_rate: 48_000, frames_per_burst: FRAME_SAMPLES as i32, channel_count: 1, + bt_active: if bt { 1 } else { 0 }, }; let rings = WzpOboeRings { capture_buf: b.capture.buf_ptr(), diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0a2c685..e6965e3 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -814,10 +814,18 @@ async fn set_bluetooth_sco(on: bool) -> Result<(), String> { } if wzp_native::is_loaded() && wzp_native::audio_is_running() { tracing::info!(on, "set_bluetooth_sco: restarting Oboe for route change"); - tokio::task::spawn_blocking(|| { + tokio::task::spawn_blocking(move || { wzp_native::audio_stop(); - wzp_native::audio_start() - .map_err(|code| format!("audio_start after BT toggle: code {code}")) + if on { + // BT mode: skip sample rate + input preset on capture + // so the system can route to the BT SCO device natively. + wzp_native::audio_start_bt() + .map_err(|code| format!("audio_start_bt after BT on: code {code}")) + } else { + // Normal mode: restore 48kHz + VoiceCommunication preset. + wzp_native::audio_start() + .map_err(|code| format!("audio_start after BT off: code {code}")) + } }) .await .map_err(|e| format!("spawn_blocking join: {e}"))??; diff --git a/desktop/src-tauri/src/wzp_native.rs b/desktop/src-tauri/src/wzp_native.rs index 4f73e98..e96e39e 100644 --- a/desktop/src-tauri/src/wzp_native.rs +++ b/desktop/src-tauri/src/wzp_native.rs @@ -26,6 +26,7 @@ static LIB: OnceLock = OnceLock::new(); static VERSION: OnceLock i32> = OnceLock::new(); static HELLO: OnceLock usize> = OnceLock::new(); static AUDIO_START: OnceLock i32> = OnceLock::new(); +static AUDIO_START_BT: OnceLock i32> = OnceLock::new(); static AUDIO_STOP: OnceLock = OnceLock::new(); static AUDIO_READ_CAPTURE: OnceLock usize> = OnceLock::new(); static AUDIO_WRITE_PLAYOUT: OnceLock usize> = OnceLock::new(); @@ -65,6 +66,7 @@ pub fn init() -> Result<(), String> { resolve!(VERSION, unsafe extern "C" fn() -> i32, b"wzp_native_version"); resolve!(HELLO, unsafe extern "C" fn(*mut u8, usize) -> usize, b"wzp_native_hello"); resolve!(AUDIO_START, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start"); + resolve!(AUDIO_START_BT, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start_bt"); resolve!(AUDIO_STOP, unsafe extern "C" fn(), b"wzp_native_audio_stop"); resolve!(AUDIO_READ_CAPTURE, unsafe extern "C" fn(*mut i16, usize) -> usize, b"wzp_native_audio_read_capture"); resolve!(AUDIO_WRITE_PLAYOUT, unsafe extern "C" fn(*const i16, usize) -> usize, b"wzp_native_audio_write_playout"); @@ -104,6 +106,14 @@ pub fn audio_start() -> Result<(), i32> { if ret == 0 { Ok(()) } else { Err(ret) } } +/// Start Oboe in Bluetooth SCO mode — capture skips sample rate and +/// input preset so the system routes to the BT SCO device natively. +pub fn audio_start_bt() -> Result<(), i32> { + let f = AUDIO_START_BT.get().ok_or(-100_i32)?; + let ret = unsafe { f() }; + if ret == 0 { Ok(()) } else { Err(ret) } +} + /// Stop both streams. Safe to call even if not running. pub fn audio_stop() { if let Some(f) = AUDIO_STOP.get() {