#!/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_.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_.bin лежит на диске, blocks — длина трека", " * в порциях насоса по 128 байт (в байтах не влезло бы в uint16).", " */", "#ifndef POP_MUSIC_TBL_H", "#define POP_MUSIC_TBL_H", "", "#include ", "", 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()