Skip to content

Phase 2 Plan 02-02: audio I/O hardening + WAV-export correctness - #96

Merged
debpalash merged 3 commits into
mainfrom
phase-2-plan-02-02-audio-io-hardening
May 20, 2026
Merged

Phase 2 Plan 02-02: audio I/O hardening + WAV-export correctness#96
debpalash merged 3 commits into
mainfrom
phase-2-plan-02-02-audio-io-hardening

Conversation

@debpalash

@debpalash debpalash commented May 20, 2026

Copy link
Copy Markdown
Owner

Closes BUG-01 / closes #48.

Summary

  • Centralizes every WAV/audio write in backend/api/routers/ on a single audited helper path in backend/services/audio_io.py. The two new helpers (_safe_torchaudio_save, _safe_soundfile_write) enforce CPU device, float32 dtype, [-1, 1] clamp, contiguous memory, and explicit encoding=PCM_S/PCM_F + bits_per_sample before delegating — defending against all four documented torchaudio.save silent-corruption modes plus the torchaudio 2.9+ TorchCodec-backend behavior drift.
  • The P0 atomic-write helper (atomic_save_wav, commit fb52140) is preserved — it now delegates the actual encode to _safe_torchaudio_save, so atomicity and audited normalization compose. Every byte that lands at a dub-output path was produced by the audited helper.
  • 12 grep-audit call sites in 5 router files migrated to the helpers (full table in .planning/phases/02-engine-isolation-subprocessbackend-indextts-wav-export-dubbi/02-02-SUMMARY.md). A CI grep gate in the new test file prevents future drift.

Coverage of the P0 commit

Commit fb52140 covered three dub-pipeline disk-write sites (dub_generate.py:289/328/390) with atomic os.replace semantics. This PR does not regress that coverage:

  • Those three sites still call atomic_save_wav unchanged.
  • atomic_save_wav now delegates internally to _safe_torchaudio_save, so the on-disk WAV at those three paths additionally inherits the new audit checks (clamp, contig, encoding lock).
  • backend/tests/test_atomic_wav.py (the P0 regression suite) still passes — 7/7 green.

New artifacts

  • backend/services/audio_io.py — extended (_safe_torchaudio_save, _safe_soundfile_write, atomic_save_wav now delegates to the safe helper)
  • tests/backend/services/test_audio_io.py — 29 parametric tests (25 pass + 4 skipped for MPS dtype incompatibility)
  • tests/backend/test_dub_pipeline_wav.py — 6 tests (5 pass + 1 xfail pending Phase 0 video fixture); includes in-process and subprocess grep gates, the torch.cat-of-slices [Bug] Exported Wav file is corrupted #48 smoking-gun reproduction, the atomic_save_wav assembly-pattern test, and the _safe_soundfile_write dub_core ASR-chunk test
  • .planning/phases/02-engine-isolation-subprocessbackend-indextts-wav-export-dubbi/02-02-SUMMARY.md — execution summary

Test plan

  • uv run pytest tests/backend/services/test_audio_io.py -v — 25 passed, 4 skipped (MPS dtype)
  • uv run pytest tests/backend/test_dub_pipeline_wav.py -v — 5 passed, 1 xfail (missing fixture)
  • uv run pytest tests/ -q --ignore=tests/manual — 348 passed, 10 skipped, 13 xfailed, 1 xpassed
  • uv run pytest tests/smoke/ -q — 4 passed
  • cd backend && uv run pytest tests/test_atomic_wav.py -v — 7 passed (P0 not regressed)
  • Grep gate: zero bare torchaudio.save / soundfile.write / sf.write in backend/api/routers/
  • git diff backend/services/sonitranslate.py empty (D1 locked decision)
  • No new Python dependencies
  • Cross-platform: macOS Apple Silicon verified; awaiting CI matrix for Linux + Windows
  • Manual smoke: dub a 5-second video and confirm the output WAV decodes cleanly + plays audible audio (deferred to release-bash)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved audio output reliability across all endpoints (batch processing, preview generation, audio synthesis, streaming endpoints) to prevent silent corruption and truncation issues.
  • Tests

    • Added comprehensive test coverage for audio encoding and output paths to enforce quality standards and prevent regressions.

Review Change Stack

