mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
Compare commits
10 Commits
937d3e27ed
...
e49140902b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e49140902b | ||
|
|
3182ae9146 | ||
|
|
34b3b83d65 | ||
|
|
a767eebc2e | ||
|
|
6ce8d2cc1e | ||
|
|
9017b76f6d | ||
|
|
449885c1ea | ||
|
|
59bbe9e503 | ||
|
|
cc492c4ead | ||
|
|
ec0f41b574 |
@@ -151,12 +151,13 @@ D's 16-bit argument encodes four mutually exclusive modes using the top nibble a
|
||||
|
||||
## E $xxxx — Pitch slide down by $xxxx
|
||||
|
||||
**Plain.** Lowers the channel's pitch by the argument per tick. The coarse argument has **two distinct interpretations** chosen by the song-table `f` flag (effect `1`, bit 1):
|
||||
**Plain.** Lowers the channel's pitch by the argument per tick. The coarse argument has **three distinct interpretations** chosen by the song-table `ff` field (effect `1`, bits 1-2):
|
||||
|
||||
- **Linear mode** (`f` unset, default): the argument is a value in the 4096-TET pitch grid, subtracted directly from the stored pitch. `E $0155` ≈ one semitone per tick.
|
||||
- **Amiga (cycle-based) mode** (`f` set): the argument is a **raw ProTracker/ST3 period unit count** — the same byte the original tracker stored on disk, *unscaled*. The engine converts the channel's stored 4096-TET pitch back to an Amiga period, subtracts the argument from that period directly, then converts the result back to 4096-TET. `E $0001` therefore corresponds to PT `201` and produces the characteristic non-linear pitch drift of ProTracker-style slides (lower pitches drift more slowly in semitone terms than higher pitches).
|
||||
- **Linear mode** (`ff = 0`, default): the argument is a value in the 4096-TET pitch grid, subtracted directly from the stored pitch. `E $0155` ≈ one semitone per tick.
|
||||
- **Amiga (cycle-based) mode** (`ff = 1`): the argument is a **raw ProTracker/ST3 period unit count** — the same byte the original tracker stored on disk, *unscaled*. The engine converts the channel's stored 4096-TET pitch back to an Amiga period, subtracts the argument from that period directly, then converts the result back to 4096-TET. `E $0001` therefore corresponds to PT `201` and produces the characteristic non-linear pitch drift of ProTracker-style slides (lower pitches drift more slowly in semitone terms than higher pitches).
|
||||
- **Linear-frequency mode** (`ff = 2`): the argument is **Hz/tick** at A4 = 440 Hz / C4 ≈ 261.6256 Hz reference. The engine converts the stored pitch to audible frequency, subtracts the argument from that frequency, then converts the result back to 4096-TET. `E $0010` is the verbatim Monotone `210` (drop 16 Hz/tick); the slide produces a constant *frequency* delta per tick, so the perceived semitone drop is larger at low pitches and smaller at high pitches — exactly Monotone's tracker semantics.
|
||||
|
||||
Because Amiga period units fit in a single byte (PT/ST3 max value $FF), the coarse range in Amiga mode never approaches the $F000 fine-slide marker, so the same argument-format selector still distinguishes coarse from fine. **Fine slides (`E $Fxxx`) follow the same dual-interpretation rule as coarse**: in linear mode the low 12 bits are 4096-TET units; in Amiga mode they are raw tracker period units (the PT `E2x` / ST3 `EFx` or `EEx` digit), applied once per row at tick 0 in period space. A coarse slide uses the full value range; a fine slide applies only once per row.
|
||||
Because Amiga period units (and Monotone Hz/tick) fit in a single byte (PT/ST3 max value $FF, MONOTONE max $3F), the coarse range never approaches the $F000 fine-slide marker, so the same argument-format selector still distinguishes coarse from fine across all three modes. **Fine slides (`E $Fxxx`) follow the same mode-selection rule as coarse**: linear mode reads the low 12 bits as 4096-TET units, Amiga mode reads them as raw tracker period units, and linear-frequency mode reads them as Hz. A coarse slide uses the full value range; a fine slide applies only once per row.
|
||||
|
||||
Coarse and fine modes are distinguished by the high nibble of the argument:
|
||||
|
||||
@@ -166,16 +167,19 @@ Coarse and fine modes are distinguished by the high nibble of the argument:
|
||||
|
||||
**Compatibility.** ST3 pitch slides operate on Amiga periods or linear slide units; Taud's storage depends on the song-table mode flag:
|
||||
|
||||
- **Linear-source ST3 song** (`linear_slides` set in S3M flags → Taud `f` bit clear):
|
||||
- **Linear-source ST3 song** (`linear_slides` set in S3M flags → Taud `ff = 0`):
|
||||
- ST3 `Exx` coarse (where `xx < $E0`) → Taud `E round($00xx × 64/3)` (1 ST3 coarse unit = 1/16 semitone = 64/3 ≈ 21.33 Taud units, rounded).
|
||||
- ST3 `EFx` fine → Taud `E $F0 round(x × 16/3)` (1 ST3 fine unit = 1/64 semitone = 16/3 ≈ 5.33 Taud units, applied once per row).
|
||||
- ST3 `EEx` extra-fine → Taud `E $F0 round(x × 16/3)` (same unit as fine, applied once per row).
|
||||
|
||||
- **Amiga-source ST3/PT song** (`linear_slides` clear → Taud `f` bit set):
|
||||
- **Amiga-source ST3/PT song** (`linear_slides` clear → Taud `ff = 1`):
|
||||
- ST3 `Exx` coarse / PT `2xx` → Taud `E $00xx` **verbatim**, with no `× 64/3` scaling. The engine reads the stored byte as Amiga period units and applies it in period space, recovering the original tracker's exact period-step count.
|
||||
- ST3 `EFx` fine / `EEx` extra-fine / PT `E2x` → Taud `E $F00x` **verbatim** (raw period-unit nibble in the low 4 bits), with no `× 16/3` scaling. The engine performs the once-per-row fine slide in Amiga period space, mirroring the coarse arithmetic.
|
||||
|
||||
The Amiga-mode flag therefore controls **two** decoder behaviours simultaneously: (a) which numeric scale the converter should have used when emitting coarse arguments, and (b) which arithmetic the engine performs on those arguments per tick. Converters MUST set bit 1 (`f`) of the song-table flags byte whenever they emit raw period-unit coarse arguments, and MUST NOT mix the two scales within one Taud song.
|
||||
- **MONOTONE source** (Taud `ff = 2`):
|
||||
- MONOTONE `2xx` → Taud `E $00xx` **verbatim** (Hz/tick). The engine converts the stored pitch to frequency, subtracts the argument, and converts back. MONOTONE has no fine-slide form; converters never emit `E $Fxxx` for ff=2 sources.
|
||||
|
||||
The mode flag therefore controls **two** decoder behaviours simultaneously: (a) which numeric scale the converter should have used when emitting coarse arguments, and (b) which arithmetic the engine performs on those arguments per tick. Converters MUST set bits 1-2 (`ff`) of the song-table flags byte to match the units they emit, and MUST NOT mix scales within one Taud song.
|
||||
|
||||
Because E and F share memory in Taud (narrower than ST3's broad shared memory), an ST3 song that used `E00` or `F00` to recall a D, G, or Q argument will break on import; the converter must eagerly resolve ST3 recalls into explicit Taud arguments rather than relying on memory.
|
||||
|
||||
@@ -188,10 +192,11 @@ on row start:
|
||||
else: memory_EF = raw
|
||||
if (raw & $F000) == $F000: # fine, applied once on tick 0
|
||||
mag = raw & $0FFF
|
||||
if amiga_mode:
|
||||
# mag is a raw tracker period-unit count; subtract pitch ⇒ add period.
|
||||
if tone_mode == 1: # Amiga: mag is raw period units; pitch down ⇒ +period
|
||||
pitch = amiga_slide_down(pitch, mag)
|
||||
else:
|
||||
elif tone_mode == 2: # linear-freq: mag is Hz/tick; pitch down ⇒ −freq
|
||||
pitch = linear_freq_slide(pitch, −mag)
|
||||
else: # linear: mag is 4096-TET units
|
||||
pitch -= mag
|
||||
mode_this_row = FINE
|
||||
else: # coarse
|
||||
@@ -200,12 +205,18 @@ on row start:
|
||||
|
||||
on tick > 0:
|
||||
if mode_this_row == COARSE:
|
||||
if amiga_mode:
|
||||
if tone_mode == 1:
|
||||
# slide_amount_this_row is a raw tracker period-unit count (no × 64/3 scaling).
|
||||
# period = AMIGA_BASE_PERIOD × 2^(−(pitch − C4) / 4096)
|
||||
# period_new = period + slide_amount_this_row # E subtracts pitch ⇒ adds period
|
||||
# pitch = C4 + 4096 × log2(AMIGA_BASE_PERIOD / period_new)
|
||||
pitch = amiga_slide_down(pitch, slide_amount_this_row)
|
||||
elif tone_mode == 2:
|
||||
# slide_amount_this_row is Hz/tick (verbatim from MONOTONE 2xx).
|
||||
# freq = LINEAR_FREQ_C4_HZ × 2^((pitch − C4) / 4096)
|
||||
# freq_new = max(freq − slide_amount_this_row, 1)
|
||||
# pitch = C4 + 4096 × log2(freq_new / LINEAR_FREQ_C4_HZ)
|
||||
pitch = linear_freq_slide(pitch, −slide_amount_this_row)
|
||||
else:
|
||||
pitch -= slide_amount_this_row
|
||||
```
|
||||
@@ -216,9 +227,9 @@ Glissando control (S $1x) snaps the output pitch to the nearest semitone after e
|
||||
|
||||
## F $xxxx — Pitch slide up by $xxxx
|
||||
|
||||
**Plain.** Raises the channel's pitch by the argument per tick, with the same mode-selection scheme as E. Coarse, fine, memory behaviour, and Amiga-mode handling are identical in form but inverted in direction. The same dual-interpretation rule applies to **both** coarse and fine arguments: 4096-TET units in linear mode, raw tracker period units in Amiga mode.
|
||||
**Plain.** Raises the channel's pitch by the argument per tick, with the same mode-selection scheme as E. Coarse, fine, memory behaviour, and Amiga / linear-freq mode handling are identical in form but inverted in direction. The same triple-interpretation rule applies to **both** coarse and fine arguments: 4096-TET units in linear mode, raw tracker period units in Amiga mode, Hz/tick in linear-frequency mode.
|
||||
|
||||
**Compatibility.** Same as E. In linear-source songs, ST3 `Fxx` coarse converts using `round(x × 64/3)` and `FFx`/`FEx` fine/extra-fine use `round(x × 16/3)`. In Amiga-source songs (PT or S3M with `linear_slides` clear), both forms are stored verbatim: `Fxx` coarse → `F $00xx`, and `FFx`/`FEx` fine/extra-fine / PT `E1x` → `F $F00x`. F and E share one memory slot in Taud. Amiga-mode behaviour is controlled by the same `f` flag as E; under that flag, both coarse (per-tick) and fine (tick-0 only) F slides are applied in period space.
|
||||
**Compatibility.** Same as E. In linear-source songs, ST3 `Fxx` coarse converts using `round(x × 64/3)` and `FFx`/`FEx` fine/extra-fine use `round(x × 16/3)`. In Amiga-source songs (PT or S3M with `linear_slides` clear), both forms are stored verbatim: `Fxx` coarse → `F $00xx`, and `FFx`/`FEx` fine/extra-fine / PT `E1x` → `F $F00x`. In MONOTONE-source songs (ff=2), `1xx` → `F $00xx` verbatim (Hz/tick); MONOTONE has no fine-slide form. F and E share one memory slot in Taud. Slide-mode behaviour is controlled by the same `ff` field as E; under any non-linear mode, both coarse (per-tick) and fine (tick-0 only) F slides are applied in the corresponding mode's space.
|
||||
|
||||
**Implementation.** As for E, but add instead of subtract. No upper pitch cap is defined by the effect itself, but the sample-rate conversion at the mixer will saturate well before arithmetic overflow at reasonable playing ranges.
|
||||
|
||||
@@ -226,26 +237,39 @@ Glissando control (S $1x) snaps the output pitch to the nearest semitone after e
|
||||
|
||||
## G $xxxx — Tone portamento with speed $xxxx
|
||||
|
||||
**Plain.** Slides the channel's current pitch toward the note specified in the same row, at $xxxx Taud units per tick (after tick 0), stopping when the target is reached. A row with G and a note does **not** re-trigger the sample — the note's pitch becomes the portamento target and the already-sounding sample continues at its current pitch.
|
||||
**Plain.** Slides the channel's current pitch toward the note specified in the same row, at $xxxx units per tick (after tick 0), stopping when the target is reached. A row with G and a note does **not** re-trigger the sample — the note's pitch becomes the portamento target and the already-sounding sample continues at its current pitch.
|
||||
|
||||
**Compatibility.** ST3 `Gxx` uses an 8-bit value in period-table units; convert to Taud using the same `round(× 64/3)` scale as E/F coarse (1/16 semitone per ST3 slide unit). ST3 linear mode is the expected import source; Amiga-mode G sources should be treated as linear. G has its **own** memory slot in both ST3 and Taud, so conversion is straightforward and does not suffer the shared-memory problem of E/F.
|
||||
The unit of `$xxxx` depends on the song-table tone mode (effect `1`, bits 1-2):
|
||||
|
||||
- `ff = 0` (linear) and `ff = 1` (Amiga): 4096-TET pitch units per tick. Amiga sources should be converted to linear units on G, since the original PT G slide already operated semi-linearly within a small range and the shared-memory pitfall of E/F does not apply here.
|
||||
- `ff = 2` (linear-frequency): Hz/tick. The engine walks the channel's *frequency* toward the target note's frequency by `±$xxxx` Hz each non-first tick. This is MONOTONE's `3xx` behaviour verbatim (MTSRC/MT_PLAY.PAS:620-630).
|
||||
|
||||
**Compatibility.** ST3 `Gxx` uses an 8-bit value in period-table units; convert to Taud using the same `round(× 64/3)` scale as E/F coarse (1/16 semitone per ST3 slide unit). ST3 linear mode is the expected import source; Amiga-mode G sources should be treated as linear. MONOTONE `3xx` → Taud `G $00xx` verbatim under ff=2. G has its **own** memory slot in both ST3 and Taud, so conversion is straightforward and does not suffer the shared-memory problem of E/F.
|
||||
|
||||
**Implementation.**
|
||||
|
||||
```
|
||||
on row parse:
|
||||
if row has note and G effect:
|
||||
target_pitch = period_for(note)
|
||||
target_pitch = pitch_for(note)
|
||||
# do NOT re-trigger sample
|
||||
if arg != 0:
|
||||
memory_G = arg
|
||||
speed_this_row = memory_G
|
||||
|
||||
on tick > 0:
|
||||
on tick > 0 (linear / Amiga modes):
|
||||
if target_pitch set:
|
||||
delta = sign(target_pitch - pitch) × speed_this_row
|
||||
pitch += delta
|
||||
if sign crossed target: pitch = target_pitch; target_pitch = None
|
||||
|
||||
on tick > 0 (linear-frequency mode):
|
||||
if target_pitch set:
|
||||
target_freq = LINEAR_FREQ_C4_HZ × 2^((target_pitch − C4) / 4096)
|
||||
cur_freq = cached freq (or recomputed from pitch on first use)
|
||||
cur_freq += sign(target_freq − cur_freq) × speed_this_row
|
||||
if sign crossed target_freq: cur_freq = target_freq; target_pitch = None
|
||||
pitch = C4 + 4096 × log2(cur_freq / LINEAR_FREQ_C4_HZ)
|
||||
```
|
||||
|
||||
Glissando (S $1x) snaps the output frequency to the nearest semitone ($0155 step approximation) after each advance without changing the internal pitch counter; it affects only what the mixer sees.
|
||||
@@ -956,14 +980,14 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
|
||||
|
||||
**Plain.** Sets mixer-wide behaviour flags. Available flags are:
|
||||
|
||||
0b 0000 0Ffp
|
||||
0b 0000 0ffp
|
||||
|
||||
- p unset: Linear panning mode (tracker-accurate). Centre panning gets 3 dB boost. Default setting.
|
||||
- p set: Equal-power panning mode. L/R amplitude is at 0.707 when centre-panned.
|
||||
|
||||
- Ff = 0: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
|
||||
- Ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
|
||||
- Ff = 2: Linear frequency mode. Pitch shift will behave against frequency number.
|
||||
- ff = 0: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
|
||||
- ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
|
||||
- ff = 2: Linear-frequency tone mode (MONOTONE compat). **E, F, and G arguments are stored as Hz/tick** (a signed change in audible frequency per song tick), and the engine converts the channel's stored 4096-TET pitch back to a frequency, adds/subtracts the argument, then converts back to 4096-TET. Reference is fixed at 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, which matches MONOTONE's MT_PLAY.PAS `notesHz` table (A0 = 27.5 Hz, equal-temperament). Unlike Amiga mode, *all three* slide effects use the new arithmetic — Monotone's `1xx`, `2xx`, and `3xx` are all in Hz/tick (see MTSRC/MT_PLAY.PAS:606-630).
|
||||
|
||||
(Bit 2 is reserved. It previously held an `m` "fadeout-zero policy" flag intended to swap between IT and FT2 semantics for `storedFadeout = 0`. That flag was removed once both trackers were verified to share identical "stored 0 ⇒ no fade" semantics — see schismtracker `player/sndmix.c:330-342` and ft2-clone `src/ft2_replayer.c:1467-1481`. Fadeout scaling now lives in the converters; see "Volume Fadeout" below.)
|
||||
|
||||
@@ -1014,13 +1038,20 @@ There is no separate "use fadeout" flag — both extremes share the same field,
|
||||
- L_gain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0
|
||||
- R_gain = if (pan < 0x80) pan / 128.0 else 1.0
|
||||
- Panning-equal-power:
|
||||
- L_gain = cos(pi*x / 512.0)
|
||||
- R_gain = sin(pi*x / 512.0)
|
||||
- L_gain = cos(πx / 512.0)
|
||||
- R_gain = sin(πx / 512.0)
|
||||
- Amiga tone (both coarse and fine E/F pitch slides). The `slideArg` is a **raw tracker period-unit count** (no scaling), with sign matching linear mode (negative for E, positive for F). Coarse slides apply on every non-first tick; fine slides apply once on tick 0 — the per-step arithmetic is identical:
|
||||
- AMIGA_BASE_PERIOD = 428.0 (period at the Taud reference pitch C4 for a standard 8363 Hz instrument, NTSC clock — identical to PT "C-2" period 428)
|
||||
- period = AMIGA_BASE_PERIOD × 2^(−(noteVal − C4) / 4096)
|
||||
- period_new = period − slideArg (E subtracts pitch ⇒ adds period; F adds pitch ⇒ subtracts period)
|
||||
- noteVal_new = C4 + 4096 × log2(AMIGA_BASE_PERIOD / period_new)
|
||||
- Linear-frequency tone (E / F / G in Hz/tick). The `slideArg` is a **signed Hz delta per tick** at the audible reference 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, identical to the value MONOTONE stores in its 1xx/2xx/3xx commands. Sign convention matches linear/Amiga modes (negative for E, positive for F):
|
||||
- LINEAR_FREQ_C4_HZ = 261.625565... (12-TET, so A4 = 440 Hz exactly)
|
||||
- freq = LINEAR_FREQ_C4_HZ × 2^((noteVal − C4) / 4096)
|
||||
- freq_new = max(freq + slideArg, 1.0)
|
||||
- noteVal_new = C4 + 4096 × log2(freq_new / LINEAR_FREQ_C4_HZ)
|
||||
- For tone portamento (G), `tonePortaSpeed` is also in Hz/tick: each tick walks `freq` toward `noteValToFreq(target)` by `±tonePortaSpeed` until the target frequency is reached.
|
||||
- Like Amiga mode, the per-voice intermediate frequency is cached across ticks (no round-trip rounding) and reseeded on note trigger, S$2x finetune, fine slides, and the start of a fresh multi-tick coarse slide.
|
||||
|
||||
**Initialisation from the song table.** The same flags byte is stored in the song-table entry (see file format §Song Table). A Taud player should write this byte to MMIO playhead register 7 before starting playback; the mixer then applies it as the initial state on every reset, and subsequent in-pattern `1` effects may override it.
|
||||
|
||||
|
||||
@@ -555,9 +555,10 @@ let timelineRowStyle = 0
|
||||
let COLSIZE_TIMELINE_FULL = TIMELINE_COLSIZES[0]
|
||||
let VOCSIZE_TIMELINE_FULL = Math.floor((SCRW - 3) / COLSIZE_TIMELINE_FULL)
|
||||
|
||||
const ORDERS_CMD_X = 5
|
||||
const ORDERS_VOICE_X = 9
|
||||
const VOCSIZE_ORDERS = Math.floor((SCRW - 10) / 4)
|
||||
const ORDERS_CMD_X = 5
|
||||
const ORDERS_VOICE_X = 12 // 1-indexed col where voice columns begin
|
||||
const ORDERS_VOICE_COL_W = 4
|
||||
const VOCSIZE_ORDERS = Math.floor((SCRW - (ORDERS_VOICE_X - 1)) / ORDERS_VOICE_COL_W)
|
||||
|
||||
const VIEW_TIMELINE = 0
|
||||
const VIEW_CUES = 1
|
||||
@@ -1407,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) {
|
||||
drawOrdersHeader()
|
||||
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) drawOrdersRowAt(ordersScroll + vr)
|
||||
}
|
||||
|
||||
// Redraw all rows of one voice column slot (0..VOCSIZE_ORDERS-1).
|
||||
function drawOrdersVoiceColumnAt(slot) {
|
||||
const v = ordersVoiceOff + slot
|
||||
const x = ORDERS_VOICE_X + slot * ORDERS_VOICE_COL_W
|
||||
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
|
||||
|
||||
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
|
||||
const ci = ordersScroll + vr
|
||||
const y = PTNVIEW_OFFSET_Y + vr
|
||||
const ci = ordersScroll + vr
|
||||
const y = PTNVIEW_OFFSET_Y + vr
|
||||
|
||||
if (ci > maxCue) {
|
||||
con.move(y, x)
|
||||
con.color_pair(colBackPtn, colBackPtn)
|
||||
print(' ')
|
||||
continue
|
||||
}
|
||||
const isSel = (ci === ordersCursor)
|
||||
const isCur = playbackMode !== PLAYMODE_NONE && ci === cueIdx
|
||||
const back = isSel ? (playbackMode !== PLAYMODE_NONE ? colPlayback : colHighlight)
|
||||
: (isCur ? colPlayback : colBackPtn)
|
||||
: (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)
|
||||
if (ci > maxCue) {
|
||||
con.color_pair(colBackPtn, colBackPtn)
|
||||
print(' '.repeat(SCRW - 1))
|
||||
} else {
|
||||
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 * 4
|
||||
if (endX <= SCRW) { con.color_pair(colBackPtn, back); print(' '.repeat(SCRW - endX)) }
|
||||
con.move(y, x)
|
||||
con.color_pair(ptn === CUE_EMPTY ? colSep : colStatus, vBack)
|
||||
print(ptn === CUE_EMPTY ? '---' : ptn.hex03())
|
||||
con.color_pair(colBackPtn, back)
|
||||
print(' ')
|
||||
}
|
||||
}
|
||||
|
||||
// Memory-shift the voice-column area horizontally by `dVoice` voice columns.
|
||||
// Positive = scroll left (new column exposed on right); negative = scroll right.
|
||||
// Touches body rows only; the header and Cmd column are untouched.
|
||||
function shiftOrdersAreaHorizontal(dVoice) {
|
||||
if (dVoice === 0) return
|
||||
const absD = (dVoice < 0) ? -dVoice : dVoice
|
||||
if (absD >= VOCSIZE_ORDERS) return // nothing to salvage
|
||||
|
||||
const stripWidth = (VOCSIZE_ORDERS - absD) * ORDERS_VOICE_COL_W
|
||||
const srcX = ORDERS_VOICE_X + (dVoice > 0 ? absD * ORDERS_VOICE_COL_W : 0)
|
||||
const dstX = ORDERS_VOICE_X + (dVoice > 0 ? 0 : absD * ORDERS_VOICE_COL_W)
|
||||
const srcOff = srcX - 1
|
||||
const dstOff = dstX - 1
|
||||
|
||||
for (let p = 0; p < 3; p++) {
|
||||
const chanOff = TEXT_PLANES[p]
|
||||
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
|
||||
const rowBase = GPU_MEM - chanOff - (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
|
||||
sys.memcpy(rowBase - srcOff, SCRATCH_PTR, stripWidth)
|
||||
sys.memcpy(SCRATCH_PTR, rowBase - dstOff, stripWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1592,26 +1652,53 @@ function ordersInput(wo, event) {
|
||||
stopPlayback(); drawAlwaysOnElems(); return
|
||||
}
|
||||
|
||||
if (keysym === '<UP>') {
|
||||
ordersCursor = Math.max(0, ordersCursor - moveDelta)
|
||||
if (ordersCursor < ordersScroll) ordersScroll = ordersCursor
|
||||
drawOrdersContents(wo)
|
||||
} else if (keysym === '<DOWN>') {
|
||||
ordersCursor = Math.min(maxCue, ordersCursor + moveDelta)
|
||||
if (ordersCursor >= ordersScroll + PTNVIEW_HEIGHT) ordersScroll = Math.max(0, ordersCursor - PTNVIEW_HEIGHT + 1)
|
||||
drawOrdersContents(wo)
|
||||
} else if (keysym === '<PAGE_UP>') {
|
||||
ordersCursor = Math.max(0, ordersCursor - PTNVIEW_HEIGHT)
|
||||
ordersScroll = Math.max(0, ordersScroll - PTNVIEW_HEIGHT)
|
||||
drawOrdersContents(wo)
|
||||
} else if (keysym === '<PAGE_DOWN>') {
|
||||
ordersCursor = Math.min(maxCue, ordersCursor + PTNVIEW_HEIGHT)
|
||||
if (ordersCursor >= ordersScroll + PTNVIEW_HEIGHT) ordersScroll = Math.max(0, ordersCursor - PTNVIEW_HEIGHT + 1)
|
||||
drawOrdersContents(wo)
|
||||
if (keysym === '<UP>' || keysym === '<DOWN>' || keysym === '<PAGE_UP>' || keysym === '<PAGE_DOWN>') {
|
||||
const oldCursor = ordersCursor
|
||||
const oldScroll = ordersScroll
|
||||
|
||||
if (keysym === '<UP>') {
|
||||
ordersCursor = Math.max(0, ordersCursor - moveDelta)
|
||||
if (ordersCursor < ordersScroll) ordersScroll = ordersCursor
|
||||
} else if (keysym === '<DOWN>') {
|
||||
ordersCursor = Math.min(maxCue, ordersCursor + moveDelta)
|
||||
if (ordersCursor >= ordersScroll + PTNVIEW_HEIGHT) ordersScroll = Math.max(0, ordersCursor - PTNVIEW_HEIGHT + 1)
|
||||
} else if (keysym === '<PAGE_UP>') {
|
||||
ordersCursor = Math.max(0, ordersCursor - PTNVIEW_HEIGHT)
|
||||
ordersScroll = Math.max(0, ordersScroll - PTNVIEW_HEIGHT)
|
||||
} else if (keysym === '<PAGE_DOWN>') {
|
||||
ordersCursor = Math.min(maxCue, ordersCursor + PTNVIEW_HEIGHT)
|
||||
if (ordersCursor >= ordersScroll + PTNVIEW_HEIGHT) ordersScroll = Math.max(0, ordersCursor - PTNVIEW_HEIGHT + 1)
|
||||
}
|
||||
|
||||
if (ordersCursor === oldCursor && ordersScroll === oldScroll) return
|
||||
const dScroll = ordersScroll - oldScroll
|
||||
if (dScroll === 0) {
|
||||
drawOrdersRowAt(oldCursor)
|
||||
drawOrdersRowAt(ordersCursor)
|
||||
} else if (Math.abs(dScroll) >= PTNVIEW_HEIGHT) {
|
||||
drawOrdersContents(wo)
|
||||
} else {
|
||||
shiftPatternArea(-dScroll)
|
||||
if (dScroll > 0) for (let i = 0; i < dScroll; i++) drawOrdersRowAt(ordersScroll + PTNVIEW_HEIGHT - 1 - i)
|
||||
else for (let i = 0; i < -dScroll; i++) drawOrdersRowAt(ordersScroll + i)
|
||||
if (oldCursor >= ordersScroll && oldCursor < ordersScroll + PTNVIEW_HEIGHT) drawOrdersRowAt(oldCursor)
|
||||
drawOrdersRowAt(ordersCursor)
|
||||
}
|
||||
} else if (keysym === '<LEFT>' || keysym === '<RIGHT>') {
|
||||
const oldVoiceOff = ordersVoiceOff
|
||||
const oldColCursor = ordersColCursor
|
||||
ordersColCursor += (keysym === '<LEFT>') ? -1 : 1
|
||||
clampOrdersHoriz()
|
||||
drawOrdersContents(wo)
|
||||
if (ordersColCursor === oldColCursor) return // hit edge
|
||||
|
||||
const dVoice = ordersVoiceOff - oldVoiceOff
|
||||
if (dVoice !== 0) {
|
||||
shiftOrdersAreaHorizontal(dVoice)
|
||||
if (dVoice > 0) for (let i = 0; i < dVoice; i++) drawOrdersVoiceColumnAt(VOCSIZE_ORDERS - 1 - i)
|
||||
else for (let i = 0; i < -dVoice; i++) drawOrdersVoiceColumnAt(i)
|
||||
}
|
||||
drawOrdersHeader()
|
||||
drawOrdersRowAt(ordersCursor)
|
||||
} else if (keyJustHit && keysym === '\n') {
|
||||
cueIdx = ordersCursor
|
||||
clampCue()
|
||||
@@ -1704,7 +1791,7 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
let bpm = audio.getBPM(PLAYHEAD) // best-effort starting tempo
|
||||
let speed = audio.getTickRate(PLAYHEAD)
|
||||
let globalVol = 0xFF
|
||||
let panLaw = 0, amigaMode = false
|
||||
let panLaw = 0, toneMode = 0 // toneMode: 0=linear, 1=Amiga, 2=linear-freq, 3=reserved
|
||||
|
||||
let memEF = 0, memG = 0
|
||||
let memHU = { speed: 0, depth: 0 }
|
||||
@@ -1739,8 +1826,10 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
const isGRow = (effop === OP_G)
|
||||
const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD)
|
||||
// Track whether this row reloads the channel's default volume. Engine:
|
||||
// triggerNote() resets channelVolume to 0x3F on fresh triggers, and an
|
||||
// instrument byte on a tone-porta row also reloads default vol (matches
|
||||
// triggerNote() resets channelVolume to 0x3F only when the row carries an
|
||||
// instrument byte; a note-only retrigger (inst === 0) inherits the
|
||||
// channel's existing volume. Tone-porta rows follow the same rule —
|
||||
// an instrument byte on a porta row reloads default vol (matches
|
||||
// schism csf_instrument_change inst_column branch).
|
||||
let reloadDefaultVol = false
|
||||
if (note !== 0xFFFF && note !== 0xFFFE) {
|
||||
@@ -1755,17 +1844,21 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
lastNote = note
|
||||
pitchOff = 0
|
||||
portaTarget = -1
|
||||
reloadDefaultVol = true
|
||||
if (inst !== 0) reloadDefaultVol = true
|
||||
} else {
|
||||
lastNote = note
|
||||
pitchOff = 0
|
||||
portaTarget = -1
|
||||
reloadDefaultVol = true
|
||||
if (inst !== 0) reloadDefaultVol = true
|
||||
}
|
||||
}
|
||||
if (inst !== 0) lastInst = inst
|
||||
// Default vol reset must happen before the volume column so a SET selector
|
||||
// can still override on the same row (engine order: triggerNote → applyVolColumn).
|
||||
// Pan: simulator does not track per-instrument default pan, so it never resets
|
||||
// panAbs on trigger — this naturally matches the "stay at old value when inst === 0"
|
||||
// half of the policy. The engine-side default-pan reload (gated on inst !== 0)
|
||||
// is invisible here.
|
||||
if (reloadDefaultVol) volAbs = 0x3F
|
||||
|
||||
// Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET).
|
||||
@@ -1808,8 +1901,8 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
if (effop !== 0 || effarg !== 0) {
|
||||
if (effop === OP_1) {
|
||||
const flags = (effarg >>> 8) & 0xFF
|
||||
panLaw = flags & 1
|
||||
amigaMode = (flags & 2) !== 0
|
||||
panLaw = flags & 1
|
||||
toneMode = (flags >>> 1) & 3
|
||||
// bit 2 reserved (was 'm' fadeout-zero policy; removed)
|
||||
}
|
||||
else if (effop === OP_8) {
|
||||
@@ -1948,7 +2041,7 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
|
||||
return { lastNote, lastInst, volAbs, panAbs, pitchOff,
|
||||
bpm, speed, globalVol,
|
||||
panLaw, amigaMode,
|
||||
panLaw, toneMode,
|
||||
bitcrushDepth, bitcrushSkip, overdriveAmp, clipMode,
|
||||
glissandoOn, vibratoWave, tremoloWave, panbrelloWave,
|
||||
memEF, memG, memHU, memR, memY,
|
||||
@@ -2153,15 +2246,14 @@ function drawProjectContents(wo) {
|
||||
let flagstr = [
|
||||
['Linear pan','EquNrg pan'],
|
||||
['Linear pitch','Amiga pitch', 'Linear freq', ''], // TODO MONOTONE uses linear-freq pitch
|
||||
['IT fade','FT2 fade'],
|
||||
]
|
||||
for (let i = 0; i < flagstr.length; i++) {
|
||||
if (i != 1 && 1 != 3) {
|
||||
if (i != 1 && 1 != 2) {
|
||||
let s = flagstr[i][(mixerflag >>> i) & 1 != 0]
|
||||
flagStrSelected.push(s)
|
||||
}
|
||||
}
|
||||
let toneMode = (((mixerflag >>> 1) & 1)) | (((mixerflag >>> 3) & 1) << 1)
|
||||
let toneMode = (mixerflag >>> 1) & 3
|
||||
flagStrSelected.splice(1, 0, flagstr[1][toneMode])
|
||||
|
||||
|
||||
|
||||
@@ -11,15 +11,36 @@ Tags:
|
||||
<l> - align left
|
||||
<o> - create virtual typesetting box. Left anchor: where the text cursor is. Right anchor: end of the line
|
||||
µtone; - replace with the brand string (<col 211>Micro</col><col 239>tone</col>)
|
||||
|
||||
&bul; - replace with bullet (\u00F9)
|
||||
&ddot; - replace with double-dot (\u008419u)
|
||||
&mdot; - replace with BIGDOT (\u00FA)
|
||||
&updn; - up-down arrow (\u008418u)
|
||||
&udlr; - four direction arrow (\u008428u\u008429u)
|
||||
|
||||
&keyoffsym; - pattern view key-off symbol (\u00A0\u00CD\u00CD\u00A1)
|
||||
¬ecutsym; - pattern view note-cut symbol (\u00A4\u00A4\u00A4\u00A4)
|
||||
|
||||
&demisharp;
|
||||
♯
|
||||
&sesquisharp;
|
||||
&doublesharp;
|
||||
&triplesharp;
|
||||
&quadsharp;
|
||||
&demiflat;
|
||||
♭
|
||||
&sesquiflat;
|
||||
&doubleflat;
|
||||
&tripleflat;
|
||||
&quadflat;
|
||||
&accuptick;
|
||||
&accdntick;
|
||||
&accupup;
|
||||
&accdndn;
|
||||
|
||||
- nonbreakable space (only meaningful for typesetters)
|
||||
­ - soft hyphen (only meaningful for typesetters)
|
||||
|
||||
default alignment: fully justified
|
||||
*/
|
||||
|
||||
@@ -29,7 +50,8 @@ let helpNotation = `<c>CONTROL NOTATON</c>
|
||||
&bul;<b>a</b>&ddot;<b>z</b> : <O>alphabet without shift-in</O>
|
||||
&bul;<b>A</b>&ddot;<b>Z</b> : <O>alphabet with shift-in</O>
|
||||
&bul;<b>^q</b> : <O>hit 'q' with control key</O>
|
||||
&bul;<b>^Q</b> : <O>hit 'q' with control and shift key</O>`
|
||||
&bul;<b>^Q</b> : <O>hit 'q' with control and shift key</O>
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -37,7 +59,8 @@ let helpJam = `<c>NOTE JAMMING</c>
|
||||
|
||||
Push keys to play or insert notes.
|
||||
w e t y u
|
||||
a s d f g h j k`
|
||||
a s d f g h j k
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -50,7 +73,8 @@ let helpCommon = `<c>COMMON CONTROLS</c>
|
||||
&bul;<b>O</b> : <O>stop the playback</O>
|
||||
&bul;<b>tab</b> : <O>switch forward a tab</O>
|
||||
&bul;<b>TAB</b> : <O>switch backward a tab</O>
|
||||
&bul;<b>q</b> : <O>close µtone;</O>`
|
||||
&bul;<b>q</b> : <O>close µtone;</O>
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -79,7 +103,12 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using
|
||||
&bul;<b><</b>&mdot;<b>></b>: <O>(panning column) slide left/right</O>
|
||||
&bul;<b>-</b>&mdot;<b>=</b> : <O>(vol/pan col) fine slide down/up</O>
|
||||
&bul;<b>&udlr;</b> : <O>move the viewing cursor by columns and rows</O>
|
||||
&bul;<b>pg&updn;</b> : <O>go to previous/next cue</O>`
|
||||
&bul;<b>pg&updn;</b> : <O>go to previous/next cue</O>
|
||||
|
||||
<b>ACCIDENTALS</b>
|
||||
&demisharp; ♯ &doublesharp; &triplesharp; &quadsharp; &demiflat; ♭ &doubleflat; &tripleflat; &accuptick; &accupup; &accdntick; &accdndn;
|
||||
<b>C c x cx xx B b bb bbb ^ ^^ v vv</b>
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -114,6 +143,22 @@ function expandEntities(s) {
|
||||
.replaceAll('­', '')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&demisharp;', '\u0080\u0081')
|
||||
.replaceAll('♯', '\u0082\u0083')
|
||||
.replaceAll('&sesquisharp;', '\u0084132u\u0085')
|
||||
.replaceAll('&doublesharp;', '\u0086\u0087')
|
||||
.replaceAll('&triplesharp;', '\u0088\u0089')
|
||||
.replaceAll('&quadsharp;', '\u008A\u008B')
|
||||
.replaceAll('&demiflat;', '\u008C\u008D')
|
||||
.replaceAll('♭', '\u008E\u008F')
|
||||
.replaceAll('&sesquiflat;', '\u0090\u0091')
|
||||
.replaceAll('&doubleflat;', '\u0092\u0093')
|
||||
.replaceAll('&tripleflat;', '\u0094\u0095')
|
||||
.replaceAll('&quadflat;', '\u0096\u0097')
|
||||
.replaceAll('&accuptick;', '\u009A')
|
||||
.replaceAll('&accdntick;', '\u009B')
|
||||
.replaceAll('&accupup;', '\u009C')
|
||||
.replaceAll('&accdndn;', '\u009D')
|
||||
}
|
||||
|
||||
// Tokenise a (post-entity-expansion) line. Returns an array of:
|
||||
@@ -324,13 +369,13 @@ function typeset(text, customWidth) {
|
||||
}
|
||||
|
||||
let helpMessages = [ // index: taut.js PANEL_NAMES
|
||||
[helpJam, helpTimeline, helpCommon, helpNotation].join('\n\n'),
|
||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
||||
[helpJam, helpTimeline, helpCommon, helpNotation].join('\n'),
|
||||
[helpCommon, helpNotation].join('\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n'), // placeholder
|
||||
]
|
||||
|
||||
help.MSG_BY_TABS = helpMessages.map(it => typeset(it))
|
||||
|
||||
58
it2taud.py
58
it2taud.py
@@ -53,7 +53,7 @@ from taud_common import (
|
||||
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry,
|
||||
normalise_sample, encode_song_entry, nearest_minifloat,
|
||||
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
|
||||
)
|
||||
|
||||
@@ -117,60 +117,6 @@ IT_MEM_EFFECTS = frozenset({
|
||||
|
||||
SIGNATURE = b'it2taud/TSVM ' # 14 bytes
|
||||
|
||||
# ThreeFiveMiniUfloat LUT — 256 entries, seconds 0.0..126.0 (must match Kotlin)
|
||||
_MINUFLOAT_LUT = [
|
||||
0.0, 0.03125, 0.0625, 0.09375, 0.125, 0.15625, 0.1875, 0.21875,
|
||||
0.25, 0.28125, 0.3125, 0.34375, 0.375, 0.40625, 0.4375, 0.46875,
|
||||
0.5, 0.53125, 0.5625, 0.59375, 0.625, 0.65625, 0.6875, 0.71875,
|
||||
0.75, 0.78125, 0.8125, 0.84375, 0.875, 0.90625, 0.9375, 0.96875,
|
||||
1.0, 1.03125, 1.0625, 1.09375, 1.125, 1.15625, 1.1875, 1.21875,
|
||||
1.25, 1.28125, 1.3125, 1.34375, 1.375, 1.40625, 1.4375, 1.46875,
|
||||
1.5, 1.53125, 1.5625, 1.59375, 1.625, 1.65625, 1.6875, 1.71875,
|
||||
1.75, 1.78125, 1.8125, 1.84375, 1.875, 1.90625, 1.9375, 1.96875,
|
||||
2.0, 2.0625, 2.125, 2.1875, 2.25, 2.3125, 2.375, 2.4375,
|
||||
2.5, 2.5625, 2.625, 2.6875, 2.75, 2.8125, 2.875, 2.9375,
|
||||
3.0, 3.0625, 3.125, 3.1875, 3.25, 3.3125, 3.375, 3.4375,
|
||||
3.5, 3.5625, 3.625, 3.6875, 3.75, 3.8125, 3.875, 3.9375,
|
||||
4.0, 4.125, 4.25, 4.375, 4.5, 4.625, 4.75, 4.875,
|
||||
5.0, 5.125, 5.25, 5.375, 5.5, 5.625, 5.75, 5.875,
|
||||
6.0, 6.125, 6.25, 6.375, 6.5, 6.625, 6.75, 6.875,
|
||||
7.0, 7.125, 7.25, 7.375, 7.5, 7.625, 7.75, 7.875,
|
||||
8.0, 8.25, 8.5, 8.75, 9.0, 9.25, 9.5, 9.75,
|
||||
10.0, 10.25, 10.5, 10.75, 11.0, 11.25, 11.5, 11.75,
|
||||
12.0, 12.25, 12.5, 12.75, 13.0, 13.25, 13.5, 13.75,
|
||||
14.0, 14.25, 14.5, 14.75, 15.0, 15.25, 15.5, 15.75,
|
||||
16.0, 16.5, 17.0, 17.5, 18.0, 18.5, 19.0, 19.5,
|
||||
20.0, 20.5, 21.0, 21.5, 22.0, 22.5, 23.0, 23.5,
|
||||
24.0, 24.5, 25.0, 25.5, 26.0, 26.5, 27.0, 27.5,
|
||||
28.0, 28.5, 29.0, 29.5, 30.0, 30.5, 31.0, 31.5,
|
||||
32.0, 33.0, 34.0, 35.0, 36.0, 37.0, 38.0, 39.0,
|
||||
40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0,
|
||||
48.0, 49.0, 50.0, 51.0, 52.0, 53.0, 54.0, 55.0,
|
||||
56.0, 57.0, 58.0, 59.0, 60.0, 61.0, 62.0, 63.0,
|
||||
64.0, 66.0, 68.0, 70.0, 72.0, 74.0, 76.0, 78.0,
|
||||
80.0, 82.0, 84.0, 86.0, 88.0, 90.0, 92.0, 94.0,
|
||||
96.0, 98.0, 100.0, 102.0, 104.0, 106.0, 108.0, 110.0,
|
||||
112.0, 114.0, 116.0, 118.0, 120.0, 122.0, 124.0, 126.0,
|
||||
]
|
||||
|
||||
def _nearest_minifloat(sec: float) -> int:
|
||||
"""Return ThreeFiveMiniUfloat index (0-255) for the nearest representable seconds value."""
|
||||
if sec <= 0.0:
|
||||
return 0
|
||||
if sec >= 126.0:
|
||||
return 255
|
||||
lo, hi = 0, len(_MINUFLOAT_LUT) - 1
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
if _MINUFLOAT_LUT[mid] < sec:
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
# lo is first index where LUT[lo] >= sec; check lo-1 for nearest
|
||||
if lo > 0 and abs(_MINUFLOAT_LUT[lo - 1] - sec) <= abs(_MINUFLOAT_LUT[lo] - sec):
|
||||
return lo - 1
|
||||
return lo
|
||||
|
||||
|
||||
# ── IT header parser ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -626,7 +572,7 @@ def _parse_it_envelope(data: bytes, env_ptr: int, kind: str,
|
||||
if k < len(nodes) - 1:
|
||||
_, next_tick = nodes[k + 1]
|
||||
delta_sec = max(0.0, (next_tick - tick) / ticks_per_sec)
|
||||
mf_idx = _nearest_minifloat(delta_sec)
|
||||
mf_idx = nearest_minifloat(delta_sec)
|
||||
else:
|
||||
mf_idx = 0
|
||||
else:
|
||||
|
||||
39
mon2taud.py
39
mon2taud.py
@@ -14,14 +14,15 @@ This converter:
|
||||
- splits each Monotone pattern (64 × N voices) into N Taud patterns
|
||||
- converts notes (A0=27.5 Hz chromatic) to Taud 4096-TET centred on C4
|
||||
- maps the 8 Monotone effects to their closest Taud equivalents
|
||||
- approximates Hz/tick slides (1xx/2xx/3xx) at an A4=440 Hz reference
|
||||
- emits Hz/tick slides (1xx/2xx/3xx) verbatim and turns on Taud's
|
||||
linear-frequency tone mode (Effect 1 ff=2) so the engine interprets
|
||||
E/F/G arguments as Hz at A4=440 Hz reference — no scaling drift
|
||||
|
||||
Limits: numVoices ≤ 20, numPatterns × numVoices ≤ 4095.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
|
||||
@@ -51,11 +52,13 @@ MON_EFFECT_LETTERS = ['0', '1', '2', '3', '4', 'B', 'D', 'F']
|
||||
# Note value 1 = A0; C4 sits at value 40 (A0 + 39 semitones).
|
||||
MON_NOTE_C4 = 40
|
||||
|
||||
# Slides are linear-in-Hz on Monotone but linear-in-4096-TET on Taud. Take A4
|
||||
# (440 Hz) as the reference: 1 Hz at A4 ≈ 12/(440·ln 2) semitones, scaled by
|
||||
# 4096/12 to Taud units. ≈ 161.0. Off by ±1 octave at the extremes; documented
|
||||
# in the script header.
|
||||
SLIDE_UNITS_PER_HZ = 12.0 / (440.0 * math.log(2.0)) * 4096.0 / 12.0
|
||||
# Global behaviour flags byte (Taud Effect 1 / song-table byte 15):
|
||||
# bit 0 (p) : pan law — leave 0 (linear) for tracker accuracy
|
||||
# bits 1-2 (ff): tone mode — 2 = linear-frequency (Hz/tick)
|
||||
# Selecting ff=2 makes the engine interpret 1xx/2xx/3xx slide arguments in
|
||||
# audible Hz at the A4=440 Hz reference, matching Monotone's MT_PLAY.PAS
|
||||
# `Frequency:=Frequency±parm1` arithmetic (see MTSRC/MT_PLAY.PAS:606-630).
|
||||
GLOBAL_FLAGS_LINEAR_FREQ = 0b100
|
||||
|
||||
|
||||
# ── Taud container ───────────────────────────────────────────────────────────
|
||||
@@ -132,7 +135,7 @@ def mon_note_to_taud(mon_note: int) -> int:
|
||||
if mon_note == 0:
|
||||
return NOTE_NOP
|
||||
if mon_note == 0x7F:
|
||||
return NOTE_KEYOFF
|
||||
return NOTE_CUT
|
||||
val = TAUD_C4 + round((mon_note - MON_NOTE_C4) * 4096.0 / 12.0)
|
||||
return max(1, min(0xFFFD, val))
|
||||
|
||||
@@ -150,17 +153,14 @@ def encode_effect(eff_code: int, data: int) -> tuple:
|
||||
y = data & 0x7
|
||||
return (TOP_J, (J_SEMI_TABLE[x] << 8) | J_SEMI_TABLE[y])
|
||||
|
||||
if letter == '1': # slide up Hz/tick → Taud F
|
||||
arg = round(data * SLIDE_UNITS_PER_HZ) & 0xFFFF
|
||||
return (TOP_F, arg)
|
||||
if letter == '1': # slide up Hz/tick → Taud F (Hz/tick under ff=2)
|
||||
return (TOP_F, data & 0xFFFF)
|
||||
|
||||
if letter == '2': # slide down Hz/tick → Taud E
|
||||
arg = round(data * SLIDE_UNITS_PER_HZ) & 0xFFFF
|
||||
return (TOP_E, arg)
|
||||
if letter == '2': # slide down Hz/tick → Taud E (Hz/tick under ff=2)
|
||||
return (TOP_E, data & 0xFFFF)
|
||||
|
||||
if letter == '3': # tone porta Hz/tick → Taud G
|
||||
arg = round(data * SLIDE_UNITS_PER_HZ) & 0xFFFF
|
||||
return (TOP_G, arg)
|
||||
if letter == '3': # tone porta Hz/tick → Taud G (Hz/tick under ff=2)
|
||||
return (TOP_G, data & 0xFFFF)
|
||||
|
||||
if letter == '4': # vibrato xy → Taud H
|
||||
x = (data >> 3) & 0x7 # speed (3 bits)
|
||||
@@ -366,10 +366,11 @@ def assemble_taud(mon: dict) -> bytes:
|
||||
|
||||
# BPM 150 + ticks=mon_speed → row rate = 60/mon_speed (matches Monotone).
|
||||
bpm_stored = 150 - 24
|
||||
# bit 2 reserved (was 'm' fadeout-zero policy; removed). Monotone has no instrument-level
|
||||
# Linear-frequency tone mode (ff=2) so 1xx/2xx/3xx Hz/tick semantics survive verbatim;
|
||||
# pan law stays 0 (linear), bit 2 stays 0 (reserved). Monotone has no instrument-level
|
||||
# fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire on
|
||||
# sample-end or pattern note-cut instead.
|
||||
flags_byte = 0x00
|
||||
flags_byte = GLOBAL_FLAGS_LINEAR_FREQ
|
||||
|
||||
song_table = encode_song_entry(
|
||||
song_offset = song_offset,
|
||||
|
||||
@@ -226,7 +226,7 @@ def encode_note(s3m_note: int) -> int:
|
||||
if s3m_note == S3M_NOTE_EMPTY:
|
||||
return NOTE_NOP
|
||||
if s3m_note == S3M_NOTE_OFF:
|
||||
return NOTE_KEYOFF
|
||||
return NOTE_CUT
|
||||
octave = (s3m_note >> 4) & 0xF
|
||||
pitch = s3m_note & 0xF
|
||||
if pitch > 11:
|
||||
|
||||
@@ -27,6 +27,9 @@ def vprint(*a, **kw) -> None:
|
||||
# ── Taud container constants ─────────────────────────────────────────────────
|
||||
|
||||
TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
|
||||
# Bumped 2026-05-07: envelope offset minifloat rebiased (smallest step 1/256 s,
|
||||
# max 15.75 s; previously 1/32 s, max 126 s). v1 .taud envelopes will play with
|
||||
# the wrong tempo on a v2 engine — re-convert from source.
|
||||
TAUD_VERSION = 1
|
||||
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14)
|
||||
TAUD_SONG_ENTRY = 32 # full spec entry (see encode_song_entry)
|
||||
@@ -103,6 +106,69 @@ EFF_U = 21; EFF_V = 22; EFF_W = 23; EFF_X = 24; EFF_Y = 25
|
||||
EFF_Z = 26
|
||||
|
||||
|
||||
# ── Envelope offset minifloat ────────────────────────────────────────────────
|
||||
#
|
||||
# Mirror of tsvm_core/.../ThreeFiveMinifloat.kt — used by every *2taud
|
||||
# converter that emits envelope nodes. 3.5 unsigned minifloat (3-bit exponent
|
||||
# + 5-bit mantissa) rebiased so the smallest non-zero step is 1/256 s ≈ 3.91
|
||||
# ms and the maximum is 15.75 s. The previous bias (1/32-step, max 126 s)
|
||||
# under-resolved single-tick deltas at typical tracker BPMs. Every value here
|
||||
# is the original LUT divided by 8.
|
||||
|
||||
MINUFLOAT_LUT = (
|
||||
0.0, 0.00390625, 0.0078125, 0.01171875, 0.015625, 0.01953125, 0.0234375, 0.02734375,
|
||||
0.03125, 0.03515625, 0.0390625, 0.04296875, 0.046875, 0.05078125, 0.0546875, 0.05859375,
|
||||
0.0625, 0.06640625, 0.0703125, 0.07421875, 0.078125, 0.08203125, 0.0859375, 0.08984375,
|
||||
0.09375, 0.09765625, 0.1015625, 0.10546875, 0.109375, 0.11328125, 0.1171875, 0.12109375,
|
||||
0.125, 0.12890625, 0.1328125, 0.13671875, 0.140625, 0.14453125, 0.1484375, 0.15234375,
|
||||
0.15625, 0.16015625, 0.1640625, 0.16796875, 0.171875, 0.17578125, 0.1796875, 0.18359375,
|
||||
0.1875, 0.19140625, 0.1953125, 0.19921875, 0.203125, 0.20703125, 0.2109375, 0.21484375,
|
||||
0.21875, 0.22265625, 0.2265625, 0.23046875, 0.234375, 0.23828125, 0.2421875, 0.24609375,
|
||||
0.25, 0.2578125, 0.265625, 0.2734375, 0.28125, 0.2890625, 0.296875, 0.3046875,
|
||||
0.3125, 0.3203125, 0.328125, 0.3359375, 0.34375, 0.3515625, 0.359375, 0.3671875,
|
||||
0.375, 0.3828125, 0.390625, 0.3984375, 0.40625, 0.4140625, 0.421875, 0.4296875,
|
||||
0.4375, 0.4453125, 0.453125, 0.4609375, 0.46875, 0.4765625, 0.484375, 0.4921875,
|
||||
0.5, 0.515625, 0.53125, 0.546875, 0.5625, 0.578125, 0.59375, 0.609375,
|
||||
0.625, 0.640625, 0.65625, 0.671875, 0.6875, 0.703125, 0.71875, 0.734375,
|
||||
0.75, 0.765625, 0.78125, 0.796875, 0.8125, 0.828125, 0.84375, 0.859375,
|
||||
0.875, 0.890625, 0.90625, 0.921875, 0.9375, 0.953125, 0.96875, 0.984375,
|
||||
1.0, 1.03125, 1.0625, 1.09375, 1.125, 1.15625, 1.1875, 1.21875,
|
||||
1.25, 1.28125, 1.3125, 1.34375, 1.375, 1.40625, 1.4375, 1.46875,
|
||||
1.5, 1.53125, 1.5625, 1.59375, 1.625, 1.65625, 1.6875, 1.71875,
|
||||
1.75, 1.78125, 1.8125, 1.84375, 1.875, 1.90625, 1.9375, 1.96875,
|
||||
2.0, 2.0625, 2.125, 2.1875, 2.25, 2.3125, 2.375, 2.4375,
|
||||
2.5, 2.5625, 2.625, 2.6875, 2.75, 2.8125, 2.875, 2.9375,
|
||||
3.0, 3.0625, 3.125, 3.1875, 3.25, 3.3125, 3.375, 3.4375,
|
||||
3.5, 3.5625, 3.625, 3.6875, 3.75, 3.8125, 3.875, 3.9375,
|
||||
4.0, 4.125, 4.25, 4.375, 4.5, 4.625, 4.75, 4.875,
|
||||
5.0, 5.125, 5.25, 5.375, 5.5, 5.625, 5.75, 5.875,
|
||||
6.0, 6.125, 6.25, 6.375, 6.5, 6.625, 6.75, 6.875,
|
||||
7.0, 7.125, 7.25, 7.375, 7.5, 7.625, 7.75, 7.875,
|
||||
8.0, 8.25, 8.5, 8.75, 9.0, 9.25, 9.5, 9.75,
|
||||
10.0, 10.25, 10.5, 10.75, 11.0, 11.25, 11.5, 11.75,
|
||||
12.0, 12.25, 12.5, 12.75, 13.0, 13.25, 13.5, 13.75,
|
||||
14.0, 14.25, 14.5, 14.75, 15.0, 15.25, 15.5, 15.75,
|
||||
)
|
||||
|
||||
|
||||
def nearest_minifloat(sec: float) -> int:
|
||||
"""Return the ThreeFiveMiniUfloat index (0..255) for the LUT entry nearest to `sec`."""
|
||||
if sec <= 0.0:
|
||||
return 0
|
||||
if sec >= MINUFLOAT_LUT[-1]:
|
||||
return 255
|
||||
lo, hi = 0, len(MINUFLOAT_LUT) - 1
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
if MINUFLOAT_LUT[mid] < sec:
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
if lo > 0 and abs(MINUFLOAT_LUT[lo - 1] - sec) < abs(MINUFLOAT_LUT[lo] - sec):
|
||||
return lo - 1
|
||||
return lo
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def d_arg_to_col(arg: int):
|
||||
|
||||
113
terranmon.txt
113
terranmon.txt
@@ -2137,13 +2137,13 @@ from source.
|
||||
(bits 14..15 reserved)
|
||||
21 Bit16x25 Volume envelopes
|
||||
Byte 1: Volume (00..3F)
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
|
||||
71 Bit16x25 Panning envelopes
|
||||
Byte 1: Pan (00..FF)
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
|
||||
121 Bit16x25 Pitch/Filter envelopes
|
||||
Byte 1: Value (00..FF)
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
|
||||
171 Uint8 Instrument Global Volume (0..255)
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
- ImpulseTracker also has samplewise default volume (0..64) and samplewise global volume (0..64), and they must be taken into account because Taud has no samplewise config, following the ImpulseTracker spec
|
||||
@@ -2327,9 +2327,29 @@ TODO:
|
||||
2026-05-06 .taud files predate the P bit and need re-conversion
|
||||
for pan/pf envelopes to play. See byte 15/17/19 spec for the LOOP
|
||||
word bit layout.
|
||||
[ ] implement extended tone mode (MONOTONE compat)
|
||||
[ ] pattern loops stops working after processed once (test with slumberjack.xm)
|
||||
[ ] milkytracker-style volume ramping (on sample-end only)
|
||||
[x] slumberjack.xm: E6x commands are not processed
|
||||
[x] implement linear-freq tone mode (MONOTONE compat)
|
||||
Resolution: ff=2 in song-table flags byte (was reserved). E / F / G
|
||||
arguments are interpreted as Hz/tick at A4 = 440 Hz / C4 ≈ 261.6256 Hz
|
||||
reference, exactly matching MONOTONE's MT_PLAY.PAS `Frequency`
|
||||
arithmetic (MTSRC/MT_PLAY.PAS:606-630). Per-voice `linearFreq` cache
|
||||
in AudioAdapter.kt preserves sub-noteVal precision across ticks; the
|
||||
Voice cache reseeds on note trigger, fine slides, S$2x finetune, and
|
||||
the start of a fresh multi-tick coarse slide. mon2taud.py now emits
|
||||
Hz values verbatim (no SLIDE_UNITS_PER_HZ scaling) and sets the
|
||||
linear-freq flag in the song-table flags byte. Spec details in
|
||||
TAUD_NOTE_EFFECTS.md §1, §E, §F, §G.
|
||||
[x] milkytracker-style volume ramping (on sample-end only)
|
||||
[x] make Cues tab move faster
|
||||
Resolution: Cues panel now uses memory-shift (`shiftOrdersAreaHorizontal`)
|
||||
for LEFT/RIGHT and `shiftPatternArea` for UP/DOWN, plus per-row
|
||||
(`drawOrdersRowAt`) and per-column (`drawOrdersVoiceColumnAt`) helpers,
|
||||
replacing the full-panel redraw on every keystroke.
|
||||
[x] volume and panning policy to match note effect policy: when note is "retriggerred" (note command with instrument specified), the volume/pan must take default value; if not (note command with instrument 0) the volume/pan must stay at the old value. Make both audio engine and taut.js simulator changes.
|
||||
[ ] xm volume column commands (+x, -x, Dx, Lx, Mx, Px, Rx, Sx, Ux, Vx) are completely ignored
|
||||
[x] theday.xm order 0x28, channel 6..8 has 'note trigger with inst 1 but no volume -> key-off -> set-volume to 0x20 -> key-off -> set-volume to 0x10 -> key-off -> ...' and it sounds like gating: key-off silences the output, set-volume turns on the output again; notably, this behaviour only works when volume envelope is turned off (any fadeouts progress normally). FT2's keyOff (ft2_replayer.c:411-435) zeroes realVol/outVol when the volume envelope is disabled — IT/Schism does not, and Taud's engine follows IT semantics (no fade when fadeStep == 0). Resolved in xm2taud.py: a pre-pass tracks per-channel bound XM instrument across the order-list walk, and any key-off cell whose bound instrument has vol_env_type & XM_ENV_ON == 0 is paired with `SEL_SET vol=0` in the same row. A subsequent vol-col SET on the channel restores audibility — exactly mirroring FT2's outVol/realVol gate without diverging the engine. Engine semantics stay IT-pure.
|
||||
[ ] remove panning mode selection and replace global panning rule to 3 dB rule (not the equal energy)
|
||||
[ ] FT2/MOD double effects (5xx, 6xx) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py)
|
||||
|
||||
|
||||
Play Data: play data are series of tracker-like instructions, visualised as:
|
||||
@@ -2430,9 +2450,9 @@ Play Head Flags
|
||||
Byte 2
|
||||
- PCM Mode: Write non-zero value to start uploading; always 0 when read
|
||||
- Tracker Mode: Global mixer flags. Maps directly to Taud effect symbol '1'
|
||||
0b 0000 00fp
|
||||
0b 0000 0ffp
|
||||
p: panning mode (0: linear, 1: equal-power)
|
||||
f: pitchshift mode (0: tone-linear, 1: Amiga)
|
||||
ff: pitchshift mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
|
||||
Tracker command may change the mixer state, but the changes WILL NOT BE REFLECTED BACK.
|
||||
Starting a new song will use whatever written to this register. In other words, changes
|
||||
made by songs will not persist.
|
||||
@@ -2464,40 +2484,45 @@ Play Head Flags
|
||||
|
||||
65536..131071 RW: PCM Sample buffer
|
||||
|
||||
Table of 3.5 Minifloat values (CSV)
|
||||
Table of 3.5 Minifloat values (CSV).
|
||||
Rebiased 2026-05-07 so the smallest non-zero step is 1/256 s and the maximum
|
||||
is 15.75 s — every cell is the original LUT value divided by 8. Chosen for
|
||||
tracker envelopes: a single song tick (≈ 8.9 ms at BPM 280, ≈ 41.7 ms at
|
||||
BPM 24) now lands within ±17 % of an LUT entry across the whole supported
|
||||
BPM range; the previous bias was ±150 % at common BPMs.
|
||||
,000,001,010,011,100,101,110,111,MSB
|
||||
00000,0,1,2,4,8,16,32,64
|
||||
00001,0.03125,1.03125,2.0625,4.125,8.25,16.5,33,66
|
||||
00010,0.0625,1.0625,2.125,4.25,8.5,17,34,68
|
||||
00011,0.09375,1.09375,2.1875,4.375,8.75,17.5,35,70
|
||||
00100,0.125,1.125,2.25,4.5,9,18,36,72
|
||||
00101,0.15625,1.15625,2.3125,4.625,9.25,18.5,37,74
|
||||
00110,0.1875,1.1875,2.375,4.75,9.5,19,38,76
|
||||
00111,0.21875,1.21875,2.4375,4.875,9.75,19.5,39,78
|
||||
01000,0.25,1.25,2.5,5,10,20,40,80
|
||||
01001,0.28125,1.28125,2.5625,5.125,10.25,20.5,41,82
|
||||
01010,0.3125,1.3125,2.625,5.25,10.5,21,42,84
|
||||
01011,0.34375,1.34375,2.6875,5.375,10.75,21.5,43,86
|
||||
01100,0.375,1.375,2.75,5.5,11,22,44,88
|
||||
01101,0.40625,1.40625,2.8125,5.625,11.25,22.5,45,90
|
||||
01110,0.4375,1.4375,2.875,5.75,11.5,23,46,92
|
||||
01111,0.46875,1.46875,2.9375,5.875,11.75,23.5,47,94
|
||||
10000,0.5,1.5,3,6,12,24,48,96
|
||||
10001,0.53125,1.53125,3.0625,6.125,12.25,24.5,49,98
|
||||
10010,0.5625,1.5625,3.125,6.25,12.5,25,50,100
|
||||
10011,0.59375,1.59375,3.1875,6.375,12.75,25.5,51,102
|
||||
10100,0.625,1.625,3.25,6.5,13,26,52,104
|
||||
10101,0.65625,1.65625,3.3125,6.625,13.25,26.5,53,106
|
||||
10110,0.6875,1.6875,3.375,6.75,13.5,27,54,108
|
||||
10111,0.71875,1.71875,3.4375,6.875,13.75,27.5,55,110
|
||||
11000,0.75,1.75,3.5,7,14,28,56,112
|
||||
11001,0.78125,1.78125,3.5625,7.125,14.25,28.5,57,114
|
||||
11010,0.8125,1.8125,3.625,7.25,14.5,29,58,116
|
||||
11011,0.84375,1.84375,3.6875,7.375,14.75,29.5,59,118
|
||||
11100,0.875,1.875,3.75,7.5,15,30,60,120
|
||||
11101,0.90625,1.90625,3.8125,7.625,15.25,30.5,61,122
|
||||
11110,0.9375,1.9375,3.875,7.75,15.5,31,62,124
|
||||
11111,0.96875,1.96875,3.9375,7.875,15.75,31.5,63,126
|
||||
00000,0,0.125,0.25,0.5,1,2,4,8
|
||||
00001,0.00390625,0.12890625,0.2578125,0.515625,1.03125,2.0625,4.125,8.25
|
||||
00010,0.0078125,0.1328125,0.265625,0.53125,1.0625,2.125,4.25,8.5
|
||||
00011,0.01171875,0.13671875,0.2734375,0.546875,1.09375,2.1875,4.375,8.75
|
||||
00100,0.015625,0.140625,0.28125,0.5625,1.125,2.25,4.5,9
|
||||
00101,0.01953125,0.14453125,0.2890625,0.578125,1.15625,2.3125,4.625,9.25
|
||||
00110,0.0234375,0.1484375,0.296875,0.59375,1.1875,2.375,4.75,9.5
|
||||
00111,0.02734375,0.15234375,0.3046875,0.609375,1.21875,2.4375,4.875,9.75
|
||||
01000,0.03125,0.15625,0.3125,0.625,1.25,2.5,5,10
|
||||
01001,0.03515625,0.16015625,0.3203125,0.640625,1.28125,2.5625,5.125,10.25
|
||||
01010,0.0390625,0.1640625,0.328125,0.65625,1.3125,2.625,5.25,10.5
|
||||
01011,0.04296875,0.16796875,0.3359375,0.671875,1.34375,2.6875,5.375,10.75
|
||||
01100,0.046875,0.171875,0.34375,0.6875,1.375,2.75,5.5,11
|
||||
01101,0.05078125,0.17578125,0.3515625,0.703125,1.40625,2.8125,5.625,11.25
|
||||
01110,0.0546875,0.1796875,0.359375,0.71875,1.4375,2.875,5.75,11.5
|
||||
01111,0.05859375,0.18359375,0.3671875,0.734375,1.46875,2.9375,5.875,11.75
|
||||
10000,0.0625,0.1875,0.375,0.75,1.5,3,6,12
|
||||
10001,0.06640625,0.19140625,0.3828125,0.765625,1.53125,3.0625,6.125,12.25
|
||||
10010,0.0703125,0.1953125,0.390625,0.78125,1.5625,3.125,6.25,12.5
|
||||
10011,0.07421875,0.19921875,0.3984375,0.796875,1.59375,3.1875,6.375,12.75
|
||||
10100,0.078125,0.203125,0.40625,0.8125,1.625,3.25,6.5,13
|
||||
10101,0.08203125,0.20703125,0.4140625,0.828125,1.65625,3.3125,6.625,13.25
|
||||
10110,0.0859375,0.2109375,0.421875,0.84375,1.6875,3.375,6.75,13.5
|
||||
10111,0.08984375,0.21484375,0.4296875,0.859375,1.71875,3.4375,6.875,13.75
|
||||
11000,0.09375,0.21875,0.4375,0.875,1.75,3.5,7,14
|
||||
11001,0.09765625,0.22265625,0.4453125,0.890625,1.78125,3.5625,7.125,14.25
|
||||
11010,0.1015625,0.2265625,0.453125,0.90625,1.8125,3.625,7.25,14.5
|
||||
11011,0.10546875,0.23046875,0.4609375,0.921875,1.84375,3.6875,7.375,14.75
|
||||
11100,0.109375,0.234375,0.46875,0.9375,1.875,3.75,7.5,15
|
||||
11101,0.11328125,0.23828125,0.4765625,0.953125,1.90625,3.8125,7.625,15.25
|
||||
11110,0.1171875,0.2421875,0.484375,0.96875,1.9375,3.875,7.75,15.5
|
||||
11111,0.12109375,0.24609375,0.4921875,0.984375,1.96875,3.9375,7.875,15.75
|
||||
LSB
|
||||
|
||||
## Tracker Note Effects
|
||||
@@ -2546,9 +2571,9 @@ Endianness: Little
|
||||
Uint16 Current Tuning base note (1..65533). A4 (western default) is 0x5C00. C9 (tracker default) is 0xA000. If zero, assume the tracker default value
|
||||
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
|
||||
Uint8 Flags for Global Behaviour (effect symbol '1')
|
||||
0b 0000 0Ffp
|
||||
p: panning law (0=linear, 1=equal-power)
|
||||
Ff: tone mode (0=linear pitch slides, 1=Amiga period slides, 2=linear-frequency slides, 3=reserved)
|
||||
0b 0000 0ffp
|
||||
p: panning law (0: linear, 1: equal-power)
|
||||
ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
|
||||
(bit 2 reserved — was 'm' fadeout-zero policy, removed; fadeout
|
||||
scaling now lives entirely in the converter — see byte 172/173
|
||||
of the instrument record for engine semantics)
|
||||
|
||||
@@ -136,7 +136,7 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
ph.initialGlobalFlags = flags
|
||||
ph.trackerState?.let { ts ->
|
||||
ts.panLaw = flags and 1
|
||||
ts.amigaMode = (flags and 2) != 0
|
||||
ts.toneMode = (flags ushr 1) and 3
|
||||
// bit 2 reserved (was 'm' fadeout-zero policy; removed — see AudioAdapter.kt
|
||||
// and TAUD_NOTE_EFFECTS.md §1 "Volume Fadeout")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
package net.torvald.tsvm
|
||||
|
||||
/**
|
||||
* Created by minjaesong on 2022-12-30.
|
||||
* 3.5 unsigned minifloat (3-bit exponent + 5-bit mantissa), scaled so the
|
||||
* smallest non-zero step is 1/256 s ≈ 3.91 ms and the maximum representable
|
||||
* value is 15.75 s. Used for Taud envelope point offsets — the resolution at
|
||||
* the low end is fine enough to resolve individual tracker ticks at every
|
||||
* supported BPM (worst case ±17 % at BPM 250+, vs. ±150 % under the original
|
||||
* 1/32-step bias).
|
||||
*
|
||||
* Created by minjaesong on 2022-12-30. Rebiased for tracker tick resolution
|
||||
* on 2026-05-07 (entire LUT divided by 8).
|
||||
*/
|
||||
@JvmInline
|
||||
value class ThreeFiveMiniUfloat(val index: Int = 0) {
|
||||
@@ -11,7 +19,7 @@ value class ThreeFiveMiniUfloat(val index: Int = 0) {
|
||||
}
|
||||
|
||||
companion object {
|
||||
val LUT = floatArrayOf(0f,0.03125f,0.0625f,0.09375f,0.125f,0.15625f,0.1875f,0.21875f,0.25f,0.28125f,0.3125f,0.34375f,0.375f,0.40625f,0.4375f,0.46875f,0.5f,0.53125f,0.5625f,0.59375f,0.625f,0.65625f,0.6875f,0.71875f,0.75f,0.78125f,0.8125f,0.84375f,0.875f,0.90625f,0.9375f,0.96875f,1f,1.03125f,1.0625f,1.09375f,1.125f,1.15625f,1.1875f,1.21875f,1.25f,1.28125f,1.3125f,1.34375f,1.375f,1.40625f,1.4375f,1.46875f,1.5f,1.53125f,1.5625f,1.59375f,1.625f,1.65625f,1.6875f,1.71875f,1.75f,1.78125f,1.8125f,1.84375f,1.875f,1.90625f,1.9375f,1.96875f,2f,2.0625f,2.125f,2.1875f,2.25f,2.3125f,2.375f,2.4375f,2.5f,2.5625f,2.625f,2.6875f,2.75f,2.8125f,2.875f,2.9375f,3f,3.0625f,3.125f,3.1875f,3.25f,3.3125f,3.375f,3.4375f,3.5f,3.5625f,3.625f,3.6875f,3.75f,3.8125f,3.875f,3.9375f,4f,4.125f,4.25f,4.375f,4.5f,4.625f,4.75f,4.875f,5f,5.125f,5.25f,5.375f,5.5f,5.625f,5.75f,5.875f,6f,6.125f,6.25f,6.375f,6.5f,6.625f,6.75f,6.875f,7f,7.125f,7.25f,7.375f,7.5f,7.625f,7.75f,7.875f,8f,8.25f,8.5f,8.75f,9f,9.25f,9.5f,9.75f,10f,10.25f,10.5f,10.75f,11f,11.25f,11.5f,11.75f,12f,12.25f,12.5f,12.75f,13f,13.25f,13.5f,13.75f,14f,14.25f,14.5f,14.75f,15f,15.25f,15.5f,15.75f,16f,16.5f,17f,17.5f,18f,18.5f,19f,19.5f,20f,20.5f,21f,21.5f,22f,22.5f,23f,23.5f,24f,24.5f,25f,25.5f,26f,26.5f,27f,27.5f,28f,28.5f,29f,29.5f,30f,30.5f,31f,31.5f,32f,33f,34f,35f,36f,37f,38f,39f,40f,41f,42f,43f,44f,45f,46f,47f,48f,49f,50f,51f,52f,53f,54f,55f,56f,57f,58f,59f,60f,61f,62f,63f,64f,66f,68f,70f,72f,74f,76f,78f,80f,82f,84f,86f,88f,90f,92f,94f,96f,98f,100f,102f,104f,106f,108f,110f,112f,114f,116f,118f,120f,122f,124f,126f)
|
||||
val LUT = floatArrayOf(0f,0.00390625f,0.0078125f,0.01171875f,0.015625f,0.01953125f,0.0234375f,0.02734375f,0.03125f,0.03515625f,0.0390625f,0.04296875f,0.046875f,0.05078125f,0.0546875f,0.05859375f,0.0625f,0.06640625f,0.0703125f,0.07421875f,0.078125f,0.08203125f,0.0859375f,0.08984375f,0.09375f,0.09765625f,0.1015625f,0.10546875f,0.109375f,0.11328125f,0.1171875f,0.12109375f,0.125f,0.12890625f,0.1328125f,0.13671875f,0.140625f,0.14453125f,0.1484375f,0.15234375f,0.15625f,0.16015625f,0.1640625f,0.16796875f,0.171875f,0.17578125f,0.1796875f,0.18359375f,0.1875f,0.19140625f,0.1953125f,0.19921875f,0.203125f,0.20703125f,0.2109375f,0.21484375f,0.21875f,0.22265625f,0.2265625f,0.23046875f,0.234375f,0.23828125f,0.2421875f,0.24609375f,0.25f,0.2578125f,0.265625f,0.2734375f,0.28125f,0.2890625f,0.296875f,0.3046875f,0.3125f,0.3203125f,0.328125f,0.3359375f,0.34375f,0.3515625f,0.359375f,0.3671875f,0.375f,0.3828125f,0.390625f,0.3984375f,0.40625f,0.4140625f,0.421875f,0.4296875f,0.4375f,0.4453125f,0.453125f,0.4609375f,0.46875f,0.4765625f,0.484375f,0.4921875f,0.5f,0.515625f,0.53125f,0.546875f,0.5625f,0.578125f,0.59375f,0.609375f,0.625f,0.640625f,0.65625f,0.671875f,0.6875f,0.703125f,0.71875f,0.734375f,0.75f,0.765625f,0.78125f,0.796875f,0.8125f,0.828125f,0.84375f,0.859375f,0.875f,0.890625f,0.90625f,0.921875f,0.9375f,0.953125f,0.96875f,0.984375f,1f,1.03125f,1.0625f,1.09375f,1.125f,1.15625f,1.1875f,1.21875f,1.25f,1.28125f,1.3125f,1.34375f,1.375f,1.40625f,1.4375f,1.46875f,1.5f,1.53125f,1.5625f,1.59375f,1.625f,1.65625f,1.6875f,1.71875f,1.75f,1.78125f,1.8125f,1.84375f,1.875f,1.90625f,1.9375f,1.96875f,2f,2.0625f,2.125f,2.1875f,2.25f,2.3125f,2.375f,2.4375f,2.5f,2.5625f,2.625f,2.6875f,2.75f,2.8125f,2.875f,2.9375f,3f,3.0625f,3.125f,3.1875f,3.25f,3.3125f,3.375f,3.4375f,3.5f,3.5625f,3.625f,3.6875f,3.75f,3.8125f,3.875f,3.9375f,4f,4.125f,4.25f,4.375f,4.5f,4.625f,4.75f,4.875f,5f,5.125f,5.25f,5.375f,5.5f,5.625f,5.75f,5.875f,6f,6.125f,6.25f,6.375f,6.5f,6.625f,6.75f,6.875f,7f,7.125f,7.25f,7.375f,7.5f,7.625f,7.75f,7.875f,8f,8.25f,8.5f,8.75f,9f,9.25f,9.5f,9.75f,10f,10.25f,10.5f,10.75f,11f,11.25f,11.5f,11.75f,12f,12.25f,12.5f,12.75f,13f,13.25f,13.5f,13.75f,14f,14.25f,14.5f,14.75f,15f,15.25f,15.5f,15.75f)
|
||||
|
||||
private fun fromFloatToIndex(fval: Float): Int {
|
||||
val (llim, hlim) = binarySearchInterval(fval, LUT)
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.io.OutputStream
|
||||
import java.nio.charset.Charset
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.ceil
|
||||
|
||||
|
||||
@@ -549,7 +550,7 @@ class VM(
|
||||
// println("peek $addr -> ${offset}@${memspace?.javaClass?.canonicalName}")
|
||||
|
||||
return if (memspace == null)
|
||||
throw NullPointerException()//null
|
||||
throw OpenBusException(addr)//null
|
||||
else if (memspace is UnsafePtr) {
|
||||
if (addr >= memspace.size)
|
||||
throw ErrorIllegalAccess(this, addr)
|
||||
@@ -564,7 +565,7 @@ class VM(
|
||||
val (memspace, offset) = translateAddr(addr)
|
||||
|
||||
return if (memspace == null)
|
||||
throw NullPointerException()//null
|
||||
throw OpenBusException(addr)//null
|
||||
else if (memspace is UnsafePtr) {
|
||||
if (addr >= memspace.size)
|
||||
throw ErrorIllegalAccess(this, addr)
|
||||
@@ -583,7 +584,7 @@ class VM(
|
||||
val (memspace, offset) = translateAddr(addr)
|
||||
|
||||
return if (memspace == null)
|
||||
throw NullPointerException()//null
|
||||
throw OpenBusException(addr)//null
|
||||
else if (memspace is UnsafePtr) {
|
||||
if (addr >= memspace.size)
|
||||
throw ErrorIllegalAccess(this, addr)
|
||||
@@ -608,7 +609,7 @@ class VM(
|
||||
val (memspace, offset) = translateAddr(addr)
|
||||
|
||||
return if (memspace == null)
|
||||
throw NullPointerException()//null
|
||||
throw OpenBusException(addr)//null
|
||||
else if (memspace is UnsafePtr) {
|
||||
if (addr >= memspace.size)
|
||||
throw ErrorIllegalAccess(this, addr)
|
||||
@@ -853,3 +854,10 @@ class PeripheralEntry2(
|
||||
)
|
||||
|
||||
internal fun Int.kB() = this * 1024L
|
||||
|
||||
fun Long.memAddrToReadable() = "'${this}' (bank " + this.absoluteValue.minus(if (this < 0) 1 else 0).div(1048576) +
|
||||
" offset " + this.absoluteValue.minus(if (this < 0) 1 else 0).mod(1048576) + ")"
|
||||
|
||||
class OpenBusException(addr: Long) : NullPointerException(
|
||||
"Address ${addr.memAddrToReadable()} is open bus"
|
||||
)
|
||||
@@ -133,6 +133,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// Trackers may use different labelling conventions (e.g. C5) for Middle C.
|
||||
// For non-tracker context, Middle C shall be labelled as C4.
|
||||
const val AMIGA_BASE_PERIOD = 428.0
|
||||
// Reference frequency for linear-freq tone mode (toneMode == 2). Fixed at 12-TET
|
||||
// A4 = 440 Hz so that 1 Hz/tick at C4 ≈ 1 Hz at the audible output: 261.6256 ×
|
||||
// 2^(9/12) = 440 Hz exactly. MONOTONE (.MON) — the only source format using
|
||||
// linear-freq slides — uses A0 = 27.5 Hz with the same equal-temperament tuning,
|
||||
// so emitted Hz values map directly to audible Hz at any pitch.
|
||||
const val LINEAR_FREQ_C4_HZ = 261.6255653005986
|
||||
// Anti-click ramp-out: when a sample naturally ends or is cut, the voice keeps
|
||||
// mixing for this many output samples while gain decays linearly to 0.
|
||||
// 8 ms at 32 kHz — long enough to bury the click, short enough not to read as fade.
|
||||
// Applied on sample end only (preserves attack transients on note start).
|
||||
const val RAMP_OUT_SAMPLES = 256
|
||||
}
|
||||
|
||||
// Memory map (terranmon.txt:1985-1997, updated 2026-05-06):
|
||||
@@ -419,7 +430,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
private var mp2Context = mp2Env.initialise()
|
||||
|
||||
private fun decodeMp2() {
|
||||
val periMmioBase = vm.findPeriSlotNum(this)!! * -786432 - 1L
|
||||
val periMmioBase = vm.findPeriSlotNum(this)!! * -131072 - 1L
|
||||
mp2Env.decodeFrameU8(mp2Context, periMmioBase - 2368, true, periMmioBase - 64)
|
||||
}
|
||||
|
||||
@@ -1209,6 +1220,34 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
return amigaPeriodToNoteVal(newPeriod)
|
||||
}
|
||||
|
||||
// Linear-frequency mode (toneMode == 2): E / F / G arguments are interpreted as Hz/tick.
|
||||
// The reference is fixed at 12-TET A4 = 440 Hz (so MIDDLE_C ≈ 261.6256 Hz). MONOTONE
|
||||
// (.MON) is the canonical source — its 1xx/2xx/3xx commands use Hz/tick directly, so
|
||||
// mon2taud.py emits the raw byte and relies on this mode. Like Amiga mode, a per-voice
|
||||
// linearFreq cache (`voice.linearFreq`) preserves sub-noteVal precision across ticks;
|
||||
// -1.0 means stale and must be reseeded from current noteVal.
|
||||
private fun noteValToFreqHz(noteVal: Int): Double =
|
||||
LINEAR_FREQ_C4_HZ * 2.0.pow((noteVal - MIDDLE_C).toDouble() / 4096.0)
|
||||
|
||||
private fun freqHzToNoteVal(freq: Double): Int =
|
||||
(MIDDLE_C + 4096.0 * log2(freq / LINEAR_FREQ_C4_HZ)).roundToInt()
|
||||
|
||||
// Per-tick linear-freq slide. Sign convention matches linear/Amiga modes: positive
|
||||
// slideArg = pitch up = freq rises; negative = pitch down = freq falls.
|
||||
private fun linearFreqSlideTick(voice: Voice, slideArg: Int): Int {
|
||||
if (voice.linearFreq < 0.0) voice.linearFreq = noteValToFreqHz(voice.noteVal)
|
||||
voice.linearFreq = (voice.linearFreq + slideArg).coerceAtLeast(1.0)
|
||||
return freqHzToNoteVal(voice.linearFreq)
|
||||
}
|
||||
|
||||
// One-shot linear-freq slide for fine E/F (applied once per row at tick 0); does
|
||||
// not mutate persistent state.
|
||||
private fun linearFreqSlideOnce(noteVal: Int, slideArg: Int): Int {
|
||||
val freq = noteValToFreqHz(noteVal)
|
||||
val newFreq = (freq + slideArg).coerceAtLeast(1.0)
|
||||
return freqHzToNoteVal(newFreq)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active wrap region for an envelope based on the LOOP and
|
||||
* SUSTAIN words and key state.
|
||||
@@ -1595,6 +1634,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val s1 = (b1 - 127.5) / 127.5
|
||||
val sample = s0 + (s1 - s0) * frac
|
||||
|
||||
// While ramping out at sample end, hold position so the mixer keeps emitting the
|
||||
// clamped last-sample value with decaying gain — no further advance, no re-trigger
|
||||
// of the end check.
|
||||
if (voice.rampOutSamples > 0) return sample
|
||||
|
||||
if (voice.forward) {
|
||||
voice.samplePos += voice.playbackRate
|
||||
// When the sustain bit is set, key-off escapes the loop: the sample plays past
|
||||
@@ -1602,10 +1646,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val effectiveLoopMode =
|
||||
if (inst.sampleLoopSustain && voice.keyOff) 0 else (inst.loopMode and 3)
|
||||
when (effectiveLoopMode) {
|
||||
0 -> if (voice.samplePos >= sampleLen) voice.active = false
|
||||
0 -> if (voice.samplePos >= sampleLen) {
|
||||
voice.samplePos = (sampleLen - 1).toDouble().coerceAtLeast(0.0)
|
||||
startRampOut(voice)
|
||||
}
|
||||
1 -> if (voice.samplePos >= loopEnd) voice.samplePos -= (loopEnd - loopStart).coerceAtLeast(1.0)
|
||||
2 -> if (voice.samplePos >= loopEnd) { voice.samplePos = loopEnd; voice.forward = false }
|
||||
3 -> if (voice.samplePos >= sampleLen) { voice.samplePos = sampleLen.toDouble() - 1; voice.active = false }
|
||||
3 -> if (voice.samplePos >= sampleLen) {
|
||||
voice.samplePos = (sampleLen - 1).toDouble().coerceAtLeast(0.0)
|
||||
startRampOut(voice)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
voice.samplePos -= voice.playbackRate
|
||||
@@ -1614,6 +1664,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
return sample
|
||||
}
|
||||
|
||||
/**
|
||||
* Engage the MilkyTracker-style sample-end ramp. The voice keeps emitting its held
|
||||
* last-sample value for [RAMP_OUT_SAMPLES] more output samples while gain decays
|
||||
* linearly from 1.0 to 0.0; the mixer flips voice.active = false at the end.
|
||||
* No-op if already ramping (don't restart a running ramp from a re-entrant call).
|
||||
*/
|
||||
private fun startRampOut(voice: Voice) {
|
||||
if (voice.rampOutSamples > 0) return
|
||||
voice.rampOutSamples = RAMP_OUT_SAMPLES
|
||||
voice.rampOutGain = 1.0
|
||||
voice.rampOutStep = 1.0 / RAMP_OUT_SAMPLES
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a fresh note on [voice]: load the instrument, reset sample position, kick off the envelope.
|
||||
* Pulled out so S$Dx (note delay) can defer the same logic to a later tick.
|
||||
@@ -1647,6 +1710,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
voice.envPfValue = if (voice.hasPfEnv) inst.pfEnvelopes[0].value / 255.0 else 0.5
|
||||
// Fadeout starts at unity; advances only after key-off.
|
||||
voice.fadeoutVolume = 1.0
|
||||
// Cancel any sample-end ramp left over from the previous note — a fresh trigger's
|
||||
// attack must not be muted by a trailing fade.
|
||||
voice.rampOutSamples = 0
|
||||
voice.rampOutGain = 0.0
|
||||
// Auto-vibrato sweep ramp restarts on every fresh trigger.
|
||||
voice.autoVibPhase = 0
|
||||
voice.autoVibTicksSinceTrigger = 0
|
||||
@@ -1655,19 +1722,24 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
(Math.random() * (2 * inst.volumeSwing + 1)).toInt() - inst.volumeSwing else 0
|
||||
voice.randomPanBias = if (inst.panSwing != 0)
|
||||
(Math.random() * (2 * inst.panSwing + 1)).toInt() - inst.panSwing else 0
|
||||
// Default pan: applied unless the pattern row has already overridden channelPan.
|
||||
// The pan envelope's 'p' flag ("use default pan") lives in the pan LOOP word at bit 7.
|
||||
if ((inst.panEnvLoop ushr 7) and 1 != 0) {
|
||||
voice.channelPan = inst.defaultPan
|
||||
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
|
||||
}
|
||||
// Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan.
|
||||
// PPS is signed (-32..+32), full-scale at one octave (4096 4096-TET units) above PPC.
|
||||
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)
|
||||
// Default pan / pitch-pan separation: only re-applied when the row carried an instrument
|
||||
// byte. A note-only retrigger (instId == 0) inherits the channel's existing pan, mirroring
|
||||
// the volume policy below.
|
||||
if (instId != 0) {
|
||||
// Default pan: applied unless the pattern row has already overridden channelPan.
|
||||
// The pan envelope's 'p' flag ("use default pan") lives in the pan LOOP word at bit 7.
|
||||
if ((inst.panEnvLoop ushr 7) and 1 != 0) {
|
||||
voice.channelPan = inst.defaultPan
|
||||
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
|
||||
}
|
||||
// Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan.
|
||||
// PPS is signed (-32..+32), full-scale at one octave (4096 4096-TET units) above PPC.
|
||||
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.
|
||||
// 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.basePitch = noteVal
|
||||
voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new noteVal
|
||||
voice.linearFreq = -1.0 // ditto for linear-freq mode (toneMode == 2)
|
||||
voice.playbackRate = computePlaybackRate(inst, noteVal)
|
||||
// Fresh trigger resets channel volume to full ($3F). Per-instrument scaling lives in
|
||||
// instGlobalVolume (byte 171), which the mixer applies as a multiplier. Converters
|
||||
// therefore no longer need to emit SEL_SET=Sv on note-trigger rows.
|
||||
voice.channelVolume = if (volOverride >= 0) volOverride.coerceIn(0, 0x3F) else 0x3F
|
||||
// Fresh trigger resets channel volume to full ($3F) ONLY when the row carried an
|
||||
// instrument byte; a note-only retrigger (instId == 0) inherits the channel's existing
|
||||
// volume so the user can sustain a held volume across re-triggered notes. Per-instrument
|
||||
// scaling lives in instGlobalVolume (byte 171), which the mixer applies as a multiplier.
|
||||
// Converters therefore no longer need to emit SEL_SET=Sv on note-trigger rows.
|
||||
voice.channelVolume = when {
|
||||
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
|
||||
instId != 0 -> 0x3F
|
||||
else -> voice.channelVolume
|
||||
}
|
||||
voice.rowVolume = voice.channelVolume
|
||||
voice.noteWasCut = false
|
||||
voice.noteFading = false
|
||||
@@ -1824,6 +1903,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
v.noteVal = src.noteVal
|
||||
v.basePitch = src.basePitch
|
||||
v.amigaPeriod = src.amigaPeriod
|
||||
v.linearFreq = src.linearFreq
|
||||
v.volEnvOn = src.volEnvOn
|
||||
v.panEnvOn = src.panEnvOn
|
||||
v.pfEnvOn = src.pfEnvOn
|
||||
@@ -1947,11 +2027,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// Instrument byte on a porta row reloads the channel's default
|
||||
// volume even though the sample isn't retriggered. Mirrors schism
|
||||
// csf_instrument_change (effects.c:1302) which writes
|
||||
// chan->volume = psmp->volume whenever inst_column is set.
|
||||
// chan->volume = psmp->volume whenever inst_column is set, and
|
||||
// (effects.c:1402-1403) which clears CHN_KEYOFF | CHN_NOTEFADE
|
||||
// so an in-progress fadeout from the prior note does not bleed
|
||||
// into the porta'd note. fadeoutVolume is reset to unity so a
|
||||
// volume-column SET on this row is heard at face value rather
|
||||
// than scaled by the decayed tail.
|
||||
if (row.instrment != 0) {
|
||||
voice.instrumentId = row.instrment
|
||||
voice.channelVolume = 0x3F
|
||||
voice.rowVolume = 0x3F
|
||||
voice.keyOff = false
|
||||
voice.noteFading = false
|
||||
voice.fadeoutVolume = 1.0
|
||||
}
|
||||
} else if ((row.effect == EffectOp.OP_S) && ((row.effectArg ushr 12) and 0xF) == 0xD) {
|
||||
// Note delay: defer trigger to the requested tick. NNA fires when the
|
||||
@@ -1989,13 +2077,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
EffectOp.OP_NONE -> {}
|
||||
EffectOp.OP_1 -> {
|
||||
// 1 $xx00 — Global behaviour flags byte in the high byte (see TAUD_NOTE_EFFECTS.md §1).
|
||||
// bit 0 (p): 0=linear pan, 1=equal-power pan
|
||||
// bit 1 (f): 0=linear pitch slides, 1=Amiga-mode pitch slides
|
||||
// bit 2 : reserved (was 'm' fadeout-zero policy; removed — converters now scale
|
||||
// source fadeout into Taud-native units, so the engine has a single divisor)
|
||||
// bit 0 (p): 0=linear pan, 1=equal-power pan
|
||||
// bits 1-2 (ff): 0=linear pitch, 1=Amiga period, 2=linear frequency (Hz/tick),
|
||||
// 3=reserved
|
||||
val flags = rawArg ushr 8
|
||||
ts.panLaw = flags and 1
|
||||
ts.amigaMode = (flags and 2) != 0
|
||||
ts.toneMode = (flags ushr 1) and 3
|
||||
}
|
||||
EffectOp.OP_8 -> {
|
||||
// 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §8.
|
||||
@@ -2065,32 +2152,38 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val arg = resolveArg(rawArg, voice.mem.ef).also { if (rawArg != 0) voice.mem.ef = it }
|
||||
if ((arg and 0xF000) == 0xF000) {
|
||||
val mag = arg and 0x0FFF
|
||||
voice.noteVal = if (ts.amigaMode)
|
||||
amigaSlideOnce(voice.noteVal, -mag).coerceIn(0, 0xFFFE)
|
||||
else
|
||||
(voice.noteVal - mag).coerceIn(0, 0xFFFE)
|
||||
voice.noteVal = when (ts.toneMode) {
|
||||
1 -> amigaSlideOnce(voice.noteVal, -mag) // Amiga: subtract from pitch ⇒ adds period
|
||||
2 -> linearFreqSlideOnce(voice.noteVal, -mag) // Hz/tick: pitch down ⇒ -Hz
|
||||
else -> voice.noteVal - mag // linear 4096-TET
|
||||
}.coerceIn(1, 0xFFFD)
|
||||
voice.basePitch = voice.noteVal
|
||||
voice.amigaPeriod = -1.0 // reseed on next per-tick slide
|
||||
voice.linearFreq = -1.0
|
||||
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
|
||||
} else {
|
||||
voice.slideMode = 1; voice.slideArg = -arg
|
||||
voice.amigaPeriod = -1.0 // reseed at the start of a fresh multi-tick slide
|
||||
voice.linearFreq = -1.0
|
||||
}
|
||||
}
|
||||
EffectOp.OP_F -> {
|
||||
val arg = resolveArg(rawArg, voice.mem.ef).also { if (rawArg != 0) voice.mem.ef = it }
|
||||
if ((arg and 0xF000) == 0xF000) {
|
||||
val mag = arg and 0x0FFF
|
||||
voice.noteVal = if (ts.amigaMode)
|
||||
amigaSlideOnce(voice.noteVal, mag).coerceIn(0, 0xFFFE)
|
||||
else
|
||||
(voice.noteVal + mag).coerceIn(0, 0xFFFE)
|
||||
voice.noteVal = when (ts.toneMode) {
|
||||
1 -> amigaSlideOnce(voice.noteVal, mag)
|
||||
2 -> linearFreqSlideOnce(voice.noteVal, mag)
|
||||
else -> voice.noteVal + mag
|
||||
}.coerceIn(1, 0xFFFD)
|
||||
voice.basePitch = voice.noteVal
|
||||
voice.amigaPeriod = -1.0
|
||||
voice.linearFreq = -1.0
|
||||
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
|
||||
} else {
|
||||
voice.slideMode = 2; voice.slideArg = arg
|
||||
voice.amigaPeriod = -1.0
|
||||
voice.linearFreq = -1.0
|
||||
}
|
||||
}
|
||||
EffectOp.OP_G -> {
|
||||
@@ -2203,9 +2296,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
when (sub) {
|
||||
0x1 -> voice.glissandoOn = (x != 0)
|
||||
0x2 -> {
|
||||
voice.noteVal = (voice.noteVal + FINETUNE_OFFSET[x]).coerceIn(0, 0xFFFE)
|
||||
voice.noteVal = (voice.noteVal + FINETUNE_OFFSET[x]).coerceIn(1, 0xFFFD)
|
||||
voice.basePitch = voice.noteVal
|
||||
voice.amigaPeriod = -1.0
|
||||
voice.linearFreq = -1.0
|
||||
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
|
||||
}
|
||||
0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 }
|
||||
@@ -2291,24 +2385,46 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
// Pitch slides (E/F coarse on tick > 0).
|
||||
if (ts.tickInRow > 0 && (voice.slideMode == 1 || voice.slideMode == 2)) {
|
||||
voice.noteVal = if (ts.amigaMode)
|
||||
amigaSlideTick(voice, voice.slideArg).coerceIn(0, 0xFFFE)
|
||||
else
|
||||
(voice.noteVal + voice.slideArg).coerceIn(0, 0xFFFE)
|
||||
voice.noteVal = when (ts.toneMode) {
|
||||
1 -> amigaSlideTick(voice, voice.slideArg)
|
||||
2 -> linearFreqSlideTick(voice, voice.slideArg)
|
||||
else -> voice.noteVal + voice.slideArg
|
||||
}.coerceIn(1, 0xFFFD)
|
||||
voice.basePitch = voice.noteVal
|
||||
}
|
||||
|
||||
// Tone portamento (G).
|
||||
// Tone portamento (G). In linear-freq mode the speed is interpreted as Hz/tick
|
||||
// so MONOTONE 3xx (port-to-note in Hz) round-trips faithfully; in linear and
|
||||
// Amiga modes the speed is in 4096-TET pitch units (Amiga period units would be
|
||||
// backwards relative to PT semantics — see TAUD_NOTE_EFFECTS.md §G).
|
||||
if (voice.tonePortaTarget >= 0 && ts.tickInRow > 0) {
|
||||
val target = voice.tonePortaTarget
|
||||
val sp = voice.tonePortaSpeed
|
||||
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
|
||||
if (ts.toneMode == 2) {
|
||||
if (voice.linearFreq < 0.0) voice.linearFreq = noteValToFreqHz(voice.noteVal)
|
||||
val targetFreq = noteValToFreqHz(target)
|
||||
val dir = if (targetFreq > voice.linearFreq) +1.0 else -1.0
|
||||
voice.linearFreq += dir * sp
|
||||
if ((dir > 0 && voice.linearFreq >= targetFreq) ||
|
||||
(dir < 0 && voice.linearFreq <= targetFreq)) {
|
||||
voice.linearFreq = targetFreq
|
||||
voice.noteVal = target
|
||||
voice.tonePortaTarget = -1
|
||||
} else {
|
||||
voice.noteVal = freqHzToNoteVal(voice.linearFreq).coerceIn(1, 0xFFFD)
|
||||
}
|
||||
voice.basePitch = voice.noteVal
|
||||
voice.amigaPeriod = -1.0
|
||||
} else {
|
||||
val delta = if (target > voice.noteVal) sp else -sp
|
||||
voice.noteVal += delta
|
||||
if ((delta > 0 && voice.noteVal >= target) || (delta < 0 && voice.noteVal <= target)) {
|
||||
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).
|
||||
@@ -2348,14 +2464,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
if (voice.vibratoActive) {
|
||||
val sine = lfoSample(voice.vibratoLfoPos, voice.vibratoWave)
|
||||
val pitchDelta = (sine * voice.mem.huDepth) shr voice.vibratoFineShift
|
||||
pitchToMixer = (voice.noteVal + pitchDelta).coerceIn(0, 0xFFFE)
|
||||
pitchToMixer = (voice.noteVal + pitchDelta).coerceIn(1, 0xFFFD)
|
||||
voice.vibratoLfoPos = (voice.vibratoLfoPos + voice.mem.huSpeed * 4) and 0xFF
|
||||
}
|
||||
|
||||
// Glissando (S$1x) — snap pitchToMixer to nearest semitone but leave noteVal smooth.
|
||||
if (voice.glissandoOn) {
|
||||
val semis = ((pitchToMixer * 12 + 2048) / 4096)
|
||||
pitchToMixer = (semis * 4096 / 12).coerceIn(0, 0xFFFE)
|
||||
pitchToMixer = (semis * 4096 / 12).coerceIn(1, 0xFFFD)
|
||||
}
|
||||
|
||||
// Tremolo (R) — modulates output volume around base.
|
||||
@@ -2378,7 +2494,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
if (voice.arpActive) {
|
||||
val voiceIdx = ts.tickInRow % 3
|
||||
val arpDelta = when (voiceIdx) { 1 -> voice.arpOff1 shl 8; 2 -> voice.arpOff2 shl 8; else -> 0 }
|
||||
pitchToMixer = (voice.basePitch + arpDelta).coerceIn(0, 0xFFFE)
|
||||
pitchToMixer = (voice.basePitch + arpDelta).coerceIn(1, 0xFFFD)
|
||||
voice.lastArpVoice = voiceIdx
|
||||
}
|
||||
|
||||
@@ -2415,7 +2531,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
((voice.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
|
||||
else 0
|
||||
|
||||
val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(0, 0xFFFE)
|
||||
val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(1, 0xFFFD)
|
||||
voice.playbackRate = computePlaybackRate(inst, finalPitch)
|
||||
|
||||
// Filter envelope (filter mode): scale baseCut by envValue (0..1, 0.5 = unity).
|
||||
@@ -2509,7 +2625,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val pitchEnvDelta = if (bg.hasPfEnv && bg.pfEnvOn && !bg.envPfIsFilter)
|
||||
((bg.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
|
||||
else 0
|
||||
val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(0, 0xFFFE)
|
||||
val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(1, 0xFFFD)
|
||||
bg.playbackRate = computePlaybackRate(inst, finalPitch)
|
||||
// Filter-mode pf envelope: same scaling rule as foreground.
|
||||
if (bg.hasPfEnv && bg.pfEnvOn && bg.envPfIsFilter) {
|
||||
@@ -2610,8 +2726,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
rGain = if (pan < 0x80) pan / 128.0 else 1.0
|
||||
}
|
||||
}
|
||||
mixL += s * vol * lGain
|
||||
mixR += s * vol * rGain
|
||||
// Sample-end ramp-out: snapshot gain, advance the ramp, deactivate at zero.
|
||||
val rampGain = if (voice.rampOutSamples > 0) {
|
||||
val g = voice.rampOutGain
|
||||
voice.rampOutGain -= voice.rampOutStep
|
||||
voice.rampOutSamples--
|
||||
if (voice.rampOutSamples == 0) voice.active = false
|
||||
g
|
||||
} else 1.0
|
||||
mixL += s * vol * lGain * rampGain
|
||||
mixR += s * vol * rGain * rampGain
|
||||
}
|
||||
// Background (NNA-ghost) voices — same per-sample mixing path as foreground, but
|
||||
// they live in a mixer-private pool that no row event can address.
|
||||
@@ -2631,14 +2755,24 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val lGain: Double
|
||||
val rGain: Double
|
||||
when (ts.panLaw) {
|
||||
1 -> { lGain = cos(PI * pan / 512.0); rGain = sin(PI * pan / 512.0) }
|
||||
1 -> {
|
||||
lGain = cos(PI * pan / 512.0)
|
||||
rGain = sin(PI * pan / 512.0)
|
||||
}
|
||||
else -> {
|
||||
lGain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0
|
||||
rGain = if (pan < 0x80) pan / 128.0 else 1.0
|
||||
}
|
||||
}
|
||||
mixL += s * vol * lGain
|
||||
mixR += s * vol * rGain
|
||||
val rampGain = if (bg.rampOutSamples > 0) {
|
||||
val g = bg.rampOutGain
|
||||
bg.rampOutGain -= bg.rampOutStep
|
||||
bg.rampOutSamples--
|
||||
if (bg.rampOutSamples == 0) bg.active = false
|
||||
g
|
||||
} else 1.0
|
||||
mixL += s * vol * lGain * rampGain
|
||||
mixR += s * vol * rGain * rampGain
|
||||
}
|
||||
|
||||
ts.mixLeft[n] = mixL.toFloat().coerceIn(-1.0f, 1.0f)
|
||||
@@ -2863,6 +2997,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// Volume fadeout — engaged after key-off, decays to 0 at rate inst.volumeFadeoutLow.
|
||||
var fadeoutVolume = 1.0
|
||||
|
||||
// MilkyTracker-style anti-click ramp-out. Engaged when a sample naturally ends
|
||||
// (loopMode 0/3 reaching sampleLen). Gain ramps from 1.0 → 0.0 over rampOutSamples
|
||||
// while the held last-sample value keeps being emitted; voice deactivates at 0.
|
||||
// Not engaged on note start — attack transients pass unsmoothed.
|
||||
var rampOutSamples = 0
|
||||
var rampOutGain = 0.0
|
||||
var rampOutStep = 0.0
|
||||
|
||||
// Auto-vibrato (per-sample on the IT side, hoisted to the instrument here).
|
||||
var autoVibPhase = 0 // 8-bit phase counter
|
||||
var autoVibTicksSinceTrigger = 0 // for sweep ramp-up
|
||||
@@ -2896,6 +3038,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// sub-noteVal precision through repeated round-trip rounding (see amigaSlideTick).
|
||||
// -1.0 means "needs reseed from current noteVal".
|
||||
var amigaPeriod: Double = -1.0
|
||||
// Linear-frequency-mode state (Hz). Same -1.0 = stale convention as amigaPeriod.
|
||||
// Used by toneMode == 2 (MONOTONE compat) for E / F coarse slides and G tone porta.
|
||||
var linearFreq: Double = -1.0
|
||||
|
||||
// Per-row effect state (set in applyTrackerRow, consumed by applyTrackerTick).
|
||||
var rowEffect = 0
|
||||
@@ -3001,7 +3146,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
// Global mixer config (effect 1).
|
||||
var panLaw = 0 // 0 = linear balance (default), 1 = equal-power
|
||||
var amigaMode = false // false = linear pitch slides, true = Amiga period-space slides
|
||||
// Tone-slide mode for E / F / G effects (terranmon.txt §Song Table flags byte):
|
||||
// 0 = linear pitch slides (4096-TET units, default)
|
||||
// 1 = Amiga period slides (raw PT period units, applied in period space)
|
||||
// 2 = linear-frequency slides (Hz/tick — MONOTONE compat)
|
||||
// 3 = reserved
|
||||
var toneMode = 0
|
||||
|
||||
// Pending row-end events (set during a row by B/C; consumed at row end).
|
||||
var pendingOrderJump = -1 // -1 = none; otherwise the order index to jump to
|
||||
@@ -3114,7 +3264,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
initialGlobalFlags = byte
|
||||
trackerState?.let { ts ->
|
||||
ts.panLaw = byte and 1
|
||||
ts.amigaMode = (byte and 2) != 0
|
||||
ts.toneMode = (byte ushr 1) and 3
|
||||
}
|
||||
}
|
||||
8 -> { bpm = byte + 24 }
|
||||
@@ -3149,7 +3299,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
ts.sexWinningChannel = -1
|
||||
ts.finePatternDelayExtra = 0
|
||||
ts.panLaw = initialGlobalFlags and 1
|
||||
ts.amigaMode = (initialGlobalFlags and 2) != 0
|
||||
ts.toneMode = (initialGlobalFlags ushr 1) and 3
|
||||
ts.voices.forEach {
|
||||
it.active = false
|
||||
it.channelVolume = 0x3F
|
||||
|
||||
@@ -43,6 +43,7 @@ package net.torvald.tsvm.peripheral
|
||||
|
||||
import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUint
|
||||
import net.torvald.tsvm.VM
|
||||
import net.torvald.tsvm.memAddrToReadable
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.math.ceil
|
||||
@@ -398,7 +399,7 @@ class MP2Env(val vm: VM) {
|
||||
};
|
||||
// check for valid header: syncword OK, MPEG-Audio Layer 2
|
||||
if ((syspeek(mp2_frame!!) != 0xFF) || ((syspeek(mp2_frame!! + 1*incr) and 0xFE) != 0xFC)){
|
||||
throw Error("Invalid MP2 header at $mp2_frame: ${syspeek(mp2_frame!!).toString(16)} ${syspeek(mp2_frame!! + 1*incr).toString(16)}")
|
||||
throw Error("Invalid MP2 header at ${(mp2_frame as Long).memAddrToReadable()}: ${syspeek(mp2_frame!!).toString(16)} ${syspeek(mp2_frame!! + 1*incr).toString(16)}")
|
||||
};
|
||||
|
||||
// set up the bitstream reader
|
||||
|
||||
@@ -54,8 +54,8 @@ public class AppLoader {
|
||||
|
||||
|
||||
ArrayList defaultPeripherals = new ArrayList();
|
||||
defaultPeripherals.add(new Pair(3, new PeripheralEntry2("net.torvald.tsvm.peripheral.AudioAdapter", vm)));
|
||||
defaultPeripherals.add(new Pair(4, new PeripheralEntry2("net.torvald.tsvm.peripheral.HostFileHSDPA", vm, "assets/diskMediabin/lnterz_013.mv2", "assets/diskMediabin/ba60d.mov", "", "", 999999999L)));
|
||||
defaultPeripherals.add(new Pair(2, new PeripheralEntry2("net.torvald.tsvm.peripheral.AudioAdapter", vm)));
|
||||
defaultPeripherals.add(new Pair(3, new PeripheralEntry2("net.torvald.tsvm.peripheral.HostFileHSDPA", vm, "assets/diskMediabin/lnterz_013.mv2", "assets/diskMediabin/ba60d.mov", "", "", 999999999L)));
|
||||
|
||||
|
||||
EmulInstance reference = new EmulInstance(vm, "net.torvald.tsvm.peripheral.ReferenceGraphicsAdapter", diskPath, 560, 448, defaultPeripherals);
|
||||
|
||||
159
xm2taud.py
159
xm2taud.py
@@ -53,7 +53,7 @@ from taud_common import (
|
||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry,
|
||||
normalise_sample, encode_song_entry, nearest_minifloat,
|
||||
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
|
||||
)
|
||||
|
||||
@@ -78,61 +78,6 @@ XM_ENV_LOOP = 0x04
|
||||
SIGNATURE = b"xm2taud/TSVM " # 14 bytes
|
||||
|
||||
|
||||
# ── Minifloat LUT (must match it2taud / engine) ──────────────────────────────
|
||||
|
||||
_MINUFLOAT_LUT = [
|
||||
0.0, 0.03125, 0.0625, 0.09375, 0.125, 0.15625, 0.1875, 0.21875,
|
||||
0.25, 0.28125, 0.3125, 0.34375, 0.375, 0.40625, 0.4375, 0.46875,
|
||||
0.5, 0.53125, 0.5625, 0.59375, 0.625, 0.65625, 0.6875, 0.71875,
|
||||
0.75, 0.78125, 0.8125, 0.84375, 0.875, 0.90625, 0.9375, 0.96875,
|
||||
1.0, 1.03125, 1.0625, 1.09375, 1.125, 1.15625, 1.1875, 1.21875,
|
||||
1.25, 1.28125, 1.3125, 1.34375, 1.375, 1.40625, 1.4375, 1.46875,
|
||||
1.5, 1.53125, 1.5625, 1.59375, 1.625, 1.65625, 1.6875, 1.71875,
|
||||
1.75, 1.78125, 1.8125, 1.84375, 1.875, 1.90625, 1.9375, 1.96875,
|
||||
2.0, 2.0625, 2.125, 2.1875, 2.25, 2.3125, 2.375, 2.4375,
|
||||
2.5, 2.5625, 2.625, 2.6875, 2.75, 2.8125, 2.875, 2.9375,
|
||||
3.0, 3.0625, 3.125, 3.1875, 3.25, 3.3125, 3.375, 3.4375,
|
||||
3.5, 3.5625, 3.625, 3.6875, 3.75, 3.8125, 3.875, 3.9375,
|
||||
4.0, 4.125, 4.25, 4.375, 4.5, 4.625, 4.75, 4.875,
|
||||
5.0, 5.125, 5.25, 5.375, 5.5, 5.625, 5.75, 5.875,
|
||||
6.0, 6.125, 6.25, 6.375, 6.5, 6.625, 6.75, 6.875,
|
||||
7.0, 7.125, 7.25, 7.375, 7.5, 7.625, 7.75, 7.875,
|
||||
8.0, 8.25, 8.5, 8.75, 9.0, 9.25, 9.5, 9.75,
|
||||
10.0, 10.25, 10.5, 10.75, 11.0, 11.25, 11.5, 11.75,
|
||||
12.0, 12.25, 12.5, 12.75, 13.0, 13.25, 13.5, 13.75,
|
||||
14.0, 14.25, 14.5, 14.75, 15.0, 15.25, 15.5, 15.75,
|
||||
16.0, 16.5, 17.0, 17.5, 18.0, 18.5, 19.0, 19.5,
|
||||
20.0, 20.5, 21.0, 21.5, 22.0, 22.5, 23.0, 23.5,
|
||||
24.0, 24.5, 25.0, 25.5, 26.0, 26.5, 27.0, 27.5,
|
||||
28.0, 28.5, 29.0, 29.5, 30.0, 30.5, 31.0, 31.5,
|
||||
32.0, 33.0, 34.0, 35.0, 36.0, 37.0, 38.0, 39.0,
|
||||
40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0,
|
||||
48.0, 49.0, 50.0, 51.0, 52.0, 53.0, 54.0, 55.0,
|
||||
56.0, 57.0, 58.0, 59.0, 60.0, 61.0, 62.0, 63.0,
|
||||
64.0, 66.0, 68.0, 70.0, 72.0, 74.0, 76.0, 78.0,
|
||||
80.0, 82.0, 84.0, 86.0, 88.0, 90.0, 92.0, 94.0,
|
||||
96.0, 98.0, 100.0, 102.0, 104.0, 106.0, 108.0, 110.0,
|
||||
112.0, 114.0, 116.0, 118.0, 120.0, 122.0, 124.0, 126.0,
|
||||
]
|
||||
|
||||
|
||||
def _nearest_minifloat(sec: float) -> int:
|
||||
if sec <= 0.0:
|
||||
return 0
|
||||
if sec >= 126.0:
|
||||
return 255
|
||||
lo, hi = 0, len(_MINUFLOAT_LUT) - 1
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
if _MINUFLOAT_LUT[mid] < sec:
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
if lo > 0 and abs(_MINUFLOAT_LUT[lo - 1] - sec) < abs(_MINUFLOAT_LUT[lo] - sec):
|
||||
return lo - 1
|
||||
return lo
|
||||
|
||||
|
||||
# ── Data classes ─────────────────────────────────────────────────────────────
|
||||
|
||||
class XMHeader:
|
||||
@@ -611,10 +556,11 @@ def encode_effect_xm(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
# Set finetune — convert to S5x sub-effect (4-bit signed nibble).
|
||||
return (TOP_S, 0x5000 | (val << 8), None, None)
|
||||
if sub == 0x6:
|
||||
# Set loop point / loop. Taud S6x = fine pattern delay; the
|
||||
# closest analogue here is dropping with a warn if val>0.
|
||||
vprint(f" dropped E6{val:X} (set loop) at ch{ch} row{row}")
|
||||
return (TOP_NONE, 0, None, None)
|
||||
# XM E6x = pattern loop (E60 sets loop start, E6x with x>0 loops
|
||||
# x times). Maps directly onto Taud SBx, which has identical
|
||||
# semantics — the engine handles per-voice loopStartRow /
|
||||
# loopCount in applySEffect (sub 0xB).
|
||||
return (TOP_S, 0xB000 | (val << 8), None, None)
|
||||
if sub == 0x8:
|
||||
# Pan position 0..15 → set pan column (XM nybble × 17 → 8-bit).
|
||||
pan8 = (val << 4) | val
|
||||
@@ -750,6 +696,58 @@ def remap_b_effects_xm(chunks: list, chunk_map: list,
|
||||
row.effect_arg = taud_cue & 0xFF
|
||||
|
||||
|
||||
def compute_keyoff_zero_marks_xm(taud_cue_list: list, chunks: list,
|
||||
num_xm_channels: int, instruments: list,
|
||||
active_channels: list) -> dict:
|
||||
"""Identify key-off cells whose bound XM instrument has the volume envelope
|
||||
DISABLED. FT2's keyOff() (ft2_replayer.c:411-435) zeroes realVol/outVol on
|
||||
such key-offs; IT/Schism does not, and the Taud engine follows IT semantics.
|
||||
To preserve XM gating without diverging engine behaviour, the converter pairs
|
||||
each flagged key-off with `SEL_SET vol=0` in the same row's volume column —
|
||||
a later vol-col SET on the channel restores audibility, exactly mirroring
|
||||
the FT2 outVol/realVol path.
|
||||
|
||||
Walks taud_cue_list in playback order so per-channel instrument bindings
|
||||
carry across cues. When the same chunk is visited under conflicting
|
||||
bindings, the union of all flags is kept (conservatively prefers gating).
|
||||
|
||||
Returns: dict mapping chunk_idx → set of (active_voice_idx, row_idx) tuples.
|
||||
The voice_idx matches build_pattern_xm's `ch_idx` (the index into
|
||||
`active_channels`).
|
||||
"""
|
||||
xm_to_vi = {ch: vi for vi, ch in enumerate(active_channels)}
|
||||
marks = {}
|
||||
bound = [0] * num_xm_channels # 1-based XM instrument id; 0 = none
|
||||
|
||||
for ci in taud_cue_list:
|
||||
cg = chunks[ci]
|
||||
chunk_marks = marks.setdefault(ci, set())
|
||||
max_ch = min(num_xm_channels, len(cg))
|
||||
max_rows = max((len(cg[ch]) for ch in range(max_ch)), default=0)
|
||||
for r in range(max_rows):
|
||||
for xm_ch in range(max_ch):
|
||||
if r >= len(cg[xm_ch]):
|
||||
continue
|
||||
cell = cg[xm_ch][r]
|
||||
# FT2 keyOff() reads ch->instrPtr — the latest binding wins, even
|
||||
# when the inst byte is on the same row as the key-off.
|
||||
if cell.inst > 0:
|
||||
bound[xm_ch] = cell.inst
|
||||
is_keyoff = (cell.note == XM_NOTE_OFF) or (cell.effect == 0x14)
|
||||
if not is_keyoff:
|
||||
continue
|
||||
ii = bound[xm_ch]
|
||||
if ii == 0 or ii - 1 >= len(instruments):
|
||||
continue
|
||||
inst = instruments[ii - 1]
|
||||
if inst.vol_env_type & XM_ENV_ON:
|
||||
continue
|
||||
vi = xm_to_vi.get(xm_ch)
|
||||
if vi is not None:
|
||||
chunk_marks.add((vi, r))
|
||||
return marks
|
||||
|
||||
|
||||
# ── Sample / instrument bin ───────────────────────────────────────────────────
|
||||
|
||||
class _XMSampleProxy:
|
||||
@@ -808,7 +806,7 @@ def _xm_envelope_to_taud(env_pts: list, num_pts: int, env_type: int,
|
||||
if k < len(nodes) - 1:
|
||||
next_frame, _ = nodes[k + 1]
|
||||
delta_sec = max(0.0, (next_frame - frame) / ticks_per_sec)
|
||||
mf_idx = _nearest_minifloat(delta_sec)
|
||||
mf_idx = nearest_minifloat(delta_sec)
|
||||
else:
|
||||
mf_idx = 0
|
||||
else:
|
||||
@@ -1053,8 +1051,16 @@ def build_sample_inst_bin_xm(proxies: list) -> tuple:
|
||||
# ── Pattern bin builder ───────────────────────────────────────────────────────
|
||||
|
||||
def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int,
|
||||
inst_to_taud_slot: dict, amiga_mode: bool = False) -> bytes:
|
||||
"""Render one Taud channel's 512-byte pattern from a 64-row chunk grid."""
|
||||
inst_to_taud_slot: dict, amiga_mode: bool = False,
|
||||
keyoff_zero_rows: set = None) -> bytes:
|
||||
"""Render one Taud channel's 512-byte pattern from a 64-row chunk grid.
|
||||
|
||||
`keyoff_zero_rows`: optional set of row indices on this channel whose key-off
|
||||
cells should be paired with `SEL_SET vol=0` (FT2 vol-env-off gating — see
|
||||
compute_keyoff_zero_marks_xm).
|
||||
"""
|
||||
if keyoff_zero_rows is None:
|
||||
keyoff_zero_rows = frozenset()
|
||||
out = bytearray(PATTERN_BYTES)
|
||||
if ch_idx >= len(chunk_grid):
|
||||
rows = [XMRow()] * PATTERN_ROWS
|
||||
@@ -1122,6 +1128,17 @@ def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int,
|
||||
else:
|
||||
pan_sel, pan_value = SEL_FINE, 0
|
||||
|
||||
# FT2 vol-env-off key-off gating: pair the key-off with SEL_SET vol=0
|
||||
# so a later vol-col SET on the channel restores audibility (see
|
||||
# compute_keyoff_zero_marks_xm). Override any vol-col content the row
|
||||
# already has — FT2 zeros realVol/outVol after vol-col is applied
|
||||
# (ft2_replayer.c:411-428), so a SET on the same row would be clobbered.
|
||||
if r in keyoff_zero_rows and note_taud == NOTE_KEYOFF:
|
||||
if not (vol_sel == SEL_FINE and vol_value == 0):
|
||||
vprint(f" ch{ch_idx} row{r}: FT2 key-off zero overrides "
|
||||
f"vol-col (sel={vol_sel}, val={vol_value})")
|
||||
vol_sel, vol_value = SEL_SET, 0
|
||||
|
||||
vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6)
|
||||
pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6)
|
||||
|
||||
@@ -1241,6 +1258,17 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
|
||||
|
||||
remap_b_effects_xm(chunks, chunk_map, h.order_list, xm_ord_to_taud_cue, C)
|
||||
|
||||
# FT2 vol-env-off key-off gating: pre-compute per-(chunk, voice, row) flags
|
||||
# for key-off cells whose bound XM instrument has volume envelope disabled.
|
||||
# build_pattern_xm pairs each flagged key-off with `SEL_SET vol=0` so the
|
||||
# IT-style Taud engine reproduces FT2's channel-volume zeroing gate.
|
||||
keyoff_zero_marks = compute_keyoff_zero_marks_xm(
|
||||
taud_cue_list, chunks, h.channels, instruments, active_channels)
|
||||
if any(keyoff_zero_marks.values()):
|
||||
flagged = sum(len(s) for s in keyoff_zero_marks.values())
|
||||
vprint(f" FT2 keyoff-gate: {flagged} key-off cell(s) paired with vol=0 "
|
||||
f"(vol-env-off instruments)")
|
||||
|
||||
# ── Pattern bin ─────────────────────────────────────────────────────────
|
||||
total_taud_pats = len(taud_cue_list) * C
|
||||
if total_taud_pats > NUM_PATTERNS_MAX:
|
||||
@@ -1256,10 +1284,13 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
|
||||
pat_bin = bytearray()
|
||||
for ci in taud_cue_list:
|
||||
cg = chunks[ci]
|
||||
chunk_marks = keyoff_zero_marks.get(ci, frozenset())
|
||||
for vi, ch in enumerate(active_channels):
|
||||
row_marks = {r for (mvi, r) in chunk_marks if mvi == vi}
|
||||
pat_bin += build_pattern_xm(cg, ch, default_pans[vi],
|
||||
resolve_inst_slot,
|
||||
amiga_mode=not h.linear_freq)
|
||||
amiga_mode=not h.linear_freq,
|
||||
keyoff_zero_rows=row_marks)
|
||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||
|
||||
orig_count = len(taud_cue_list) * C
|
||||
|
||||
Reference in New Issue
Block a user