Added flac and mp3 output format.

This commit is contained in:
Johannes Findeisen 2026-10-15 02:31:32 +02:00
commit d1dca0f477
10 changed files with 745 additions and 128 deletions

2
.gitignore vendored
View file

@ -4,6 +4,8 @@
# Recordings # Recordings
recordings/ recordings/
*.wav *.wav
*.flac
*.mp3
# Rust backup files # Rust backup files
*.rs.bk *.rs.bk

3
Cargo.lock generated
View file

@ -73,8 +73,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]] [[package]]
name = "tuxrecord2" name = "tuxrecord2"
version = "1.0.0" version = "1.1.0"
dependencies = [ dependencies = [
"alsa", "alsa",
"libc", "libc",
"pkg-config",
] ]

View file

@ -1,15 +1,16 @@
[package] [package]
name = "tuxrecord2" name = "tuxrecord2"
version = "1.0.0" version = "1.1.0"
edition = "2021" edition = "2021"
rust-version = "1.77" 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 <you@hanez.org>"] authors = ["Johannes Findeisen <you@hanez.org>"]
license = "Apache-2.0" license = "Apache-2.0"
readme = "README.md" readme = "README.md"
repository = "https://xw3.org/hanez/tuxrecord2" repository = "https://xw3.org/hanez/tuxrecord2"
keywords = ["audio", "alsa", "wav", "recording"] keywords = ["audio", "alsa", "wav", "flac", "mp3", "recording"]
categories = ["multimedia::audio", "command-line-utilities"] categories = ["multimedia::audio", "command-line-utilities"]
build = "build.rs"
[[bin]] [[bin]]
name = "tuxrecord2" name = "tuxrecord2"
@ -19,5 +20,8 @@ path = "src/main.rs"
alsa = "0.12" alsa = "0.12"
libc = "0.2" libc = "0.2"
[build-dependencies]
pkg-config = "0.3"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3

View file

