fix(audio): add WASAPI loopback support, mono-to-stereo upmix, and 48kHz resampler
This commit is contained in:
parent
22e3dbda4c
commit
1c3f09c6fb
3 changed files with 195 additions and 8 deletions
|
|
@ -34,8 +34,9 @@ if [ -f "$SYSTEMD_USER_DIR/vrec-daemon.service" ] || systemctl --user is-active
|
|||
rm -f "$SYSTEMD_USER_DIR/default.target.wants/vrec-daemon.service"
|
||||
fi
|
||||
|
||||
if systemctl --user is-active --quiet scythe-daemon.service 2>/dev/null; then
|
||||
if [ -f "$SYSTEMD_USER_DIR/scythe-daemon.service" ] || systemctl --user is-enabled --quiet scythe-daemon.service 2>/dev/null; then
|
||||
echo "Restarting active scythe-daemon systemd service..."
|
||||
systemctl --user reset-failed scythe-daemon.service 2>/dev/null || true
|
||||
systemctl --user restart scythe-daemon.service
|
||||
elif pgrep -x scythe-daemon > /dev/null || pgrep -x vrec-daemon > /dev/null; then
|
||||
echo "Restarting active scythe-daemon engine..."
|
||||
|
|
|
|||
|
|
@ -143,6 +143,102 @@ pub fn soft_limit(x: f32) -> f32 {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ResampleState {
|
||||
pub phase: f64,
|
||||
}
|
||||
|
||||
pub fn convert_to_stereo_48k(
|
||||
raw_data: &[f32],
|
||||
in_channels: u16,
|
||||
in_sample_rate: u32,
|
||||
gain: f32,
|
||||
state: &mut ResampleState,
|
||||
) -> Vec<f32> {
|
||||
let ch = (in_channels as usize).max(1);
|
||||
let num_frames = raw_data.len() / ch;
|
||||
if num_frames == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Step 1: Up/downmix any input channel layout into stereo (2 channels)
|
||||
let mut stereo_in = Vec::with_capacity(num_frames * 2);
|
||||
match ch {
|
||||
1 => {
|
||||
for &sample in raw_data {
|
||||
let s = sample * gain;
|
||||
stereo_in.push(s);
|
||||
stereo_in.push(s);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
for pair in raw_data.chunks_exact(2) {
|
||||
stereo_in.push(pair[0] * gain);
|
||||
stereo_in.push(pair[1] * gain);
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
for frame in raw_data.chunks_exact(3) {
|
||||
let center = frame[2] * std::f32::consts::FRAC_1_SQRT_2;
|
||||
stereo_in.push((frame[0] + center) * gain);
|
||||
stereo_in.push((frame[1] + center) * gain);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Surround downmix (4, 5.1, 7.1)
|
||||
for frame in raw_data.chunks_exact(ch) {
|
||||
let center = if ch > 2 { frame[2] * std::f32::consts::FRAC_1_SQRT_2 } else { 0.0 };
|
||||
let l_surround = if ch > 4 { frame[4] * std::f32::consts::FRAC_1_SQRT_2 } else { 0.0 };
|
||||
let r_surround = if ch > 5 { frame[5] * std::f32::consts::FRAC_1_SQRT_2 } else { 0.0 };
|
||||
let l = (frame[0] + center + l_surround) * gain;
|
||||
let r = (frame[1] + center + r_surround) * gain;
|
||||
stereo_in.push(l);
|
||||
stereo_in.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Sample rate conversion to 48,000 Hz
|
||||
if in_sample_rate == 48000 || in_sample_rate == 0 {
|
||||
for sample in stereo_in.iter_mut() {
|
||||
*sample = soft_limit(*sample);
|
||||
}
|
||||
return stereo_in;
|
||||
}
|
||||
|
||||
let in_frames = stereo_in.len() / 2;
|
||||
let ratio = in_sample_rate as f64 / 48000.0;
|
||||
let est_out = ((in_frames as f64) / ratio).ceil() as usize;
|
||||
let mut out = Vec::with_capacity(est_out * 2 + 8);
|
||||
|
||||
while state.phase < in_frames as f64 {
|
||||
let idx = state.phase.floor() as usize;
|
||||
let frac = (state.phase - idx as f64) as f32;
|
||||
|
||||
let (cur_l, cur_r) = (stereo_in[idx * 2], stereo_in[idx * 2 + 1]);
|
||||
let (next_l, next_r) = if idx + 1 < in_frames {
|
||||
(stereo_in[(idx + 1) * 2], stereo_in[(idx + 1) * 2 + 1])
|
||||
} else {
|
||||
(cur_l, cur_r)
|
||||
};
|
||||
|
||||
let l = cur_l + (next_l - cur_l) * frac;
|
||||
let r = cur_r + (next_r - cur_r) * frac;
|
||||
|
||||
out.push(soft_limit(l));
|
||||
out.push(soft_limit(r));
|
||||
|
||||
state.phase += ratio;
|
||||
}
|
||||
|
||||
state.phase -= in_frames as f64;
|
||||
if state.phase < 0.0 {
|
||||
state.phase = 0.0;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_parec_available() -> bool {
|
||||
std::process::Command::new("parec")
|
||||
|
|
@ -382,6 +478,13 @@ impl AudioCapture {
|
|||
let host = cpal::default_host();
|
||||
|
||||
let find_system_device = || -> Option<cpal::Device> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(out_dev) = host.default_output_device() {
|
||||
return Some(out_dev);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
if let Ok(devs) = host.input_devices() {
|
||||
for d in devs {
|
||||
let n = get_device_name(&d).to_lowercase();
|
||||
|
|
@ -417,21 +520,24 @@ impl AudioCapture {
|
|||
|
||||
let levels_for_cpal = Arc::clone(&levels);
|
||||
let build_stream = move |dev: &cpal::Device, tx: Sender<Vec<f32>>, gain: f32, is_mic: bool| -> Result<(cpal::Stream, u32, u16), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let config = dev.default_input_config()?;
|
||||
let sample_rate = config.sample_rate();
|
||||
let channels = config.channels();
|
||||
let config = dev.default_input_config().or_else(|_| dev.default_output_config())?;
|
||||
let in_sample_rate = config.sample_rate();
|
||||
let in_channels = config.channels();
|
||||
let format = config.sample_format();
|
||||
let err_fn = |err| eprintln!("Audio stream error: {}", err);
|
||||
let stream_config: cpal::StreamConfig = config.into();
|
||||
let lvl_f32 = Arc::clone(&levels_for_cpal);
|
||||
let lvl_i16 = Arc::clone(&levels_for_cpal);
|
||||
|
||||
let mut resample_state_f32 = ResampleState::default();
|
||||
let mut resample_state_i16 = ResampleState::default();
|
||||
|
||||
let stream = match format {
|
||||
cpal::SampleFormat::F32 => {
|
||||
dev.build_input_stream(
|
||||
stream_config,
|
||||
move |data: &[f32], _: &_| {
|
||||
let f32_data: Vec<f32> = data.iter().map(|&s| soft_limit(s * gain)).collect();
|
||||
let f32_data = convert_to_stereo_48k(data, in_channels, in_sample_rate, gain, &mut resample_state_f32);
|
||||
if is_mic {
|
||||
lvl_f32.update_mic(&f32_data);
|
||||
} else {
|
||||
|
|
@ -447,7 +553,8 @@ impl AudioCapture {
|
|||
dev.build_input_stream(
|
||||
stream_config,
|
||||
move |data: &[i16], _: &_| {
|
||||
let f32_data: Vec<f32> = data.iter().map(|&s| soft_limit((s as f32 / i16::MAX as f32) * gain)).collect();
|
||||
let converted_f32: Vec<f32> = data.iter().map(|&s| s as f32 / 32768.0).collect();
|
||||
let f32_data = convert_to_stereo_48k(&converted_f32, in_channels, in_sample_rate, gain, &mut resample_state_i16);
|
||||
if is_mic {
|
||||
lvl_i16.update_mic(&f32_data);
|
||||
} else {
|
||||
|
|
@ -462,7 +569,7 @@ impl AudioCapture {
|
|||
_ => return Err("Unsupported audio format".into()),
|
||||
};
|
||||
stream.play()?;
|
||||
Ok((stream, sample_rate, channels))
|
||||
Ok((stream, 48000, 2))
|
||||
};
|
||||
|
||||
match audio_mode {
|
||||
|
|
@ -640,5 +747,48 @@ mod tests {
|
|||
}
|
||||
drop(both_cap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_mono_44100_to_stereo_48000() {
|
||||
let mut state = ResampleState::default();
|
||||
// 4410 mono frames at 44.1kHz = 0.1 seconds
|
||||
let mono_input: Vec<f32> = (0..4410).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
|
||||
let output = convert_to_stereo_48k(&mono_input, 1, 44100, 1.0, &mut state);
|
||||
|
||||
// At 48000 Hz, 0.1s should yield ~4800 stereo frames = ~9600 floats
|
||||
let num_out_frames = output.len() / 2;
|
||||
assert!(
|
||||
(num_out_frames as i32 - 4800).abs() <= 5,
|
||||
"Expected ~4800 frames, got {}",
|
||||
num_out_frames
|
||||
);
|
||||
|
||||
// Ensure every pair has left == right (mono centered)
|
||||
for pair in output.chunks_exact(2) {
|
||||
assert!((pair[0] - pair[1]).abs() < 1e-5, "Mono upmix must be centered: {} != {}", pair[0], pair[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_stereo_48000_passthrough() {
|
||||
let mut state = ResampleState::default();
|
||||
let stereo_input = vec![0.25f32, -0.25f32, 0.5f32, -0.5f32];
|
||||
let output = convert_to_stereo_48k(&stereo_input, 2, 48000, 1.0, &mut state);
|
||||
assert_eq!(output.len(), 4);
|
||||
assert!((output[0] - 0.25).abs() < 1e-5);
|
||||
assert!((output[1] - (-0.25)).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_5_1_surround_downmix() {
|
||||
let mut state = ResampleState::default();
|
||||
// 6 channels: FL, FR, FC, LFE, SL, SR
|
||||
let surround_input = vec![0.2f32, 0.2f32, 0.4f32, 0.0f32, 0.1f32, 0.1f32];
|
||||
let output = convert_to_stereo_48k(&surround_input, 6, 48000, 1.0, &mut state);
|
||||
assert_eq!(output.len(), 2);
|
||||
// Both channels should be equal due to symmetrical inputs
|
||||
assert!((output[0] - output[1]).abs() < 1e-5);
|
||||
assert!(output[0] > 0.2, "Downmixing center/surround should contribute to stereo channel");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -335,6 +335,42 @@ fn pick_folder(current_dir: &str, tx: Sender<String>, is_active: Arc<AtomicBool>
|
|||
});
|
||||
}
|
||||
|
||||
fn spawn_daemon_process() {
|
||||
let mut cmd = if let Ok(mut path) = std::env::current_exe() {
|
||||
path.pop();
|
||||
#[cfg(target_os = "windows")]
|
||||
let primary = path.join("scythe-daemon.exe");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let primary = path.join("scythe-daemon");
|
||||
|
||||
if primary.exists() {
|
||||
std::process::Command::new(primary)
|
||||
} else {
|
||||
#[cfg(target_os = "windows")]
|
||||
{ std::process::Command::new("scythe-daemon.exe") }
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{ std::process::Command::new("scythe-daemon") }
|
||||
}
|
||||
} else {
|
||||
#[cfg(target_os = "windows")]
|
||||
{ std::process::Command::new("scythe-daemon.exe") }
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{ std::process::Command::new("scythe-daemon") }
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
}
|
||||
|
||||
let _ = cmd
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
|
||||
// Asynchronous daemon command dispatcher to avoid blocking the egui render loop
|
||||
fn async_send_command(cmd: Command) {
|
||||
std::thread::spawn(move || {
|
||||
|
|
@ -1266,7 +1302,7 @@ impl ScytheOverlayApp {
|
|||
self.config.autostart = ar;
|
||||
let _ = self.config.save();
|
||||
if ar {
|
||||
let _ = std::process::Command::new("scythe-daemon").spawn();
|
||||
spawn_daemon_process();
|
||||
}
|
||||
self.show_hud_notification(
|
||||
"AUTOSTART REPLAY",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue