P0(dub): atomic WAV writes — closes #48

Direct torchaudio.save(path, ...) writes bytes as the encoder produces
them. SIGKILL, OOM kill, or Tauri sidecar reap mid-write leaves the file
at `path` truncated. Downstream tools (ffmpeg in the dub mux, NLEs the
user imports the WAV into) happily read truncated RIFF — the header
appears first, then the data chunk gets cut short — and surface as
silently corrupt audio later in the pipeline. That's the shape of #48.

New helper services/audio_io.py:atomic_save_wav() writes to a sibling
temp file in the same directory then os.replace() into place. POSIX
rename(2) is atomic; os.replace() ports the same guarantee to Windows.
Either the target ends up with a complete WAV or it keeps its previous
contents (or never exists) — no third state.

Migrated three call sites in api/routers/dub_generate.py:
- L289: RVC per-segment write
- L328: deferred batch write of all segments
- L390: final mixed-track export

Left L508 alone — it writes to BytesIO (in-memory response body), no
atomicity needed.

Implementation note (recorded as a docstring in audio_io.py): the temp
file must end in `.wav`, not `.tmp`. torchaudio.save infers the output
format from the path suffix and ignores the `format=` kwarg with the
soundfile backend. A `.tmp` suffix raises "Unsupported format: tmp". The
leading dot + target-name prefix still marks the file as transient.

