taud: auto RPB/Tickspeed resolution

This commit is contained in:
minjaesong
2026-06-14 21:16:34 +09:00
parent af8dd6aea8
commit d5f2e5ed02
3 changed files with 361 additions and 44 deletions

View File

@@ -300,9 +300,31 @@ const colEffArg = 231
const colBackPtn = 255
const PITCH_PRESET_IDX_DEFAULT = 120
let PITCH_PRESET_IDX = PITCH_PRESET_IDX_DEFAULT // TODO read from the Project Data section of the .taud
let beatDivPrimary = 4 // TODO read from the Project Data section of the .taud
let beatDivSecondary = 16
// Seed value used during global init (integrity check + first rebuildPitchLut);
// the open/switch paths override it per-song from sMet via applySongPitchPreset().
let PITCH_PRESET_IDX = PITCH_PRESET_IDX_DEFAULT
// Row-highlight grid. Populated per-song from the sMet block's beat divisions
// (Primary = rows per beat, Secondary = rows per bar); 4/16 is the 4/4 default
// used when a song carries no sMet entry. See applySongBeatDiv().
const BEAT_DIV_PRIMARY_DEFAULT = 4
const BEAT_DIV_SECONDARY_DEFAULT = 16
let beatDivPrimary = BEAT_DIV_PRIMARY_DEFAULT
let beatDivSecondary = BEAT_DIV_SECONDARY_DEFAULT
// Set the row-highlight grid from a per-song metadata record (songsMeta.songs[i]).
function applySongBeatDiv(s) {
beatDivPrimary = (s && s.beatDivPrimary) ? s.beatDivPrimary : BEAT_DIV_PRIMARY_DEFAULT
beatDivSecondary = (s && s.beatDivSecondary) ? s.beatDivSecondary : BEAT_DIV_SECONDARY_DEFAULT
}
// Set the active pitch/notation preset from a per-song metadata record (the sMet
// 'notation' field) and rebuild the pitch LUT. Falls back to the default when the
// song carries no notation or an unknown preset index.
function applySongPitchPreset(s) {
const idx = s ? s.pitchPresetIdx : null
PITCH_PRESET_IDX = (idx != null && pitchTablePresets[idx]) ? idx : PITCH_PRESET_IDX_DEFAULT
rebuildPitchLut()
}
let hasUnsavedChanges = false
let patternsOutOfSync = false // in-memory song.patterns has edits not yet pushed to the audio adapter
@@ -901,6 +923,8 @@ function loadTaudSongList(filePath) {
composer: '',
copyright: '',
pitchPresetIdx: null,
beatDivPrimary: null,
beatDivSecondary: null,
}
}
@@ -973,6 +997,8 @@ function loadTaudSongList(filePath) {
// payload: notation(u16) + beat_pri(u8) + beat_sec(u8) + name\0 + composer\0 + copyright\0
const notation = (sys.peek(ptr + subStart) & 0xFF) |
((sys.peek(ptr + subStart + 1) & 0xFF) << 8)
const beatPri = sys.peek(ptr + subStart + 2) & 0xFF
const beatSec = sys.peek(ptr + subStart + 3) & 0xFF
let r = subStart + 4 // skip notation(2) + pri(1) + sec(1)
const strs = []
while (strs.length < 3 && r < subStart + subLen) {
@@ -986,6 +1012,9 @@ function loadTaudSongList(filePath) {
}
if (idx < numSongs) {
songs[idx].pitchPresetIdx = notation
// 0 = unset → applySongBeatDiv falls back to the 4/4 default
songs[idx].beatDivPrimary = beatPri || null
songs[idx].beatDivSecondary = beatSec || null
if (strs[0] !== undefined) songs[idx].name = strs[0]
if (strs[1] !== undefined) songs[idx].composer = strs[1]
if (strs[2] !== undefined) songs[idx].copyright = strs[2]
@@ -1977,6 +2006,8 @@ const PROJ_META_FLAGS = 0
const PROJ_META_GVOL = 1
const PROJ_META_MVOL = 2
let song = loadTaud(fullPathObj.full, currentSongIndex)
applySongPitchPreset(songsMeta.songs[currentSongIndex])
applySongBeatDiv(songsMeta.songs[currentSongIndex])
const voiceMutes = new Array(NUM_VOICES).fill(false)
let timelineMuteSnapshot = null
@@ -2017,11 +2048,8 @@ function switchSong(newIndex) {
song = loadTaud(fullPathObj.full, newIndex)
refreshSamplesCache()
const newPitchIdx = songsMeta.songs[newIndex].pitchPresetIdx
PITCH_PRESET_IDX = (newPitchIdx != null && pitchTablePresets[newPitchIdx])
? newPitchIdx
: PITCH_PRESET_IDX_DEFAULT
rebuildPitchLut()
applySongPitchPreset(songsMeta.songs[newIndex])
applySongBeatDiv(songsMeta.songs[newIndex])
taud.uploadTaudFile(fullPathObj.full, newIndex, PLAYHEAD)
patternsOutOfSync = false

View File

@@ -54,10 +54,25 @@ Behaviour (per midi2taud.md):
The choke is the new fast note-fade (note 0x0004, ~0.3 s) emitted at the next
same-class onset; without it long percussion tails wash over the whole beat.
* Sub-row timing is carried by S $Dx note delays (one row = `--speed`
ticks, default 6; one beat = `--rpb` rows, default 4 → 1/24-beat grid).
ticks; one beat = `--rpb` rows). The grid (Tickspeed + RPB) is auto-set by
default from the tempo map, the MIDI time signatures and onset-subdivision
analysis: rpb·speed fine-ticks per beat is chosen to represent the finest
subdivision actually used, keep every tempo inside the Taud BPM register
(25..280), and stay near the proven 24-fts/beat grid — so plain 4/4 @ 120
BPM still reproduces the old speed 6 / rpb 4. Passing --rpb or --speed pins
that axis and auto-fits the other; pass both to fully override. As a final
step, a bend- or polyphony-heavy song with rpb < 8 has its rpb doubled (and
speed halved, so F and the tempo are unchanged) up to 8: the extra rows give
key-offs, exclusiveClass chokes, bend portamento (G) and channel-volume (M)
effects more distinct rows to land on, so fewer are eaten by same-row / per-
cell-slot collisions. Disabled by pinning --rpb or --speed.
MIDI tempo changes map to T $xx00 set-tempo effects; channel volume /
expression (CC7 × CC11) map to M $xx00 channel-volume effects so they
never disturb the velocity-driven patch selection axis.
* Cues are broken at every time-signature change, and each section is packed
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.
"""
import argparse
@@ -142,6 +157,11 @@ 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 == 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-
# quarter) carry no information the Taud grid needs.
evs.append((tick, ('timesig', payload[0], payload[1])))
elif mtype == 0x2F:
evs.append((tick, ('eot',)))
break
@@ -278,7 +298,8 @@ def _curve_push(fts: list, vals: list, ft: int, val):
class Song:
__slots__ = ('notes', 'channels', 'tempo_ft', 'tempo_bpm', 'title', 'end_ft')
__slots__ = ('notes', 'channels', 'tempo_ft', 'tempo_bpm', 'title', 'end_ft',
'timesig_ft', 'timesig')
def extract_song(division, merged, rpb: int, speed: int) -> Song:
@@ -301,6 +322,7 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song:
chs = [_ChState() for _ in range(16)]
notes = []
tempo_ft, tempo_bpm = [], []
timesig_ft, timesig = [], [] # ft → (numerator, denom_power)
title = None
max_ft = 0
@@ -401,6 +423,13 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song:
elif kind == 'tempo':
tempo_ft.append(ft); tempo_bpm.append(ev[1])
elif kind == 'timesig':
sig = (ev[1], ev[2])
if timesig_ft and timesig_ft[-1] == ft:
timesig[-1] = sig # last event at this ft wins
elif not timesig or timesig[-1] != sig:
timesig_ft.append(ft); timesig.append(sig)
elif kind == 'title':
if title is None:
title = ev[1]
@@ -425,11 +454,184 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song:
song.channels = chs
song.tempo_ft = tempo_ft
song.tempo_bpm = tempo_bpm
song.timesig_ft = timesig_ft
song.timesig = timesig
song.title = title
song.end_ft = max_ft
return song
# ── Auto timing (Tickspeed + RPB) ──────────────────────────────────────────────
# Candidate beat subdivisions tested by the onset analyser (per quarter note).
_SUBDIV_CANDIDATES = (1, 2, 3, 4, 6, 8, 12, 16)
# Fraction-of-a-quarter tolerance for an onset to count as "on" a 1/D grid.
_SUBDIV_TOL = 0.04
# Coverage at which a subdivision is accepted as the finest one in use. 0.95
# keeps the picker from chasing the last few percent of ornament/swing onsets
# into a needlessly fine grid (those land on the sub-row S$Dx grid anyway).
_SUBDIV_THRESHOLD = 0.95
# The proven default resolution (rpb 4 × speed 6). The picker anchors F=rpb·speed
# at the smallest multiple of the detected subdivision that is >= this, so any
# subdivision dividing 24 (1/2..1/12 and triplets) reproduces the old 6/4 grid.
# NOTE: row/pattern count depends only on rpb (rows = beats×rpb); speed is "free"
# sub-row + tempo precision, so the picker spends it rather than minimising F.
_F_TARGET = 24
# Taud BPM register is bias-25 in [25, 280]; tick rate Hz = bpm·2/5.
_TAUD_BPM_LO, _TAUD_BPM_HI = 25, 280
# RPB bump: bend- or polyphony-heavy songs cram more triggers / key-offs / chokes
# / bend-G / channel-M into each beat than emit_cells can place on distinct rows,
# so events get eaten by same-row & per-cell-slot collisions. Raising rows-per-beat
# (doubling rpb, halving tickspeed so F=rpb·speed — hence the tempo — is unchanged)
# spreads them out. Applied only when both axes are auto and rpb < 8.
_BUMP_TARGET_RPB = 8 # raise rpb up to (at least) this
_BUMP_BEND_MIN_EVENTS = 24 # "significant pitch-bend": at least this many...
_BUMP_BEND_MIN_DENSITY = 0.25 # ... non-centre bend events, and >= this per note
_BUMP_POLY_PEAK = 10 # "many polyphony": peak simultaneous notes >= this
def _peak_polyphony(merged) -> int:
"""Peak count of simultaneously-sounding (channel, key) notes across the song.
Sustain pedal is ignored — this is a polyphony proxy, not exact voicing."""
active = set()
cur = peak = 0
for _tick, _seq, ev in merged:
if ev[0] == 'on':
k = (ev[1], ev[2])
if k not in active:
active.add(k); cur += 1
if cur > peak:
peak = cur
elif ev[0] == 'off':
k = (ev[1], ev[2])
if k in active:
active.discard(k); cur -= 1
return peak
def _detect_subdivision(onsets, tpq: int) -> int:
"""Finest beat subdivision (per quarter) the onsets actually use.
Returns the smallest D from _SUBDIV_CANDIDATES whose 1/D grid covers
>= _SUBDIV_THRESHOLD of onsets within _SUBDIV_TOL; else the best-covering
candidate (so heavily syncopated/swing material lands on a usable grid
rather than forcing the maximum)."""
if not onsets:
return 1
best_d, best_cov = 1, -1.0
for d in _SUBDIV_CANDIDATES:
hits = 0
for t in onsets:
frac = (t % tpq) / tpq
if abs(frac - round(frac * d) / d) <= _SUBDIV_TOL:
hits += 1
cov = hits / len(onsets)
if cov >= _SUBDIV_THRESHOLD:
return d
if cov > best_cov:
best_d, best_cov = d, cov
return best_d
def auto_timing(division, merged, rpb_fixed, speed_fixed, max_voices) -> tuple:
"""Choose (rpb, speed, info) for the Taud grid from the tempo map, the MIDI
time signatures and onset-subdivision analysis. A non-None rpb_fixed /
speed_fixed pins that axis (the user passed it); the other is auto-fit. Both
pinned → returned verbatim. When both are auto, a final RPB bump raises
rows-per-beat for bend/polyphony-heavy songs (see the _BUMP_* constants)."""
# SMPTE has no musical beat grid; the ft mapping pins a 120 BPM equivalent,
# so there is nothing to optimise — keep the proven default / pinned values.
if division[0] != 'ppq':
return (rpb_fixed or 4, speed_fixed or 6,
"SMPTE division — auto timing skipped")
tpq = division[1]
onsets = [tick for (tick, _seq, ev) in merged if ev[0] == 'on']
tempos = sorted((tick, ev[1]) for (tick, _seq, ev) in merged if ev[0] == 'tempo')
first_onset = onsets[0] if onsets else 0
last_tick = max((tick for tick, _s, _e in merged), default=0)
def bpm_at(tick):
i = bisect.bisect_right([t for t, _ in tempos], tick) - 1
return tempos[i][1] if i >= 0 else 120.0
bpm0 = bpm_at(first_onset)
all_bpms = {b for _t, b in tempos} or {120.0}
bend_events = sum(1 for (_t, _s, ev) in merged if ev[0] == 'bend' and ev[2] != 8192)
bends_present = bend_events > 0
peak_poly = _peak_polyphony(merged)
subdiv = _detect_subdivision(onsets, tpq)
# Anchor: smallest multiple of the subdivision that is >= the proven grid, so
# it represents the rhythm exactly (F % subdiv == 0) without going below 24.
f_want = -(-_F_TARGET // subdiv) * subdiv
rpb_opts = [rpb_fixed] if rpb_fixed else [4, 8, 2, 16]
speed_lo = 2 if bends_present else 1
speed_opts = [speed_fixed] if speed_fixed else list(range(1, 16))
def taud_bpm(bpm, F):
return round(bpm * F / 24.0)
best = None # (sort_key, rpb, speed)
for rpb in rpb_opts:
for speed in speed_opts:
if speed < speed_lo:
continue
F = rpb * speed
init_ok = _TAUD_BPM_LO <= taud_bpm(bpm0, F) <= _TAUD_BPM_HI
rhythm_ok = (F % subdiv == 0)
clamped = sum(1 for b in all_bpms
if not _TAUD_BPM_LO <= taud_bpm(b, F) <= _TAUD_BPM_HI)
key = (0 if init_ok else 1, # initial tempo must fit the register
clamped, # fewest tempo changes forced to clamp
[4, 8, 2, 16].index(rpb), # prefer the conventional rpb=4 (rows
# = beats×rpb, so this caps pattern
# count and keeps the highlight grid)
abs(F - f_want), # spend speed to reach the subdiv grid
0 if rhythm_ok else 1, # ... exactly, if a tie remains
abs(speed - 6)) # tie-break: near the proven speed 6
if best is None or key < best[0]:
best = (key, rpb, speed)
_, rpb, speed = best
# ── RPB bump for bend/polyphony-heavy songs (both axes auto only) ──
# Double rpb / halve speed (F, hence tempo, unchanged) until rpb reaches the
# target, while speed stays an integer >= the portamento floor and the bumped
# grid is estimated to fit the cue / pattern budget (so a long dense song does
# not flip a working conversion into a hard error — pin --rpb 4 to opt out).
bend_heavy = (bend_events >= _BUMP_BEND_MIN_EVENTS and
bend_events >= _BUMP_BEND_MIN_DENSITY * max(1, len(onsets)))
many_poly = peak_poly >= _BUMP_POLY_PEAK
bumped = False
if rpb_fixed is None and speed_fixed is None and rpb < _BUMP_TARGET_RPB \
and (bend_heavy or many_poly):
total_quarters = max(0, last_tick - first_onset) / tpq
nvoices_est = min(max_voices, peak_poly + 1)
def fits(rpb_try):
est_rows = math.ceil(total_quarters * rpb_try) + rpb_try
est_cues = math.ceil(est_rows / 56) + 4 # /56 (not 64) + margin: odd meters
return est_cues <= NUM_CUES and est_cues * nvoices_est <= NUM_PATTERNS_MAX
while (rpb < _BUMP_TARGET_RPB and speed % 2 == 0
and speed // 2 >= speed_lo and rpb * 2 <= 16 and fits(rpb * 2)):
rpb *= 2; speed //= 2; bumped = True
info = (f"bpm0 {bpm0:.1f}, finest 1/{subdiv}-quarter subdivision, "
f"{'bends present, ' if bends_present else ''}"
f"F={rpb * speed} fts/beat (want {f_want}) → Taud BPM "
f"{taud_bpm(bpm0, rpb * speed)}")
if bumped:
why = " + ".join(w for w, on in
(("dense bends", bend_heavy), ("high polyphony", many_poly)) if on)
info += (f"; RPB bumped to {rpb} / speed {speed} to spread events "
f"({why}; peak poly {peak_poly})")
return rpb, speed, info
# ── SF2 parser ────────────────────────────────────────────────────────────────
GEN_START_OFF = 0
@@ -2071,14 +2273,55 @@ def emit_cells(song: Song, insts: dict, speed: int, rpb: int,
# ── Pattern / cue emission and final assembly ────────────────────────────────
def build_pattern_bin(cells: dict, n_voices: int, n_cues: int) -> bytes:
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
each section into whole-bar cues — the largest multiple of the section's bar
length that fits in 64 rows (so the tracker's bar/beat highlight lines up).
Returns (cue_starts, cue_lens, init_bar_rows). cue_starts[i] is the absolute
starting row of cue i; cue_lens[i] is its playable row count (<= 64). A
constant 4/4 song still yields 64-row (= 4-bar) cues."""
def timesig_at(row):
ft = row * speed + shift_ft
i = bisect.bisect_right(timesig_ft, ft) - 1
return timesig[i] if i >= 0 else (4, 2) # MIDI default = 4/4
def bar_rows_of(sig):
num, dpow = sig
bar_quarters = num * 4.0 / (2 ** dpow) # bar length in quarter notes
return max(1, round(bar_quarters * rpb))
breaks = {(ft - shift_ft) // speed for ft in timesig_ft}
bounds = sorted({0, total_rows} | {r for r in breaks if 0 < r < total_rows})
cue_starts, cue_lens = [], []
for bi in range(len(bounds) - 1):
seg_start, seg_end = bounds[bi], bounds[bi + 1]
br = bar_rows_of(timesig_at(seg_start))
cue_max = br * (PATTERN_ROWS // br) if br <= PATTERN_ROWS else PATTERN_ROWS
r = seg_start
while r < seg_end:
length = min(cue_max, seg_end - r)
cue_starts.append(r)
cue_lens.append(length)
r += length
return cue_starts, cue_lens, bar_rows_of(timesig_at(0))
def build_pattern_bin(cells: dict, n_voices: int,
cue_starts: list, cue_lens: list) -> bytes:
"""Pack patterns for cues that may start at arbitrary rows and run fewer
than 64 rows (bar-aligned / time-signature-broken cues). Rows past a cue's
length are silent padding (the LEN cue instruction stops playback there)."""
n_cues = len(cue_starts)
out = bytearray(n_cues * n_voices * PATTERN_BYTES)
pos = 0
for cue in range(n_cues):
for ci, (start, length) in enumerate(zip(cue_starts, cue_lens)):
for v in range(n_voices):
for r in range(PATTERN_ROWS):
base = pos + r * 8
c = cells.get((v, cue * PATTERN_ROWS + r))
c = cells.get((v, start + r)) if r < length else None
if c is None:
out[base + 3] = 0xC0
out[base + 4] = 0xC0
@@ -2117,7 +2360,11 @@ def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list,
song, None, speed, rpb, eps_units, args.drum_keyoff, shift_ft,
args.max_voices)
n_cues = (total_rows + PATTERN_ROWS - 1) // PATTERN_ROWS
# 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)
n_cues = len(cue_starts)
if n_cues > NUM_CUES:
sys.exit(f"error: song needs {n_cues} cues > {NUM_CUES} limit "
f"(try a smaller --rpb)")
@@ -2125,21 +2372,23 @@ def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list,
sys.exit(f"error: {n_cues} cues × {n_voices} voices "
f"> {NUM_PATTERNS_MAX} pattern limit")
pat_bin = build_pattern_bin(cells, n_voices, n_cues)
pat_bin = build_pattern_bin(cells, n_voices, cue_starts, cue_lens)
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)
vprint(f" patterns: {n_cues * n_voices}{n_unique} unique; "
f"{n_cues} cue(s), {n_voices} voice(s), {total_rows} rows")
f"{n_cues} cue(s), {n_voices} voice(s), {total_rows} rows"
+ (f"; {n_breaks} time-signature break(s)" if n_breaks > 0 else ""))
sheet = bytearray(NUM_CUES * CUE_SIZE)
for ci in range(NUM_CUES):
sheet[ci*CUE_SIZE:(ci+1)*CUE_SIZE] = encode_cue([], 0)
for ci in range(n_cues):
pats = [remap[ci * n_voices + v] for v in range(n_voices)]
tail = total_rows - ci * PATTERN_ROWS
if ci == n_cues - 1:
instr = CUE_INST_HALT
elif tail < PATTERN_ROWS:
instr = cue_instruction_len(tail)
elif cue_lens[ci] < PATTERN_ROWS:
instr = cue_instruction_len(cue_lens[ci])
else:
instr = CUE_INST_NOP
sheet[ci*CUE_SIZE:(ci+1)*CUE_SIZE] = encode_cue(pats, instr)
@@ -2194,10 +2443,23 @@ def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list,
vprint(f" ixmp: {sum(len(p) for p in ixmp.values())} patch(es) "
f"across {len(ixmp)} instrument(s)")
title = song.title or os.path.splitext(os.path.basename(args.input))[0]
# sMet beat divisions drive the tracker's row highlighting: primary =
# rows per NOTATED beat (the time-sig denominator), secondary = rows per
# bar. Using the denominator beat (not the rpb=rows-per-quarter) keeps the
# primary highlight a divisor of the bar — e.g. 7/8 → 2 rows (eighth), bar
# 14: 14 % 2 == 0, aligned; rpb=4 would drift (14 % 4 != 0). 4/4 → 4.
i = bisect.bisect_right(song.timesig_ft, shift_ft) - 1
_, init_dpow = song.timesig[i] if i >= 0 else (4, 2)
beat_pri = max(1, round(rpb * 4 / (2 ** init_dpow)))
song_meta = [{'index': 0, 'name': title,
'notation': 240, # 24-TET (MIDI is 12-TET but 24 is harmless & cleaner pre-pitchbend transpose notation); 0 = raw/hex display
'beat_pri': max(1, min(255, beat_pri)),
'beat_sec': max(1, min(255, init_bar_rows))}]
proj_data = build_project_data(
project_name=title,
instrument_names=inst_names,
sample_names=smp_names,
song_metadata=song_meta,
ixmp_patches=ixmp or None,
)
@@ -2236,10 +2498,13 @@ def main():
metavar=('BANK', 'INST'),
help='Force the percussion channel to this SF2 preset '
'(default: bank 128, channel program)')
ap.add_argument('--rpb', type=int, default=4, choices=(2, 4, 8, 16),
help='Rows per beat (default 4 = 16th-note rows)')
ap.add_argument('--speed', type=int, default=6,
help='Ticks per row, 1..15 (default 6)')
ap.add_argument('--rpb', type=int, default=None, choices=(2, 4, 8, 16),
help='Rows per beat (default: auto from time signatures + '
'onset analysis). Passing a value pins this axis and '
'auto-fits --speed')
ap.add_argument('--speed', type=int, default=None,
help='Ticks per row, 1..15 (default: auto, see --rpb). '
'Passing a value pins this axis and auto-fits --rpb')
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 '
@@ -2271,7 +2536,7 @@ def main():
args = ap.parse_args()
set_verbose(args.verbose)
if not (1 <= args.speed <= 15):
if args.speed is not None and not (1 <= args.speed <= 15):
sys.exit("error: --speed must be 1..15")
if not (1 <= args.max_voices <= 20):
sys.exit("error: --max-voices must be 1..20")
@@ -2282,8 +2547,16 @@ def main():
vprint(f"parsing MIDI '{args.input}'")
division, merged = parse_midi(args.input)
# Resolve the Taud grid (Tickspeed + RPB) before mapping ticks to fine-ticks.
# A pinned --rpb/--speed fixes that axis; the rest is auto-fit.
args.rpb, args.speed, timing_info = auto_timing(
division, merged, args.rpb, args.speed, args.max_voices)
vprint(f" timing: rpb {args.rpb}, speed {args.speed} ({timing_info})")
song = extract_song(division, merged, args.rpb, args.speed)
vprint(f" {len(song.notes)} note(s), {len(song.tempo_ft)} tempo event(s)")
vprint(f" {len(song.notes)} note(s), {len(song.tempo_ft)} tempo event(s), "
f"{len(song.timesig_ft)} time-signature event(s)")
if not song.notes:
sys.exit("error: MIDI contains no playable notes")

View File

@@ -2808,25 +2808,41 @@ TODO:
If you find sustained pads now cut a touch short (their long tails are more noticeable),
nudging it toward 0.300.35 lengthens the tail without returning to the old over-long
behaviour.
[ ] auto-set optimal-ish Tickspeed and RPB using MIDI Time Signature events and note analysis. Break pattern when Time Signature changes.
Time Signature
FF 58 04 nn dd cc bb
Time signature is expressed as 4 numbers. nn and dd represent the "numerator" and "denominator" of the signature as notated on sheet music. The denominator is a negative power of 2: 2 = quarter note, 3 = eighth, etc.
The cc expresses the number of MIDI clocks in a metronome click.
The bb parameter expresses the number of notated 32nd notes in a MIDI quarter note (24 MIDI clocks). This event allows a program to relate what MIDI thinks of as a quarter, to something entirely different.
For example, 6/8 time with a metronome click every 3 eighth notes and 24 clocks per quarter note would be the following event:
FF 58 04 06 03 18 08
NOTE: If there are no time signature events in a MIDI file, then the time signature is assumed to be 4/4.
In a format 0 file, the time signatures changes are scattered throughout the one MTrk. In format 1, the very first MTrk should consist of only the time signature (and tempo) events so that it could be read by some device capable of generating a "tempo map". It is best not to place MIDI events in this MTrk. In format 2, each MTrk should begin with at least one initial time signature (and tempo) event.
[x] auto-set optimal-ish Tickspeed and RPB using MIDI Time Signature events and note analysis. Break pattern when Time Signature changes.
* DONE 2026-06-14. midi2taud.py now parses the time-signature meta event (FF 58) and
auto-sets the grid by DEFAULT (--rpb/--speed default to auto; passing one pins that
axis and auto-fits the other, pass both to override). auto_timing() picks F = rpb·speed
fine-ticks/beat to (a) represent the finest onset subdivision actually used
(_detect_subdivision: smallest 1/D-of-quarter grid covering >=95% of note onsets,
D in {1,2,3,4,6,8,12,16}), (b) keep every tempo inside the Taud BPM register [25,280],
and (c) anchor at the proven 24-fts/beat grid (smallest multiple of D that is >=24), so
plain material reproduces the old speed 6 / rpb 4. Lexicographic key: init-tempo-fits >
fewest-clamped-tempos > prefer-rpb-4 (rows = beats×rpb, so rpb caps pattern count;
speed is "free" sub-row + tempo precision) > closeness-to-F_want > exact-grid > near
speed 6. Verified rpb4/speed6 on Onestop / E1M1 / E2M1 / flourish / keep_on_rolling /
pokemon-theme. RPB bump (final step, both axes auto only): a bend-heavy (>=24 non-centre
bend events AND >=0.25/note) OR many-polyphony (peak simultaneous notes >= 10) song with
rpb < 8 gets rpb doubled / speed halved (F=rpb·speed, hence tempo, UNCHANGED) up to rpb 8,
so more rows host key-offs / chokes / bend-G / channel-M and fewer are eaten by same-row &
per-cell-slot collisions. Guarded by a cue/pattern-budget estimate so it can't flip a long
dense song into a hard error (pin --rpb 4 to opt out). Measured on Onestop: key-offs
absorbed 1856→1574, bend segments 266→552. All six demo MIDIs are dense → rpb8/speed3.
Pattern breaking: plan_cues() breaks a cue at every time-sig change and
packs each section into whole-bar cues (largest multiple of the bar length that fits in
64 rows) via the LEN cue instruction (constant 4/4 still = 64-row cues; 7/8 flourish =
56-row cues; mid-song change starts the new section on a fresh cue). The project-data
sMet block gets Primary beat division = rows per NOTATED beat (the time-sig denominator,
= round(rpb·4/2^dpow): 4 for x/4, 2 for x/8 — always a divisor of the bar so the highlight
stays bar-aligned; rpb=rows-per-quarter would drift on 7/8 since 14%4!=0) and Secondary =
rows per bar, so the tracker's bar/beat highlight lines up. sMet notation = 120 (12-TET;
MIDI is 12-TET) — REQUIRED now that taut.js honours notation on load (the taud_common
default 0 = "Raw format" would show hex note numbers). build_pattern_bin now takes
(cue_starts, cue_lens) instead of a flat 64-row chunking.
taut.js side: loadTaudSongList now reads beat_pri/beat_sec from sMet (were skipped) into
songsMeta.songs[i]; applySongBeatDiv() + applySongPitchPreset() apply per-song beat
divisions AND notation/pitch-preset on BOTH initial open and switchSong (initial open
previously left both at the 4/16 + 12-TET defaults — the latent pitch-preset gap is now
closed too). Only midi2taud emits sMet; the other 2taud converters omit it → 12-TET default.
[x] Taut UI commit
- Inst > Gen.1 > sample binding: ~~~....[two doubledots] et al. (n extra samples)
- Inst > Gen.2 > filter: IT/SF mode toggle (which also need to redefine slider range and their writebacks as IT takes 8-bit and SF takes 16-bit values)