mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-15 00:44:05 +09:00
taud: auto RPB/Tickspeed resolution
This commit is contained in:
@@ -300,9 +300,31 @@ const colEffArg = 231
|
|||||||
const colBackPtn = 255
|
const colBackPtn = 255
|
||||||
|
|
||||||
const PITCH_PRESET_IDX_DEFAULT = 120
|
const PITCH_PRESET_IDX_DEFAULT = 120
|
||||||
let PITCH_PRESET_IDX = PITCH_PRESET_IDX_DEFAULT // TODO read from the Project Data section of the .taud
|
// Seed value used during global init (integrity check + first rebuildPitchLut);
|
||||||
let beatDivPrimary = 4 // TODO read from the Project Data section of the .taud
|
// the open/switch paths override it per-song from sMet via applySongPitchPreset().
|
||||||
let beatDivSecondary = 16
|
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 hasUnsavedChanges = false
|
||||||
let patternsOutOfSync = false // in-memory song.patterns has edits not yet pushed to the audio adapter
|
let patternsOutOfSync = false // in-memory song.patterns has edits not yet pushed to the audio adapter
|
||||||
|
|
||||||
@@ -901,6 +923,8 @@ function loadTaudSongList(filePath) {
|
|||||||
composer: '',
|
composer: '',
|
||||||
copyright: '',
|
copyright: '',
|
||||||
pitchPresetIdx: null,
|
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
|
// payload: notation(u16) + beat_pri(u8) + beat_sec(u8) + name\0 + composer\0 + copyright\0
|
||||||
const notation = (sys.peek(ptr + subStart) & 0xFF) |
|
const notation = (sys.peek(ptr + subStart) & 0xFF) |
|
||||||
((sys.peek(ptr + subStart + 1) & 0xFF) << 8)
|
((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)
|
let r = subStart + 4 // skip notation(2) + pri(1) + sec(1)
|
||||||
const strs = []
|
const strs = []
|
||||||
while (strs.length < 3 && r < subStart + subLen) {
|
while (strs.length < 3 && r < subStart + subLen) {
|
||||||
@@ -986,6 +1012,9 @@ function loadTaudSongList(filePath) {
|
|||||||
}
|
}
|
||||||
if (idx < numSongs) {
|
if (idx < numSongs) {
|
||||||
songs[idx].pitchPresetIdx = notation
|
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[0] !== undefined) songs[idx].name = strs[0]
|
||||||
if (strs[1] !== undefined) songs[idx].composer = strs[1]
|
if (strs[1] !== undefined) songs[idx].composer = strs[1]
|
||||||
if (strs[2] !== undefined) songs[idx].copyright = strs[2]
|
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_GVOL = 1
|
||||||
const PROJ_META_MVOL = 2
|
const PROJ_META_MVOL = 2
|
||||||
let song = loadTaud(fullPathObj.full, currentSongIndex)
|
let song = loadTaud(fullPathObj.full, currentSongIndex)
|
||||||
|
applySongPitchPreset(songsMeta.songs[currentSongIndex])
|
||||||
|
applySongBeatDiv(songsMeta.songs[currentSongIndex])
|
||||||
|
|
||||||
const voiceMutes = new Array(NUM_VOICES).fill(false)
|
const voiceMutes = new Array(NUM_VOICES).fill(false)
|
||||||
let timelineMuteSnapshot = null
|
let timelineMuteSnapshot = null
|
||||||
@@ -2017,11 +2048,8 @@ function switchSong(newIndex) {
|
|||||||
song = loadTaud(fullPathObj.full, newIndex)
|
song = loadTaud(fullPathObj.full, newIndex)
|
||||||
refreshSamplesCache()
|
refreshSamplesCache()
|
||||||
|
|
||||||
const newPitchIdx = songsMeta.songs[newIndex].pitchPresetIdx
|
applySongPitchPreset(songsMeta.songs[newIndex])
|
||||||
PITCH_PRESET_IDX = (newPitchIdx != null && pitchTablePresets[newPitchIdx])
|
applySongBeatDiv(songsMeta.songs[newIndex])
|
||||||
? newPitchIdx
|
|
||||||
: PITCH_PRESET_IDX_DEFAULT
|
|
||||||
rebuildPitchLut()
|
|
||||||
|
|
||||||
taud.uploadTaudFile(fullPathObj.full, newIndex, PLAYHEAD)
|
taud.uploadTaudFile(fullPathObj.full, newIndex, PLAYHEAD)
|
||||||
patternsOutOfSync = false
|
patternsOutOfSync = false
|
||||||
|
|||||||
307
midi2taud.py
307
midi2taud.py
@@ -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
|
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.
|
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`
|
* 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 /
|
MIDI tempo changes map to T $xx00 set-tempo effects; channel volume /
|
||||||
expression (CC7 × CC11) map to M $xx00 channel-volume effects so they
|
expression (CC7 × CC11) map to M $xx00 channel-volume effects so they
|
||||||
never disturb the velocity-driven patch selection axis.
|
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
|
import argparse
|
||||||
@@ -142,6 +157,11 @@ def _parse_track(data: bytes, pos: int, end: int) -> list:
|
|||||||
txt = payload.decode('latin-1', errors='replace').strip()
|
txt = payload.decode('latin-1', errors='replace').strip()
|
||||||
if txt:
|
if txt:
|
||||||
evs.append((tick, ('title', 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:
|
elif mtype == 0x2F:
|
||||||
evs.append((tick, ('eot',)))
|
evs.append((tick, ('eot',)))
|
||||||
break
|
break
|
||||||
@@ -278,7 +298,8 @@ def _curve_push(fts: list, vals: list, ft: int, val):
|
|||||||
|
|
||||||
|
|
||||||
class Song:
|
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:
|
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)]
|
chs = [_ChState() for _ in range(16)]
|
||||||
notes = []
|
notes = []
|
||||||
tempo_ft, tempo_bpm = [], []
|
tempo_ft, tempo_bpm = [], []
|
||||||
|
timesig_ft, timesig = [], [] # ft → (numerator, denom_power)
|
||||||
title = None
|
title = None
|
||||||
max_ft = 0
|
max_ft = 0
|
||||||
|
|
||||||
@@ -401,6 +423,13 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song:
|
|||||||
elif kind == 'tempo':
|
elif kind == 'tempo':
|
||||||
tempo_ft.append(ft); tempo_bpm.append(ev[1])
|
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':
|
elif kind == 'title':
|
||||||
if title is None:
|
if title is None:
|
||||||
title = ev[1]
|
title = ev[1]
|
||||||
@@ -425,11 +454,184 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song:
|
|||||||
song.channels = chs
|
song.channels = chs
|
||||||
song.tempo_ft = tempo_ft
|
song.tempo_ft = tempo_ft
|
||||||
song.tempo_bpm = tempo_bpm
|
song.tempo_bpm = tempo_bpm
|
||||||
|
song.timesig_ft = timesig_ft
|
||||||
|
song.timesig = timesig
|
||||||
song.title = title
|
song.title = title
|
||||||
song.end_ft = max_ft
|
song.end_ft = max_ft
|
||||||
return song
|
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 ────────────────────────────────────────────────────────────────
|
# ── SF2 parser ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
GEN_START_OFF = 0
|
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 ────────────────────────────────
|
# ── 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)
|
out = bytearray(n_cues * n_voices * PATTERN_BYTES)
|
||||||
pos = 0
|
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 v in range(n_voices):
|
||||||
for r in range(PATTERN_ROWS):
|
for r in range(PATTERN_ROWS):
|
||||||
base = pos + r * 8
|
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:
|
if c is None:
|
||||||
out[base + 3] = 0xC0
|
out[base + 3] = 0xC0
|
||||||
out[base + 4] = 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,
|
song, None, speed, rpb, eps_units, args.drum_keyoff, shift_ft,
|
||||||
args.max_voices)
|
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:
|
if n_cues > NUM_CUES:
|
||||||
sys.exit(f"error: song needs {n_cues} cues > {NUM_CUES} limit "
|
sys.exit(f"error: song needs {n_cues} cues > {NUM_CUES} limit "
|
||||||
f"(try a smaller --rpb)")
|
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 "
|
sys.exit(f"error: {n_cues} cues × {n_voices} voices "
|
||||||
f"> {NUM_PATTERNS_MAX} pattern limit")
|
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)
|
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; "
|
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)
|
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||||
for ci in range(NUM_CUES):
|
for ci in range(NUM_CUES):
|
||||||
sheet[ci*CUE_SIZE:(ci+1)*CUE_SIZE] = encode_cue([], 0)
|
sheet[ci*CUE_SIZE:(ci+1)*CUE_SIZE] = encode_cue([], 0)
|
||||||
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)]
|
||||||
tail = total_rows - ci * PATTERN_ROWS
|
|
||||||
if ci == n_cues - 1:
|
if ci == n_cues - 1:
|
||||||
instr = CUE_INST_HALT
|
instr = CUE_INST_HALT
|
||||||
elif tail < PATTERN_ROWS:
|
elif cue_lens[ci] < PATTERN_ROWS:
|
||||||
instr = cue_instruction_len(tail)
|
instr = cue_instruction_len(cue_lens[ci])
|
||||||
else:
|
else:
|
||||||
instr = CUE_INST_NOP
|
instr = CUE_INST_NOP
|
||||||
sheet[ci*CUE_SIZE:(ci+1)*CUE_SIZE] = encode_cue(pats, instr)
|
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) "
|
vprint(f" ixmp: {sum(len(p) for p in ixmp.values())} patch(es) "
|
||||||
f"across {len(ixmp)} instrument(s)")
|
f"across {len(ixmp)} instrument(s)")
|
||||||
title = song.title or os.path.splitext(os.path.basename(args.input))[0]
|
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(
|
proj_data = build_project_data(
|
||||||
project_name=title,
|
project_name=title,
|
||||||
instrument_names=inst_names,
|
instrument_names=inst_names,
|
||||||
sample_names=smp_names,
|
sample_names=smp_names,
|
||||||
|
song_metadata=song_meta,
|
||||||
ixmp_patches=ixmp or None,
|
ixmp_patches=ixmp or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2236,10 +2498,13 @@ def main():
|
|||||||
metavar=('BANK', 'INST'),
|
metavar=('BANK', 'INST'),
|
||||||
help='Force the percussion channel to this SF2 preset '
|
help='Force the percussion channel to this SF2 preset '
|
||||||
'(default: bank 128, channel program)')
|
'(default: bank 128, channel program)')
|
||||||
ap.add_argument('--rpb', type=int, default=4, choices=(2, 4, 8, 16),
|
ap.add_argument('--rpb', type=int, default=None, choices=(2, 4, 8, 16),
|
||||||
help='Rows per beat (default 4 = 16th-note rows)')
|
help='Rows per beat (default: auto from time signatures + '
|
||||||
ap.add_argument('--speed', type=int, default=6,
|
'onset analysis). Passing a value pins this axis and '
|
||||||
help='Ticks per row, 1..15 (default 6)')
|
'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,
|
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 '
|
||||||
@@ -2271,7 +2536,7 @@ def main():
|
|||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
set_verbose(args.verbose)
|
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")
|
sys.exit("error: --speed must be 1..15")
|
||||||
if not (1 <= args.max_voices <= 20):
|
if not (1 <= args.max_voices <= 20):
|
||||||
sys.exit("error: --max-voices must be 1..20")
|
sys.exit("error: --max-voices must be 1..20")
|
||||||
@@ -2282,8 +2547,16 @@ def main():
|
|||||||
|
|
||||||
vprint(f"parsing MIDI '{args.input}'…")
|
vprint(f"parsing MIDI '{args.input}'…")
|
||||||
division, merged = parse_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)
|
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:
|
if not song.notes:
|
||||||
sys.exit("error: MIDI contains no playable notes")
|
sys.exit("error: MIDI contains no playable notes")
|
||||||
|
|
||||||
|
|||||||
@@ -2808,25 +2808,41 @@ TODO:
|
|||||||
If you find sustained pads now cut a touch short (their long tails are more noticeable),
|
If you find sustained pads now cut a touch short (their long tails are more noticeable),
|
||||||
nudging it toward 0.30–0.35 lengthens the tail without returning to the old over-long
|
nudging it toward 0.30–0.35 lengthens the tail without returning to the old over-long
|
||||||
behaviour.
|
behaviour.
|
||||||
[ ] auto-set optimal-ish Tickspeed and RPB using MIDI Time Signature events and note analysis. Break pattern when Time Signature changes.
|
[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
|
||||||
Time Signature
|
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
|
||||||
FF 58 04 nn dd cc bb
|
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,
|
||||||
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.
|
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
|
||||||
The cc expresses the number of MIDI clocks in a metronome click.
|
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;
|
||||||
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.
|
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 /
|
||||||
For example, 6/8 time with a metronome click every 3 eighth notes and 24 clocks per quarter note would be the following event:
|
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
|
||||||
FF 58 04 06 03 18 08
|
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 &
|
||||||
NOTE: If there are no time signature events in a MIDI file, then the time signature is assumed to be 4/4.
|
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
|
||||||
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.
|
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
|
[x] Taut UI commit
|
||||||
- Inst > Gen.1 > sample binding: ~~~....[two doubledots] et al. (n extra samples)
|
- 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)
|
- 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user