Initial commit

This commit is contained in:
Joey Hines 2025-08-16 10:54:09 -06:00
commit 22ac4dbc2b
Signed by: joeyahines
GPG Key ID: 38BA6F25C94C9382
11 changed files with 1443 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

8
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

14
Cargo.toml Normal file
View 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
View 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
View 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
View 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(())
}
}