Tests in backend/tests/test_atomic_wav.py:
- success path: writes valid WAV, no temp leaks, overwrites cleanly
- atomicity: target unchanged when save raises (pre-existing target)
- atomicity: target absent when save raises (new target path)
- no temp leaks on failure
- temp file lives in target_dir (cross-fs renames are not atomic)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
debpalash
2026-05-19 09:24:50 +05:30
co-authored by Claude Opus 4.7
parent 92f716e0d4
commit fb521400a2
3 changed files with 233 additions and 3 deletions
+4 -3
View File
@@ -14,6 +14,7 @@ from core.tasks import task_manager
from schemas.requests import DubRequest
from services.model_manager import get_model, _gpu_pool
from services.audio_dsp import apply_mastering, normalize_audio
from services.audio_io import atomic_save_wav
from services.rvc import apply_rvc, is_enabled as rvc_is_enabled
from services.incremental import segment_fingerprint
from services.watermark import embed_watermark
@@ -286,7 +287,7 @@ async def dub_generate(job_id: str, req: DubRequest):
# when RVC is active (uncommon path).
if rvc_is_enabled():
seg_wav_path = os.path.join(DUB_DIR, job_id, f"seg_{i}.wav")
torchaudio.save(seg_wav_path, audio_tensor, _model.sampling_rate)
atomic_save_wav(seg_wav_path, audio_tensor, _model.sampling_rate)
try:
await loop.run_in_executor(_gpu_pool, apply_rvc, seg_wav_path)
rvc_wav, rvc_sr = torchaudio.load(seg_wav_path)
@@ -325,7 +326,7 @@ async def dub_generate(job_id: str, req: DubRequest):
try:
# Apply invisible watermark before writing to disk
_wav = embed_watermark(_wav, _sr)
torchaudio.save(seg_wav_path, _wav, _sr)
atomic_save_wav(seg_wav_path, _wav, _sr)
except Exception as e:
logger.warning("deferred seg write failed for %s: %s", _sid, e)
if _fp is not None:
@@ -387,7 +388,7 @@ async def dub_generate(job_id: str, req: DubRequest):
_t_save_0 = time.perf_counter()
# Apply invisible watermark to the final assembled track
full_audio = embed_watermark(full_audio, sr)
torchaudio.save(track_path, full_audio, sr)
atomic_save_wav(track_path, full_audio, sr)
_t_save = time.perf_counter() - _t_save_0
_t_mix = _t_save_0 - _t_loop_end
job["dubbed_tracks"][lang_code] = {
+83
View File
@@ -0,0 +1,83 @@
"""Atomic disk writes for audio files.
A direct ``torchaudio.save(path, ...)`` writes bytes to ``path`` as the
encoder produces them. If the process is killed mid-write (SIGKILL, OOM
kill, power loss, Tauri sidecar reap), the file at ``path`` exists but is
truncated. Downstream tools — ffmpeg in the dub mux step, NLEs the user
imports the WAV into — often happily read a truncated RIFF (the header
appears first, then the data chunk gets cut short), producing silently
corrupt audio later in the pipeline. That is the root of issue #48.
``atomic_save_wav`` writes to a sibling temp file in the same directory and
``os.replace()`` it into place once encoding completes. POSIX guarantees
``rename(2)`` is atomic on the same filesystem; ``os.replace()`` makes the
same guarantee portable to Windows, including the case where the target
path already exists.
Either the new file fully exists at ``target_path`` after the call returns,
or the target keeps its previous contents (or never existed). There is no
intermediate window where a partial WAV is visible at ``target_path``.
Closes #48.
"""
from __future__ import annotations
import logging
import os
import tempfile
from typing import Any
import torch
import torchaudio
logger = logging.getLogger("omnivoice.audio_io")
def atomic_save_wav(
target_path: str,
audio: torch.Tensor,
sample_rate: int,
**kwargs: Any,
) -> None:
"""Write a WAV to ``target_path`` atomically.
Implementation: write to a sibling temp file in the same directory, then
``os.replace()`` into place. Cross-filesystem renames are *not* atomic
on POSIX, so the temp file must live next to the target — that is why
we use ``dir=target_dir`` instead of the system temp dir.
Args:
target_path: Final destination. Parent directory must already exist.
audio: ``(channels, samples)`` tensor — the same shape
``torchaudio.save`` expects.
sample_rate: WAV sample rate in Hz.
**kwargs: Forwarded to ``torchaudio.save``.
Raises:
Whatever ``torchaudio.save`` raises. The temp file is unlinked on
failure so we do not leak ``.tmp`` files in ``DUB_DIR``.
"""
target_dir = os.path.dirname(target_path) or "."
target_base = os.path.basename(target_path)
# The temp file must end in ``.wav`` even though it is conceptually a
# ``.tmp`` file. torchaudio.save infers the output format from the path
# suffix and *ignores* the ``format=`` kwarg with the soundfile backend
# — a ``.tmp`` suffix raises ``ValueError: Unsupported format: tmp``.
# The leading dot + ``target_base`` prefix still marks the file as
# transient and groups it next to its target in directory listings.
fd, tmp_path = tempfile.mkstemp(
prefix=f".{target_base}.",
suffix=".wav",
dir=target_dir,
)
os.close(fd) # torchaudio reopens by path; we just needed a unique name.
try:
torchaudio.save(tmp_path, audio, sample_rate, **kwargs)
os.replace(tmp_path, target_path)
except BaseException:
# BaseException so we clean up on KeyboardInterrupt + SystemExit too.
try:
os.unlink(tmp_path)
except OSError:
pass
raise
+146
View File
@@ -0,0 +1,146 @@
"""Tests for ``services.audio_io.atomic_save_wav`` — closes #48.
The invariant we are protecting: when ``atomic_save_wav`` returns, the
target path either contains a complete, valid WAV or is unchanged. There
is no third state where a partial WAV is visible at the target path and
downstream tools (ffmpeg in the dub mux, NLEs the user imports the WAV
into) read truncated audio without an error.
"""
import os
import sys
from pathlib import Path
import pytest
import torch
import torchaudio
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from services.audio_io import atomic_save_wav # noqa: E402
class TestSuccessPath:
def test_writes_valid_wav(self, tmp_path: Path):
target = tmp_path / "out.wav"
audio = torch.randn(1, 24000) # 1s mono @ 24kHz
atomic_save_wav(str(target), audio, 24000)
assert target.exists()
loaded, sr = torchaudio.load(str(target))
assert sr == 24000
assert loaded.shape == audio.shape
def test_no_temp_leaks_on_success(self, tmp_path: Path):
target = tmp_path / "out.wav"
atomic_save_wav(str(target), torch.zeros(1, 100), 24000)
leaked = [p for p in tmp_path.glob(".*") if p.name.startswith(".")]
assert leaked == [], f"leaked temp files after success: {leaked}"
def test_overwrites_existing_target(self, tmp_path: Path):
target = tmp_path / "out.wav"
# Pre-populate with a different-length WAV
torchaudio.save(str(target), torch.zeros(1, 1000), 24000)
old_samples = torchaudio.load(str(target))[0].shape[-1]
new_audio = torch.randn(1, 5000)
atomic_save_wav(str(target), new_audio, 24000)
loaded, _ = torchaudio.load(str(target))
assert loaded.shape[-1] == 5000
assert loaded.shape[-1] != old_samples
class TestAtomicity:
"""The core invariant of #48: no partial files at the target path."""
def test_target_unchanged_when_save_raises(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
target = tmp_path / "out.wav"
original_bytes = b"PREVIOUS-CONTENT-DO-NOT-CORRUPT"
target.write_bytes(original_bytes)
def explode(*args, **kwargs):
raise RuntimeError("simulated kill mid-write")
# Patch the symbol *inside* the audio_io module, not the global —
# rebinding torchaudio.save would leak into other tests.
monkeypatch.setattr(
"services.audio_io.torchaudio.save", explode
)
with pytest.raises(RuntimeError, match="simulated kill"):
atomic_save_wav(str(target), torch.zeros(1, 100), 24000)
assert target.read_bytes() == original_bytes, (
"atomic_save_wav must not modify the target path on failure"
)
def test_no_temp_leaks_on_failure(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
target = tmp_path / "out.wav"
def explode(*args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(
"services.audio_io.torchaudio.save", explode
)
with pytest.raises(RuntimeError):
atomic_save_wav(str(target), torch.zeros(1, 100), 24000)
leaked = [p for p in tmp_path.glob(".*") if p.name.startswith(".")]
assert leaked == [], f"leaked temp files after failure: {leaked}"
def test_target_absent_when_save_raises_on_new_path(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
target = tmp_path / "never-existed.wav"
assert not target.exists()
def explode(*args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(
"services.audio_io.torchaudio.save", explode
)
with pytest.raises(RuntimeError):
atomic_save_wav(str(target), torch.zeros(1, 100), 24000)
assert not target.exists(), (
"atomic_save_wav must not create the target path on failure"
)
def test_temp_file_lives_in_target_dir(self, tmp_path: Path):
"""Cross-fs renames are not atomic on POSIX. The temp file *must*
live next to the target so os.replace() stays a single rename().
We assert this by intercepting torchaudio.save to inspect the path
it was handed.
"""
target = tmp_path / "out.wav"
captured: list[str] = []
# Capture the path torchaudio.save is called with, then call the
# real implementation so the test still ends in a valid WAV.
from services import audio_io as _aio
real_save = _aio.torchaudio.save
def spy(path, *args, **kwargs):
captured.append(path)
return real_save(path, *args, **kwargs)
import unittest.mock
with unittest.mock.patch.object(_aio.torchaudio, "save", side_effect=spy):
atomic_save_wav(str(target), torch.zeros(1, 100), 24000)
assert len(captured) == 1
tmp_used = captured[0]
assert os.path.dirname(tmp_used) == str(tmp_path), (
f"temp file {tmp_used} not in target dir {tmp_path}"
"cross-fs rename would break atomicity"
)
assert tmp_used != str(target)