81e8ed4676
Берёт то, что уже лежит в assets/packed, делает тише и кладёт в dist/ в раскладке диска, перезаписывая прежнее. Оригиналы не трогаются, поэтому скрипт идемпотентен: сколько раз ни запусти, громкость упадёт один раз. Формула — та, что предложил пользователь: PCM у нас 8 бит без знака с тишиной 0x80, значит вдвое тише это out = in/2 + 0x40 (беззнаковый сдвиг). Таблица на 256 значений строится floor-делением отклонения от центра, что при gain = 1/2 совпадает с этой формулой байт в байт — то есть результат можно сверять с реализацией на Z80 (srl a / add a,#0x40). Тишина остаётся тишиной при любом gain, клиппинга нет по построению. Пересчитываются: MUS/m*.bin целиком (чистый PCM) и ТОЛЬКО тела записей PBA1 в SND/snd.arc — заголовок и выравнивающие хвосты остаются как есть, там нули упаковщика, и пересчёт превратил бы их в 0x40. Индексы копируются без изменений (длины те же), но с проверкой магии. Прогон на текущих наборах: 10 записей архива и 22 трека, пик 128 -> 64, размеры байт в байт прежние, заголовок PBA1 не тронут. Тест закрепляет формулу и неприкосновенность служебных байт; make test-tools — 45 тестов. Раскладка dist/ добавлена в tools/paths.py, по правилу «пути знает один файл». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
109 lines
4.2 KiB
Python
109 lines
4.2 KiB
Python
"""test_pop_quiet — правило громкости и неприкосновенность служебных байт.
|
|
|
|
Скрипт трогает данные, которые потом играет насос на Z80, поэтому тест
|
|
закрепляет ровно две вещи: формулу (она обязана совпадать с беззнаковым
|
|
`A/2 + 0x40`, чтобы результат можно было сверять с железной реализацией) и
|
|
то, что заголовок архива с выравнивающим хвостом остаются как были.
|
|
|
|
Запуск: make test-tools (либо python3 -m unittest discover tools/tests)
|
|
"""
|
|
import os
|
|
import struct
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
import pop_quiet as X
|
|
|
|
|
|
class Formula(unittest.TestCase):
|
|
"""Таблица громкости: то же, что сделал бы Z80."""
|
|
|
|
def test_half_is_shift_plus_40(self):
|
|
t = X.gain_table(0.5)
|
|
for v in range(256):
|
|
self.assertEqual(t[v], (v >> 1) + 0x40, f"байт {v:#04x}")
|
|
|
|
def test_silence_stays_silence(self):
|
|
for gain in (0.5, 0.25, 0.1, 1.0):
|
|
self.assertEqual(X.gain_table(gain)[X.SILENCE], X.SILENCE)
|
|
|
|
def test_no_clipping_and_range_shrinks(self):
|
|
t = X.gain_table(0.5)
|
|
self.assertEqual(t[0x00], 0x40)
|
|
self.assertEqual(t[0xFF], 0xBF)
|
|
for v in range(256):
|
|
self.assertLessEqual(abs(t[v] - X.SILENCE), abs(v - X.SILENCE))
|
|
|
|
def test_gain_one_is_identity(self):
|
|
self.assertEqual(X.gain_table(1.0), bytes(range(256)))
|
|
|
|
def test_gain_out_of_range_rejected(self):
|
|
for bad in (-0.1, 1.5):
|
|
with self.assertRaises(SystemExit):
|
|
X.gain_table(bad)
|
|
|
|
|
|
def make_arc(bodies):
|
|
"""Собрать PBA1 ровно так, как это делает tools/pop_pack_arc.py."""
|
|
raw = bytearray(X.ARC_HEADER)
|
|
raw[0:4] = X.ARC_MAGIC
|
|
raw[4] = len(bodies)
|
|
blob = bytearray()
|
|
for i, body in enumerate(bodies):
|
|
off = X.ARC_HEADER + len(blob)
|
|
struct.pack_into("<HH", raw, 8 + i * 4, off // X.ARC_SECTOR, len(body))
|
|
blob += body
|
|
blob += bytes(-len(blob) % X.ARC_SECTOR) # хвост выравнивания — нули
|
|
return bytes(raw + blob)
|
|
|
|
|
|
class Archive(unittest.TestCase):
|
|
"""PBA1: тела пересчитаны, служебные байты — нет."""
|
|
|
|
def setUp(self):
|
|
self.dir = Path(tempfile.mkdtemp())
|
|
self.bodies = [bytes([0x80, 0xFF, 0x00, 0x81, 0x7F]), bytes([0xC0] * 3)]
|
|
self.src = self.dir / "in.arc"
|
|
self.dst = self.dir / "out.arc"
|
|
self.src.write_bytes(make_arc(self.bodies))
|
|
|
|
def test_bodies_scaled(self):
|
|
X.quiet_arc(self.src, self.dst, X.gain_table(0.5))
|
|
raw = self.dst.read_bytes()
|
|
off = X.ARC_HEADER
|
|
got = raw[off:off + len(self.bodies[0])]
|
|
self.assertEqual(got, bytes([0x80, 0xBF, 0x40, 0x80, 0x7F]))
|
|
|
|
def test_header_and_padding_untouched(self):
|
|
X.quiet_arc(self.src, self.dst, X.gain_table(0.5))
|
|
src, dst = self.src.read_bytes(), self.dst.read_bytes()
|
|
self.assertEqual(len(src), len(dst))
|
|
self.assertEqual(src[:X.ARC_HEADER], dst[:X.ARC_HEADER])
|
|
pad_at = X.ARC_HEADER + len(self.bodies[0])
|
|
pad_len = -len(self.bodies[0]) % X.ARC_SECTOR
|
|
self.assertEqual(dst[pad_at:pad_at + pad_len], bytes(pad_len))
|
|
|
|
def test_foreign_magic_rejected(self):
|
|
self.src.write_bytes(b"NOPE" + bytes(X.ARC_HEADER))
|
|
with self.assertRaises(SystemExit):
|
|
X.quiet_arc(self.src, self.dst, X.gain_table(0.5))
|
|
|
|
|
|
class Track(unittest.TestCase):
|
|
"""Музыкальный трек — чистый PCM, пересчитывается целиком."""
|
|
|
|
def test_whole_file(self):
|
|
d = Path(tempfile.mkdtemp())
|
|
src, dst = d / "m54.bin", d / "out.bin"
|
|
src.write_bytes(bytes([0x00, 0x80, 0xFF] * 4))
|
|
n = X.quiet_raw(src, dst, X.gain_table(0.5))
|
|
self.assertEqual(n, 12)
|
|
self.assertEqual(dst.read_bytes(), bytes([0x40, 0x80, 0xBF] * 4))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|