#!/usr/bin/env python3 """pop_pack_music.py — музыка PoP в сырой поток для CBL. Источник — готовые записи DOS-версии (assets/orig/PoP1_DOS_music). Набор выбирается ключом --format: flac (по умолчанию), mp3, ogg, mt32. Оцифровки музыки в самой игре НЕТ: в digisnd*.dat лежат только эффекты, а музыка хранится как MIDI под Adlib. Поэтому здесь мы не «конвертируем ресурс игры», а укладываем готовую запись в формат, который наше железо играет без единого преобразования в рантайме. ФОРМАТ — тот же, что у эффектов (см. pop_pack_sound.py и sound_plan.md): 8 бит БЕЗЗНАКОВЫЙ моно на 10 937,5 Гц — ровно CBL_FREQ_10K9. Частоту берём не 11 000, а частоту железа: тогда CBL открывается ОДИН раз за запуск и не переключается ни между эффектами, ни при переходе на музыку. ОДИН ФАЙЛ НА ТРЕК. Резать его на куски не нужно: читаем порциями по 16 КБ из ОДНОГО открытого файла (`bank_read_page` + `lseek`), и «дорогой» open платится раз на трек, а не на каждую страницу — замер 2026-08-25: open 51,4 мс против 32,6 мс на чтение 16 КБ. Этот же путь работает для финального `won` (1,2 МБ), который в память целиком не помещается и обязан подгружаться по ходу. Хвост дополняется тишиной (0x80) до кратности 128: насос отдаёт CBL блоками по 128 байт, и остаток «не полного блока» ему пришлось бы досчитывать в прерывании. Длина трека уходит в MUS/mus.idx блоками по 128 байт, а не байтами: самый длинный трек (won, 115 с) — это 1,2 МБ, что не влезает в uint16, а в блоках даёт 9855. 32-битной арифметики на Z80 избегаем (см. memory avoid_32bit_arith_z80). """ import subprocess import sys from pathlib import Path import sys as _sys, os as _os _sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__))) import paths as P # раскладка каталогов SprPoP (tools/paths.py) import pop_idx # формат дискового индекса PMI1 (tools/pop_idx.py) # Формат источника выбирается ключом --format (по умолчанию flac — он же # лучший по качеству исходник). Каталог и расширение знает tools/paths.py. FMT = "flac" SRC_DIR, SRC_EXT = P.music_src(FMT) OUT_DIR = P.atl("music") OUT_IDX = "mus.idx" # дисковый индекс набора, ложится в MUS/ рядом с треками 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: """запись любого формата -> сырой 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}{SRC_EXT}" 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) (OUT_DIR / f"m{track_id:02d}.bin").write_bytes(bytes(data)) return chunks, len(data) // BLOCK def main() -> None: global FMT, SRC_DIR, SRC_EXT args = sys.argv[1:] if args and args[0] == "--format": FMT = args[1] SRC_DIR, SRC_EXT = P.music_src(FMT) args = args[2:] wanted = [int(a) for a in args] or sorted(TRACKS) print(f"музыка из набора {FMT}: {SRC_DIR}") 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} с") # ДИСКОВЫЙ ИНДЕКС набора: длины треков и длительности реплик. Пока # печатаются и заголовки выше — переход на runtime идёт по шагам, и # индекс обязан описывать ровно то же, что они (docs/music_runtime_index_plan.md). # Тики считает pop_idx.mus_ticks — общая формула для обеих сторон. if len(rows) != len(TRACKS): print(f" ВНИМАНИЕ: упаковано {len(rows)} треков из {len(TRACKS)} — " f"индекс описывает только их") entries = [(0, 0)] * pop_idx.MUS_COUNT for tid, _name, _chunks, blocks in rows: entries[tid] = (blocks, pop_idx.mus_ticks(blocks)) (OUT_DIR / OUT_IDX).write_bytes(pop_idx.mus_pack(entries)) print(f"индекс: {OUT_DIR / OUT_IDX}") print(f"итого {total / 1024:.0f} КБ в {OUT_DIR}") if __name__ == "__main__": main()