tsii and tpif; more vt stuffs

This commit is contained in:
minjaesong
2026-06-15 22:09:03 +09:00
parent e8112e78c8
commit 930e867b3e
6 changed files with 532 additions and 118 deletions

View File

@@ -553,6 +553,28 @@ arithmetic (no regression outside vtmgr). Applied so far in
`assets/disk0/tvdos/bin/taut.js` and `assets/disk0/hopper/include/aa.mjs`
(used by `bb.js`). Any future direct-VRAM app needs the same one-line `vaddr`.
### Raw-keyboard apps must grab (the `con.grabRawKeyboard` pattern)
Fullscreen apps that poll the **raw key snapshot** (`sys.poke(-40,1)` then
`sys.peek(-41..-48)`) directly — e.g. the DOOM port's `i_input.mjs` — bypass the
pane input ring entirely. But the dispatcher keeps the cooked collector (`-39`)
on and drains typed chars into the *active* pane's ring every frame. While such
an app is the active pane, every keystroke piles into a ring it never reads, and
floods its parent shell the instant the app exits (no bug outside vtmgr, where
`-39` is off while a raw app runs). Fix: the pane bootstrap exposes
`con.grabRawKeyboard()` / `con.releaseRawKeyboard()` (write the active VT number
into `CTRL+CTRL_RAW_GRAB_VT`); while the active pane holds the grab the
dispatcher discards cooked chars and keeps that pane's ring flushed. `con.getch`
self-heals a grab leaked by a crashed app (a cooked reader isn't a grabber).
A raw-input app feature-detects (`typeof con.grabRawKeyboard === "function"`) and
grabs/releases around its fullscreen session — DOOM does it in
`i_video.mjs` `I_InitGraphics`/`I_ShutdownGraphics` (covers every fullscreen
mode; shutdown runs in `wadplayer.js`'s `finally`). Complementary: such an app's
poll should also no-op when it's *not* the active VT (compare `VT_CTRL_ADDR`
byte 0 to `VT_NUM`) so a backgrounded app doesn't eat the foreground console's
input — DOOM's `I_PollKeys` does this. Any future raw-key app under vtmgr needs
both.
### Files
- New: `assets/disk0/tvdos/VTMGR.SYS` (dispatcher + per-pane bootstrap)
@@ -565,6 +587,10 @@ arithmetic (no regression outside vtmgr). Applied so far in
- `assets/disk0/AUTOEXEC.BAT`: per-console launch (Korean IME + `command -fancy`)
- `assets/disk0/tvdos/bin/taut.js`, `assets/disk0/hopper/include/aa.mjs`:
`vaddr` VT-aware direct-VRAM addressing
- `assets/disk0/tvdos/VTMGR.SYS`: `CTRL_RAW_GRAB_VT` flag +
`con.grabRawKeyboard`/`releaseRawKeyboard` (raw-keyboard apps); dispatcher
drain honours it. DOOM consumer: `assets/disk0/home/doom/i_video.mjs`
(grab/release) + `i_input.mjs` (active-VT poll guard)
### Gotcha: injectIntChk vs. embedded source

View File

@@ -17,7 +17,10 @@
// +1 switch_request u8 (0 = none, 1..6 = target; set by chvt, cleared by dispatcher)
// +2 debounce_held u8
// +3 vt_spawned_bits u8 (bit n-1 set if VT n is alive)
// +4..63 reserved
// +4 raw_grab_vt u8 (0 = none; else VT n holding a raw-keyboard grab,
// i.e. a fullscreen app reading -41..-48 directly, e.g. DOOM. While set
// and == active_vt, the dispatcher stops feeding that pane's ring.)
// +5..63 reserved
// VT block (× MAX_VT) starting at base + 64, each VT_BLOCK_SIZE bytes
// +0..7 reserved (cursor & color state lives inside text plane itself)
// +8 queue_head u8 (next-read index)
@@ -38,6 +41,7 @@ const CTRL_ACTIVE_VT = 0
const CTRL_SWITCH_REQUEST = 1
const CTRL_DEBOUNCE_HELD = 2
const CTRL_SPAWNED_BITS = 3
const CTRL_RAW_GRAB_VT = 4
const GPU_TEXTAREA_OFFSET = 253950
const TEXT_COLS = 80
@@ -381,6 +385,8 @@ con.prnch = function(c) {
// keyboard MMIO — that's the dispatcher's exclusive territory. Cooperative
// gate on active_vt keeps background panes parked when they call getch.
const RAW_GRAB_ADDR = CTRL + ${CTRL_RAW_GRAB_VT}
function queuePop() {
let head = sys.peek(QUEUE_HEAD_ADDR)
let tail = sys.peek(QUEUE_TAIL_ADDR)
@@ -390,6 +396,10 @@ function queuePop() {
return b
}
con.getch = function() {
// Reading cooked input means we are NOT a raw-keyboard grabber; drop any
// stale grab this VT left set (e.g. a fullscreen app that crashed without
// releasing) so the dispatcher resumes feeding our ring.
if (sys.peek(RAW_GRAB_ADDR) === VT_NUM) sys.poke(RAW_GRAB_ADDR, 0)
while (true) {
if (sys.peek(ACTIVE_VT_ADDR) === VT_NUM) {
let k = queuePop()
@@ -398,6 +408,18 @@ con.getch = function() {
sys.sleep(20)
}
}
// A fullscreen app that reads the raw keyboard snapshot (-41..-48) directly,
// bypassing this ring, must grab so the dispatcher stops piling cooked chars
// into a ring it never drains. Flush any prior type-ahead on grab; the
// dispatcher keeps the ring empty while held. Release on exit (or the next
// con.getch self-heals a grab leaked by a crashed app).
con.grabRawKeyboard = function() {
sys.poke(RAW_GRAB_ADDR, VT_NUM)
sys.poke(QUEUE_HEAD_ADDR, sys.peek(QUEUE_TAIL_ADDR))
}
con.releaseRawKeyboard = function() {
if (sys.peek(RAW_GRAB_ADDR) === VT_NUM) sys.poke(RAW_GRAB_ADDR, 0)
}
con.hitterminate = function() { return false }
con.hiteof = function() { return false }
con.resetkeybuf = function() { sys.poke(QUEUE_HEAD_ADDR, sys.peek(QUEUE_TAIL_ADDR)) }
@@ -555,11 +577,23 @@ while (running) {
// keyboardBuffer, so doing it every frame would drop chars typed last frame.
if (sys.peek(-39) === 0) sys.poke(-39, 1)
// drain typed chars into the active pane's queue
// drain typed chars into the active pane's queue — UNLESS that pane holds
// a raw-keyboard grab (a fullscreen app reading -41..-48 directly, e.g.
// DOOM). Such an app never reads its ring, so cooked chars would pile up
// there and flood its shell the instant the app exits. We still drain the
// cooked buffer (must read -38 to clear the -50 count) but discard the
// chars, and keep the grabbing pane's ring flushed so nothing surfaces
// later. Alt-N is unaffected — it reads the raw snapshot above.
const rawGrab = sys.peek(CTRL + CTRL_RAW_GRAB_VT)
const feedRing = rawGrab !== active
while (sys.peek(-50) !== 0) {
let k = sys.peek(-38)
if (k < 0) k += 256
queuePush(active, k)
if (feedRing) queuePush(active, k)
}
if (!feedRing) {
const qb = vtBlockAddr(active)
sys.poke(qb + 8, sys.peek(qb + 9)) // head = tail: flush stale type-ahead
}
sys.sleep(33)

View File

@@ -9,6 +9,15 @@
const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSVMaud
const TAUD_VERSION = 1
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + sig(14)
// Container kind = top two bits of the version byte (terranmon.txt:3342-3401).
// 00 → full .taud (sample+inst image + song table + patterns)
// 10 → .tsii (sample+inst image only; numSongs = 0, no song table)
// 11 → .tpif (patterns only; sample+inst compSize = 0 — section absent —
// instruments come from a previously-loaded .tsii)
const TAUD_KIND_MASK = 0xC0
const TAUD_KIND_FULL = 0x00
const TAUD_KIND_SAMPLEINST = 0x80
const TAUD_KIND_PATTERN = 0xC0
const TAUD_SONG_ENTRY = 32 // see encodeSongEntry / decodeSongEntry below
// Sample+instrument image: 8 MB sample pool (banked, 16 × 512 K) + 64 K instrument bin = 8256 kB total.
// (terranmon.txt:1985-1997, 2533-2564 — bank-switched via MMIO 46.)
@@ -46,12 +55,18 @@ function _pokeU32LE(ptr, off, v) {
// ── uploadTaudFile ──────────────────────────────────────────────────────────
/**
* Load one song from a Taud file into the tracker hardware and configure the
* given playhead ready to play.
* Load a Taud container into the tracker hardware. Handles all three kinds
* (terranmon.txt:3342-3401), distinguished by the top two bits of the version
* byte:
* - full .taud (00): uploads the sample+instrument image AND loads one song.
* - .tsii (10): uploads the sample+instrument image ONLY (the shared bank for a
* collection of .tpif files). songIndex / playhead are ignored.
* - .tpif (11): loads one song's patterns ONLY, leaving the resident
* sample+instrument bank untouched — load the companion .tsii FIRST.
*
* @param inFile Full path with drive letter, e.g. "A:/music/song.taud"
* @param songIndex 0-based index of the song in the SONG TABLE
* @param playhead Playhead number (0-3) to configure
* @param songIndex 0-based index of the song in the SONG TABLE (ignored for .tsii)
* @param playhead Playhead number (0-3) to configure (ignored for .tsii)
*/
function uploadTaudFile(inFile, songIndex, playhead) {
const drive = inFile[0].toUpperCase()
@@ -92,21 +107,32 @@ function uploadTaudFile(inFile, songIndex, playhead) {
pos += 14 // signature
// pos == 32 == TAUD_HEADER_SIZE
if (songIndex < 0 || songIndex >= numSongs) {
sys.free(filePtr)
throw Error("taud: songIndex " + songIndex + " out of range (numSongs=" + numSongs + ")")
}
const kind = version & TAUD_KIND_MASK
const isSampleInst = (kind === TAUD_KIND_SAMPLEINST) // .tsii: instruments only
const isPattern = (kind === TAUD_KIND_PATTERN) // .tpif: patterns only
// -- 4. Decompress and upload sample+instrument bin -----------------------
// The decompressed image is 8256 kB (8 MB samples bank-major + 64 K instruments)
// which exceeds the 8 MB user-space cap, so we route through a hardware helper
// that decompresses straight into the adapter's native sample/instrument
// storage instead of staging a buffer in user memory.
audio.uploadSampleInstBlob(filePtr + pos, compressedSize)
audio.setSampleBank(0)
pos += compressedSize
// Skipped for .tpif — its sample+inst section is absent (compSize = 0) and the
// resident bank (from a previously-loaded .tsii) must be left intact.
if (!isPattern) {
audio.uploadSampleInstBlob(filePtr + pos, compressedSize)
audio.setSampleBank(0)
pos += compressedSize
}
// -- 5. Parse song-table entry for the requested song --------------------
// -- 5. Song table → patterns → cues → playhead (full .taud / .tpif only) --
// A .tsii carries no song table (numSongs = 0); it stops after the bank + Ixmp.
if (!isSampleInst) {
if (songIndex < 0 || songIndex >= numSongs) {
sys.free(filePtr)
throw Error("taud: songIndex " + songIndex + " out of range (numSongs=" + numSongs + ")")
}
// -- 5a. Parse song-table entry for the requested song -------------------
let entryOff = pos + songIndex * TAUD_SONG_ENTRY
let songOffset = _peekU32LE(filePtr, entryOff)
let numVoices = sys.peek(filePtr + entryOff + 4) & 0xFF
@@ -156,8 +182,11 @@ function uploadTaudFile(inFile, songIndex, playhead) {
audio.setTrackerMixerFlags(playhead, mixerflags)
audio.setSongGlobalVolume(playhead, songGlobalVolume)
audio.setSongMixingVolume(playhead, songMixingVolume)
} // end !isSampleInst (song table / patterns / cues / playhead)
// -- 9. Project Data — walk Ixmp blocks for multi-sample instruments -----
// Runs for every kind: a .tsii carries its instruments' Ixmp patches here; a
// .tpif carries only p/s blocks (sMet) and contributes no patches.
// Terranmon spec: Project Data starts at `projOff` (zero = absent), magic is
// \x1ETaudPrJ + 8 reserved bytes, then a stream of FourCC + Uint32-length
// sections. We only consume "Ixmp" here; other sections (PNam, INam, sMet,

View File

@@ -8,6 +8,20 @@ Usage:
[--bend-epsilon CENTS] [--drum-keyoff]
[-v] [--no-project-data]
# Batch / directory mode (terranmon.txt:3342-3401):
python3 midi2taud.py midi_dir/ soundfont.sf2 [out_dir/]
When the first argument is a DIRECTORY, every .mid/.midi inside it is compiled
against the one SoundFont into the split Taud format: a single shared Sample and
Instrument Image (<soundfont>.tsii) holding the instrument bank for all the songs,
plus one Pattern Image (<song>.tpif) per MIDI carrying just that song's patterns.
A .tpif is played by first loading its companion .tsii. The shared bank spans the
UNION of every song's instruments, so the 8 MB sample / 255 slot budgets are shared
too (overflow degrades exactly as in single-file mode). Instrument fadeouts encode
SF2 release times in seconds but the engine fades per song-tick (rate ∝ BPM), so the
shared image targets the mean of the songs' initial tempos; pass --fadeout for a
tempo-independent step. Output directory defaults to the input directory.
Behaviour (per midi2taud.md):
* Pitch bends are preserved as much as possible. A note starting under a
non-zero bend triggers directly at the bent 4096-TET pitch (Taud notes
@@ -85,6 +99,7 @@ import sys
from taud_common import (
set_verbose, vprint,
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
TAUD_KIND_FULL, TAUD_KIND_SAMPLEINST, TAUD_KIND_PATTERN,
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT,
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, NOTE_FASTFADE, TAUD_C4,
@@ -2382,10 +2397,15 @@ def build_pattern_bin(cells: dict, n_voices: int,
return bytes(out)
def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list,
slot_name: dict, pool: list, args) -> bytes:
speed, rpb = args.speed, args.rpb
def build_song_section(song: Song, speed: int, rpb: int, src_path: str,
args) -> dict:
"""Per-song pattern/cue build shared by the full .taud and the .tpif paths.
Trims leading silence, emits the cell grid, plans cues, builds & dedupes the
pattern bin, and packs the cue sheet — everything that depends on this song's
notes but NOT on the (possibly shared) sample+instrument image. Returns a dict
carrying the compressed pattern bin / cue sheet plus the song-table and sMet
fields the container assemblers need."""
# Leading-silence trim: shift the grid so the first trigger is row 0.
first_row = min(n.start_ft // speed for n in song.notes if n.slot > 0)
shift_ft = first_row * speed
@@ -2452,90 +2472,112 @@ def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list,
instr = CUE_INST_NOP
sheet[ci*CUE_SIZE:(ci+1)*CUE_SIZE] = encode_cue(pats, instr)
# ── Sample + instrument bin ──
sampleinst_raw = build_sample_inst_bin(sf, pool, layer_insts, meta_records,
args.fadeout, bpm0,
force_synth_loop=args.force_synth_loop)
assert len(sampleinst_raw) == SAMPLEINST_SIZE
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
comp_size = len(compressed)
# sMet beat divisions drive the tracker's row highlighting: primary = rows per
# NOTATED beat (the time-sig denominator), secondary = rows per bar. Using the
# denominator beat (not the rpb=rows-per-quarter) keeps the primary highlight a
# divisor of the bar — e.g. 7/8 → 2 rows (eighth), bar 14: 14 % 2 == 0, aligned;
# rpb=4 would drift (14 % 4 != 0). 4/4 → 4.
i = bisect.bisect_right(song.timesig_ft, shift_ft) - 1
_, init_dpow = song.timesig[i] if i >= 0 else (4, 2)
beat_pri = max(1, round(rpb * 4 / (2 ** init_dpow)))
title = song.title or os.path.splitext(os.path.basename(src_path))[0]
pat_comp = compress_blob(pat_bin, "pattern bin")
cue_comp = compress_blob(bytes(sheet), "cue sheet")
return {
'pat_comp': compress_blob(pat_bin, "pattern bin"),
'cue_comp': compress_blob(bytes(sheet), "cue sheet"),
'n_voices': n_voices,
'n_unique': n_unique,
'bpm0': bpm0,
'speed': speed,
'title': title,
'beat_pri': max(1, min(255, beat_pri)),
'beat_sec': max(1, min(255, init_bar_rows)),
}
song_table_off = TAUD_HEADER_SIZE + comp_size
song_off = song_table_off + TAUD_SONG_ENTRY
entry = encode_song_entry(
def make_song_entry(section: dict, song_off: int, args) -> bytes:
"""32-byte song-table row from a build_song_section() result."""
return encode_song_entry(
song_offset=song_off,
num_voices=n_voices,
num_patterns=n_unique,
bpm_stored=(bpm0 - 25) & 0xFF,
tick_rate=speed,
num_voices=section['n_voices'],
num_patterns=section['n_unique'],
bpm_stored=(section['bpm0'] - 25) & 0xFF,
tick_rate=section['speed'],
base_note=0xA000,
base_freq=8363.0,
flags_byte=0x00, # linear pitch mode
pat_bin_comp_size=len(pat_comp),
cue_sheet_comp_size=len(cue_comp),
pat_bin_comp_size=len(section['pat_comp']),
cue_sheet_comp_size=len(section['cue_comp']),
global_vol=0xFF,
mixing_vol=args.mixing_vol,
)
# ── Project data: names + the Ixmp section recreating SF2 layering ──
proj_data = b''
proj_off = 0
if not args.no_project_data:
# Names indexed by slot (0 = unused). Layer slots carry the (suffixed) layer
# instrument name; meta slots carry the bare preset name.
max_slot = max([0] + list(slot_name))
inst_names = ['' for _ in range(max_slot + 1)]
for s, nm in slot_name.items():
inst_names[s] = nm
smp_names = [''] + [ms.name for ms in pool]
ixmp = {}
for ti in layer_insts:
if not ti.usable:
continue
pl = [p.to_ixmp_dict(ti.canonical, bpm0, args.fadeout)
for p in ti.patches if p is not ti.canonical]
if pl:
ixmp[ti.slot] = pl
if ixmp:
vprint(f" ixmp: {sum(len(p) for p in ixmp.values())} patch(es) "
f"across {len(ixmp)} instrument(s)")
title = song.title or os.path.splitext(os.path.basename(args.input))[0]
# sMet beat divisions drive the tracker's row highlighting: primary =
# rows per NOTATED beat (the time-sig denominator), secondary = rows per
# bar. Using the denominator beat (not the rpb=rows-per-quarter) keeps the
# primary highlight a divisor of the bar — e.g. 7/8 → 2 rows (eighth), bar
# 14: 14 % 2 == 0, aligned; rpb=4 would drift (14 % 4 != 0). 4/4 → 4.
i = bisect.bisect_right(song.timesig_ft, shift_ft) - 1
_, init_dpow = song.timesig[i] if i >= 0 else (4, 2)
beat_pri = max(1, round(rpb * 4 / (2 ** init_dpow)))
song_meta = [{'index': 0, 'name': title,
'notation': 240, # 24-TET (MIDI is 12-TET but 24 is harmless & cleaner pre-pitchbend transpose notation); 0 = raw/hex display
'beat_pri': max(1, min(255, beat_pri)),
'beat_sec': max(1, min(255, init_bar_rows))}]
proj_data = build_project_data(
project_name=title,
instrument_names=inst_names,
sample_names=smp_names,
song_metadata=song_meta,
ixmp_patches=ixmp or None,
)
def make_song_meta(section: dict, index: int) -> dict:
"""sMet entry (Project Data 's' block) from a build_song_section() result."""
return {'index': index, 'name': section['title'],
'notation': 240, # 24-TET (MIDI is 12-TET but 24 is harmless & cleaner pre-pitchbend transpose notation); 0 = raw/hex display
'beat_pri': section['beat_pri'],
'beat_sec': section['beat_sec']}
def build_sampleinst_blob(sf: SF2, pool: list, layer_insts: list,
meta_records: list, bpm0: int, args) -> bytes:
"""Render + compress the sample+instrument image. MUST run before any
to_ixmp_dict() call, as it assigns each MonoSample's pool offset."""
sampleinst_raw = build_sample_inst_bin(sf, pool, layer_insts, meta_records,
args.fadeout, bpm0,
force_synth_loop=args.force_synth_loop)
assert len(sampleinst_raw) == SAMPLEINST_SIZE
return compress_blob(sampleinst_raw, "sample+inst bin")
def build_inst_names(slot_name: dict, pool: list) -> tuple:
"""(instrument_names, sample_names) slot-indexed lists for INam / SNam."""
max_slot = max([0] + list(slot_name))
inst_names = ['' for _ in range(max_slot + 1)]
for s, nm in slot_name.items():
inst_names[s] = nm
smp_names = [''] + [ms.name for ms in pool]
return inst_names, smp_names
def build_ixmp(layer_insts: list, bpm0: int, args) -> dict:
"""The Ixmp section recreating SF2 layering (instrument-id → patch dicts).
Reads each MonoSample's pool offset, so build_sampleinst_blob() must run first."""
ixmp = {}
for ti in layer_insts:
if not ti.usable:
continue
pl = [p.to_ixmp_dict(ti.canonical, bpm0, args.fadeout)
for p in ti.patches if p is not ti.canonical]
if pl:
ixmp[ti.slot] = pl
if ixmp:
vprint(f" ixmp: {sum(len(p) for p in ixmp.values())} patch(es) "
f"across {len(ixmp)} instrument(s)")
return ixmp
def taud_header(kind: int, num_songs: int, comp_size: int) -> bytes:
"""32-byte container header with projOff left at zero (patched by
finish_container when project data is present). `kind` is one of the
TAUD_KIND_* constants; the version byte is `kind | TAUD_VERSION`."""
header = (TAUD_MAGIC
+ bytes([TAUD_VERSION, 1])
+ bytes([(kind & 0xC0) | TAUD_VERSION, num_songs & 0xFF])
+ struct.pack('<I', comp_size)
+ struct.pack('<I', 0) # patched below if proj data
+ struct.pack('<I', 0) # projOff, patched in finish_container
+ (SIGNATURE + b' ' * 14)[:14])
assert len(header) == TAUD_HEADER_SIZE
return header
out = bytearray()
out += header
out += compressed
out += entry
out += pat_comp
out += cue_comp
def finish_container(kind: int, num_songs: int, comp_size: int,
body_parts: list, proj_data: bytes) -> bytes:
"""Concatenate header + body parts + optional project data, patching projOff."""
out = bytearray(taud_header(kind, num_songs, comp_size))
for part in body_parts:
out += part
if proj_data:
proj_off = len(out)
struct.pack_into('<I', out, 14, proj_off)
@@ -2544,16 +2586,94 @@ def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list,
return bytes(out)
def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list,
slot_name: dict, pool: list, args) -> bytes:
"""Full single-song .taud file: sample+inst image + song table + patterns."""
section = build_song_section(song, args.speed, args.rpb, args.input, args)
compressed = build_sampleinst_blob(sf, pool, layer_insts, meta_records,
section['bpm0'], args)
comp_size = len(compressed)
song_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
entry = make_song_entry(section, song_off, args)
# ── Project data: names + Ixmp (I/S) + song metadata (s) + project name (P) ──
proj_data = b''
if not args.no_project_data:
inst_names, smp_names = build_inst_names(slot_name, pool)
ixmp = build_ixmp(layer_insts, section['bpm0'], args)
proj_data = build_project_data(
project_name=section['title'],
instrument_names=inst_names,
sample_names=smp_names,
song_metadata=[make_song_meta(section, 0)],
ixmp_patches=ixmp or None,
)
return finish_container(TAUD_KIND_FULL, 1, comp_size,
[compressed, entry, section['pat_comp'],
section['cue_comp']], proj_data)
def assemble_tsii(sf: SF2, pool: list, layer_insts: list, meta_records: list,
slot_name: dict, bpm0: int, args) -> bytes:
"""Sample and Instrument Image (.tsii): the shared sample+instrument bank for
a collection of .tpif pattern files (terranmon.txt:3342). numSongs = 0, no
song table / patterns; project data carries only the I/S blocks
(INam, SNam, Ixmp)."""
compressed = build_sampleinst_blob(sf, pool, layer_insts, meta_records,
bpm0, args)
proj_data = b''
if not args.no_project_data:
inst_names, smp_names = build_inst_names(slot_name, pool)
ixmp = build_ixmp(layer_insts, bpm0, args)
proj_data = build_project_data(
instrument_names=inst_names,
sample_names=smp_names,
ixmp_patches=ixmp or None,
)
return finish_container(TAUD_KIND_SAMPLEINST, 0, len(compressed),
[compressed], proj_data)
def assemble_tpif(sections: list, args) -> bytes:
"""Pattern Image (.tpif): song table + patterns only, sharing the instruments
of a separately-loaded .tsii (terranmon.txt:3368). Sample+inst compSize = 0
(section absent); project data carries only the p/s blocks (sMet here, no
pattern names). `sections` is a list of build_song_section() results — one
per song in the file."""
n = len(sections)
table = bytearray()
blobs = bytearray()
# Pattern/cue data follows the whole song table; each entry points at its blob.
cursor = TAUD_HEADER_SIZE + n * TAUD_SONG_ENTRY
for sec in sections:
table += make_song_entry(sec, cursor, args)
blobs += sec['pat_comp']
blobs += sec['cue_comp']
cursor += len(sec['pat_comp']) + len(sec['cue_comp'])
proj_data = b''
if not args.no_project_data:
metas = [make_song_meta(sec, i) for i, sec in enumerate(sections)]
proj_data = build_project_data(song_metadata=metas)
return finish_container(TAUD_KIND_PATTERN, n, 0,
[bytes(table), bytes(blobs)], proj_data)
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('input', help='Input .mid file')
ap.add_argument('input', help='Input .mid file, OR a directory of MIDIs '
'(batch mode → shared .tsii + per-file .tpif)')
ap.add_argument('soundfont', help='SoundFont 2 (.sf2) sample library')
ap.add_argument('output', nargs='?', default=None,
help='Output .taud (default: input stem + .taud)')
help='Output .taud (default: input stem + .taud). In directory '
'mode: output directory (default: the input directory)')
ap.add_argument('--perc-force-mapping', nargs=2, type=int, default=None,
metavar=('BANK', 'INST'),
help='Force the percussion channel to this SF2 preset '
@@ -2615,37 +2735,52 @@ def main():
sys.exit("error: --max-layers must be 1..25")
if not (0 <= args.mixing_vol <= 255):
sys.exit("error: --mixingvol must be 0..255")
if args.output is None:
args.output = os.path.splitext(args.input)[0] + '.taud'
vprint(f"parsing MIDI '{args.input}'")
division, merged = parse_midi(args.input)
if os.path.isdir(args.input):
run_directory(args)
else:
run_single(args)
# ── Pipeline helpers (shared by single-file and directory modes) ───────────────
def load_sf2_verbose(path: str) -> SF2:
vprint(f"parsing SF2 '{path}'")
sf = parse_sf2(path)
vprint(f" {len(sf.presets)} preset(s), {len(sf.shdrs)} sample header(s)")
return sf
def load_midi_song(path: str, sf: SF2, args):
"""Parse one MIDI into a Song with its resolved Taud grid, then apply SF2
exclusive-class percussion choking. Returns (song, rpb, speed), or None when
the MIDI carries no playable notes."""
vprint(f"parsing MIDI '{path}'")
division, merged = parse_midi(path)
# Resolve the Taud grid (Tickspeed + RPB) before mapping ticks to fine-ticks.
# A pinned --rpb/--speed fixes that axis; the rest is auto-fit.
args.rpb, args.speed, timing_info = auto_timing(
rpb, speed, timing_info = auto_timing(
division, merged, args.rpb, args.speed, args.max_voices)
vprint(f" timing: rpb {args.rpb}, speed {args.speed} ({timing_info})")
vprint(f" timing: rpb {rpb}, speed {speed} ({timing_info})")
song = extract_song(division, merged, args.rpb, args.speed)
song = extract_song(division, merged, rpb, speed)
vprint(f" {len(song.notes)} note(s), {len(song.tempo_ft)} tempo event(s), "
f"{len(song.timesig_ft)} time-signature event(s)")
if not song.notes:
sys.exit("error: MIDI contains no playable notes")
vprint(f"parsing SF2 '{args.soundfont}'")
sf = parse_sf2(args.soundfont)
vprint(f" {len(sf.presets)} preset(s), {len(sf.shdrs)} sample header(s)")
return None
# SF2 exclusiveClass percussion choking (closed hi-hat silences open hi-hat, etc.).
apply_exclusive_class(song, sf, args.perc_force_mapping)
return song, rpb, speed
# Presets in first-use order; triggers keyed by the exact (noteVal-with-initial-
# bend, vol6) pair the patterns will carry, so layer trimming sees precisely what
# the engine matches at runtime.
slot_keys = []
seen_keys = set()
triggers = {}
def collect_triggers(song: Song, slot_keys: list, seen_keys: set,
triggers: dict) -> None:
"""Append this song's presets (first-use order) to slot_keys and merge its
trigger (noteVal-with-initial-bend, vol6) histogram into `triggers`. The keys
match exactly what the patterns will carry, so layer trimming sees precisely
what the engine matches at runtime."""
for n in song.notes:
if n.inst_key not in seen_keys:
seen_keys.add(n.inst_key)
@@ -2653,15 +2788,13 @@ def main():
t = triggers.setdefault(n.inst_key, {})
k = (key_to_noteval(n.key + n.bend0), round(n.vel * 63 / 127))
t[k] = t.get(k, 0) + 1
vprint(f" {len(slot_keys)} preset(s) in use")
registry = {}
presets = build_presets(sf, slot_keys, triggers, args.perc_force_mapping,
registry, args.max_layers)
# Allocate instrument-bin slots: each layer is a normal instrument; a preset with
# >1 layer also takes a Metainstrument slot the note references. Single-layer
# presets stay plain instruments (no meta, no extra slot).
def allocate_slots(presets: dict, slot_keys: list):
"""Assign instrument-bin slots across `slot_keys`. Each layer is a normal
instrument; a preset with >1 layer also takes a Metainstrument slot the note
references. Single-layer presets stay plain instruments (no meta, no extra
slot). Returns (layer_insts, meta_records, slot_name, note_slot)."""
next_slot = 1
layer_insts = [] # all normal instruments, .slot assigned
meta_records = [] # (meta_slot, name, [(layer_slot, bbox_rect)])
@@ -2691,8 +2824,12 @@ def main():
note_slot[ik] = meta_slot
vprint(f" slots: {next_slot - 1} used — {len(layer_insts)} instrument(s), "
f"{len(meta_records)} Metainstrument(s)")
return layer_insts, meta_records, slot_name, note_slot
# Tag notes with their trigger slot; notes whose preset failed to resolve drop.
def tag_notes(song: Song, note_slot: dict) -> bool:
"""Tag each note with its trigger slot and drop the unresolvable ones. Returns
True when the song keeps at least one note."""
unplayable = 0
for n in song.notes:
n.slot = note_slot.get(n.inst_key, 0)
@@ -2701,11 +2838,12 @@ def main():
if unplayable:
vprint(f" warning: {unplayable} note(s) dropped (unresolvable preset)")
song.notes = [n for n in song.notes if n.slot > 0]
if not song.notes:
sys.exit("error: no notes survived preset resolution")
return bool(song.notes)
# Pool = every sample referenced by a kept patch (canonical included), in
# deterministic first-reference order. Everything else is trimmed.
def build_pool(layer_insts: list) -> list:
"""Pool = every sample referenced by a kept patch (canonical included), in
deterministic first-reference order. Everything else is trimmed."""
pool = []
seen = set()
for ti in layer_insts:
@@ -2713,6 +2851,46 @@ def main():
if id(p.ms) not in seen:
seen.add(id(p.ms))
pool.append(p.ms)
return pool
def find_midi_files(dir_path: str) -> list:
"""Top-level .mid / .midi files in `dir_path`, sorted for deterministic order."""
out = []
for name in sorted(os.listdir(dir_path)):
full = os.path.join(dir_path, name)
if (os.path.isfile(full)
and os.path.splitext(name)[1].lower() in ('.mid', '.midi')):
out.append(full)
return out
# ── Conversion entry points ────────────────────────────────────────────────────
def run_single(args) -> None:
"""Single MIDI → one self-contained .taud."""
if args.output is None:
args.output = os.path.splitext(args.input)[0] + '.taud'
sf = load_sf2_verbose(args.soundfont)
loaded = load_midi_song(args.input, sf, args)
if loaded is None:
sys.exit("error: MIDI contains no playable notes")
song, args.rpb, args.speed = loaded
slot_keys, seen_keys, triggers = [], set(), {}
collect_triggers(song, slot_keys, seen_keys, triggers)
vprint(f" {len(slot_keys)} preset(s) in use")
registry = {}
presets = build_presets(sf, slot_keys, triggers, args.perc_force_mapping,
registry, args.max_layers)
layer_insts, meta_records, slot_name, note_slot = allocate_slots(
presets, slot_keys)
if not tag_notes(song, note_slot):
sys.exit("error: no notes survived preset resolution")
pool = build_pool(layer_insts)
taud = assemble_taud(sf, song, layer_insts, meta_records, slot_name, pool, args)
sf.file.close()
@@ -2722,5 +2900,78 @@ def main():
print(f"wrote {len(taud)} bytes to '{args.output}'")
def run_directory(args) -> None:
"""Directory of MIDIs → one shared .tsii (sample+instrument bank spanning the
union of every song) + one .tpif per MIDI (patterns only). terranmon.txt:3342."""
out_dir = args.output or args.input
midis = find_midi_files(args.input)
if not midis:
sys.exit(f"error: no .mid/.midi files in directory '{args.input}'")
os.makedirs(out_dir, exist_ok=True)
vprint(f"directory mode: {len(midis)} MIDI file(s) → shared .tsii + per-file .tpif")
sf = load_sf2_verbose(args.soundfont)
# Phase 1: parse every MIDI, aggregating the preset/trigger universe so the
# shared instrument bank covers the union of all songs.
jobs = [] # (path, song, rpb, speed) for files with playable notes
slot_keys, seen_keys, triggers = [], set(), {}
for path in midis:
loaded = load_midi_song(path, sf, args)
if loaded is None:
vprint(f" warning: '{os.path.basename(path)}' has no playable notes — skipped")
continue
song, rpb, speed = loaded
collect_triggers(song, slot_keys, seen_keys, triggers)
jobs.append((path, song, rpb, speed))
if not jobs:
sys.exit("error: no MIDI file produced playable notes")
vprint(f" {len(slot_keys)} preset(s) across {len(jobs)} song(s)")
# Phase 2: build the one shared instrument set for the whole union.
registry = {}
presets = build_presets(sf, slot_keys, triggers, args.perc_force_mapping,
registry, args.max_layers)
layer_insts, meta_records, slot_name, note_slot = allocate_slots(
presets, slot_keys)
# Phase 3: per song — tag notes against the shared slots, build the pattern
# section, and write its .tpif. (Independent of the sample+inst image below.)
sections = []
for path, song, rpb, speed in jobs:
stem = os.path.splitext(os.path.basename(path))[0]
vprint(f"building '{stem}'")
if not tag_notes(song, note_slot):
vprint(f" warning: '{stem}' lost all notes to preset resolution — skipped")
continue
section = build_song_section(song, speed, rpb, path, args)
tpif = assemble_tpif([section], args)
out_path = os.path.join(out_dir, stem + '.tpif')
with open(out_path, 'wb') as f:
f.write(tpif)
print(f"wrote {len(tpif)} bytes to '{out_path}'")
sections.append(section)
if not sections:
sys.exit("error: no song survived preset resolution")
# Phase 4: the shared .tsii. Its fadeouts encode SF2 release times in seconds,
# but the engine fades per song-tick (rate ∝ BPM), so one image matches only one
# tempo exactly — target the mean of the songs' initial BPMs (override per-step
# with --fadeout). build_pool / build_sampleinst_blob run last because they
# assign the sample offsets the .tsii's Ixmp reads.
pool = build_pool(layer_insts)
ref_bpm0 = round(sum(s['bpm0'] for s in sections) / len(sections))
vprint(f"building shared .tsii (reference BPM {ref_bpm0})…")
tsii = assemble_tsii(sf, pool, layer_insts, meta_records, slot_name,
ref_bpm0, args)
sf.file.close()
sf_stem = os.path.splitext(os.path.basename(args.soundfont))[0]
tsii_path = os.path.join(out_dir, sf_stem + '.tsii')
with open(tsii_path, 'wb') as f:
f.write(tsii)
print(f"wrote {len(tsii)} bytes to '{tsii_path}'")
if __name__ == '__main__':
main()

View File

@@ -71,6 +71,18 @@ TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
TAUD_VERSION = 1
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14)
TAUD_SONG_ENTRY = 32 # full spec entry (see encode_song_entry)
# Container kind = top two bits of the version byte (terranmon.txt §.tsii / §.tpif,
# 3342-3401). The low six bits always carry TAUD_VERSION, so the full version byte
# is `kind | TAUD_VERSION`.
# 00 → full song file (.taud): sample+inst image + song table + patterns
# 10 → sample+instrument image (.tsii): numSongs = 0, no song table/patterns;
# project data carries only I*/S* blocks (INam, SNam, Ixmp)
# 11 → pattern image (.tpif): sample+inst compSize = 0 (section absent), song
# table + patterns present; project data carries only p*/s* blocks (pNam, sMet)
TAUD_KIND_FULL = 0x00
TAUD_KIND_SAMPLEINST = 0x80
TAUD_KIND_PATTERN = 0xC0
INST_RECORD_SIZE = 256 # widened 2026-05-06 (was 192). 256 inst × 256 = 64K.
# Sample+instrument image (terranmon.txt:1985-1997, 2533-2564 — updated 2026-05-08).
# Sample pool is now 8 MB, banked through MMIO 46 in 16 × 512 K windows.

View File

@@ -3339,6 +3339,68 @@ prefixes:
--------------------------------------------------------------------------------
**Taud Sample and Instrument Image Format (.tsii)**
Created by CuriousTorvald on 2026-06-15
This is a file format for storing sample+inst image only, intended for multiple Taud Pattern Images (.tpif) that share a same samples.
Endianness: Little
# Conformance language
Identical to **Taud serialisation format**.
# File Structure
\x1F T S V M a u d
[HEADER]
[SAMPLE+INSTRUMENT BIN IMAGE (GZip or Zstd compressed. Read 4-byte magic to determine)]
[PROJECT DATA] (optional)
[DATA BLOCKS WITH FOURCC HEADER (see Project Data section)]
Basically a specialised "interpretation" of **Taud serialisation format**. The format differs in the following ways:
* Empty song/pattern table. Number of songs is zero
* Version is always `0b10_xxxxxx`, where 'x' follows the expected Taud version
* PROJECT DATA has just enough blocks to represent instruments (ones starting with 'I' or 'S')
**Taud Pattern Image Format (.tpif)**
Created by CuriousTorvald on 2026-06-15
This is a file format for storing patterns/songs only.
Endianness: Little
# Conformance language
Identical to **Taud serialisation format**.
# File Structure
\x1F T S V M a u d
[HEADER]
[SONG TABLE]
[PATTERN BIN for SONG 0 (GZip or Zstd compressed)]
[CUE SHEET for SONG 0 (GZip or Zstd compressed)]
[PATTERN BIN for SONG 1 (GZip or Zstd compressed)]
[CUE SHEET for SONG 1 (GZip or Zstd compressed)]
[PATTERN BIN for SONG 2 (GZip or Zstd compressed)]
[CUE SHEET for SONG 2 (GZip or Zstd compressed)]
...
[PROJECT DATA] (optional)
[DATA BLOCKS WITH FOURCC HEADER (see Project Data section)]
Basically a specialised "interpretation" of **Taud serialisation format**. The format differs in the following ways:
* Compressed size of SAMPLE+INST section is zero
* Version is always `0b11_xxxxxx`, where 'x' follows the expected Taud version
* PROJECT DATA has just enough blocks to represent patterns and songs (ones starting with 'p' or 's')
# Intended Use Case
* Converting a collection of MIDI to Taud with single SoundFont for all. SoundFont is translated into .tsii, and MIDI files are translated into .tpif
--------------------------------------------------------------------------------
**S3M (ScreamTracker 3) to Taud conversion notes**
(Implemented in s3m2taud.py)
Created by CuriousTorvald on 2026-04-20