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 ## 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. - **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** (`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). - **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: 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: **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 `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 `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). - 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 `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. - 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. 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 else: memory_EF = raw
if (raw & $F000) == $F000: # fine, applied once on tick 0 if (raw & $F000) == $F000: # fine, applied once on tick 0
mag = raw & $0FFF mag = raw & $0FFF
if amiga_mode: if tone_mode == 1: # Amiga: mag is raw period units; pitch down ⇒ +period
# mag is a raw tracker period-unit count; subtract pitch ⇒ add period.
pitch = amiga_slide_down(pitch, mag) 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 pitch -= mag
mode_this_row = FINE mode_this_row = FINE
else: # coarse else: # coarse
@@ -200,12 +205,18 @@ on row start:
on tick > 0: on tick > 0:
if mode_this_row == COARSE: 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). # slide_amount_this_row is a raw tracker period-unit count (no × 64/3 scaling).
# period = AMIGA_BASE_PERIOD × 2^((pitch C4) / 4096) # period = AMIGA_BASE_PERIOD × 2^((pitch C4) / 4096)
# period_new = period + slide_amount_this_row # E subtracts pitch ⇒ adds period # period_new = period + slide_amount_this_row # E subtracts pitch ⇒ adds period
# pitch = C4 + 4096 × log2(AMIGA_BASE_PERIOD / period_new) # pitch = C4 + 4096 × log2(AMIGA_BASE_PERIOD / period_new)
pitch = amiga_slide_down(pitch, slide_amount_this_row) 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: else:
pitch -= slide_amount_this_row 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 ## 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. **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 ## 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.** **Implementation.**
``` ```
on row parse: on row parse:
if row has note and G effect: if row has note and G effect:
target_pitch = period_for(note) target_pitch = pitch_for(note)
# do NOT re-trigger sample # do NOT re-trigger sample
if arg != 0: if arg != 0:
memory_G = arg memory_G = arg
speed_this_row = memory_G speed_this_row = memory_G
on tick > 0: on tick > 0 (linear / Amiga modes):
if target_pitch set: if target_pitch set:
delta = sign(target_pitch - pitch) × speed_this_row delta = sign(target_pitch - pitch) × speed_this_row
pitch += delta pitch += delta
if sign crossed target: pitch = target_pitch; target_pitch = None 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. 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: **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 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. - 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 = 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 = 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 = 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.) (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 - 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 - R_gain = if (pan < 0x80) pan / 128.0 else 1.0
- Panning-equal-power: - Panning-equal-power:
- L_gain = cos(pi*x / 512.0) - L_gain = cos(πx / 512.0)
- R_gain = sin(pi*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 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) - 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 = AMIGA_BASE_PERIOD × 2^((noteVal C4) / 4096)
- period_new = period slideArg (E subtracts pitch ⇒ adds period; F adds pitch ⇒ subtracts period) - period_new = period slideArg (E subtracts pitch ⇒ adds period; F adds pitch ⇒ subtracts period)
- noteVal_new = C4 + 4096 × log2(AMIGA_BASE_PERIOD / period_new) - 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. **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

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

View File

@@ -11,15 +11,36 @@ Tags:
<l> - align left <l> - align left
<o> - create virtual typesetting box. Left anchor: where the text cursor is. Right anchor: end of the line <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>) &microtone; - replace with the brand string (<col 211>Micro</col><col 239>tone</col>)
&bul; - replace with bullet (\u00F9) &bul; - replace with bullet (\u00F9)
&ddot; - replace with double-dot (\u008419u) &ddot; - replace with double-dot (\u008419u)
&mdot; - replace with BIGDOT (\u00FA) &mdot; - replace with BIGDOT (\u00FA)
&updn; - up-down arrow (\u008418u) &updn; - up-down arrow (\u008418u)
&udlr; - four direction arrow (\u008428u\u008429u) &udlr; - four direction arrow (\u008428u\u008429u)
&keyoffsym; - pattern view key-off symbol (\u00A0\u00CD\u00CD\u00A1) &keyoffsym; - pattern view key-off symbol (\u00A0\u00CD\u00CD\u00A1)
&notecutsym; - pattern view note-cut symbol (\u00A4\u00A4\u00A4\u00A4) &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) &nbsp; - nonbreakable space (only meaningful for typesetters)
&shy; - soft hyphen (only meaningful for typesetters) &shy; - soft hyphen (only meaningful for typesetters)
default alignment: fully justified 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 without shift-in</O>
&bul;<b>A</b>&ddot;<b>Z</b> : <O>alphabet with 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 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. Push keys to play or insert notes.
&nbsp;w&nbsp;e&nbsp;&nbsp;&nbsp;t&nbsp;y&nbsp;u &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>O</b> : <O>stop the playback</O>
&bul;<b>tab</b> : <O>switch forward a tab</O> &bul;<b>tab</b> : <O>switch forward a tab</O>
&bul;<b>TAB</b> : <O>switch backward 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>&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>-</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>&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('&shy;', '')
.replaceAll('&lt;', '<') .replaceAll('&lt;', '<')
.replaceAll('&gt;', '>') .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: // 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 let helpMessages = [ // index: taut.js PANEL_NAMES
[helpJam, helpTimeline, helpCommon, helpNotation].join('\n\n'), [helpJam, helpTimeline, helpCommon, helpNotation].join('\n'),
[helpCommon, helpNotation].join('\n\n'), // placeholder [helpCommon, helpNotation].join('\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder [helpCommon, helpNotation].join('\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder [helpCommon, helpNotation].join('\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder [helpCommon, helpNotation].join('\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder [helpCommon, helpNotation].join('\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder [helpCommon, helpNotation].join('\n'), // placeholder
] ]
help.MSG_BY_TABS = helpMessages.map(it => typeset(it)) 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, EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
J_SEMI_TABLE, J_SEMI_TABLE,
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns, 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, 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 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 ────────────────────────────────────────────────────────── # ── IT header parser ──────────────────────────────────────────────────────────
@@ -626,7 +572,7 @@ def _parse_it_envelope(data: bytes, env_ptr: int, kind: str,
if k < len(nodes) - 1: if k < len(nodes) - 1:
_, next_tick = nodes[k + 1] _, next_tick = nodes[k + 1]
delta_sec = max(0.0, (next_tick - tick) / ticks_per_sec) delta_sec = max(0.0, (next_tick - tick) / ticks_per_sec)
mf_idx = _nearest_minifloat(delta_sec) mf_idx = nearest_minifloat(delta_sec)
else: else:
mf_idx = 0 mf_idx = 0
else: else:

View File

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

View File

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

View File

@@ -27,6 +27,9 @@ def vprint(*a, **kw) -> None:
# ── Taud container constants ───────────────────────────────────────────────── # ── Taud container constants ─────────────────────────────────────────────────
TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64]) 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_VERSION = 1
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14) 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) 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 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 ────────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────────
def d_arg_to_col(arg: int): def d_arg_to_col(arg: int):

