ea8efdb0fd
Добавить общий каталог назначения для HDD и удалить локальную копию упаковщика.\n\nУбрать устаревшие generated-имена ресурсов, выводить число страниц Kid из kid.arc и ограничить звуковую таблицу горячим модулем.\n\nЗафиксировать планы runtime-индексов музыки и PCM-эффектов.
210 lines
11 KiB
Python
210 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
pop_pack_sound.py — упаковщик ЗВУКОВЫХ ЭФФЕКТОВ (оцифровка, chtab нет).
|
||
|
||
ИСТОЧНИК. `MSDOS/digisnd1..3.dat` — оригинальные контейнеры. Разбор:
|
||
индекс по 8 байт (id, offset, size), **первый байт ресурса — контрольная
|
||
сумма**, тело за ней (спецификация `docs/POP-DAT-FormatSpecifications`,
|
||
§3.1.2). Тело — один из двух вариантов заголовка:
|
||
|
||
digi_type (1.0/1.1): word rate, word count, word unk, byte size=8
|
||
digi_new_type (1.3/1.4): word rate, byte size=8, word count, word, word
|
||
|
||
Различаем по позиции байта размера: у нового он на смещении 3, у старого
|
||
на 7. Сэмплы — 8 бит БЕЗЗНАКОВЫЕ, МОНО (проверено по `convert_digi_sound`,
|
||
seg009.c:2358: один байт на кадр, `(b | b<<8) - 32768`; стерео в данных
|
||
нет вовсе, каналы размножаются уже на выходе). Это ровно формат нашего
|
||
CBL — конвертировать нечего.
|
||
|
||
ЧАСТОТА. Приводим ВСЁ к 10 937,5 Гц — это `CBL_FREQ_10K9`, то есть
|
||
частота железа. Не к 11 000: проигрывание 11 000-данных на 10 937,5 дало
|
||
бы сдвиг тона −9,9 цента, а пересчитывать три файла (2 750, 8 200,
|
||
14 000 Гц) всё равно надо. Взамен CBL открывается ОДИН раз и частота не
|
||
меняется никогда — ни между эффектами, ни при переходе на музыку.
|
||
|
||
РАСКЛАДКА. Всё склеивается в EMM-страницы по 16 384 Б, и начало каждого
|
||
звука ВЫРАВНИВАЕТСЯ НА 128. Это не косметика: блок запроса CBL — ровно
|
||
128 байт, а 16 384 кратно 128, поэтому при таком выравнивании ни один
|
||
блок не пересекает границу страницы. Проигрыватель за счёт этого не
|
||
знает слова «стык»: замапил страницу, отдал 128 байт, сдвинул указатель.
|
||
Плата — до 127 байт на звук, всего ~2 КБ.
|
||
|
||
Выход: build/atl/sound/s0..sN.bin (по 16 384 Б) + gen/pop_sound_tbl.h
|
||
"""
|
||
import os
|
||
import struct
|
||
import sys
|
||
|
||
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)
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
DAT_DIR = str(P.MSDOS)
|
||
OUT_DIR = str(P.atl("sound"))
|
||
HDR = str(P.GEN / "pop_sound_tbl.h")
|
||
|
||
PAGE = 16384
|
||
BLOCK = 128 # блок запроса CBL при 8 бит (см. cbl.h)
|
||
RATE = 10937.5 # CBL_FREQ_10K9
|
||
NSOUND = 57 # id 0..56; у музыки оцифровки нет — длина 0
|
||
|
||
|
||
def dat_resources(path):
|
||
"""id -> тело ресурса (без байта контрольной суммы)."""
|
||
d = open(path, "rb").read()
|
||
off, _ = struct.unpack("<IH", d[0:6])
|
||
n = struct.unpack("<H", d[off:off + 2])[0]
|
||
out = {}
|
||
for i in range(n):
|
||
e = off + 2 + i * 8
|
||
rid = struct.unpack("<H", d[e:e + 2])[0]
|
||
roff = struct.unpack("<I", d[e + 2:e + 6])[0]
|
||
rsz = struct.unpack("<H", d[e + 6:e + 8])[0]
|
||
out[rid - 10000] = d[roff + 1:roff + 1 + rsz]
|
||
return out
|
||
|
||
|
||
def digi(body):
|
||
"""(rate, samples) или None, если ресурс не оцифровка."""
|
||
if not body or (body[0] & 7) != 1:
|
||
return None
|
||
if len(body) > 4 and body[3] == 8: # digi_new_type
|
||
rate = struct.unpack("<H", body[1:3])[0]
|
||
cnt = struct.unpack("<H", body[4:6])[0]
|
||
head = 10
|
||
elif len(body) > 7 and body[7] == 8: # digi_type
|
||
rate, cnt = struct.unpack("<HH", body[1:5])
|
||
head = 8
|
||
else:
|
||
return None
|
||
return rate, body[head:head + cnt]
|
||
|
||
|
||
def resample(src, src_rate, dst_rate):
|
||
"""Линейная интерполяция. Данные 8 бит беззнаковые, поэтому и работаем
|
||
в беззнаковых — никаких знаковых промежуточных представлений."""
|
||
if src_rate == dst_rate or not src:
|
||
return bytes(src)
|
||
n = int(round(len(src) * dst_rate / src_rate))
|
||
out = bytearray(n)
|
||
step = src_rate / dst_rate
|
||
for i in range(n):
|
||
x = i * step
|
||
i0 = int(x)
|
||
if i0 >= len(src) - 1:
|
||
out[i] = src[-1]
|
||
else:
|
||
a = x - i0
|
||
out[i] = int(round(src[i0] * (1.0 - a) + src[i0 + 1] * a))
|
||
return bytes(out)
|
||
|
||
|
||
def main():
|
||
res = {}
|
||
for i in (1, 2, 3):
|
||
p = os.path.join(DAT_DIR, f"digisnd{i}.dat")
|
||
if not os.path.exists(p):
|
||
sys.exit(f"нет файла {p}")
|
||
res.update(dat_resources(p))
|
||
|
||
sounds = {}
|
||
for sid, body in sorted(res.items()):
|
||
g = digi(body)
|
||
if g is None:
|
||
print(f" звук {sid}: не оцифровка, пропуск")
|
||
continue
|
||
rate, samples = g
|
||
pcm = resample(samples, rate, RATE)
|
||
sounds[sid] = pcm
|
||
if rate != 11000:
|
||
print(f" звук {sid}: {rate} Гц -> {RATE:g} Гц, {len(samples)} -> {len(pcm)} сэмплов")
|
||
|
||
# Раскладка: начало каждого звука кратно BLOCK, страницы по PAGE.
|
||
# ПЕРВЫЙ БЛОК — ТИШИНА. Он нужен насосу: когда эффект кончился, в CBL
|
||
# всё равно надо что-то отдавать, иначе железо доигрывает по кругу хвост
|
||
# своего буфера (жужжание). Держим тишину СВОЮ, а не просим у libc
|
||
# (CBL_UNDERRUN_SILENCE делает malloc), потому что куча в резиденте W2
|
||
# тесная и её отказ ронял бы весь звук.
|
||
blob = bytearray([0x80] * BLOCK)
|
||
table = {}
|
||
for sid in sorted(sounds):
|
||
pad = (-len(blob)) % BLOCK
|
||
blob += bytes([0x80]) * pad # 0x80 — тишина в беззнаковом 8-бит
|
||
# Длину округляем ВВЕРХ до блока: хвост последнего блока — это
|
||
# выравнивающая тишина следующего звука, и проигрыватель за счёт
|
||
# этого не знает слова «неполный блок».
|
||
need = (len(sounds[sid]) + BLOCK - 1) // BLOCK * BLOCK
|
||
# ЗВУК ЦЕЛИКОМ В ОДНОЙ СТРАНИЦЕ (решение пользователя 2026-08-25).
|
||
# Раньше длинные эффекты перетекали в следующую страницу, и насос
|
||
# переключал её прямо посреди воспроизведения — лишняя логика в ISR
|
||
# и целый класс отказов «играет не тот звук». Теперь страница —
|
||
# как атлас: не влезает в остаток — начинаем новую, хвост добиваем
|
||
# тишиной. Платим страницами EMM, их у нас с запасом.
|
||
if len(blob) % PAGE + need > PAGE:
|
||
blob += bytes([0x80]) * ((-len(blob)) % PAGE)
|
||
table[sid] = (len(blob), need)
|
||
blob += sounds[sid]
|
||
blob += bytes([0x80]) * ((-len(blob)) % PAGE)
|
||
|
||
os.makedirs(OUT_DIR, exist_ok=True)
|
||
npages = len(blob) // PAGE
|
||
for i in range(npages):
|
||
with open(os.path.join(OUT_DIR, f"s{i}.bin"), "wb") as f:
|
||
f.write(blob[i * PAGE:(i + 1) * PAGE])
|
||
|
||
lines = [
|
||
"/* pop_sound_tbl.h — раскладка оцифрованных эффектов PoP.",
|
||
" * СГЕНЕРЕНО tools/pop_pack_sound.py — НЕ править вручную.",
|
||
" *",
|
||
" * Данные: 8 бит беззнаковые, МОНО, %g Гц (CBL_FREQ_10K9) — формат" % RATE,
|
||
" * CBL один в один, конвертировать в рантайме нечего.",
|
||
" *",
|
||
" * Начало каждого звука кратно %d — размеру блока запроса CBL, а" % BLOCK,
|
||
" * страница (%d) кратна ему же. Поэтому НИ ОДИН блок не пересекает" % PAGE,
|
||
" * границу страницы. Больше того: с 2026-08-25 каждый звук лежит\n * ЦЕЛИКОМ в одной странице — не влезает в остаток, значит начинаем\n * новую (страницы EMM у нас с запасом). Единственное исключение —\n * эффект, который сам ДЛИННЕЕ страницы: для него насос переключает\n * страницу на ходу, и эта ветка в pop_sfx_fill остаётся ради него.",
|
||
" *",
|
||
" * Смещение хранится РАЗОБРАННЫМ на страницу и адрес в окне: на Z80",
|
||
" * 32-битная арифметика дорога, а так всё влезает в 8/16 бит.",
|
||
" * Длина округлена вверх до блока — неполных блоков не бывает.",
|
||
" */",
|
||
"#ifndef POP_SOUND_TBL_H",
|
||
"#define POP_SOUND_TBL_H",
|
||
"",
|
||
"#include <stdint.h>",
|
||
"",
|
||
"#define POP_SND_PAGES %d" % npages,
|
||
"#define POP_SND_COUNT %d" % NSOUND,
|
||
"#define POP_SND_BLOCK %d" % BLOCK,
|
||
"/* Первый блок страницы 0 — тишина для насоса (см. упаковщик). */",
|
||
"#define POP_SND_SILENCE_PAGE 0",
|
||
"#define POP_SND_SILENCE_OFF 0x0000",
|
||
"",
|
||
"/* Полная таблица нужна только горячей половине звука. */",
|
||
"#ifdef POP_SND_TABLE",
|
||
"typedef struct { uint8_t page; uint16_t off; uint16_t len; } pop_snd_ent_t;",
|
||
"",
|
||
"/* len == 0 — у звука нет оцифровки (это музыка, см. sound_plan.md). */",
|
||
"static const pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT] = {",
|
||
]
|
||
for sid in range(NSOUND):
|
||
off, ln = table.get(sid, (0, 0))
|
||
lines.append(" { %d, 0x%04X, %5d }, /* %2d */" % (off >> 14, off & 0x3FFF, ln, sid))
|
||
lines += [
|
||
"};",
|
||
"#endif /* POP_SND_TABLE */",
|
||
"",
|
||
"#endif",
|
||
"",
|
||
]
|
||
with open(HDR, "w") as f:
|
||
f.write("\n".join(lines))
|
||
|
||
have = sum(1 for s in sounds.values() if s)
|
||
print(f"ИТОГО: {have} эффектов, {len(blob)} Б = {npages} EMM-страниц -> {OUT_DIR}")
|
||
print(f" заголовок -> {HDR}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|