mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-16 01:14:04 +09:00
tsii and tpif; more vt stuffs
This commit is contained in:
26
CLAUDE.md
26
CLAUDE.md
@@ -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`
|
`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`.
|
(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
|
### Files
|
||||||
|
|
||||||
- New: `assets/disk0/tvdos/VTMGR.SYS` (dispatcher + per-pane bootstrap)
|
- 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/AUTOEXEC.BAT`: per-console launch (Korean IME + `command -fancy`)
|
||||||
- `assets/disk0/tvdos/bin/taut.js`, `assets/disk0/hopper/include/aa.mjs`:
|
- `assets/disk0/tvdos/bin/taut.js`, `assets/disk0/hopper/include/aa.mjs`:
|
||||||
`vaddr` VT-aware direct-VRAM addressing
|
`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
|
### Gotcha: injectIntChk vs. embedded source
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,10 @@
|
|||||||
// +1 switch_request u8 (0 = none, 1..6 = target; set by chvt, cleared by dispatcher)
|
// +1 switch_request u8 (0 = none, 1..6 = target; set by chvt, cleared by dispatcher)
|
||||||
// +2 debounce_held u8
|
// +2 debounce_held u8
|
||||||
// +3 vt_spawned_bits u8 (bit n-1 set if VT n is alive)
|
// +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
|
// VT block (× MAX_VT) starting at base + 64, each VT_BLOCK_SIZE bytes
|
||||||
// +0..7 reserved (cursor & color state lives inside text plane itself)
|
// +0..7 reserved (cursor & color state lives inside text plane itself)
|
||||||
// +8 queue_head u8 (next-read index)
|
// +8 queue_head u8 (next-read index)
|
||||||
@@ -38,6 +41,7 @@ const CTRL_ACTIVE_VT = 0
|
|||||||
const CTRL_SWITCH_REQUEST = 1
|
const CTRL_SWITCH_REQUEST = 1
|
||||||
const CTRL_DEBOUNCE_HELD = 2
|
const CTRL_DEBOUNCE_HELD = 2
|
||||||
const CTRL_SPAWNED_BITS = 3
|
const CTRL_SPAWNED_BITS = 3
|
||||||
|
const CTRL_RAW_GRAB_VT = 4
|
||||||
|
|
||||||
const GPU_TEXTAREA_OFFSET = 253950
|
const GPU_TEXTAREA_OFFSET = 253950
|
||||||
const TEXT_COLS = 80
|
const TEXT_COLS = 80
|
||||||
@@ -381,6 +385,8 @@ con.prnch = function(c) {
|
|||||||
// keyboard MMIO — that's the dispatcher's exclusive territory. Cooperative
|
// keyboard MMIO — that's the dispatcher's exclusive territory. Cooperative
|
||||||
// gate on active_vt keeps background panes parked when they call getch.
|
// gate on active_vt keeps background panes parked when they call getch.
|
||||||
|
|
||||||
|
const RAW_GRAB_ADDR = CTRL + ${CTRL_RAW_GRAB_VT}
|
||||||
|
|
||||||
function queuePop() {
|
function queuePop() {
|
||||||
let head = sys.peek(QUEUE_HEAD_ADDR)
|
let head = sys.peek(QUEUE_HEAD_ADDR)
|
||||||
let tail = sys.peek(QUEUE_TAIL_ADDR)
|
let tail = sys.peek(QUEUE_TAIL_ADDR)
|
||||||
@@ -390,6 +396,10 @@ function queuePop() {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
con.getch = function() {
|
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) {
|
while (true) {
|
||||||
if (sys.peek(ACTIVE_VT_ADDR) === VT_NUM) {
|
if (sys.peek(ACTIVE_VT_ADDR) === VT_NUM) {
|
||||||
let k = queuePop()
|
let k = queuePop()
|
||||||
@@ -398,6 +408,18 @@ con.getch = function() {
|
|||||||
sys.sleep(20)
|
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.hitterminate = function() { return false }
|
||||||
con.hiteof = function() { return false }
|
con.hiteof = function() { return false }
|
||||||
con.resetkeybuf = function() { sys.poke(QUEUE_HEAD_ADDR, sys.peek(QUEUE_TAIL_ADDR)) }
|
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.
|
// keyboardBuffer, so doing it every frame would drop chars typed last frame.
|
||||||
if (sys.peek(-39) === 0) sys.poke(-39, 1)
|
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) {
|
while (sys.peek(-50) !== 0) {
|
||||||
let k = sys.peek(-38)
|
let k = sys.peek(-38)
|
||||||
if (k < 0) k += 256
|
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)
|
sys.sleep(33)
|
||||||
|
|||||||
@@ -9,6 +9,15 @@
|
|||||||
const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSVMaud
|
const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSVMaud
|
||||||
const TAUD_VERSION = 1
|
const TAUD_VERSION = 1
|
||||||
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + sig(14)
|
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
|
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.
|
// 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.)
|
// (terranmon.txt:1985-1997, 2533-2564 — bank-switched via MMIO 46.)
|
||||||
@@ -46,12 +55,18 @@ function _pokeU32LE(ptr, off, v) {
|
|||||||
// ── uploadTaudFile ──────────────────────────────────────────────────────────
|
// ── uploadTaudFile ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load one song from a Taud file into the tracker hardware and configure the
|
* Load a Taud container into the tracker hardware. Handles all three kinds
|
||||||
* given playhead ready to play.
|
* (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 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 songIndex 0-based index of the song in the SONG TABLE (ignored for .tsii)
|
||||||
* @param playhead Playhead number (0-3) to configure
|
* @param playhead Playhead number (0-3) to configure (ignored for .tsii)
|
||||||
*/
|
*/
|
||||||
function uploadTaudFile(inFile, songIndex, playhead) {
|
function uploadTaudFile(inFile, songIndex, playhead) {
|
||||||
const drive = inFile[0].toUpperCase()
|
const drive = inFile[0].toUpperCase()
|
||||||
@@ -92,21 +107,32 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
|||||||
pos += 14 // signature
|
pos += 14 // signature
|
||||||
// pos == 32 == TAUD_HEADER_SIZE
|
// pos == 32 == TAUD_HEADER_SIZE
|
||||||
|
|
||||||
if (songIndex < 0 || songIndex >= numSongs) {
|
const kind = version & TAUD_KIND_MASK
|
||||||
sys.free(filePtr)
|
const isSampleInst = (kind === TAUD_KIND_SAMPLEINST) // .tsii: instruments only
|
||||||
throw Error("taud: songIndex " + songIndex + " out of range (numSongs=" + numSongs + ")")
|
const isPattern = (kind === TAUD_KIND_PATTERN) // .tpif: patterns only
|
||||||
}
|
|
||||||
|
|
||||||
// -- 4. Decompress and upload sample+instrument bin -----------------------
|
// -- 4. Decompress and upload sample+instrument bin -----------------------
|
||||||
// The decompressed image is 8256 kB (8 MB samples bank-major + 64 K instruments)
|
// 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
|
// 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
|
// that decompresses straight into the adapter's native sample/instrument
|
||||||
// storage instead of staging a buffer in user memory.
|
// storage instead of staging a buffer in user memory.
|
||||||
|
// 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.uploadSampleInstBlob(filePtr + pos, compressedSize)
|
||||||
audio.setSampleBank(0)
|
audio.setSampleBank(0)
|
||||||
pos += compressedSize
|
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 entryOff = pos + songIndex * TAUD_SONG_ENTRY
|
||||||
let songOffset = _peekU32LE(filePtr, entryOff)
|
let songOffset = _peekU32LE(filePtr, entryOff)
|
||||||
let numVoices = sys.peek(filePtr + entryOff + 4) & 0xFF
|
let numVoices = sys.peek(filePtr + entryOff + 4) & 0xFF
|
||||||
@@ -156,8 +182,11 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
|||||||
audio.setTrackerMixerFlags(playhead, mixerflags)
|
audio.setTrackerMixerFlags(playhead, mixerflags)
|
||||||
audio.setSongGlobalVolume(playhead, songGlobalVolume)
|
audio.setSongGlobalVolume(playhead, songGlobalVolume)
|
||||||
audio.setSongMixingVolume(playhead, songMixingVolume)
|
audio.setSongMixingVolume(playhead, songMixingVolume)
|
||||||
|
} // end !isSampleInst (song table / patterns / cues / playhead)
|
||||||
|
|
||||||
// -- 9. Project Data — walk Ixmp blocks for multi-sample instruments -----
|
// -- 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
|
// Terranmon spec: Project Data starts at `projOff` (zero = absent), magic is
|
||||||
// \x1ETaudPrJ + 8 reserved bytes, then a stream of FourCC + Uint32-length
|
// \x1ETaudPrJ + 8 reserved bytes, then a stream of FourCC + Uint32-length
|
||||||
// sections. We only consume "Ixmp" here; other sections (PNam, INam, sMet,
|
// sections. We only consume "Ixmp" here; other sections (PNam, INam, sMet,
|
||||||
|
|||||||
425
midi2taud.py
425
midi2taud.py
@@ -8,6 +8,20 @@ Usage:
|
|||||||
[--bend-epsilon CENTS] [--drum-keyoff]
|
[--bend-epsilon CENTS] [--drum-keyoff]
|
||||||
[-v] [--no-project-data]
|
[-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):
|
Behaviour (per midi2taud.md):
|
||||||
* Pitch bends are preserved as much as possible. A note starting under a
|
* 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
|
non-zero bend triggers directly at the bent 4096-TET pitch (Taud notes
|
||||||
@@ -85,6 +99,7 @@ import sys
|
|||||||
from taud_common import (
|
from taud_common import (
|
||||||
set_verbose, vprint,
|
set_verbose, vprint,
|
||||||
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
|
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,
|
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT,
|
||||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||||
NOTE_NOP, NOTE_KEYOFF, NOTE_FASTFADE, TAUD_C4,
|
NOTE_NOP, NOTE_KEYOFF, NOTE_FASTFADE, TAUD_C4,
|
||||||
@@ -2382,10 +2397,15 @@ def build_pattern_bin(cells: dict, n_voices: int,
|
|||||||
return bytes(out)
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list,
|
def build_song_section(song: Song, speed: int, rpb: int, src_path: str,
|
||||||
slot_name: dict, pool: list, args) -> bytes:
|
args) -> dict:
|
||||||
speed, rpb = args.speed, args.rpb
|
"""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.
|
# 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)
|
first_row = min(n.start_ft // speed for n in song.notes if n.slot > 0)
|
||||||
shift_ft = first_row * speed
|
shift_ft = first_row * speed
|
||||||
@@ -2452,45 +2472,79 @@ def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list,
|
|||||||
instr = CUE_INST_NOP
|
instr = CUE_INST_NOP
|
||||||
sheet[ci*CUE_SIZE:(ci+1)*CUE_SIZE] = encode_cue(pats, instr)
|
sheet[ci*CUE_SIZE:(ci+1)*CUE_SIZE] = encode_cue(pats, instr)
|
||||||
|
|
||||||
# ── Sample + instrument bin ──
|
# sMet beat divisions drive the tracker's row highlighting: primary = rows per
|
||||||
sampleinst_raw = build_sample_inst_bin(sf, pool, layer_insts, meta_records,
|
# NOTATED beat (the time-sig denominator), secondary = rows per bar. Using the
|
||||||
args.fadeout, bpm0,
|
# denominator beat (not the rpb=rows-per-quarter) keeps the primary highlight a
|
||||||
force_synth_loop=args.force_synth_loop)
|
# divisor of the bar — e.g. 7/8 → 2 rows (eighth), bar 14: 14 % 2 == 0, aligned;
|
||||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
# rpb=4 would drift (14 % 4 != 0). 4/4 → 4.
|
||||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
i = bisect.bisect_right(song.timesig_ft, shift_ft) - 1
|
||||||
comp_size = len(compressed)
|
_, 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")
|
return {
|
||||||
cue_comp = compress_blob(bytes(sheet), "cue sheet")
|
'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
|
def make_song_entry(section: dict, song_off: int, args) -> bytes:
|
||||||
entry = encode_song_entry(
|
"""32-byte song-table row from a build_song_section() result."""
|
||||||
|
return encode_song_entry(
|
||||||
song_offset=song_off,
|
song_offset=song_off,
|
||||||
num_voices=n_voices,
|
num_voices=section['n_voices'],
|
||||||
num_patterns=n_unique,
|
num_patterns=section['n_unique'],
|
||||||
bpm_stored=(bpm0 - 25) & 0xFF,
|
bpm_stored=(section['bpm0'] - 25) & 0xFF,
|
||||||
tick_rate=speed,
|
tick_rate=section['speed'],
|
||||||
base_note=0xA000,
|
base_note=0xA000,
|
||||||
base_freq=8363.0,
|
base_freq=8363.0,
|
||||||
flags_byte=0x00, # linear pitch mode
|
flags_byte=0x00, # linear pitch mode
|
||||||
pat_bin_comp_size=len(pat_comp),
|
pat_bin_comp_size=len(section['pat_comp']),
|
||||||
cue_sheet_comp_size=len(cue_comp),
|
cue_sheet_comp_size=len(section['cue_comp']),
|
||||||
global_vol=0xFF,
|
global_vol=0xFF,
|
||||||
mixing_vol=args.mixing_vol,
|
mixing_vol=args.mixing_vol,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Project data: names + the Ixmp section recreating SF2 layering ──
|
|
||||||
proj_data = b''
|
def make_song_meta(section: dict, index: int) -> dict:
|
||||||
proj_off = 0
|
"""sMet entry (Project Data 's' block) from a build_song_section() result."""
|
||||||
if not args.no_project_data:
|
return {'index': index, 'name': section['title'],
|
||||||
# Names indexed by slot (0 = unused). Layer slots carry the (suffixed) layer
|
'notation': 240, # 24-TET (MIDI is 12-TET but 24 is harmless & cleaner pre-pitchbend transpose notation); 0 = raw/hex display
|
||||||
# instrument name; meta slots carry the bare preset name.
|
'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))
|
max_slot = max([0] + list(slot_name))
|
||||||
inst_names = ['' for _ in range(max_slot + 1)]
|
inst_names = ['' for _ in range(max_slot + 1)]
|
||||||
for s, nm in slot_name.items():
|
for s, nm in slot_name.items():
|
||||||
inst_names[s] = nm
|
inst_names[s] = nm
|
||||||
smp_names = [''] + [ms.name for ms in pool]
|
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 = {}
|
ixmp = {}
|
||||||
for ti in layer_insts:
|
for ti in layer_insts:
|
||||||
if not ti.usable:
|
if not ti.usable:
|
||||||
@@ -2502,40 +2556,28 @@ def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list,
|
|||||||
if ixmp:
|
if ixmp:
|
||||||
vprint(f" ixmp: {sum(len(p) for p in ixmp.values())} patch(es) "
|
vprint(f" ixmp: {sum(len(p) for p in ixmp.values())} patch(es) "
|
||||||
f"across {len(ixmp)} instrument(s)")
|
f"across {len(ixmp)} instrument(s)")
|
||||||
title = song.title or os.path.splitext(os.path.basename(args.input))[0]
|
return ixmp
|
||||||
# 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 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
|
header = (TAUD_MAGIC
|
||||||
+ bytes([TAUD_VERSION, 1])
|
+ bytes([(kind & 0xC0) | TAUD_VERSION, num_songs & 0xFF])
|
||||||
+ struct.pack('<I', comp_size)
|
+ 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])
|
+ (SIGNATURE + b' ' * 14)[:14])
|
||||||
assert len(header) == TAUD_HEADER_SIZE
|
assert len(header) == TAUD_HEADER_SIZE
|
||||||
|
return header
|
||||||
|
|
||||||
out = bytearray()
|
|
||||||
out += header
|
def finish_container(kind: int, num_songs: int, comp_size: int,
|
||||||
out += compressed
|
body_parts: list, proj_data: bytes) -> bytes:
|
||||||
out += entry
|
"""Concatenate header + body parts + optional project data, patching projOff."""
|
||||||
out += pat_comp
|
out = bytearray(taud_header(kind, num_songs, comp_size))
|
||||||
out += cue_comp
|
for part in body_parts:
|
||||||
|
out += part
|
||||||
if proj_data:
|
if proj_data:
|
||||||
proj_off = len(out)
|
proj_off = len(out)
|
||||||
struct.pack_into('<I', out, 14, proj_off)
|
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)
|
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 ──────────────────────────────────────────────────────────────────────
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(
|
ap = argparse.ArgumentParser(
|
||||||
description=__doc__,
|
description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
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('soundfont', help='SoundFont 2 (.sf2) sample library')
|
||||||
ap.add_argument('output', nargs='?', default=None,
|
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,
|
ap.add_argument('--perc-force-mapping', nargs=2, type=int, default=None,
|
||||||
metavar=('BANK', 'INST'),
|
metavar=('BANK', 'INST'),
|
||||||
help='Force the percussion channel to this SF2 preset '
|
help='Force the percussion channel to this SF2 preset '
|
||||||
@@ -2615,37 +2735,52 @@ def main():
|
|||||||
sys.exit("error: --max-layers must be 1..25")
|
sys.exit("error: --max-layers must be 1..25")
|
||||||
if not (0 <= args.mixing_vol <= 255):
|
if not (0 <= args.mixing_vol <= 255):
|
||||||
sys.exit("error: --mixingvol must be 0..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}'…")
|
if os.path.isdir(args.input):
|
||||||
division, merged = parse_midi(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.
|
# Resolve the Taud grid (Tickspeed + RPB) before mapping ticks to fine-ticks.
|
||||||
# A pinned --rpb/--speed fixes that axis; the rest is auto-fit.
|
# 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)
|
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), "
|
vprint(f" {len(song.notes)} note(s), {len(song.tempo_ft)} tempo event(s), "
|
||||||
f"{len(song.timesig_ft)} time-signature event(s)")
|
f"{len(song.timesig_ft)} time-signature event(s)")
|
||||||
if not song.notes:
|
if not song.notes:
|
||||||
sys.exit("error: MIDI contains no playable notes")
|
return None
|
||||||
|
|
||||||
vprint(f"parsing SF2 '{args.soundfont}'…")
|
|
||||||
sf = parse_sf2(args.soundfont)
|
|
||||||
vprint(f" {len(sf.presets)} preset(s), {len(sf.shdrs)} sample header(s)")
|
|
||||||
|
|
||||||
# SF2 exclusiveClass percussion choking (closed hi-hat silences open hi-hat, etc.).
|
# SF2 exclusiveClass percussion choking (closed hi-hat silences open hi-hat, etc.).
|
||||||
apply_exclusive_class(song, sf, args.perc_force_mapping)
|
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
|
def collect_triggers(song: Song, slot_keys: list, seen_keys: set,
|
||||||
# the engine matches at runtime.
|
triggers: dict) -> None:
|
||||||
slot_keys = []
|
"""Append this song's presets (first-use order) to slot_keys and merge its
|
||||||
seen_keys = set()
|
trigger (noteVal-with-initial-bend, vol6) histogram into `triggers`. The keys
|
||||||
triggers = {}
|
match exactly what the patterns will carry, so layer trimming sees precisely
|
||||||
|
what the engine matches at runtime."""
|
||||||
for n in song.notes:
|
for n in song.notes:
|
||||||
if n.inst_key not in seen_keys:
|
if n.inst_key not in seen_keys:
|
||||||
seen_keys.add(n.inst_key)
|
seen_keys.add(n.inst_key)
|
||||||
@@ -2653,15 +2788,13 @@ def main():
|
|||||||
t = triggers.setdefault(n.inst_key, {})
|
t = triggers.setdefault(n.inst_key, {})
|
||||||
k = (key_to_noteval(n.key + n.bend0), round(n.vel * 63 / 127))
|
k = (key_to_noteval(n.key + n.bend0), round(n.vel * 63 / 127))
|
||||||
t[k] = t.get(k, 0) + 1
|
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
|
def allocate_slots(presets: dict, slot_keys: list):
|
||||||
# >1 layer also takes a Metainstrument slot the note references. Single-layer
|
"""Assign instrument-bin slots across `slot_keys`. Each layer is a normal
|
||||||
# presets stay plain instruments (no meta, no extra slot).
|
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
|
next_slot = 1
|
||||||
layer_insts = [] # all normal instruments, .slot assigned
|
layer_insts = [] # all normal instruments, .slot assigned
|
||||||
meta_records = [] # (meta_slot, name, [(layer_slot, bbox_rect)])
|
meta_records = [] # (meta_slot, name, [(layer_slot, bbox_rect)])
|
||||||
@@ -2691,8 +2824,12 @@ def main():
|
|||||||
note_slot[ik] = meta_slot
|
note_slot[ik] = meta_slot
|
||||||
vprint(f" slots: {next_slot - 1} used — {len(layer_insts)} instrument(s), "
|
vprint(f" slots: {next_slot - 1} used — {len(layer_insts)} instrument(s), "
|
||||||
f"{len(meta_records)} Metainstrument(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
|
unplayable = 0
|
||||||
for n in song.notes:
|
for n in song.notes:
|
||||||
n.slot = note_slot.get(n.inst_key, 0)
|
n.slot = note_slot.get(n.inst_key, 0)
|
||||||
@@ -2701,11 +2838,12 @@ def main():
|
|||||||
if unplayable:
|
if unplayable:
|
||||||
vprint(f" warning: {unplayable} note(s) dropped (unresolvable preset)")
|
vprint(f" warning: {unplayable} note(s) dropped (unresolvable preset)")
|
||||||
song.notes = [n for n in song.notes if n.slot > 0]
|
song.notes = [n for n in song.notes if n.slot > 0]
|
||||||
if not song.notes:
|
return bool(song.notes)
|
||||||
sys.exit("error: no notes survived preset resolution")
|
|
||||||
|
|
||||||
# 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 = []
|
pool = []
|
||||||
seen = set()
|
seen = set()
|
||||||
for ti in layer_insts:
|
for ti in layer_insts:
|
||||||
@@ -2713,6 +2851,46 @@ def main():
|
|||||||
if id(p.ms) not in seen:
|
if id(p.ms) not in seen:
|
||||||
seen.add(id(p.ms))
|
seen.add(id(p.ms))
|
||||||
pool.append(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)
|
taud = assemble_taud(sf, song, layer_insts, meta_records, slot_name, pool, args)
|
||||||
sf.file.close()
|
sf.file.close()
|
||||||
@@ -2722,5 +2900,78 @@ def main():
|
|||||||
print(f"wrote {len(taud)} bytes to '{args.output}'")
|
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__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
|
|||||||
TAUD_VERSION = 1
|
TAUD_VERSION = 1
|
||||||
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14)
|
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)
|
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.
|
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+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.
|
# Sample pool is now 8 MB, banked through MMIO 46 in 16 × 512 K windows.
|
||||||
|
|||||||
@@ -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**
|
**S3M (ScreamTracker 3) to Taud conversion notes**
|
||||||
(Implemented in s3m2taud.py)
|
(Implemented in s3m2taud.py)
|
||||||
Created by CuriousTorvald on 2026-04-20
|
Created by CuriousTorvald on 2026-04-20
|
||||||
|
|||||||
Reference in New Issue
Block a user