Added flac and mp3 output format.
This commit is contained in:
parent
d0a20cd562
commit
d1dca0f477
10 changed files with 745 additions and 128 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -4,6 +4,8 @@
|
|||
# Recordings
|
||||
recordings/
|
||||
*.wav
|
||||
*.flac
|
||||
*.mp3
|
||||
|
||||
# Rust backup files
|
||||
*.rs.bk
|
||||
|
|
|
|||
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -73,8 +73,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
|||
|
||||
[[package]]
|
||||
name = "tuxrecord2"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
|
|
|||
10
Cargo.toml
10
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 <you@hanez.org>"]
|
||||
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
|
||||
|
|
|
|||
47
README.md
47
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 <secs>` | Stop automatically after `<secs>` seconds |
|
||||
| `-p <prefix>` | Filename prefix instead of `tuxrecord2` |
|
||||
| `-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 |
|
||||
|
||||
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 `<prefix>__<timestamp>.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 `<prefix>__<timestamp>.<ext>` where `<ext>` 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
|
||||
`<prefix>__<timestamp>.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 `<prefix>__<timestamp>.<ext>` naming and can be stopped with `q` or
|
||||
**Ctrl+C**. The default setting can be restored later with
|
||||
`pactl set-default-source <your-input-source>`.
|
||||
|
||||
### Troubleshooting
|
||||
|
|
|
|||
25
build.rs
Normal file
25
build.rs
Normal 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
114
src/encoder.rs
Normal 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
168
src/flac.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
190
src/main.rs
190
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 <dir> Output directory (default: current directory)\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 -D <device> ALSA capture device (default: \"{device}\")\n\
|
||||
\x20 -h, --help Show this help\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 -p <prefix> Filename prefix instead of \"{prefix}\" (default: {prefix})\n\
|
||||
\x20 -D <device> 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 <prefix>__<timestamp>.wav\n\
|
||||
The file is written as <prefix>__<timestamp>.<ext> where <ext> 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<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 */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
|
@ -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
|
||||
* "<dir>/<prefix>__<timestamp>.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 "<dir>/<prefix>__<timestamp>.<ext>" 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::<i16>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
206
src/mp3.rs
Normal file
206
src/mp3.rs
Normal 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
108
src/wav.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue