79 lines
4.3 KiB
Python
79 lines
4.3 KiB
Python
"""선형 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)
|