mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -07:00
Enhance audio capture functionality and update dependencies
- Added support for capturing system audio on Windows using WASAPI with improved error handling and thread safety. - Introduced the 'scopeguard' crate for better resource management during audio capture. - Updated Cargo.toml to include 'scopeguard' and modified Windows-specific dependencies for enhanced functionality. - Added a new test for validating audio capture output, ensuring the captured audio data is valid and non-empty.
This commit is contained in:
@@ -4,15 +4,16 @@ from PyInstaller.utils.hooks import collect_submodules
|
|||||||
from PyInstaller.utils.hooks import copy_metadata
|
from PyInstaller.utils.hooks import copy_metadata
|
||||||
|
|
||||||
datas = []
|
datas = []
|
||||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli']
|
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern']
|
||||||
datas += collect_data_files('qwen_tts')
|
datas += collect_data_files('qwen_tts')
|
||||||
datas += copy_metadata('qwen-tts')
|
datas += copy_metadata('qwen-tts')
|
||||||
hiddenimports += collect_submodules('qwen_tts')
|
hiddenimports += collect_submodules('qwen_tts')
|
||||||
|
hiddenimports += collect_submodules('jaraco')
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['server.py'],
|
['server.py'],
|
||||||
pathex=['/Users/jamespine/Projects/voice/Qwen3-TTS'],
|
pathex=['C:\\Users\\ijame\\Projects\\voice\\Qwen3-TTS'],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=datas,
|
datas=datas,
|
||||||
hiddenimports=hiddenimports,
|
hiddenimports=hiddenimports,
|
||||||
|
|||||||
Generated
+1
@@ -4490,6 +4490,7 @@ dependencies = [
|
|||||||
"coreaudio-sys",
|
"coreaudio-sys",
|
||||||
"hound",
|
"hound",
|
||||||
"objc",
|
"objc",
|
||||||
|
"scopeguard",
|
||||||
"screencapturekit",
|
"screencapturekit",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ serde_json = "1.0"
|
|||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
hound = "3.5"
|
hound = "3.5"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
scopeguard = "1.2.0"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
screencapturekit = { version = "1", features = ["async"] }
|
screencapturekit = { version = "1", features = ["async"] }
|
||||||
@@ -31,7 +32,7 @@ core-foundation-sys = "0.8"
|
|||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
wasapi = "0.22"
|
wasapi = "0.22"
|
||||||
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }
|
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Com"] }
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-updater = "2.0"
|
tauri-plugin-updater = "2.0"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use std::io::Cursor;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
|
||||||
use wasapi::*;
|
use wasapi::*;
|
||||||
|
use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_MULTITHREADED};
|
||||||
|
|
||||||
pub async fn start_capture(
|
pub async fn start_capture(
|
||||||
state: &AudioCaptureState,
|
state: &AudioCaptureState,
|
||||||
@@ -37,6 +37,20 @@ pub async fn start_capture(
|
|||||||
// Spawn capture task on a dedicated thread (WASAPI COM objects are not Send)
|
// Spawn capture task on a dedicated thread (WASAPI COM objects are not Send)
|
||||||
// All WASAPI objects must be created and used on the same thread
|
// All WASAPI objects must be created and used on the same thread
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
|
// Initialize COM for this thread
|
||||||
|
unsafe {
|
||||||
|
let hr = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||||
|
if hr.is_err() {
|
||||||
|
eprintln!("Failed to initialize COM: {:?}", hr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure COM is uninitialized when thread exits
|
||||||
|
let _com_guard = scopeguard::guard((), |_| unsafe {
|
||||||
|
CoUninitialize();
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize WASAPI on this thread
|
// Initialize WASAPI on this thread
|
||||||
let device = match DeviceEnumerator::new()
|
let device = match DeviceEnumerator::new()
|
||||||
.and_then(|enumerator| enumerator.get_default_device(&Direction::Render))
|
.and_then(|enumerator| enumerator.get_default_device(&Direction::Render))
|
||||||
@@ -76,10 +90,21 @@ pub async fn start_capture(
|
|||||||
*sample_rate_arc.lock().unwrap() = mix_format.get_samplespersec();
|
*sample_rate_arc.lock().unwrap() = mix_format.get_samplespersec();
|
||||||
*channels_arc.lock().unwrap() = mix_format.get_nchannels();
|
*channels_arc.lock().unwrap() = mix_format.get_nchannels();
|
||||||
|
|
||||||
|
// Get device period
|
||||||
|
let (_def_period, min_period) = match audio_client.get_device_period() {
|
||||||
|
Ok(periods) => periods,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to get device period: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize audio client for loopback with StreamMode
|
// Initialize audio client for loopback with StreamMode
|
||||||
|
// For loopback mode: get Render device, initialize with Capture direction
|
||||||
|
// This triggers AUDCLNT_STREAMFLAGS_LOOPBACK in the wasapi crate
|
||||||
let stream_mode = StreamMode::EventsShared {
|
let stream_mode = StreamMode::EventsShared {
|
||||||
autoconvert: false,
|
autoconvert: true, // Enable automatic format conversion
|
||||||
buffer_duration_hns: 0, // 0 = use default buffer size
|
buffer_duration_hns: min_period, // Use minimum period
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = audio_client.initialize_client(&mix_format, &Direction::Capture, &stream_mode) {
|
if let Err(e) = audio_client.initialize_client(&mix_format, &Direction::Capture, &stream_mode) {
|
||||||
@@ -89,6 +114,15 @@ pub async fn start_capture(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up event handle for EventsShared mode
|
||||||
|
let h_event = match audio_client.set_get_eventhandle() {
|
||||||
|
Ok(event) => event,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to set event handle: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let capture_client = match audio_client.get_audiocaptureclient() {
|
let capture_client = match audio_client.get_audiocaptureclient() {
|
||||||
Ok(client) => client,
|
Ok(client) => client,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -158,8 +192,10 @@ pub async fn start_capture(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sleep briefly to avoid busy-waiting
|
// Wait for event signal (with timeout to allow checking stop flag)
|
||||||
thread::sleep(Duration::from_millis(10));
|
if h_event.wait_for_event(100).is_err() {
|
||||||
|
// Timeout is expected - just continue to check stop flag
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop the stream when done
|
// Stop the stream when done
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod audio_capture;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// NOTE: This test requires system audio to be playing during execution.
|
||||||
|
// To run this test successfully:
|
||||||
|
// 1. Start playing audio (music, video, etc.)
|
||||||
|
// 2. Run: cargo test --test audio_capture_test -- --nocapture
|
||||||
|
// 3. The test will capture audio for 5 seconds and verify the output
|
||||||
|
|
||||||
|
use voicebox::audio_capture::{AudioCaptureState, start_capture, stop_capture};
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_system_audio_capture() {
|
||||||
|
// Create AudioCaptureState
|
||||||
|
let state = AudioCaptureState::new();
|
||||||
|
|
||||||
|
println!("Starting system audio capture with 5 second max duration...");
|
||||||
|
|
||||||
|
// Start capture with 5 second max duration
|
||||||
|
let result = start_capture(&state, 5).await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
panic!("Failed to start capture: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Capture started, waiting 5 seconds...");
|
||||||
|
|
||||||
|
// Wait 5 seconds for capture to complete
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
println!("Stopping capture...");
|
||||||
|
|
||||||
|
// Stop capture and get the result
|
||||||
|
let audio_data = stop_capture(&state).await;
|
||||||
|
|
||||||
|
match audio_data {
|
||||||
|
Ok(base64_wav) => {
|
||||||
|
println!("Capture stopped successfully");
|
||||||
|
|
||||||
|
// Validate the returned base64 WAV data
|
||||||
|
println!("Validating base64 WAV data...");
|
||||||
|
|
||||||
|
// Decode base64 to bytes
|
||||||
|
let decoded_bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&base64_wav)
|
||||||
|
.expect("Failed to decode base64 data");
|
||||||
|
|
||||||
|
// Verify bytes array is not empty
|
||||||
|
assert!(!decoded_bytes.is_empty(), "Decoded bytes array is empty");
|
||||||
|
|
||||||
|
// Confirm data has content (length > 0)
|
||||||
|
println!("WAV data length: {} bytes", decoded_bytes.len());
|
||||||
|
assert!(decoded_bytes.len() > 0, "WAV data has no content");
|
||||||
|
|
||||||
|
println!("✓ Test passed: Audio capture produced valid WAV data");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
panic!("Failed to stop capture or get audio data: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user