Files
Find-Word/tests/python/test_linear_normalization.py
T

79 lines
4.3 KiB
Python
Raw Normal View History

2026-10-14 12:12:13 +00:00
"""선형 gain만 허용하고 과도 peak·비수렴·측정 실패는 공개 전에 거부한다."""
from array import array
import math
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import wave
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'scripts'))
import ambience_audio as audio
def stats(i=-32.5, tp=-15):
return dict(integratedLUFS=i, truePeakDBTP=tp, loudnessRangeLU=1.0, thresholdLUFS=i-10)
class LinearNormalization(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.target = Path(self.temporary.name) / 'staged.wav'
values = [0.03 * math.sin(i / 20) for i in range(24000 * 12)]
values[0] = values[-1] = 0
self.samples = array('f', values)
self.pcm = self.samples.tobytes()
def test_real_output_is_only_scalar_gain_with_pcm16_rounding(self):
result = audio.normalize_pcm(self.pcm, self.target)
self.assertEqual(len(result['iterations']), 1)
gain = 10 ** (result['iterations'][0]['gainDB'] / 20)
with wave.open(str(self.target)) as stream:
actual = array('h', stream.readframes(stream.getnframes()))
# 리미터/컴프레서 없이 전 sample이 동일 gain이며 PCM16 반올림 오차만 허용한다.
self.assertEqual(len(actual), len(self.samples))
self.assertLessEqual(max(abs(x / 32768 - y * gain) for x, y in zip(actual, self.samples)), 0.5001 / 32768)
self.assertLessEqual(abs(result['final']['integratedLUFS'] + 32.5), 0.2 + 1e-9)
self.assertLessEqual(result['final']['truePeakDBTP'], -6.2)
def test_unsafe_predicted_peak_is_rejected_before_volume_conversion(self):
with patch.object(audio, 'measure_loudness', return_value=stats(-40, -10)), patch.object(audio.subprocess, 'run') as run:
with self.assertRaisesRegex(ValueError, 'peak'):
audio.normalize_pcm(self.pcm, self.target)
run.assert_not_called()
self.assertFalse(self.target.exists())
def test_correction_restarts_from_identical_source_and_records_both_passes(self):
real_run = subprocess.run
with patch.object(audio, 'measure_loudness', side_effect=[stats(-35), stats(-32.9), stats()]), patch.object(audio.subprocess, 'run', wraps=real_run) as run:
result = audio.normalize_pcm(self.pcm, self.target)
self.assertEqual(len(result['iterations']), 2)
self.assertEqual([r['gainDB'] for r in result['iterations']], [2.5, 2.9])
self.assertEqual([c.kwargs['input'] for c in run.call_args_list], [self.pcm, self.pcm])
for call, expected in zip(run.call_args_list, ['volume=2.500000000000dB:precision=double', 'volume=2.900000000000dB:precision=double']):
self.assertIn(expected, call.args[0])
def test_nonconvergence_is_bounded_and_fails(self):
with patch.object(audio, 'measure_loudness', side_effect=[stats(-35), stats(-33), stats(-33), stats(-33)]) as measure:
with self.assertRaisesRegex(ValueError, '수렴'):
audio.normalize_pcm(self.pcm, self.target)
self.assertEqual(measure.call_count, 4)
def test_each_corrective_gain_is_peak_checked_before_conversion(self):
with patch.object(audio, 'measure_loudness', side_effect=[stats(-35, -9), stats(-33)]), patch.object(audio.subprocess, 'run', wraps=subprocess.run) as run:
with self.assertRaisesRegex(ValueError, 'peak'):
audio.normalize_pcm(self.pcm, self.target)
self.assertEqual(run.call_count, 1)
def test_final_peak_and_nonfinite_measurement_are_rejected(self):
for invalid in [stats(tp=-6.1), stats(i=float('nan')), stats(tp=float('inf'))]:
with self.subTest(invalid=invalid), patch.object(audio, 'measure_loudness', side_effect=[stats(-35), invalid]):
with self.assertRaises(ValueError): audio.normalize_pcm(self.pcm, self.target)
def test_parser_rejects_missing_nonfinite_or_partial_summary(self):
for text in ['', 'Summary:\nI: -32.5 LUFS', 'Summary:\nI: nan LUFS\nThreshold: -42.5 LUFS\nLRA: 0.0 LU\nPeak: -10.0 dBFS']:
with self.subTest(text=text), self.assertRaises(ValueError): audio.parse_loudness(text)