View File

@@ -2137,13 +2137,13 @@ from source.
(bits 14..15 reserved) (bits 14..15 reserved)
21 Bit16x25 Volume envelopes 21 Bit16x25 Volume envelopes
Byte 1: Volume (00..3F) 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 71 Bit16x25 Panning envelopes
Byte 1: Pan (00..FF) 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 121 Bit16x25 Pitch/Filter envelopes
Byte 1: Value (00..FF) 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) 171 Uint8 Instrument Global Volume (0..255)
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int * 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 - 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 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 for pan/pf envelopes to play. See byte 15/17/19 spec for the LOOP
word bit layout. word bit layout.
[ ] implement extended tone mode (MONOTONE compat) [x] slumberjack.xm: E6x commands are not processed
[ ] pattern loops stops working after processed once (test with slumberjack.xm) [x] implement linear-freq tone mode (MONOTONE compat)
[ ] milkytracker-style volume ramping (on sample-end only) 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: Play Data: play data are series of tracker-like instructions, visualised as:
@@ -2430,9 +2450,9 @@ Play Head Flags
Byte 2 Byte 2
- PCM Mode: Write non-zero value to start uploading; always 0 when read - 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' - 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) 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. 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 Starting a new song will use whatever written to this register. In other words, changes
made by songs will not persist. made by songs will not persist.
@@ -2464,40 +2484,45 @@ Play Head Flags
65536..131071 RW: PCM Sample buffer 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 ,000,001,010,011,100,101,110,111,MSB
00000,0,1,2,4,8,16,32,64 00000,0,0.125,0.25,0.5,1,2,4,8
00001,0.03125,1.03125,2.0625,4.125,8.25,16.5,33,66 00001,0.00390625,0.12890625,0.2578125,0.515625,1.03125,2.0625,4.125,8.25
00010,0.0625,1.0625,2.125,4.25,8.5,17,34,68 00010,0.0078125,0.1328125,0.265625,0.53125,1.0625,2.125,4.25,8.5
00011,0.09375,1.09375,2.1875,4.375,8.75,17.5,35,70 00011,0.01171875,0.13671875,0.2734375,0.546875,1.09375,2.1875,4.375,8.75
00100,0.125,1.125,2.25,4.5,9,18,36,72 00100,0.015625,0.140625,0.28125,0.5625,1.125,2.25,4.5,9
00101,0.15625,1.15625,2.3125,4.625,9.25,18.5,37,74 00101,0.01953125,0.14453125,0.2890625,0.578125,1.15625,2.3125,4.625,9.25
00110,0.1875,1.1875,2.375,4.75,9.5,19,38,76 00110,0.0234375,0.1484375,0.296875,0.59375,1.1875,2.375,4.75,9.5
00111,0.21875,1.21875,2.4375,4.875,9.75,19.5,39,78 00111,0.02734375,0.15234375,0.3046875,0.609375,1.21875,2.4375,4.875,9.75
01000,0.25,1.25,2.5,5,10,20,40,80 01000,0.03125,0.15625,0.3125,0.625,1.25,2.5,5,10
01001,0.28125,1.28125,2.5625,5.125,10.25,20.5,41,82 01001,0.03515625,0.16015625,0.3203125,0.640625,1.28125,2.5625,5.125,10.25
01010,0.3125,1.3125,2.625,5.25,10.5,21,42,84 01010,0.0390625,0.1640625,0.328125,0.65625,1.3125,2.625,5.25,10.5
01011,0.34375,1.34375,2.6875,5.375,10.75,21.5,43,86 01011,0.04296875,0.16796875,0.3359375,0.671875,1.34375,2.6875,5.375,10.75
01100,0.375,1.375,2.75,5.5,11,22,44,88 01100,0.046875,0.171875,0.34375,0.6875,1.375,2.75,5.5,11
01101,0.40625,1.40625,2.8125,5.625,11.25,22.5,45,90 01101,0.05078125,0.17578125,0.3515625,0.703125,1.40625,2.8125,5.625,11.25
01110,0.4375,1.4375,2.875,5.75,11.5,23,46,92 01110,0.0546875,0.1796875,0.359375,0.71875,1.4375,2.875,5.75,11.5
01111,0.46875,1.46875,2.9375,5.875,11.75,23.5,47,94 01111,0.05859375,0.18359375,0.3671875,0.734375,1.46875,2.9375,5.875,11.75
10000,0.5,1.5,3,6,12,24,48,96 10000,0.0625,0.1875,0.375,0.75,1.5,3,6,12
10001,0.53125,1.53125,3.0625,6.125,12.25,24.5,49,98 10001,0.06640625,0.19140625,0.3828125,0.765625,1.53125,3.0625,6.125,12.25
10010,0.5625,1.5625,3.125,6.25,12.5,25,50,100 10010,0.0703125,0.1953125,0.390625,0.78125,1.5625,3.125,6.25,12.5
10011,0.59375,1.59375,3.1875,6.375,12.75,25.5,51,102 10011,0.07421875,0.19921875,0.3984375,0.796875,1.59375,3.1875,6.375,12.75
10100,0.625,1.625,3.25,6.5,13,26,52,104 10100,0.078125,0.203125,0.40625,0.8125,1.625,3.25,6.5,13
10101,0.65625,1.65625,3.3125,6.625,13.25,26.5,53,106 10101,0.08203125,0.20703125,0.4140625,0.828125,1.65625,3.3125,6.625,13.25
10110,0.6875,1.6875,3.375,6.75,13.5,27,54,108 10110,0.0859375,0.2109375,0.421875,0.84375,1.6875,3.375,6.75,13.5
10111,0.71875,1.71875,3.4375,6.875,13.75,27.5,55,110 10111,0.08984375,0.21484375,0.4296875,0.859375,1.71875,3.4375,6.875,13.75
11000,0.75,1.75,3.5,7,14,28,56,112 11000,0.09375,0.21875,0.4375,0.875,1.75,3.5,7,14
11001,0.78125,1.78125,3.5625,7.125,14.25,28.5,57,114 11001,0.09765625,0.22265625,0.4453125,0.890625,1.78125,3.5625,7.125,14.25
11010,0.8125,1.8125,3.625,7.25,14.5,29,58,116 11010,0.1015625,0.2265625,0.453125,0.90625,1.8125,3.625,7.25,14.5
11011,0.84375,1.84375,3.6875,7.375,14.75,29.5,59,118 11011,0.10546875,0.23046875,0.4609375,0.921875,1.84375,3.6875,7.375,14.75
11100,0.875,1.875,3.75,7.5,15,30,60,120 11100,0.109375,0.234375,0.46875,0.9375,1.875,3.75,7.5,15
11101,0.90625,1.90625,3.8125,7.625,15.25,30.5,61,122 11101,0.11328125,0.23828125,0.4765625,0.953125,1.90625,3.8125,7.625,15.25
11110,0.9375,1.9375,3.875,7.75,15.5,31,62,124 11110,0.1171875,0.2421875,0.484375,0.96875,1.9375,3.875,7.75,15.5
11111,0.96875,1.96875,3.9375,7.875,15.75,31.5,63,126 11111,0.12109375,0.24609375,0.4921875,0.984375,1.96875,3.9375,7.875,15.75
LSB LSB
## Tracker Note Effects ## 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 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 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') Uint8 Flags for Global Behaviour (effect symbol '1')
0b 0000 0Ffp 0b 0000 0ffp
p: panning law (0=linear, 1=equal-power) 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) 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 (bit 2 reserved — was 'm' fadeout-zero policy, removed; fadeout
scaling now lives entirely in the converter — see byte 172/173 scaling now lives entirely in the converter — see byte 172/173
of the instrument record for engine semantics) of the instrument record for engine semantics)

