Files
Sprinter-SDCC/applications/PoP/toolchain/pop_pack_music.py
T
snark13 34ba5f71d5 Музыка: PCM-трек через CBL, первый экран истории озвучен
Путь C из sound_plan.md вместо ранее выбранного A (ноты спикера на AY).
Причина смены: появились готовые записи DOS-версии, и строка «нужен
синтезатор на хосте», из-за которой C считался дорогим, отпала —
осталось `ffmpeg -ac 1 -ar 10937 -f u8`.  Оцифровки музыки в самой игре
нет вовсе: digisnd*.dat содержит только эффекты, музыка лежит MIDI под
Adlib, так что конвертировать ресурс игры всё равно было не из чего.

* toolchain/pop_pack_music.py — запись flac -> сырой u8 на 10 937,5 Гц
  (частота CBL, чтобы её не переключать никогда), нарезка на куски
  MUS\m<id>_<nn>.bin по 16 КБ: bank_load_file читает файл только целиком
  и только в одну страницу.  Каталог -> pop_music_tbl.h; длина трека
  хранится ПОРЦИЯМИ по 128 байт — 169 КБ в uint16 не влезает, 1350
  блоков влезает (32-бит арифметики на Z80 избегаем).
* pop_music.c (банк 9) — загрузка трека в EMM и курсор; pop_sfx_fill
  (резидент) получил третий источник: эффект важнее музыки, музыка
  важнее тишины.  Эффект музыку не сбрасывает — её курсор стоит, пока
  тот доигрывает, и она продолжается с места.
* story_1_absence звучит на первом экране истории, где была тишина.
  Грузим ПОСЛЕ сборки картинки: чтение идёт через ESTEX, и при открытом
  CBL насос не успел бы долить блок.

Проверено записью звука из MAME: корреляция огибающих с эталоном 0,836
при сдвиге 1,8 с, RMS 22,6 против 18,3 (разница — 8-битное квантование).
Эффекты двери в PV-сцене после трека звучат по-прежнему.

Бюджет: резидент +83 Б кода и +25 Б данных, запас W2 151 Б (кучи в
приложении нет, malloc не слинкован); банк 9 занят на 88 %.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:21:22 +03:00

