From fb0765a041f19876ae8d62679228415967c21ae6 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sun, 14 Jun 2026 23:12:41 +0900 Subject: [PATCH] taud: 'halt at x' cmd --- CLAUDE.md | 3 + it2taud.py | 26 +++--- midi2taud.py | 85 +++++++++++++------ taud_common.py | 51 +++++++++-- terranmon.txt | 2 + .../torvald/tsvm/peripheral/AudioAdapter.kt | 44 ++++++---- xm2taud.py | 25 +++--- 7 files changed, 164 insertions(+), 72 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a325eaf..d6f8554 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,6 +200,9 @@ The Taud playback engine lives in `tsvm_core/src/net/torvald/tsvm/peripheral/Aud **SoundFont filter mode uses an RBJ biquad, NOT the IT all-pole filter.** `refreshVoiceFilter` has two topologies. The IT/tracker path (`else` branch) is the all-pole 2-pole resonant LPF from `reference_materials/tracker_filter/` (no feedforward zeros) — must stay byte-faithful for tracker playback, do not touch it. The **`filterSfMode` branch ports FluidSynth's voice filter** (`reference_materials/fluidsynth/`, see its `README.md`): cutoff = absolute cents → Hz via `8.176·2^(cents/1200)` clamped to `[5 Hz, 0.45·fs]`; Q from centibels with FluidSynth's **−3.01 dB offset** (so Q=0 cB ⇒ q_lin = 1/√2 Butterworth, no resonance hump); RBJ cookbook low-pass coefficients with the SF2 `1/√Q` passband gain-norm. `applyVoiceFilter` runs the biquad (Direct Form I: `y = b02·(x+x₂) + b1·x₁ − a1·y₁ − a2·y₂`) when `voice.filterIsBiquad`. The old code reused the all-pole filter for SF mode too; it is overdamped and rolled the passband off ~3 dB @ 8 kHz / ~5 dB @ 12 kHz vs FluidSynth → audible muffling on every filtered GM instrument. Per-voice biquad state (`filterBqB02/B1/A1/A2`, input history `filterX1/X2`) must be reset on trigger/retrigger and copied in `copyVoice` (NNA ghost) alongside the output history. The background-voice filter-env path must branch on `filterSfMode` too, else an SF-mode ghost's cents-domain cutoff gets clamped into the IT 0..254 byte range (≈9 Hz → silence). +### System Soundfont Location +Look for `/media/torvald/Warehouse/*.sf2` and `/media/torvald/Warehouse/*.SF2` + ## TVDOS ### TVDOS Movie Formats diff --git a/it2taud.py b/it2taud.py index 0511075..052e652 100644 --- a/it2taud.py +++ b/it2taud.py @@ -55,7 +55,8 @@ from taud_common import ( d_arg_to_col, resample_linear, rescale_offset_effects_per_slot, encode_cue, deduplicate_patterns, normalise_sample, encode_song_entry, nearest_minifloat, compress_blob, - CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len, + CUE_INST_NOP, CUE_INST_HALT, cue_instruction_len, + cue_instruction_halt_at, build_project_data, detect_subsongs, IXMP_PAN_NO_OVERRIDE, ) @@ -1873,29 +1874,26 @@ def _build_song_payload(h: ITHeader, patterns_rows_template: list, for c in range(NUM_CUES): sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0) - last_active = -1 + n_emit = min(len(cue_list), NUM_CUES) len_cue_count = 0 - for cue_idx, ci in enumerate(cue_list): - if cue_idx >= NUM_CUES: break + for cue_idx in range(n_emit): + ci = cue_list[cue_idx] base_pat = cue_idx * C pat_idx_list = [pat_remap[base_pat + vi] for vi in range(C)] clen = chunk_lens[ci] if ci < len(chunk_lens) else PATTERN_ROWS - if clen < PATTERN_ROWS: + if cue_idx == n_emit - 1: + # Final cue: play its own length then HALT. "Halt at x" preserves the + # partial length (a short terminal pattern halts at `clen` instead of + # running the full 64-row padding); a full-length cue emits a plain HALT. + instr = cue_instruction_halt_at(clen) + elif clen < PATTERN_ROWS: instr = cue_instruction_len(clen) len_cue_count += 1 else: instr = CUE_INST_NOP sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(pat_idx_list, instr) - last_active = cue_idx - if last_active >= 0: - b30_existing = sheet[last_active * CUE_SIZE + 30] - if b30_existing == CUE_INST_LEN: - vprint(f" [{song_label}] warning: last active cue {last_active} had LEN; " - f"replaced with HALT (partial tail at song terminus)") - sheet[last_active * CUE_SIZE + 30] = CUE_INST_HALT - sheet[last_active * CUE_SIZE + 31] = 0x00 - else: + if n_emit == 0: sheet[30] = CUE_INST_HALT if len_cue_count: vprint(f" [{song_label}] emitted {len_cue_count} LEN cue instruction(s) " diff --git a/midi2taud.py b/midi2taud.py index 2c3039f..b6a5cc1 100644 --- a/midi2taud.py +++ b/midi2taud.py @@ -37,8 +37,7 @@ Behaviour (per midi2taud.md): while the key is on. There is NO release leg — the SF2 *release segment* is the Volume Fadeout (with NNA Note Fade): on key-off the voice holds at the sustain node and fades to silence over the SF2 releaseVolEnv time - (measured against the 100 dB envelope floor: releaseVolEnv·(1000−sus_cb)/ - 1000 seconds, then scaled to FluidSynth's PERCEIVED release length because + (the full release, scaled to FluidSynth's PERCEIVED release length because the engine's fadeout is linear in amplitude, not dB — see _zone_fadeout). Per-layer Ixmp patches carry their own fadeout when their release differs. The canonical zone's ADSR represents the instrument. @@ -93,7 +92,8 @@ from taud_common import ( SEL_SET, SEL_FINE, CUE_INST_NOP, CUE_INST_HALT, resample_linear, encode_cue, deduplicate_patterns, encode_song_entry, - compress_blob, build_project_data, cue_instruction_len, nearest_minifloat, + compress_blob, build_project_data, cue_instruction_len, + cue_instruction_halt_at, last_note_cue_index, nearest_minifloat, IXMP_PAN_NO_OVERRIDE, atten_cb_to_octet, ) @@ -1254,10 +1254,14 @@ class Patch: # Volume Fadeout = this patch's own SF2 release segment; emit 'x' when it (or any # filter / atten field) differs from the canonical zone so the per-layer release # time is faithful (an absent 'x' falls through to the base record's fadeout). A - # synthesized-loop sample disables its key-off fadeout (its decay is the vol-env, - # which runs from note-on regardless of key state). - fo_s = 0 if self.ms.synth_loop is not None else _zone_fadeout(z, bpm0, fadeout_override) - fo_c = 0 if canonical.ms.synth_loop is not None else _zone_fadeout(c, bpm0, fadeout_override) + # synthesized-loop sample keeps its key-off fadeout too: the peak->0 decay vol-env + # (no sustain wrap) only fades the HELD note to silence ~SF2_SYNTH_DECAY_SEC after + # note-on; on key-off the voice must still release over the SF2 release time as + # FluidSynth does. Forcing 0 here left key-off inert, so released notes rang for the + # whole 10 s decay (audible on piano/pizz/mallet patches in Musyng Kite & Timbres + # of Heaven 4.00 whose long unlooped samples take the synth-loop path). + fo_s = _zone_fadeout(z, bpm0, fadeout_override) + fo_c = _zone_fadeout(c, bpm0, fadeout_override) filt_differs = (filt_s != filt_c) if (sf_s != sf_c or cut_s != cut_c or res_s != res_c or att_s != att_c or filt_differs or fo_s != fo_c): @@ -1587,21 +1591,35 @@ _RELEASE_PERCEPTUAL_SCALE = 0.25 def _zone_fadeout(z: SFZone, bpm0: int, fadeout_override) -> int: """Volume Fadeout step encoding the zone's SF2 release segment (gen 38, releaseVolEnv). With NNA Note Fade the fadeout IS the release: on key-off the - voice holds at the sustain level and fades to silence. The SF2 release ramps a - constant 100 dB per `releaseVolEnv` seconds (spec sfspec24.txt:1934-1941 — "until - 100dB attenuation were reached"), so the time from the sustain level (sus_cb cB of - attenuation) down to the 100 dB floor is releaseVolEnv·(1000−sus_cb)/1000. + voice fades to silence over the release time. - But the engine's fadeout is linear in AMPLITUDE while FluidSynth's release is linear - in dB (see [_RELEASE_PERCEPTUAL_SCALE]); matching the floor-reaching time would make - the audible tail ~4× too long, so fade_sec is scaled to FluidSynth's perceived release. + FluidSynth's release (fluid_rvoice.c:54-55, fluid_voice.c:1092-1094) ramps the + volume-envelope coefficient LINEARLY from its value at key-off down to 0, where + amplitude = cb2amp(960·(1−volenv_val)) — i.e. the coefficient is linear in dB. The + release rate is fixed: a full 1.0→0 ramp takes `releaseVolEnv` seconds, so a note + released at coefficient v reaches silence in v·releaseVolEnv. v is HIGH whenever the + note is still audible at key-off — which is the norm for the long-decay instruments + (bells, organs, harpsichord, sitar, mute guitar) that Timbres of Heaven & friends + encode with a silent sustain (sustainVolEnv≈1000 cB) and a multi-second decay: the + decay IS the sound, and the key is lifted long before it reaches the silent sustain. + So the fadeout must reflect the FULL releaseVolEnv, NOT a sustain-scaled fraction. + + The earlier model scaled by (1000−sus_cb)/1000 (the release time FROM the sustain + level), which is only correct for a note held all the way to its sustain. For the + decay instruments above (sus_cb≈1000) it collapsed to ~0 → an instant cut on key-off + instead of FluidSynth's seconds-long release — released organ/bell/harpsichord notes + were chopped off. Dropping the factor leaves the common sustained instruments + (sus_cb≈0, factor was ≈1) unchanged and gives the decay instruments a real release. + + The engine's fadeout is linear in AMPLITUDE while FluidSynth's release is linear in + dB (see [_RELEASE_PERCEPTUAL_SCALE]); matching the floor-reaching time would make the + audible tail ~4× too long, so fade_sec is scaled to FluidSynth's perceived release. fadeStep makes the fadeout complete in fade_sec at bpm0: the engine subtracts fadeStep/1024 of unit volume per song tick, and the tick rate is bpm0·2/5 Hz, giving fadeStep = 2560/(fade_sec·bpm0).""" if fadeout_override is not None: return min(0xFFF, max(0, fadeout_override)) - sus_cb = min(max(0.0, z.env_sustain_cb), 1000.0) - fade_sec = max(0.02, _RELEASE_PERCEPTUAL_SCALE * z.env_release * (1000.0 - sus_cb) / 1000.0) + fade_sec = max(0.02, _RELEASE_PERCEPTUAL_SCALE * z.env_release) return max(1, min(0xFFF, round(2560.0 / (fade_sec * bpm0)))) @@ -1934,11 +1952,13 @@ def build_sample_inst_bin(sf: SF2, pool: list, layer_insts: list, meta_records: wenv(197, 199, 201, pit_env) # Volume Fadeout = the SF2 release segment (NNA Note Fade below). Derived from - # the canonical zone's releaseVolEnv against the 100 dB envelope floor; see - # _zone_fadeout for the timecent→step derivation. A synthesized-loop sample - # disables the key-off fadeout (its decay is the vol-envelope, which runs from - # note-on regardless of key state) so key-off does not cut it short. - fo = 0 if ms.synth_loop is not None else _zone_fadeout(c.zone, bpm0, fadeout_override) + # the canonical zone's full releaseVolEnv; see _zone_fadeout for the timecent→step + # derivation and why it is NOT sustain-scaled. A synthesized-loop sample keeps + # its key-off fadeout too: the peak->0 decay vol-env (no sustain wrap) only fades + # the HELD note to silence over ~SF2_SYNTH_DECAY_SEC from note-on; on key-off the + # voice must still release over the SF2 release time (FluidSynth does), else the + # released note rings for the whole decay span instead of stopping. + fo = _zone_fadeout(c.zone, bpm0, fadeout_override) inst_bin[base + 171] = 0xFF # IGV (unit) inst_bin[base + 172] = fo & 0xFF # byte 173: bits 0-3 = fadeout high nibble, bit 4 = SF filter mode (cutoff/resonance @@ -2373,6 +2393,21 @@ def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list, f"> {NUM_PATTERNS_MAX} pattern limit") pat_bin = build_pattern_bin(cells, n_voices, cue_starts, cue_lens) + + # Trim trailing note-free cues: the MIDI release pass emits a final cue that + # is just key-offs (and the silence after the song's last note), which shows + # up as a dead bar at the end (e.g. M_E1M1's lone-key-off terminus cue). Drop + # whole cues with no actual note; the new last cue then HALTs at its own + # length. Special notes (key-off/cut/fade) are not notes here. + last_cue = last_note_cue_index(pat_bin, n_cues, n_voices) + if 0 <= last_cue < n_cues - 1: + dropped = n_cues - 1 - last_cue + n_cues = last_cue + 1 + cue_starts = cue_starts[:n_cues] + cue_lens = cue_lens[:n_cues] + pat_bin = pat_bin[:n_cues * n_voices * PATTERN_BYTES] + vprint(f" info: trimmed {dropped} trailing note-free cue(s)") + pat_bin, remap, n_unique = deduplicate_patterns(pat_bin, n_cues * n_voices) n_breaks = sum(1 for ft in song.timesig_ft if 0 < (ft - shift_ft) // speed < total_rows) @@ -2386,7 +2421,9 @@ def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list, for ci in range(n_cues): pats = [remap[ci * n_voices + v] for v in range(n_voices)] if ci == n_cues - 1: - instr = CUE_INST_HALT + # Halt after this cue's own length (a partial final bar plays only its + # rows instead of the full 64-row pattern). + instr = cue_instruction_halt_at(cue_lens[ci]) elif cue_lens[ci] < PATTERN_ROWS: instr = cue_instruction_len(cue_lens[ci]) else: @@ -2508,8 +2545,8 @@ def main(): ap.add_argument('--fadeout', type=int, default=None, help='Override the computed fadeout step (0..4095). By ' 'default each instrument/patch gets a Volume Fadeout ' - 'reproducing its SF2 release segment (releaseVolEnv vs ' - 'the 100 dB floor), played out via NNA Note Fade') + 'reproducing its SF2 release segment (the full ' + 'releaseVolEnv), played out via NNA Note Fade') ap.add_argument('--max-voices', type=int, default=20, help='Voice-column budget, 1..20 (default 20). NNA ' 'background ghosts carry release/ring tails, so ' diff --git a/taud_common.py b/taud_common.py index 6ef48dd..170245f 100644 --- a/taud_common.py +++ b/taud_common.py @@ -105,12 +105,13 @@ TAUD_C4 = 0x5000 # The audio engine's Middle C # Cue sheet instruction byte (cue offset 30; offset 31 = arg byte for 2-byte forms). # Per terranmon.txt §"Cue Sheet": -# 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64 -# 00000001 (HALT) end of song -# 00000000 (NOP) default 64-row cue -# 1000xxxx yyyyyyyy (BAK) go back 12-bit arg -# 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg -# 1111xxxx yyyyyyyy (JMP) go to absolute pattern +# 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64 +# 00000001 00000000 (HALT) play the full pattern then stop (end of song) +# 00000001 01xxxxxx (HALT x) play x rows then stop (x = 0 ⇒ full length) +# 00000000 (NOP) default 64-row cue +# 1000xxxx yyyyyyyy (BAK) go back 12-bit arg +# 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg +# 1111xxxx yyyyyyyy (JMP) go to absolute pattern CUE_INST_NOP = 0x00 CUE_INST_HALT = 0x01 CUE_INST_LEN = 0x02 @@ -359,6 +360,44 @@ def cue_instruction_len(rows: int) -> tuple: return (CUE_INST_LEN, (rows - 1) & 0x3F) +def cue_instruction_halt_at(rows: int) -> tuple: + """Build the 2-byte 'Halt at x' cue instruction (terranmon.txt §"Cue Sheet"). + + Plays `rows` rows of the pattern (1..64) then stops playback. Encoding is + byte30 = 0x01, byte31 = 0b01xxxxxx where x is the row count itself (NOT + rows-1 like LEN); x = 0 means "full length". A full-length halt (rows >= 64) + is therefore emitted as a plain HALT (byte31 = 0) so existing full-pattern + final cues stay byte-identical. + """ + if not 1 <= rows <= 64: + raise ValueError(f"halt-at row count must be 1..64, got {rows}") + if rows >= 64: + return (CUE_INST_HALT, 0x00) + return (CUE_INST_HALT, 0x40 | (rows & 0x3F)) + + +def last_note_cue_index(pat_bin: bytes, num_cues: int, num_channels: int) -> int: + """Index of the last cue holding an *actual* note, or -1 if none. + + `pat_bin` is the raw (pre-dedup) pattern binary, laid out as + `num_cues × num_channels` consecutive PATTERN_BYTES blocks, channel-minor + within each cue (block = (cue*num_channels + ch)*PATTERN_BYTES; each row is + 8 bytes, note = little-endian u16 at offset 0). Special notes — NOP, + key-off, cut, note-fade, fast-fade (values 0..NOTE_FASTFADE) — are not + notes; only pitches above that count. Used to trim trailing note-free cues + (e.g. a MIDI conversion's final all-key-off release cue). + """ + for cue in range(num_cues - 1, -1, -1): + for ch in range(num_channels): + block = (cue * num_channels + ch) * PATTERN_BYTES + for row in range(PATTERN_ROWS): + rb = block + row * 8 + note = pat_bin[rb] | (pat_bin[rb + 1] << 8) + if note > NOTE_FASTFADE: + return cue + return -1 + + def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple: """Consolidate identical 512-byte Taud patterns into a single copy. diff --git a/terranmon.txt b/terranmon.txt index 96d7ce0..823cbfe 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2868,6 +2868,7 @@ TODO: at execApp (:1457:16) at Object.execute (:893:38) at :867:31 + [ ] Timbres of Heaven's Overdriven Gt does not decay even after the fix TODO - list of demo songs that MUST ship with Microtone: * 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes @@ -3019,6 +3020,7 @@ Play Head Flags 1111xxxx yyyyyyyy (JMP000) - Go to absolute pattern number 0bxxxxyyyyyyyy 00000010 00xxxxxx (LEN 00) - Pattern length for this cue (0..63), where 0: 1 row, 63: 64 rows (decoded by AudioAdapter as of 2026-05-05; emitted by xm2taud / it2taud for non-multiple-of-64 source patterns) 00000001 00000000 - Halt (HALT ) - Play the full length of the pattern then stop the playback + 00000001 01xxxxxx - Halt at x (HALT 00) - Play the specified length of the pattern then stop the playback. x = 0 is identical to regular HALT. (decoded by AudioAdapter as of 2026-06-14; emitted as the final cue by midi2taud / it2taud / xm2taud so a partial last bar halts at its own length) 00000001 00xxxxxx - Fadeout (FADOUT) - Gradually decrease global volume such that at row 0bxxxxxx it reaches zero, then stop the playback 00000000 - No operation diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 536465e..73d5b8a 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -2812,7 +2812,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { val length = (rawRow.effectArg ushr 8) and 0xFF val repeats = rawRow.effectArg and 0xFF if (length > 0 && repeats > 0 && length <= n) { - val patLen = (cue.instruction as? PlayInstPatLen)?.rows ?: 64 + val patLen = cueRowLimit(cue.instruction) voice.dittoSourceStart = n - length voice.dittoLength = length voice.dittoEndRow = minOf(n + length * repeats - 1, patLen - 1) @@ -3823,9 +3823,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { else -> vol }.coerceIn(0, 0x3F) + /** Effective playable row count for a cue: LEN and "halt at x" both shorten it. */ + private fun cueRowLimit(instr: PlayInstruction): Int = when (instr) { + is PlayInstPatLen -> instr.rows + is PlayInstHaltAt -> instr.rows + else -> 64 + } + private fun advanceTrackerCue(ts: TrackerState, playhead: Playhead) { val instr = cueSheet[ts.cuePos].instruction - if (instr is PlayInstHalt) { playhead.isPlaying = false; return } + if (instr is PlayInstHalt || instr is PlayInstHaltAt) { playhead.isPlaying = false; return } ts.cuePos = when (instr) { is PlayInstGoBack -> (ts.cuePos - instr.arg).coerceAtLeast(0) is PlayInstSkip -> (ts.cuePos + instr.arg).coerceAtMost(1023) @@ -4056,12 +4063,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } else -> { ts.rowIndex++ - // LEN cue instruction shortens the effective row count so the - // engine wraps to the next cue early. Patterns fed by the - // converter are still 64 rows long; rows past `rowLimit` are - // silent padding that we skip here. - val currentInst = cueSheet[ts.cuePos].instruction - val rowLimit = if (currentInst is PlayInstPatLen) currentInst.rows else 64 + // LEN / "halt at x" cue instructions shorten the effective row + // count so the engine wraps to the next cue (or halts) early. + // Patterns fed by the converter are still 64 rows long; rows past + // `rowLimit` are silent padding that we skip here. + val rowLimit = cueRowLimit(cueSheet[ts.cuePos].instruction) if (ts.rowIndex >= rowLimit) { ts.rowIndex = 0 advanceTrackerCue(ts, playhead) @@ -4085,18 +4091,24 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // byte 30: instruction (low byte) // byte 31: instruction arg byte (used by 2-byte forms: LEN, BAK, FWD, JMP) // Decoding rules per terranmon.txt §"Cue Sheet": - // 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64 - // 00000001 (HALT) end of song - // 00000000 (NOP) default 64-row cue - // 1000xxxx yyyyyyyy (BAK) go back 12-bit arg - // 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg - // 1111xxxx yyyyyyyy (JMP) go to absolute pattern (currently unused) + // 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64 + // 00000001 00000000 (HALT) play the full pattern then stop + // 00000001 01xxxxxx (HALT x) play x rows then stop (x = 0 ⇒ full length) + // 00000000 (NOP) default 64-row cue + // 1000xxxx yyyyyyyy (BAK) go back 12-bit arg + // 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg + // 1111xxxx yyyyyyyy (JMP) go to absolute pattern (currently unused) private fun recomputeInstruction() { val b30 = instByte30 val b31 = instByte31 instruction = when { b30 == 0x02 -> PlayInstPatLen((b31 and 0x3F) + 1) - b30 == 0x01 -> PlayInstHalt + // HALT family: arg byte 01xxxxxx ⇒ "halt at x" (play x rows; x = 0 ⇒ + // full length, identical to a plain HALT). Any other arg ⇒ plain HALT. + b30 == 0x01 -> if ((b31 and 0xC0) == 0x40) { + val x = b31 and 0x3F + PlayInstHaltAt(if (x == 0) 64 else x) + } else PlayInstHalt b30 == 0x00 -> PlayInstNop // BAK: 1000xxxx yyyyyyyy — 12-bit arg combining b30 low nybble + b31. (b30 and 0xF0) == 0x80 -> PlayInstGoBack(((b30 and 0xF) shl 8) or (b31 and 0xFF)) @@ -4149,6 +4161,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { internal class PlayInstGoBack(arg: Int) : PlayInstruction(arg) internal class PlayInstSkip(arg: Int) : PlayInstruction(arg) internal class PlayInstPatLen(val rows: Int) : PlayInstruction(rows) + /** "Halt at x": play [rows] rows of the pattern (1..64) then stop. */ + internal class PlayInstHaltAt(val rows: Int) : PlayInstruction(rows) internal object PlayInstHalt : PlayInstruction(0) internal object PlayInstNop : PlayInstruction(0) diff --git a/xm2taud.py b/xm2taud.py index aefa4b9..bb8a4e2 100644 --- a/xm2taud.py +++ b/xm2taud.py @@ -64,7 +64,8 @@ from taud_common import ( d_arg_to_col, resample_linear, rescale_offset_effects_per_slot, encode_cue, deduplicate_patterns, normalise_sample, encode_song_entry, nearest_minifloat, compress_blob, - CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len, + CUE_INST_NOP, CUE_INST_HALT, cue_instruction_len, + cue_instruction_halt_at, build_project_data, detect_subsongs, ) @@ -1376,28 +1377,26 @@ def _build_song_payload_xm(h: XMHeader, patterns_template: list, for c in range(NUM_CUES): sheet[c * CUE_SIZE:c * CUE_SIZE + CUE_SIZE] = encode_cue([], 0) - last_active = -1 + n_emit = min(len(cue_list), NUM_CUES) len_cue_count = 0 - for cue_idx, ci in enumerate(cue_list): - if cue_idx >= NUM_CUES: break + for cue_idx in range(n_emit): + ci = cue_list[cue_idx] base_pat = cue_idx * C pats = [pat_remap[base_pat + vi] for vi in range(C)] clen = chunk_lens[ci] if ci < len(chunk_lens) else PATTERN_ROWS - if clen < PATTERN_ROWS: + if cue_idx == n_emit - 1: + # Final cue: play its own length then HALT. "Halt at x" preserves the + # partial length (a short terminal pattern halts at `clen` instead of + # running the full 64-row padding); a full-length cue emits a plain HALT. + instr = cue_instruction_halt_at(clen) + elif clen < PATTERN_ROWS: instr = cue_instruction_len(clen) len_cue_count += 1 else: instr = CUE_INST_NOP sheet[cue_idx * CUE_SIZE:(cue_idx + 1) * CUE_SIZE] = encode_cue(pats, instr) - last_active = cue_idx - if last_active >= 0: - if sheet[last_active * CUE_SIZE + 30] == CUE_INST_LEN: - vprint(f" [{song_label}] warning: last active cue {last_active} " - f"had LEN; replaced with HALT (partial tail at song terminus)") - sheet[last_active * CUE_SIZE + 30] = CUE_INST_HALT - sheet[last_active * CUE_SIZE + 31] = 0x00 - else: + if n_emit == 0: sheet[30] = CUE_INST_HALT if len_cue_count: vprint(f" [{song_label}] emitted {len_cue_count} LEN cue instruction(s) "