debpalash and others added 3 commits May 20, 2026 06:28
Centralizes WAV/audio writes through a single audited path that defends
against the four documented torchaudio.save failure modes (CUDA/MPS
tensor, non-contiguous, out-of-range, wrong dtype) AND the torchaudio
2.9+ TorchCodec-delegation behavior drift.

* services/audio_io.py:_safe_torchaudio_save now performs:
  - .cpu() move (torchaudio cannot serialize CUDA/MPS)
  - dtype coercion to torch.float32
  - .clamp(-1.0, 1.0) (out-of-range = silent clipping on some backends)
  - .unsqueeze(0) for 1D (mono) inputs
  - .contiguous() (torch.cat of slices = non-contig = silent corruption)
  - explicit encoding="PCM_S/PCM_F" + bits_per_sample so future
    torchaudio backend selection cannot drift the on-disk format
  - format passthrough for wav/flac/mp3/ogg with encoding-kwarg fallback
    for older codec builds

* services/audio_io.py:_safe_soundfile_write — sibling helper for the
  one sf.write call site (dub_core.py). Applies the same dtype/contig/
  range checks before delegating to soundfile.write.

* services/audio_io.py:atomic_save_wav (existing P0 helper) now
  delegates the actual encode to _safe_torchaudio_save so atomicity
  and correctness compose: every byte that lands at the target path
  was produced by the audited helper.

* tests/backend/services/test_audio_io.py — 29 tests (25 pass + 4
  skipped for MPS dtype incompatibility): parametric round-trip across
  dtype x device x contiguity, plus out-of-range clamp, format
  passthrough, in-memory buffer, empty-tensor rejection, 1D auto-
  unsqueeze, and a smoke check that atomic_save_wav inherits the
  safety guarantees.

No new Python dependencies. SoniTranslate untouched (D1 locked).

Refs BUG-01 / #48.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrates all 12 grep-audit bare audio-write call sites in
backend/api/routers/ to route through services.audio_io. Closes the
last surface area of BUG-01 / #48 that the P0 atomic-write commit
(fb52140) did not cover.

Sites migrated (grep before → after):

  generation.py:148  torchaudio.save     → _safe_torchaudio_save
  generation.py:162  torchaudio.save     → _safe_torchaudio_save
  openai_compat.py:155 torchaudio.save   → _safe_torchaudio_save
  openai_compat.py:160 torchaudio.save   → _safe_torchaudio_save
  openai_compat.py:168 torchaudio.save   → _safe_torchaudio_save
  openai_compat.py:172 torchaudio.save   → _safe_torchaudio_save
  openai_compat.py:178 torchaudio.save   → _safe_torchaudio_save
  openai_compat.py:182 torchaudio.save   → _safe_torchaudio_save
  openai_compat.py:193 torchaudio.save   → _safe_torchaudio_save
  dub_generate.py:509  torchaudio.save   → _safe_torchaudio_save
  batch.py:341         torchaudio.save   → atomic_save_wav (track assembly)
  dub_core.py:438      sf.write          → _safe_soundfile_write

batch.py:341 specifically swapped to atomic_save_wav (not just the safe
helper) because it writes the final track to disk — same shape as
dub_generate.py:390 — and needs atomic publication, not only audited
encoding. atomic_save_wav already delegates internally to
_safe_torchaudio_save (per the Task 1 commit) so it inherits both
guarantees.

openai_compat.py:185 pcm branch produces raw int16 bytes (no
container), so it can't go through _safe_torchaudio_save; it now
inlines the same .cpu/.float32/.clamp/.contiguous sanity steps the
helper enforces.

