Музыка: 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>
This commit is contained in:
2026-08-25 15:21:22 +03:00
parent ffb59b3470
commit 34ba5f71d5
20 changed files with 531 additions and 10 deletions
@@ -0,0 +1,153 @@
#!/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()