SprPoP: pop_quiet.py — тихая копия готовых наборов звука и музыки

Берёт то, что уже лежит в 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
This commit is contained in:
2026-09-02 14:16:07 +03:00
parent 8490288d79
commit 81e8ed4676
3 changed files with 280 additions and 0 deletions
+9
View File
@@ -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
+163
View File
@@ -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("<HH", raw, 8 + i * 4)
off = off_sec * ARC_SECTOR
if off + size > 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())
@@ -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("<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()