@ -1,13 +1,14 @@
# tuxrecord2 # 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 `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 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 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 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 amount of `libc` for terminal, polling and signal handling. FLAC and MP3 output
**Cargo**. 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: macrecord was created by my good friend Armin. You can find his code here:
[https://codeberg.org/armin/macrecord](https://codeberg.org/armin/macrecord) [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` - ALSA development headers and `pkg-config`
(`libasound2-dev` on Debian/Ubuntu, `alsa-lib-devel` on Fedora, (`libasound2-dev` on Debian/Ubuntu, `alsa-lib-devel` on Fedora,
`alsa-lib` on Arch) `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 ## Usage
@ -36,6 +43,10 @@ tuxrecord2 -p myrec
# Use a specific ALSA capture device # Use a specific ALSA capture device
tuxrecord2 -D hw:1,0 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) # Record from PipeWire (the PipeWire sound server)
tuxrecord2 -D pipewire tuxrecord2 -D pipewire
``` ```
@ -48,6 +59,7 @@ tuxrecord2 -D pipewire
| `-d <secs>` | Stop automatically after `<secs>` seconds | | `-d <secs>` | Stop automatically after `<secs>` seconds |
| `-p <prefix>` | Filename prefix instead of `tuxrecord2` | | `-p <prefix>` | Filename prefix instead of `tuxrecord2` |
| `-D <device>` | ALSA capture device (default: `default`; `pipewire` on PipeWire systems) | | `-D <device>` | ALSA capture device (default: `default`; `pipewire` on PipeWire systems) |
| `-f, --format <fmt>` | Output format: `wav`, `flac` or `mp3` (default: `wav`) |
| `-h, --help` | Show help | | `-h, --help` | Show help |
Run `arecord -l` to list available ALSA capture devices. On systems with 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 - **q** - Stop recording and save the file
- **Ctrl+C** - Stop recording and save the file - **Ctrl+C** - Stop recording and save the file
The file is written as `<prefix>__<timestamp>.wav` (default prefix The file is written as `<prefix>__<timestamp>.<ext>` where `<ext>` is `wav`,
`tuxrecord2`). The VU meter shows the input level of the left and right channels `flac` or `mp3` depending on `-f/--format` (default prefix `tuxrecord2`). The VU
in real time when stderr is a terminal. 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 ## Recording system audio
@ -132,9 +161,9 @@ tuxrecord2 -D pipewire
``` ```
The `.monitor` source carries exactly what the sink is playing, so anything 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 your players output ends up in the recorded file. Every capture follows the
`<prefix>__<timestamp>.wav` naming and can be stopped with `q` or **Ctrl+C**. same `<prefix>__<timestamp>.<ext>` naming and can be stopped with `q` or
The default setting can be restored later with **Ctrl+C**. The default setting can be restored later with
`pactl set-default-source <your-input-source>`. `pactl set-default-source <your-input-source>`.
### Troubleshooting ### Troubleshooting

25
build.rs Normal file
View file

@ -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");
}

114
src/encoder.rs Normal file
View file

@ -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 <you@hanez.org>
* 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<Format> {
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<Encoder, String> {
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<u64> {
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(),
}
}
}

168
src/flac.rs Normal file
View file

@ -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 <you@hanez.org>
* 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<i32>,
finished: bool,
}
impl FlacEncoder {
pub fn create(path: &Path, rate: u32, channels: u32, bits: u32) -> Result<FlacEncoder, String> {
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);
}
}
}

View file

@ -2,7 +2,7 @@
* This file is part of tuxrecord2. * This file is part of tuxrecord2.
* https://xw3.org/hanez/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 * tuxrecord2 is the Rust port of "tuxrecord", the Linux port of the former
* macOS-only "macrecord" tool. The original tuxrecord was rewritten in C99 * macOS-only "macrecord" tool. The original tuxrecord was rewritten in C99
@ -17,17 +17,22 @@
* https://opensource.org/license/apache-2-0 * https://opensource.org/license/apache-2-0
*/ */
use std::fs::{File, OpenOptions}; mod encoder;
use std::io::{self, Seek, SeekFrom, Write}; mod flac;
use std::os::unix::fs::OpenOptionsExt; mod mp3;
mod wav;
use std::io::{self, Write};
use std::path::Path; use std::path::Path;
use std::process::ExitCode; use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant}; 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 alsa::{Direction, PollDescriptors, ValueOr};
use crate::encoder::{Encoder, Format};
const DEFAULT_RATE: u32 = 48_000; const DEFAULT_RATE: u32 = 48_000;
const DEFAULT_CHANNELS: u32 = 2; const DEFAULT_CHANNELS: u32 = 2;
const DEFAULT_BITS: u32 = 16; const DEFAULT_BITS: u32 = 16;
@ -38,19 +43,16 @@ const VU_WIDTH: usize = 48;
const VU_MIN_DB: f64 = -60.0; const VU_MIN_DB: f64 = -60.0;
/// Redraw the VU meter at most every 50 ms. /// Redraw the VU meter at most every 50 ms.
const VU_INTERVAL: Duration = Duration::from_millis(50); 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 * Samples are captured as S16_LE. The WAV writer hands the in-memory
* being little-endian: we negotiate S16_LE with ALSA, so the in-memory * representation to the file as raw bytes, and the FLAC/MP3 encoders
* representation is already the byte order the RIFF/WAV container expects. * interpret the samples as native i16, so both rely on the host being
* Refuse to build on big-endian hosts instead of silently producing a * little-endian. Refuse to build on big-endian hosts instead of silently
* corrupted file. * producing a corrupted file.
*/ */
#[cfg(not(target_endian = "little"))] #[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. /// Set to true by the signal handler to request a clean stop.
static STOP: AtomicBool = AtomicBool::new(false); 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 * 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 * 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 * 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 * process abruptly; instead the loop exits normally, the output is
* patched, the file is closed and the terminal restored. * finalized, the file is closed and the terminal restored.
*/ */
extern "C" fn handle_signal(_sig: libc::c_int) { extern "C" fn handle_signal(_sig: libc::c_int) {
STOP.store(true, Ordering::SeqCst); STOP.store(true, Ordering::SeqCst);
@ -76,19 +78,20 @@ extern "C" fn handle_signal(_sig: libc::c_int) {
*/ */
fn usage() { fn usage() {
print!( print!(
"tuxrecord2 - record audio to a WAV file using ALSA\n\ "tuxrecord2 - record audio to WAV, FLAC or MP3 using ALSA\n\
\n\ \n\
USAGE\n\ USAGE\n\
\x20 tuxrecord2 [options]\n\ \x20 tuxrecord2 [options]\n\
\n\ \n\
OPTIONS\n\ OPTIONS\n\
\x20 -o <dir> Output directory (default: current directory)\n\ \x20 -o <dir> Output directory (default: current directory)\n\
\x20 -d <secs> Stop automatically after <secs> seconds (default: until q or Ctrl+C)\n\ \x20 -d <secs> Stop automatically after <secs> seconds (default: until q or Ctrl+C)\n\
\x20 -p <prefix> Filename prefix instead of \"{prefix}\" (default: {prefix})\n\ \x20 -p <prefix> Filename prefix instead of \"{prefix}\" (default: {prefix})\n\
\x20 -D <device> ALSA capture device (default: \"{device}\")\n\ \x20 -D <device> ALSA capture device (default: \"{device}\")\n\
\x20 -h, --help Show this help\n\ \x20 -f, --format F Output format: {formats} (default: {format})\n\
\x20 -h, --help Show this help\n\
\n\ \n\
The file is written as <prefix>__<timestamp>.wav\n\ The file is written as <prefix>__<timestamp>.<ext> where <ext> is wav, flac, or mp3.\n\
\n\ \n\
By default the ALSA capture device \"default\" is used, which is usually\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\ 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", \x20 tuxrecord2 -D pipewire (system audio, uses the sink monitor)\n",
prefix = DEFAULT_PREFIX, prefix = DEFAULT_PREFIX,
device = DEFAULT_DEVICE, device = DEFAULT_DEVICE,
format = Format::Wav.extension(),
formats = Format::names(),
); );
let _ = io::stdout().flush(); let _ = io::stdout().flush();
} }
@ -219,69 +224,6 @@ fn draw_meter(db_l: f64, db_r: f64, first: bool) {
eprintln!("{l}\n{r}"); 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<File> {
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 */ /* 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 * access, a channel count/sample rate "near" the requested values, and
* commits the configuration. The actually negotiated rate, channel * commits the configuration. The actually negotiated rate, channel
* count and period size are read back so the caller can allocate sane * 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. * the values.
* *
* On failure a descriptive error is printed to stderr (including a hint * 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; return None;
} }
if let Err(_err) = hw.set_format(Format::s16()) { if let Err(_err) = hw.set_format(AlsaFormat::s16()) {
eprintln!( eprintln!(
"Error: device '{device}' does not support 16-bit signed PCM; try 'plug:{device}'" "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, * Parses the command line (-o dir, -d seconds, -p prefix, -D device,
* -h/--help), validates the output directory, builds the output filename * -f/--format, -h/--help), validates the output directory, builds the
* "<dir>/<prefix>__<timestamp>.wav" and opens the ALSA capture device * output filename "<dir>/<prefix>__<timestamp>.<ext>" and opens the ALSA
* and the WAV file. It then installs SIGINT/SIGTERM handlers and runs a * capture device and the output encoder. It then installs SIGINT/SIGTERM
* poll() loop that reads captured PCM frames from ALSA and appends them * handlers and runs a poll() loop that reads captured PCM frames from ALSA
* to the file, redrawing the VU meter on a tty stderr at most every * and feeds them to the encoder as they arrive, redrawing the VU meter on
* VU_INTERVAL. The loop stops on 'q' (raw mode), Ctrl+C/SIGINT, * a tty stderr at most every VU_INTERVAL. The loop stops on 'q' (raw mode),
* SIGTERM, the -d duration elapsing, or on error. Finally the WAV header * Ctrl+C/SIGINT, SIGTERM, the -d duration elapsing, or on error. Finally
* is patched, the terminal restored and the result announced. * 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. * 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 output_dir = String::from(".");
let mut prefix = DEFAULT_PREFIX.to_string(); let mut prefix = DEFAULT_PREFIX.to_string();
let mut device = DEFAULT_DEVICE.to_string(); let mut device = DEFAULT_DEVICE.to_string();
let mut format = Format::Wav;
let mut duration = 0.0f64; // 0 = record until stopped let mut duration = 0.0f64; // 0 = record until stopped
// ---- argument parsing ------------------------------------------- // ---- argument parsing -------------------------------------------
@ -544,6 +487,20 @@ fn run() -> i32 {
} }
device = args[i].clone(); 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" => { "-h" | "--help" => {
usage(); usage();
return 0; return 0;
@ -566,24 +523,24 @@ fn run() -> i32 {
} }
let stamp = timestamp(); 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 { if path_str.len() >= 4096 {
eprintln!("Error: output path too long"); eprintln!("Error: output path too long");
return 1; return 1;
} }
let path = Path::new(&path_str); 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) let (pcm, rate, channels, period) = match open_capture(&device, DEFAULT_RATE, DEFAULT_CHANNELS)
{ {
Some(v) => v, Some(v) => v,
None => return 1, None => return 1,
}; };
let mut file = match wav_open(path, rate, channels, DEFAULT_BITS) { let mut encoder = match Encoder::create(format, path, rate, channels, DEFAULT_BITS) {
Ok(f) => f, Ok(e) => e,
Err(e) => { Err(msg) => {
eprintln!("Error: cannot open output file: {}: {e}", path.display()); eprintln!("Error: cannot create output file {}: {msg}", path.display());
return 1; return 1;
} }
}; };
@ -596,7 +553,8 @@ fn run() -> i32 {
eprintln!("Recording to {}", path.display()); eprintln!("Recording to {}", path.display());
eprintln!( 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."); eprintln!("Press q or Ctrl+C to stop.");
@ -733,16 +691,18 @@ fn run() -> i32 {
let n_samples = got * channels as usize; let n_samples = got * channels as usize;
let bytes = n_samples * std::mem::size_of::<i16>(); let bytes = n_samples * std::mem::size_of::<i16>();
if data_bytes + bytes as u64 > WAV_DATA_MAX { // The 32-bit RIFF size field limits WAV recordings; the
eprintln!("Warning: 4 GiB WAV limit reached, stopping."); // streaming FLAC/MP3 encoders have no such limit.
break; 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. // Feed the captured buffer to the encoder in real time.
let raw = if let Err(msg) = encoder.write_samples(&samples[..n_samples]) {
unsafe { std::slice::from_raw_parts(samples.as_ptr() as *const u8, bytes) }; eprintln!("Error: failed to encode audio: {msg}");
if let Err(err) = file.write_all(raw) {
eprintln!("Error: failed to write audio: {err}");
result = 1; result = 1;
break; break;
} }
@ -765,14 +725,14 @@ fn run() -> i32 {
restore_terminal(&saved_term); restore_terminal(&saved_term);
eprintln!("\nRecording ended."); eprintln!("\nRecording ended.");
match wav_finalize(&mut file, data_bytes) { match encoder.finish() {
Ok(()) => { Ok(()) => {
if result == 0 { if result == 0 {
eprintln!("Saved {}", path.display()); eprintln!("Saved {}", path.display());
} }
} }
Err(_) => { Err(msg) => {
eprintln!("Error: could not finalize WAV file {}", path.display()); eprintln!("Error: could not finalize {}: {msg}", path.display());
result = 1; result = 1;
} }
} }

206
src/mp3.rs Normal file
View file

@ -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 <you@hanez.org>
* 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<u8>,
finished: bool,
}
impl Mp3Encoder {
pub fn create(path: &Path, rate: u32, channels: u32) -> Result<Mp3Encoder, String> {
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);
}
}
}

108
src/wav.rs Normal file
View file

@ -0,0 +1,108 @@
/*
* This file is part of tuxrecord2.
* https://xw3.org/hanez/tuxrecord2
*
* Streaming WAV (RIFF/WAVE) writer.
*
* Copyright 2026 Johannes Findeisen <you@hanez.org>
* 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<WavWriter> {
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(())
}
}