midi2taud: loop point detection and forced-loop

This commit is contained in:
minjaesong
2026-06-16 23:04:51 +09:00
parent 930e867b3e
commit c3702fb597
5 changed files with 315 additions and 29 deletions

View File

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

View File

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

View File

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

View File

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

View File

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