Quality-first multi-pass audiobook generation from English manuscripts. Includes: - umbraaudio/ core package (pipeline, config, voices, web review UI) - scripts/ CLI utilities + model download script - tts_server/ remote TTS server - specs/ design docs, tests/, pyproject.toml + uv.lock - Qwen3-TTS git submodule (pinned 022e286) with local demo.py UI patch saved at scripts/qwen3_tts_demo_umbra_patch.patch - .env with runtime config/secrets Excluded (see .gitignore): models/ (downloaded via scripts/download_models.sh), output/, logs/, .venv, .umbra runtime data, and book projects (DealHunterShip_*), which are committed separately.
205 lines
5.4 KiB
Markdown
205 lines
5.4 KiB
Markdown
# Spec: Normalization & Watermarking
|
|
|
|
## Overview
|
|
|
|
Post-generation processing includes audio normalization (RMS leveling, peak limiting) and neural watermarking (author identity embedded in audio). These run as part of the generation pipeline per-utterance.
|
|
|
|
## Components
|
|
|
|
### 1. Audio Normalizer (`assembly/normalize.py`)
|
|
|
|
```python
|
|
class AudioNormalizer:
|
|
def __init__(
|
|
self,
|
|
target_rms_db: float = -20.0,
|
|
peak_limit_db: float = -3.0,
|
|
noise_floor_db: float = -60.0,
|
|
sample_rate: int = 24000,
|
|
) -> None
|
|
|
|
def normalize_file(self, input_path: Path, output_path: Path) -> dict
|
|
def check_acx_compliance(self, audio_path: Path) -> dict
|
|
```
|
|
|
|
#### Normalization Algorithm
|
|
|
|
```python
|
|
def normalize_file(self, input_path: Path, output_path: Path) -> dict:
|
|
# 1. Read audio
|
|
audio, sr = sf.read(input_path)
|
|
|
|
# 2. Mono downmix if stereo
|
|
if audio.ndim > 1:
|
|
audio = audio.mean(axis=1)
|
|
|
|
# 3. Compute current RMS in dB
|
|
rms = np.sqrt(np.mean(audio ** 2))
|
|
current_rms_db = 20 * np.log10(rms + 1e-10)
|
|
|
|
# 4. Calculate gain
|
|
gain_db = self.target_rms_db - current_rms_db
|
|
gain = 10 ** (gain_db / 20)
|
|
audio = audio * gain
|
|
|
|
# 5. Peak limiting
|
|
peak = np.max(np.abs(audio))
|
|
peak_db = 20 * np.log10(peak + 1e-10)
|
|
if peak_db > self.peak_limit_db:
|
|
limiting_gain = 10 ** ((self.peak_limit_db - peak_db) / 20)
|
|
audio = audio * limiting_gain
|
|
|
|
# 6. Clip to [-1.0, 1.0]
|
|
audio = np.clip(audio, -1.0, 1.0)
|
|
|
|
# 7. Write output
|
|
sf.write(output_path, audio, self.sample_rate)
|
|
```
|
|
|
|
**Returns:**
|
|
```python
|
|
{
|
|
"input_path": str,
|
|
"output_path": str,
|
|
"original_rms_db": float,
|
|
"normalized_rms_db": float,
|
|
"peak_db": float,
|
|
"gain_applied_db": float,
|
|
}
|
|
```
|
|
|
|
#### ACX Compliance Check
|
|
|
|
```python
|
|
def check_acx_compliance(self, audio_path: Path) -> dict
|
|
```
|
|
|
|
**ACX Requirements:**
|
|
| Metric | Range |
|
|
|--------|-------|
|
|
| RMS level | -23.0 dB to -18.0 dB |
|
|
| Peak level | Below -3.0 dB |
|
|
|
|
**Returns:**
|
|
```python
|
|
{
|
|
"rms_db": float,
|
|
"peak_db": float,
|
|
"rms_in_range": bool, # -23.0 <= rms <= -18.0
|
|
"peak_below_limit": bool, # peak < -3.0
|
|
"compliant": bool, # rms_in_range AND peak_below_limit
|
|
}
|
|
```
|
|
|
|
### 2. Watermarking
|
|
|
|
#### Author Watermark Bits
|
|
|
|
```python
|
|
def _author_watermark_bits(author_name: str, length: int = 128) -> np.ndarray
|
|
```
|
|
|
|
**Algorithm:**
|
|
1. SHA-256 hash of author name string
|
|
2. Convert 128 bits from the hash digest to a float array
|
|
3. Each byte → 8 bits, take first `length` bits
|
|
4. Return as `np.float32` array
|
|
|
|
**Example:**
|
|
```python
|
|
author_name = "B.D. Murphy"
|
|
digest = sha256(b"B.D. Murphy").digest() # 32 bytes = 256 bits
|
|
bits = first 128 bits → np.float32 array of 128 values (0.0 or 1.0)
|
|
```
|
|
|
|
#### Perth Neural Watermarker
|
|
|
|
```python
|
|
class ChatterboxEngine:
|
|
def _get_watermarker(self) -> watermarker | None
|
|
def apply_watermark(self, wav, watermark_bits, sample_rate) -> torch.Tensor
|
|
```
|
|
|
|
**Watermarker Selection:**
|
|
1. Try `PerthImplicitWatermarker(device="cuda")` — neural watermarking
|
|
2. Fall back to `DummyWatermarker()` — no-op pass-through
|
|
3. Return `None` if neither available
|
|
|
|
**Application:**
|
|
```python
|
|
def apply_watermark(self, wav, watermark_bits, sample_rate=None):
|
|
if self._watermarker is None:
|
|
return wav # No watermarking available
|
|
|
|
try:
|
|
audio_np = wav.squeeze(0).cpu().numpy()
|
|
if audio_np.ndim > 1:
|
|
audio_np = audio_np.mean(axis=0)
|
|
|
|
watermarked = self._watermarker.embed(audio_np, watermark_bits)
|
|
return torch.from_numpy(watermarked).unsqueeze(0)
|
|
except Exception:
|
|
return wav # Fallback: return unwatermarked
|
|
```
|
|
|
|
**PerthImplicitWatermarker:**
|
|
- Located at: `~/.cache/torch/hub/snakers4_perth`
|
|
- Model: `perth_net` — implicit neural audio watermarking
|
|
- Embeds 128-bit payload into audio signal
|
|
- Robust to common audio transformations (compression, resampling)
|
|
- VRAM usage: minimal (~50MB)
|
|
|
|
**DummyWatermarker:**
|
|
- No-op implementation
|
|
- Returns audio unchanged
|
|
- Used as fallback when Perth is unavailable
|
|
|
|
### Integration with Generation
|
|
|
|
Watermarking runs inside `ChatterboxEngine.generate()`:
|
|
|
|
```python
|
|
def generate(self, text, output_path, voice_ref, instruct, watermark_bits, ...):
|
|
wav = self._model.generate(text, ...)
|
|
|
|
if watermark_bits is not None:
|
|
wav = self.apply_watermark(wav, watermark_bits)
|
|
|
|
# Write to file
|
|
sf.write(str(output_path), audio_np, self.sample_rate)
|
|
```
|
|
|
|
### Integration with Normalization
|
|
|
|
Normalization runs inside `ChapterGenerator.generate_chapter()`:
|
|
|
|
```python
|
|
# After successful generation:
|
|
if self.normalizer:
|
|
normalized_path = normalized_dir / out_path.name
|
|
self.normalizer.normalize_file(out_path, normalized_path)
|
|
```
|
|
|
|
### Quality Defect Tracking
|
|
|
|
ACX compliance is checked per-file in the orchestrator:
|
|
|
|
```python
|
|
# After generation:
|
|
result = normalizer.check_acx_compliance(audio_path)
|
|
if not result["compliant"]:
|
|
db.log_defect(project_id, "quality", "WARNING",
|
|
f"ACX non-compliance: RMS={result['rms_db']:.1f}dB, Peak={result['peak_db']:.1f}dB",
|
|
chapter_id=ch["id"])
|
|
```
|
|
|
|
## Default Parameters
|
|
|
|
| Parameter | Default | ACX Requirement |
|
|
|-----------|---------|-----------------|
|
|
| `target_rms_db` | -20.0 dB | -23.0 to -18.0 dB |
|
|
| `peak_limit_db` | -3.0 dB | Below -3.0 dB |
|
|
| `noise_floor_db` | -60.0 dB | N/A |
|
|
| `sample_rate` | 24000 Hz | N/A |
|
|
| `watermark_length` | 128 bits | N/A |
|