80 lines
2.5 KiB
Python
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),
|
|
}
|