Initial commit
This commit is contained in:
commit
22ac4dbc2b
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
8
.idea/misc.xml
generated
Normal file
8
.idea/misc.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CMakeSettings">
|
||||||
|
<configurations>
|
||||||
|
<configuration PROFILE_NAME="Debug" CONFIG_NAME="Debug" ENABLED="true" />
|
||||||
|
</configurations>
|
||||||
|
</component>
|
||||||
|
</project>
|
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/tap.iml" filepath="$PROJECT_DIR$/.idea/tap.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
11
.idea/tap.iml
generated
Normal file
11
.idea/tap.iml
generated
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="EMPTY_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
1158
Cargo.lock
generated
Normal file
1158
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
Normal file
14
Cargo.toml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "tap"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
cpal = "0.16.0"
|
||||||
|
clap = { version = "4.5.43", features = ["derive"] }
|
||||||
|
anyhow = "1.0.98"
|
||||||
|
log = "0.4.27"
|
||||||
|
env_logger = "0.11.8"
|
||||||
|
audiopus = "0.3.0-rc.0"
|
||||||
|
thiserror = "2.0.12"
|
||||||
|
circular-buffer = "1.1.0"
|
133
src/main.rs
Normal file
133
src/main.rs
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
mod sound_buffer;
|
||||||
|
mod udp_connection;
|
||||||
|
|
||||||
|
use crate::sound_buffer::SoundBuffer;
|
||||||
|
use crate::udp_connection::OpusUdpConnection;
|
||||||
|
use audiopus::coder::Encoder;
|
||||||
|
use audiopus::{Application, Channels, SampleRate};
|
||||||
|
use clap::Parser;
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use cpal::{FromSample, Sample, StreamConfig};
|
||||||
|
use log::{LevelFilter, info};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
pub const SAMPLE_RATE: SampleRate = SampleRate::Hz48000;
|
||||||
|
pub const SAMPLE_RATE_RAW: usize = 48_000;
|
||||||
|
pub const AUDIO_FRAME_RATE: usize = 50;
|
||||||
|
pub const MONO_FRAME_SIZE: usize = SAMPLE_RATE_RAW / AUDIO_FRAME_RATE;
|
||||||
|
pub const STEREO_FRAME_SIZE: usize = 2 * MONO_FRAME_SIZE;
|
||||||
|
pub const CIRC_BUFFER_SIZE: usize = STEREO_FRAME_SIZE * 4;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(version, about = "Tap sounds from a audio device and send them over a UDP socket", long_about = None)]
|
||||||
|
struct Opt {
|
||||||
|
/// The audio device to use
|
||||||
|
#[arg(short, long, default_value_t = String::from("default"))]
|
||||||
|
device: String,
|
||||||
|
/// Src UDP socket addr to send data from
|
||||||
|
src: SocketAddr,
|
||||||
|
/// UDP socket addr to connect to
|
||||||
|
dest: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), anyhow::Error> {
|
||||||
|
env_logger::builder()
|
||||||
|
.filter_level(LevelFilter::Info)
|
||||||
|
.parse_default_env()
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let opt = Opt::parse();
|
||||||
|
let host = cpal::default_host();
|
||||||
|
|
||||||
|
// Set up the input device and stream with the default input config.
|
||||||
|
let device = if opt.device == "default" {
|
||||||
|
host.default_input_device()
|
||||||
|
} else {
|
||||||
|
host.input_devices()?
|
||||||
|
.find(|x| x.name().map(|y| y == opt.device).unwrap_or(false))
|
||||||
|
}
|
||||||
|
.expect("failed to find input device");
|
||||||
|
|
||||||
|
info!("Input device: {}", device.name()?);
|
||||||
|
|
||||||
|
let config = device
|
||||||
|
.default_input_config()
|
||||||
|
.expect("Failed to get default input config");
|
||||||
|
|
||||||
|
let mut stream_config = StreamConfig::from(config.clone());
|
||||||
|
|
||||||
|
stream_config.sample_rate = cpal::SampleRate(SAMPLE_RATE_RAW as u32);
|
||||||
|
|
||||||
|
let udp_connection = OpusUdpConnection::new(opt.src, opt.dest)?;
|
||||||
|
let encoder = Encoder::new(SAMPLE_RATE, Channels::Stereo, Application::Audio)?;
|
||||||
|
|
||||||
|
let sound_buffer = Arc::new(Mutex::new(SoundBuffer::new(
|
||||||
|
STEREO_FRAME_SIZE,
|
||||||
|
encoder,
|
||||||
|
udp_connection,
|
||||||
|
)));
|
||||||
|
|
||||||
|
// A flag to indicate that recording is in progress.
|
||||||
|
println!("Begin recording...");
|
||||||
|
|
||||||
|
let err_fn = move |err| {
|
||||||
|
eprintln!("an error occurred on stream: {err}");
|
||||||
|
};
|
||||||
|
|
||||||
|
let sound_buffer2 = sound_buffer.clone();
|
||||||
|
let stream = match config.sample_format() {
|
||||||
|
cpal::SampleFormat::I8 => device.build_input_stream(
|
||||||
|
&stream_config,
|
||||||
|
move |data, _: &_| write_input_data::<CIRC_BUFFER_SIZE, i8>(data, &sound_buffer2),
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)?,
|
||||||
|
cpal::SampleFormat::I16 => device.build_input_stream(
|
||||||
|
&stream_config,
|
||||||
|
move |data, _: &_| write_input_data::<CIRC_BUFFER_SIZE, i16>(data, &sound_buffer2),
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)?,
|
||||||
|
cpal::SampleFormat::I32 => device.build_input_stream(
|
||||||
|
&stream_config,
|
||||||
|
move |data, _: &_| write_input_data::<CIRC_BUFFER_SIZE, i32>(data, &sound_buffer2),
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)?,
|
||||||
|
cpal::SampleFormat::F32 => device.build_input_stream(
|
||||||
|
&stream_config,
|
||||||
|
move |data, _: &_| write_input_data::<CIRC_BUFFER_SIZE, f32>(data, &sound_buffer2),
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)?,
|
||||||
|
sample_format => {
|
||||||
|
return Err(anyhow::Error::msg(format!(
|
||||||
|
"Unsupported sample format '{sample_format}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
stream.play()?;
|
||||||
|
|
||||||
|
// Let recording go for roughly three seconds.
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||||
|
drop(stream);
|
||||||
|
drop(sound_buffer);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_input_data<const N: usize, T>(
|
||||||
|
input: &[T],
|
||||||
|
ctx: &Arc<Mutex<SoundBuffer<N, OpusUdpConnection>>>,
|
||||||
|
) where
|
||||||
|
T: Sample,
|
||||||
|
f32: FromSample<T>,
|
||||||
|
{
|
||||||
|
if let Ok(mut ctx) = ctx.try_lock() {
|
||||||
|
let sample: Vec<f32> = input.iter().map(|s| f32::from_sample(*s)).collect();
|
||||||
|
|
||||||
|
ctx.write_data(&sample).unwrap();
|
||||||
|
}
|
||||||
|
}
|
69
src/sound_buffer.rs
Normal file
69
src/sound_buffer.rs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
use audiopus::coder::Encoder;
|
||||||
|
use circular_buffer::CircularBuffer;
|
||||||
|
use std::io::Write;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
#[allow(clippy::enum_variant_names)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Buffer too small to handle error")]
|
||||||
|
BufferTooSmall,
|
||||||
|
#[error("IO error on offload: {0}")]
|
||||||
|
IoError(#[from] std::io::Error),
|
||||||
|
#[error("Encode error: {0}")]
|
||||||
|
EncodeError(#[from] audiopus::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SoundBuffer<const N: usize, T: Write> {
|
||||||
|
circular_buffer: CircularBuffer<N, f32>,
|
||||||
|
frame_size: usize,
|
||||||
|
encoder: Encoder,
|
||||||
|
offload: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const N: usize, T: Write> SoundBuffer<N, T> {
|
||||||
|
pub fn new(frame_size: usize, encoder: Encoder, offload: T) -> Self {
|
||||||
|
Self {
|
||||||
|
circular_buffer: CircularBuffer::new(),
|
||||||
|
frame_size,
|
||||||
|
encoder,
|
||||||
|
offload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(&mut self, output_buffer: &mut [u8]) -> Result<usize, Error> {
|
||||||
|
let encode_buffer: Vec<f32> = self.circular_buffer.drain(0..self.frame_size).collect();
|
||||||
|
Ok(self.encoder.encode_float(&encode_buffer, output_buffer)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offload_buffer_data(&mut self) -> Result<(), Error> {
|
||||||
|
let mut output_buffer = vec![0; 32 * 1024];
|
||||||
|
let size = self.encode(&mut output_buffer)?;
|
||||||
|
self.offload.write_all(&output_buffer[0..size])?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_data(&mut self, buf: &[f32]) -> Result<usize, Error> {
|
||||||
|
if buf.len() > N * 4 {
|
||||||
|
return Err(Error::BufferTooSmall);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.circular_buffer.extend_from_slice(buf);
|
||||||
|
|
||||||
|
if self.circular_buffer.len() > self.frame_size {
|
||||||
|
self.offload_buffer_data()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(buf.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const N: usize, T: Write> Drop for SoundBuffer<N, T> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.circular_buffer.len() > N {
|
||||||
|
let _ = self.offload_buffer_data().is_ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
27
src/udp_connection.rs
Normal file
27
src/udp_connection.rs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
use std::io::Write;
|
||||||
|
use std::net::{SocketAddr, UdpSocket};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct OpusUdpConnection {
|
||||||
|
udp_socket: UdpSocket,
|
||||||
|
dest: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpusUdpConnection {
|
||||||
|
pub fn new(src: SocketAddr, dest: SocketAddr) -> Result<Self, std::io::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
udp_socket: UdpSocket::bind(src)?,
|
||||||
|
dest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for OpusUdpConnection {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||||
|
self.udp_socket.send_to(buf, self.dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user