diff --git a/applications/SprPoP/tools/paths.py b/applications/SprPoP/tools/paths.py index b040b18..7dee7b6 100644 --- a/applications/SprPoP/tools/paths.py +++ b/applications/SprPoP/tools/paths.py @@ -125,6 +125,8 @@ def find_ci(directory, name): ATL = APP / "build" / "atl" # промежуточные атласы, по набору в папке PACKED = APP / "assets" / "packed" # раскладка диска игры GEN = APP / "gen" # генерируемые заголовки +DIST = APP / "dist" # ручное содержимое дистрибутива (README и + # производные наборы, напр. тихий звук) def atl(name): @@ -139,3 +141,10 @@ def packed(name): p = PACKED / name p.mkdir(parents=True, exist_ok=True) return p + + +def dist(name): + """Каталог внутри dist/ в раскладке диска; создаётся при обращении.""" + p = DIST / name + p.mkdir(parents=True, exist_ok=True) + return p diff --git a/applications/SprPoP/tools/pop_quiet.py b/applications/SprPoP/tools/pop_quiet.py new file mode 100755 index 0000000..0adf26b --- /dev/null +++ b/applications/SprPoP/tools/pop_quiet.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""pop_quiet.py — тише в N раз: пережать ГОТОВЫЕ наборы звука и музыки. + +Берёт то, что уже лежит в assets/packed (SND/snd.arc + MUS/m*.bin), делает +тише и кладёт в dist/ в той же раскладке диска, ПЕРЕЗАПИСЫВАЯ то, что там +было. Оригиналы в assets/packed не трогаются никогда — значит скрипт +идемпотентен: сколько раз ни запусти, громкость упадёт ровно один раз. + +ФОРМУЛА. PCM у нас 8 бит БЕЗ ЗНАКА, тишина = 0x80 (см. cbl.h и +docs/sound_plan.md), то есть байт — это знаковое отклонение от 0x80. +Тише вдвое: + + out = 0x80 + (in - 0x80) / 2 = in / 2 + 0x40 + +Правая форма — та самая, что делается на Z80 двумя командами +(`srl a` / `add a,#0x40`), поэтому таблицу строим floor-делением: тогда +результат совпадает с ней БАЙТ В БАЙТ и его можно сверять с железной +реализацией. Тишина остаётся тишиной (0x80 -> 0x80) при любом gain, а +клиппинга не бывает по построению: |отклонение| только уменьшается. + +ЧТО ИМЕННО ПЕРЕСЧИТЫВАЕТСЯ. + + MUS/m*.bin — файл целиком: это чистый PCM без заголовка, загрузчик + читает его страницами с нулевого смещения (pop_music.c). + SND/snd.arc — ТОЛЬКО тела записей архива PBA1. Заголовок (512 Б) и + выравнивающий хвост между записями остаются как есть: + там нули упаковщика, а не звук, и пересчёт превратил бы + их в 0x40 — мусор на месте служебных байт. + *.idx — копируются без изменений: длины и смещения не поменялись, + меняются только сами отсчёты. Магию всё же проверяем, + чтобы молча не скопировать файл чужого формата. + +Запуск: + + python3 tools/pop_quiet.py # вдвое тише, packed -> dist + python3 tools/pop_quiet.py --gain 0.25 # вчетверо тише + python3 tools/pop_quiet.py --src DIR --out DIR +""" +import argparse +import shutil +import struct +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import paths + +SILENCE = 0x80 # центр 8-битного беззнакового PCM +ARC_MAGIC = b"PBA1" # архив ресурсов, см. src/pop_arc.h +ARC_HEADER = 512 +ARC_SECTOR = 512 +SND_IDX_MAGIC = b"PSI1" +MUS_IDX_MAGIC = b"PMI1" + + +def gain_table(gain: float) -> bytes: + """Таблица перекодировки байта: 256 значений, готовые для bytes.translate. + + floor, а не округление к ближайшему: при gain = 1/2 это ровно + `in >> 1 + 0x40`, то есть побайтово то же, что сделал бы Z80. + """ + if not 0.0 <= gain <= 1.0: + raise SystemExit("gain обязан лежать в [0, 1]; громче делать нечем — " + "запас по амплитуде в исходнике уже выбран") + out = bytearray(256) + for v in range(256): + dev = v - SILENCE # знаковое отклонение + out[v] = max(0, min(255, SILENCE + _floor_div(dev, gain))) + return bytes(out) + + +def _floor_div(dev: int, gain: float) -> int: + """dev * gain, округлённое ВНИЗ (к минус бесконечности).""" + scaled = dev * gain + i = int(scaled) + return i - 1 if scaled < i else i # int() режет к нулю + + +def quiet_raw(src: Path, dst: Path, table: bytes) -> int: + """Файл целиком — PCM (музыкальный трек). Возвращает размер.""" + data = src.read_bytes() + dst.write_bytes(data.translate(table)) + return len(data) + + +def quiet_arc(src: Path, dst: Path, table: bytes) -> int: + """Архив PBA1: пересчитываем только тела записей.""" + raw = bytearray(src.read_bytes()) + if raw[0:4] != ARC_MAGIC: + raise SystemExit(f"{src}: не {ARC_MAGIC.decode()} — это не наш архив") + count = raw[4] + if not 1 <= count <= (ARC_HEADER - 8) // 4: + raise SystemExit(f"{src}: count = {count}, вне 1..126") + for i in range(count): + off_sec, size = struct.unpack_from(" len(raw): + raise SystemExit(f"{src}: запись {i} выходит за файл " + f"({off} + {size} > {len(raw)})") + raw[off:off + size] = bytes(raw[off:off + size]).translate(table) + dst.write_bytes(bytes(raw)) + return count + + +def copy_idx(src: Path, dst: Path, magic: bytes) -> None: + """Индекс переносим как есть — но убеждаемся, что он наш.""" + head = src.read_bytes()[:4] + if head != magic: + raise SystemExit(f"{src}: магия {head!r}, ожидалась {magic!r}") + shutil.copyfile(src, dst) + + +def peak(data: bytes) -> int: + """Максимальное отклонение от тишины — для отчёта «стало тише».""" + return max(abs(b - SILENCE) for b in data) if data else 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--gain", type=float, default=0.5, + help="множитель амплитуды (по умолчанию 0.5 — вдвое тише)") + ap.add_argument("--src", type=Path, default=paths.PACKED, + help="откуда брать готовые наборы (умолчание assets/packed)") + ap.add_argument("--out", type=Path, default=paths.DIST, + help="куда класть (умолчание dist/)") + args = ap.parse_args() + + table = gain_table(args.gain) + src_snd, src_mus = args.src / "SND", args.src / "MUS" + out_snd, out_mus = args.out / "SND", args.out / "MUS" + if not src_snd.is_dir() or not src_mus.is_dir(): + raise SystemExit(f"{args.src}: нет SND/ или MUS/ — сначала " + "`make resources`") + out_snd.mkdir(parents=True, exist_ok=True) + out_mus.mkdir(parents=True, exist_ok=True) + + print(f"тише в {1 / args.gain:g} раз(а): {args.src} -> {args.out}") + + arc = src_snd / "snd.arc" + before = peak(arc.read_bytes()[ARC_HEADER:]) + n = quiet_arc(arc, out_snd / "snd.arc", table) + after = peak((out_snd / "snd.arc").read_bytes()[ARC_HEADER:]) + copy_idx(src_snd / "snd.idx", out_snd / "snd.idx", SND_IDX_MAGIC) + print(f" SND/snd.arc {n} записей, пик {before} -> {after}") + + tracks = sorted(src_mus.glob("m*.bin")) + if not tracks: + raise SystemExit(f"{src_mus}: нет ни одного m*.bin") + total = 0 + worst_before = worst_after = 0 + for t in tracks: + total += quiet_raw(t, out_mus / t.name, table) + worst_before = max(worst_before, peak(t.read_bytes())) + worst_after = max(worst_after, peak((out_mus / t.name).read_bytes())) + copy_idx(src_mus / "mus.idx", out_mus / "mus.idx", MUS_IDX_MAGIC) + print(f" MUS/ {len(tracks)} треков, {total // 1024} КБ, " + f"пик {worst_before} -> {worst_after}") + print("индексы (snd.idx, mus.idx) скопированы без изменений — длины те же") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/applications/SprPoP/tools/tests/test_pop_quiet.py b/applications/SprPoP/tools/tests/test_pop_quiet.py new file mode 100644 index 0000000..aaedb4f --- /dev/null +++ b/applications/SprPoP/tools/tests/test_pop_quiet.py @@ -0,0 +1,108 @@ +"""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("