first commit
This commit is contained in:
commit
65430188aa
11 changed files with 2819 additions and 0 deletions
1
rust/.gitignore
vendored
Normal file
1
rust/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
1799
rust/Cargo.lock
generated
Normal file
1799
rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
11
rust/Cargo.toml
Normal file
11
rust/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "rust"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
cpal = "0.15"
|
||||
562
rust/src/main.rs
Normal file
562
rust/src/main.rs
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
use std::{
|
||||
fmt::Write as _,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use clap::Parser;
|
||||
use cpal::{
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
Device, Sample, SampleFormat, SampleRate, Stream, StreamConfig,
|
||||
};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "livestream-cc", version, about = "Capture audio (PipeWire/CPAL) -> DFPWM -> WebSocket broadcast")]
|
||||
struct Args {
|
||||
// Substring to match an input device by name. If omitted, uses the default input device.
|
||||
#[arg(long)]
|
||||
device: Option<String>,
|
||||
// Bind address for the WebSocket server.
|
||||
#[arg(long, default_value = "127.0.0.1:8080")]
|
||||
bind: String,
|
||||
// Frame duration in milliseconds (must result in samples divisible by 8).
|
||||
#[arg(long, default_value_t = 20)]
|
||||
frame_ms: u32,
|
||||
// Attempt to use this sample rate. Falls back to the device default if not supported.
|
||||
#[arg(long)]
|
||||
sample_rate: Option<u32>,
|
||||
// List input devices and exit.
|
||||
#[arg(long)]
|
||||
list_devices: bool,
|
||||
// NEW: request stereo (if device has ≥2 channels; mono dup if 1 ch)
|
||||
#[arg(long)] stereo: bool,
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
tx: broadcast::Sender<Vec<u8>>,
|
||||
meta: StreamMeta,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone)]
|
||||
struct StreamMeta {
|
||||
codec: &'static str,
|
||||
const_prec: i32,
|
||||
frame_ms: u32,
|
||||
frame_samples: usize,
|
||||
frame_bytes: usize, // DFPWM bytes per channel
|
||||
sample_rate: u32,
|
||||
channels_source: u16, // input channels from device
|
||||
channels_encoded: u16, // 1 or 2
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
let host = cpal::default_host();
|
||||
|
||||
if args.list_devices {
|
||||
list_input_devices(&host);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick device
|
||||
let device = match pick_input_device(&host, args.device.as_deref()) {
|
||||
Ok(dev) => dev,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to get input device: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
println!("Using input device: {}", device.name().unwrap_or_else(|_| "<unknown>".into()));
|
||||
|
||||
// Pick config
|
||||
let (mut config, sample_format) = match pick_stream_config(&device, args.sample_rate) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to get stream config: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if args.stereo {
|
||||
config.channels = 2;
|
||||
}
|
||||
|
||||
println!(
|
||||
"Device reports: {} Hz, {} channels, format {:?}",
|
||||
config.sample_rate.0, config.channels, sample_format
|
||||
);
|
||||
|
||||
let sr = config.sample_rate.0;
|
||||
let ch = config.channels;
|
||||
let enc_ch = if args.stereo { 2 } else { 1 };
|
||||
|
||||
|
||||
// Determine per-frame sample count (ensure multiple of 8)
|
||||
let mut frame_samples =
|
||||
((sr as u64) * (args.frame_ms as u64) / 1000) as usize;
|
||||
if frame_samples < 8 {
|
||||
frame_samples = 8;
|
||||
}
|
||||
frame_samples -= frame_samples % 8;
|
||||
let frame_bytes = frame_samples / 8;
|
||||
|
||||
let meta = StreamMeta {
|
||||
codec: "dfpwm-1a",
|
||||
const_prec: DfpwmEncoder::CONST_PREC,
|
||||
frame_ms: args.frame_ms,
|
||||
frame_samples,
|
||||
frame_bytes, // per channel!
|
||||
sample_rate: sr,
|
||||
channels_source: ch,
|
||||
channels_encoded: enc_ch,
|
||||
};
|
||||
|
||||
|
||||
println!(
|
||||
"Configured: {} Hz, {}ch (downmix->mono), frame {} ms -> {} samples -> {} dfpwm bytes, CONST_PREC={}",
|
||||
meta.sample_rate,
|
||||
meta.channels_source,
|
||||
meta.frame_ms,
|
||||
meta.frame_samples,
|
||||
meta.frame_bytes,
|
||||
meta.const_prec
|
||||
);
|
||||
|
||||
// Broadcast channel for DFPWM frames
|
||||
let (tx, _rx) = broadcast::channel::<Vec<u8>>(128);
|
||||
let state = Arc::new(AppState { tx: tx.clone(), meta: meta.clone() });
|
||||
|
||||
// Build and start CPAL stream
|
||||
let stream = match build_cpal_stream(
|
||||
&device,
|
||||
&config,
|
||||
sample_format,
|
||||
frame_samples,
|
||||
tx.clone(),
|
||||
enc_ch
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to build input stream: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
stream.play().expect("failed to start input stream");
|
||||
|
||||
// Axum WebSocket server
|
||||
let app = Router::new()
|
||||
.route("/ws", get(ws_handler))
|
||||
.with_state(state);
|
||||
|
||||
println!("WebSocket server listening on ws://{}/ws", args.bind);
|
||||
|
||||
let listener = match tokio::net::TcpListener::bind(&args.bind).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to bind {}: {e}", args.bind);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
eprintln!("Server error: {e}");
|
||||
}
|
||||
|
||||
// Keep the stream alive
|
||||
drop(stream);
|
||||
}
|
||||
|
||||
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| ws_on_upgraded(socket, state))
|
||||
}
|
||||
|
||||
async fn ws_on_upgraded(mut socket: WebSocket, state: Arc<AppState>) {
|
||||
// Send a small textual metadata message first.
|
||||
if let Err(e) = socket
|
||||
.send(Message::Text(build_meta_text(&state.meta)))
|
||||
.await
|
||||
{
|
||||
eprintln!("WS send meta failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rx = state.tx.subscribe();
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(block) => {
|
||||
if socket.send(Message::Binary(block)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
eprintln!("WS receiver lagged by {n} frames, dropping old frames");
|
||||
continue;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_meta_text(m: &StreamMeta) -> String {
|
||||
let mut s = String::new();
|
||||
let _ = write!(
|
||||
s,
|
||||
"{{\"type\":\"meta\",\"codec\":\"{}\",\"const_prec\":{},\"frame_ms\":{},\"frame_samples\":{},\"frame_bytes\":{},\"sample_rate\":{},\"channels_source\":{},\"channels_encoded\":{}}}",
|
||||
m.codec, m.const_prec, m.frame_ms, m.frame_samples, m.frame_bytes,
|
||||
m.sample_rate, m.channels_source, m.channels_encoded
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
fn list_input_devices(host: &cpal::Host) {
|
||||
println!("Input devices:");
|
||||
match host.input_devices() {
|
||||
Ok(devs) => {
|
||||
for (i, d) in devs.enumerate() {
|
||||
let name = d.name().unwrap_or_else(|_| "<unknown>".into());
|
||||
println!(" [{i}] {name}");
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!(" Failed to enumerate input devices: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick an input device, matching by substring if provided; else default input device.
|
||||
fn pick_input_device(host: &cpal::Host, name_substr: Option<&str>) -> Result<Device, String> {
|
||||
if let Some(sub) = name_substr {
|
||||
let sub_l = sub.to_lowercase();
|
||||
let mut found: Option<Device> = None;
|
||||
for d in host.input_devices().map_err(|e| e.to_string())? {
|
||||
let name = d.name().unwrap_or_else(|_| "<unknown>".into());
|
||||
if name.to_lowercase().contains(&sub_l) {
|
||||
found = Some(d);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(d) = found {
|
||||
Ok(d)
|
||||
} else {
|
||||
Err(format!("No input device matching substring: {sub}"))
|
||||
}
|
||||
} else {
|
||||
host.default_input_device().ok_or_else(|| "No default input device".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick a usable stream config, preferring the requested sample rate if possible.
|
||||
fn pick_stream_config(
|
||||
device: &Device,
|
||||
target_sample_rate: Option<u32>,
|
||||
) -> Result<(StreamConfig, SampleFormat), String> {
|
||||
// Try supported configs first so we can request a specific sample rate.
|
||||
if let Ok(mut supported) = device.supported_input_configs() {
|
||||
let mut candidates = Vec::new();
|
||||
for cfg in supported {
|
||||
candidates.push(cfg);
|
||||
}
|
||||
// Try to find exact sample rate match
|
||||
if let Some(sr) = target_sample_rate {
|
||||
// Most devices expose ranges; pick one that contains the sample rate.
|
||||
for supp in &candidates {
|
||||
if supp.min_sample_rate().0 <= sr && sr <= supp.max_sample_rate().0 {
|
||||
let sf = supp.sample_format();
|
||||
let cfg = StreamConfig {
|
||||
channels: supp.channels(),
|
||||
sample_rate: SampleRate(sr),
|
||||
buffer_size: cpal::BufferSize::Default,
|
||||
};
|
||||
return Ok((cfg, sf));
|
||||
}
|
||||
}
|
||||
eprintln!("Target sample rate {sr} not in any supported range; falling back to default input config");
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to device default config
|
||||
let default_cfg = device.default_input_config().map_err(|e| e.to_string())?;
|
||||
let sf = default_cfg.sample_format();
|
||||
Ok((default_cfg.config(), sf))
|
||||
}
|
||||
|
||||
/// Build and return a CPAL input stream that performs:
|
||||
/// - downmix to mono
|
||||
/// - convert to i8 PCM [-128,127]
|
||||
/// - DFPWM encode in frames
|
||||
/// - broadcast frames over a channel
|
||||
fn build_cpal_stream(
|
||||
device: &Device,
|
||||
cfg: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
frame_samples: usize,
|
||||
tx: broadcast::Sender<Vec<u8>>,
|
||||
enc_ch: u16,
|
||||
) -> Result<Stream, String> {
|
||||
match sample_format {
|
||||
SampleFormat::F32 => build_stream_f32(device, cfg, frame_samples, tx),
|
||||
SampleFormat::I16 => build_stream_i16(device, cfg, frame_samples, tx),
|
||||
SampleFormat::U16 => build_stream_u16(device, cfg, frame_samples, tx),
|
||||
SampleFormat::U8 => build_stream_u8(device, cfg, frame_samples, tx, enc_ch),
|
||||
other => Err(format!("Unsupported sample format: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_stream_f32(
|
||||
device: &Device,
|
||||
cfg: &StreamConfig,
|
||||
frame_samples: usize,
|
||||
tx: broadcast::Sender<Vec<u8>>,
|
||||
) -> Result<Stream, String> {
|
||||
let channels = cfg.channels as usize;
|
||||
|
||||
let mut encoder = DfpwmEncoder::new();
|
||||
let mut acc: Vec<i8> = Vec::with_capacity(frame_samples * 2);
|
||||
|
||||
let mut process_buffer = move |data: &[f32]| {
|
||||
// Downmix to mono and convert to i8
|
||||
for frame in data.chunks(channels) {
|
||||
let mut sum = 0.0f32;
|
||||
for &s in frame {
|
||||
sum += s;
|
||||
}
|
||||
let f = sum / channels as f32;
|
||||
let i = f32_to_i8(f);
|
||||
acc.push(i);
|
||||
}
|
||||
|
||||
// Encode full frames
|
||||
while acc.len() >= frame_samples {
|
||||
let frame: Vec<i8> = acc.drain(..frame_samples).collect();
|
||||
let encoded = encoder.encode(&frame);
|
||||
let _ = tx.send(encoded);
|
||||
}
|
||||
};
|
||||
|
||||
let err_fn = |err: cpal::StreamError| {
|
||||
eprintln!("Stream error: {err}");
|
||||
};
|
||||
|
||||
device
|
||||
.build_input_stream(cfg, move |data: &[f32], _| process_buffer(data), err_fn, None)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn build_stream_i16(
|
||||
device: &Device,
|
||||
cfg: &StreamConfig,
|
||||
frame_samples: usize,
|
||||
tx: broadcast::Sender<Vec<u8>>,
|
||||
) -> Result<Stream, String> {
|
||||
let channels = cfg.channels as usize;
|
||||
|
||||
let mut encoder = DfpwmEncoder::new();
|
||||
let mut acc: Vec<i8> = Vec::with_capacity(frame_samples * 2);
|
||||
|
||||
let mut process_buffer = move |data: &[i16]| {
|
||||
for frame in data.chunks(channels) {
|
||||
let mut sum = 0.0f32;
|
||||
for &s in frame {
|
||||
// Map i16 [-32768, 32767] -> f32 [-1.0, 1.0)
|
||||
sum += (s as f32) / 32768.0;
|
||||
}
|
||||
let f = sum / channels as f32;
|
||||
let i = f32_to_i8(f);
|
||||
acc.push(i);
|
||||
}
|
||||
|
||||
while acc.len() >= frame_samples {
|
||||
let frame: Vec<i8> = acc.drain(..frame_samples).collect();
|
||||
let encoded = encoder.encode(&frame);
|
||||
let _ = tx.send(encoded);
|
||||
}
|
||||
};
|
||||
|
||||
let err_fn = |err: cpal::StreamError| {
|
||||
eprintln!("Stream error: {err}");
|
||||
};
|
||||
|
||||
device
|
||||
.build_input_stream(cfg, move |data: &[i16], _| process_buffer(data), err_fn, None)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
fn build_stream_u8(
|
||||
device: &Device,
|
||||
cfg: &StreamConfig,
|
||||
frame_samples: usize,
|
||||
tx: broadcast::Sender<Vec<u8>>,
|
||||
enc_ch: u16, // 1 or 2
|
||||
) -> Result<Stream, String> {
|
||||
let channels = cfg.channels as usize;
|
||||
let mut accL: Vec<i8> = Vec::with_capacity(frame_samples * 2);
|
||||
let mut accR: Vec<i8> = Vec::with_capacity(frame_samples * 2);
|
||||
let mut enc = DfpwmEncoder::new();
|
||||
|
||||
let mut process_buffer = move |data: &[u8]| {
|
||||
for frame in data.chunks(channels.max(1)) {
|
||||
// Convert to f32 [-1,1)
|
||||
let s0 = ((frame.get(0).copied().unwrap_or(128) as f32) - 128.0) / 128.0;
|
||||
let left_i8 = f32_to_i8(s0);
|
||||
|
||||
let right_i8 = if enc_ch == 2 {
|
||||
let s1 = if channels >= 2 {
|
||||
((frame[1] as f32) - 128.0) / 128.0
|
||||
} else {
|
||||
s0 // mono dup to right
|
||||
};
|
||||
f32_to_i8(s1)
|
||||
} else {
|
||||
left_i8 // mono encoder
|
||||
};
|
||||
accL.push(left_i8);
|
||||
if enc_ch == 2 { accR.push(right_i8); }
|
||||
}
|
||||
|
||||
while accL.len() >= frame_samples && (enc_ch == 1 || accR.len() >= frame_samples) {
|
||||
let frameL: Vec<i8> = accL.drain(..frame_samples).collect();
|
||||
|
||||
if enc_ch == 2 {
|
||||
let frameR: Vec<i8> = accR.drain(..frame_samples).collect();
|
||||
|
||||
let L = enc.encode(&frameL);
|
||||
let R = enc.encode(&frameR);
|
||||
|
||||
// LR concatenation: [left][right]
|
||||
let mut packet = Vec::with_capacity(L.len() * 3 + R.len() * 3);
|
||||
packet.extend_from_slice(&L);
|
||||
packet.extend_from_slice(&L);
|
||||
packet.extend_from_slice(&L);
|
||||
|
||||
packet.extend_from_slice(&R);
|
||||
packet.extend_from_slice(&R);
|
||||
packet.extend_from_slice(&R);
|
||||
|
||||
let _ = tx.send(packet);
|
||||
} else {
|
||||
let outL = enc.encode(&frameL);
|
||||
|
||||
let _ = tx.send(outL);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let err_fn = |err: cpal::StreamError| eprintln!("Stream error: {err}");
|
||||
|
||||
device
|
||||
.build_input_stream(cfg, move |data: &[u8], _| process_buffer(data), err_fn, None)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn build_stream_u16(
|
||||
device: &Device,
|
||||
cfg: &StreamConfig,
|
||||
frame_samples: usize,
|
||||
tx: broadcast::Sender<Vec<u8>>,
|
||||
) -> Result<Stream, String> {
|
||||
let channels = cfg.channels as usize;
|
||||
|
||||
let mut encoder = DfpwmEncoder::new();
|
||||
let mut acc: Vec<i8> = Vec::with_capacity(frame_samples * 2);
|
||||
|
||||
let mut process_buffer = move |data: &[u16]| {
|
||||
for frame in data.chunks(channels) {
|
||||
let mut sum = 0.0f32;
|
||||
for &s in frame {
|
||||
// Map u16 [0, 65535] -> f32 [-1.0, 1.0)
|
||||
sum += ((s as f32) - 32768.0) / 32768.0;
|
||||
}
|
||||
let f = sum / channels as f32;
|
||||
let i = f32_to_i8(f);
|
||||
acc.push(i);
|
||||
}
|
||||
|
||||
while acc.len() >= frame_samples {
|
||||
let frame: Vec<i8> = acc.drain(..frame_samples).collect();
|
||||
let encoded = encoder.encode(&frame);
|
||||
let _ = tx.send(encoded);
|
||||
}
|
||||
};
|
||||
|
||||
let err_fn = |err: cpal::StreamError| {
|
||||
eprintln!("Stream error: {err}");
|
||||
};
|
||||
|
||||
device
|
||||
.build_input_stream(cfg, move |data: &[u16], _| process_buffer(data), err_fn, None)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Convert f32 [-1.0, 1.0] to i8 [-128, 127] with clamping and rounding.
|
||||
fn f32_to_i8(x: f32) -> i8 {
|
||||
// Scale so -1.0 -> -128, 1.0 -> 127, then clamp.
|
||||
let v = (x * 128.0).round() as i32;
|
||||
v.clamp(-128, 127) as i8
|
||||
}
|
||||
|
||||
struct DfpwmEncoder {
|
||||
q: i32,
|
||||
s: i32,
|
||||
lt: i32,
|
||||
}
|
||||
|
||||
impl DfpwmEncoder {
|
||||
const CONST_PREC: i32 = 10;
|
||||
|
||||
fn new() -> Self {
|
||||
Self { q: 0, s: 0, lt: -128 }
|
||||
}
|
||||
|
||||
fn encode(&mut self, input: &[i8]) -> Vec<u8> {
|
||||
assert!(
|
||||
input.len() % 8 == 0,
|
||||
"DFPWM encode expects input length multiple of 8"
|
||||
);
|
||||
|
||||
let mut out = Vec::with_capacity(input.len() / 8);
|
||||
|
||||
for chunk in input.chunks(8) {
|
||||
let mut d: u8 = 0;
|
||||
for (bit, &v_i8) in chunk.iter().enumerate() {
|
||||
let v = v_i8 as i32;
|
||||
let t = if v < self.q || v == -128 { -128 } else { 127 };
|
||||
|
||||
if t > 0 {
|
||||
d |= 1 << bit;
|
||||
}
|
||||
|
||||
let mut nq = self.q + ((self.s * (t - self.q) + (1 << (Self::CONST_PREC - 1))) >> Self::CONST_PREC);
|
||||
if nq == self.q && nq != t {
|
||||
nq += if t == 127 { 1 } else { -1 };
|
||||
}
|
||||
self.q = nq;
|
||||
|
||||
let st = if t != self.lt { 0 } else { (1 << Self::CONST_PREC) - 1 };
|
||||
let mut ns = self.s;
|
||||
if ns != st {
|
||||
ns += if st != 0 { 1 } else { -1 };
|
||||
}
|
||||
if Self::CONST_PREC > 8 {
|
||||
let min = 1 + (1 << (Self::CONST_PREC - 8));
|
||||
if ns < min { ns = min; }
|
||||
}
|
||||
self.s = ns;
|
||||
self.lt = t;
|
||||
}
|
||||
out.push(d);
|
||||
}
|
||||
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue