Files
UmbraAudio/specs/06-normalization-watermarking.md
B.D. Murphy c86e323d7a Initial commit: UmbraAudio audiobook generation program
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.
2026-08-30 07:58:24 -07:00

5.4 KiB

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)

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

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:

{
    "input_path": str,
    "output_path": str,
    "original_rms_db": float,
    "normalized_rms_db": float,
    "peak_db": float,
    "gain_applied_db": float,
}

ACX Compliance Check

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:

{
    "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

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:

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

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:

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():

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():

# 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:

# 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