Initial commit.
This commit is contained in:
commit
d0a20cd562
7 changed files with 1160 additions and 0 deletions
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Rust build output
|
||||
/target/
|
||||
|
||||
# Recordings
|
||||
recordings/
|
||||
*.wav
|
||||
|
||||
# Rust backup files
|
||||
*.rs.bk
|
||||
|
||||
# IDE ignores
|
||||
.idea/
|
||||
|
||||
# Vim swap files
|
||||
*.swp
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
80
Cargo.lock
generated
Normal file
80
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3f93573e7ddb15145d27c58c0fa0a452faad9f5452ac17ed4554ea035ea9b71"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0320138053ef93099f168102c37b46ac0b9c53affc56ef7696633ad5477e2be"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "tuxrecord2"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"libc",
|
||||
]
|
||||
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "tuxrecord2"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
description = "Record audio to a WAV file 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"]
|
||||
categories = ["multimedia::audio", "command-line-utilities"]
|
||||
|
||||
[[bin]]
|
||||
name = "tuxrecord2"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
alsa = "0.12"
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
13
LICENSE
Normal file
13
LICENSE
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
Copyright 2025 Johannes Findeisen
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
42
Makefile
Normal file
42
Makefile
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Makefile for tuxrecord2
|
||||
#
|
||||
# tuxrecord2 is written in Rust and built with Cargo; this Makefile is a
|
||||
# convenience wrapper around the common Cargo commands and provides
|
||||
# system-wide install/uninstall targets.
|
||||
|
||||
CARGO ?= cargo
|
||||
|
||||
PREFIX ?= /usr/local
|
||||
BINDIR = $(PREFIX)/bin
|
||||
|
||||
BINARY = tuxrecord2
|
||||
RELEASE = target/release/$(BINARY)
|
||||
|
||||
all: release
|
||||
|
||||
release:
|
||||
$(CARGO) build --release
|
||||
|
||||
debug:
|
||||
$(CARGO) build
|
||||
|
||||
test:
|
||||
$(CARGO) test
|
||||
|
||||
fmt:
|
||||
$(CARGO) fmt
|
||||
|
||||
lint:
|
||||
$(CARGO) clippy -- -D warnings
|
||||
|
||||
install: release
|
||||
install -d $(DESTDIR)$(BINDIR)
|
||||
install -m 755 $(RELEASE) $(DESTDIR)$(BINDIR)/$(BINARY)
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)$(BINDIR)/$(BINARY)
|
||||
|
||||
clean:
|
||||
$(CARGO) clean
|
||||
|
||||
.PHONY: all release debug test fmt lint install uninstall clean
|
||||
199
README.md
Normal file
199
README.md
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# tuxrecord2
|
||||
|
||||
Record audio to a WAV file 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**.
|
||||
macrecord was created by my good friend Armin. You can find his code here:
|
||||
[https://codeberg.org/armin/macrecord](https://codeberg.org/armin/macrecord)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rust toolchain (Cargo) — 1.77 or newer (uses C string literals)
|
||||
- ALSA development headers and `pkg-config`
|
||||
(`libasound2-dev` on Debian/Ubuntu, `alsa-lib-devel` on Fedora,
|
||||
`alsa-lib` on Arch)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Record until q or Ctrl+C
|
||||
tuxrecord2
|
||||
|
||||
# Record for N seconds
|
||||
tuxrecord2 -d 30
|
||||
|
||||
# Specify output directory
|
||||
tuxrecord2 -o /path/to/dir
|
||||
|
||||
# Use a custom filename prefix
|
||||
tuxrecord2 -p myrec
|
||||
|
||||
# Use a specific ALSA capture device
|
||||
tuxrecord2 -D hw:1,0
|
||||
|
||||
# Record from PipeWire (the PipeWire sound server)
|
||||
tuxrecord2 -D pipewire
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
| -------------- | -------------------------------------------------------- |
|
||||
| `-o <dir>` | Output directory (default: current directory) |
|
||||
| `-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) |
|
||||
| `-h, --help` | Show help |
|
||||
|
||||
Run `arecord -l` to list available ALSA capture devices. On systems with
|
||||
PipeWire, list the PipeWire source nodes with `pactl list short sources`
|
||||
or `wpctl status` (see "Recording from PipeWire" below).
|
||||
|
||||
## Controls
|
||||
|
||||
- **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.
|
||||
|
||||
## Recording system audio
|
||||
|
||||
The default ALSA capture device (`default`) is usually the microphone. To
|
||||
record **system audio** (everything playing on the machine), route playback
|
||||
through the ALSA loopback driver `snd-aloop` and capture from the other side
|
||||
of the loop.
|
||||
|
||||
```bash
|
||||
# 1. Load the loopback module (once per boot)
|
||||
sudo modprobe snd-aloop
|
||||
|
||||
# 2. Play audio on the "playback" side of the loop
|
||||
aplay -D hw:Loopback,0 song.wav
|
||||
|
||||
# 3. In another terminal, record from the paired "capture" side
|
||||
tuxrecord2 -D hw:Loopback,1
|
||||
```
|
||||
|
||||
Audio written to `hw:Loopback,0` (playback) is heard on `hw:Loopback,1`
|
||||
(capture): the two subdevices form a loop and the samples pass straight
|
||||
through. Point your players at `hw:Loopback,0` (default sink) and record from
|
||||
`hw:Loopback,1` to capture the system mix. Subdevice pairing depends on the
|
||||
kernel version; if one direction is silent, try recording from
|
||||
`hw:Loopback,0` while playing to `hw:Loopback,1`.
|
||||
|
||||
## Recording from PipeWire
|
||||
|
||||
On modern distributions (Fedora 34+, Ubuntu 22.04+, Arch Linux) the **PipeWire**
|
||||
sound server takes over audio handling. PipeWire ships an ALSA compatibility
|
||||
plugin (`pipewire-alsa`) that exposes the server as a normal ALSA capture
|
||||
device named `pipewire`, so `tuxrecord2` can record from it with no extra
|
||||
setup:
|
||||
|
||||
```bash
|
||||
# Record from PipeWire's default source (usually the microphone)
|
||||
tuxrecord2 -D pipewire
|
||||
|
||||
# Record a fixed duration
|
||||
tuxrecord2 -D pipewire -d 10
|
||||
```
|
||||
|
||||
### What "pipewire" records
|
||||
|
||||
The pipewire-alsa plugin routes capture to PipeWire's **default source**:
|
||||
|
||||
```bash
|
||||
# Show the current default source
|
||||
pactl get-default-source # or: wpctl status
|
||||
|
||||
# List all available PipeWire sources
|
||||
wpctl status
|
||||
pactl list short sources
|
||||
```
|
||||
|
||||
### Recording system audio (what you hear)
|
||||
|
||||
To capture everything that is currently playing (system audio), point the
|
||||
default source at the **monitor** of the default sink. This is the PipeWire
|
||||
equivalent of the ALSA loopback approach above but needs no kernel module:
|
||||
|
||||
```bash
|
||||
# 1. Find the monitor of the default sink
|
||||
pactl set-default-source "$(pactl get-default-sink).monitor"
|
||||
|
||||
# 2. Record it with tuxrecord2
|
||||
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
|
||||
`pactl set-default-source <your-input-source>`.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **"cannot open ALSA capture device 'pipewire'"** — the `pipewire-alsa`
|
||||
plugin is missing. Install it (e.g. `pipewire-alsa` on Debian/Ubuntu/Arch,
|
||||
`pipewire-alsa` on Fedora), or the PipeWire server is not running
|
||||
(`systemctl --user status pipewire`). In that case fall back to a regular
|
||||
ALSA device with `tuxrecord2 -D hw:0,0`.
|
||||
- Recording from a specific device or virtual node instead of the default
|
||||
source is possible by setting the default source first (see `pactl` /
|
||||
`wpctl` output) or by creating a loopback with `pw-loopback` and recording
|
||||
that node.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make
|
||||
# or directly:
|
||||
# cargo build --release
|
||||
```
|
||||
|
||||
The resulting binary is `target/release/tuxrecord2`. You can also install it
|
||||
straight from the source tree:
|
||||
|
||||
```bash
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
Install (installs to `$(PREFIX)/bin`, default `/usr/local/bin`):
|
||||
|
||||
```bash
|
||||
sudo make install
|
||||
# or: sudo cp target/release/tuxrecord2 /usr/local/bin/tuxrecord2
|
||||
```
|
||||
|
||||
Install to a custom prefix:
|
||||
|
||||
```bash
|
||||
make install PREFIX=~/.local
|
||||
```
|
||||
|
||||
Uninstall:
|
||||
|
||||
```bash
|
||||
sudo make uninstall
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
make debug # cargo build
|
||||
make test # cargo test
|
||||
make fmt # cargo fmt
|
||||
make lint # cargo clippy -- -D warnings
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 — see the LICENSE file for details.
|
||||
|
||||
Copyright 2026 Johannes Findeisen
|
||||
785
src/main.rs
Normal file
785
src/main.rs
Normal file
|
|
@ -0,0 +1,785 @@
|
|||
/*
|
||||
* This file is part of tuxrecord2.
|
||||
* https://xw3.org/hanez/tuxrecord2
|
||||
*
|
||||
* tuxrecord2 - record audio to a WAV file 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
|
||||
* and used the ALSA capture API directly; this version keeps the same
|
||||
* behaviour and command-line interface but is implemented in Rust on top of
|
||||
* the safe `alsa` bindings and a small amount of `libc` for the terminal,
|
||||
* polling and signal handling. macrecord was created by my good friend Armin.
|
||||
* You can find his code here: https://codeberg.org/armin/macrecord
|
||||
*
|
||||
* Copyright 2026 Johannes Findeisen <you@hanez.org>
|
||||
* Licensed under the terms of the Apache-2.0 license.
|
||||
* 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;
|
||||
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::{Direction, PollDescriptors, ValueOr};
|
||||
|
||||
const DEFAULT_RATE: u32 = 48_000;
|
||||
const DEFAULT_CHANNELS: u32 = 2;
|
||||
const DEFAULT_BITS: u32 = 16;
|
||||
const DEFAULT_PREFIX: &str = "tuxrecord2";
|
||||
const DEFAULT_DEVICE: &str = "default";
|
||||
|
||||
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.
|
||||
*/
|
||||
#[cfg(not(target_endian = "little"))]
|
||||
compile_error!("tuxrecord2 assumes a little-endian host for raw S16_LE output");
|
||||
|
||||
/// Set to true by the signal handler to request a clean stop.
|
||||
static STOP: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/*
|
||||
* Handle SIGINT and SIGTERM by requesting a clean 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
|
||||
* 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.
|
||||
*/
|
||||
extern "C" fn handle_signal(_sig: libc::c_int) {
|
||||
STOP.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/*
|
||||
* Print the usage/help text to stdout.
|
||||
*
|
||||
* Called when -h/--help is given (the program then exits 0) and for any
|
||||
* unknown, missing, or malformed option (the program then exits 2).
|
||||
*/
|
||||
fn usage() {
|
||||
print!(
|
||||
"tuxrecord2 - record audio to a WAV file 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\
|
||||
\n\
|
||||
The file is written as <prefix>__<timestamp>.wav\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\
|
||||
system) see the \"Recording system audio\" section in the README.\n\
|
||||
\n\
|
||||
PipeWire users: record from the PipeWire default source via the\n\
|
||||
ALSA compatibility plugin by passing the device name \"pipewire\":\n\
|
||||
\x20 tuxrecord2 -D pipewire (default source)\n\
|
||||
\x20 pactl set-default-source \"$(pactl get-default-sink).monitor\"\n\
|
||||
\x20 tuxrecord2 -D pipewire (system audio, uses the sink monitor)\n",
|
||||
prefix = DEFAULT_PREFIX,
|
||||
device = DEFAULT_DEVICE,
|
||||
);
|
||||
let _ = io::stdout().flush();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* VU meter */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Compute RMS levels (0.0..1.0) for the left and right channel.
|
||||
*
|
||||
* Scans all interleaved frames: for each frame the first sample is the
|
||||
* left channel and the second sample (when channels > 1) is the right
|
||||
* channel. Per-channel squared samples are summed, the mean is taken and
|
||||
* the square root is normalized by the full-scale amplitude of i16
|
||||
* (32768.0) so a full-scale sine wave reads near 0.7.
|
||||
*/
|
||||
fn rms_levels(samples: &[i16], frames: usize, channels: usize) -> (f64, f64) {
|
||||
let mut sum_l = 0.0f64;
|
||||
let mut sum_r = 0.0f64;
|
||||
let mut n_l = 0usize;
|
||||
let mut n_r = 0usize;
|
||||
|
||||
for f in 0..frames {
|
||||
let v = samples[f * channels] as f64;
|
||||
sum_l += v * v;
|
||||
n_l += 1;
|
||||
if channels > 1 {
|
||||
let v = samples[f * channels + 1] as f64;
|
||||
sum_r += v * v;
|
||||
n_r += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let left = if n_l > 0 {
|
||||
(sum_l / n_l as f64).sqrt() / 32768.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let right = if channels > 1 && n_r > 0 {
|
||||
(sum_r / n_r as f64).sqrt() / 32768.0
|
||||
} else {
|
||||
left
|
||||
};
|
||||
(left, right)
|
||||
}
|
||||
|
||||
/*
|
||||
* Convert a linear 0.0..1.0 level into decibels.
|
||||
*
|
||||
* Returns VU_MIN_DB for 0.0, otherwise 20*log10(level), clamped to the
|
||||
* meter's dynamic range [VU_MIN_DB, 0.0] so the VU bar is never
|
||||
* completely empty above the floor or offset at full scale.
|
||||
*/
|
||||
fn to_db(level: f64) -> f64 {
|
||||
if level <= 0.0 {
|
||||
return VU_MIN_DB;
|
||||
}
|
||||
(20.0 * level.log10()).clamp(VU_MIN_DB, 0.0)
|
||||
}
|
||||
|
||||
/*
|
||||
* Render one horizontal VU meter line (label + 48 segments) into a String.
|
||||
*
|
||||
* Converts a dB level into a filled fraction of the meter width
|
||||
* (VU_MIN_DB..0) and draws VU_WIDTH segments: the lit part as full blocks
|
||||
* (U+2588) colored by level (red above 85 %, yellow above 60 %, green
|
||||
* otherwise) and the unlit part as faint blocks (U+2591). Each emitted
|
||||
* segment carries the ANSI color code and a reset, so the result is safe
|
||||
* to print to a terminal.
|
||||
*/
|
||||
fn vu_meter_line(db: f64, label: &str) -> String {
|
||||
let fraction = ((db - VU_MIN_DB) / (0.0 - VU_MIN_DB)).clamp(0.0, 1.0);
|
||||
let filled = (fraction * VU_WIDTH as f64) as usize;
|
||||
let pct = fraction * 100.0;
|
||||
let fg = if pct > 85.0 {
|
||||
"\x1b[31m"
|
||||
} else if pct > 60.0 {
|
||||
"\x1b[33m"
|
||||
} else {
|
||||
"\x1b[32m"
|
||||
};
|
||||
|
||||
let mut out = String::with_capacity(label.len() + 1 + VU_WIDTH * 12);
|
||||
out.push_str(label);
|
||||
out.push(' ');
|
||||
for k in 0..VU_WIDTH {
|
||||
if k < filled {
|
||||
out.push_str(fg);
|
||||
out.push('\u{2588}');
|
||||
out.push_str("\x1b[0m");
|
||||
} else {
|
||||
out.push_str("\x1b[32m");
|
||||
out.push('\u{2591}');
|
||||
out.push_str("\x1b[0m");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/*
|
||||
* Draw the left and right VU meter lines to stderr.
|
||||
*
|
||||
* Renders both channels and prints them as two consecutive lines. After the
|
||||
* first draw, an ANSI "move cursor up two lines" escape (\x1b[2A) is emitted
|
||||
* first so each redraw overwrites the previous meter in place instead of
|
||||
* scrolling.
|
||||
*/
|
||||
fn draw_meter(db_l: f64, db_r: f64, first: bool) {
|
||||
let l = vu_meter_line(db_l, "L");
|
||||
let r = vu_meter_line(db_r, "R");
|
||||
|
||||
if !first {
|
||||
eprint!("\x1b[2A"); // move cursor up two lines, then redraw
|
||||
}
|
||||
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 */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
fn config_fail(device: &str, err: &alsa::Error) {
|
||||
eprintln!("Error: failed to configure ALSA capture on '{device}': {err}");
|
||||
}
|
||||
|
||||
/*
|
||||
* Open an ALSA capture device and negotiate the stream parameters.
|
||||
*
|
||||
* Opens the named PCM in capture mode and starts from the device's
|
||||
* current ("any") hardware parameters, then requests interleaved S16_LE
|
||||
* 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
|
||||
* the values.
|
||||
*
|
||||
* On failure a descriptive error is printed to stderr (including a hint
|
||||
* to use 'plug:<device>' when S16_LE is rejected) and None is returned.
|
||||
*/
|
||||
fn open_capture(device: &str, rate_req: u32, channels_req: u32) -> Option<(PCM, u32, u32, i64)> {
|
||||
let pcm = match PCM::new(device, Direction::Capture, false) {
|
||||
Ok(pcm) => pcm,
|
||||
Err(err) => {
|
||||
eprintln!("Error: cannot open ALSA capture device '{device}': {err}");
|
||||
eprintln!("Run 'arecord -l' to list available capture devices.");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let hw = match HwParams::any(&pcm) {
|
||||
Ok(hw) => hw,
|
||||
Err(err) => {
|
||||
config_fail(device, &err);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = hw.set_access(Access::RWInterleaved) {
|
||||
config_fail(device, &err);
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Err(_err) = hw.set_format(Format::s16()) {
|
||||
eprintln!(
|
||||
"Error: device '{device}' does not support 16-bit signed PCM; try 'plug:{device}'"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Err(err) = hw.set_channels_near(channels_req) {
|
||||
config_fail(device, &err);
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Err(err) = hw.set_rate_near(rate_req, ValueOr::Nearest) {
|
||||
config_fail(device, &err);
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Err(err) = pcm.hw_params(&hw) {
|
||||
config_fail(device, &err);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Read back the values that were actually negotiated.
|
||||
let rate = match hw.get_rate() {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
config_fail(device, &err);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let channels = match hw.get_channels() {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
config_fail(device, &err);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let period = match hw.get_period_size() {
|
||||
Ok(p) if p > 0 => p,
|
||||
_ => 1024,
|
||||
};
|
||||
|
||||
// Release the hardware-parameters handle (it borrows `pcm`) so the PCM
|
||||
// can be moved out and returned to the caller.
|
||||
drop(hw);
|
||||
|
||||
Some((pcm, rate, channels, period))
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Terminal */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Switch stdin into raw (non-canonical, no-echo) terminal mode.
|
||||
*
|
||||
* Only active when stdin is a TTY; otherwise it returns None and leaves
|
||||
* the terminal alone (recording works, 'q' just is not available). Raw
|
||||
* mode makes each keystroke immediately available to read() (VMIN=1,
|
||||
* VTIME=0) with echo disabled, so the recording loop can detect 'q' as
|
||||
* it is typed. TCSANOW applies the change without flushing a key that
|
||||
* may already have been typed.
|
||||
*
|
||||
* Returns the saved terminal settings to be passed to restore_terminal()
|
||||
* later, or None if stdin is not a TTY or the change failed.
|
||||
*/
|
||||
fn enable_raw_mode() -> Option<libc::termios> {
|
||||
unsafe {
|
||||
if libc::isatty(libc::STDIN_FILENO) != 1 {
|
||||
return None;
|
||||
}
|
||||
let mut old: libc::termios = std::mem::zeroed();
|
||||
if libc::tcgetattr(libc::STDIN_FILENO, &mut old) != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut raw = old;
|
||||
raw.c_lflag &= !(libc::ICANON | libc::ECHO);
|
||||
raw.c_cc[libc::VMIN] = 1;
|
||||
raw.c_cc[libc::VTIME] = 0;
|
||||
// TCSANOW: don't flush a key that was already typed before raw mode
|
||||
if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(old)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Restore the terminal settings saved by enable_raw_mode().
|
||||
*
|
||||
* Re-applies the saved termios with TCSAFLUSH (drops any buffered
|
||||
* input/output) so the terminal is always left in its original state.
|
||||
* Safe to call even if raw mode was never enabled.
|
||||
*/
|
||||
fn restore_terminal(saved: &Option<libc::termios>) {
|
||||
if let Some(old) = saved {
|
||||
unsafe {
|
||||
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSAFLUSH, old);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Install handlers for SIGINT and SIGTERM that request a clean stop.
|
||||
*
|
||||
* Uses sigaction with zero flags (matching the C original) so that a
|
||||
* signal interrupts a blocking poll() with EINTR instead of being
|
||||
* transparently restarted; the loop then observes the STOP flag.
|
||||
*/
|
||||
fn install_signals() {
|
||||
unsafe {
|
||||
let mut sa: libc::sigaction = std::mem::zeroed();
|
||||
sa.sa_sigaction = handle_signal as *const () as usize;
|
||||
sa.sa_flags = 0;
|
||||
libc::sigemptyset(&mut sa.sa_mask);
|
||||
libc::sigaction(libc::SIGINT, &sa, std::ptr::null_mut());
|
||||
libc::sigaction(libc::SIGTERM, &sa, std::ptr::null_mut());
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Build the "<YYYYmmdd_HHMMSS>" timestamp used in the output filename.
|
||||
*
|
||||
* Uses the local time zone, exactly like the C original; falls back to a
|
||||
* fixed placeholder if the time or formatting calls fail.
|
||||
*/
|
||||
fn timestamp() -> String {
|
||||
let mut buf = [0u8; 32];
|
||||
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
|
||||
|
||||
let ok = unsafe {
|
||||
let t = libc::time(std::ptr::null_mut());
|
||||
!libc::localtime_r(&t, &mut tm).is_null()
|
||||
&& libc::strftime(
|
||||
buf.as_mut_ptr() as *mut libc::c_char,
|
||||
buf.len(),
|
||||
c"%Y%m%d_%H%M%S".as_ptr(),
|
||||
&tm,
|
||||
) != 0
|
||||
};
|
||||
|
||||
if !ok {
|
||||
return "00000000_000000".to_string();
|
||||
}
|
||||
let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(&buf[..len]).into_owned()
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* main */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Program entry point: parse options, record audio, write a WAV file.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Returns 0 on success, 1 on a runtime error, 2 on a command-line error.
|
||||
*/
|
||||
fn run() -> i32 {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut output_dir = String::from(".");
|
||||
let mut prefix = DEFAULT_PREFIX.to_string();
|
||||
let mut device = DEFAULT_DEVICE.to_string();
|
||||
let mut duration = 0.0f64; // 0 = record until stopped
|
||||
|
||||
// ---- argument parsing -------------------------------------------
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"-o" => {
|
||||
i += 1;
|
||||
if i >= args.len() {
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
output_dir = args[i].clone();
|
||||
}
|
||||
"-d" => {
|
||||
i += 1;
|
||||
if i >= args.len() {
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
match args[i].parse::<f64>() {
|
||||
Ok(d) if d.is_finite() && d > 0.0 => duration = d,
|
||||
_ => {
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
"-p" => {
|
||||
i += 1;
|
||||
if i >= args.len() || args[i].is_empty() {
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
prefix = args[i].clone();
|
||||
}
|
||||
"-D" => {
|
||||
i += 1;
|
||||
if i >= args.len() || args[i].is_empty() {
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
device = args[i].clone();
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
usage();
|
||||
return 0;
|
||||
}
|
||||
_ => {
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// ---- output directory and filename -------------------------------
|
||||
if !std::fs::metadata(&output_dir)
|
||||
.map(|m| m.is_dir())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
eprintln!("Error: output directory does not exist: {output_dir}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
let stamp = timestamp();
|
||||
let path_str = format!("{}/{prefix}__{stamp}.wav", output_dir);
|
||||
if path_str.len() >= 4096 {
|
||||
eprintln!("Error: output path too long");
|
||||
return 1;
|
||||
}
|
||||
let path = Path::new(&path_str);
|
||||
|
||||
// ---- audio device and output file --------------------------------
|
||||
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());
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- signals -----------------------------------------------------
|
||||
install_signals();
|
||||
|
||||
let stderr_tty = unsafe { libc::isatty(libc::STDERR_FILENO) } == 1;
|
||||
let use_stdin = unsafe { libc::isatty(libc::STDIN_FILENO) } == 1;
|
||||
|
||||
eprintln!("Recording to {}", path.display());
|
||||
eprintln!(
|
||||
"Using ALSA device '{device}' at {rate} Hz, {channels} channel(s), {DEFAULT_BITS}-bit"
|
||||
);
|
||||
eprintln!("Press q or Ctrl+C to stop.");
|
||||
|
||||
if let Err(err) = pcm.start() {
|
||||
eprintln!("Error: could not start capture on '{device}': {err}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
eprintln!("Recording started.");
|
||||
if stderr_tty {
|
||||
draw_meter(VU_MIN_DB, VU_MIN_DB, true);
|
||||
}
|
||||
|
||||
// ---- recording loop -----------------------------------------------
|
||||
let frame_samples = period as usize * channels as usize;
|
||||
let mut samples = vec![0i16; frame_samples];
|
||||
|
||||
let mut pfds = match pcm.get() {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
eprintln!("Error: could not query ALSA poll descriptors: {err}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let pcm_count = pfds.len();
|
||||
if pcm_count == 0 {
|
||||
eprintln!("Error: ALSA returned no poll descriptors");
|
||||
return 1;
|
||||
}
|
||||
if use_stdin {
|
||||
pfds.push(alsa::poll::pollfd {
|
||||
fd: libc::STDIN_FILENO,
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let saved_term = if use_stdin { enable_raw_mode() } else { None };
|
||||
|
||||
let io = match pcm.io_i16() {
|
||||
Ok(io) => io,
|
||||
Err(err) => {
|
||||
eprintln!("Error: could not open PCM I/O: {err}");
|
||||
restore_terminal(&saved_term);
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
let start_t = Instant::now();
|
||||
let mut last_draw = start_t;
|
||||
let mut data_bytes: u64 = 0;
|
||||
let mut result = 0;
|
||||
|
||||
while !STOP.load(Ordering::SeqCst) {
|
||||
let mut timeout: i32 = -1;
|
||||
|
||||
// automatic stop timer
|
||||
if duration > 0.0 {
|
||||
let remaining = duration - start_t.elapsed().as_secs_f64();
|
||||
if remaining <= 0.0 {
|
||||
break;
|
||||
}
|
||||
timeout = (remaining * 1000.0) as i32;
|
||||
if timeout < 1 {
|
||||
timeout = 1;
|
||||
}
|
||||
}
|
||||
|
||||
match alsa::poll::poll(&mut pfds, timeout) {
|
||||
Ok(0) => continue, // timed out; re-check timer
|
||||
Ok(_) => {}
|
||||
Err(err) if err.errno() == libc::EINTR => continue, // interrupted by a signal
|
||||
Err(err) => {
|
||||
eprintln!("Error: poll failed: {err}");
|
||||
result = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// keyboard input (raw mode, 'q' to quit)
|
||||
if use_stdin && (pfds[pcm_count].revents & libc::POLLIN) != 0 {
|
||||
let mut buf = [0u8; 16];
|
||||
let n = unsafe {
|
||||
libc::read(
|
||||
libc::STDIN_FILENO,
|
||||
buf.as_mut_ptr() as *mut libc::c_void,
|
||||
buf.len(),
|
||||
)
|
||||
};
|
||||
if n > 0 {
|
||||
for &b in &buf[..n as usize] {
|
||||
if b == b'q' || b == b'Q' {
|
||||
eprintln!("Quit key received, stopping...");
|
||||
STOP.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if STOP.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
// capture audio
|
||||
let revents = match pcm.revents(&pfds[..pcm_count]) {
|
||||
Ok(f) => f,
|
||||
Err(err) => {
|
||||
eprintln!("Error: could not read ALSA poll state: {err}");
|
||||
result = 1;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if !(revents.contains(alsa::poll::Flags::IN) || revents.contains(alsa::poll::Flags::ERR)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match io.readi(&mut samples) {
|
||||
Err(err) if err.errno() == libc::EINTR => continue,
|
||||
Err(err) if err.errno() == libc::EPIPE || err.errno() == libc::ESTRPIPE => {
|
||||
eprintln!("Capture underrun/device suspended, resuming...");
|
||||
if let Err(re) = pcm.try_recover(err, true) {
|
||||
eprintln!("Error: could not recover the ALSA stream: {re}");
|
||||
result = 1;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Error: failed to read audio: {err}");
|
||||
result = 1;
|
||||
break;
|
||||
}
|
||||
Ok(got) if got > 0 => {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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}");
|
||||
result = 1;
|
||||
break;
|
||||
}
|
||||
data_bytes += bytes as u64;
|
||||
|
||||
// VU meter, redrawn at most every 50 ms
|
||||
let meter_now = Instant::now();
|
||||
if stderr_tty && meter_now.duration_since(last_draw) >= VU_INTERVAL {
|
||||
let (l, r) = rms_levels(&samples[..n_samples], got, channels as usize);
|
||||
draw_meter(to_db(l), to_db(r), false);
|
||||
last_draw = meter_now;
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- wrap up ------------------------------------------------------
|
||||
drop(io);
|
||||
restore_terminal(&saved_term);
|
||||
eprintln!("\nRecording ended.");
|
||||
|
||||
match wav_finalize(&mut file, data_bytes) {
|
||||
Ok(()) => {
|
||||
if result == 0 {
|
||||
eprintln!("Saved {}", path.display());
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("Error: could not finalize WAV file {}", path.display());
|
||||
result = 1;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
ExitCode::from(run() as u8)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue