mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-15 00:44:05 +09:00
taud: 'halt at x' cmd
This commit is contained in:
@@ -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
|
||||
|
||||
26
it2taud.py
26
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) "
|
||||
|
||||
85
midi2taud.py
85
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 '
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
25
xm2taud.py
25
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) "
|
||||
|
||||
Reference in New Issue
Block a user