程序遭強制終止後,如何保持 WAV 錄音檔可讀
Your WAV file says it's empty: keeping recordings readable when the process gets killed
Rust 音訊引擎現會每錄滿 5 秒音訊更新 WAV 標頭,另提供舊錄音修復功能,令程序遭強制終止後的檔案仍可由標準讀取器開啟。修復流程會建立備份,並在同一資料夾以 os.replace 原子替換檔案;作者亦以獨立子程序實際測試強制終止,確認檔案仍能開啟。
I found the bug through a test that transcribed nothing. A streaming speech-to-text harness read a recorded call, sent zero audio, and reported success. The WAV file was several megabytes, but Python's wave module said it had 0 frames. So did the media players.
Then I checked the rest of the recordings folder: 4 of 19 files looked the same. Every one of them came from a session that had ended with Ctrl+C instead of a clean stop. These were interview recordings people wanted to keep. The audio was all there; the files just said otherwise.
Why the file lies
A canonical PCM WAV file starts with a 44-byte header, and two of its fields are sizes:
- bytes 4–7: RIFF chunk size, which is the file size minus 8;
- bytes 40–43:
datachunk size, the number of audio bytes that follow.
A streaming recorder doesn't know the final size when it starts. The usual approach writes the header with zeros, appends audio for the whole call, then seeks back and patches both sizes on close. If the process dies before close, the sizes stay zero. Readers that trust the header, which is most of them, see an empty file. Some APIs scan the raw data anyway and hide the problem, which is why it took a while to notice.
Fix 1: checkpoint the header while recording
The recorder in our Rust audio engine now patches the header periodically instead of only on close. A dedicated writer thread receives PCM chunks and writes them through a BufWriter. After every 5 seconds of audio (sample_rate × 2 bytes × 5), it checkpoints:
fn checkpoint(&mut self) -> Result<()> {
self.writer.flush().context("failed to flush WAV audio")?;
self.writer.seek(SeekFrom::Start(4))?;
self.writer.write_all(&(self.data_len + WAV_HEADER_LEN - 8).to_le_bytes())?;
self.writer.seek(SeekFrom::Start(40))?;
self.writer.write_all(&self.data_len.to_le_bytes())?;
self.writer.flush().context("failed to checkpoint WAV recording")?;
self.writer.seek(SeekFrom::Start(u64::from(WAV_HEADER_LEN) + u64::from(self.data_len)))?;
self.checkpoint_data_len = self.data_len;
Ok(())
}
The order matters. Audio is flushed first, so the header never claims bytes that aren't on disk yet. Then both size fields are patched and flushed, and the writer seeks back to the end so the next chunk appends rather than overwrites. Closing the recorder is just one last checkpoint.
If the process is killed now, the header is at most one checkpoint behind. Players see a valid file that ends a few seconds early, and whatever audio reached the disk after the last checkpoint is still in the file for recovery (below).
Two guards came with it:
- Size limit. WAV sizes are 32-bit. The writer uses checked arithmetic and refuses to grow past 4 GiB instead of wrapping the counter, which would produce a header that lies in a new way.
- Backpressure. The capture side sends chunks over a bounded channel of 128. When it's full, for example when a reconnect releases a backlog of audio, the sender waits up to 100 ms before giving up. A momentary stall shouldn't end the recording for the rest of the call.
Testing a crash, for real
A unit test can't kill its own process, so the test spawns its own test binary as a child. The child runs an ignored helper test that writes 80,000 samples (5 seconds at 16 kHz) and then sleeps. The parent waits until the file is 160,044 bytes with a data size of 160,000 in the header, then kills the child:
let mut child = command.spawn().unwrap();
let ready = loop {
if std::fs::read(&path).ok().is_some_and(|bytes| bytes.len() >= 160_044 &&
u32::from_le_bytes(bytes[40..44].try_into().unwrap()) == 160_000) { break true; }
if std::time::Instant::now() >= deadline { break false; }
std::thread::sleep(std::time::Duration::from_millis(20));
};
let _ = child.kill();
After the kill it reads the file again and checks the size and the header field. The test proves what users depend on: a hard kill leaves a file that standard readers open. On Windows the child gets the CREATE_NO_WINDOW flag so the test doesn't flash a console.
Fix 2: repair the files you already have
Old recordings still had zero-size headers, and even with checkpoints the last few seconds sit after the recorded size. The app has a "recover audio" action on a finished session. The Python side is careful in a few specific ways:
-
Refuse anything unexpected. The file must be inside the recordings folder (no symlinks), start with
RIFF/WAVEfmt, havedataat offset 36, and be 16-bit mono PCM with a sane sample rate. Anything else is "not a recording of ours" and is left alone. -
Compute the real size from the file length, rounded down to whole samples:
(size - 44) // alignment * alignment. A trailing half-sample from a write cut short gets trimmed and reported as such. - Do nothing if the header is already right. Recovery runs repeatedly without side effects.
-
Never edit in place. Copy the original to
name.wav.before-recovery, adding a numeric suffix if one already exists. Write the patched header and the audio to a temporary file in the same folder,fsync, thenos.replaceit over the original. If the source changes size mid-copy, it raises instead of producing a half-file.
patched = bytearray(header)
struct.pack_into("<I", patched, 4, data_size + 36)
struct.pack_into("<I", patched, 40, data_size)
os.replace is atomic on the same volume, so at every moment there's either the old file or the complete new one, plus the backup.
The endpoint also refuses to run while the session is still recording. Repairing a file that another process is appending to is a race you don't want.
What I'd tell myself earlier
- If your format has sizes in the header, decide what a crash leaves behind before you ship. "We patch it on close" means "we lose it on crash".
- Flush data before you update the header that describes it.
- Test the crash with a real process kill, not with a mock.
- Repair tools should be idempotent, keep a backup, and replace atomically. The user's only copy is in that file.
I'm building Talkaside, a Windows app for job interviews and calls in another language.
Drafted with AI assistance from this project's code and logs.
來源:DEV Community · dev.to

