diff --git a/.gitignore b/.gitignore index 3069603..69322a5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ # Recordings recordings/ *.wav +*.flac +*.mp3 # Rust backup files *.rs.bk diff --git a/Cargo.lock b/Cargo.lock index 4f5a5fd..3587b29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,8 +73,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "tuxrecord2" -version = "1.0.0" +version = "1.1.0" dependencies = [ "alsa", "libc", + "pkg-config", ] diff --git a/Cargo.toml b/Cargo.toml index d04424e..0bca219 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,16 @@ [package] name = "tuxrecord2" -version = "1.0.0" +version = "1.1.0" edition = "2021" rust-version = "1.77" -description = "Record audio to a WAV file on Linux using ALSA" +description = "Record audio to WAV, FLAC or MP3 on Linux using ALSA" authors = ["Johannes Findeisen "] license = "Apache-2.0" readme = "README.md" repository = "https://xw3.org/hanez/tuxrecord2" -keywords = ["audio", "alsa", "wav", "recording"] +keywords = ["audio", "alsa", "wav", "flac", "mp3", "recording"] categories = ["multimedia::audio", "command-line-utilities"] +build = "build.rs" [[bin]] name = "tuxrecord2" @@ -19,5 +20,8 @@ path = "src/main.rs" alsa = "0.12" libc = "0.2" +[build-dependencies] +pkg-config = "0.3" + [profile.release] opt-level = 3 diff --git a/README.md b/README.md index 49025c6..fe04031 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # tuxrecord2 -Record audio to a WAV file on **Linux** using **ALSA** as the recording source. +Record audio to WAV, FLAC or MP3 files on **Linux** using **ALSA** as the recording source. `tuxrecord2` is the **Rust** port of `tuxrecord`, the Linux port of the former macOS-only **macrecord** tool. The original port was written in C99; this rewrite keeps the same command-line interface and behaviour but is implemented in Rust on top of the safe [alsa](https://crates.io/crates/alsa) bindings and a small -amount of `libc` for terminal, polling and signal handling. It builds with -**Cargo**. +amount of `libc` for terminal, polling and signal handling. FLAC and MP3 output +is encoded in real time through small FFI shims over the system `libFLAC` and +`libmp3lame` libraries. It builds with **Cargo**. macrecord was created by my good friend Armin. You can find his code here: [https://codeberg.org/armin/macrecord](https://codeberg.org/armin/macrecord) @@ -17,6 +18,12 @@ macrecord was created by my good friend Armin. You can find his code here: - ALSA development headers and `pkg-config` (`libasound2-dev` on Debian/Ubuntu, `alsa-lib-devel` on Fedora, `alsa-lib` on Arch) +- FLAC and LAME (MP3) development headers and `pkg-config`, used for the + bundled real-time encoders + (`libflac-dev` and `libmp3lame-dev` on Debian/Ubuntu, `flac-devel` and + `lame-devel` on Fedora, `flac` and `lame` on Arch). They are only needed + to build; at run time the shared `libFLAC` / `libmp3lame` libraries are + used. ## Usage @@ -36,6 +43,10 @@ tuxrecord2 -p myrec # Use a specific ALSA capture device tuxrecord2 -D hw:1,0 +# Encode to FLAC (lossless) or MP3 (lossy) in real time +tuxrecord2 -f flac +tuxrecord2 --format mp3 + # Record from PipeWire (the PipeWire sound server) tuxrecord2 -D pipewire ``` @@ -48,6 +59,7 @@ tuxrecord2 -D pipewire | `-d ` | Stop automatically after `` seconds | | `-p ` | Filename prefix instead of `tuxrecord2` | | `-D ` | ALSA capture device (default: `default`; `pipewire` on PipeWire systems) | +| `-f, --format ` | Output format: `wav`, `flac` or `mp3` (default: `wav`) | | `-h, --help` | Show help | Run `arecord -l` to list available ALSA capture devices. On systems with @@ -59,9 +71,26 @@ or `wpctl status` (see "Recording from PipeWire" below). - **q** - Stop recording and save the file - **Ctrl+C** - Stop recording and save the file -The file is written as `__.wav` (default prefix -`tuxrecord2`). The VU meter shows the input level of the left and right channels -in real time when stderr is a terminal. +The file is written as `__.` where `` is `wav`, +`flac` or `mp3` depending on `-f/--format` (default prefix `tuxrecord2`). The VU +meter shows the input level of the left and right channels in real time when +stderr is a terminal. + +## Output formats + +Audio is captured once and handed to the selected encoder as it arrives, so +FLAC and MP3 are produced **in real time** — no intermediate WAV file is +written and no second conversion pass runs afterwards. + +- **wav** (default) — uncompressed 16-bit little-endian PCM in a RIFF/WAVE + container. +- **flac** — lossless compression via libFLAC (streaming, default + compression level 5). +- **mp3** — lossy compression via LAME at a constant 192 kbit/s, quality 2. + +`wav` and `flac` preserve the captured stream exactly; `mp3` is lossy. The +classic 4 GiB RIFF limit applies to `wav` only (the program stops cleanly +before exceeding it); `flac` and `mp3` have no such limit. ## Recording system audio @@ -132,9 +161,9 @@ tuxrecord2 -D pipewire ``` The `.monitor` source carries exactly what the sink is playing, so anything -your players output ends up in the WAV file. Every capture follows the same -`__.wav` naming and can be stopped with `q` or **Ctrl+C**. -The default setting can be restored later with +your players output ends up in the recorded file. Every capture follows the +same `__.` naming and can be stopped with `q` or +**Ctrl+C**. The default setting can be restored later with `pactl set-default-source `. ### Troubleshooting diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..a3264af --- /dev/null +++ b/build.rs @@ -0,0 +1,25 @@ +/* + * Build script for tuxrecord2. + * + * The FLAC and MP3 encoders are provided by the system libFLAC and + * libmp3lame libraries, which are used through the small FFI shims in + * src/flac.rs and src/mp3.rs. Locate them with pkg-config (which also + * emits the correct link flags and library search paths) so builds work + * on distributions where the headers/libraries live in non-standard + * locations. + */ + +fn main() { + for lib in ["flac", "lame"] { + pkg_config::Config::new().probe(lib).unwrap_or_else(|err| { + panic!( + "could not find the '{lib}' library with pkg-config: {err}\n\ + Install the development package (e.g. 'libflac-dev' and \ + 'libmp3lame-dev' on Debian/Ubuntu, 'flac-devel' and \ + 'lame-devel' on Fedora, 'flac' and 'lame' on Arch)." + ); + }); + } + + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/src/encoder.rs b/src/encoder.rs new file mode 100644 index 0000000..469aeac --- /dev/null +++ b/src/encoder.rs @@ -0,0 +1,114 @@ +/* + * This file is part of tuxrecord2. + * https://xw3.org/hanez/tuxrecord2 + * + * Output-format abstraction: selects the WAV writer or the real-time FLAC + * / MP3 encoders and exposes a single streaming interface to the capture + * loop. + * + * Copyright 2026 Johannes Findeisen + * Licensed under the terms of the Apache-2.0 license. + */ + +use std::path::Path; + +use crate::flac::FlacEncoder; +use crate::mp3::Mp3Encoder; +use crate::wav::WavWriter; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Format { + Wav, + Flac, + Mp3, +} + +impl Format { + /// File extension (without the dot) used for this format. + pub fn extension(self) -> &'static str { + match self { + Format::Wav => "wav", + Format::Flac => "flac", + Format::Mp3 => "mp3", + } + } + + /// Parse a user-supplied format name (case-insensitive). + pub fn parse(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "wav" => Some(Format::Wav), + "flac" => Some(Format::Flac), + "mp3" => Some(Format::Mp3), + _ => None, + } + } + + /// Human-readable list of the supported format names. + pub fn names() -> &'static str { + "wav, flac, mp3" + } +} + +enum Inner { + Wav(WavWriter), + Flac(FlacEncoder), + Mp3(Mp3Encoder), +} + +/* + * Streaming output encoder. + * + * The capture loop hands interleaved S16_LE buffers to write_samples() as + * they are read from ALSA. finish() must be called once at the end to + * patch headers / flush the codec; it is also attempted from Drop so a + * partial recording is still left in a valid state. + */ +pub struct Encoder { + inner: Inner, +} + +impl Encoder { + pub fn create( + format: Format, + path: &Path, + rate: u32, + channels: u32, + bits: u32, + ) -> Result { + let inner = match format { + Format::Wav => Inner::Wav( + WavWriter::create(path, rate, channels, bits) + .map_err(|e| format!("cannot open output file: {e}"))?, + ), + Format::Flac => Inner::Flac(FlacEncoder::create(path, rate, channels, bits)?), + Format::Mp3 => Inner::Mp3(Mp3Encoder::create(path, rate, channels)?), + }; + Ok(Encoder { inner }) + } + + /// Upper bound on the number of PCM bytes to accept, if any (WAV only). + pub fn max_data_bytes(&self) -> Option { + match self.inner { + Inner::Wav(_) => Some(crate::wav::WAV_DATA_MAX), + Inner::Flac(_) | Inner::Mp3(_) => None, + } + } + + /// Encode one buffer of interleaved S16_LE samples. + pub fn write_samples(&mut self, samples: &[i16]) -> Result<(), String> { + match &mut self.inner { + Inner::Wav(w) => w.write_samples(samples).map_err(|e| e.to_string()), + Inner::Flac(f) => f.write_samples(samples), + Inner::Mp3(m) => m.write_samples(samples), + } + } + + /// Finalize the stream (patch the WAV header / flush the codec). + pub fn finish(&mut self) -> Result<(), String> { + match &mut self.inner { + Inner::Wav(w) => w.finish().map_err(|e| e.to_string()), + Inner::Flac(f) => f.finish(), + Inner::Mp3(m) => m.finish(), + } + } +} diff --git a/src/flac.rs b/src/flac.rs new file mode 100644 index 0000000..25bf702 --- /dev/null +++ b/src/flac.rs @@ -0,0 +1,168 @@ +/* + * This file is part of tuxrecord2. + * https://xw3.org/hanez/tuxrecord2 + * + * Real-time FLAC encoding through a minimal FFI shim over the system + * libFLAC stream encoder. Captured PCM buffers are fed directly to + * libFLAC as they arrive, so no intermediate WAV file is needed. + * + * Copyright 2026 Johannes Findeisen + * Licensed under the terms of the Apache-2.0 license. + */ + +use std::ffi::CString; +use std::os::raw::{c_char, c_int, c_uint, c_void}; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +/// Compression level passed to libFLAC (0 = fastest, 8 = best). +const COMPRESSION_LEVEL: u32 = 5; + +/// Opaque `FLAC__StreamEncoder`. +#[repr(C)] +struct StreamEncoder { + _private: [u8; 0], +} + +// Safety: the encoder handle is only ever used from the thread that owns +// it; tuxrecord2 is single-threaded. +unsafe impl Send for StreamEncoder {} + +extern "C" { + fn FLAC__stream_encoder_new() -> *mut StreamEncoder; + fn FLAC__stream_encoder_delete(encoder: *mut StreamEncoder); + fn FLAC__stream_encoder_set_compression_level( + encoder: *mut StreamEncoder, + value: c_uint, + ) -> c_int; + fn FLAC__stream_encoder_set_channels(encoder: *mut StreamEncoder, value: c_uint) -> c_int; + fn FLAC__stream_encoder_set_bits_per_sample( + encoder: *mut StreamEncoder, + value: c_uint, + ) -> c_int; + fn FLAC__stream_encoder_set_sample_rate(encoder: *mut StreamEncoder, value: c_uint) -> c_int; + fn FLAC__stream_encoder_init_file( + encoder: *mut StreamEncoder, + filename: *const c_char, + progress_callback: *const c_void, + client_data: *mut c_void, + ) -> c_int; + fn FLAC__stream_encoder_process_interleaved( + encoder: *mut StreamEncoder, + buffer: *const i32, + samples: c_uint, + ) -> c_int; + fn FLAC__stream_encoder_finish(encoder: *mut StreamEncoder) -> c_int; +} + +/* + * Real-time FLAC encoder. + * + * Owns a libFLAC stream encoder writing to the given path. Interleaved + * S16_LE buffers are expanded into the i32 layout libFLAC expects using a + * reusable conversion buffer, then handed to the encoder; the final + * STREAMINFO block is written by finish(). + */ +pub struct FlacEncoder { + encoder: *mut StreamEncoder, + channels: usize, + /// Reusable i32 conversion buffer (the processing buffer). + buffer: Vec, + finished: bool, +} + +impl FlacEncoder { + pub fn create(path: &Path, rate: u32, channels: u32, bits: u32) -> Result { + let filename = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| "invalid output path".to_string())?; + + // SAFETY: all libFLAC calls are made on a freshly allocated encoder + // and the return values checked before use. + unsafe { + let encoder = FLAC__stream_encoder_new(); + if encoder.is_null() { + return Err("could not allocate the FLAC encoder".to_string()); + } + + let ok = FLAC__stream_encoder_set_compression_level(encoder, COMPRESSION_LEVEL) != 0 + && FLAC__stream_encoder_set_channels(encoder, channels) != 0 + && FLAC__stream_encoder_set_bits_per_sample(encoder, bits) != 0 + && FLAC__stream_encoder_set_sample_rate(encoder, rate) != 0; + if !ok { + FLAC__stream_encoder_delete(encoder); + return Err("could not configure the FLAC encoder".to_string()); + } + + let status = FLAC__stream_encoder_init_file( + encoder, + filename.as_ptr(), + std::ptr::null(), + std::ptr::null_mut(), + ); + if status != 0 { + FLAC__stream_encoder_delete(encoder); + return Err(format!( + "could not open the FLAC output file (status {status})" + )); + } + + Ok(FlacEncoder { + encoder, + channels: channels as usize, + buffer: Vec::new(), + finished: false, + }) + } + } + + /// Encode one buffer of interleaved S16_LE samples. + pub fn write_samples(&mut self, samples: &[i16]) -> Result<(), String> { + if self.finished { + return Err("FLAC encoder already finished".to_string()); + } + if self.channels == 0 || samples.is_empty() { + return Ok(()); + } + + self.buffer.clear(); + self.buffer.extend(samples.iter().map(|&s| i32::from(s))); + + // libFLAC counts frames (samples per channel), not interleaved items. + let frames = (self.buffer.len() / self.channels) as c_uint; + // SAFETY: buffer holds exactly frames * channels valid i32 samples. + let ok = unsafe { + FLAC__stream_encoder_process_interleaved(self.encoder, self.buffer.as_ptr(), frames) + }; + if ok == 0 { + return Err("FLAC encoding failed".to_string()); + } + Ok(()) + } + + /// Flush the remaining frames and write the final STREAMINFO block. + pub fn finish(&mut self) -> Result<(), String> { + if self.finished { + return Ok(()); + } + self.finished = true; + // SAFETY: self.encoder is a live encoder initialised in create(). + let ok = unsafe { FLAC__stream_encoder_finish(self.encoder) }; + if ok == 0 { + return Err("could not finalize the FLAC stream".to_string()); + } + Ok(()) + } +} + +impl Drop for FlacEncoder { + fn drop(&mut self) { + if !self.finished { + let _ = self.finish(); + } + // SAFETY: the encoder is deleted exactly once; its pointer is never + // used again afterwards. + unsafe { + FLAC__stream_encoder_delete(self.encoder); + } + } +} diff --git a/src/main.rs b/src/main.rs index 07753e3..d1f9e63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ * This file is part of tuxrecord2. * https://xw3.org/hanez/tuxrecord2 * - * tuxrecord2 - record audio to a WAV file using ALSA + * tuxrecord2 - record audio to WAV, FLAC or MP3 using ALSA * * tuxrecord2 is the Rust port of "tuxrecord", the Linux port of the former * macOS-only "macrecord" tool. The original tuxrecord was rewritten in C99 @@ -17,17 +17,22 @@ * https://opensource.org/license/apache-2-0 */ -use std::fs::{File, OpenOptions}; -use std::io::{self, Seek, SeekFrom, Write}; -use std::os::unix::fs::OpenOptionsExt; +mod encoder; +mod flac; +mod mp3; +mod wav; + +use std::io::{self, Write}; use std::path::Path; use std::process::ExitCode; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; -use alsa::pcm::{Access, Format, HwParams, PCM}; +use alsa::pcm::{Access, Format as AlsaFormat, HwParams, PCM}; use alsa::{Direction, PollDescriptors, ValueOr}; +use crate::encoder::{Encoder, Format}; + const DEFAULT_RATE: u32 = 48_000; const DEFAULT_CHANNELS: u32 = 2; const DEFAULT_BITS: u32 = 16; @@ -38,19 +43,16 @@ const VU_WIDTH: usize = 48; const VU_MIN_DB: f64 = -60.0; /// Redraw the VU meter at most every 50 ms. const VU_INTERVAL: Duration = Duration::from_millis(50); -const HEADER_LENGTH: usize = 44; -/// Classic 32-bit RIFF limit. -const WAV_DATA_MAX: u64 = u32::MAX as u64 - 36; /* - * Raw samples are handed to the WAV writer as bytes, relying on the host - * being little-endian: we negotiate S16_LE with ALSA, so the in-memory - * representation is already the byte order the RIFF/WAV container expects. - * Refuse to build on big-endian hosts instead of silently producing a - * corrupted file. + * Samples are captured as S16_LE. The WAV writer hands the in-memory + * representation to the file as raw bytes, and the FLAC/MP3 encoders + * interpret the samples as native i16, so both rely on the host being + * little-endian. Refuse to build on big-endian hosts instead of silently + * producing a corrupted file. */ #[cfg(not(target_endian = "little"))] -compile_error!("tuxrecord2 assumes a little-endian host for raw S16_LE output"); +compile_error!("tuxrecord2 assumes a little-endian host for raw S16_LE audio"); /// Set to true by the signal handler to request a clean stop. static STOP: AtomicBool = AtomicBool::new(false); @@ -61,8 +63,8 @@ static STOP: AtomicBool = AtomicBool::new(false); * This is an async-signal-safe handler: it only stores to the atomic STOP * flag, which the recording loop checks on every iteration. Because no I/O * or locking is done here, Ctrl+C (or a SIGTERM) does not terminate the - * process abruptly; instead the loop exits normally, the WAV header is - * patched, the file is closed and the terminal restored. + * process abruptly; instead the loop exits normally, the output is + * finalized, the file is closed and the terminal restored. */ extern "C" fn handle_signal(_sig: libc::c_int) { STOP.store(true, Ordering::SeqCst); @@ -76,19 +78,20 @@ extern "C" fn handle_signal(_sig: libc::c_int) { */ fn usage() { print!( - "tuxrecord2 - record audio to a WAV file using ALSA\n\ + "tuxrecord2 - record audio to WAV, FLAC or MP3 using ALSA\n\ \n\ USAGE\n\ \x20 tuxrecord2 [options]\n\ \n\ OPTIONS\n\ - \x20 -o Output directory (default: current directory)\n\ - \x20 -d Stop automatically after seconds (default: until q or Ctrl+C)\n\ - \x20 -p Filename prefix instead of \"{prefix}\" (default: {prefix})\n\ - \x20 -D ALSA capture device (default: \"{device}\")\n\ - \x20 -h, --help Show this help\n\ + \x20 -o Output directory (default: current directory)\n\ + \x20 -d Stop automatically after seconds (default: until q or Ctrl+C)\n\ + \x20 -p Filename prefix instead of \"{prefix}\" (default: {prefix})\n\ + \x20 -D ALSA capture device (default: \"{device}\")\n\ + \x20 -f, --format F Output format: {formats} (default: {format})\n\ + \x20 -h, --help Show this help\n\ \n\ - The file is written as __.wav\n\ + The file is written as __. where is wav, flac, or mp3.\n\ \n\ By default the ALSA capture device \"default\" is used, which is usually\n\ the microphone. To record system audio (everything playing on the\n\ @@ -101,6 +104,8 @@ fn usage() { \x20 tuxrecord2 -D pipewire (system audio, uses the sink monitor)\n", prefix = DEFAULT_PREFIX, device = DEFAULT_DEVICE, + format = Format::Wav.extension(), + formats = Format::names(), ); let _ = io::stdout().flush(); } @@ -219,69 +224,6 @@ fn draw_meter(db_l: f64, db_r: f64, first: bool) { eprintln!("{l}\n{r}"); } -/* ------------------------------------------------------------------------ */ -/* WAV file writer */ -/* ------------------------------------------------------------------------ */ - -/* - * Create and open a WAV file, writing a 44-byte PCM header. - * - * Creates (or truncates) the file with mode 0644 and writes the RIFF / - * WAVE / fmt / data chunk layout for the given rate, channel count and - * bit depth. The RIFF chunk size and data chunk size fields are left as - * zero placeholders and patched later by wav_finalize() once the exact - * amount of audio data is known. - */ -fn wav_open(path: &Path, rate: u32, channels: u32, bits: u32) -> io::Result { - let byte_rate = rate * channels * bits / 8; - let mut hdr = [0u8; HEADER_LENGTH]; - - hdr[0..4].copy_from_slice(b"RIFF"); - hdr[4..8].copy_from_slice(&0u32.to_le_bytes()); // RIFF chunk size - hdr[8..12].copy_from_slice(b"WAVE"); - hdr[12..16].copy_from_slice(b"fmt "); - hdr[16..20].copy_from_slice(&16u32.to_le_bytes()); // fmt chunk size - hdr[20..22].copy_from_slice(&1u16.to_le_bytes()); // PCM - hdr[22..24].copy_from_slice(&(channels as u16).to_le_bytes()); - hdr[24..28].copy_from_slice(&rate.to_le_bytes()); - hdr[28..32].copy_from_slice(&byte_rate.to_le_bytes()); - hdr[32..34].copy_from_slice(&((channels * bits / 8) as u16).to_le_bytes()); - hdr[34..36].copy_from_slice(&(bits as u16).to_le_bytes()); - hdr[36..40].copy_from_slice(b"data"); - hdr[40..44].copy_from_slice(&0u32.to_le_bytes()); // data chunk size - - let mut file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o644) - .open(path)?; - file.write_all(&hdr)?; - Ok(file) -} - -/* - * Patch the header chunk sizes and flush the WAV file. - * - * Seeks back to the beginning of the file and writes the real RIFF chunk - * size (header + data - 8) at offset 4 and the data chunk size (the - * number of audio bytes) at offset 40, then fsyncs. Called once after the - * recording loop with the total bytes accumulated. - */ -fn wav_finalize(file: &mut File, data_bytes: u64) -> io::Result<()> { - file.seek(SeekFrom::Start(4))?; - let riff_size = (HEADER_LENGTH as u32) - .wrapping_add(data_bytes as u32) - .wrapping_sub(8); - file.write_all(&riff_size.to_le_bytes())?; - - file.seek(SeekFrom::Start(40))?; - file.write_all(&(data_bytes as u32).to_le_bytes())?; - - file.sync_all()?; - Ok(()) -} - /* ------------------------------------------------------------------------ */ /* ALSA capture */ /* ------------------------------------------------------------------------ */ @@ -298,7 +240,7 @@ fn config_fail(device: &str, err: &alsa::Error) { * access, a channel count/sample rate "near" the requested values, and * commits the configuration. The actually negotiated rate, channel * count and period size are read back so the caller can allocate sane - * buffers and record a correct WAV header even when the device adjusted + * buffers and describe the stream correctly even when the device adjusted * the values. * * On failure a descriptive error is printed to stderr (including a hint @@ -327,7 +269,7 @@ fn open_capture(device: &str, rate_req: u32, channels_req: u32) -> Option<(PCM, return None; } - if let Err(_err) = hw.set_format(Format::s16()) { + if let Err(_err) = hw.set_format(AlsaFormat::s16()) { eprintln!( "Error: device '{device}' does not support 16-bit signed PCM; try 'plug:{device}'" ); @@ -481,17 +423,17 @@ fn timestamp() -> String { /* ------------------------------------------------------------------------ */ /* - * Program entry point: parse options, record audio, write a WAV file. + * Program entry point: parse options, record audio, encode the output. * * Parses the command line (-o dir, -d seconds, -p prefix, -D device, - * -h/--help), validates the output directory, builds the output filename - * "/__.wav" and opens the ALSA capture device - * and the WAV file. It then installs SIGINT/SIGTERM handlers and runs a - * poll() loop that reads captured PCM frames from ALSA and appends them - * to the file, redrawing the VU meter on a tty stderr at most every - * VU_INTERVAL. The loop stops on 'q' (raw mode), Ctrl+C/SIGINT, - * SIGTERM, the -d duration elapsing, or on error. Finally the WAV header - * is patched, the terminal restored and the result announced. + * -f/--format, -h/--help), validates the output directory, builds the + * output filename "/__." and opens the ALSA + * capture device and the output encoder. It then installs SIGINT/SIGTERM + * handlers and runs a poll() loop that reads captured PCM frames from ALSA + * and feeds them to the encoder as they arrive, redrawing the VU meter on + * a tty stderr at most every VU_INTERVAL. The loop stops on 'q' (raw mode), + * Ctrl+C/SIGINT, SIGTERM, the -d duration elapsing, or on error. Finally + * the output is finalized, the terminal restored and the result announced. * * Returns 0 on success, 1 on a runtime error, 2 on a command-line error. */ @@ -500,6 +442,7 @@ fn run() -> i32 { let mut output_dir = String::from("."); let mut prefix = DEFAULT_PREFIX.to_string(); let mut device = DEFAULT_DEVICE.to_string(); + let mut format = Format::Wav; let mut duration = 0.0f64; // 0 = record until stopped // ---- argument parsing ------------------------------------------- @@ -544,6 +487,20 @@ fn run() -> i32 { } device = args[i].clone(); } + "-f" | "--format" => { + i += 1; + if i >= args.len() { + usage(); + return 2; + } + match Format::parse(&args[i]) { + Some(f) => format = f, + None => { + usage(); + return 2; + } + } + } "-h" | "--help" => { usage(); return 0; @@ -566,24 +523,24 @@ fn run() -> i32 { } let stamp = timestamp(); - let path_str = format!("{}/{prefix}__{stamp}.wav", output_dir); + let path_str = format!("{}/{prefix}__{stamp}.{}", output_dir, format.extension()); if path_str.len() >= 4096 { eprintln!("Error: output path too long"); return 1; } let path = Path::new(&path_str); - // ---- audio device and output file -------------------------------- + // ---- audio device and output encoder ----------------------------- let (pcm, rate, channels, period) = match open_capture(&device, DEFAULT_RATE, DEFAULT_CHANNELS) { Some(v) => v, None => return 1, }; - let mut file = match wav_open(path, rate, channels, DEFAULT_BITS) { - Ok(f) => f, - Err(e) => { - eprintln!("Error: cannot open output file: {}: {e}", path.display()); + let mut encoder = match Encoder::create(format, path, rate, channels, DEFAULT_BITS) { + Ok(e) => e, + Err(msg) => { + eprintln!("Error: cannot create output file {}: {msg}", path.display()); return 1; } }; @@ -596,7 +553,8 @@ fn run() -> i32 { eprintln!("Recording to {}", path.display()); eprintln!( - "Using ALSA device '{device}' at {rate} Hz, {channels} channel(s), {DEFAULT_BITS}-bit" + "Using ALSA device '{device}' at {rate} Hz, {channels} channel(s), {DEFAULT_BITS}-bit, {} format", + format.extension() ); eprintln!("Press q or Ctrl+C to stop."); @@ -733,16 +691,18 @@ fn run() -> i32 { let n_samples = got * channels as usize; let bytes = n_samples * std::mem::size_of::(); - if data_bytes + bytes as u64 > WAV_DATA_MAX { - eprintln!("Warning: 4 GiB WAV limit reached, stopping."); - break; + // The 32-bit RIFF size field limits WAV recordings; the + // streaming FLAC/MP3 encoders have no such limit. + if let Some(max) = encoder.max_data_bytes() { + if data_bytes + bytes as u64 > max { + eprintln!("Warning: 4 GiB WAV limit reached, stopping."); + break; + } } - // S16_LE samples are already in the byte order RIFF expects. - let raw = - unsafe { std::slice::from_raw_parts(samples.as_ptr() as *const u8, bytes) }; - if let Err(err) = file.write_all(raw) { - eprintln!("Error: failed to write audio: {err}"); + // Feed the captured buffer to the encoder in real time. + if let Err(msg) = encoder.write_samples(&samples[..n_samples]) { + eprintln!("Error: failed to encode audio: {msg}"); result = 1; break; } @@ -765,14 +725,14 @@ fn run() -> i32 { restore_terminal(&saved_term); eprintln!("\nRecording ended."); - match wav_finalize(&mut file, data_bytes) { + match encoder.finish() { Ok(()) => { if result == 0 { eprintln!("Saved {}", path.display()); } } - Err(_) => { - eprintln!("Error: could not finalize WAV file {}", path.display()); + Err(msg) => { + eprintln!("Error: could not finalize {}: {msg}", path.display()); result = 1; } } diff --git a/src/mp3.rs b/src/mp3.rs new file mode 100644 index 0000000..f5c2905 --- /dev/null +++ b/src/mp3.rs @@ -0,0 +1,206 @@ +/* + * This file is part of tuxrecord2. + * https://xw3.org/hanez/tuxrecord2 + * + * Real-time MP3 encoding through a minimal FFI shim over the system + * libmp3lame encoder. Captured PCM buffers are handed to LAME as they + * arrive, so no intermediate WAV file is needed. + * + * Copyright 2026 Johannes Findeisen + * Licensed under the terms of the Apache-2.0 license. + */ + +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::os::raw::{c_int, c_uchar}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; + +/// Constant bitrate used for MP3 output, in kbit/s. +const BITRATE_KBPS: c_int = 192; +/// LAME quality (0 = best/slowest, 9 = worst/fastest). +const QUALITY: c_int = 2; +/// Slop added to the recommended output buffer size. +const OUTPUT_SLOP: usize = 7200; + +/// Opaque `lame_global_flags`. +#[repr(C)] +struct LameGlobalFlags { + _private: [u8; 0], +} + +extern "C" { + fn lame_init() -> *mut LameGlobalFlags; + fn lame_set_in_samplerate(gfp: *mut LameGlobalFlags, rate: c_int) -> c_int; + fn lame_set_num_channels(gfp: *mut LameGlobalFlags, channels: c_int) -> c_int; + fn lame_set_brate(gfp: *mut LameGlobalFlags, bitrate: c_int) -> c_int; + fn lame_set_quality(gfp: *mut LameGlobalFlags, quality: c_int) -> c_int; + fn lame_init_params(gfp: *mut LameGlobalFlags) -> c_int; + fn lame_encode_buffer_interleaved( + gfp: *mut LameGlobalFlags, + pcm: *mut i16, + num_samples: c_int, + mp3buf: *mut c_uchar, + mp3buf_size: c_int, + ) -> c_int; + fn lame_encode_flush(gfp: *mut LameGlobalFlags, mp3buf: *mut c_uchar, size: c_int) -> c_int; + fn lame_close(gfp: *mut LameGlobalFlags) -> c_int; +} + +/// Maximum number of samples per channel LAME accepts in a single call. +const MAX_SAMPLES_PER_CALL: usize = 100_000; + +/* + * Real-time MP3 encoder. + * + * Owns a LAME encoder and the output file. Each captured buffer is + * converted to MP3 frames and appended to the file immediately; finish() + * flushes LAME's internal delay. + */ +pub struct Mp3Encoder { + gfp: *mut LameGlobalFlags, + channels: usize, + file: File, + /// Reusable encoded-output buffer (the processing buffer). + buffer: Vec, + finished: bool, +} + +impl Mp3Encoder { + pub fn create(path: &Path, rate: u32, channels: u32) -> Result { + if channels != 1 && channels != 2 { + return Err(format!( + "MP3 supports only 1 or 2 channels (device negotiated {channels})" + )); + } + + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o644) + .open(path) + .map_err(|e| format!("cannot open output file: {e}"))?; + + // SAFETY: LAME is driven through its documented C API; every return + // code is checked and the handle is closed on error and in Drop. + unsafe { + let gfp = lame_init(); + if gfp.is_null() { + return Err("could not allocate the LAME encoder".to_string()); + } + + let ok = lame_set_in_samplerate(gfp, rate as c_int) >= 0 + && lame_set_num_channels(gfp, channels as c_int) >= 0 + && lame_set_brate(gfp, BITRATE_KBPS) >= 0 + && lame_set_quality(gfp, QUALITY) >= 0; + if !ok { + lame_close(gfp); + return Err("could not configure the LAME encoder".to_string()); + } + + if lame_init_params(gfp) < 0 { + lame_close(gfp); + return Err("could not initialize the LAME encoder".to_string()); + } + + Ok(Mp3Encoder { + gfp, + channels: channels as usize, + file, + buffer: Vec::new(), + finished: false, + }) + } + } + + /// Encode one buffer of interleaved S16_LE samples and append it. + pub fn write_samples(&mut self, samples: &[i16]) -> Result<(), String> { + if self.finished { + return Err("MP3 encoder already finished".to_string()); + } + let total_frames = samples.len() / self.channels; + if total_frames == 0 { + return Ok(()); + } + + // LAME cannot process arbitrarily large buffers in one call. + let mut offset = 0; + while offset < total_frames { + let frames = (total_frames - offset).min(MAX_SAMPLES_PER_CALL); + let chunk = &samples[offset * self.channels..(offset + frames) * self.channels]; + self.encode_chunk(chunk, frames)?; + offset += frames; + } + Ok(()) + } + + fn encode_chunk(&mut self, chunk: &[i16], frames: usize) -> Result<(), String> { + // LAME recommends 1.25 * samples + 7200 bytes of output space. + let capacity = frames + frames / 4 + OUTPUT_SLOP; + self.buffer.clear(); + self.buffer.resize(capacity, 0); + + // SAFETY: chunk holds frames * channels valid i16 samples and buffer + // is at least `capacity` bytes, matching LAME's requirements. LAME + // declares the input as mutable but does not modify it. + let written = unsafe { + lame_encode_buffer_interleaved( + self.gfp, + chunk.as_ptr() as *mut i16, + frames as c_int, + self.buffer.as_mut_ptr(), + capacity as c_int, + ) + }; + if written < 0 { + return Err(format!("MP3 encoding failed (code {written})")); + } + + self.file + .write_all(&self.buffer[..written as usize]) + .map_err(|e| format!("cannot write MP3 data: {e}")) + } + + /// Flush LAME's remaining frames and close the file. + pub fn finish(&mut self) -> Result<(), String> { + if self.finished { + return Ok(()); + } + self.finished = true; + + self.buffer.clear(); + self.buffer.resize(OUTPUT_SLOP, 0); + // SAFETY: buffer is at least OUTPUT_SLOP bytes and gfp is live. + let written = unsafe { + lame_encode_flush( + self.gfp, + self.buffer.as_mut_ptr(), + self.buffer.len() as c_int, + ) + }; + if written < 0 { + return Err(format!("could not flush the MP3 stream (code {written})")); + } + + self.file + .write_all(&self.buffer[..written as usize]) + .map_err(|e| format!("cannot write MP3 data: {e}"))?; + self.file + .flush() + .map_err(|e| format!("cannot flush MP3 data: {e}"))?; + Ok(()) + } +} + +impl Drop for Mp3Encoder { + fn drop(&mut self) { + if !self.finished { + let _ = self.finish(); + } + // SAFETY: the LAME handle is closed exactly once and never reused. + unsafe { + lame_close(self.gfp); + } + } +} diff --git a/src/wav.rs b/src/wav.rs new file mode 100644 index 0000000..1d5dc73 --- /dev/null +++ b/src/wav.rs @@ -0,0 +1,108 @@ +/* + * This file is part of tuxrecord2. + * https://xw3.org/hanez/tuxrecord2 + * + * Streaming WAV (RIFF/WAVE) writer. + * + * Copyright 2026 Johannes Findeisen + * Licensed under the terms of the Apache-2.0 license. + */ + +use std::fs::{File, OpenOptions}; +use std::io::{self, Seek, SeekFrom, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; + +const HEADER_LENGTH: usize = 44; + +/// Classic 32-bit RIFF limit. +pub const WAV_DATA_MAX: u64 = u32::MAX as u64 - 36; + +/* + * Streaming writer for 16-bit little-endian PCM WAV files. + * + * The 44-byte header is written up front with zero placeholders for the + * RIFF and data chunk sizes; audio bytes are appended as they are + * captured and the two size fields are patched by finish(). + */ +pub struct WavWriter { + file: File, + data_bytes: u64, +} + +impl WavWriter { + /* + * Create (or truncate) the file with mode 0644 and write the RIFF / + * WAVE / fmt / data chunk layout for the given rate, channel count and + * bit depth. + */ + pub fn create(path: &Path, rate: u32, channels: u32, bits: u32) -> io::Result { + let byte_rate = rate * channels * bits / 8; + let mut hdr = [0u8; HEADER_LENGTH]; + + hdr[0..4].copy_from_slice(b"RIFF"); + hdr[4..8].copy_from_slice(&0u32.to_le_bytes()); // RIFF chunk size + hdr[8..12].copy_from_slice(b"WAVE"); + hdr[12..16].copy_from_slice(b"fmt "); + hdr[16..20].copy_from_slice(&16u32.to_le_bytes()); // fmt chunk size + hdr[20..22].copy_from_slice(&1u16.to_le_bytes()); // PCM + hdr[22..24].copy_from_slice(&(channels as u16).to_le_bytes()); + hdr[24..28].copy_from_slice(&rate.to_le_bytes()); + hdr[28..32].copy_from_slice(&byte_rate.to_le_bytes()); + hdr[32..34].copy_from_slice(&((channels * bits / 8) as u16).to_le_bytes()); + hdr[34..36].copy_from_slice(&(bits as u16).to_le_bytes()); + hdr[36..40].copy_from_slice(b"data"); + hdr[40..44].copy_from_slice(&0u32.to_le_bytes()); // data chunk size + + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o644) + .open(path)?; + file.write_all(&hdr)?; + Ok(WavWriter { + file, + data_bytes: 0, + }) + } + + /* + * Append one buffer of interleaved S16_LE samples. + * + * The samples are handed to the file as raw bytes, relying on the host + * being little-endian (enforced at compile time in main.rs): we + * negotiate S16_LE with ALSA, so the in-memory representation is + * already the byte order the RIFF/WAV container expects. + */ + pub fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> { + let bytes = std::mem::size_of_val(samples); + // SAFETY: reinterpreting &[i16] as &[u8] is valid (same size, + // alignment of u8 is weaker) and only reads the samples. + let raw = unsafe { std::slice::from_raw_parts(samples.as_ptr() as *const u8, bytes) }; + self.file.write_all(raw)?; + self.data_bytes += bytes as u64; + Ok(()) + } + + /* + * Patch the RIFF and data chunk sizes, then fsync and close the file. + * + * The RIFF chunk size is header + data - 8; the data chunk size is the + * number of audio bytes written. + */ + pub fn finish(&mut self) -> io::Result<()> { + self.file.seek(SeekFrom::Start(4))?; + let riff_size = (HEADER_LENGTH as u32) + .wrapping_add(self.data_bytes as u32) + .wrapping_sub(8); + self.file.write_all(&riff_size.to_le_bytes())?; + + self.file.seek(SeekFrom::Start(40))?; + self.file + .write_all(&(self.data_bytes as u32).to_le_bytes())?; + + self.file.sync_all()?; + Ok(()) + } +}