#!/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 КБ. Выход: poc/res/sound/s0..sN.bin (по 16 384 Б) + roomtest/pop_sound_tbl.h """ import os import struct import sys HERE = os.path.dirname(os.path.abspath(__file__)) DAT_DIR = os.path.join(HERE, "..", "MSDOS") OUT_DIR = os.path.join(HERE, "..", "poc", "res", "sound") HDR = os.path.join(HERE, "..", "roomtest", "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(" 4 and body[3] == 8: # digi_new_type rate = struct.unpack(" 7 and body[7] == 8: # digi_type rate, cnt = struct.unpack("= 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-бит # Длину округляем ВВЕРХ до блока: хвост последнего блока — это # выравнивающая тишина следующего звука, и проигрыватель за счёт # этого не знает слова «неполный блок». table[sid] = (len(blob), (len(sounds[sid]) + BLOCK - 1) // BLOCK * BLOCK) 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.", " * СГЕНЕРЕНО toolchain/pop_pack_sound.py — НЕ править вручную.", " *", " * Данные: 8 бит беззнаковые, МОНО, %g Гц (CBL_FREQ_10K9) — формат" % RATE, " * CBL один в один, конвертировать в рантайме нечего.", " *", " * Начало каждого звука кратно %d — размеру блока запроса CBL, а" % BLOCK, " * страница (%d) кратна ему же. Поэтому НИ ОДИН блок не пересекает" % PAGE, " * границу страницы, и проигрывателю не нужна логика стыка.", " *", " * Смещение хранится РАЗОБРАННЫМ на страницу и адрес в окне: на Z80", " * 32-битная арифметика дорога, а так всё влезает в 8/16 бит.", " * Длина округлена вверх до блока — неполных блоков не бывает.", " */", "#ifndef POP_SOUND_TBL_H", "#define POP_SOUND_TBL_H", "", "#include ", "", "#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", "", "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 += [ "};", "", "/* Имена файлов видит ТОЛЬКО тот, кто попросил POP_SND_FILES (холодная", " * половина): иначе копия строк уезжает в каждый включивший заголовок", " * модуль, а в резиденте W1/W2 каждая сотня байт на счету. Тот же", " * приём, что у POP_BG_ATLAS_NAMES в pop_bg_atlas.h. */", "#ifdef POP_SND_FILES", "static const char *const pop_snd_files[POP_SND_PAGES] = {", ] lines += [' "SND\\\\s%d.bin",' % i for i in range(npages)] lines += ["};", "#endif", "", "#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()