Files
bedd1c4098 feat(voice/clone): support MP3 and other audio formats for reference voices (#20)
## Summary
- Adds support for MP3, FLAC, OGG, and other audio formats when
uploading reference voices for cloning
- Replace WAV-only `audio_read()` with a `soundfile`-based
implementation
- Update web UI file picker to accept multiple audio formats

## Changes
- `pocket_tts/data/audio.py`: Use `soundfile` library instead of
Python's `wave` module
- `pocket_tts/main.py`: Preserve original file extension in temp files
for format detection
- `pocket_tts/main.py`: Add missing `truncate=True` to CLI generate
command
- `pocket_tts/static/index.html`: Update file input to accept
`.wav,.mp3,.flac,.ogg,.m4a,audio/*`
- `pyproject.toml`: Add `soundfile>=0.12.0` dependency

## Tested
- [x] Upload a WAV file as reference voice and generate speech
- [x] Upload an MP3 file as reference voice and generate speech
- [x] Upload a FLAC file as reference voice and generate speech
- [x] Verify CLI `--voice` option works with MP3 files

---------

Co-authored-by: Gabriel de Marmiesse <gabrieldemarmiesse@gmail.com>
Co-authored-by: Václav Volhejn <8401624+vvolhejn@users.noreply.github.com>
2026-01-20 10:12:50 +01:00

145 lines
4.9 KiB
Python

"""
Audio IO methods are defined in this module (info, read, write),
We rely on av library for faster read when possible, otherwise on torchaudio.
"""
import logging
import os
import sys
import wave
from contextlib import nullcontext
from pathlib import Path
from typing import Any
import numpy as np
import torch
from beartype.typing import Iterator
logger = logging.getLogger(__name__)
FIRST_CHUNK_LENGTH_SECONDS = float(os.environ.get("FIRST_CHUNK_LENGTH_SECONDS", "0"))
def audio_read(filepath: str | Path) -> tuple[torch.Tensor, int]:
"""Read audio file. WAV uses built-in wave module; other formats require soundfile."""
filepath = Path(filepath)
if filepath.suffix.lower() == ".wav":
# Use built-in wave module for WAV files
with wave.open(str(filepath), "rb") as wav_file:
sample_rate = wav_file.getframerate()
n_channels = wav_file.getnchannels()
raw_data = wav_file.readframes(-1)
samples = np.frombuffer(raw_data, dtype=np.int16).astype(np.float32) / 32768.0
if n_channels > 1:
samples = samples.reshape(-1, n_channels).mean(axis=1)
return torch.from_numpy(samples).unsqueeze(0), sample_rate
# For non-WAV formats, use soundfile (optional dependency)
try:
import soundfile as sf
except ImportError as e:
raise ImportError(
"soundfile is required to read non-WAV audio files. "
"Install with: `pip install soundfile` or `uvx --with soundfile`"
) from e
data, sample_rate = sf.read(str(filepath), dtype="float32")
if data.ndim == 1:
wav = torch.from_numpy(data).unsqueeze(0)
else:
wav = torch.from_numpy(data.mean(axis=1)).unsqueeze(0)
return wav, sample_rate
class StreamingWAVWriter:
"""WAV writer using Python's standard library wave module."""
def __init__(self, output_stream, sample_rate: int):
self.output_stream = output_stream
self.sample_rate = sample_rate
self.wave_writer = None
self.first_chunk_buffer = []
def write_header(self, sample_rate: int):
"""Initialize WAV writer with header."""
# For stdout streaming, we need to handle the unseekable stream case
# The wave module supports unseekable streams since Python 3.4
self.wave_writer = wave.open(self.output_stream, "wb")
self.wave_writer.setnchannels(1) # Mono
self.wave_writer.setsampwidth(2) # 16-bit
self.wave_writer.setframerate(sample_rate)
self.wave_writer.setnframes(1_000_000_000)
def write_pcm_data(self, audio_chunk: torch.Tensor):
"""Write PCM data using wave module."""
# Convert to int16 PCM bytes
chunk_int16 = (audio_chunk.clamp(-1, 1) * 32767).short()
chunk_bytes = chunk_int16.detach().cpu().numpy().tobytes()
if self.first_chunk_buffer is not None:
self.first_chunk_buffer.append(chunk_bytes)
total_length = sum(len(c) for c in self.first_chunk_buffer)
target_length = (
int(self.sample_rate * FIRST_CHUNK_LENGTH_SECONDS) * 2
) # 2 bytes per sample
if total_length < target_length:
return
self._flush()
return
# Use writeframesraw to avoid frame count validation for streaming
self.wave_writer.writeframesraw(chunk_bytes)
def _flush(self):
if self.first_chunk_buffer is not None:
self.wave_writer.writeframesraw(b"".join(self.first_chunk_buffer))
self.first_chunk_buffer = None
def finalize(self):
"""Close the wave writer."""
self._flush()
# Let's add 200ms of silence to ensure proper playback
silence_duration_sec = 0.2
num_silence_samples = int(self.sample_rate * silence_duration_sec)
self.wave_writer.writeframesraw(bytes(num_silence_samples * 2))
if self.wave_writer:
# do not update the header for unseekable streams
self.wave_writer._patchheader = lambda: None
self.wave_writer.close()
def is_file_like(obj):
"""Check if object has basic file-like methods."""
return all(hasattr(obj, attr) for attr in ["write", "close"])
def stream_audio_chunks(
path: str | Path | None | Any, audio_chunks: Iterator[torch.Tensor], sample_rate: int
):
"""Stream audio chunks to a WAV file or stdout, optionally playing them."""
if path == "-":
f = sys.stdout.buffer
elif path is None:
f = nullcontext()
elif is_file_like(path):
f = path
else:
f = open(path, "wb")
with f:
if path is not None:
writer = StreamingWAVWriter(f, sample_rate)
writer.write_header(sample_rate)
for chunk in audio_chunks:
# Then write to file
if path is not None:
writer.write_pcm_data(chunk)
if path is not None:
writer.finalize()