taud: 'halt at x' cmd

This commit is contained in:
minjaesong
2026-06-14 23:12:41 +09:00
parent 9eb8b9b3f8
commit fb0765a041
7 changed files with 164 additions and 72 deletions

View File

@@ -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

View File

@@ -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) "

View File

@@ -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·(1000sus_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·(1000sus_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·(1volenv_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 (1000sus_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 '

View File

@@ -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.

View File

@@ -2868,6 +2868,7 @@ TODO:
at execApp (<eval>:1457:16)
at Object.execute (<eval>:893:38)
at <eval>: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

View File

@@ -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)

View File

@@ -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) "