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
164 lines
8.0 KiB
Python
Executable File
164 lines
8.0 KiB
Python
Executable File
#!/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())
|