Files
UmbraAudio/umbraaudio/assembly/normalize.py
2026-10-18 19:01:04 -07:00

80 lines
2.5 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import logging
from pathlib import Path
import numpy as np
import soundfile as sf
logger = logging.getLogger(__name__)
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:
self.target_rms = target_rms_db
self.peak_limit = peak_limit_db
self.noise_floor = noise_floor_db
self.sr = sample_rate
def normalize_file(self, input_path: Path, output_path: Path) -> dict:
audio, sr = sf.read(str(input_path), dtype="float32")
if audio.ndim > 1:
audio = audio.mean(axis=1)
current_rms = float(np.sqrt(np.mean(audio**2)))
current_rms_db = 20 * np.log10(max(current_rms, 1e-10))
gain_db = self.target_rms - current_rms_db
gain = 10 ** (gain_db / 20)
audio = audio * gain
peak = float(np.max(np.abs(audio)))
peak_db = 20 * np.log10(max(peak, 1e-10))
if peak_db > self.peak_limit:
limit_gain = 10 ** ((self.peak_limit - peak_db) / 20)
audio = audio * limit_gain
audio = np.clip(audio, -1.0, 1.0)
output_path.parent.mkdir(parents=True, exist_ok=True)
sf.write(str(output_path), audio, sr)
final_rms = float(np.sqrt(np.mean(audio**2)))
final_peak = float(np.max(np.abs(audio)))
return {
"input_path": str(input_path),
"output_path": str(output_path),
"original_rms_db": round(current_rms_db, 2),
"normalized_rms_db": round(20 * np.log10(max(final_rms, 1e-10)), 2),
"peak_db": round(20 * np.log10(max(final_peak, 1e-10)), 2),
"gain_applied_db": round(gain_db, 2),
}
def check_acx_compliance(self, audio_path: Path) -> dict:
audio, sr = sf.read(str(audio_path), dtype="float32")
if audio.ndim > 1:
audio = audio.mean(axis=1)
rms = float(np.sqrt(np.mean(audio**2)))
rms_db = 20 * np.log10(max(rms, 1e-10))
peak = float(np.max(np.abs(audio)))
peak_db = 20 * np.log10(max(peak, 1e-10))
return {
"rms_db": round(rms_db, 2),
"peak_db": round(peak_db, 2),
"rms_in_range": -23.0 <= rms_db <= -18.0,
"peak_below_limit": peak_db <= -3.0,
"compliant": (-23.0 <= rms_db <= -18.0) and (peak_db <= -3.0),
}