mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-17 01:44:05 +09:00
midi2taud: loop point detection and forced-loop
This commit is contained in:
266
midi2taud.py
266
midi2taud.py
@@ -6,6 +6,7 @@ Usage:
|
||||
[--perc-force-mapping BANK INST]
|
||||
[--rpb N] [--speed N] [--fadeout N]
|
||||
[--bend-epsilon CENTS] [--drum-keyoff]
|
||||
[--loop] [--loop-at-eot]
|
||||
[-v] [--no-project-data]
|
||||
|
||||
# Batch / directory mode (terranmon.txt:3342-3401):
|
||||
@@ -86,6 +87,24 @@ Behaviour (per midi2taud.md):
|
||||
into whole-bar cues (the largest multiple of its bar length that fits in 64
|
||||
rows) so the tracker's bar/beat highlighting (sMet beat divisions) lines up
|
||||
with the music.
|
||||
* Looping. A MIDI that carries its own loop markers is ALWAYS made to loop at
|
||||
those points (regardless of --loop); --loop additionally loops a marker-less
|
||||
MIDI start-to-end. Recognised loop-marker conventions (case-insensitive,
|
||||
first occurrence wins; resolved in this priority):
|
||||
- FF 06 / FF 01 text STARTING with "loops" (loop start) / "loope" (end);
|
||||
- CC #116 (loop start) + CC #117 (loop end);
|
||||
- CC #110 (loop start) + CC #111 (loop end);
|
||||
- CC #111 alone = loop START, loop end = End-of-Track (FF 2F 00).
|
||||
A missing loop end defaults to End-of-Track. The loop is realised as a cue
|
||||
jump: when it spans complete full-length cues from a cue boundary, the final
|
||||
cue's HALT is replaced with a JMP back to the loop-start cue; otherwise an
|
||||
in-pattern order jump (effect B → loop-start cue, plus effect C → row when
|
||||
the loop start is mid-cue) is placed on the last looped row. Cues after the
|
||||
loop end are dropped.
|
||||
--loop (whole song) rounds its loop-end UP to the next bar line by default,
|
||||
so the loop seam stays on the beat grid (and usually lands on a full cue → a
|
||||
clean JMP); --loop-at-eot loops exactly at End-of-Track instead. Bar rounding
|
||||
never applies to explicit MIDI loop markers — those loop verbatim.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -103,12 +122,13 @@ from taud_common import (
|
||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT,
|
||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_FASTFADE, TAUD_C4,
|
||||
TOP_G, TOP_M, TOP_S, TOP_T,
|
||||
TOP_B, TOP_C, TOP_G, TOP_M, TOP_S, TOP_T,
|
||||
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,
|
||||
cue_instruction_halt_at, last_note_cue_index, nearest_minifloat,
|
||||
cue_instruction_halt_at, cue_instruction_jump,
|
||||
last_note_cue_index, nearest_minifloat,
|
||||
IXMP_PAN_NO_OVERRIDE, atten_cb_to_octet,
|
||||
)
|
||||
|
||||
@@ -172,6 +192,15 @@ def _parse_track(data: bytes, pos: int, end: int) -> list:
|
||||
txt = payload.decode('latin-1', errors='replace').strip()
|
||||
if txt:
|
||||
evs.append((tick, ('title', txt)))
|
||||
elif mtype in (0x01, 0x06): # text (0x01) / marker (0x06)
|
||||
# Loop points by text convention: a marker/text whose ASCII
|
||||
# STARTS with "loops" / "loope" (case-insensitive) — see the
|
||||
# loop-convention list in the module docstring.
|
||||
tag = payload.decode('latin-1', errors='replace').strip().lower()
|
||||
if tag.startswith('loops'):
|
||||
evs.append((tick, ('loopstart',)))
|
||||
elif tag.startswith('loope'):
|
||||
evs.append((tick, ('loopend',)))
|
||||
elif mtype == 0x58 and ln >= 2: # time signature (FF 58 04 nn dd cc bb)
|
||||
# nn = numerator, dd = denominator as a negative power of 2
|
||||
# (2 = quarter, 3 = eighth). cc/bb (clocks-per-click, 32nds-per-
|
||||
@@ -314,7 +343,35 @@ def _curve_push(fts: list, vals: list, ft: int, val):
|
||||
|
||||
class Song:
|
||||
__slots__ = ('notes', 'channels', 'tempo_ft', 'tempo_bpm', 'title', 'end_ft',
|
||||
'timesig_ft', 'timesig')
|
||||
'timesig_ft', 'timesig', 'loop_start_ft', 'loop_end_ft', 'eot_ft')
|
||||
|
||||
|
||||
# CC numbers used as loop start / end markers by various sequencers (see the
|
||||
# loop-convention list in the module docstring). Values are ignored — only the
|
||||
# tick matters.
|
||||
CC_LOOP_START_A = 110 # 0x6E (paired with CC 111 as end)
|
||||
CC_LOOP_END_A = 111 # 0x6F (also a loop-START when 110 is absent → end = EoT)
|
||||
CC_LOOP_START_B = 116 # 0x74
|
||||
CC_LOOP_END_B = 117 # 0x75
|
||||
|
||||
|
||||
def _resolve_loop(text_start, text_end, cc, eot_ft, max_ft):
|
||||
"""Resolve the song's loop region (start_ft, end_ft) from the collected loop
|
||||
markers, or (None, None) when the MIDI defines none. `cc` maps each loop CC
|
||||
number to its first occurrence ft. Priority: text markers > CC 116/117 >
|
||||
CC 110/111 > CC 111-only (RPG-Maker style; loop-end = End-of-Track). An
|
||||
absent end falls back to End-of-Track (or the last event when no EoT)."""
|
||||
end_default = eot_ft if eot_ft is not None else max_ft
|
||||
if text_start is not None or text_end is not None:
|
||||
return (text_start if text_start is not None else 0,
|
||||
text_end if text_end is not None else end_default)
|
||||
if CC_LOOP_START_B in cc:
|
||||
return cc[CC_LOOP_START_B], cc.get(CC_LOOP_END_B, end_default)
|
||||
if CC_LOOP_START_A in cc: # 110 present ⇒ 111 is the end
|
||||
return cc[CC_LOOP_START_A], cc.get(CC_LOOP_END_A, end_default)
|
||||
if CC_LOOP_END_A in cc: # 111 alone ⇒ it is the start
|
||||
return cc[CC_LOOP_END_A], end_default
|
||||
return None, None
|
||||
|
||||
|
||||
def extract_song(division, merged, rpb: int, speed: int) -> Song:
|
||||
@@ -340,6 +397,9 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song:
|
||||
timesig_ft, timesig = [], [] # ft → (numerator, denom_power)
|
||||
title = None
|
||||
max_ft = 0
|
||||
loop_text_start = loop_text_end = None # FF 06/01 "loops"/"loope" ft
|
||||
loop_cc = {} # loop CC number → first occurrence ft
|
||||
eot_ft = None # latest End-of-Track ft (loop-end fallback)
|
||||
|
||||
def end_note(n: Note, ft: int):
|
||||
if n.end_ft is None:
|
||||
@@ -386,6 +446,9 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song:
|
||||
elif kind == 'cc':
|
||||
_, ch, num, val = ev
|
||||
st = chs[ch]
|
||||
if num in (CC_LOOP_START_A, CC_LOOP_END_A,
|
||||
CC_LOOP_START_B, CC_LOOP_END_B):
|
||||
loop_cc.setdefault(num, ft) # first occurrence wins
|
||||
if num == 0:
|
||||
st.bank = val
|
||||
elif num == 7:
|
||||
@@ -449,6 +512,16 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song:
|
||||
if title is None:
|
||||
title = ev[1]
|
||||
|
||||
elif kind == 'loopstart':
|
||||
if loop_text_start is None:
|
||||
loop_text_start = ft
|
||||
elif kind == 'loopend':
|
||||
if loop_text_end is None:
|
||||
loop_text_end = ft
|
||||
elif kind == 'eot':
|
||||
if eot_ft is None or ft > eot_ft:
|
||||
eot_ft = ft
|
||||
|
||||
# Close anything still ringing at end-of-file.
|
||||
for st in chs:
|
||||
for n in list(st.active.values()):
|
||||
@@ -473,6 +546,9 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song:
|
||||
song.timesig = timesig
|
||||
song.title = title
|
||||
song.end_ft = max_ft
|
||||
song.eot_ft = eot_ft
|
||||
song.loop_start_ft, song.loop_end_ft = _resolve_loop(
|
||||
loop_text_start, loop_text_end, loop_cc, eot_ft, max_ft)
|
||||
return song
|
||||
|
||||
|
||||
@@ -2285,6 +2361,22 @@ def emit_cells(song: Song, insts: dict, speed: int, rpb: int,
|
||||
|
||||
total_rows = max(r for (_v, r) in cells) + 1
|
||||
|
||||
# Anchor the song's length to the MIDI End-of-Track, NOT to the last
|
||||
# surviving note. Preset resolution drops notes whose SoundFont preset is
|
||||
# missing, so a sparse bank can drop the trailing notes and silently shorten
|
||||
# the song (E2M1: 3453 rows on a full GM bank vs 3449 when the last notes are
|
||||
# dropped) — the cue layout and final HALT then depend on the SoundFont. EoT
|
||||
# is a fixed MIDI property, so the row count is the same for every bank: the
|
||||
# dropped notes' time becomes trailing silence instead of vanishing. `max`
|
||||
# with the last trigger row still honours a note that (pathologically) starts
|
||||
# past EoT. The EoT row itself is the end-of-song boundary (exclusive), so a
|
||||
# final key-off landing exactly on it is dropped (the HALT cuts the note),
|
||||
# which also avoids the lone-key-off terminus cue the trim below handled.
|
||||
if song.eot_ft is not None:
|
||||
eot_row = (song.eot_ft - shift_ft) // speed
|
||||
last_trigger = max((n.start_ft // speed for n in notes), default=0)
|
||||
total_rows = max(eot_row, last_trigger + 1)
|
||||
|
||||
# ── Pass 5: T tempo changes ──
|
||||
bpm0 = midi_bpm_at(shift_ft) # tempo in effect at row 0
|
||||
last = taud_bpm(bpm0)
|
||||
@@ -2330,6 +2422,23 @@ def emit_cells(song: Song, insts: dict, speed: int, rpb: int,
|
||||
|
||||
# ── Pattern / cue emission and final assembly ────────────────────────────────
|
||||
|
||||
def _bar_align_up(row: int, timesig_ft: list, timesig: list,
|
||||
shift_ft: int, speed: int, rpb: int) -> int:
|
||||
"""Smallest bar-line row >= `row`, using the time signature of the section
|
||||
`row` falls in (sections start at time-sig changes, as in plan_cues). A row
|
||||
already on a bar line is returned unchanged. Used to bar-align the --loop
|
||||
whole-song loop-end so the loop seam lands on a bar (and a full cue)."""
|
||||
def bar_rows_of(r):
|
||||
ft = r * speed + shift_ft
|
||||
i = bisect.bisect_right(timesig_ft, ft) - 1
|
||||
num, dpow = timesig[i] if i >= 0 else (4, 2)
|
||||
return max(1, round(num * 4.0 / (2 ** dpow) * rpb))
|
||||
breaks = sorted({0} | {(ft - shift_ft) // speed for ft in timesig_ft})
|
||||
sec_start = max(b for b in breaks if b <= row)
|
||||
br = bar_rows_of(sec_start)
|
||||
return sec_start + ((row - sec_start + br - 1) // br) * br
|
||||
|
||||
|
||||
def plan_cues(timesig_ft: list, timesig: list, total_rows: int,
|
||||
shift_ft: int, speed: int, rpb: int) -> tuple:
|
||||
"""Plan the cue layout: break a cue at every time-signature change, and pack
|
||||
@@ -2397,6 +2506,79 @@ def build_pattern_bin(cells: dict, n_voices: int,
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _inject_loop(pat_bin: bytes, cue_starts: list, cue_lens: list, n_voices: int,
|
||||
rs: int, re: int) -> tuple:
|
||||
"""Make the song loop from row `re` (exclusive) back to row `rs`.
|
||||
|
||||
Returns (pat_bin, cue_starts, cue_lens, jump_instr). When the loop covers
|
||||
complete full-length cues from a cue boundary, `jump_instr` is the 2-byte JMP
|
||||
cue instruction to place on the (new) last cue — the clean "replace HALT with
|
||||
JMP000" case. Otherwise an in-pattern order jump (effect B → cue cs, plus
|
||||
effect C → row when the loop-start is mid-cue) is written on the last looped
|
||||
row and `jump_instr` is None. Cues after the loop-end are unreachable once we
|
||||
loop, so they are dropped."""
|
||||
n_cues = len(cue_starts)
|
||||
song_end_row = cue_starts[-1] + cue_lens[-1]
|
||||
rs = max(0, min(rs, song_end_row - 1))
|
||||
re = max(rs + 1, min(re, song_end_row))
|
||||
last_played = re - 1
|
||||
|
||||
def cue_of(row):
|
||||
for ci in range(n_cues):
|
||||
if cue_starts[ci] <= row < cue_starts[ci] + cue_lens[ci]:
|
||||
return ci
|
||||
return n_cues - 1
|
||||
cs = cue_of(rs)
|
||||
ce = cue_of(last_played)
|
||||
rs_off = rs - cue_starts[cs]
|
||||
re_off = last_played - cue_starts[ce]
|
||||
|
||||
# Drop the now-unreachable tail cues (after the loop-end cue).
|
||||
if ce < n_cues - 1:
|
||||
cue_starts = cue_starts[:ce + 1]
|
||||
cue_lens = cue_lens[:ce + 1]
|
||||
pat_bin = pat_bin[:(ce + 1) * n_voices * PATTERN_BYTES]
|
||||
n_cues = ce + 1
|
||||
|
||||
# Clean whole-cue case: loop-start on a cue boundary AND loop-end on the last
|
||||
# row of a FULL 64-row cue. A cue-level JMP fires at the cue's full-pattern
|
||||
# end, which only lines up here; partial / mid-pattern loops use effect B.
|
||||
if rs_off == 0 and re_off == cue_lens[ce] - 1 and cue_lens[ce] == PATTERN_ROWS:
|
||||
return pat_bin, cue_starts, cue_lens, cue_instruction_jump(cs)
|
||||
|
||||
buf = bytearray(pat_bin)
|
||||
|
||||
def free_voice(skip):
|
||||
# Prefer a fully-empty cell, then any cell without an effect.
|
||||
for want_empty in (True, False):
|
||||
for v in range(n_voices):
|
||||
if v in skip:
|
||||
continue
|
||||
off = (ce * n_voices + v) * PATTERN_BYTES + re_off * 8
|
||||
if buf[off + 5] != 0:
|
||||
continue
|
||||
if want_empty and (buf[off] != 0 or buf[off + 1] != 0):
|
||||
continue
|
||||
return v, off
|
||||
return None, None
|
||||
|
||||
vb, ob = free_voice(set())
|
||||
if vb is None: # every cell on this row already has an effect
|
||||
vb, ob = 0, (ce * n_voices) * PATTERN_BYTES + re_off * 8
|
||||
vprint(" warning: loop-end row crowded — overwriting an effect with the B jump")
|
||||
buf[ob + 5] = TOP_B
|
||||
struct.pack_into('<H', buf, ob + 6, cs & 0xFFFF)
|
||||
if rs_off > 0:
|
||||
vc, oc = free_voice({vb})
|
||||
if vc is None:
|
||||
vprint(" warning: no free slot for the C row-jump — looping to the "
|
||||
"loop-start cue's first row instead")
|
||||
else:
|
||||
buf[oc + 5] = TOP_C
|
||||
struct.pack_into('<H', buf, oc + 6, rs_off & 0xFFFF)
|
||||
return bytes(buf), cue_starts, cue_lens, None
|
||||
|
||||
|
||||
def build_song_section(song: Song, speed: int, rpb: int, src_path: str,
|
||||
args) -> dict:
|
||||
"""Per-song pattern/cue build shared by the full .taud and the .tpif paths.
|
||||
@@ -2422,6 +2604,18 @@ def build_song_section(song: Song, speed: int, rpb: int, src_path: str,
|
||||
song, None, speed, rpb, eps_units, args.drum_keyoff, shift_ft,
|
||||
args.max_voices)
|
||||
|
||||
# --loop (whole song, no MIDI markers) bar-aligns its loop-end by DEFAULT:
|
||||
# extend the song to the next bar line so the loop seam stays on the beat grid
|
||||
# (and lands on a full cue → a clean cue-level JMP). --loop-at-eot opts out,
|
||||
# looping exactly at End-of-Track. The extension is silent padding past EoT;
|
||||
# MIDI loop markers are always honoured verbatim, never bar-rounded.
|
||||
if args.loop and song.loop_start_ft is None and not args.loop_at_eot:
|
||||
aligned = _bar_align_up(total_rows, song.timesig_ft, song.timesig,
|
||||
shift_ft, speed, rpb)
|
||||
if aligned != total_rows:
|
||||
vprint(f" loop: bar-aligning song end {total_rows} → {aligned} rows")
|
||||
total_rows = aligned
|
||||
|
||||
# Cue layout: break at time-signature changes, pack into whole-bar cues.
|
||||
cue_starts, cue_lens, init_bar_rows = plan_cues(
|
||||
song.timesig_ft, song.timesig, total_rows, shift_ft, speed, rpb)
|
||||
@@ -2436,12 +2630,29 @@ def build_song_section(song: Song, speed: int, rpb: int, src_path: str,
|
||||
|
||||
pat_bin = build_pattern_bin(cells, n_voices, cue_starts, cue_lens)
|
||||
|
||||
# The song length is anchored to End-of-Track (emit_cells), so when the MIDI
|
||||
# carries an EoT the last cue IS the EoT cue: keep the whole span (n_cues-1
|
||||
# floor) so the trailing-rest trim can't shorten it — dropped trailing notes
|
||||
# must stay as silence, not vanish, or the length would again depend on the
|
||||
# SoundFont. Without an EoT, fall back to the note-based trim; an explicit
|
||||
# loop-end marker still floors it at the cue holding the loop-end row (the
|
||||
# marker may sit in a trailing rest before EoT).
|
||||
keep_floor = 0
|
||||
if song.eot_ft is not None:
|
||||
keep_floor = n_cues - 1
|
||||
elif song.loop_end_ft is not None:
|
||||
end_row = max(0, (song.loop_end_ft - shift_ft) // speed - 1)
|
||||
for ci in range(n_cues):
|
||||
if cue_starts[ci] <= end_row < cue_starts[ci] + cue_lens[ci]:
|
||||
keep_floor = ci
|
||||
break
|
||||
|
||||
# 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)
|
||||
last_cue = max(last_note_cue_index(pat_bin, n_cues, n_voices), keep_floor)
|
||||
if 0 <= last_cue < n_cues - 1:
|
||||
dropped = n_cues - 1 - last_cue
|
||||
n_cues = last_cue + 1
|
||||
@@ -2450,6 +2661,33 @@ def build_song_section(song: Song, speed: int, rpb: int, src_path: str,
|
||||
pat_bin = pat_bin[:n_cues * n_voices * PATTERN_BYTES]
|
||||
vprint(f" info: trimmed {dropped} trailing note-free cue(s)")
|
||||
|
||||
# Looping. MIDI loop markers (loops/loope text, CC 110/111/116/117) ALWAYS
|
||||
# convert to a pattern jump; the --loop flag additionally loops a marker-less
|
||||
# MIDI start-to-end. Markers win when both are present.
|
||||
loop_jump = None
|
||||
song_end_row = cue_starts[-1] + cue_lens[-1]
|
||||
if song.loop_start_ft is not None:
|
||||
rs = (song.loop_start_ft - shift_ft) // speed
|
||||
re = (song.loop_end_ft - shift_ft) // speed
|
||||
loop_src = "MIDI loop marker"
|
||||
elif args.loop:
|
||||
rs, re = 0, song_end_row
|
||||
loop_src = "--loop (whole song)"
|
||||
else:
|
||||
rs = re = None
|
||||
if rs is not None:
|
||||
rs = max(0, min(rs, song_end_row - 1))
|
||||
re = min(re, song_end_row)
|
||||
if re <= rs:
|
||||
vprint(f" warning: {loop_src} loop region empty after clamping — not looping")
|
||||
else:
|
||||
pat_bin, cue_starts, cue_lens, loop_jump = _inject_loop(
|
||||
pat_bin, cue_starts, cue_lens, n_voices, rs, re)
|
||||
n_cues = len(cue_starts)
|
||||
how = "JMP cue jump" if loop_jump is not None else "in-pattern B jump"
|
||||
vprint(f" loop: {loop_src} → rows [{rs}, {re}) via {how} "
|
||||
f"({n_cues} cue(s) after loop trim)")
|
||||
|
||||
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)
|
||||
@@ -2463,9 +2701,11 @@ def build_song_section(song: Song, speed: int, rpb: int, src_path: str,
|
||||
for ci in range(n_cues):
|
||||
pats = [remap[ci * n_voices + v] for v in range(n_voices)]
|
||||
if ci == n_cues - 1:
|
||||
# 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])
|
||||
# Loop back via a cue-level JMP when the loop is whole-cue clean; else
|
||||
# halt after this cue's own length (an in-pattern B jump, if any, fires
|
||||
# first, so the HALT is just the never-reached safety terminus).
|
||||
instr = loop_jump if loop_jump is not None \
|
||||
else cue_instruction_halt_at(cue_lens[ci])
|
||||
elif cue_lens[ci] < PATTERN_ROWS:
|
||||
instr = cue_instruction_len(cue_lens[ci])
|
||||
else:
|
||||
@@ -2712,6 +2952,18 @@ def main():
|
||||
ap.add_argument('--drum-keyoff', action='store_true',
|
||||
help='Emit KEY_OFF for percussion-channel notes too '
|
||||
'(GM drums normally ignore note-off)')
|
||||
ap.add_argument('--loop', action='store_true',
|
||||
help='Loop a non-looping MIDI start-to-end (replace the final '
|
||||
'HALT with a jump back to the first cue). The loop-end is '
|
||||
'rounded up to the next BAR LINE by default so the loop '
|
||||
'seam stays on the beat (use --loop-at-eot to loop exactly '
|
||||
'at End-of-Track instead). MIDIs that carry their own loop '
|
||||
'markers (loops/loope text, or CC 110/111/116/117) are '
|
||||
'ALWAYS looped at those points regardless of this flag')
|
||||
ap.add_argument('--loop-at-eot', action='store_true',
|
||||
help='With --loop: loop precisely at End-of-Track instead of '
|
||||
'rounding the loop-end up to a full bar (no effect without '
|
||||
'--loop, or when the MIDI has its own loop markers)')
|
||||
ap.add_argument('--force-synth-loop', action='store_true',
|
||||
help='For looped samples whose loop sits past the 65535-frame '
|
||||
'cap even at 32 kHz (multi-second far-loop instruments, '
|
||||
|
||||
@@ -388,6 +388,18 @@ def cue_instruction_halt_at(rows: int) -> tuple:
|
||||
return (CUE_INST_HALT, 0x40 | (rows & 0x3F))
|
||||
|
||||
|
||||
def cue_instruction_jump(cue: int) -> tuple:
|
||||
"""Build the 2-byte JMP cue instruction (terranmon.txt §"Cue Sheet").
|
||||
|
||||
Go to absolute cue `cue` (0..4095) when this cue finishes its full pattern —
|
||||
the engine never halts here, so it loops. Encoding is byte30 = 0b1111xxxx
|
||||
(high nybble of the 12-bit cue) and byte31 = low 8 bits.
|
||||
"""
|
||||
if not 0 <= cue <= 0xFFF:
|
||||
raise ValueError(f"JMP cue target must be 0..4095, got {cue}")
|
||||
return (0xF0 | ((cue >> 8) & 0x0F), cue & 0xFF)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -256,7 +256,24 @@ MMIO
|
||||
0b 0000 00rc (r: TTY Raw mode, c: Cursor blink)
|
||||
7 RW
|
||||
Graphics-mode attributes
|
||||
0b 0000 rrrr (r: Resolution/colour depth)
|
||||
0b 00ii 000t (t: disable text, i: interlaced mode)
|
||||
|
||||
When interlace is enabled (i > 0), the layers are overlaind in the checkerboard pattern, allowing blending.
|
||||
|
||||
On graphics mode 2, the pattern is:
|
||||
[L1 L2]
|
||||
[L2 L1]
|
||||
for all nonzero i.
|
||||
|
||||
On graphics mode 3, the pattern is:
|
||||
i=1 [L1 L2]
|
||||
[L2 L1] (where L1: mid-low layer, L2: mid-high layer; low-layer is drawn behind, high-layer is drawn over)
|
||||
i=2 [L1 L2]
|
||||
[L3 L1] (where L1: low layer, L2: mid-low layer, L3: mid-high layer; high-layer is always drawn over)
|
||||
i=3 [L1 L2]
|
||||
[L3 L4]
|
||||
|
||||
On the other graphics modes, the flag has no effect and always zero when read
|
||||
8 RO
|
||||
Last used colour (set by poking at the framebuffer)
|
||||
9 RW
|
||||
@@ -266,7 +283,8 @@ MMIO
|
||||
11 RO
|
||||
Number of Banks, or VRAM size (1 = 256 kB, max 4)
|
||||
12 RW
|
||||
Graphics Mode
|
||||
0b 0000 mmmm, where:
|
||||
mmmm: Graphics Mode
|
||||
0: 560x448, 256 Colours, 1 layer
|
||||
1: 280x224, 256 Colours, 4 layers
|
||||
2: 280x224, 4096 Colours, 2 layers
|
||||
@@ -2879,7 +2897,8 @@ TODO:
|
||||
detaching, mirroring the existing active-branch. parent.keyOff survives deactivation and is
|
||||
reset on retrigger, so a true value means this note was released. A parent that ended naturally
|
||||
(no release) still leaves the child to finish on its own.
|
||||
[ ] Some ways to decouple Sample+Inst and patterns into separate files (tsvm-doom needs separate file access; samplepack can be uploaded once on init)
|
||||
[x] Some ways to decouple Sample+Inst and patterns into separate files (tsvm-doom needs separate file access; samplepack can be uploaded once on init)
|
||||
[ ] The same VT-aware patch thing for all fullscreen apps, possibly made simple by only requiring one row of simple code
|
||||
|
||||
TODO - list of demo songs that MUST ship with Microtone:
|
||||
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
||||
@@ -3028,7 +3047,7 @@ Play Head Flags
|
||||
Byte 31..32: instruction
|
||||
1000xxxx yyyyyyyy (BAK000) - Go back 0bxxxxyyyyyyyy patterns
|
||||
1001xxxx yyyyyyyy (FWD000) - Skip forward 0bxxxxyyyyyyyy patterns
|
||||
1111xxxx yyyyyyyy (JMP000) - Go to absolute pattern number 0bxxxxyyyyyyyy
|
||||
1111xxxx yyyyyyyy (JMP000) - Go to absolute pattern number 0bxxxxyyyyyyyy (decoded by AudioAdapter as of 2026-06-15; emitted by midi2taud --loop / loop-marker conversion to jump back to the loop-start cue)
|
||||
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 (HALT00) - 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)
|
||||
|
||||
@@ -3852,6 +3852,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
ts.cuePos = when (instr) {
|
||||
is PlayInstGoBack -> (ts.cuePos - instr.arg).coerceAtLeast(0)
|
||||
is PlayInstSkip -> (ts.cuePos + instr.arg).coerceAtMost(1023)
|
||||
is PlayInstJump -> instr.arg.coerceIn(0, 1023)
|
||||
else -> (ts.cuePos + 1).coerceAtMost(1023)
|
||||
}
|
||||
playhead.position = ts.cuePos
|
||||
@@ -4113,7 +4114,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// 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)
|
||||
// 1111xxxx yyyyyyyy (JMP) go to absolute cue (loop back to cue arg)
|
||||
private fun recomputeInstruction() {
|
||||
val b30 = instByte30
|
||||
val b31 = instByte31
|
||||
@@ -4130,7 +4131,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
(b30 and 0xF0) == 0x80 -> PlayInstGoBack(((b30 and 0xF) shl 8) or (b31 and 0xFF))
|
||||
// FWD: 1001xxxx yyyyyyyy — 12-bit arg.
|
||||
(b30 and 0xF0) == 0x90 -> PlayInstSkip(((b30 and 0xF) shl 8) or (b31 and 0xFF))
|
||||
// JMP: 1111xxxx yyyyyyyy — reserved (decoder TBD).
|
||||
// JMP: 1111xxxx yyyyyyyy — go to absolute cue 0bxxxxyyyyyyyy.
|
||||
(b30 and 0xF0) == 0xF0 -> PlayInstJump(((b30 and 0xF) shl 8) or (b31 and 0xFF))
|
||||
else -> PlayInstNop
|
||||
}
|
||||
}
|
||||
@@ -4176,6 +4178,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
internal open class PlayInstruction(val arg: Int)
|
||||
internal class PlayInstGoBack(arg: Int) : PlayInstruction(arg)
|
||||
internal class PlayInstSkip(arg: Int) : PlayInstruction(arg)
|
||||
/** "JMP": go to absolute cue [arg]. Used by looping converters to jump back
|
||||
* to the loop-start cue (e.g. midi2taud's whole-song / loop-marker loop). */
|
||||
internal class PlayInstJump(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)
|
||||
|
||||
@@ -19,13 +19,11 @@ import net.torvald.terrarum.DefaultGL32Shaders
|
||||
import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUint
|
||||
import net.torvald.tsvm.*
|
||||
import net.torvald.tsvm.peripheral.GraphicsAdapter.Companion.DRAW_SHADER_FRAG
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.*
|
||||
import kotlin.experimental.and
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.min
|
||||
|
||||
data class AdapterConfig(
|
||||
val theme: String,
|
||||
@@ -114,7 +112,7 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi
|
||||
|
||||
override var blinkCursor = true
|
||||
override var ttyRawMode = false
|
||||
private var graphicsUseSprites = false
|
||||
private var graphicsDisableTexts = false
|
||||
private var lastUsedColour = (-1).toByte()
|
||||
private var currentChrRom = 0
|
||||
private var chrWidth = 7f
|
||||
@@ -346,7 +344,7 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi
|
||||
ttyRawMode.toInt(1) or
|
||||
blinkCursor.toInt()).toByte()
|
||||
|
||||
private fun getGraphicsAttributes(): Byte = graphicsUseSprites.toInt().toByte()
|
||||
private fun getGraphicsAttributes(): Byte = graphicsDisableTexts.toInt().toByte()
|
||||
|
||||
private fun setTextmodeAttributes(rawbyte: Byte) {
|
||||
currentChrRom = rawbyte.toInt().and(0b11110000).ushr(4)
|
||||
@@ -355,7 +353,7 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi
|
||||
}
|
||||
|
||||
private fun setGraphicsAttributes(rawbyte: Byte) {
|
||||
graphicsUseSprites = rawbyte.and(1) == 1.toByte()
|
||||
graphicsDisableTexts = rawbyte.and(1) == 1.toByte()
|
||||
}
|
||||
|
||||
override fun mmio_read(addr: Long): Byte? {
|
||||
@@ -1248,7 +1246,7 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi
|
||||
|
||||
outFBObatch.color = Color.WHITE
|
||||
|
||||
if (!graphicsUseSprites) {
|
||||
if (!graphicsDisableTexts) {
|
||||
// draw texts
|
||||
val (cx, cy) = getCursorPos()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user