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:
@@ -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
|
||||
|
||||
Executable
+163
@@ -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()
|
||||
Reference in New Issue
Block a user