taud: extended tempo range

This commit is contained in:
minjaesong
2026-06-20 04:00:33 +09:00
parent 1173373789
commit 6eb73355ca
11 changed files with 101 additions and 49 deletions

View File

@@ -709,6 +709,12 @@ ProTracker `Fxx` with `xx ≥ $20` maps to Taud `T $(xx $19)00`; `Fxx` with
**Implementation.** If the high byte is non-zero, set `tempo_byte = arg >> 8`; derive `BPM = tempo_byte + $19`; compute tick duration as `samples_per_tick = 32000 × 5 / (BPM × 2) = 80000 / BPM` (integer truncated) at the fixed 32000 Hz output rate. Example: BPM 125 → 640 samples per tick; BPM 24 → 3200 samples per tick; BPM 280 → 286 samples per tick. There is no memory for set-tempo.
### T $FFxx (high byte 0xFF) — Set tempo (extended)
**Plain.** Sets the Taud tempo byte to `$FF + $xx`. The resulting BPM is `$xx + $118`: xx = $00 → 280 BPM, $64 → 380 BPM, $FF → 535 BPM.
**Compatibility.** Unique to Taud.
### T $00xy (high byte zero) — Tempo slide
**Plain.** Adjusts the tempo continuously during the row. `$00_0y` (low nibble under a zero high nibble within the low byte) slides BPM down by `$y` per non-first tick; `$00_1y` slides up. Out-of-range encodings ($00_20 through $00_FF) are reserved and behave as no-ops.

View File

