#!/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())