tests/backend/test_dub_pipeline_wav.py:
  - test_no_bare_audio_writes_in_routers (in-process grep gate)
  - test_no_bare_audio_writes_via_subprocess_grep (CI-shell parity gate)
  - test_track_assembly_handles_non_contig_after_torch_cat (the #48
    smoking-gun reproduction — torch.cat of out-of-range non-contig
    slices saved through the helper)
  - test_atomic_save_wav_assembly_pattern (same shape, via
    atomic_save_wav)
  - test_safe_soundfile_write_dub_core_pattern (ASR transcribe-chunk
    pattern from dub_core.py)
  - test_dub_pipeline_produces_valid_wav (xfailed — Phase 0 fixture
    sample_5s.mp4 not present; structural reproduction tests above
    already cover the helper code path #48 went through)

Grep gate is green:
  grep -nE '(torchaudio\.save|soundfile\.write|sf\.write)\(' \
    backend/api/routers/ -r --include='*.py' \
    | grep -v '_safe_torchaudio_save\|_safe_soundfile_write' \
    | grep -v '^[^:]*:[[:space:]]*#' \
  returns 0 lines.

Full suite green: 348 passed, 10 skipped, 13 xfailed, 1 xpassed.
SoniTranslate untouched (D1 locked).

Closes BUG-01 / #48.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR hardens audio I/O by centralizing write operations through audited helpers (_safe_torchaudio_save, _safe_soundfile_write), migrating five router call-sites, adding comprehensive test coverage, and enforcing structural regression gates to prevent future bare audio-write calls.

Changes

Audio I/O Hardening & Router Migration

Layer / File(s) Summary
Core audio I/O helpers and unit tests
backend/services/audio_io.py, tests/backend/services/test_audio_io.py
New _safe_torchaudio_save validates non-empty tensors, forces CPU/float32, clamps to [-1, 1], normalizes 2D shape, ensures contiguity, and persists PCM_16 encoding. New _safe_soundfile_write coerces numpy, rejects empty, clamps floats, and writes PCM_16. atomic_save_wav now delegates encoding to _safe_torchaudio_save. Parametric unit tests cover dtypes (float32/float64/int16), devices (CPU/MPS/CUDA), contiguity modes, clamping, encoding persistence, format variants (FLAC), in-memory streams, shape handling, and failure cases.
Simple router migrations: batch, dub_core, dub_generate
backend/api/routers/batch.py, backend/api/routers/dub_core.py, backend/api/routers/dub_generate.py
Batch dubbing replaces torchaudio.save with atomic_save_wav for dubbed audio. Dub-core replaces soundfile.write with _safe_soundfile_write for transcription chunks. Dub-generate replaces torchaudio.save with _safe_torchaudio_save for preview WAV. All include inline documentation referencing safe assembly patterns.
Complex router migrations: generation and openai_compat
backend/api/routers/generation.py, backend/api/routers/openai_compat.py
Generation uses _safe_torchaudio_save for both disk output and streaming buffer. OpenAI-compat refactors _encode_audio to route wav/flac/mp3/opus through _safe_torchaudio_save with format-specific fallback behavior; adds PCM int16 fast path (CPU move, float32 cast, clamp, contiguity enforcement) before int16 sample production; preserves aac-as-wav fallback.
Structural regression tests and e2e validation
tests/backend/test_dub_pipeline_wav.py
Grep-based gates scan routers for bare audio-write calls and enforce audited-helper-only policy. Reproduces #48 non-contiguous tensor failure modes and validates round-trip WAV integrity (frames, samplerate, subtype, amplitude, clamping). Includes dub-core chunk-write pattern validation and xfail e2e test pending sample_5s.mp4 fixture.
Plan 02-02 completion summary
.planning/phases/02-engine-isolation-subprocessbackend-indextts-wav-export-dubbi/02-02-SUMMARY.md
Documents shipped audio I/O hardening, router and helper wiring, fixture status (xfail/skip expectations), verification gates, torchaudio version notes, and follow-up constraints for Plan 02-03 sidecar write routing.

Sequence Diagram

sequenceDiagram
  participant Router as Route Handler
  participant SafeWrite as _safe_torchaudio_save
  participant TorchAudio as torchaudio.save
  Router->>SafeWrite: tensor (raw float/int/non-contiguous)
  SafeWrite->>SafeWrite: CPU move & float32 coerce
  SafeWrite->>SafeWrite: clamp values to [-1, 1]
  SafeWrite->>SafeWrite: reshape to 2D (channels, samples)
  SafeWrite->>SafeWrite: enforce contiguity
  SafeWrite->>TorchAudio: call with explicit bits_per_sample=16
  TorchAudio-->>SafeWrite: WAV file (PCM_16 on-disk format)
  SafeWrite-->>Router: return or fallback on non-WAV format error
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

  • debpalash/OmniVoice-Studio#88: Introduces atomic_save_wav to prevent truncated/corrupted WAV output; this PR expands it with additional safe helpers and routes all routers through the audited audio-write path.

Poem

🐰 With safe hands and careful eyes,
we guard each WAV from silent demise,
no truncation, no decay—
clamped samples light the way,
audited writes keep corruption at bay! 🎙️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main objective: audio I/O hardening and WAV-export correctness via centralized audited helpers, which is the core focus across all changed files.
Description check ✅ Passed The description provides comprehensive coverage of objectives, changes, test results, and artifacts; it clearly explains the problem (torchaudio.save silent corruption), solution (centralized audited helpers), and verification steps. Minor gaps: CI cross-platform results incomplete (macOS verified, Linux/Windows pending), and manual smoke test deferred.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch phase-2-plan-02-02-audio-io-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/backend/services/test_audio_io.py (1)

80-90: ⚡ Quick win

Add an int16 anti-clipping assertion to catch normalization regressions.

Current checks can pass even if int16 input is saturated to rails. Add a simple distribution/fidelity assertion for the int16 branch.

💡 Proposed test addition
     samples, _ = sf.read(str(target))
     assert samples.size > 0
     assert abs(samples).max() > 0.1, (
         f"samples too quiet: max={abs(samples).max()} — silent-corruption mode"
     )
+    if dtype == torch.int16:
+        unclipped_ratio = float(np.mean(np.abs(samples) < 0.98))
+        assert unclipped_ratio > 0.5, (
+            "int16 path appears heavily clipped; expected normalized waveform, "
+            f"got unclipped_ratio={unclipped_ratio:.3f}"
+        )

Also applies to: 115-119

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/backend/services/test_audio_io.py` around lines 80 - 90, Add an
anti-clipping assertion for the int16 branch in
tests/backend/services/test_audio_io.py: when dtype == torch.int16 (where
wave_f32 is created via _sine_tensor and wave = (wave_f32 *
32767).to(torch.int16)), assert that the int16 wave is not saturated to rails
(e.g., ensure not all values equal int16 max/min and that a reasonable fraction
lie between rails) to catch normalization regressions; add the same kind of
distribution/fidelity check for the other int16 case around the second int16
branch so both int16 test paths validate non-clipped signal content.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/services/audio_io.py`:
- Around line 124-131: The code currently casts integer PCM tensors to float32
then immediately clamps, which collapses integer ranges to ±1.0; modify the
logic in the audio conversion routine (the block handling tensor dtype in
backend/services/audio_io.py) to first detect integer dtypes (e.g., torch.int16,
torch.int32, torch.int8, torch.uint8), convert them to float32 and normalize by
their full-scale value (e.g., divide by 32768 for int16, 2147483648 for int32,
128 for int8, and 255/128 handling for uint8) to map samples into the -1.0..1.0
range, then apply tensor.clamp(-1.0, 1.0); leave non-integer floats unchanged
except for the existing dtype cast and clamping. Ensure you update the branch
that references tensor.dtype and tensor.clamp so integer inputs are normalized
before clamping.
- Around line 248-263: The function _safe_soundfile_write currently allows
NaN/Inf in samples to reach sf.write; add a finite-value check after
casting/contiguity (e.g., check np.isfinite(samples).all()) and if any
non-finite values are present raise a clear ValueError (include
sample_rate/path/subtype context) instead of calling sf.write; ensure this
validation runs for both float and integer branches before calling sf.write to
prevent invalid files.

---

Nitpick comments:
In `@tests/backend/services/test_audio_io.py`:
- Around line 80-90: Add an anti-clipping assertion for the int16 branch in
tests/backend/services/test_audio_io.py: when dtype == torch.int16 (where
wave_f32 is created via _sine_tensor and wave = (wave_f32 *
32767).to(torch.int16)), assert that the int16 wave is not saturated to rails
(e.g., ensure not all values equal int16 max/min and that a reasonable fraction
lie between rails) to catch normalization regressions; add the same kind of
distribution/fidelity check for the other int16 case around the second int16
branch so both int16 test paths validate non-clipped signal content.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f19cf185-d7ec-40e8-97e6-f65d10c47e45

📥 Commits

Reviewing files that changed from the base of the PR and between 715766c and 99540c2.

📒 Files selected for processing (9)
  • .planning/phases/02-engine-isolation-subprocessbackend-indextts-wav-export-dubbi/02-02-SUMMARY.md
  • backend/api/routers/batch.py
  • backend/api/routers/dub_core.py
  • backend/api/routers/dub_generate.py
  • backend/api/routers/generation.py
  • backend/api/routers/openai_compat.py
  • backend/services/audio_io.py
  • tests/backend/services/test_audio_io.py
  • tests/backend/test_dub_pipeline_wav.py

Comment on lines +124 to +131
if tensor.dtype != torch.float32:
tensor = tensor.to(torch.float32)

# ── Failure mode 3: out-of-range values. apply_mastering produces
# transient peaks > 1.0 on dynamic input; the soundfile backend
# wraps these around (int16 overflow) on some platforms instead of
# clipping, producing audible pops.
tensor = tensor.clamp(-1.0, 1.0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize integer PCM before clamping to avoid hard clipping.

On Line 124, integer tensors are cast to float32 and then clamped. For int16/int32 inputs this collapses most samples to ±1.0 and audibly distorts output.

💡 Proposed fix
-    if tensor.dtype != torch.float32:
-        tensor = tensor.to(torch.float32)
+    if tensor.is_floating_point():
+        if tensor.dtype != torch.float32:
+            tensor = tensor.to(torch.float32)
+    elif tensor.dtype in (torch.int8, torch.int16, torch.int32, torch.int64):
+        info = torch.iinfo(tensor.dtype)
+        scale = float(max(abs(info.min), info.max))
+        tensor = tensor.to(torch.float32) / scale
+    else:
+        raise TypeError(
+            f"_safe_torchaudio_save unsupported dtype {tensor.dtype}; expected float or signed int"
+        )
 
     # ── Failure mode 3: out-of-range values...
     tensor = tensor.clamp(-1.0, 1.0)
+    if not torch.isfinite(tensor).all():
+        raise ValueError("_safe_torchaudio_save expects finite samples (no NaN/Inf)")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/audio_io.py` around lines 124 - 131, The code currently
casts integer PCM tensors to float32 then immediately clamps, which collapses
integer ranges to ±1.0; modify the logic in the audio conversion routine (the
block handling tensor dtype in backend/services/audio_io.py) to first detect
integer dtypes (e.g., torch.int16, torch.int32, torch.int8, torch.uint8),
convert them to float32 and normalize by their full-scale value (e.g., divide by
32768 for int16, 2147483648 for int32, 128 for int8, and 255/128 handling for
uint8) to map samples into the -1.0..1.0 range, then apply tensor.clamp(-1.0,
1.0); leave non-integer floats unchanged except for the existing dtype cast and
clamping. Ensure you update the branch that references tensor.dtype and
tensor.clamp so integer inputs are normalized before clamping.

Comment on lines +248 to +263
if samples.dtype not in (np.float32, np.float64, np.int16, np.int32):
samples = samples.astype(np.float32)

# Out-of-range protection for float inputs.
if samples.dtype in (np.float32, np.float64):
# ``np.clip`` with ``out=`` requires the out array to be
# writable + same dtype. ``np.ascontiguousarray`` may return
# the original array (writable) or a copy (also writable), so
# clipping in place is safe after it.
samples = np.ascontiguousarray(samples)
np.clip(samples, -1.0, 1.0, out=samples)
else:
samples = np.ascontiguousarray(samples)

sf.write(path, samples, sample_rate, subtype=subtype)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject NaN/Inf samples before writing with soundfile.

_safe_soundfile_write documents finite-sample invariants but currently allows NaN/Inf through to sf.write, which can produce invalid output behavior.

💡 Proposed fix
     if samples.dtype in (np.float32, np.float64):
         # ``np.clip`` with ``out=`` requires the out array to be
         # writable + same dtype. ``np.ascontiguousarray`` may return
         # the original array (writable) or a copy (also writable), so
         # clipping in place is safe after it.
         samples = np.ascontiguousarray(samples)
+        if not np.isfinite(samples).all():
+            raise ValueError("_safe_soundfile_write expects finite samples (no NaN/Inf)")
         np.clip(samples, -1.0, 1.0, out=samples)
     else:
         samples = np.ascontiguousarray(samples)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/audio_io.py` around lines 248 - 263, The function
_safe_soundfile_write currently allows NaN/Inf in samples to reach sf.write; add
a finite-value check after casting/contiguity (e.g., check
np.isfinite(samples).all()) and if any non-finite values are present raise a
clear ValueError (include sample_rate/path/subtype context) instead of calling
sf.write; ensure this validation runs for both float and integer branches before
calling sf.write to prevent invalid files.

@debpalash
debpalash merged commit c6e9bbc into main May 20, 2026
8 checks passed
@debpalash
debpalash deleted the phase-2-plan-02-02-audio-io-hardening branch May 20, 2026 01:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Exported Wav file is corrupted

1 participant