Compare commits

...

10 Commits

15 changed files with 753 additions and 349 deletions

View File

@@ -151,12 +151,13 @@ D's 16-bit argument encodes four mutually exclusive modes using the top nibble a
## E $xxxx — Pitch slide down by $xxxx
**Plain.** Lowers the channel's pitch by the argument per tick. The coarse argument has **two distinct interpretations** chosen by the song-table `f` flag (effect `1`, bit 1):
**Plain.** Lowers the channel's pitch by the argument per tick. The coarse argument has **three distinct interpretations** chosen by the song-table `ff` field (effect `1`, bits 1-2):
- **Linear mode** (`f` unset, default): the argument is a value in the 4096-TET pitch grid, subtracted directly from the stored pitch. `E $0155` ≈ one semitone per tick.
- **Amiga (cycle-based) mode** (`f` set): the argument is a **raw ProTracker/ST3 period unit count** — the same byte the original tracker stored on disk, *unscaled*. The engine converts the channel's stored 4096-TET pitch back to an Amiga period, subtracts the argument from that period directly, then converts the result back to 4096-TET. `E $0001` therefore corresponds to PT `201` and produces the characteristic non-linear pitch drift of ProTracker-style slides (lower pitches drift more slowly in semitone terms than higher pitches).
- **Linear mode** (`ff = 0`, default): the argument is a value in the 4096-TET pitch grid, subtracted directly from the stored pitch. `E $0155` ≈ one semitone per tick.
- **Amiga (cycle-based) mode** (`ff = 1`): the argument is a **raw ProTracker/ST3 period unit count** — the same byte the original tracker stored on disk, *unscaled*. The engine converts the channel's stored 4096-TET pitch back to an Amiga period, subtracts the argument from that period directly, then converts the result back to 4096-TET. `E $0001` therefore corresponds to PT `201` and produces the characteristic non-linear pitch drift of ProTracker-style slides (lower pitches drift more slowly in semitone terms than higher pitches).
- **Linear-frequency mode** (`ff = 2`): the argument is **Hz/tick** at A4 = 440 Hz / C4 ≈ 261.6256 Hz reference. The engine converts the stored pitch to audible frequency, subtracts the argument from that frequency, then converts the result back to 4096-TET. `E $0010` is the verbatim Monotone `210` (drop 16 Hz/tick); the slide produces a constant *frequency* delta per tick, so the perceived semitone drop is larger at low pitches and smaller at high pitches — exactly Monotone's tracker semantics.
Because Amiga period units fit in a single byte (PT/ST3 max value $FF), the coarse range in Amiga mode never approaches the $F000 fine-slide marker, so the same argument-format selector still distinguishes coarse from fine. **Fine slides (`E $Fxxx`) follow the same dual-interpretation rule as coarse**: in linear mode the low 12 bits are 4096-TET units; in Amiga mode they are raw tracker period units (the PT `E2x` / ST3 `EFx` or `EEx` digit), applied once per row at tick 0 in period space. A coarse slide uses the full value range; a fine slide applies only once per row.
Because Amiga period units (and Monotone Hz/tick) fit in a single byte (PT/ST3 max value $FF, MONOTONE max $3F), the coarse range never approaches the $F000 fine-slide marker, so the same argument-format selector still distinguishes coarse from fine across all three modes. **Fine slides (`E $Fxxx`) follow the same mode-selection rule as coarse**: linear mode reads the low 12 bits as 4096-TET units, Amiga mode reads them as raw tracker period units, and linear-frequency mode reads them as Hz. A coarse slide uses the full value range; a fine slide applies only once per row.
Coarse and fine modes are distinguished by the high nibble of the argument:
@@ -166,16 +167,19 @@ Coarse and fine modes are distinguished by the high nibble of the argument:
**Compatibility.** ST3 pitch slides operate on Amiga periods or linear slide units; Taud's storage depends on the song-table mode flag:
- **Linear-source ST3 song** (`linear_slides` set in S3M flags → Taud `f` bit clear):
- **Linear-source ST3 song** (`linear_slides` set in S3M flags → Taud `ff = 0`):
- ST3 `Exx` coarse (where `xx < $E0`) → Taud `E round($00xx × 64/3)` (1 ST3 coarse unit = 1/16 semitone = 64/3 ≈ 21.33 Taud units, rounded).
- ST3 `EFx` fine → Taud `E $F0 round(x × 16/3)` (1 ST3 fine unit = 1/64 semitone = 16/3 ≈ 5.33 Taud units, applied once per row).
- ST3 `EEx` extra-fine → Taud `E $F0 round(x × 16/3)` (same unit as fine, applied once per row).
- **Amiga-source ST3/PT song** (`linear_slides` clear → Taud `f` bit set):
- **Amiga-source ST3/PT song** (`linear_slides` clear → Taud `ff = 1`):
- ST3 `Exx` coarse / PT `2xx` → Taud `E $00xx` **verbatim**, with no `× 64/3` scaling. The engine reads the stored byte as Amiga period units and applies it in period space, recovering the original tracker's exact period-step count.
- ST3 `EFx` fine / `EEx` extra-fine / PT `E2x` → Taud `E $F00x` **verbatim** (raw period-unit nibble in the low 4 bits), with no `× 16/3` scaling. The engine performs the once-per-row fine slide in Amiga period space, mirroring the coarse arithmetic.
The Amiga-mode flag therefore controls **two** decoder behaviours simultaneously: (a) which numeric scale the converter should have used when emitting coarse arguments, and (b) which arithmetic the engine performs on those arguments per tick. Converters MUST set bit 1 (`f`) of the song-table flags byte whenever they emit raw period-unit coarse arguments, and MUST NOT mix the two scales within one Taud song.
- **MONOTONE source** (Taud `ff = 2`):
- MONOTONE `2xx` → Taud `E $00xx` **verbatim** (Hz/tick). The engine converts the stored pitch to frequency, subtracts the argument, and converts back. MONOTONE has no fine-slide form; converters never emit `E $Fxxx` for ff=2 sources.
The mode flag therefore controls **two** decoder behaviours simultaneously: (a) which numeric scale the converter should have used when emitting coarse arguments, and (b) which arithmetic the engine performs on those arguments per tick. Converters MUST set bits 1-2 (`ff`) of the song-table flags byte to match the units they emit, and MUST NOT mix scales within one Taud song.
Because E and F share memory in Taud (narrower than ST3's broad shared memory), an ST3 song that used `E00` or `F00` to recall a D, G, or Q argument will break on import; the converter must eagerly resolve ST3 recalls into explicit Taud arguments rather than relying on memory.
@@ -188,10 +192,11 @@ on row start:
else: memory_EF = raw
if (raw & $F000) == $F000: # fine, applied once on tick 0
mag = raw & $0FFF
if amiga_mode:
# mag is a raw tracker period-unit count; subtract pitch ⇒ add period.
if tone_mode == 1: # Amiga: mag is raw period units; pitch down ⇒ +period
pitch = amiga_slide_down(pitch, mag)
else:
elif tone_mode == 2: # linear-freq: mag is Hz/tick; pitch down ⇒ freq
pitch = linear_freq_slide(pitch, mag)
else: # linear: mag is 4096-TET units
pitch -= mag
mode_this_row = FINE
else: # coarse
@@ -200,12 +205,18 @@ on row start:
on tick > 0:
if mode_this_row == COARSE:
if amiga_mode:
if tone_mode == 1:
# slide_amount_this_row is a raw tracker period-unit count (no × 64/3 scaling).
# period = AMIGA_BASE_PERIOD × 2^((pitch C4) / 4096)
# period_new = period + slide_amount_this_row # E subtracts pitch ⇒ adds period
# pitch = C4 + 4096 × log2(AMIGA_BASE_PERIOD / period_new)
pitch = amiga_slide_down(pitch, slide_amount_this_row)
elif tone_mode == 2:
# slide_amount_this_row is Hz/tick (verbatim from MONOTONE 2xx).
# freq = LINEAR_FREQ_C4_HZ × 2^((pitch C4) / 4096)
# freq_new = max(freq slide_amount_this_row, 1)
# pitch = C4 + 4096 × log2(freq_new / LINEAR_FREQ_C4_HZ)
pitch = linear_freq_slide(pitch, slide_amount_this_row)
else:
pitch -= slide_amount_this_row
```
@@ -216,9 +227,9 @@ Glissando control (S $1x) snaps the output pitch to the nearest semitone after e
## F $xxxx — Pitch slide up by $xxxx
**Plain.** Raises the channel's pitch by the argument per tick, with the same mode-selection scheme as E. Coarse, fine, memory behaviour, and Amiga-mode handling are identical in form but inverted in direction. The same dual-interpretation rule applies to **both** coarse and fine arguments: 4096-TET units in linear mode, raw tracker period units in Amiga mode.
**Plain.** Raises the channel's pitch by the argument per tick, with the same mode-selection scheme as E. Coarse, fine, memory behaviour, and Amiga / linear-freq mode handling are identical in form but inverted in direction. The same triple-interpretation rule applies to **both** coarse and fine arguments: 4096-TET units in linear mode, raw tracker period units in Amiga mode, Hz/tick in linear-frequency mode.
**Compatibility.** Same as E. In linear-source songs, ST3 `Fxx` coarse converts using `round(x × 64/3)` and `FFx`/`FEx` fine/extra-fine use `round(x × 16/3)`. In Amiga-source songs (PT or S3M with `linear_slides` clear), both forms are stored verbatim: `Fxx` coarse → `F $00xx`, and `FFx`/`FEx` fine/extra-fine / PT `E1x``F $F00x`. F and E share one memory slot in Taud. Amiga-mode behaviour is controlled by the same `f` flag as E; under that flag, both coarse (per-tick) and fine (tick-0 only) F slides are applied in period space.
**Compatibility.** Same as E. In linear-source songs, ST3 `Fxx` coarse converts using `round(x × 64/3)` and `FFx`/`FEx` fine/extra-fine use `round(x × 16/3)`. In Amiga-source songs (PT or S3M with `linear_slides` clear), both forms are stored verbatim: `Fxx` coarse → `F $00xx`, and `FFx`/`FEx` fine/extra-fine / PT `E1x``F $F00x`. In MONOTONE-source songs (ff=2), `1xx``F $00xx` verbatim (Hz/tick); MONOTONE has no fine-slide form. F and E share one memory slot in Taud. Slide-mode behaviour is controlled by the same `ff` field as E; under any non-linear mode, both coarse (per-tick) and fine (tick-0 only) F slides are applied in the corresponding mode's space.
**Implementation.** As for E, but add instead of subtract. No upper pitch cap is defined by the effect itself, but the sample-rate conversion at the mixer will saturate well before arithmetic overflow at reasonable playing ranges.
@@ -226,26 +237,39 @@ Glissando control (S $1x) snaps the output pitch to the nearest semitone after e
## G $xxxx — Tone portamento with speed $xxxx
**Plain.** Slides the channel's current pitch toward the note specified in the same row, at $xxxx Taud units per tick (after tick 0), stopping when the target is reached. A row with G and a note does **not** re-trigger the sample — the note's pitch becomes the portamento target and the already-sounding sample continues at its current pitch.
**Plain.** Slides the channel's current pitch toward the note specified in the same row, at $xxxx units per tick (after tick 0), stopping when the target is reached. A row with G and a note does **not** re-trigger the sample — the note's pitch becomes the portamento target and the already-sounding sample continues at its current pitch.
**Compatibility.** ST3 `Gxx` uses an 8-bit value in period-table units; convert to Taud using the same `round(× 64/3)` scale as E/F coarse (1/16 semitone per ST3 slide unit). ST3 linear mode is the expected import source; Amiga-mode G sources should be treated as linear. G has its **own** memory slot in both ST3 and Taud, so conversion is straightforward and does not suffer the shared-memory problem of E/F.
The unit of `$xxxx` depends on the song-table tone mode (effect `1`, bits 1-2):
- `ff = 0` (linear) and `ff = 1` (Amiga): 4096-TET pitch units per tick. Amiga sources should be converted to linear units on G, since the original PT G slide already operated semi-linearly within a small range and the shared-memory pitfall of E/F does not apply here.
- `ff = 2` (linear-frequency): Hz/tick. The engine walks the channel's *frequency* toward the target note's frequency by `±$xxxx` Hz each non-first tick. This is MONOTONE's `3xx` behaviour verbatim (MTSRC/MT_PLAY.PAS:620-630).
**Compatibility.** ST3 `Gxx` uses an 8-bit value in period-table units; convert to Taud using the same `round(× 64/3)` scale as E/F coarse (1/16 semitone per ST3 slide unit). ST3 linear mode is the expected import source; Amiga-mode G sources should be treated as linear. MONOTONE `3xx` → Taud `G $00xx` verbatim under ff=2. G has its **own** memory slot in both ST3 and Taud, so conversion is straightforward and does not suffer the shared-memory problem of E/F.
**Implementation.**
```
on row parse:
if row has note and G effect:
target_pitch = period_for(note)
target_pitch = pitch_for(note)
# do NOT re-trigger sample
if arg != 0:
memory_G = arg
speed_this_row = memory_G
on tick > 0:
on tick > 0 (linear / Amiga modes):
if target_pitch set:
delta = sign(target_pitch - pitch) × speed_this_row
pitch += delta
if sign crossed target: pitch = target_pitch; target_pitch = None
on tick > 0 (linear-frequency mode):
if target_pitch set:
target_freq = LINEAR_FREQ_C4_HZ × 2^((target_pitch C4) / 4096)
cur_freq = cached freq (or recomputed from pitch on first use)
cur_freq += sign(target_freq cur_freq) × speed_this_row
if sign crossed target_freq: cur_freq = target_freq; target_pitch = None
pitch = C4 + 4096 × log2(cur_freq / LINEAR_FREQ_C4_HZ)
```
Glissando (S $1x) snaps the output frequency to the nearest semitone ($0155 step approximation) after each advance without changing the internal pitch counter; it affects only what the mixer sees.
@@ -956,14 +980,14 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
**Plain.** Sets mixer-wide behaviour flags. Available flags are:
0b 0000 0Ffp
0b 0000 0ffp
- p unset: Linear panning mode (tracker-accurate). Centre panning gets 3 dB boost. Default setting.
- p set: Equal-power panning mode. L/R amplitude is at 0.707 when centre-panned.
- Ff = 0: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
- Ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
- Ff = 2: Linear frequency mode. Pitch shift will behave against frequency number.
- ff = 0: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
- ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
- ff = 2: Linear-frequency tone mode (MONOTONE compat). **E, F, and G arguments are stored as Hz/tick** (a signed change in audible frequency per song tick), and the engine converts the channel's stored 4096-TET pitch back to a frequency, adds/subtracts the argument, then converts back to 4096-TET. Reference is fixed at 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, which matches MONOTONE's MT_PLAY.PAS `notesHz` table (A0 = 27.5 Hz, equal-temperament). Unlike Amiga mode, *all three* slide effects use the new arithmetic — Monotone's `1xx`, `2xx`, and `3xx` are all in Hz/tick (see MTSRC/MT_PLAY.PAS:606-630).
(Bit 2 is reserved. It previously held an `m` "fadeout-zero policy" flag intended to swap between IT and FT2 semantics for `storedFadeout = 0`. That flag was removed once both trackers were verified to share identical "stored 0 ⇒ no fade" semantics — see schismtracker `player/sndmix.c:330-342` and ft2-clone `src/ft2_replayer.c:1467-1481`. Fadeout scaling now lives in the converters; see "Volume Fadeout" below.)
@@ -1014,13 +1038,20 @@ There is no separate "use fadeout" flag — both extremes share the same field,
- L_gain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0
- R_gain = if (pan < 0x80) pan / 128.0 else 1.0
- Panning-equal-power:
- L_gain = cos(pi*x / 512.0)
- R_gain = sin(pi*x / 512.0)
- L_gain = cos(πx / 512.0)
- R_gain = sin(πx / 512.0)
- Amiga tone (both coarse and fine E/F pitch slides). The `slideArg` is a **raw tracker period-unit count** (no scaling), with sign matching linear mode (negative for E, positive for F). Coarse slides apply on every non-first tick; fine slides apply once on tick 0 — the per-step arithmetic is identical:
- AMIGA_BASE_PERIOD = 428.0 (period at the Taud reference pitch C4 for a standard 8363 Hz instrument, NTSC clock — identical to PT "C-2" period 428)
- period = AMIGA_BASE_PERIOD × 2^((noteVal C4) / 4096)
- period_new = period slideArg (E subtracts pitch ⇒ adds period; F adds pitch ⇒ subtracts period)
- noteVal_new = C4 + 4096 × log2(AMIGA_BASE_PERIOD / period_new)
- Linear-frequency tone (E / F / G in Hz/tick). The `slideArg` is a **signed Hz delta per tick** at the audible reference 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, identical to the value MONOTONE stores in its 1xx/2xx/3xx commands. Sign convention matches linear/Amiga modes (negative for E, positive for F):
- LINEAR_FREQ_C4_HZ = 261.625565... (12-TET, so A4 = 440 Hz exactly)
- freq = LINEAR_FREQ_C4_HZ × 2^((noteVal C4) / 4096)
- freq_new = max(freq + slideArg, 1.0)
- noteVal_new = C4 + 4096 × log2(freq_new / LINEAR_FREQ_C4_HZ)
- For tone portamento (G), `tonePortaSpeed` is also in Hz/tick: each tick walks `freq` toward `noteValToFreq(target)` by `±tonePortaSpeed` until the target frequency is reached.
- Like Amiga mode, the per-voice intermediate frequency is cached across ticks (no round-trip rounding) and reseeded on note trigger, S$2x finetune, fine slides, and the start of a fresh multi-tick coarse slide.
**Initialisation from the song table.** The same flags byte is stored in the song-table entry (see file format §Song Table). A Taud player should write this byte to MMIO playhead register 7 before starting playback; the mixer then applies it as the initial state on every reset, and subsequent in-pattern `1` effects may override it.

View File

@@ -556,8 +556,9 @@ let COLSIZE_TIMELINE_FULL = TIMELINE_COLSIZES[0]
let VOCSIZE_TIMELINE_FULL = Math.floor((SCRW - 3) / COLSIZE_TIMELINE_FULL)
const ORDERS_CMD_X = 5
const ORDERS_VOICE_X = 9
const VOCSIZE_ORDERS = Math.floor((SCRW - 10) / 4)
const ORDERS_VOICE_X = 12 // 1-indexed col where voice columns begin
const ORDERS_VOICE_COL_W = 4
const VOCSIZE_ORDERS = Math.floor((SCRW - (ORDERS_VOICE_X - 1)) / ORDERS_VOICE_COL_W)
const VIEW_TIMELINE = 0
const VIEW_CUES = 1
@@ -1407,13 +1408,11 @@ function drawOrdersHeader() {
}
}
function drawOrdersContents(wo) {
drawOrdersHeader()
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
const ci = ordersScroll + vr
function drawOrdersRowAt(ci) {
const vr = ci - ordersScroll
if (vr < 0 || vr >= PTNVIEW_HEIGHT) return
const y = PTNVIEW_OFFSET_Y + vr
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
const isSel = (ci === ordersCursor)
const isCur = playbackMode !== PLAYMODE_NONE && ci === cueIdx
const back = isSel ? (playbackMode !== PLAYMODE_NONE ? colPlayback : colHighlight)
@@ -1423,7 +1422,9 @@ function drawOrdersContents(wo) {
if (ci > maxCue) {
con.color_pair(colBackPtn, colBackPtn)
print(' '.repeat(SCRW - 1))
} else {
return
}
const cue = song.cues[ci]
con.color_pair(ci % 4 === 0 ? colRowNumEmph1 : colRowNum, back)
print(ci.hex03())
@@ -1445,8 +1446,67 @@ function drawOrdersContents(wo) {
con.color_pair(colBackPtn, back)
print(' ')
}
const endX = ORDERS_VOICE_X + VOCSIZE_ORDERS * 4
const endX = ORDERS_VOICE_X + VOCSIZE_ORDERS * ORDERS_VOICE_COL_W
if (endX <= SCRW) { con.color_pair(colBackPtn, back); print(' '.repeat(SCRW - endX)) }
}
function drawOrdersContents(wo) {
drawOrdersHeader()
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) drawOrdersRowAt(ordersScroll + vr)
}
// Redraw all rows of one voice column slot (0..VOCSIZE_ORDERS-1).
function drawOrdersVoiceColumnAt(slot) {
const v = ordersVoiceOff + slot
const x = ORDERS_VOICE_X + slot * ORDERS_VOICE_COL_W
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
const ci = ordersScroll + vr
const y = PTNVIEW_OFFSET_Y + vr
if (ci > maxCue) {
con.move(y, x)
con.color_pair(colBackPtn, colBackPtn)
print(' ')
continue
}
const isSel = (ci === ordersCursor)
const isCur = playbackMode !== PLAYMODE_NONE && ci === cueIdx
const back = isSel ? (playbackMode !== PLAYMODE_NONE ? colPlayback : colHighlight)
: (isCur ? colPlayback : colBackPtn)
const cue = song.cues[ci]
const ptn = v < song.numVoices ? cue.ptns[v] : CUE_EMPTY
const vBack = (isSel && ordersColCursor === v + 1) ? colPlayback : back
con.move(y, x)
con.color_pair(ptn === CUE_EMPTY ? colSep : colStatus, vBack)
print(ptn === CUE_EMPTY ? '---' : ptn.hex03())
con.color_pair(colBackPtn, back)
print(' ')
}
}
// Memory-shift the voice-column area horizontally by `dVoice` voice columns.
// Positive = scroll left (new column exposed on right); negative = scroll right.
// Touches body rows only; the header and Cmd column are untouched.
function shiftOrdersAreaHorizontal(dVoice) {
if (dVoice === 0) return
const absD = (dVoice < 0) ? -dVoice : dVoice
if (absD >= VOCSIZE_ORDERS) return // nothing to salvage
const stripWidth = (VOCSIZE_ORDERS - absD) * ORDERS_VOICE_COL_W
const srcX = ORDERS_VOICE_X + (dVoice > 0 ? absD * ORDERS_VOICE_COL_W : 0)
const dstX = ORDERS_VOICE_X + (dVoice > 0 ? 0 : absD * ORDERS_VOICE_COL_W)
const srcOff = srcX - 1
const dstOff = dstX - 1
for (let p = 0; p < 3; p++) {
const chanOff = TEXT_PLANES[p]
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
const rowBase = GPU_MEM - chanOff - (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
sys.memcpy(rowBase - srcOff, SCRATCH_PTR, stripWidth)
sys.memcpy(SCRATCH_PTR, rowBase - dstOff, stripWidth)
}
}
}
@@ -1592,26 +1652,53 @@ function ordersInput(wo, event) {
stopPlayback(); drawAlwaysOnElems(); return
}
if (keysym === '<UP>' || keysym === '<DOWN>' || keysym === '<PAGE_UP>' || keysym === '<PAGE_DOWN>') {
const oldCursor = ordersCursor
const oldScroll = ordersScroll
if (keysym === '<UP>') {
ordersCursor = Math.max(0, ordersCursor - moveDelta)
if (ordersCursor < ordersScroll) ordersScroll = ordersCursor
drawOrdersContents(wo)
} else if (keysym === '<DOWN>') {
ordersCursor = Math.min(maxCue, ordersCursor + moveDelta)
if (ordersCursor >= ordersScroll + PTNVIEW_HEIGHT) ordersScroll = Math.max(0, ordersCursor - PTNVIEW_HEIGHT + 1)
drawOrdersContents(wo)
} else if (keysym === '<PAGE_UP>') {
ordersCursor = Math.max(0, ordersCursor - PTNVIEW_HEIGHT)
ordersScroll = Math.max(0, ordersScroll - PTNVIEW_HEIGHT)
drawOrdersContents(wo)
} else if (keysym === '<PAGE_DOWN>') {
ordersCursor = Math.min(maxCue, ordersCursor + PTNVIEW_HEIGHT)
if (ordersCursor >= ordersScroll + PTNVIEW_HEIGHT) ordersScroll = Math.max(0, ordersCursor - PTNVIEW_HEIGHT + 1)
}
if (ordersCursor === oldCursor && ordersScroll === oldScroll) return
const dScroll = ordersScroll - oldScroll
if (dScroll === 0) {
drawOrdersRowAt(oldCursor)
drawOrdersRowAt(ordersCursor)
} else if (Math.abs(dScroll) >= PTNVIEW_HEIGHT) {
drawOrdersContents(wo)
} else {
shiftPatternArea(-dScroll)
if (dScroll > 0) for (let i = 0; i < dScroll; i++) drawOrdersRowAt(ordersScroll + PTNVIEW_HEIGHT - 1 - i)
else for (let i = 0; i < -dScroll; i++) drawOrdersRowAt(ordersScroll + i)
if (oldCursor >= ordersScroll && oldCursor < ordersScroll + PTNVIEW_HEIGHT) drawOrdersRowAt(oldCursor)
drawOrdersRowAt(ordersCursor)
}
} else if (keysym === '<LEFT>' || keysym === '<RIGHT>') {
const oldVoiceOff = ordersVoiceOff
const oldColCursor = ordersColCursor
ordersColCursor += (keysym === '<LEFT>') ? -1 : 1
clampOrdersHoriz()
drawOrdersContents(wo)
if (ordersColCursor === oldColCursor) return // hit edge
const dVoice = ordersVoiceOff - oldVoiceOff
if (dVoice !== 0) {
shiftOrdersAreaHorizontal(dVoice)
if (dVoice > 0) for (let i = 0; i < dVoice; i++) drawOrdersVoiceColumnAt(VOCSIZE_ORDERS - 1 - i)
else for (let i = 0; i < -dVoice; i++) drawOrdersVoiceColumnAt(i)
}
drawOrdersHeader()
drawOrdersRowAt(ordersCursor)
} else if (keyJustHit && keysym === '\n') {
cueIdx = ordersCursor
clampCue()
@@ -1704,7 +1791,7 @@ function simulateRowState(ptnDat, uptoRow) {
let bpm = audio.getBPM(PLAYHEAD) // best-effort starting tempo
let speed = audio.getTickRate(PLAYHEAD)
let globalVol = 0xFF
let panLaw = 0, amigaMode = false
let panLaw = 0, toneMode = 0 // toneMode: 0=linear, 1=Amiga, 2=linear-freq, 3=reserved
let memEF = 0, memG = 0
let memHU = { speed: 0, depth: 0 }
@@ -1739,8 +1826,10 @@ function simulateRowState(ptnDat, uptoRow) {
const isGRow = (effop === OP_G)
const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD)
// Track whether this row reloads the channel's default volume. Engine:
// triggerNote() resets channelVolume to 0x3F on fresh triggers, and an
// instrument byte on a tone-porta row also reloads default vol (matches
// triggerNote() resets channelVolume to 0x3F only when the row carries an
// instrument byte; a note-only retrigger (inst === 0) inherits the
// channel's existing volume. Tone-porta rows follow the same rule —
// an instrument byte on a porta row reloads default vol (matches
// schism csf_instrument_change inst_column branch).
let reloadDefaultVol = false
if (note !== 0xFFFF && note !== 0xFFFE) {
@@ -1755,17 +1844,21 @@ function simulateRowState(ptnDat, uptoRow) {
lastNote = note
pitchOff = 0
portaTarget = -1
reloadDefaultVol = true
if (inst !== 0) reloadDefaultVol = true
} else {
lastNote = note
pitchOff = 0
portaTarget = -1
reloadDefaultVol = true
if (inst !== 0) reloadDefaultVol = true
}
}
if (inst !== 0) lastInst = inst
// Default vol reset must happen before the volume column so a SET selector
// can still override on the same row (engine order: triggerNote → applyVolColumn).
// Pan: simulator does not track per-instrument default pan, so it never resets
// panAbs on trigger — this naturally matches the "stay at old value when inst === 0"
// half of the policy. The engine-side default-pan reload (gated on inst !== 0)
// is invisible here.
if (reloadDefaultVol) volAbs = 0x3F
// Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET).
@@ -1809,7 +1902,7 @@ function simulateRowState(ptnDat, uptoRow) {
if (effop === OP_1) {
const flags = (effarg >>> 8) & 0xFF
panLaw = flags & 1
amigaMode = (flags & 2) !== 0
toneMode = (flags >>> 1) & 3
// bit 2 reserved (was 'm' fadeout-zero policy; removed)
}
else if (effop === OP_8) {
@@ -1948,7 +2041,7 @@ function simulateRowState(ptnDat, uptoRow) {
return { lastNote, lastInst, volAbs, panAbs, pitchOff,
bpm, speed, globalVol,
panLaw, amigaMode,
panLaw, toneMode,
bitcrushDepth, bitcrushSkip, overdriveAmp, clipMode,
glissandoOn, vibratoWave, tremoloWave, panbrelloWave,
memEF, memG, memHU, memR, memY,
@@ -2153,15 +2246,14 @@ function drawProjectContents(wo) {
let flagstr = [
['Linear pan','EquNrg pan'],
['Linear pitch','Amiga pitch', 'Linear freq', ''], // TODO MONOTONE uses linear-freq pitch
['IT fade','FT2 fade'],
]
for (let i = 0; i < flagstr.length; i++) {
if (i != 1 && 1 != 3) {
if (i != 1 && 1 != 2) {
let s = flagstr[i][(mixerflag >>> i) & 1 != 0]
flagStrSelected.push(s)
}
}
let toneMode = (((mixerflag >>> 1) & 1)) | (((mixerflag >>> 3) & 1) << 1)
let toneMode = (mixerflag >>> 1) & 3
flagStrSelected.splice(1, 0, flagstr[1][toneMode])

View File

@@ -11,15 +11,36 @@ Tags:
<l> - align left
<o> - create virtual typesetting box. Left anchor: where the text cursor is. Right anchor: end of the line
&microtone; - replace with the brand string (<col 211>Micro</col><col 239>tone</col>)
&bul; - replace with bullet (\u00F9)
&ddot; - replace with double-dot (\u008419u)
&mdot; - replace with BIGDOT (\u00FA)
&updn; - up-down arrow (\u008418u)
&udlr; - four direction arrow (\u008428u\u008429u)
&keyoffsym; - pattern view key-off symbol (\u00A0\u00CD\u00CD\u00A1)
&notecutsym; - pattern view note-cut symbol (\u00A4\u00A4\u00A4\u00A4)
&demisharp;
&sharp;
&sesquisharp;
&doublesharp;
&triplesharp;
&quadsharp;
&demiflat;
&flat;
&sesquiflat;
&doubleflat;
&tripleflat;
&quadflat;
&accuptick;
&accdntick;
&accupup;
&accdndn;
&nbsp; - nonbreakable space (only meaningful for typesetters)
&shy; - soft hyphen (only meaningful for typesetters)
default alignment: fully justified
*/
@@ -29,7 +50,8 @@ let helpNotation = `<c>CONTROL NOTATON</c>
&bul;<b>a</b>&ddot;<b>z</b> : <O>alphabet without shift-in</O>
&bul;<b>A</b>&ddot;<b>Z</b> : <O>alphabet with shift-in</O>
&bul;<b>^q</b> : <O>hit 'q' with control key</O>
&bul;<b>^Q</b> : <O>hit 'q' with control and shift key</O>`
&bul;<b>^Q</b> : <O>hit 'q' with control and shift key</O>
`
////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -37,7 +59,8 @@ let helpJam = `<c>NOTE JAMMING</c>
Push keys to play or insert notes.
&nbsp;w&nbsp;e&nbsp;&nbsp;&nbsp;t&nbsp;y&nbsp;u
a&nbsp;s&nbsp;d&nbsp;f&nbsp;g&nbsp;h&nbsp;j&nbsp;k`
a&nbsp;s&nbsp;d&nbsp;f&nbsp;g&nbsp;h&nbsp;j&nbsp;k
`
////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -50,7 +73,8 @@ let helpCommon = `<c>COMMON CONTROLS</c>
&bul;<b>O</b> : <O>stop the playback</O>
&bul;<b>tab</b> : <O>switch forward a tab</O>
&bul;<b>TAB</b> : <O>switch backward a tab</O>
&bul;<b>q</b> : <O>close &microtone;</O>`
&bul;<b>q</b> : <O>close &microtone;</O>
`
////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -79,7 +103,12 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using
&bul;<b>&lt;</b>&mdot;<b>&gt;</b>: <O>(panning column) slide left/right</O>
&bul;<b>-</b>&mdot;<b>=</b> : <O>(vol/pan col) fine slide down/up</O>
&bul;<b>&udlr;</b> : <O>move the viewing cursor by columns and rows</O>
&bul;<b>pg&updn;</b> : <O>go to previous/next cue</O>`
&bul;<b>pg&updn;</b> : <O>go to previous/next cue</O>
<b>ACCIDENTALS</b>
&demisharp;&nbsp;&sharp;&nbsp;&doublesharp;&nbsp;&triplesharp;&nbsp;&quadsharp;&nbsp;&demiflat;&nbsp;&flat;&nbsp;&doubleflat;&nbsp;&tripleflat;&nbsp;&nbsp;&accuptick;&nbsp;&nbsp;&accupup;&nbsp;&nbsp;&accdntick;&nbsp;&nbsp;&accdndn;
<b>C&nbsp;&nbsp;c&nbsp;&nbsp;x&nbsp;&nbsp;cx&nbsp;xx&nbsp;B&nbsp;&nbsp;b&nbsp;&nbsp;bb&nbsp;bbb&nbsp;^&nbsp;&nbsp;^^&nbsp;v&nbsp;&nbsp;vv</b>
`
////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -114,6 +143,22 @@ function expandEntities(s) {
.replaceAll('&shy;', '')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&demisharp;', '\u0080\u0081')
.replaceAll('&sharp;', '\u0082\u0083')
.replaceAll('&sesquisharp;', '\u0084132u\u0085')
.replaceAll('&doublesharp;', '\u0086\u0087')
.replaceAll('&triplesharp;', '\u0088\u0089')
.replaceAll('&quadsharp;', '\u008A\u008B')
.replaceAll('&demiflat;', '\u008C\u008D')
.replaceAll('&flat;', '\u008E\u008F')
.replaceAll('&sesquiflat;', '\u0090\u0091')
.replaceAll('&doubleflat;', '\u0092\u0093')
.replaceAll('&tripleflat;', '\u0094\u0095')
.replaceAll('&quadflat;', '\u0096\u0097')
.replaceAll('&accuptick;', '\u009A')
.replaceAll('&accdntick;', '\u009B')
.replaceAll('&accupup;', '\u009C')
.replaceAll('&accdndn;', '\u009D')
}
// Tokenise a (post-entity-expansion) line. Returns an array of:
@@ -324,13 +369,13 @@ function typeset(text, customWidth) {
}
let helpMessages = [ // index: taut.js PANEL_NAMES
[helpJam, helpTimeline, helpCommon, helpNotation].join('\n\n'),
[helpCommon, helpNotation].join('\n\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder
[helpJam, helpTimeline, helpCommon, helpNotation].join('\n'),
[helpCommon, helpNotation].join('\n'), // placeholder
[helpCommon, helpNotation].join('\n'), // placeholder
[helpCommon, helpNotation].join('\n'), // placeholder
[helpCommon, helpNotation].join('\n'), // placeholder
[helpCommon, helpNotation].join('\n'), // placeholder
[helpCommon, helpNotation].join('\n'), // placeholder
]
help.MSG_BY_TABS = helpMessages.map(it => typeset(it))

View File

@@ -53,7 +53,7 @@ from taud_common import (
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
J_SEMI_TABLE,
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
normalise_sample, encode_song_entry,
normalise_sample, encode_song_entry, nearest_minifloat,
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
)
@@ -117,60 +117,6 @@ IT_MEM_EFFECTS = frozenset({
SIGNATURE = b'it2taud/TSVM ' # 14 bytes
# ThreeFiveMiniUfloat LUT — 256 entries, seconds 0.0..126.0 (must match Kotlin)
_MINUFLOAT_LUT = [
0.0, 0.03125, 0.0625, 0.09375, 0.125, 0.15625, 0.1875, 0.21875,
0.25, 0.28125, 0.3125, 0.34375, 0.375, 0.40625, 0.4375, 0.46875,
0.5, 0.53125, 0.5625, 0.59375, 0.625, 0.65625, 0.6875, 0.71875,
0.75, 0.78125, 0.8125, 0.84375, 0.875, 0.90625, 0.9375, 0.96875,
1.0, 1.03125, 1.0625, 1.09375, 1.125, 1.15625, 1.1875, 1.21875,
1.25, 1.28125, 1.3125, 1.34375, 1.375, 1.40625, 1.4375, 1.46875,
1.5, 1.53125, 1.5625, 1.59375, 1.625, 1.65625, 1.6875, 1.71875,
1.75, 1.78125, 1.8125, 1.84375, 1.875, 1.90625, 1.9375, 1.96875,
2.0, 2.0625, 2.125, 2.1875, 2.25, 2.3125, 2.375, 2.4375,
2.5, 2.5625, 2.625, 2.6875, 2.75, 2.8125, 2.875, 2.9375,
3.0, 3.0625, 3.125, 3.1875, 3.25, 3.3125, 3.375, 3.4375,
3.5, 3.5625, 3.625, 3.6875, 3.75, 3.8125, 3.875, 3.9375,
4.0, 4.125, 4.25, 4.375, 4.5, 4.625, 4.75, 4.875,
5.0, 5.125, 5.25, 5.375, 5.5, 5.625, 5.75, 5.875,
6.0, 6.125, 6.25, 6.375, 6.5, 6.625, 6.75, 6.875,
7.0, 7.125, 7.25, 7.375, 7.5, 7.625, 7.75, 7.875,
8.0, 8.25, 8.5, 8.75, 9.0, 9.25, 9.5, 9.75,
10.0, 10.25, 10.5, 10.75, 11.0, 11.25, 11.5, 11.75,
12.0, 12.25, 12.5, 12.75, 13.0, 13.25, 13.5, 13.75,
14.0, 14.25, 14.5, 14.75, 15.0, 15.25, 15.5, 15.75,
16.0, 16.5, 17.0, 17.5, 18.0, 18.5, 19.0, 19.5,
20.0, 20.5, 21.0, 21.5, 22.0, 22.5, 23.0, 23.5,
24.0, 24.5, 25.0, 25.5, 26.0, 26.5, 27.0, 27.5,
28.0, 28.5, 29.0, 29.5, 30.0, 30.5, 31.0, 31.5,
32.0, 33.0, 34.0, 35.0, 36.0, 37.0, 38.0, 39.0,
40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0,
48.0, 49.0, 50.0, 51.0, 52.0, 53.0, 54.0, 55.0,
56.0, 57.0, 58.0, 59.0, 60.0, 61.0, 62.0, 63.0,
64.0, 66.0, 68.0, 70.0, 72.0, 74.0, 76.0, 78.0,
80.0, 82.0, 84.0, 86.0, 88.0, 90.0, 92.0, 94.0,
96.0, 98.0, 100.0, 102.0, 104.0, 106.0, 108.0, 110.0,
112.0, 114.0, 116.0, 118.0, 120.0, 122.0, 124.0, 126.0,
]
def _nearest_minifloat(sec: float) -> int:
"""Return ThreeFiveMiniUfloat index (0-255) for the nearest representable seconds value."""
if sec <= 0.0:
return 0
if sec >= 126.0:
return 255
lo, hi = 0, len(_MINUFLOAT_LUT) - 1
while lo < hi:
mid = (lo + hi) // 2
if _MINUFLOAT_LUT[mid] < sec:
lo = mid + 1
else:
hi = mid
# lo is first index where LUT[lo] >= sec; check lo-1 for nearest
if lo > 0 and abs(_MINUFLOAT_LUT[lo - 1] - sec) <= abs(_MINUFLOAT_LUT[lo] - sec):
return lo - 1
return lo
# ── IT header parser ──────────────────────────────────────────────────────────
@@ -626,7 +572,7 @@ def _parse_it_envelope(data: bytes, env_ptr: int, kind: str,
if k < len(nodes) - 1:
_, next_tick = nodes[k + 1]
delta_sec = max(0.0, (next_tick - tick) / ticks_per_sec)
mf_idx = _nearest_minifloat(delta_sec)
mf_idx = nearest_minifloat(delta_sec)
else:
mf_idx = 0
else:

View File

@@ -14,14 +14,15 @@ This converter:
- splits each Monotone pattern (64 × N voices) into N Taud patterns
- converts notes (A0=27.5 Hz chromatic) to Taud 4096-TET centred on C4
- maps the 8 Monotone effects to their closest Taud equivalents
- approximates Hz/tick slides (1xx/2xx/3xx) at an A4=440 Hz reference
- emits Hz/tick slides (1xx/2xx/3xx) verbatim and turns on Taud's
linear-frequency tone mode (Effect 1 ff=2) so the engine interprets
E/F/G arguments as Hz at A4=440 Hz reference — no scaling drift
Limits: numVoices ≤ 20, numPatterns × numVoices ≤ 4095.
"""
import argparse
import gzip
import math
import struct
import sys
@@ -51,11 +52,13 @@ MON_EFFECT_LETTERS = ['0', '1', '2', '3', '4', 'B', 'D', 'F']
# Note value 1 = A0; C4 sits at value 40 (A0 + 39 semitones).
MON_NOTE_C4 = 40
# Slides are linear-in-Hz on Monotone but linear-in-4096-TET on Taud. Take A4
# (440 Hz) as the reference: 1 Hz at A4 ≈ 12/(440·ln 2) semitones, scaled by
# 4096/12 to Taud units. ≈ 161.0. Off by ±1 octave at the extremes; documented
# in the script header.
SLIDE_UNITS_PER_HZ = 12.0 / (440.0 * math.log(2.0)) * 4096.0 / 12.0
# Global behaviour flags byte (Taud Effect 1 / song-table byte 15):
# bit 0 (p) : pan law — leave 0 (linear) for tracker accuracy
# bits 1-2 (ff): tone mode — 2 = linear-frequency (Hz/tick)
# Selecting ff=2 makes the engine interpret 1xx/2xx/3xx slide arguments in
# audible Hz at the A4=440 Hz reference, matching Monotone's MT_PLAY.PAS
# `Frequency:=Frequency±parm1` arithmetic (see MTSRC/MT_PLAY.PAS:606-630).
GLOBAL_FLAGS_LINEAR_FREQ = 0b100
# ── Taud container ───────────────────────────────────────────────────────────
@@ -132,7 +135,7 @@ def mon_note_to_taud(mon_note: int) -> int:
if mon_note == 0:
return NOTE_NOP
if mon_note == 0x7F:
return NOTE_KEYOFF
return NOTE_CUT
val = TAUD_C4 + round((mon_note - MON_NOTE_C4) * 4096.0 / 12.0)
return max(1, min(0xFFFD, val))
@@ -150,17 +153,14 @@ def encode_effect(eff_code: int, data: int) -> tuple:
y = data & 0x7
return (TOP_J, (J_SEMI_TABLE[x] << 8) | J_SEMI_TABLE[y])
if letter == '1': # slide up Hz/tick → Taud F
arg = round(data * SLIDE_UNITS_PER_HZ) & 0xFFFF
return (TOP_F, arg)
if letter == '1': # slide up Hz/tick → Taud F (Hz/tick under ff=2)
return (TOP_F, data & 0xFFFF)
if letter == '2': # slide down Hz/tick → Taud E
arg = round(data * SLIDE_UNITS_PER_HZ) & 0xFFFF
return (TOP_E, arg)
if letter == '2': # slide down Hz/tick → Taud E (Hz/tick under ff=2)
return (TOP_E, data & 0xFFFF)
if letter == '3': # tone porta Hz/tick → Taud G
arg = round(data * SLIDE_UNITS_PER_HZ) & 0xFFFF
return (TOP_G, arg)
if letter == '3': # tone porta Hz/tick → Taud G (Hz/tick under ff=2)
return (TOP_G, data & 0xFFFF)
if letter == '4': # vibrato xy → Taud H
x = (data >> 3) & 0x7 # speed (3 bits)
@@ -366,10 +366,11 @@ def assemble_taud(mon: dict) -> bytes:
# BPM 150 + ticks=mon_speed → row rate = 60/mon_speed (matches Monotone).
bpm_stored = 150 - 24
# bit 2 reserved (was 'm' fadeout-zero policy; removed). Monotone has no instrument-level
# Linear-frequency tone mode (ff=2) so 1xx/2xx/3xx Hz/tick semantics survive verbatim;
# pan law stays 0 (linear), bit 2 stays 0 (reserved). Monotone has no instrument-level
# fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire on
# sample-end or pattern note-cut instead.
flags_byte = 0x00
flags_byte = GLOBAL_FLAGS_LINEAR_FREQ
song_table = encode_song_entry(
song_offset = song_offset,

View File

@@ -226,7 +226,7 @@ def encode_note(s3m_note: int) -> int:
if s3m_note == S3M_NOTE_EMPTY:
return NOTE_NOP
if s3m_note == S3M_NOTE_OFF:
return NOTE_KEYOFF
return NOTE_CUT
octave = (s3m_note >> 4) & 0xF
pitch = s3m_note & 0xF
if pitch > 11:

View File

@@ -27,6 +27,9 @@ def vprint(*a, **kw) -> None:
# ── Taud container constants ─────────────────────────────────────────────────
TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
# Bumped 2026-05-07: envelope offset minifloat rebiased (smallest step 1/256 s,
# max 15.75 s; previously 1/32 s, max 126 s). v1 .taud envelopes will play with
# the wrong tempo on a v2 engine — re-convert from source.
TAUD_VERSION = 1
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14)
TAUD_SONG_ENTRY = 32 # full spec entry (see encode_song_entry)
@@ -103,6 +106,69 @@ EFF_U = 21; EFF_V = 22; EFF_W = 23; EFF_X = 24; EFF_Y = 25
EFF_Z = 26
# ── Envelope offset minifloat ────────────────────────────────────────────────
#
# Mirror of tsvm_core/.../ThreeFiveMinifloat.kt — used by every *2taud
# converter that emits envelope nodes. 3.5 unsigned minifloat (3-bit exponent
# + 5-bit mantissa) rebiased so the smallest non-zero step is 1/256 s ≈ 3.91
# ms and the maximum is 15.75 s. The previous bias (1/32-step, max 126 s)
# under-resolved single-tick deltas at typical tracker BPMs. Every value here
# is the original LUT divided by 8.
MINUFLOAT_LUT = (
0.0, 0.00390625, 0.0078125, 0.01171875, 0.015625, 0.01953125, 0.0234375, 0.02734375,
0.03125, 0.03515625, 0.0390625, 0.04296875, 0.046875, 0.05078125, 0.0546875, 0.05859375,
0.0625, 0.06640625, 0.0703125, 0.07421875, 0.078125, 0.08203125, 0.0859375, 0.08984375,
0.09375, 0.09765625, 0.1015625, 0.10546875, 0.109375, 0.11328125, 0.1171875, 0.12109375,
0.125, 0.12890625, 0.1328125, 0.13671875, 0.140625, 0.14453125, 0.1484375, 0.15234375,
0.15625, 0.16015625, 0.1640625, 0.16796875, 0.171875, 0.17578125, 0.1796875, 0.18359375,
0.1875, 0.19140625, 0.1953125, 0.19921875, 0.203125, 0.20703125, 0.2109375, 0.21484375,
0.21875, 0.22265625, 0.2265625, 0.23046875, 0.234375, 0.23828125, 0.2421875, 0.24609375,
0.25, 0.2578125, 0.265625, 0.2734375, 0.28125, 0.2890625, 0.296875, 0.3046875,
0.3125, 0.3203125, 0.328125, 0.3359375, 0.34375, 0.3515625, 0.359375, 0.3671875,
0.375, 0.3828125, 0.390625, 0.3984375, 0.40625, 0.4140625, 0.421875, 0.4296875,
0.4375, 0.4453125, 0.453125, 0.4609375, 0.46875, 0.4765625, 0.484375, 0.4921875,
0.5, 0.515625, 0.53125, 0.546875, 0.5625, 0.578125, 0.59375, 0.609375,
0.625, 0.640625, 0.65625, 0.671875, 0.6875, 0.703125, 0.71875, 0.734375,
0.75, 0.765625, 0.78125, 0.796875, 0.8125, 0.828125, 0.84375, 0.859375,
0.875, 0.890625, 0.90625, 0.921875, 0.9375, 0.953125, 0.96875, 0.984375,
1.0, 1.03125, 1.0625, 1.09375, 1.125, 1.15625, 1.1875, 1.21875,
1.25, 1.28125, 1.3125, 1.34375, 1.375, 1.40625, 1.4375, 1.46875,
1.5, 1.53125, 1.5625, 1.59375, 1.625, 1.65625, 1.6875, 1.71875,
1.75, 1.78125, 1.8125, 1.84375, 1.875, 1.90625, 1.9375, 1.96875,
2.0, 2.0625, 2.125, 2.1875, 2.25, 2.3125, 2.375, 2.4375,
2.5, 2.5625, 2.625, 2.6875, 2.75, 2.8125, 2.875, 2.9375,
3.0, 3.0625, 3.125, 3.1875, 3.25, 3.3125, 3.375, 3.4375,
3.5, 3.5625, 3.625, 3.6875, 3.75, 3.8125, 3.875, 3.9375,
4.0, 4.125, 4.25, 4.375, 4.5, 4.625, 4.75, 4.875,
5.0, 5.125, 5.25, 5.375, 5.5, 5.625, 5.75, 5.875,
6.0, 6.125, 6.25, 6.375, 6.5, 6.625, 6.75, 6.875,
7.0, 7.125, 7.25, 7.375, 7.5, 7.625, 7.75, 7.875,
8.0, 8.25, 8.5, 8.75, 9.0, 9.25, 9.5, 9.75,
10.0, 10.25, 10.5, 10.75, 11.0, 11.25, 11.5, 11.75,
12.0, 12.25, 12.5, 12.75, 13.0, 13.25, 13.5, 13.75,
14.0, 14.25, 14.5, 14.75, 15.0, 15.25, 15.5, 15.75,
)
def nearest_minifloat(sec: float) -> int:
"""Return the ThreeFiveMiniUfloat index (0..255) for the LUT entry nearest to `sec`."""
if sec <= 0.0:
return 0
if sec >= MINUFLOAT_LUT[-1]:
return 255
lo, hi = 0, len(MINUFLOAT_LUT) - 1
while lo < hi:
mid = (lo + hi) // 2
if MINUFLOAT_LUT[mid] < sec:
lo = mid + 1
else:
hi = mid
if lo > 0 and abs(MINUFLOAT_LUT[lo - 1] - sec) < abs(MINUFLOAT_LUT[lo] - sec):
return lo - 1
return lo
# ── Helpers ──────────────────────────────────────────────────────────────────
def d_arg_to_col(arg: int):

View File

@@ -2137,13 +2137,13 @@ from source.
(bits 14..15 reserved)
21 Bit16x25 Volume envelopes
Byte 1: Volume (00..3F)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
71 Bit16x25 Panning envelopes
Byte 1: Pan (00..FF)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
121 Bit16x25 Pitch/Filter envelopes
Byte 1: Value (00..FF)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
171 Uint8 Instrument Global Volume (0..255)
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
- ImpulseTracker also has samplewise default volume (0..64) and samplewise global volume (0..64), and they must be taken into account because Taud has no samplewise config, following the ImpulseTracker spec
@@ -2327,9 +2327,29 @@ TODO:
2026-05-06 .taud files predate the P bit and need re-conversion
for pan/pf envelopes to play. See byte 15/17/19 spec for the LOOP
word bit layout.
[ ] implement extended tone mode (MONOTONE compat)
[ ] pattern loops stops working after processed once (test with slumberjack.xm)
[ ] milkytracker-style volume ramping (on sample-end only)
[x] slumberjack.xm: E6x commands are not processed
[x] implement linear-freq tone mode (MONOTONE compat)
Resolution: ff=2 in song-table flags byte (was reserved). E / F / G
arguments are interpreted as Hz/tick at A4 = 440 Hz / C4 ≈ 261.6256 Hz
reference, exactly matching MONOTONE's MT_PLAY.PAS `Frequency`
arithmetic (MTSRC/MT_PLAY.PAS:606-630). Per-voice `linearFreq` cache
in AudioAdapter.kt preserves sub-noteVal precision across ticks; the
Voice cache reseeds on note trigger, fine slides, S$2x finetune, and
the start of a fresh multi-tick coarse slide. mon2taud.py now emits
Hz values verbatim (no SLIDE_UNITS_PER_HZ scaling) and sets the
linear-freq flag in the song-table flags byte. Spec details in
TAUD_NOTE_EFFECTS.md §1, §E, §F, §G.
[x] milkytracker-style volume ramping (on sample-end only)
[x] make Cues tab move faster
Resolution: Cues panel now uses memory-shift (`shiftOrdersAreaHorizontal`)
for LEFT/RIGHT and `shiftPatternArea` for UP/DOWN, plus per-row
(`drawOrdersRowAt`) and per-column (`drawOrdersVoiceColumnAt`) helpers,
replacing the full-panel redraw on every keystroke.
[x] volume and panning policy to match note effect policy: when note is "retriggerred" (note command with instrument specified), the volume/pan must take default value; if not (note command with instrument 0) the volume/pan must stay at the old value. Make both audio engine and taut.js simulator changes.
[ ] xm volume column commands (+x, -x, Dx, Lx, Mx, Px, Rx, Sx, Ux, Vx) are completely ignored
[x] theday.xm order 0x28, channel 6..8 has 'note trigger with inst 1 but no volume -> key-off -> set-volume to 0x20 -> key-off -> set-volume to 0x10 -> key-off -> ...' and it sounds like gating: key-off silences the output, set-volume turns on the output again; notably, this behaviour only works when volume envelope is turned off (any fadeouts progress normally). FT2's keyOff (ft2_replayer.c:411-435) zeroes realVol/outVol when the volume envelope is disabled — IT/Schism does not, and Taud's engine follows IT semantics (no fade when fadeStep == 0). Resolved in xm2taud.py: a pre-pass tracks per-channel bound XM instrument across the order-list walk, and any key-off cell whose bound instrument has vol_env_type & XM_ENV_ON == 0 is paired with `SEL_SET vol=0` in the same row. A subsequent vol-col SET on the channel restores audibility — exactly mirroring FT2's outVol/realVol gate without diverging the engine. Engine semantics stay IT-pure.
[ ] remove panning mode selection and replace global panning rule to 3 dB rule (not the equal energy)
[ ] FT2/MOD double effects (5xx, 6xx) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py)
Play Data: play data are series of tracker-like instructions, visualised as:
@@ -2430,9 +2450,9 @@ Play Head Flags
Byte 2
- PCM Mode: Write non-zero value to start uploading; always 0 when read
- Tracker Mode: Global mixer flags. Maps directly to Taud effect symbol '1'
0b 0000 00fp
0b 0000 0ffp
p: panning mode (0: linear, 1: equal-power)
f: pitchshift mode (0: tone-linear, 1: Amiga)
ff: pitchshift mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
Tracker command may change the mixer state, but the changes WILL NOT BE REFLECTED BACK.
Starting a new song will use whatever written to this register. In other words, changes
made by songs will not persist.
@@ -2464,40 +2484,45 @@ Play Head Flags
65536..131071 RW: PCM Sample buffer
Table of 3.5 Minifloat values (CSV)
Table of 3.5 Minifloat values (CSV).
Rebiased 2026-05-07 so the smallest non-zero step is 1/256 s and the maximum
is 15.75 s — every cell is the original LUT value divided by 8. Chosen for
tracker envelopes: a single song tick (≈ 8.9 ms at BPM 280, ≈ 41.7 ms at
BPM 24) now lands within ±17 % of an LUT entry across the whole supported
BPM range; the previous bias was ±150 % at common BPMs.
,000,001,010,011,100,101,110,111,MSB
00000,0,1,2,4,8,16,32,64
00001,0.03125,1.03125,2.0625,4.125,8.25,16.5,33,66
00010,0.0625,1.0625,2.125,4.25,8.5,17,34,68
00011,0.09375,1.09375,2.1875,4.375,8.75,17.5,35,70
00100,0.125,1.125,2.25,4.5,9,18,36,72
00101,0.15625,1.15625,2.3125,4.625,9.25,18.5,37,74
00110,0.1875,1.1875,2.375,4.75,9.5,19,38,76
00111,0.21875,1.21875,2.4375,4.875,9.75,19.5,39,78
01000,0.25,1.25,2.5,5,10,20,40,80
01001,0.28125,1.28125,2.5625,5.125,10.25,20.5,41,82
01010,0.3125,1.3125,2.625,5.25,10.5,21,42,84
01011,0.34375,1.34375,2.6875,5.375,10.75,21.5,43,86
01100,0.375,1.375,2.75,5.5,11,22,44,88
01101,0.40625,1.40625,2.8125,5.625,11.25,22.5,45,90
01110,0.4375,1.4375,2.875,5.75,11.5,23,46,92
01111,0.46875,1.46875,2.9375,5.875,11.75,23.5,47,94
10000,0.5,1.5,3,6,12,24,48,96
10001,0.53125,1.53125,3.0625,6.125,12.25,24.5,49,98
10010,0.5625,1.5625,3.125,6.25,12.5,25,50,100
10011,0.59375,1.59375,3.1875,6.375,12.75,25.5,51,102
10100,0.625,1.625,3.25,6.5,13,26,52,104
10101,0.65625,1.65625,3.3125,6.625,13.25,26.5,53,106
10110,0.6875,1.6875,3.375,6.75,13.5,27,54,108
10111,0.71875,1.71875,3.4375,6.875,13.75,27.5,55,110
11000,0.75,1.75,3.5,7,14,28,56,112
11001,0.78125,1.78125,3.5625,7.125,14.25,28.5,57,114
11010,0.8125,1.8125,3.625,7.25,14.5,29,58,116
11011,0.84375,1.84375,3.6875,7.375,14.75,29.5,59,118
11100,0.875,1.875,3.75,7.5,15,30,60,120
11101,0.90625,1.90625,3.8125,7.625,15.25,30.5,61,122
11110,0.9375,1.9375,3.875,7.75,15.5,31,62,124
11111,0.96875,1.96875,3.9375,7.875,15.75,31.5,63,126
00000,0,0.125,0.25,0.5,1,2,4,8
00001,0.00390625,0.12890625,0.2578125,0.515625,1.03125,2.0625,4.125,8.25
00010,0.0078125,0.1328125,0.265625,0.53125,1.0625,2.125,4.25,8.5
00011,0.01171875,0.13671875,0.2734375,0.546875,1.09375,2.1875,4.375,8.75
00100,0.015625,0.140625,0.28125,0.5625,1.125,2.25,4.5,9
00101,0.01953125,0.14453125,0.2890625,0.578125,1.15625,2.3125,4.625,9.25
00110,0.0234375,0.1484375,0.296875,0.59375,1.1875,2.375,4.75,9.5
00111,0.02734375,0.15234375,0.3046875,0.609375,1.21875,2.4375,4.875,9.75
01000,0.03125,0.15625,0.3125,0.625,1.25,2.5,5,10
01001,0.03515625,0.16015625,0.3203125,0.640625,1.28125,2.5625,5.125,10.25
01010,0.0390625,0.1640625,0.328125,0.65625,1.3125,2.625,5.25,10.5
01011,0.04296875,0.16796875,0.3359375,0.671875,1.34375,2.6875,5.375,10.75
01100,0.046875,0.171875,0.34375,0.6875,1.375,2.75,5.5,11
01101,0.05078125,0.17578125,0.3515625,0.703125,1.40625,2.8125,5.625,11.25
01110,0.0546875,0.1796875,0.359375,0.71875,1.4375,2.875,5.75,11.5
01111,0.05859375,0.18359375,0.3671875,0.734375,1.46875,2.9375,5.875,11.75
10000,0.0625,0.1875,0.375,0.75,1.5,3,6,12
10001,0.06640625,0.19140625,0.3828125,0.765625,1.53125,3.0625,6.125,12.25
10010,0.0703125,0.1953125,0.390625,0.78125,1.5625,3.125,6.25,12.5
10011,0.07421875,0.19921875,0.3984375,0.796875,1.59375,3.1875,6.375,12.75
10100,0.078125,0.203125,0.40625,0.8125,1.625,3.25,6.5,13
10101,0.08203125,0.20703125,0.4140625,0.828125,1.65625,3.3125,6.625,13.25
10110,0.0859375,0.2109375,0.421875,0.84375,1.6875,3.375,6.75,13.5
10111,0.08984375,0.21484375,0.4296875,0.859375,1.71875,3.4375,6.875,13.75
11000,0.09375,0.21875,0.4375,0.875,1.75,3.5,7,14
11001,0.09765625,0.22265625,0.4453125,0.890625,1.78125,3.5625,7.125,14.25
11010,0.1015625,0.2265625,0.453125,0.90625,1.8125,3.625,7.25,14.5
11011,0.10546875,0.23046875,0.4609375,0.921875,1.84375,3.6875,7.375,14.75
11100,0.109375,0.234375,0.46875,0.9375,1.875,3.75,7.5,15
11101,0.11328125,0.23828125,0.4765625,0.953125,1.90625,3.8125,7.625,15.25
11110,0.1171875,0.2421875,0.484375,0.96875,1.9375,3.875,7.75,15.5
11111,0.12109375,0.24609375,0.4921875,0.984375,1.96875,3.9375,7.875,15.75
LSB
## Tracker Note Effects
@@ -2546,9 +2571,9 @@ Endianness: Little
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')
0b 0000 0Ffp
p: panning law (0=linear, 1=equal-power)
Ff: tone mode (0=linear pitch slides, 1=Amiga period slides, 2=linear-frequency slides, 3=reserved)
0b 0000 0ffp
p: panning law (0: linear, 1: equal-power)
ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
(bit 2 reserved — was 'm' fadeout-zero policy, removed; fadeout
scaling now lives entirely in the converter — see byte 172/173
of the instrument record for engine semantics)

View File

@@ -136,7 +136,7 @@ class AudioJSR223Delegate(private val vm: VM) {
ph.initialGlobalFlags = flags
ph.trackerState?.let { ts ->
ts.panLaw = flags and 1
ts.amigaMode = (flags and 2) != 0
ts.toneMode = (flags ushr 1) and 3
// bit 2 reserved (was 'm' fadeout-zero policy; removed — see AudioAdapter.kt
// and TAUD_NOTE_EFFECTS.md §1 "Volume Fadeout")
}

View File

@@ -1,7 +1,15 @@
package net.torvald.tsvm
/**
* Created by minjaesong on 2022-12-30.
* 3.5 unsigned minifloat (3-bit exponent + 5-bit mantissa), scaled so the
* smallest non-zero step is 1/256 s ≈ 3.91 ms and the maximum representable
* value is 15.75 s. Used for Taud envelope point offsets — the resolution at
* the low end is fine enough to resolve individual tracker ticks at every
* supported BPM (worst case ±17 % at BPM 250+, vs. ±150 % under the original
* 1/32-step bias).
*
* Created by minjaesong on 2022-12-30. Rebiased for tracker tick resolution
* on 2026-05-07 (entire LUT divided by 8).
*/
@JvmInline
value class ThreeFiveMiniUfloat(val index: Int = 0) {
@@ -11,7 +19,7 @@ value class ThreeFiveMiniUfloat(val index: Int = 0) {
}
companion object {
val LUT = floatArrayOf(0f,0.03125f,0.0625f,0.09375f,0.125f,0.15625f,0.1875f,0.21875f,0.25f,0.28125f,0.3125f,0.34375f,0.375f,0.40625f,0.4375f,0.46875f,0.5f,0.53125f,0.5625f,0.59375f,0.625f,0.65625f,0.6875f,0.71875f,0.75f,0.78125f,0.8125f,0.84375f,0.875f,0.90625f,0.9375f,0.96875f,1f,1.03125f,1.0625f,1.09375f,1.125f,1.15625f,1.1875f,1.21875f,1.25f,1.28125f,1.3125f,1.34375f,1.375f,1.40625f,1.4375f,1.46875f,1.5f,1.53125f,1.5625f,1.59375f,1.625f,1.65625f,1.6875f,1.71875f,1.75f,1.78125f,1.8125f,1.84375f,1.875f,1.90625f,1.9375f,1.96875f,2f,2.0625f,2.125f,2.1875f,2.25f,2.3125f,2.375f,2.4375f,2.5f,2.5625f,2.625f,2.6875f,2.75f,2.8125f,2.875f,2.9375f,3f,3.0625f,3.125f,3.1875f,3.25f,3.3125f,3.375f,3.4375f,3.5f,3.5625f,3.625f,3.6875f,3.75f,3.8125f,3.875f,3.9375f,4f,4.125f,4.25f,4.375f,4.5f,4.625f,4.75f,4.875f,5f,5.125f,5.25f,5.375f,5.5f,5.625f,5.75f,5.875f,6f,6.125f,6.25f,6.375f,6.5f,6.625f,6.75f,6.875f,7f,7.125f,7.25f,7.375f,7.5f,7.625f,7.75f,7.875f,8f,8.25f,8.5f,8.75f,9f,9.25f,9.5f,9.75f,10f,10.25f,10.5f,10.75f,11f,11.25f,11.5f,11.75f,12f,12.25f,12.5f,12.75f,13f,13.25f,13.5f,13.75f,14f,14.25f,14.5f,14.75f,15f,15.25f,15.5f,15.75f,16f,16.5f,17f,17.5f,18f,18.5f,19f,19.5f,20f,20.5f,21f,21.5f,22f,22.5f,23f,23.5f,24f,24.5f,25f,25.5f,26f,26.5f,27f,27.5f,28f,28.5f,29f,29.5f,30f,30.5f,31f,31.5f,32f,33f,34f,35f,36f,37f,38f,39f,40f,41f,42f,43f,44f,45f,46f,47f,48f,49f,50f,51f,52f,53f,54f,55f,56f,57f,58f,59f,60f,61f,62f,63f,64f,66f,68f,70f,72f,74f,76f,78f,80f,82f,84f,86f,88f,90f,92f,94f,96f,98f,100f,102f,104f,106f,108f,110f,112f,114f,116f,118f,120f,122f,124f,126f)
val LUT = floatArrayOf(0f,0.00390625f,0.0078125f,0.01171875f,0.015625f,0.01953125f,0.0234375f,0.02734375f,0.03125f,0.03515625f,0.0390625f,0.04296875f,0.046875f,0.05078125f,0.0546875f,0.05859375f,0.0625f,0.06640625f,0.0703125f,0.07421875f,0.078125f,0.08203125f,0.0859375f,0.08984375f,0.09375f,0.09765625f,0.1015625f,0.10546875f,0.109375f,0.11328125f,0.1171875f,0.12109375f,0.125f,0.12890625f,0.1328125f,0.13671875f,0.140625f,0.14453125f,0.1484375f,0.15234375f,0.15625f,0.16015625f,0.1640625f,0.16796875f,0.171875f,0.17578125f,0.1796875f,0.18359375f,0.1875f,0.19140625f,0.1953125f,0.19921875f,0.203125f,0.20703125f,0.2109375f,0.21484375f,0.21875f,0.22265625f,0.2265625f,0.23046875f,0.234375f,0.23828125f,0.2421875f,0.24609375f,0.25f,0.2578125f,0.265625f,0.2734375f,0.28125f,0.2890625f,0.296875f,0.3046875f,0.3125f,0.3203125f,0.328125f,0.3359375f,0.34375f,0.3515625f,0.359375f,0.3671875f,0.375f,0.3828125f,0.390625f,0.3984375f,0.40625f,0.4140625f,0.421875f,0.4296875f,0.4375f,0.4453125f,0.453125f,0.4609375f,0.46875f,0.4765625f,0.484375f,0.4921875f,0.5f,0.515625f,0.53125f,0.546875f,0.5625f,0.578125f,0.59375f,0.609375f,0.625f,0.640625f,0.65625f,0.671875f,0.6875f,0.703125f,0.71875f,0.734375f,0.75f,0.765625f,0.78125f,0.796875f,0.8125f,0.828125f,0.84375f,0.859375f,0.875f,0.890625f,0.90625f,0.921875f,0.9375f,0.953125f,0.96875f,0.984375f,1f,1.03125f,1.0625f,1.09375f,1.125f,1.15625f,1.1875f,1.21875f,1.25f,1.28125f,1.3125f,1.34375f,1.375f,1.40625f,1.4375f,1.46875f,1.5f,1.53125f,1.5625f,1.59375f,1.625f,1.65625f,1.6875f,1.71875f,1.75f,1.78125f,1.8125f,1.84375f,1.875f,1.90625f,1.9375f,1.96875f,2f,2.0625f,2.125f,2.1875f,2.25f,2.3125f,2.375f,2.4375f,2.5f,2.5625f,2.625f,2.6875f,2.75f,2.8125f,2.875f,2.9375f,3f,3.0625f,3.125f,3.1875f,3.25f,3.3125f,3.375f,3.4375f,3.5f,3.5625f,3.625f,3.6875f,3.75f,3.8125f,3.875f,3.9375f,4f,4.125f,4.25f,4.375f,4.5f,4.625f,4.75f,4.875f,5f,5.125f,5.25f,5.375f,5.5f,5.625f,5.75f,5.875f,6f,6.125f,6.25f,6.375f,6.5f,6.625f,6.75f,6.875f,7f,7.125f,7.25f,7.375f,7.5f,7.625f,7.75f,7.875f,8f,8.25f,8.5f,8.75f,9f,9.25f,9.5f,9.75f,10f,10.25f,10.5f,10.75f,11f,11.25f,11.5f,11.75f,12f,12.25f,12.5f,12.75f,13f,13.25f,13.5f,13.75f,14f,14.25f,14.5f,14.75f,15f,15.25f,15.5f,15.75f)
private fun fromFloatToIndex(fval: Float): Int {
val (llim, hlim) = binarySearchInterval(fval, LUT)

View File

@@ -12,6 +12,7 @@ import java.io.OutputStream
import java.nio.charset.Charset
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.absoluteValue
import kotlin.math.ceil
@@ -549,7 +550,7 @@ class VM(
// println("peek $addr -> ${offset}@${memspace?.javaClass?.canonicalName}")
return if (memspace == null)
throw NullPointerException()//null
throw OpenBusException(addr)//null
else if (memspace is UnsafePtr) {
if (addr >= memspace.size)
throw ErrorIllegalAccess(this, addr)
@@ -564,7 +565,7 @@ class VM(
val (memspace, offset) = translateAddr(addr)
return if (memspace == null)
throw NullPointerException()//null
throw OpenBusException(addr)//null
else if (memspace is UnsafePtr) {
if (addr >= memspace.size)
throw ErrorIllegalAccess(this, addr)
@@ -583,7 +584,7 @@ class VM(
val (memspace, offset) = translateAddr(addr)
return if (memspace == null)
throw NullPointerException()//null
throw OpenBusException(addr)//null
else if (memspace is UnsafePtr) {
if (addr >= memspace.size)
throw ErrorIllegalAccess(this, addr)
@@ -608,7 +609,7 @@ class VM(
val (memspace, offset) = translateAddr(addr)
return if (memspace == null)
throw NullPointerException()//null
throw OpenBusException(addr)//null
else if (memspace is UnsafePtr) {
if (addr >= memspace.size)
throw ErrorIllegalAccess(this, addr)
@@ -853,3 +854,10 @@ class PeripheralEntry2(
)
internal fun Int.kB() = this * 1024L
fun Long.memAddrToReadable() = "'${this}' (bank " + this.absoluteValue.minus(if (this < 0) 1 else 0).div(1048576) +
" offset " + this.absoluteValue.minus(if (this < 0) 1 else 0).mod(1048576) + ")"
class OpenBusException(addr: Long) : NullPointerException(
"Address ${addr.memAddrToReadable()} is open bus"
)

View File

@@ -133,6 +133,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Trackers may use different labelling conventions (e.g. C5) for Middle C.
// For non-tracker context, Middle C shall be labelled as C4.
const val AMIGA_BASE_PERIOD = 428.0
// Reference frequency for linear-freq tone mode (toneMode == 2). Fixed at 12-TET
// A4 = 440 Hz so that 1 Hz/tick at C4 ≈ 1 Hz at the audible output: 261.6256 ×
// 2^(9/12) = 440 Hz exactly. MONOTONE (.MON) — the only source format using
// linear-freq slides — uses A0 = 27.5 Hz with the same equal-temperament tuning,
// so emitted Hz values map directly to audible Hz at any pitch.
const val LINEAR_FREQ_C4_HZ = 261.6255653005986
// Anti-click ramp-out: when a sample naturally ends or is cut, the voice keeps
// mixing for this many output samples while gain decays linearly to 0.
// 8 ms at 32 kHz — long enough to bury the click, short enough not to read as fade.
// Applied on sample end only (preserves attack transients on note start).
const val RAMP_OUT_SAMPLES = 256
}
// Memory map (terranmon.txt:1985-1997, updated 2026-05-06):
@@ -419,7 +430,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private var mp2Context = mp2Env.initialise()
private fun decodeMp2() {
val periMmioBase = vm.findPeriSlotNum(this)!! * -786432 - 1L
val periMmioBase = vm.findPeriSlotNum(this)!! * -131072 - 1L
mp2Env.decodeFrameU8(mp2Context, periMmioBase - 2368, true, periMmioBase - 64)
}
@@ -1209,6 +1220,34 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
return amigaPeriodToNoteVal(newPeriod)
}
// Linear-frequency mode (toneMode == 2): E / F / G arguments are interpreted as Hz/tick.
// The reference is fixed at 12-TET A4 = 440 Hz (so MIDDLE_C ≈ 261.6256 Hz). MONOTONE
// (.MON) is the canonical source — its 1xx/2xx/3xx commands use Hz/tick directly, so
// mon2taud.py emits the raw byte and relies on this mode. Like Amiga mode, a per-voice
// linearFreq cache (`voice.linearFreq`) preserves sub-noteVal precision across ticks;
// -1.0 means stale and must be reseeded from current noteVal.
private fun noteValToFreqHz(noteVal: Int): Double =
LINEAR_FREQ_C4_HZ * 2.0.pow((noteVal - MIDDLE_C).toDouble() / 4096.0)
private fun freqHzToNoteVal(freq: Double): Int =
(MIDDLE_C + 4096.0 * log2(freq / LINEAR_FREQ_C4_HZ)).roundToInt()
// Per-tick linear-freq slide. Sign convention matches linear/Amiga modes: positive
// slideArg = pitch up = freq rises; negative = pitch down = freq falls.
private fun linearFreqSlideTick(voice: Voice, slideArg: Int): Int {
if (voice.linearFreq < 0.0) voice.linearFreq = noteValToFreqHz(voice.noteVal)
voice.linearFreq = (voice.linearFreq + slideArg).coerceAtLeast(1.0)
return freqHzToNoteVal(voice.linearFreq)
}
// One-shot linear-freq slide for fine E/F (applied once per row at tick 0); does
// not mutate persistent state.
private fun linearFreqSlideOnce(noteVal: Int, slideArg: Int): Int {
val freq = noteValToFreqHz(noteVal)
val newFreq = (freq + slideArg).coerceAtLeast(1.0)
return freqHzToNoteVal(newFreq)
}
/**
* Resolve the active wrap region for an envelope based on the LOOP and
* SUSTAIN words and key state.
@@ -1595,6 +1634,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val s1 = (b1 - 127.5) / 127.5
val sample = s0 + (s1 - s0) * frac
// While ramping out at sample end, hold position so the mixer keeps emitting the
// clamped last-sample value with decaying gain — no further advance, no re-trigger
// of the end check.
if (voice.rampOutSamples > 0) return sample
if (voice.forward) {
voice.samplePos += voice.playbackRate
// When the sustain bit is set, key-off escapes the loop: the sample plays past
@@ -1602,10 +1646,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val effectiveLoopMode =
if (inst.sampleLoopSustain && voice.keyOff) 0 else (inst.loopMode and 3)
when (effectiveLoopMode) {
0 -> if (voice.samplePos >= sampleLen) voice.active = false
0 -> if (voice.samplePos >= sampleLen) {
voice.samplePos = (sampleLen - 1).toDouble().coerceAtLeast(0.0)
startRampOut(voice)
}
1 -> if (voice.samplePos >= loopEnd) voice.samplePos -= (loopEnd - loopStart).coerceAtLeast(1.0)
2 -> if (voice.samplePos >= loopEnd) { voice.samplePos = loopEnd; voice.forward = false }
3 -> if (voice.samplePos >= sampleLen) { voice.samplePos = sampleLen.toDouble() - 1; voice.active = false }
3 -> if (voice.samplePos >= sampleLen) {
voice.samplePos = (sampleLen - 1).toDouble().coerceAtLeast(0.0)
startRampOut(voice)
}
}
} else {
voice.samplePos -= voice.playbackRate
@@ -1614,6 +1664,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
return sample
}
/**
* Engage the MilkyTracker-style sample-end ramp. The voice keeps emitting its held
* last-sample value for [RAMP_OUT_SAMPLES] more output samples while gain decays
* linearly from 1.0 to 0.0; the mixer flips voice.active = false at the end.
* No-op if already ramping (don't restart a running ramp from a re-entrant call).
*/
private fun startRampOut(voice: Voice) {
if (voice.rampOutSamples > 0) return
voice.rampOutSamples = RAMP_OUT_SAMPLES
voice.rampOutGain = 1.0
voice.rampOutStep = 1.0 / RAMP_OUT_SAMPLES
}
/**
* Trigger a fresh note on [voice]: load the instrument, reset sample position, kick off the envelope.
* Pulled out so S$Dx (note delay) can defer the same logic to a later tick.
@@ -1647,6 +1710,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envPfValue = if (voice.hasPfEnv) inst.pfEnvelopes[0].value / 255.0 else 0.5
// Fadeout starts at unity; advances only after key-off.
voice.fadeoutVolume = 1.0
// Cancel any sample-end ramp left over from the previous note — a fresh trigger's
// attack must not be muted by a trailing fade.
voice.rampOutSamples = 0
voice.rampOutGain = 0.0
// Auto-vibrato sweep ramp restarts on every fresh trigger.
voice.autoVibPhase = 0
voice.autoVibTicksSinceTrigger = 0
@@ -1655,6 +1722,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
(Math.random() * (2 * inst.volumeSwing + 1)).toInt() - inst.volumeSwing else 0
voice.randomPanBias = if (inst.panSwing != 0)
(Math.random() * (2 * inst.panSwing + 1)).toInt() - inst.panSwing else 0
// Default pan / pitch-pan separation: only re-applied when the row carried an instrument
// byte. A note-only retrigger (instId == 0) inherits the channel's existing pan, mirroring
// the volume policy below.
if (instId != 0) {
// Default pan: applied unless the pattern row has already overridden channelPan.
// The pan envelope's 'p' flag ("use default pan") lives in the pan LOOP word at bit 7.
if ((inst.panEnvLoop ushr 7) and 1 != 0) {
@@ -1669,6 +1740,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.channelPan = (voice.channelPan + panShift).coerceIn(0, 255)
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
}
}
// Filter cutoff/resonance defaults — adjusted per-tick by the pf envelope when in filter mode.
// 255 = filter off (IT high-bit-clear); 0..254 = active range matching IT 0..127 at double resolution.
voice.currentCutoff = inst.defaultCutoff
@@ -1679,11 +1751,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.noteVal = noteVal
voice.basePitch = noteVal
voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new noteVal
voice.linearFreq = -1.0 // ditto for linear-freq mode (toneMode == 2)
voice.playbackRate = computePlaybackRate(inst, noteVal)
// Fresh trigger resets channel volume to full ($3F). Per-instrument scaling lives in
// instGlobalVolume (byte 171), which the mixer applies as a multiplier. Converters
// therefore no longer need to emit SEL_SET=Sv on note-trigger rows.
voice.channelVolume = if (volOverride >= 0) volOverride.coerceIn(0, 0x3F) else 0x3F
// Fresh trigger resets channel volume to full ($3F) ONLY when the row carried an
// instrument byte; a note-only retrigger (instId == 0) inherits the channel's existing
// volume so the user can sustain a held volume across re-triggered notes. Per-instrument
// scaling lives in instGlobalVolume (byte 171), which the mixer applies as a multiplier.
// Converters therefore no longer need to emit SEL_SET=Sv on note-trigger rows.
voice.channelVolume = when {
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
instId != 0 -> 0x3F
else -> voice.channelVolume
}
voice.rowVolume = voice.channelVolume
voice.noteWasCut = false
voice.noteFading = false
@@ -1824,6 +1903,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
v.noteVal = src.noteVal
v.basePitch = src.basePitch
v.amigaPeriod = src.amigaPeriod
v.linearFreq = src.linearFreq
v.volEnvOn = src.volEnvOn
v.panEnvOn = src.panEnvOn
v.pfEnvOn = src.pfEnvOn
@@ -1947,11 +2027,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Instrument byte on a porta row reloads the channel's default
// volume even though the sample isn't retriggered. Mirrors schism
// csf_instrument_change (effects.c:1302) which writes
// chan->volume = psmp->volume whenever inst_column is set.
// chan->volume = psmp->volume whenever inst_column is set, and
// (effects.c:1402-1403) which clears CHN_KEYOFF | CHN_NOTEFADE
// so an in-progress fadeout from the prior note does not bleed
// into the porta'd note. fadeoutVolume is reset to unity so a
// volume-column SET on this row is heard at face value rather
// than scaled by the decayed tail.
if (row.instrment != 0) {
voice.instrumentId = row.instrment
voice.channelVolume = 0x3F
voice.rowVolume = 0x3F
voice.keyOff = false
voice.noteFading = false
voice.fadeoutVolume = 1.0
}
} else if ((row.effect == EffectOp.OP_S) && ((row.effectArg ushr 12) and 0xF) == 0xD) {
// Note delay: defer trigger to the requested tick. NNA fires when the
@@ -1990,12 +2078,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
EffectOp.OP_1 -> {
// 1 $xx00 — Global behaviour flags byte in the high byte (see TAUD_NOTE_EFFECTS.md §1).
// bit 0 (p): 0=linear pan, 1=equal-power pan
// bit 1 (f): 0=linear pitch slides, 1=Amiga-mode pitch slides
// bit 2 : reserved (was 'm' fadeout-zero policy; removed — converters now scale
// source fadeout into Taud-native units, so the engine has a single divisor)
// bits 1-2 (ff): 0=linear pitch, 1=Amiga period, 2=linear frequency (Hz/tick),
// 3=reserved
val flags = rawArg ushr 8
ts.panLaw = flags and 1
ts.amigaMode = (flags and 2) != 0
ts.toneMode = (flags ushr 1) and 3
}
EffectOp.OP_8 -> {
// 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §8.
@@ -2065,32 +2152,38 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val arg = resolveArg(rawArg, voice.mem.ef).also { if (rawArg != 0) voice.mem.ef = it }
if ((arg and 0xF000) == 0xF000) {
val mag = arg and 0x0FFF
voice.noteVal = if (ts.amigaMode)
amigaSlideOnce(voice.noteVal, -mag).coerceIn(0, 0xFFFE)
else
(voice.noteVal - mag).coerceIn(0, 0xFFFE)
voice.noteVal = when (ts.toneMode) {
1 -> amigaSlideOnce(voice.noteVal, -mag) // Amiga: subtract from pitch ⇒ adds period
2 -> linearFreqSlideOnce(voice.noteVal, -mag) // Hz/tick: pitch down ⇒ -Hz
else -> voice.noteVal - mag // linear 4096-TET
}.coerceIn(1, 0xFFFD)
voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0 // reseed on next per-tick slide
voice.linearFreq = -1.0
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
} else {
voice.slideMode = 1; voice.slideArg = -arg
voice.amigaPeriod = -1.0 // reseed at the start of a fresh multi-tick slide
voice.linearFreq = -1.0
}
}
EffectOp.OP_F -> {
val arg = resolveArg(rawArg, voice.mem.ef).also { if (rawArg != 0) voice.mem.ef = it }
if ((arg and 0xF000) == 0xF000) {
val mag = arg and 0x0FFF
voice.noteVal = if (ts.amigaMode)
amigaSlideOnce(voice.noteVal, mag).coerceIn(0, 0xFFFE)
else
(voice.noteVal + mag).coerceIn(0, 0xFFFE)
voice.noteVal = when (ts.toneMode) {
1 -> amigaSlideOnce(voice.noteVal, mag)
2 -> linearFreqSlideOnce(voice.noteVal, mag)
else -> voice.noteVal + mag
}.coerceIn(1, 0xFFFD)
voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0
voice.linearFreq = -1.0
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
} else {
voice.slideMode = 2; voice.slideArg = arg
voice.amigaPeriod = -1.0
voice.linearFreq = -1.0
}
}
EffectOp.OP_G -> {
@@ -2203,9 +2296,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
when (sub) {
0x1 -> voice.glissandoOn = (x != 0)
0x2 -> {
voice.noteVal = (voice.noteVal + FINETUNE_OFFSET[x]).coerceIn(0, 0xFFFE)
voice.noteVal = (voice.noteVal + FINETUNE_OFFSET[x]).coerceIn(1, 0xFFFD)
voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0
voice.linearFreq = -1.0
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
}
0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 }
@@ -2291,17 +2385,37 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Pitch slides (E/F coarse on tick > 0).
if (ts.tickInRow > 0 && (voice.slideMode == 1 || voice.slideMode == 2)) {
voice.noteVal = if (ts.amigaMode)
amigaSlideTick(voice, voice.slideArg).coerceIn(0, 0xFFFE)
else
(voice.noteVal + voice.slideArg).coerceIn(0, 0xFFFE)
voice.noteVal = when (ts.toneMode) {
1 -> amigaSlideTick(voice, voice.slideArg)
2 -> linearFreqSlideTick(voice, voice.slideArg)
else -> voice.noteVal + voice.slideArg
}.coerceIn(1, 0xFFFD)
voice.basePitch = voice.noteVal
}
// Tone portamento (G).
// Tone portamento (G). In linear-freq mode the speed is interpreted as Hz/tick
// so MONOTONE 3xx (port-to-note in Hz) round-trips faithfully; in linear and
// Amiga modes the speed is in 4096-TET pitch units (Amiga period units would be
// backwards relative to PT semantics — see TAUD_NOTE_EFFECTS.md §G).
if (voice.tonePortaTarget >= 0 && ts.tickInRow > 0) {
val target = voice.tonePortaTarget
val sp = voice.tonePortaSpeed
if (ts.toneMode == 2) {
if (voice.linearFreq < 0.0) voice.linearFreq = noteValToFreqHz(voice.noteVal)
val targetFreq = noteValToFreqHz(target)
val dir = if (targetFreq > voice.linearFreq) +1.0 else -1.0
voice.linearFreq += dir * sp
if ((dir > 0 && voice.linearFreq >= targetFreq) ||
(dir < 0 && voice.linearFreq <= targetFreq)) {
voice.linearFreq = targetFreq
voice.noteVal = target
voice.tonePortaTarget = -1
} else {
voice.noteVal = freqHzToNoteVal(voice.linearFreq).coerceIn(1, 0xFFFD)
}
voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0
} else {
val delta = if (target > voice.noteVal) sp else -sp
voice.noteVal += delta
if ((delta > 0 && voice.noteVal >= target) || (delta < 0 && voice.noteVal <= target)) {
@@ -2309,6 +2423,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
}
voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0 // tone porta works in linear noteVal space; reseed period
voice.linearFreq = -1.0
}
}
// Volume slides (D coarse on tick > 0).
@@ -2348,14 +2464,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.vibratoActive) {
val sine = lfoSample(voice.vibratoLfoPos, voice.vibratoWave)
val pitchDelta = (sine * voice.mem.huDepth) shr voice.vibratoFineShift
pitchToMixer = (voice.noteVal + pitchDelta).coerceIn(0, 0xFFFE)
pitchToMixer = (voice.noteVal + pitchDelta).coerceIn(1, 0xFFFD)
voice.vibratoLfoPos = (voice.vibratoLfoPos + voice.mem.huSpeed * 4) and 0xFF
}
// Glissando (S$1x) — snap pitchToMixer to nearest semitone but leave noteVal smooth.
if (voice.glissandoOn) {
val semis = ((pitchToMixer * 12 + 2048) / 4096)
pitchToMixer = (semis * 4096 / 12).coerceIn(0, 0xFFFE)
pitchToMixer = (semis * 4096 / 12).coerceIn(1, 0xFFFD)
}
// Tremolo (R) — modulates output volume around base.
@@ -2378,7 +2494,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.arpActive) {
val voiceIdx = ts.tickInRow % 3
val arpDelta = when (voiceIdx) { 1 -> voice.arpOff1 shl 8; 2 -> voice.arpOff2 shl 8; else -> 0 }
pitchToMixer = (voice.basePitch + arpDelta).coerceIn(0, 0xFFFE)
pitchToMixer = (voice.basePitch + arpDelta).coerceIn(1, 0xFFFD)
voice.lastArpVoice = voiceIdx
}
@@ -2415,7 +2531,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
((voice.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
else 0
val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(0, 0xFFFE)
val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(1, 0xFFFD)
voice.playbackRate = computePlaybackRate(inst, finalPitch)
// Filter envelope (filter mode): scale baseCut by envValue (0..1, 0.5 = unity).
@@ -2509,7 +2625,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val pitchEnvDelta = if (bg.hasPfEnv && bg.pfEnvOn && !bg.envPfIsFilter)
((bg.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
else 0
val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(0, 0xFFFE)
val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(1, 0xFFFD)
bg.playbackRate = computePlaybackRate(inst, finalPitch)
// Filter-mode pf envelope: same scaling rule as foreground.
if (bg.hasPfEnv && bg.pfEnvOn && bg.envPfIsFilter) {
@@ -2610,8 +2726,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
rGain = if (pan < 0x80) pan / 128.0 else 1.0
}
}
mixL += s * vol * lGain
mixR += s * vol * rGain
// Sample-end ramp-out: snapshot gain, advance the ramp, deactivate at zero.
val rampGain = if (voice.rampOutSamples > 0) {
val g = voice.rampOutGain
voice.rampOutGain -= voice.rampOutStep
voice.rampOutSamples--
if (voice.rampOutSamples == 0) voice.active = false
g
} else 1.0
mixL += s * vol * lGain * rampGain
mixR += s * vol * rGain * rampGain
}
// Background (NNA-ghost) voices — same per-sample mixing path as foreground, but
// they live in a mixer-private pool that no row event can address.
@@ -2631,14 +2755,24 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val lGain: Double
val rGain: Double
when (ts.panLaw) {
1 -> { lGain = cos(PI * pan / 512.0); rGain = sin(PI * pan / 512.0) }
1 -> {
lGain = cos(PI * pan / 512.0)
rGain = sin(PI * pan / 512.0)
}
else -> {
lGain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0
rGain = if (pan < 0x80) pan / 128.0 else 1.0
}
}
mixL += s * vol * lGain
mixR += s * vol * rGain
val rampGain = if (bg.rampOutSamples > 0) {
val g = bg.rampOutGain
bg.rampOutGain -= bg.rampOutStep
bg.rampOutSamples--
if (bg.rampOutSamples == 0) bg.active = false
g
} else 1.0
mixL += s * vol * lGain * rampGain
mixR += s * vol * rGain * rampGain
}
ts.mixLeft[n] = mixL.toFloat().coerceIn(-1.0f, 1.0f)
@@ -2863,6 +2997,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Volume fadeout — engaged after key-off, decays to 0 at rate inst.volumeFadeoutLow.
var fadeoutVolume = 1.0
// MilkyTracker-style anti-click ramp-out. Engaged when a sample naturally ends
// (loopMode 0/3 reaching sampleLen). Gain ramps from 1.0 → 0.0 over rampOutSamples
// while the held last-sample value keeps being emitted; voice deactivates at 0.
// Not engaged on note start — attack transients pass unsmoothed.
var rampOutSamples = 0
var rampOutGain = 0.0
var rampOutStep = 0.0
// Auto-vibrato (per-sample on the IT side, hoisted to the instrument here).
var autoVibPhase = 0 // 8-bit phase counter
var autoVibTicksSinceTrigger = 0 // for sweep ramp-up
@@ -2896,6 +3038,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// sub-noteVal precision through repeated round-trip rounding (see amigaSlideTick).
// -1.0 means "needs reseed from current noteVal".
var amigaPeriod: Double = -1.0
// Linear-frequency-mode state (Hz). Same -1.0 = stale convention as amigaPeriod.
// Used by toneMode == 2 (MONOTONE compat) for E / F coarse slides and G tone porta.
var linearFreq: Double = -1.0
// Per-row effect state (set in applyTrackerRow, consumed by applyTrackerTick).
var rowEffect = 0
@@ -3001,7 +3146,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Global mixer config (effect 1).
var panLaw = 0 // 0 = linear balance (default), 1 = equal-power
var amigaMode = false // false = linear pitch slides, true = Amiga period-space slides
// Tone-slide mode for E / F / G effects (terranmon.txt §Song Table flags byte):
// 0 = linear pitch slides (4096-TET units, default)
// 1 = Amiga period slides (raw PT period units, applied in period space)
// 2 = linear-frequency slides (Hz/tick — MONOTONE compat)
// 3 = reserved
var toneMode = 0
// Pending row-end events (set during a row by B/C; consumed at row end).
var pendingOrderJump = -1 // -1 = none; otherwise the order index to jump to
@@ -3114,7 +3264,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
initialGlobalFlags = byte
trackerState?.let { ts ->
ts.panLaw = byte and 1
ts.amigaMode = (byte and 2) != 0
ts.toneMode = (byte ushr 1) and 3
}
}
8 -> { bpm = byte + 24 }
@@ -3149,7 +3299,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
ts.sexWinningChannel = -1
ts.finePatternDelayExtra = 0
ts.panLaw = initialGlobalFlags and 1
ts.amigaMode = (initialGlobalFlags and 2) != 0
ts.toneMode = (initialGlobalFlags ushr 1) and 3
ts.voices.forEach {
it.active = false
it.channelVolume = 0x3F

View File

@@ -43,6 +43,7 @@ package net.torvald.tsvm.peripheral
import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUint
import net.torvald.tsvm.VM
import net.torvald.tsvm.memAddrToReadable
import java.util.*
import kotlin.collections.ArrayList
import kotlin.math.ceil
@@ -398,7 +399,7 @@ class MP2Env(val vm: VM) {
};
// check for valid header: syncword OK, MPEG-Audio Layer 2
if ((syspeek(mp2_frame!!) != 0xFF) || ((syspeek(mp2_frame!! + 1*incr) and 0xFE) != 0xFC)){
throw Error("Invalid MP2 header at $mp2_frame: ${syspeek(mp2_frame!!).toString(16)} ${syspeek(mp2_frame!! + 1*incr).toString(16)}")
throw Error("Invalid MP2 header at ${(mp2_frame as Long).memAddrToReadable()}: ${syspeek(mp2_frame!!).toString(16)} ${syspeek(mp2_frame!! + 1*incr).toString(16)}")
};
// set up the bitstream reader

View File

@@ -54,8 +54,8 @@ public class AppLoader {
ArrayList defaultPeripherals = new ArrayList();
defaultPeripherals.add(new Pair(3, new PeripheralEntry2("net.torvald.tsvm.peripheral.AudioAdapter", vm)));
defaultPeripherals.add(new Pair(4, new PeripheralEntry2("net.torvald.tsvm.peripheral.HostFileHSDPA", vm, "assets/diskMediabin/lnterz_013.mv2", "assets/diskMediabin/ba60d.mov", "", "", 999999999L)));
defaultPeripherals.add(new Pair(2, new PeripheralEntry2("net.torvald.tsvm.peripheral.AudioAdapter", vm)));
defaultPeripherals.add(new Pair(3, new PeripheralEntry2("net.torvald.tsvm.peripheral.HostFileHSDPA", vm, "assets/diskMediabin/lnterz_013.mv2", "assets/diskMediabin/ba60d.mov", "", "", 999999999L)));
EmulInstance reference = new EmulInstance(vm, "net.torvald.tsvm.peripheral.ReferenceGraphicsAdapter", diskPath, 560, 448, defaultPeripherals);

View File

@@ -53,7 +53,7 @@ from taud_common import (
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
J_SEMI_TABLE,
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
normalise_sample, encode_song_entry,
normalise_sample, encode_song_entry, nearest_minifloat,
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
)
@@ -78,61 +78,6 @@ XM_ENV_LOOP = 0x04
SIGNATURE = b"xm2taud/TSVM " # 14 bytes
# ── Minifloat LUT (must match it2taud / engine) ──────────────────────────────
_MINUFLOAT_LUT = [
0.0, 0.03125, 0.0625, 0.09375, 0.125, 0.15625, 0.1875, 0.21875,
0.25, 0.28125, 0.3125, 0.34375, 0.375, 0.40625, 0.4375, 0.46875,
0.5, 0.53125, 0.5625, 0.59375, 0.625, 0.65625, 0.6875, 0.71875,
0.75, 0.78125, 0.8125, 0.84375, 0.875, 0.90625, 0.9375, 0.96875,
1.0, 1.03125, 1.0625, 1.09375, 1.125, 1.15625, 1.1875, 1.21875,
1.25, 1.28125, 1.3125, 1.34375, 1.375, 1.40625, 1.4375, 1.46875,
1.5, 1.53125, 1.5625, 1.59375, 1.625, 1.65625, 1.6875, 1.71875,
1.75, 1.78125, 1.8125, 1.84375, 1.875, 1.90625, 1.9375, 1.96875,
2.0, 2.0625, 2.125, 2.1875, 2.25, 2.3125, 2.375, 2.4375,
2.5, 2.5625, 2.625, 2.6875, 2.75, 2.8125, 2.875, 2.9375,
3.0, 3.0625, 3.125, 3.1875, 3.25, 3.3125, 3.375, 3.4375,
3.5, 3.5625, 3.625, 3.6875, 3.75, 3.8125, 3.875, 3.9375,
4.0, 4.125, 4.25, 4.375, 4.5, 4.625, 4.75, 4.875,
5.0, 5.125, 5.25, 5.375, 5.5, 5.625, 5.75, 5.875,
6.0, 6.125, 6.25, 6.375, 6.5, 6.625, 6.75, 6.875,
7.0, 7.125, 7.25, 7.375, 7.5, 7.625, 7.75, 7.875,
8.0, 8.25, 8.5, 8.75, 9.0, 9.25, 9.5, 9.75,
10.0, 10.25, 10.5, 10.75, 11.0, 11.25, 11.5, 11.75,
12.0, 12.25, 12.5, 12.75, 13.0, 13.25, 13.5, 13.75,
14.0, 14.25, 14.5, 14.75, 15.0, 15.25, 15.5, 15.75,
16.0, 16.5, 17.0, 17.5, 18.0, 18.5, 19.0, 19.5,
20.0, 20.5, 21.0, 21.5, 22.0, 22.5, 23.0, 23.5,
24.0, 24.5, 25.0, 25.5, 26.0, 26.5, 27.0, 27.5,
28.0, 28.5, 29.0, 29.5, 30.0, 30.5, 31.0, 31.5,
32.0, 33.0, 34.0, 35.0, 36.0, 37.0, 38.0, 39.0,
40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0,
48.0, 49.0, 50.0, 51.0, 52.0, 53.0, 54.0, 55.0,
56.0, 57.0, 58.0, 59.0, 60.0, 61.0, 62.0, 63.0,
64.0, 66.0, 68.0, 70.0, 72.0, 74.0, 76.0, 78.0,
80.0, 82.0, 84.0, 86.0, 88.0, 90.0, 92.0, 94.0,
96.0, 98.0, 100.0, 102.0, 104.0, 106.0, 108.0, 110.0,
112.0, 114.0, 116.0, 118.0, 120.0, 122.0, 124.0, 126.0,
]
def _nearest_minifloat(sec: float) -> int:
if sec <= 0.0:
return 0
if sec >= 126.0:
return 255
lo, hi = 0, len(_MINUFLOAT_LUT) - 1
while lo < hi:
mid = (lo + hi) // 2
if _MINUFLOAT_LUT[mid] < sec:
lo = mid + 1
else:
hi = mid
if lo > 0 and abs(_MINUFLOAT_LUT[lo - 1] - sec) < abs(_MINUFLOAT_LUT[lo] - sec):
return lo - 1
return lo
# ── Data classes ─────────────────────────────────────────────────────────────
class XMHeader:
@@ -611,10 +556,11 @@ def encode_effect_xm(cmd: int, arg: int, ch: int = 0, row: int = 0,
# Set finetune — convert to S5x sub-effect (4-bit signed nibble).
return (TOP_S, 0x5000 | (val << 8), None, None)
if sub == 0x6:
# Set loop point / loop. Taud S6x = fine pattern delay; the
# closest analogue here is dropping with a warn if val>0.
vprint(f" dropped E6{val:X} (set loop) at ch{ch} row{row}")
return (TOP_NONE, 0, None, None)
# XM E6x = pattern loop (E60 sets loop start, E6x with x>0 loops
# x times). Maps directly onto Taud SBx, which has identical
# semantics — the engine handles per-voice loopStartRow /
# loopCount in applySEffect (sub 0xB).
return (TOP_S, 0xB000 | (val << 8), None, None)
if sub == 0x8:
# Pan position 0..15 → set pan column (XM nybble × 17 → 8-bit).
pan8 = (val << 4) | val
@@ -750,6 +696,58 @@ def remap_b_effects_xm(chunks: list, chunk_map: list,
row.effect_arg = taud_cue & 0xFF
def compute_keyoff_zero_marks_xm(taud_cue_list: list, chunks: list,
num_xm_channels: int, instruments: list,
active_channels: list) -> dict:
"""Identify key-off cells whose bound XM instrument has the volume envelope
DISABLED. FT2's keyOff() (ft2_replayer.c:411-435) zeroes realVol/outVol on
such key-offs; IT/Schism does not, and the Taud engine follows IT semantics.
To preserve XM gating without diverging engine behaviour, the converter pairs
each flagged key-off with `SEL_SET vol=0` in the same row's volume column —
a later vol-col SET on the channel restores audibility, exactly mirroring
the FT2 outVol/realVol path.
Walks taud_cue_list in playback order so per-channel instrument bindings
carry across cues. When the same chunk is visited under conflicting
bindings, the union of all flags is kept (conservatively prefers gating).
Returns: dict mapping chunk_idx → set of (active_voice_idx, row_idx) tuples.
The voice_idx matches build_pattern_xm's `ch_idx` (the index into
`active_channels`).
"""
xm_to_vi = {ch: vi for vi, ch in enumerate(active_channels)}
marks = {}
bound = [0] * num_xm_channels # 1-based XM instrument id; 0 = none
for ci in taud_cue_list:
cg = chunks[ci]
chunk_marks = marks.setdefault(ci, set())
max_ch = min(num_xm_channels, len(cg))
max_rows = max((len(cg[ch]) for ch in range(max_ch)), default=0)
for r in range(max_rows):
for xm_ch in range(max_ch):
if r >= len(cg[xm_ch]):
continue
cell = cg[xm_ch][r]
# FT2 keyOff() reads ch->instrPtr — the latest binding wins, even
# when the inst byte is on the same row as the key-off.
if cell.inst > 0:
bound[xm_ch] = cell.inst
is_keyoff = (cell.note == XM_NOTE_OFF) or (cell.effect == 0x14)
if not is_keyoff:
continue
ii = bound[xm_ch]
if ii == 0 or ii - 1 >= len(instruments):
continue
inst = instruments[ii - 1]
if inst.vol_env_type & XM_ENV_ON:
continue
vi = xm_to_vi.get(xm_ch)
if vi is not None:
chunk_marks.add((vi, r))
return marks
# ── Sample / instrument bin ───────────────────────────────────────────────────
class _XMSampleProxy:
@@ -808,7 +806,7 @@ def _xm_envelope_to_taud(env_pts: list, num_pts: int, env_type: int,
if k < len(nodes) - 1:
next_frame, _ = nodes[k + 1]
delta_sec = max(0.0, (next_frame - frame) / ticks_per_sec)
mf_idx = _nearest_minifloat(delta_sec)
mf_idx = nearest_minifloat(delta_sec)
else:
mf_idx = 0
else:
@@ -1053,8 +1051,16 @@ def build_sample_inst_bin_xm(proxies: list) -> tuple:
# ── Pattern bin builder ───────────────────────────────────────────────────────
def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int,
inst_to_taud_slot: dict, amiga_mode: bool = False) -> bytes:
"""Render one Taud channel's 512-byte pattern from a 64-row chunk grid."""
inst_to_taud_slot: dict, amiga_mode: bool = False,
keyoff_zero_rows: set = None) -> bytes:
"""Render one Taud channel's 512-byte pattern from a 64-row chunk grid.
`keyoff_zero_rows`: optional set of row indices on this channel whose key-off
cells should be paired with `SEL_SET vol=0` (FT2 vol-env-off gating — see
compute_keyoff_zero_marks_xm).
"""
if keyoff_zero_rows is None:
keyoff_zero_rows = frozenset()
out = bytearray(PATTERN_BYTES)
if ch_idx >= len(chunk_grid):
rows = [XMRow()] * PATTERN_ROWS
@@ -1122,6 +1128,17 @@ def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int,
else:
pan_sel, pan_value = SEL_FINE, 0
# FT2 vol-env-off key-off gating: pair the key-off with SEL_SET vol=0
# so a later vol-col SET on the channel restores audibility (see
# compute_keyoff_zero_marks_xm). Override any vol-col content the row
# already has — FT2 zeros realVol/outVol after vol-col is applied
# (ft2_replayer.c:411-428), so a SET on the same row would be clobbered.
if r in keyoff_zero_rows and note_taud == NOTE_KEYOFF:
if not (vol_sel == SEL_FINE and vol_value == 0):
vprint(f" ch{ch_idx} row{r}: FT2 key-off zero overrides "
f"vol-col (sel={vol_sel}, val={vol_value})")
vol_sel, vol_value = SEL_SET, 0
vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6)
pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6)
@@ -1241,6 +1258,17 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
remap_b_effects_xm(chunks, chunk_map, h.order_list, xm_ord_to_taud_cue, C)
# FT2 vol-env-off key-off gating: pre-compute per-(chunk, voice, row) flags
# for key-off cells whose bound XM instrument has volume envelope disabled.
# build_pattern_xm pairs each flagged key-off with `SEL_SET vol=0` so the
# IT-style Taud engine reproduces FT2's channel-volume zeroing gate.
keyoff_zero_marks = compute_keyoff_zero_marks_xm(
taud_cue_list, chunks, h.channels, instruments, active_channels)
if any(keyoff_zero_marks.values()):
flagged = sum(len(s) for s in keyoff_zero_marks.values())
vprint(f" FT2 keyoff-gate: {flagged} key-off cell(s) paired with vol=0 "
f"(vol-env-off instruments)")
# ── Pattern bin ─────────────────────────────────────────────────────────
total_taud_pats = len(taud_cue_list) * C
if total_taud_pats > NUM_PATTERNS_MAX:
@@ -1256,10 +1284,13 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
pat_bin = bytearray()
for ci in taud_cue_list:
cg = chunks[ci]
chunk_marks = keyoff_zero_marks.get(ci, frozenset())
for vi, ch in enumerate(active_channels):
row_marks = {r for (mvi, r) in chunk_marks if mvi == vi}
pat_bin += build_pattern_xm(cg, ch, default_pans[vi],
resolve_inst_slot,
amiga_mode=not h.linear_freq)
amiga_mode=not h.linear_freq,
keyoff_zero_rows=row_marks)
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
orig_count = len(taud_cue_list) * C