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).
|
**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
|
||||||
|
|||||||
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,
|
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) "
|
||||||
|
|||||||
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*
|
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·(1000−sus_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·(1000−sus_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·(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 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 '
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
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,
|
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) "
|
||||||
|
|||||||
Reference in New Issue
Block a user