View File

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

View File

@@ -1,7 +1,15 @@
package net.torvald.tsvm 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 @JvmInline
value class ThreeFiveMiniUfloat(val index: Int = 0) { value class ThreeFiveMiniUfloat(val index: Int = 0) {
@@ -11,7 +19,7 @@ value class ThreeFiveMiniUfloat(val index: Int = 0) {
} }
companion object { 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 { private fun fromFloatToIndex(fval: Float): Int {
val (llim, hlim) = binarySearchInterval(fval, LUT) val (llim, hlim) = binarySearchInterval(fval, LUT)

View File

@@ -12,6 +12,7 @@ import java.io.OutputStream
import java.nio.charset.Charset import java.nio.charset.Charset
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.absoluteValue
import kotlin.math.ceil import kotlin.math.ceil
@@ -549,7 +550,7 @@ class VM(
// println("peek $addr -> ${offset}@${memspace?.javaClass?.canonicalName}") // println("peek $addr -> ${offset}@${memspace?.javaClass?.canonicalName}")
return if (memspace == null) return if (memspace == null)
throw NullPointerException()//null throw OpenBusException(addr)//null
else if (memspace is UnsafePtr) { else if (memspace is UnsafePtr) {
if (addr >= memspace.size) if (addr >= memspace.size)
throw ErrorIllegalAccess(this, addr) throw ErrorIllegalAccess(this, addr)
@@ -564,7 +565,7 @@ class VM(
val (memspace, offset) = translateAddr(addr) val (memspace, offset) = translateAddr(addr)
return if (memspace == null) return if (memspace == null)
throw NullPointerException()//null throw OpenBusException(addr)//null
else if (memspace is UnsafePtr) { else if (memspace is UnsafePtr) {
if (addr >= memspace.size) if (addr >= memspace.size)
throw ErrorIllegalAccess(this, addr) throw ErrorIllegalAccess(this, addr)
@@ -583,7 +584,7 @@ class VM(
val (memspace, offset) = translateAddr(addr) val (memspace, offset) = translateAddr(addr)
return if (memspace == null) return if (memspace == null)
throw NullPointerException()//null throw OpenBusException(addr)//null
else if (memspace is UnsafePtr) { else if (memspace is UnsafePtr) {
if (addr >= memspace.size) if (addr >= memspace.size)
throw ErrorIllegalAccess(this, addr) throw ErrorIllegalAccess(this, addr)
@@ -608,7 +609,7 @@ class VM(
val (memspace, offset) = translateAddr(addr) val (memspace, offset) = translateAddr(addr)
return if (memspace == null) return if (memspace == null)
throw NullPointerException()//null throw OpenBusException(addr)//null
else if (memspace is UnsafePtr) { else if (memspace is UnsafePtr) {
if (addr >= memspace.size) if (addr >= memspace.size)
throw ErrorIllegalAccess(this, addr) throw ErrorIllegalAccess(this, addr)
@@ -853,3 +854,10 @@ class PeripheralEntry2(
) )
internal fun Int.kB() = this * 1024L 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. // Trackers may use different labelling conventions (e.g. C5) for Middle C.
// For non-tracker context, Middle C shall be labelled as C4. // For non-tracker context, Middle C shall be labelled as C4.
const val AMIGA_BASE_PERIOD = 428.0 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): // 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 var mp2Context = mp2Env.initialise()
private fun decodeMp2() { 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) mp2Env.decodeFrameU8(mp2Context, periMmioBase - 2368, true, periMmioBase - 64)
} }
@@ -1209,6 +1220,34 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
return amigaPeriodToNoteVal(newPeriod) 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 * Resolve the active wrap region for an envelope based on the LOOP and
* SUSTAIN words and key state. * 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 s1 = (b1 - 127.5) / 127.5
val sample = s0 + (s1 - s0) * frac 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) { if (voice.forward) {
voice.samplePos += voice.playbackRate voice.samplePos += voice.playbackRate
// When the sustain bit is set, key-off escapes the loop: the sample plays past // 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 = val effectiveLoopMode =
if (inst.sampleLoopSustain && voice.keyOff) 0 else (inst.loopMode and 3) if (inst.sampleLoopSustain && voice.keyOff) 0 else (inst.loopMode and 3)
when (effectiveLoopMode) { 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) 1 -> if (voice.samplePos >= loopEnd) voice.samplePos -= (loopEnd - loopStart).coerceAtLeast(1.0)
2 -> if (voice.samplePos >= loopEnd) { voice.samplePos = loopEnd; voice.forward = false } 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 { } else {
voice.samplePos -= voice.playbackRate voice.samplePos -= voice.playbackRate
@@ -1614,6 +1664,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
return sample 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. * 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. * 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 voice.envPfValue = if (voice.hasPfEnv) inst.pfEnvelopes[0].value / 255.0 else 0.5
// Fadeout starts at unity; advances only after key-off. // Fadeout starts at unity; advances only after key-off.
voice.fadeoutVolume = 1.0 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. // Auto-vibrato sweep ramp restarts on every fresh trigger.
voice.autoVibPhase = 0 voice.autoVibPhase = 0
voice.autoVibTicksSinceTrigger = 0 voice.autoVibTicksSinceTrigger = 0
@@ -1655,19 +1722,24 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
(Math.random() * (2 * inst.volumeSwing + 1)).toInt() - inst.volumeSwing else 0 (Math.random() * (2 * inst.volumeSwing + 1)).toInt() - inst.volumeSwing else 0
voice.randomPanBias = if (inst.panSwing != 0) voice.randomPanBias = if (inst.panSwing != 0)
(Math.random() * (2 * inst.panSwing + 1)).toInt() - inst.panSwing else 0 (Math.random() * (2 * inst.panSwing + 1)).toInt() - inst.panSwing else 0
// Default pan: applied unless the pattern row has already overridden channelPan. // Default pan / pitch-pan separation: only re-applied when the row carried an instrument
// The pan envelope's 'p' flag ("use default pan") lives in the pan LOOP word at bit 7. // byte. A note-only retrigger (instId == 0) inherits the channel's existing pan, mirroring
if ((inst.panEnvLoop ushr 7) and 1 != 0) { // the volume policy below.
voice.channelPan = inst.defaultPan if (instId != 0) {
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63) // 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.
// Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan. if ((inst.panEnvLoop ushr 7) and 1 != 0) {
// PPS is signed (-32..+32), full-scale at one octave (4096 4096-TET units) above PPC. voice.channelPan = inst.defaultPan
if (inst.pitchPanSeparation != 0) { voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
val noteDelta = (noteVal - inst.pitchPanCentre).toDouble() / 4096.0 }
val panShift = (noteDelta * inst.pitchPanSeparation * 4.0).toInt() // ~×4 = 32→128 swing // Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan.
voice.channelPan = (voice.channelPan + panShift).coerceIn(0, 255) // PPS is signed (-32..+32), full-scale at one octave (4096 4096-TET units) above PPC.
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63) if (inst.pitchPanSeparation != 0) {
val noteDelta = (noteVal - inst.pitchPanCentre).toDouble() / 4096.0
val panShift = (noteDelta * inst.pitchPanSeparation * 4.0).toInt() // ~×4 = 32→128 swing
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. // 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. // 255 = filter off (IT high-bit-clear); 0..254 = active range matching IT 0..127 at double resolution.
@@ -1679,11 +1751,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.noteVal = noteVal voice.noteVal = noteVal
voice.basePitch = noteVal voice.basePitch = noteVal
voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new 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) voice.playbackRate = computePlaybackRate(inst, noteVal)
// Fresh trigger resets channel volume to full ($3F). Per-instrument scaling lives in // Fresh trigger resets channel volume to full ($3F) ONLY when the row carried an
// instGlobalVolume (byte 171), which the mixer applies as a multiplier. Converters // instrument byte; a note-only retrigger (instId == 0) inherits the channel's existing
// therefore no longer need to emit SEL_SET=Sv on note-trigger rows. // volume so the user can sustain a held volume across re-triggered notes. Per-instrument
voice.channelVolume = if (volOverride >= 0) volOverride.coerceIn(0, 0x3F) else 0x3F // 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.rowVolume = voice.channelVolume
voice.noteWasCut = false voice.noteWasCut = false
voice.noteFading = false voice.noteFading = false
@@ -1824,6 +1903,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
v.noteVal = src.noteVal v.noteVal = src.noteVal
v.basePitch = src.basePitch v.basePitch = src.basePitch
v.amigaPeriod = src.amigaPeriod v.amigaPeriod = src.amigaPeriod
v.linearFreq = src.linearFreq
v.volEnvOn = src.volEnvOn v.volEnvOn = src.volEnvOn
v.panEnvOn = src.panEnvOn v.panEnvOn = src.panEnvOn
v.pfEnvOn = src.pfEnvOn 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 // Instrument byte on a porta row reloads the channel's default
// volume even though the sample isn't retriggered. Mirrors schism // volume even though the sample isn't retriggered. Mirrors schism
// csf_instrument_change (effects.c:1302) which writes // 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) { if (row.instrment != 0) {
voice.instrumentId = row.instrment voice.instrumentId = row.instrment
voice.channelVolume = 0x3F voice.channelVolume = 0x3F
voice.rowVolume = 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) { } 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 // Note delay: defer trigger to the requested tick. NNA fires when the
@@ -1989,13 +2077,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
EffectOp.OP_NONE -> {} EffectOp.OP_NONE -> {}
EffectOp.OP_1 -> { EffectOp.OP_1 -> {
// 1 $xx00 — Global behaviour flags byte in the high byte (see TAUD_NOTE_EFFECTS.md §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 0 (p): 0=linear pan, 1=equal-power pan
// bit 1 (f): 0=linear pitch slides, 1=Amiga-mode pitch slides // bits 1-2 (ff): 0=linear pitch, 1=Amiga period, 2=linear frequency (Hz/tick),
// bit 2 : reserved (was 'm' fadeout-zero policy; removed — converters now scale // 3=reserved
// source fadeout into Taud-native units, so the engine has a single divisor)
val flags = rawArg ushr 8 val flags = rawArg ushr 8
ts.panLaw = flags and 1 ts.panLaw = flags and 1
ts.amigaMode = (flags and 2) != 0 ts.toneMode = (flags ushr 1) and 3
} }
EffectOp.OP_8 -> { EffectOp.OP_8 -> {
// 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §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 } val arg = resolveArg(rawArg, voice.mem.ef).also { if (rawArg != 0) voice.mem.ef = it }
if ((arg and 0xF000) == 0xF000) { if ((arg and 0xF000) == 0xF000) {
val mag = arg and 0x0FFF val mag = arg and 0x0FFF
voice.noteVal = if (ts.amigaMode) voice.noteVal = when (ts.toneMode) {
amigaSlideOnce(voice.noteVal, -mag).coerceIn(0, 0xFFFE) 1 -> amigaSlideOnce(voice.noteVal, -mag) // Amiga: subtract from pitch ⇒ adds period
else 2 -> linearFreqSlideOnce(voice.noteVal, -mag) // Hz/tick: pitch down ⇒ -Hz
(voice.noteVal - mag).coerceIn(0, 0xFFFE) else -> voice.noteVal - mag // linear 4096-TET
}.coerceIn(1, 0xFFFD)
voice.basePitch = voice.noteVal voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0 // reseed on next per-tick slide voice.amigaPeriod = -1.0 // reseed on next per-tick slide
voice.linearFreq = -1.0
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal) voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
} else { } else {
voice.slideMode = 1; voice.slideArg = -arg voice.slideMode = 1; voice.slideArg = -arg
voice.amigaPeriod = -1.0 // reseed at the start of a fresh multi-tick slide voice.amigaPeriod = -1.0 // reseed at the start of a fresh multi-tick slide
voice.linearFreq = -1.0
} }
} }
EffectOp.OP_F -> { EffectOp.OP_F -> {
val arg = resolveArg(rawArg, voice.mem.ef).also { if (rawArg != 0) voice.mem.ef = it } val arg = resolveArg(rawArg, voice.mem.ef).also { if (rawArg != 0) voice.mem.ef = it }
if ((arg and 0xF000) == 0xF000) { if ((arg and 0xF000) == 0xF000) {
val mag = arg and 0x0FFF val mag = arg and 0x0FFF
voice.noteVal = if (ts.amigaMode) voice.noteVal = when (ts.toneMode) {
amigaSlideOnce(voice.noteVal, mag).coerceIn(0, 0xFFFE) 1 -> amigaSlideOnce(voice.noteVal, mag)
else 2 -> linearFreqSlideOnce(voice.noteVal, mag)
(voice.noteVal + mag).coerceIn(0, 0xFFFE) else -> voice.noteVal + mag
}.coerceIn(1, 0xFFFD)
voice.basePitch = voice.noteVal voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0 voice.amigaPeriod = -1.0
voice.linearFreq = -1.0
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal) voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
} else { } else {
voice.slideMode = 2; voice.slideArg = arg voice.slideMode = 2; voice.slideArg = arg
voice.amigaPeriod = -1.0 voice.amigaPeriod = -1.0
voice.linearFreq = -1.0
} }
} }
EffectOp.OP_G -> { EffectOp.OP_G -> {
@@ -2203,9 +2296,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
when (sub) { when (sub) {
0x1 -> voice.glissandoOn = (x != 0) 0x1 -> voice.glissandoOn = (x != 0)
0x2 -> { 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.basePitch = voice.noteVal
voice.amigaPeriod = -1.0 voice.amigaPeriod = -1.0
voice.linearFreq = -1.0
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal) voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
} }
0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 } 0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 }
@@ -2291,24 +2385,46 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Pitch slides (E/F coarse on tick > 0). // Pitch slides (E/F coarse on tick > 0).
if (ts.tickInRow > 0 && (voice.slideMode == 1 || voice.slideMode == 2)) { if (ts.tickInRow > 0 && (voice.slideMode == 1 || voice.slideMode == 2)) {
voice.noteVal = if (ts.amigaMode) voice.noteVal = when (ts.toneMode) {
amigaSlideTick(voice, voice.slideArg).coerceIn(0, 0xFFFE) 1 -> amigaSlideTick(voice, voice.slideArg)
else 2 -> linearFreqSlideTick(voice, voice.slideArg)
(voice.noteVal + voice.slideArg).coerceIn(0, 0xFFFE) else -> voice.noteVal + voice.slideArg
}.coerceIn(1, 0xFFFD)
voice.basePitch = voice.noteVal 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) { if (voice.tonePortaTarget >= 0 && ts.tickInRow > 0) {
val target = voice.tonePortaTarget val target = voice.tonePortaTarget
val sp = voice.tonePortaSpeed val sp = voice.tonePortaSpeed
val delta = if (target > voice.noteVal) sp else -sp if (ts.toneMode == 2) {
voice.noteVal += delta if (voice.linearFreq < 0.0) voice.linearFreq = noteValToFreqHz(voice.noteVal)
if ((delta > 0 && voice.noteVal >= target) || (delta < 0 && voice.noteVal <= target)) { val targetFreq = noteValToFreqHz(target)
voice.noteVal = target; voice.tonePortaTarget = -1 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)) {
voice.noteVal = target; voice.tonePortaTarget = -1
}
voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0 // tone porta works in linear noteVal space; reseed period
voice.linearFreq = -1.0
} }
voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0 // tone porta works in linear noteVal space; reseed period
} }
// Volume slides (D coarse on tick > 0). // Volume slides (D coarse on tick > 0).
@@ -2348,14 +2464,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.vibratoActive) { if (voice.vibratoActive) {
val sine = lfoSample(voice.vibratoLfoPos, voice.vibratoWave) val sine = lfoSample(voice.vibratoLfoPos, voice.vibratoWave)
val pitchDelta = (sine * voice.mem.huDepth) shr voice.vibratoFineShift 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 voice.vibratoLfoPos = (voice.vibratoLfoPos + voice.mem.huSpeed * 4) and 0xFF
} }
// Glissando (S$1x) — snap pitchToMixer to nearest semitone but leave noteVal smooth. // Glissando (S$1x) — snap pitchToMixer to nearest semitone but leave noteVal smooth.
if (voice.glissandoOn) { if (voice.glissandoOn) {
val semis = ((pitchToMixer * 12 + 2048) / 4096) 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. // Tremolo (R) — modulates output volume around base.
@@ -2378,7 +2494,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.arpActive) { if (voice.arpActive) {
val voiceIdx = ts.tickInRow % 3 val voiceIdx = ts.tickInRow % 3
val arpDelta = when (voiceIdx) { 1 -> voice.arpOff1 shl 8; 2 -> voice.arpOff2 shl 8; else -> 0 } 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 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() ((voice.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
else 0 else 0
val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(0, 0xFFFE) val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(1, 0xFFFD)
voice.playbackRate = computePlaybackRate(inst, finalPitch) voice.playbackRate = computePlaybackRate(inst, finalPitch)
// Filter envelope (filter mode): scale baseCut by envValue (0..1, 0.5 = unity). // 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) val pitchEnvDelta = if (bg.hasPfEnv && bg.pfEnvOn && !bg.envPfIsFilter)
((bg.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt() ((bg.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
else 0 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) bg.playbackRate = computePlaybackRate(inst, finalPitch)
// Filter-mode pf envelope: same scaling rule as foreground. // Filter-mode pf envelope: same scaling rule as foreground.
if (bg.hasPfEnv && bg.pfEnvOn && bg.envPfIsFilter) { 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 rGain = if (pan < 0x80) pan / 128.0 else 1.0
} }
} }
mixL += s * vol * lGain // Sample-end ramp-out: snapshot gain, advance the ramp, deactivate at zero.
mixR += s * vol * rGain 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 // 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. // 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 lGain: Double
val rGain: Double val rGain: Double
when (ts.panLaw) { 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 -> { else -> {
lGain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0 lGain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0
rGain = if (pan < 0x80) pan / 128.0 else 1.0 rGain = if (pan < 0x80) pan / 128.0 else 1.0
} }
} }
mixL += s * vol * lGain val rampGain = if (bg.rampOutSamples > 0) {
mixR += s * vol * rGain 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) 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. // Volume fadeout — engaged after key-off, decays to 0 at rate inst.volumeFadeoutLow.
var fadeoutVolume = 1.0 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). // Auto-vibrato (per-sample on the IT side, hoisted to the instrument here).
var autoVibPhase = 0 // 8-bit phase counter var autoVibPhase = 0 // 8-bit phase counter
var autoVibTicksSinceTrigger = 0 // for sweep ramp-up 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). // sub-noteVal precision through repeated round-trip rounding (see amigaSlideTick).
// -1.0 means "needs reseed from current noteVal". // -1.0 means "needs reseed from current noteVal".
var amigaPeriod: Double = -1.0 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). // Per-row effect state (set in applyTrackerRow, consumed by applyTrackerTick).
var rowEffect = 0 var rowEffect = 0
@@ -3001,7 +3146,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Global mixer config (effect 1). // Global mixer config (effect 1).
var panLaw = 0 // 0 = linear balance (default), 1 = equal-power 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). // 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 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 initialGlobalFlags = byte
trackerState?.let { ts -> trackerState?.let { ts ->
ts.panLaw = byte and 1 ts.panLaw = byte and 1
ts.amigaMode = (byte and 2) != 0 ts.toneMode = (byte ushr 1) and 3
} }
} }
8 -> { bpm = byte + 24 } 8 -> { bpm = byte + 24 }
@@ -3149,7 +3299,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
ts.sexWinningChannel = -1 ts.sexWinningChannel = -1
ts.finePatternDelayExtra = 0 ts.finePatternDelayExtra = 0
ts.panLaw = initialGlobalFlags and 1 ts.panLaw = initialGlobalFlags and 1
ts.amigaMode = (initialGlobalFlags and 2) != 0 ts.toneMode = (initialGlobalFlags ushr 1) and 3
ts.voices.forEach { ts.voices.forEach {
it.active = false it.active = false
it.channelVolume = 0x3F 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.terrarum.modulecomputers.virtualcomputer.tvd.toUint
import net.torvald.tsvm.VM import net.torvald.tsvm.VM
import net.torvald.tsvm.memAddrToReadable
import java.util.* import java.util.*
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
import kotlin.math.ceil import kotlin.math.ceil
@@ -398,7 +399,7 @@ class MP2Env(val vm: VM) {
}; };
// check for valid header: syncword OK, MPEG-Audio Layer 2 // check for valid header: syncword OK, MPEG-Audio Layer 2
if ((syspeek(mp2_frame!!) != 0xFF) || ((syspeek(mp2_frame!! + 1*incr) and 0xFE) != 0xFC)){ 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 // set up the bitstream reader

View File

@@ -54,8 +54,8 @@ public class AppLoader {
ArrayList defaultPeripherals = new ArrayList(); ArrayList defaultPeripherals = new ArrayList();
defaultPeripherals.add(new Pair(3, new PeripheralEntry2("net.torvald.tsvm.peripheral.AudioAdapter", vm))); defaultPeripherals.add(new Pair(2, 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(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); 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, SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
J_SEMI_TABLE, J_SEMI_TABLE,
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns, 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, 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 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 ───────────────────────────────────────────────────────────── # ── Data classes ─────────────────────────────────────────────────────────────
class XMHeader: 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). # Set finetune — convert to S5x sub-effect (4-bit signed nibble).
return (TOP_S, 0x5000 | (val << 8), None, None) return (TOP_S, 0x5000 | (val << 8), None, None)
if sub == 0x6: if sub == 0x6:
# Set loop point / loop. Taud S6x = fine pattern delay; the # XM E6x = pattern loop (E60 sets loop start, E6x with x>0 loops
# closest analogue here is dropping with a warn if val>0. # x times). Maps directly onto Taud SBx, which has identical
vprint(f" dropped E6{val:X} (set loop) at ch{ch} row{row}") # semantics — the engine handles per-voice loopStartRow /
return (TOP_NONE, 0, None, None) # loopCount in applySEffect (sub 0xB).
return (TOP_S, 0xB000 | (val << 8), None, None)
if sub == 0x8: if sub == 0x8:
# Pan position 0..15 → set pan column (XM nybble × 17 → 8-bit). # Pan position 0..15 → set pan column (XM nybble × 17 → 8-bit).
pan8 = (val << 4) | val pan8 = (val << 4) | val
@@ -750,6 +696,58 @@ def remap_b_effects_xm(chunks: list, chunk_map: list,
row.effect_arg = taud_cue & 0xFF 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 ─────────────────────────────────────────────────── # ── Sample / instrument bin ───────────────────────────────────────────────────
class _XMSampleProxy: 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: if k < len(nodes) - 1:
next_frame, _ = nodes[k + 1] next_frame, _ = nodes[k + 1]
delta_sec = max(0.0, (next_frame - frame) / ticks_per_sec) delta_sec = max(0.0, (next_frame - frame) / ticks_per_sec)
mf_idx = _nearest_minifloat(delta_sec) mf_idx = nearest_minifloat(delta_sec)
else: else:
mf_idx = 0 mf_idx = 0
else: else:
@@ -1053,8 +1051,16 @@ def build_sample_inst_bin_xm(proxies: list) -> tuple:
# ── Pattern bin builder ─────────────────────────────────────────────────────── # ── Pattern bin builder ───────────────────────────────────────────────────────
def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int, def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int,
inst_to_taud_slot: dict, amiga_mode: bool = False) -> bytes: inst_to_taud_slot: dict, amiga_mode: bool = False,
"""Render one Taud channel's 512-byte pattern from a 64-row chunk grid.""" 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) out = bytearray(PATTERN_BYTES)
if ch_idx >= len(chunk_grid): if ch_idx >= len(chunk_grid):
rows = [XMRow()] * PATTERN_ROWS rows = [XMRow()] * PATTERN_ROWS
@@ -1122,6 +1128,17 @@ def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int,
else: else:
pan_sel, pan_value = SEL_FINE, 0 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) vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6)
pan_byte = (pan_value & 0x3F) | ((pan_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) 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 ───────────────────────────────────────────────────────── # ── Pattern bin ─────────────────────────────────────────────────────────
total_taud_pats = len(taud_cue_list) * C total_taud_pats = len(taud_cue_list) * C
if total_taud_pats > NUM_PATTERNS_MAX: if total_taud_pats > NUM_PATTERNS_MAX:
@@ -1256,10 +1284,13 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
pat_bin = bytearray() pat_bin = bytearray()
for ci in taud_cue_list: for ci in taud_cue_list:
cg = chunks[ci] cg = chunks[ci]
chunk_marks = keyoff_zero_marks.get(ci, frozenset())
for vi, ch in enumerate(active_channels): 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], pat_bin += build_pattern_xm(cg, ch, default_pans[vi],
resolve_inst_slot, 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) pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
orig_count = len(taud_cue_list) * C orig_count = len(taud_cue_list) * C