@@ -23,7 +23,7 @@ const BEEP_P_LO = -96 // MMIO 95: pppppp_QQ
const BEEP_A = -97 // MMIO 96: A
const BEEP_B = -98 // MMIO 97: B
const BEEP_HALFCLOCK = 3579545 / 16 / 2 // f = BEEP_HALFCLOCK / divider
const BEEP_HALFCLOCK = (3579545.4545454545 / 16.0) / 2 // f = BEEP_HALFCLOCK / divider
const DIVIDER_MAX = 0x3FFF // 14-bit
const QQ_NONE = 0, QQ_TWO = 2, QQ_THREE = 3 // beeper note-effect (QQ field)
@@ -308,6 +308,10 @@ function applyTickEffects(v, t) {
const sleepUntil = (nano) => { const ms = (nano - sys.nanoTime()) / 1e6; if (ms >= 1) sys.sleep(Math.floor(ms)) }
function cmdToInt(cmd) {
return cmd[0] | (cmd[1] << 8) | (cmd[2] << 16) | (cmd[3] << 24);
}
// ---------------------------------------------------------------------------
// Render loop
// ---------------------------------------------------------------------------
@@ -322,6 +326,8 @@ const checkStop = () => {
return stopReq
}
let oldDiv = 0xFFFFFFFF
try {
let o = 0
let startRow = 0
@@ -352,12 +358,19 @@ try {
}
uploadBeeper(cmd[0], cmd[1], cmd[2], cmd[3])
println(`${String(globalTick).padStart(6, '0')} ` +
let cmdInt = cmdToInt(cmd)
if (oldDiv != cmdInt) {
println(`${String(globalTick).padStart(6, '0')} ` +
`c${String(o).padStart(2)} r${String(row).padStart(2)} t${String(t).padStart(2)} ` +
describeCommand(cmd, swInfo))
}
globalTick++
nextTick += TICK_NANO
oldDiv = cmdInt
sleepUntil(nextTick)
}

View File

@@ -158,8 +158,9 @@ function parseTaud(path, songIndex) {
const numVoices = sys.peek(ptr + entryOff + 4) & 0xFF
const numPats = (sys.peek(ptr + entryOff + 5) & 0xFF) |
((sys.peek(ptr + entryOff + 6) & 0xFF) << 8)
const bpm = (sys.peek(ptr + entryOff + 7) & 0xFF) + 25
const tickRate = sys.peek(ptr + entryOff + 8) & 0xFF
const tickPacked = sys.peek(ptr + entryOff + 8) & 0xFF
const bpm = (sys.peek(ptr + entryOff + 7) & 0xFF) + 25 + ((tickPacked & 0x80) << 1) // bit 7 = BPM high bit
const tickRate = tickPacked & 0x7F
const patCompSize = _peekU32LE(ptr, entryOff + 18)
const cueCompSize = _peekU32LE(ptr, entryOff + 22)

View File

@@ -826,8 +826,9 @@ function loadTaud(filePath, songIndex) {
const numVoices = sys.peek(ptr + entryOff + 4) & 0xFF
const numPats = (sys.peek(ptr + entryOff + 5) & 0xFF) |
((sys.peek(ptr + entryOff + 6) & 0xFF) << 8)
const bpmStored = sys.peek(ptr + entryOff + 7) & 0xFF
const tickRate = sys.peek(ptr + entryOff + 8) & 0xFF
const tickPacked = sys.peek(ptr + entryOff + 8) & 0xFF
const bpmStored = (sys.peek(ptr + entryOff + 7) & 0xFF) | ((tickPacked & 0x80) << 1) // bit 7 of byte 8 = BPM high bit
const tickRate = tickPacked & 0x7F
const patBinCompSize = _peekU32LE(ptr, entryOff + 18)
const cueSheetCompSize = _peekU32LE(ptr, entryOff + 22)
@@ -914,8 +915,8 @@ function loadTaudSongList(filePath) {
numVoices: sys.peek(ptr + entryOff + 4) & 0xFF,
numPats: (sys.peek(ptr + entryOff + 5) & 0xFF) |
((sys.peek(ptr + entryOff + 6) & 0xFF) << 8),
bpm: ((sys.peek(ptr + entryOff + 7) & 0xFF) + 25),
tickRate: sys.peek(ptr + entryOff + 8) & 0xFF,
bpm: ((sys.peek(ptr + entryOff + 7) & 0xFF) + 25 + ((sys.peek(ptr + entryOff + 8) & 0x80) << 1)), // bit 7 of byte 8 = BPM high bit
tickRate: sys.peek(ptr + entryOff + 8) & 0x7F,
mixerflags: sys.peek(ptr + entryOff + 15) & 0xFF,
songGlobalVolume: sys.peek(ptr + entryOff + 16) & 0xFF,
songMixingVolume: sys.peek(ptr + entryOff + 17) & 0xFF,
@@ -2726,8 +2727,10 @@ function simulateRowState(ptnDat, uptoRow) {
}
else if (effop === OP_T) {
const hi = (effarg >>> 8) & 0xFF
if (hi !== 0) {
bpm = Math.max(25, Math.min(280, hi + 0x19))
if (hi === 0xFF) {
bpm = Math.max(25, Math.min(535, (effarg & 0xFF) + 0x118)) // T $FFxx — extended tempo
} else if (hi !== 0) {
bpm = Math.max(25, Math.min(535, hi + 0x19))
} else {
const low = effarg & 0xFF
if ((low & 0xF0) === 0x00 || (low & 0xF0) === 0x10) memTSlide = low

View File

@@ -139,7 +139,9 @@ function uploadTaudFile(inFile, songIndex, playhead) {
let numPatsLo = sys.peek(filePtr + entryOff + 5) & 0xFF
let numPatsHi = sys.peek(filePtr + entryOff + 6) & 0xFF
let bpmStored = sys.peek(filePtr + entryOff + 7) & 0xFF
let tickRate = sys.peek(filePtr + entryOff + 8) & 0xFF
let tickPacked = sys.peek(filePtr + entryOff + 8) & 0xFF
let tickRate = tickPacked & 0x7F // bits 0..6
bpmStored |= (tickPacked & 0x80) << 1 // bit 7 of byte 8 = BPM high bit (0x100..0x1FE)
let mixerflags = sys.peek(filePtr + entryOff + 15) & 0xFF
let songGlobalVolume = sys.peek(filePtr + entryOff + 16) & 0xFF
let songMixingVolume = sys.peek(filePtr + entryOff + 17) & 0xFF
@@ -299,7 +301,7 @@ function captureTrackerDataToFile(outFile) {
// -- 3. BPM / tick-rate / volumes from playhead 0 -------------------------
let bpm = audio.getBPM(0) || 125
let tickRate = audio.getTickRate(0) || 6
let bpmStored = (bpm - 25) & 0xFF
let bpmStored = Math.max(0, Math.min(0x1FE, bpm - 25)) // 9-bit (0..510 ⇒ BPM 25..535)
let songGlobalVolume = audio.getSongGlobalVolume(0)
let songMixingVolume = audio.getSongMixingVolume(0)
if (songGlobalVolume === undefined || songGlobalVolume === null) songGlobalVolume = 0x80
@@ -393,8 +395,8 @@ function captureTrackerDataToFile(outFile) {
(songOffset >>> 24) & 0xFF,
20, // numVoices
numPats & 0xFF, (numPats >>> 8) & 0xFF, // numPatterns Uint16 LE
bpmStored, // BPM with 25 bias
tickRate, // initial tick-rate
bpmStored & 0xFF, // BPM with 25 bias (low 8 bits)
(((bpmStored >> 8) & 1) << 7) | (tickRate & 0x7F), // bit 7 = BPM high bit; bits 0..6 = tick-rate
0x00,0xA0, // basenote (0xA000 -- C9)
0x00,0xAC,0x02,0x46, // basefreq (8363 Hz)
sys.peek(baseAddr - 7), // mixer flags

View File

@@ -72,7 +72,7 @@ Behaviour (per midi2taud.md):
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
(25..535), 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
@@ -80,7 +80,8 @@ Behaviour (per midi2taud.md):
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 (or T $FFxx extended
set-tempo above 280 BPM); 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
@@ -569,8 +570,8 @@ _SUBDIV_THRESHOLD = 0.95
# 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
# Taud BPM register is bias-25 in [25, 535] (T $FFxx extends past 280); tick rate Hz = bpm·2/5.
_TAUD_BPM_LO, _TAUD_BPM_HI = 25, 535
# 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,
@@ -2477,10 +2478,16 @@ def emit_cells(song: Song, insts: dict, speed: int, rpb: int,
def taud_bpm(b):
t = round(b * scale)
if not (25 <= t <= 280):
if not (_TAUD_BPM_LO <= t <= _TAUD_BPM_HI):
vprint(f" warning: tempo {b:.1f} BPM maps to Taud {t}, "
f"clamped to 25..280 (try a different --rpb/--speed)")
return max(25, min(280, t))
f"clamped to {_TAUD_BPM_LO}..{_TAUD_BPM_HI} (try a different --rpb/--speed)")
return max(_TAUD_BPM_LO, min(_TAUD_BPM_HI, t))
def tempo_effarg(tb):
# T $xx00 set-tempo (BPM = xx+$19) up to 280; T $FFxx extended (BPM = xx+$118) above.
if tb <= 280:
return ((tb - 25) & 0xFF) << 8
return 0xFF00 | ((tb - 280) & 0xFF)
n_voices = allocate_voices(notes, speed, max_voices)
if n_voices == 0:
@@ -2663,7 +2670,7 @@ def emit_cells(song: Song, insts: dict, speed: int, rpb: int,
c = cells.get((v, row))
if c is None or c['eff'] is None:
c = _cell(cells, v, row)
c['eff'] = (TOP_T, ((tb - 25) & 0xFF) << 8)
c['eff'] = (TOP_T, tempo_effarg(tb))
c['prio'] = PRIO_TEMPO
placed = True
break
@@ -2673,7 +2680,7 @@ def emit_cells(song: Song, insts: dict, speed: int, rpb: int,
if not placed and victim is not None:
if victim['prio'] == PRIO_PORTA:
victim['note'] = NOTE_NOP # orphan G note would retrigger
victim['eff'] = (TOP_T, ((tb - 25) & 0xFF) << 8)
victim['eff'] = (TOP_T, tempo_effarg(tb))
victim['prio'] = PRIO_TEMPO
placed = True
t_evict += 1
@@ -3008,7 +3015,7 @@ def make_song_entry(section: dict, song_off: int, args) -> bytes:
song_offset=song_off,
num_voices=section['n_voices'],
num_patterns=section['n_unique'],
bpm_stored=(section['bpm0'] - 25) & 0xFF,
bpm_stored=section['bpm0'] - 25, # 9-bit; encode_song_entry packs bit 8 into byte-8 bit 7
tick_rate=section['speed'],
base_note=0xA000,
base_freq=8363.0,

View File

@@ -452,18 +452,23 @@ def encode_song_entry(song_offset: int, num_voices: int, num_patterns: int,
Layout:
u32 song_offset, u8 num_voices, u16 num_patterns,
u8 bpm_stored, u8 tick_rate,
u8 bpm_stored, u8 bpm_hi_bit<<7 | tick_rate,
u16 base_note, f32 base_freq,
u8 flags, u8 global_vol, u8 mixing_vol,
u32 pat_bin_comp_size, u32 cue_sheet_comp_size,
byte[6] reserved.
`bpm_stored` is `bpm - 25` and may be a 9-bit value (0..510 ⇒ BPM 25..535);
its low 8 bits go to the bpm byte and bit 8 is packed into bit 7 of the
tick-rate byte (which therefore caps tick_rate at 0..127). See terranmon.txt.
"""
bpm_stored = max(0, min(0x1FE, bpm_stored))
entry = struct.pack('<IBHBBHfBBBII',
song_offset,
num_voices & 0xFF,
num_patterns & 0xFFFF,
bpm_stored & 0xFF,
tick_rate & 0xFF,
(((bpm_stored >> 8) & 1) << 7) | (tick_rate & 0x7F),
base_note & 0xFFFF,
float(base_freq),
flags_byte & 0xFF,

View File

@@ -780,8 +780,8 @@ def main():
soff = u32(data, eoff)
nvoices = data[eoff + 4]
npats = u16(data, eoff + 5)
bpm = data[eoff + 7] + 25
tickrate = data[eoff + 8]
bpm = data[eoff + 7] + 25 + ((data[eoff + 8] & 0x80) << 1) # bit 7 of byte 8 = BPM high bit
tickrate = data[eoff + 8] & 0x7F
tuning_base = u16(data, eoff + 9)
base_freq = struct.unpack_from('<f', data, eoff + 11)[0]
gbflags = data[eoff + 15]

View File

@@ -119,14 +119,13 @@ MMIO
93 RO: Set beeper status (aka upload beeper command)
READING causes the side effect (and returns beeper status — 1 if a tone is currently sounding, 0 otherwise). WRITING DOES NOTHING
94..97 RW: Beeper command
0bPPPPPPPP 0bpppppp_QQ 0bAAAAAAAA 0bBBBBBBBB
94..99 RW: Beeper command
0bPPPPPPPP 0bpppppp_QQ 0bqqAABBCC 0baaaaaaaa 0bbbbbbbbb 0bcccccccc
PPPPPPPPpppppp: frequency divider (master clock: 3579545 / 16 Hz), determines pitch.
PPPPPPPPpppppp: 14-bit frequency divider (master clock: 3579545 / 16 Hz), determines pitch.
0: no sound
QQ: note effect
00: none
01: fixed arpeggio (rate = 60 Hz, second note is always divisor (P >>> 1))
10: two-note argeggio (rate = 60 Hz)
tick 1: base note at divisor P is played
tick 2: second note at divisor (P - (B << 8 | A)) is played
@@ -134,7 +133,12 @@ MMIO
tick 1: base note at divisor P is played
tick 2: second note at divisor (P - A) is played
tick 3: third note at divisor (P - A - B) is played
A/B: note effect arguments
01: four-note arpeggio (rate = 60 Hz)
tick 1: base note at divisor P is played
tick 2: second note at divisor (P - A) is played
tick 3: third note at divisor (P - A - B) is played
tick 4: fourth note at divisor (P - A - B - C) is played
Aa/Bb/Cc: note effect arguments (10-bit divisor delta for arpeggiator; byte 96 has high two bits)
1024..2047 RW: Reserved for integrated peripherals (e.g. built-in status display)
@@ -2942,7 +2946,10 @@ TODO:
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.
[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
[x] The same VT-aware patch thing for all fullscreen apps, possibly made simple by only requiring one row of simple code
* DONE 2026-06-20. See `con.setFullscreen()`
[x] Taud double the BPM ceiling
* DONE 2026-06-20. BPM range 25..535 via `T $FFxx` + the song-table byte-8 high bit (tickrate now 7-bit).
TODO - list of demo songs that MUST ship with Microtone:
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
@@ -3199,7 +3206,7 @@ Endianness: Little
Uint8 Number of voices
Uint16 Number of patterns (0 is invalid. pattern bin length = numPats * 8 bytes)
Uint8 Initial BPM (bias of -25. 0x00=25, 0xFF=280)
Uint8 Initial Tickrate (0 is invalid)
Uint8 BPM high bit (bit 7; 0x100=281, 0x1FE = 535; `T FFxx` cannot put 0x1FF into BPM) + initial Tickrate (0 is invalid; bit 0..6)
Uint16 Current Tuning base note (1..65533). A4 (western default) is 0x5C00. C9 (tracker default) is 0xA000. If zero, assume the tracker default value
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
Uint8 Flags for Global Behaviour (effect symbol '1')

View File

@@ -89,7 +89,7 @@ class AudioJSR223Delegate(private val vm: VM) {
fun startSampleUpload(playhead: Int) { getPlayhead(playhead)?.pcmUpload = true }
fun setBPM(playhead: Int, bpm: Int) { getPlayhead(playhead)?.bpm = (bpm - 25).and(255) + 25 }
fun setBPM(playhead: Int, bpm: Int) { getPlayhead(playhead)?.bpm = bpm.coerceIn(25, 535) }
fun getBPM(playhead: Int) = getPlayhead(playhead)?.bpm
fun setTickRate(playhead: Int, rate: Int) { getPlayhead(playhead)?.tickRate = rate and 255 }

View File

@@ -3370,14 +3370,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
EffectOp.OP_S -> applySEffect(ts, voice, vi, rawArg)
EffectOp.OP_T -> {
val hi = (rawArg ushr 8) and 0xFF
if (hi != 0) {
val tempoByte = hi
playhead.bpm = (tempoByte + 0x19).coerceIn(25, 280)
} else {
val low = rawArg and 0xFF
when (low and 0xF0) {
0x00 -> { voice.tempoSlideDir = -1; voice.tempoSlideAmount = low and 0x0F; voice.mem.tslide = low }
0x10 -> { voice.tempoSlideDir = +1; voice.tempoSlideAmount = low and 0x0F; voice.mem.tslide = low }
when {
hi == 0xFF -> {
// T $FFxx — extended set-tempo: BPM = $xx + $118 (280..535). See TAUD_NOTE_EFFECTS.md §T $FFxx.
playhead.bpm = ((rawArg and 0xFF) + 0x118).coerceIn(25, 535)
}
hi != 0 -> {
// T $xx00 — set-tempo: BPM = $xx + $19 (25..280).
playhead.bpm = (hi + 0x19).coerceIn(25, 535)
}
else -> {
val low = rawArg and 0xFF
when (low and 0xF0) {
0x00 -> { voice.tempoSlideDir = -1; voice.tempoSlideAmount = low and 0x0F; voice.mem.tslide = low }
0x10 -> { voice.tempoSlideDir = +1; voice.tempoSlideAmount = low and 0x0F; voice.mem.tslide = low }
}
}
}
}
@@ -4778,7 +4785,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var masterVolume: Int = 0,
var masterPan: Int = 128,
// var samplingRateMult: ThreeFiveMiniUfloat = ThreeFiveMiniUfloat(32),
var bpm: Int = 125, // BPM, derived from tempoByte + 25. Spec default $64 ⇒ 125 BPM.
var bpm: Int = 125, // BPM, derived from tempoByte + 25. Spec default $64 ⇒ 125 BPM. Range 25..535 (T $FFxx extends past 280).
var tickRate: Int = 6,
var pcmUpload: Boolean = false,
var patBank1: Int = 0,
@@ -4833,8 +4840,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
5 -> masterPan.toByte()
6 -> (isPcmMode.toInt(7) or isPlaying.toInt(4) or pcmQueueSizeIndex.and(15)).toByte()
7 -> initialGlobalFlags.toByte()
8 -> (bpm - 25).toByte()
9 -> tickRate.toByte()
8 -> ((bpm - 25) and 0xFF).toByte()
// bit 7 = BPM high bit (bit 8 of bpm-25, for the 281..535 range); bits 0..6 = tickRate. See terranmon.txt §Taud song table.
9 -> (((((bpm - 25) ushr 8) and 1) shl 7) or (tickRate and 0x7F)).toByte()
else -> throw InternalError("Bad offset $index")
}
@@ -4861,8 +4869,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
initialGlobalFlags = byte
updateTrackerGlobalBehaviour(initialGlobalFlags)
}
8 -> { bpm = byte + 25 }
9 -> { tickRate = byte }
8 -> { bpm = (((bpm - 25) and 0x100) or byte) + 25 } // low 8 bits of bpm-25, preserve high bit
9 -> { tickRate = byte and 0x7F; bpm = (((byte and 0x80) shl 1) or ((bpm - 25) and 0xFF)) + 25 } // bit 7 -> bpm high bit
else -> throw InternalError("Bad offset $index")
}
}