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). **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
### TVDOS Movie Formats ### TVDOS Movie Formats

View File

@@ -55,7 +55,8 @@ from taud_common import (
d_arg_to_col, resample_linear, rescale_offset_effects_per_slot, d_arg_to_col, resample_linear, rescale_offset_effects_per_slot,
encode_cue, deduplicate_patterns, encode_cue, deduplicate_patterns,
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob, 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, build_project_data, detect_subsongs,
IXMP_PAN_NO_OVERRIDE, IXMP_PAN_NO_OVERRIDE,
) )
@@ -1873,29 +1874,26 @@ def _build_song_payload(h: ITHeader, patterns_rows_template: list,
for c in range(NUM_CUES): for c in range(NUM_CUES):
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0) 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 len_cue_count = 0
for cue_idx, ci in enumerate(cue_list): for cue_idx in range(n_emit):
if cue_idx >= NUM_CUES: break ci = cue_list[cue_idx]
base_pat = cue_idx * C base_pat = cue_idx * C
pat_idx_list = [pat_remap[base_pat + vi] for vi in range(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 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) instr = cue_instruction_len(clen)
len_cue_count += 1 len_cue_count += 1
else: else:
instr = CUE_INST_NOP instr = CUE_INST_NOP
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(pat_idx_list, instr) sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(pat_idx_list, instr)
last_active = cue_idx
if last_active >= 0: if n_emit == 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:
sheet[30] = CUE_INST_HALT sheet[30] = CUE_INST_HALT
if len_cue_count: if len_cue_count:
vprint(f" [{song_label}] emitted {len_cue_count} LEN cue instruction(s) " 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* 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 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 the sustain node and fades to silence over the SF2 releaseVolEnv time
(measured against the 100 dB envelope floor: releaseVolEnv·(1000sus_cb)/ (the full release, scaled to FluidSynth's PERCEIVED release length because
1000 seconds, then scaled to FluidSynth's PERCEIVED release length because
the engine's fadeout is linear in amplitude, not dB — see _zone_fadeout). 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. Per-layer Ixmp patches carry their own fadeout when their release differs.
The canonical zone's ADSR represents the instrument. The canonical zone's ADSR represents the instrument.
@@ -93,7 +92,8 @@ from taud_common import (
SEL_SET, SEL_FINE, SEL_SET, SEL_FINE,
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_NOP, CUE_INST_HALT,
resample_linear, encode_cue, deduplicate_patterns, encode_song_entry, 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, 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 # 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 # 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 # 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, # synthesized-loop sample keeps its key-off fadeout too: the peak->0 decay vol-env
# which runs from note-on regardless of key state). # (no sustain wrap) only fades the HELD note to silence ~SF2_SYNTH_DECAY_SEC after
fo_s = 0 if self.ms.synth_loop is not None else _zone_fadeout(z, bpm0, fadeout_override) # note-on; on key-off the voice must still release over the SF2 release time as
fo_c = 0 if canonical.ms.synth_loop is not None else _zone_fadeout(c, bpm0, fadeout_override) # 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) 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 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): 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: def _zone_fadeout(z: SFZone, bpm0: int, fadeout_override) -> int:
"""Volume Fadeout step encoding the zone's SF2 release segment (gen 38, """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 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 voice fades to silence over the release time.
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.
But the engine's fadeout is linear in AMPLITUDE while FluidSynth's release is linear FluidSynth's release (fluid_rvoice.c:54-55, fluid_voice.c:1092-1094) ramps the
in dB (see [_RELEASE_PERCEPTUAL_SCALE]); matching the floor-reaching time would make volume-envelope coefficient LINEARLY from its value at key-off down to 0, where
the audible tail ~4× too long, so fade_sec is scaled to FluidSynth's perceived release. 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 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/1024 of unit volume per song tick, and the tick rate is bpm0·2/5 Hz, giving
fadeStep = 2560/(fade_sec·bpm0).""" fadeStep = 2560/(fade_sec·bpm0)."""
if fadeout_override is not None: if fadeout_override is not None:
return min(0xFFF, max(0, fadeout_override)) 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)
fade_sec = max(0.02, _RELEASE_PERCEPTUAL_SCALE * z.env_release * (1000.0 - sus_cb) / 1000.0)
return max(1, min(0xFFF, round(2560.0 / (fade_sec * bpm0)))) 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) wenv(197, 199, 201, pit_env)
# Volume Fadeout = the SF2 release segment (NNA Note Fade below). Derived from # Volume Fadeout = the SF2 release segment (NNA Note Fade below). Derived from
# the canonical zone's releaseVolEnv against the 100 dB envelope floor; see # the canonical zone's full releaseVolEnv; see _zone_fadeout for the timecent→step
# _zone_fadeout for the timecent→step derivation. A synthesized-loop sample # derivation and why it is NOT sustain-scaled. A synthesized-loop sample keeps
# disables the key-off fadeout (its decay is the vol-envelope, which runs from # its key-off fadeout too: the peak->0 decay vol-env (no sustain wrap) only fades
# note-on regardless of key state) so key-off does not cut it short. # the HELD note to silence over ~SF2_SYNTH_DECAY_SEC from note-on; on key-off the
fo = 0 if ms.synth_loop is not None else _zone_fadeout(c.zone, bpm0, fadeout_override) # 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 + 171] = 0xFF # IGV (unit)
inst_bin[base + 172] = fo & 0xFF inst_bin[base + 172] = fo & 0xFF
# byte 173: bits 0-3 = fadeout high nibble, bit 4 = SF filter mode (cutoff/resonance # 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") f"> {NUM_PATTERNS_MAX} pattern limit")
pat_bin = build_pattern_bin(cells, n_voices, cue_starts, cue_lens) 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) pat_bin, remap, n_unique = deduplicate_patterns(pat_bin, n_cues * n_voices)
n_breaks = sum(1 for ft in song.timesig_ft n_breaks = sum(1 for ft in song.timesig_ft
if 0 < (ft - shift_ft) // speed < total_rows) 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): for ci in range(n_cues):
pats = [remap[ci * n_voices + v] for v in range(n_voices)] pats = [remap[ci * n_voices + v] for v in range(n_voices)]
if ci == n_cues - 1: 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: elif cue_lens[ci] < PATTERN_ROWS:
instr = cue_instruction_len(cue_lens[ci]) instr = cue_instruction_len(cue_lens[ci])
else: else:
@@ -2508,8 +2545,8 @@ def main():
ap.add_argument('--fadeout', type=int, default=None, ap.add_argument('--fadeout', type=int, default=None,
help='Override the computed fadeout step (0..4095). By ' help='Override the computed fadeout step (0..4095). By '
'default each instrument/patch gets a Volume Fadeout ' 'default each instrument/patch gets a Volume Fadeout '
'reproducing its SF2 release segment (releaseVolEnv vs ' 'reproducing its SF2 release segment (the full '
'the 100 dB floor), played out via NNA Note Fade') 'releaseVolEnv), played out via NNA Note Fade')
ap.add_argument('--max-voices', type=int, default=20, ap.add_argument('--max-voices', type=int, default=20,
help='Voice-column budget, 1..20 (default 20). NNA ' help='Voice-column budget, 1..20 (default 20). NNA '
'background ghosts carry release/ring tails, so ' '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). # Cue sheet instruction byte (cue offset 30; offset 31 = arg byte for 2-byte forms).
# Per terranmon.txt §"Cue Sheet": # Per terranmon.txt §"Cue Sheet":
# 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64 # 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64
# 00000001 (HALT) end of song # 00000001 00000000 (HALT) play the full pattern then stop (end of song)
# 00000000 (NOP) default 64-row cue # 00000001 01xxxxxx (HALT x) play x rows then stop (x = 0 ⇒ full length)
# 1000xxxx yyyyyyyy (BAK) go back 12-bit arg # 00000000 (NOP) default 64-row cue
# 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg # 1000xxxx yyyyyyyy (BAK) go back 12-bit arg
# 1111xxxx yyyyyyyy (JMP) go to absolute pattern # 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg
# 1111xxxx yyyyyyyy (JMP) go to absolute pattern
CUE_INST_NOP = 0x00 CUE_INST_NOP = 0x00
CUE_INST_HALT = 0x01 CUE_INST_HALT = 0x01
CUE_INST_LEN = 0x02 CUE_INST_LEN = 0x02
@@ -359,6 +360,44 @@ def cue_instruction_len(rows: int) -> tuple:
return (CUE_INST_LEN, (rows - 1) & 0x3F) 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: def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple:
"""Consolidate identical 512-byte Taud patterns into a single copy. """Consolidate identical 512-byte Taud patterns into a single copy.

View File

@@ -2868,6 +2868,7 @@ TODO:
at execApp (<eval>:1457:16) at execApp (<eval>:1457:16)
at Object.execute (<eval>:893:38) at Object.execute (<eval>:893:38)
at <eval>:867:31 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: TODO - list of demo songs that MUST ship with Microtone:
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes * 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 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) 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 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 00000001 00xxxxxx - Fadeout (FADOUT) - Gradually decrease global volume such that at row 0bxxxxxx it reaches zero, then stop the playback
00000000 - No operation 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 length = (rawRow.effectArg ushr 8) and 0xFF
val repeats = rawRow.effectArg and 0xFF val repeats = rawRow.effectArg and 0xFF
if (length > 0 && repeats > 0 && length <= n) { 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.dittoSourceStart = n - length
voice.dittoLength = length voice.dittoLength = length
voice.dittoEndRow = minOf(n + length * repeats - 1, patLen - 1) voice.dittoEndRow = minOf(n + length * repeats - 1, patLen - 1)
@@ -3823,9 +3823,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
else -> vol else -> vol
}.coerceIn(0, 0x3F) }.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) { private fun advanceTrackerCue(ts: TrackerState, playhead: Playhead) {
val instr = cueSheet[ts.cuePos].instruction 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) { ts.cuePos = when (instr) {
is PlayInstGoBack -> (ts.cuePos - instr.arg).coerceAtLeast(0) is PlayInstGoBack -> (ts.cuePos - instr.arg).coerceAtLeast(0)
is PlayInstSkip -> (ts.cuePos + instr.arg).coerceAtMost(1023) is PlayInstSkip -> (ts.cuePos + instr.arg).coerceAtMost(1023)
@@ -4056,12 +4063,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
else -> { else -> {
ts.rowIndex++ ts.rowIndex++
// LEN cue instruction shortens the effective row count so the // LEN / "halt at x" cue instructions shorten the effective row
// engine wraps to the next cue early. Patterns fed by the // count so the engine wraps to the next cue (or halts) early.
// converter are still 64 rows long; rows past `rowLimit` are // Patterns fed by the converter are still 64 rows long; rows past
// silent padding that we skip here. // `rowLimit` are silent padding that we skip here.
val currentInst = cueSheet[ts.cuePos].instruction val rowLimit = cueRowLimit(cueSheet[ts.cuePos].instruction)
val rowLimit = if (currentInst is PlayInstPatLen) currentInst.rows else 64
if (ts.rowIndex >= rowLimit) { if (ts.rowIndex >= rowLimit) {
ts.rowIndex = 0 ts.rowIndex = 0
advanceTrackerCue(ts, playhead) advanceTrackerCue(ts, playhead)
@@ -4085,18 +4091,24 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// byte 30: instruction (low byte) // byte 30: instruction (low byte)
// byte 31: instruction arg byte (used by 2-byte forms: LEN, BAK, FWD, JMP) // byte 31: instruction arg byte (used by 2-byte forms: LEN, BAK, FWD, JMP)
// Decoding rules per terranmon.txt §"Cue Sheet": // Decoding rules per terranmon.txt §"Cue Sheet":
// 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64 // 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64
// 00000001 (HALT) end of song // 00000001 00000000 (HALT) play the full pattern then stop
// 00000000 (NOP) default 64-row cue // 00000001 01xxxxxx (HALT x) play x rows then stop (x = 0 ⇒ full length)
// 1000xxxx yyyyyyyy (BAK) go back 12-bit arg // 00000000 (NOP) default 64-row cue
// 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg // 1000xxxx yyyyyyyy (BAK) go back 12-bit arg
// 1111xxxx yyyyyyyy (JMP) go to absolute pattern (currently unused) // 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg
// 1111xxxx yyyyyyyy (JMP) go to absolute pattern (currently unused)
private fun recomputeInstruction() { private fun recomputeInstruction() {
val b30 = instByte30 val b30 = instByte30
val b31 = instByte31 val b31 = instByte31
instruction = when { instruction = when {
b30 == 0x02 -> PlayInstPatLen((b31 and 0x3F) + 1) 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 b30 == 0x00 -> PlayInstNop
// BAK: 1000xxxx yyyyyyyy — 12-bit arg combining b30 low nybble + b31. // 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)) (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 PlayInstGoBack(arg: Int) : PlayInstruction(arg)
internal class PlayInstSkip(arg: Int) : PlayInstruction(arg) internal class PlayInstSkip(arg: Int) : PlayInstruction(arg)
internal class PlayInstPatLen(val rows: Int) : PlayInstruction(rows) 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 PlayInstHalt : PlayInstruction(0)
internal object PlayInstNop : 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, d_arg_to_col, resample_linear, rescale_offset_effects_per_slot,
encode_cue, deduplicate_patterns, encode_cue, deduplicate_patterns,
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob, 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, 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): for c in range(NUM_CUES):
sheet[c * CUE_SIZE:c * CUE_SIZE + CUE_SIZE] = encode_cue([], 0) 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 len_cue_count = 0
for cue_idx, ci in enumerate(cue_list): for cue_idx in range(n_emit):
if cue_idx >= NUM_CUES: break ci = cue_list[cue_idx]
base_pat = cue_idx * C base_pat = cue_idx * C
pats = [pat_remap[base_pat + vi] for vi in range(C)] pats = [pat_remap[base_pat + vi] for vi in range(C)]
clen = chunk_lens[ci] if ci < len(chunk_lens) else PATTERN_ROWS 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) instr = cue_instruction_len(clen)
len_cue_count += 1 len_cue_count += 1
else: else:
instr = CUE_INST_NOP instr = CUE_INST_NOP
sheet[cue_idx * CUE_SIZE:(cue_idx + 1) * CUE_SIZE] = encode_cue(pats, instr) sheet[cue_idx * CUE_SIZE:(cue_idx + 1) * CUE_SIZE] = encode_cue(pats, instr)
last_active = cue_idx
if last_active >= 0: if n_emit == 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:
sheet[30] = CUE_INST_HALT sheet[30] = CUE_INST_HALT
if len_cue_count: if len_cue_count:
vprint(f" [{song_label}] emitted {len_cue_count} LEN cue instruction(s) " vprint(f" [{song_label}] emitted {len_cue_count} LEN cue instruction(s) "