154 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""pop_pack_music.py — музыка PoP в сырой поток для CBL.
Источник — готовые записи DOS-версии (applications/PoP/PoP1_DOS_music,
формат flac). Оцифровки музыки в самой игре НЕТ: в digisnd*.dat лежат
только эффекты, а музыка хранится как MIDI под Adlib. Поэтому здесь мы
не «конвертируем ресурс игры», а укладываем готовую запись в формат,
который наше железо играет без единого преобразования в рантайме.
ФОРМАТ — тот же, что у эффектов (см. pop_pack_sound.py и sound_plan.md):
8 бит БЕЗЗНАКОВЫЙ моно на 10 937,5 Гц — ровно CBL_FREQ_10K9. Частоту
берём не 11 000, а частоту железа: тогда CBL открывается ОДИН раз за
запуск и не переключается ни между эффектами, ни при переходе на музыку.
РЕЖЕМ НА СТРАНИЦЫ EMM по 16 КБ. bank_load_file умеет читать файл только
целиком и только в одну страницу, поэтому трек на диске лежит пачкой
кусков M<id>_<nn>.BIN — ровно как набор эффектов лежит s0..s9.bin.
Последний кусок дополняется тишиной (0x80) до кратности 128: насос отдаёт
CBL блоками по 128 байт, и остаток «не полного блока» ему пришлось бы
досчитывать в прерывании.
Длина трека уходит в pop_music_tbl.h блоками по 128 байт, а не байтами:
самый длинный трек (won, 115 с) — это 1,2 МБ, что не влезает в uint16, а
в блоках даёт 9855. 32-битной арифметики на Z80 избегаем (см. memory
avoid_32bit_arith_z80).
"""
import subprocess
import sys
from pathlib import Path
POP = Path(__file__).resolve().parent.parent # applications/PoP
SRC_DIR = POP / "PoP1_DOS_music" / "flac"
OUT_DIR = POP / "poc" / "res" / "music"
OUT_H = POP / "roomtest" / "pop_music_tbl.h"
CBL_RATE = 10937.5 # CBL_FREQ_10K9 — частота железа, не 11 000
PAGE = 16384 # EMM-страница
BLOCK = 128 # порция насоса CBL
SILENCE = 0x80 # центр беззнакового 8-битного сэмпла
# id — нумерация звуков ОРИГИНАЛА (SDLPoP data/music/names.txt). Держим её,
# чтобы игровой код просил трек тем же номером, каким оригинал зовёт
# play_sound, и таблицу можно было сверить глазами.
TRACKS = {
24: "regular_death",
25: "find_mirror_and_find_shadow",
26: "embrace",
27: "cutscene_before_2_4_6_12",
28: "fight_death",
29: "meet_Jaffar",
30: "life_potion",
32: "end_level_4_and_killed_by_shadow",
33: "potion",
35: "cutscene_before_8_9",
36: "out_of_time",
37: "guard_death_and_obtaining_the_sword",
39: "floating",
40: "cutscene_not_much_time_left",
41: "story_5_level_end_and_merge_with_shadow",
43: "killed_Jaffar",
50: "story_2_princess",
52: "story_4_Jaffar_leaves",
53: "story_3_Jaffar_enters",
54: "intro_theme",
55: "story_1_absence",
56: "won",
}
def decode(path: Path) -> bytes:
"""flac -> сырой u8 моно на частоте CBL."""
out = subprocess.run(
["ffmpeg", "-v", "error", "-i", str(path),
"-ac", "1", "-ar", str(int(CBL_RATE)), "-f", "u8", "-"],
capture_output=True, check=True)
return out.stdout
def pack(track_id: int, name: str) -> tuple[int, int]:
src = SRC_DIR / f"{name}.flac"
if not src.exists():
raise SystemExit(f"нет источника: {src}")
data = bytearray(decode(src))
# Добить до кратности блока насоса — хвост тишиной, а не обрезкой:
# обрезать значило бы щёлкнуть на последнем сэмпле.
tail = len(data) % BLOCK
if tail:
data += bytes([SILENCE]) * (BLOCK - tail)
chunks = (len(data) + PAGE - 1) // PAGE
OUT_DIR.mkdir(parents=True, exist_ok=True)
for i in range(chunks):
part = data[i * PAGE:(i + 1) * PAGE]
(OUT_DIR / f"m{track_id:02d}_{i:02d}.bin").write_bytes(part)
return chunks, len(data) // BLOCK
def main() -> None:
wanted = [int(a) for a in sys.argv[1:]] or sorted(TRACKS)
rows = []
total = 0
for tid in wanted:
if tid not in TRACKS:
raise SystemExit(f"неизвестный трек {tid}")
chunks, blocks = pack(tid, TRACKS[tid])
rows.append((tid, TRACKS[tid], chunks, blocks))
total += blocks * BLOCK
print(f" {tid:2d} {TRACKS[tid]:<40} {chunks:2d} стр, "
f"{blocks * BLOCK / 1024:7.1f} КБ, {blocks * BLOCK / CBL_RATE:5.1f} с")
max_id = max(t for t, *_ in rows)
lines = [
"/*",
" * pop_music_tbl.h — каталог музыкальных треков, СГЕНЕРИРОВАН.",
" * Источник: applications/PoP/PoP1_DOS_music/flac (записи DOS-версии).",
" * Генератор: toolchain/pop_pack_music.py. Руками не править.",
" *",
" * Нумерация id — оригинала (SDLPoP data/music/names.txt). pages —",
" * сколько кусков m<id>_<nn>.bin лежит на диске, blocks — длина трека",
" * в порциях насоса по 128 байт (в байтах не влезло бы в uint16).",
" */",
"#ifndef POP_MUSIC_TBL_H",
"#define POP_MUSIC_TBL_H",
"",
"#include <stdint.h>",
"",
f"#define POP_MUS_COUNT {max_id + 1}",
f"#define POP_MUS_BLOCK {BLOCK}",
f"#define POP_MUS_MAX_PAGES {max(c for _, _, c, _ in rows)}",
"",
"typedef struct {",
" uint8_t pages; /* 0 — трека нет */",
" uint16_t blocks; /* длина в порциях по 128 байт */",
"} pop_mus_ent_t;",
"",
"static const pop_mus_ent_t pop_mus_tbl[POP_MUS_COUNT] = {",
]
by_id = {t: (c, b, n) for t, n, c, b in rows}
for i in range(max_id + 1):
if i in by_id:
c, b, n = by_id[i]
lines.append(f" {{ {c:2d}, {b:5d} }}, /* {i:2d} {n} */")
else:
lines.append(f" {{ 0, 0 }}, /* {i:2d} */")
lines += ["};", "", "#endif /* POP_MUSIC_TBL_H */", ""]
OUT_H.write_text("\n".join(lines))
print(f"итого {total / 1024:.0f} КБ в {OUT_DIR}")
print(f"каталог: {OUT_H}")
if __name__ == "__main__":
main()