diff --git a/TAUD_NOTE_EFFECTS.md b/TAUD_NOTE_EFFECTS.md index 5d49217..2756fd0 100644 --- a/TAUD_NOTE_EFFECTS.md +++ b/TAUD_NOTE_EFFECTS.md @@ -151,7 +151,12 @@ 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. By default (linear mode, `f` bit unset in effect `1`) the coarse slide value is subtracted directly from the stored pitch in the 4096-TET grid. When Amiga mode is active (`f` bit set), coarse slides are instead applied in Amiga period space: the stored value is converted back to Amiga period units and subtracted from the equivalent period, producing the characteristic non-linear pitch drift of ProTracker-style slides. Fine slides (`E $Fxxx`) are always applied in linear pitch-unit space regardless of mode. A coarse slide uses the full value range; a fine slide applies only once per row. +**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): + +- **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). + +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. Coarse and fine modes are distinguished by the high nibble of the argument: @@ -159,13 +164,18 @@ Coarse and fine modes are distinguished by the high nibble of the argument: - `E $F000..$FFFF` — fine slide: on tick 0 only, subtracts `arg & $0FFF` from pitch. - `E $0000` — recalls the last E-or-F argument and applies it as a down-slide, preserving the original form (coarse or fine). -**Compatibility.** This is **the single intentionally ST3-incompatible command in Taud**. ST3 pitch slides operate on Amiga periods or linear slide units; Taud operates directly on 4096-TET pitch units. Coarse and fine forms use different unit sizes: +**Compatibility.** ST3 pitch slides operate on Amiga periods or linear slide units; Taud's storage depends on the song-table mode flag: -- 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). +- **Linear-source ST3 song** (`linear_slides` set in S3M flags → Taud `f` bit clear): + - 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). -ST3 Amiga-mode coarse slides do not have a clean conversion and should be treated as linear-mode equivalents during import (same `round(× 64/3)` scale). The Amiga-mode flag (`f` bit in effect `1` or the song-table flags byte) is set in the output file to signal the mixer to apply the stored values in period space rather than directly in pitch space. This preserves the characteristic non-linearity of Amiga slides (lower pitches slide more slowly in semitone terms) without requiring a different numeric encoding. Fine and extra-fine slides (`E $Fxxx`) are always applied in linear pitch-unit space regardless of the Amiga-mode flag, as they are ST3-specific extensions absent from ProTracker. +- **Amiga-source ST3/PT song** (`linear_slides` clear → Taud `f` bit set): + - 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. 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. @@ -176,8 +186,13 @@ on row start: raw = arg if raw == 0: raw = memory_EF else: memory_EF = raw - if (raw & $F000) == $F000: # fine - pitch -= (raw & $0FFF) + 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. + pitch = amiga_slide_down(pitch, mag) + else: + pitch -= mag mode_this_row = FINE else: # coarse slide_amount_this_row = raw @@ -186,9 +201,10 @@ on row start: on tick > 0: if mode_this_row == COARSE: if amiga_mode: - # period = AMIGA_BASE_PERIOD × 2^(−(pitch − C3) / 4096) - # period += slide_amount_this_row × (3/64) # convert Taud units → Amiga period units - # pitch = C3 + 4096 × log2(AMIGA_BASE_PERIOD / period) + # 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) else: pitch -= slide_amount_this_row @@ -200,9 +216,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. +**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. -**Compatibility.** Same as E. ST3 `Fxx` coarse converts using `round(x × 64/3)`; `FFx` fine and `FEx` extra-fine convert using `round(x × 16/3)`. F and E share one memory slot in Taud. Amiga-mode behaviour is controlled by the same `f` flag as E; coarse F slides are applied in period space when the flag is set, while fine slides remain linear. +**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. **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. @@ -552,9 +568,9 @@ Peak at maximum settings: $7F × $FF >> 9 = $3F — the full panning range. Retr S is a multiplexing opcode; the **high nibble of the high byte** selects the sub-effect, and the remainder is the sub-argument. -## S $1x00 — Glissando control +## S $1x00 — ST3/IT Glissando control -**Plain.** `$1000` turns glissando off; `$1100` turns it on. When on, tone portamento (G) output is quantised to the nearest semitone ($0155 approximation) before being sent to the mixer. The internal G pitch counter still advances smoothly; only the audible pitch steps. **This command is implemented sorely for ST3 compatibility.** +**Plain.** `$1000` turns glissando off; `$1100` turns it on. When on, tone portamento (G) output is quantised to the nearest semitone ($0155 approximation) before being sent to the mixer. The internal G pitch counter still advances smoothly; only the audible pitch steps. **This command is implemented sorely for ST3/IT compatibility.** and therefore only works in 12-TET context. **Compatibility.** ST3 `S10`/`S11` maps directly. In Taud, "nearest semitone" uses the best integer approximation: round `pitch / $155` to the nearest integer, multiply by $155; equivalently, `snapped = (pitch + $AB) / $155 × $155`. Because $155 is an approximation of 4096/12, accumulated rounding across many octaves will drift by up to a few cents; this is documented behaviour and intentional given the microtonal grid. @@ -693,9 +709,9 @@ The crucial bug fix relative to ST3: the loop-counter decrement happens **once p **Plain.** Delays the triggering of the note (and any co-row instrument, offset, and volume event) until tick `$x`. Until then, any currently playing note continues. -**Compatibility.** ST3 `SDx` maps directly. ProTracker `EDx` also maps directly. `SD0` plays the note normally on tick 0. If `$x ≥ speed`, the note never plays on this row and does not carry over to the next row. +**Compatibility.** ST3 `SDx` maps directly. ProTracker `EDx` also maps directly. `SD0` plays the note normally on tick 0. If `$x ≥ speed`, the note never plays on this row and does not carry over to the next row. Some trackers allow playback of "malformed" note delays (`$x` greater than current tick speed). Taud discards those notes. If such note events have been encountered during conversion, they must be corrected on the converter. -**Implementation.** On row parse, defer the note-trigger event (including sample selection, volume, offset, and any volume-column effect) until tick `$x`. On tick `$x`, execute the deferred trigger. When combined with pattern delay (S $Ex00), the deferred trigger re-fires at the start of each row repetition — matching ST3's `kRowDelayWithNoteDelay` behaviour. +**Implementation.** On row parse, defer the note-trigger event (including sample selection, volume, offset, and any volume-column effect) until tick `$x`. On tick `$x`, execute the deferred trigger. When combined with pattern delay (S $Ex00), the deferred trigger re-fires at the start of each row repetition — matching ST3's `kRowDelayWithNoteDelay` behaviour. If `$x` is greater than current tick speed, the note must be discarded (see compatibility notes above) --- @@ -785,8 +801,8 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o - 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. -- f unset: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. -- f set: Amiga tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. +- f unset: 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. +- f set: 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. **Implementation.** - Panning-linear: @@ -795,12 +811,11 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o - Panning-equal-power: - L_gain = cos(pi*x / 512.0) - R_gain = sin(pi*x / 512.0) -- Amiga tone (coarse E/F pitch slides only; fine slides are always linear): - - AMIGA_BASE_PERIOD = 214.0 (period at the Taud reference pitch C3 for a standard 8363 Hz instrument, NTSC clock) - - AMIGA_PERIOD_SCALE = 3.0 / 64.0 (converts stored Taud coarse-slide units back to Amiga period units) - - period = AMIGA_BASE_PERIOD × 2^(−(noteVal − C3) / 4096) - - period_new = period − slideArg × AMIGA_PERIOD_SCALE (slideArg < 0 for E, > 0 for F) - - noteVal_new = C3 + 4096 × log2(AMIGA_BASE_PERIOD / period_new) +- 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) **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. @@ -813,9 +828,9 @@ This table maps each PT effect to its Taud equivalent. Arguments follow PT's two | PT effect | Taud effect | Notes | |---------|-----------|-------| | `0 $xy` | `J $xxyy` | Arpeggio; nibble-repeat each byte. See the 12-TET → Taud table above for conversion losses | -| `1 $xx` | `F round($0xxx × 64/3)` | Portamento up; ST3 coarse slide unit = 1/16 semitone | -| `2 $xx` | `E round($0xxx × 64/3)` | Portamento down | -| `3 $xx` | `G round($0xxx × 64/3)` | Portamento to note | +| `1 $xx` | `F $00xx` (Amiga mode, `f` set) | Portamento up; raw PT period units, applied in period space | +| `2 $xx` | `E $00xx` (Amiga mode, `f` set) | Portamento down; raw PT period units, applied in period space | +| `3 $xx` | `G round($0xxx × 64/3)` | Portamento to note; G is always linear (4096-TET units) regardless of mode | | `4 $xy` | `H $xxyy` | Vibrato; nibble-repeat each byte. | | `5 $xy` | `L $xy00` | Combined portamento + volume slide (see compatibility note) | | `6 $xy` | `K $xy00` | Combined vibrato + volume slide (see compatibility note) | @@ -827,8 +842,8 @@ This table maps each PT effect to its Taud equivalent. Arguments follow PT's two | `C $xx` | Volume column `0.$xx` | Set volume | | `D $xx` | `C $00xx` (after BCD decode) | Pattern break | | `E $0x` | `S $000x` | (UNIMPLEMENTED) Set filter | -| `E $1x` | `E $F000 + round($0xxx × 16/3)` | Fine pitch slide up | -| `E $2x` | `E $F000 + round($0xxx × 16/3)` | Fine pitch slide down | +| `E $1x` | `F $F00x` (Amiga mode, `f` set) | Fine pitch slide up; raw PT period units, applied in period space at tick 0 | +| `E $2x` | `E $F00x` (Amiga mode, `f` set) | Fine pitch slide down; raw PT period units, applied in period space at tick 0 | | `E $3x` | `S $1x00` | Glissando control | | `E $4x` | `S $3x00` | Vibrato waveform | | `E $5x` | `S $2x00` | Set fine-tune | @@ -867,7 +882,11 @@ These quirks of ST3 are worth preserving or flagging when importing S3M files in **Global volume scale.** ST3's 0..$40 maps to Taud's 0..$FF with a ×4 scale on import, truncated ÷4 on export. -**Linear pitch slides.** ST3's slide arithmetic is period-based (Amiga) or linear-table-indexed; Taud's default is purely linear in 4096-TET units. ST3 songs in linear mode convert cleanly: coarse forms (Exx/Fxx/Gxx) use `round(× 64/3)` (1/16 semitone per unit), fine/extra-fine forms (EFx/EEx/FFx/FEx) use `round(× 16/3)` (1/64 semitone per unit). ST3 songs in Amiga mode use the **same numeric conversion** for coarse E/F (the exact period-step count is not preserved), but the converter sets bit 1 (`f`) of the song-table flags byte and Taud's mixer re-applies the stored coarse slide values in Amiga period space at playback, recovering the non-linear pitch character. G is always treated as linear regardless of mode. Fine/extra-fine slides are always linear. +**Linear pitch slides.** ST3's slide arithmetic is period-based (Amiga) or linear-table-indexed; Taud carries both interpretations and selects between them via the song-table `f` flag. Conversion rules: + +- **ST3 linear mode** (`linear_slides` set in S3M flags): coarse forms (Exx/Fxx) use `round(× 64/3)` (1/16 semitone per ST3 unit); fine/extra-fine (EFx/EEx/FFx/FEx) use `round(× 16/3)` (1/64 semitone per ST3 unit). Taud `f` flag is **clear**; the engine subtracts the stored 4096-TET argument directly from the channel pitch. +- **ST3 Amiga mode** (`linear_slides` clear): both coarse (Exx/Fxx) and fine/extra-fine (EFx/EEx/FFx/FEx) are stored **verbatim** as raw ST3 period units — coarse as `E/F $00xx`, fine as `E/F $F00x` — with no scaling. Taud `f` flag is **set**; the engine applies both forms in Amiga period space at playback, exactly recovering the source's period-step count and the non-linear pitch character. +- G (tone portamento) is always converted with `round(× 64/3)` and treated as linear, regardless of mode. **Default tempo byte.** Taud's default $65 equals 125 BPM under the $18 offset; this is not the same as ST3's `$7D` default, which maps to Taud `$65` after subtracting $18. Converters must remap on both import and export. diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 3ffba58..3807b00 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -310,7 +310,7 @@ function buildRowCell(ptnDat, row) { else if (voleff >>> 6 == 3) { if (voleffarg == 0) { sVolEff = sym.middot - sVolArg = sym.middot.repeat(1) + sVolArg = sym.middot } else if (voleffarg >= 32) { sVolEff = volEffSym[3] @@ -334,7 +334,7 @@ function buildRowCell(ptnDat, row) { else if (paneff >>> 6 == 3) { if (paneffarg == 0) { sPanEff = sym.middot - sPanArg = sym.middot.repeat(1) + sPanArg = sym.middot } else if (paneffarg >= 32) { sPanEff = panEffSym[4] @@ -359,7 +359,7 @@ function buildRowCell(ptnDat, row) { const EMPTY_CELL = { sNote: sym.middot.repeat(4), - sInst: sym.middot.repeat(3), + sInst: sym.middot.repeat(2), sVolEff: '', sVolArg: sym.middot.repeat(2), sPanEff: '', diff --git a/it2taud.py b/it2taud.py index 220907a..fdd8bae 100644 --- a/it2taud.py +++ b/it2taud.py @@ -770,7 +770,8 @@ def decode_volcol(vc: int): # ── Effect translator ───────────────────────────────────────────────────────── -def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: +def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0, + amiga_mode: bool = False) -> tuple: """Return (taud_op, taud_arg16, vol_override, pan_override). Differs from s3m2taud.encode_effect in: @@ -778,6 +779,12 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: - V: IT global vol 0-128 scaled ×2 - X: IT full 8-bit pan → 6-bit - S6x, S7x, SAx, SFx handled (mostly dropped) + + amiga_mode mirrors the inverse of the IT ``linear_slides`` flag. When + set, E/F coarse pitch-slide arguments are emitted as raw IT period units + (the engine applies them directly in period space); when clear they are + quantised to 4096-TET units via ``round(× 64/3)``. Fine/extra-fine + slides and tone portamento (G) are always linear regardless of mode. """ if cmd == 0: return (TOP_NONE, 0, None, None) @@ -801,11 +808,20 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: return (TOP_D, (arg & 0xFF) << 8, None, None) if cmd in (EFF_E, EFF_F): + # Coarse: 1/16 semitone = 64/3 Taud units in linear mode; raw IT period + # units in Amiga mode (engine consumes them in period space). + # Fine/extra-fine (Exx with hi ∈ {E,F}): 1/64 semitone = 16/3 Taud units + # in linear mode; raw IT period units in Amiga mode (engine consumes + # them in period space, applied once per row at tick 0). op = TOP_E if cmd == EFF_E else TOP_F hi = (arg >> 4) & 0xF lo = arg & 0xF if hi in (0xE, 0xF) and lo > 0: + if amiga_mode: + return (op, 0xF000 | (lo & 0xFFF), None, None) return (op, 0xF000 | (round(lo * 16 / 3) & 0xFFF), None, None) + if amiga_mode: + return (op, arg & 0xFFFF, None, None) return (op, round(arg * 64 / 3) & 0xFFFF, None, None) if cmd == EFF_G: @@ -1236,7 +1252,7 @@ def _it_default_pan(raw_pan: int) -> int: return min(0x3F, round(p * 63 / 64)) def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int, - inst_vols: dict) -> bytes: + inst_vols: dict, amiga_mode: bool = False) -> bytes: """Build a 512-byte Taud pattern for one IT channel from a 64-row chunk grid.""" out = bytearray(PATTERN_BYTES) rows = chunk_grid[ch_idx] if ch_idx < len(chunk_grid) else [ITRow()] * PATTERN_ROWS @@ -1263,7 +1279,7 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int, # Encode main effect op, arg16, vol_override, pan_override = encode_effect_it( - cell.effect, cell.effect_arg, ch_idx, r) + cell.effect, cell.effect_arg, ch_idx, r, amiga_mode=amiga_mode) # ── Note ──────────────────────────────────────────────────────────── note_taud = NOTE_NOP @@ -1280,9 +1296,11 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int, # > retrigger recall > vol-col slide > main-effect vol override > nop if cell.volcol >= 0 and cell.volcol <= VC_VOL_HI: vol_sel, vol_value = SEL_SET, min(cell.volcol, 0x3F) - elif note_triggers and last_inst > 0: + elif note_triggers and cell.inst > 0: vol_sel = SEL_SET vol_value = inst_vols.get(last_inst, 0x3F) + elif note_triggers and last_vol is not None: + vol_sel, vol_value = SEL_SET, last_vol elif (cell.inst > 0 and cell.note < 0 and last_note_it >= 0 and last_vol is not None): # Instrument-only retrigger: restate last volume @@ -1604,7 +1622,8 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list, for ci in taud_cue_list: cg = chunks[ci] for vi, ch in enumerate(active_channels): - pat_bin += build_pattern_it(cg, ch, default_pans[vi], inst_vols) + pat_bin += build_pattern_it(cg, ch, default_pans[vi], inst_vols, + amiga_mode=not h.linear_slides) orig_count = len(taud_cue_list) * C pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count) diff --git a/mod2taud.py b/mod2taud.py index a95c36d..63430e8 100644 --- a/mod2taud.py +++ b/mod2taud.py @@ -244,11 +244,17 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: lo = arg & 0xF return (TOP_J, (J_SEMI_TABLE[hi] << 8) | J_SEMI_TABLE[lo], None, None) + # PT is Amiga-cycle-based by definition (the Taud Amiga-mode flag is set in + # the song table, see end of build_taud()). E/F coarse pitch-slide arguments + # are therefore stored as raw PT period units; the engine consumes them + # directly in period space. G (tone portamento) is treated as linear even + # in Amiga mode per the Taud spec, so its argument is still quantised to + # 4096-TET units. Fine slides (E1x/E2x below) likewise remain linear. if cmd == 0x1: - return (TOP_F, round(arg * 64 / 3) & 0xFFFF, None, None) + return (TOP_F, arg & 0xFFFF, None, None) if cmd == 0x2: - return (TOP_E, round(arg * 64 / 3) & 0xFFFF, None, None) + return (TOP_E, arg & 0xFFFF, None, None) if cmd == 0x3: return (TOP_G, round(arg * 64 / 3) & 0xFFFF, None, None) @@ -304,11 +310,11 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: # E0x = filter on/off (Amiga LED filter); no Taud equivalent. return (TOP_NONE, 0, None, None) if sub == 0x1: - # Fine pitch up. - return (TOP_F, 0xF000 | (round(x * 16 / 3) & 0xFFF), None, None) + # Fine pitch up — raw PT period units in Amiga mode (file is always Amiga). + return (TOP_F, 0xF000 | (x & 0xFFF), None, None) if sub == 0x2: - # Fine pitch down. - return (TOP_E, 0xF000 | (round(x * 16 / 3) & 0xFFF), None, None) + # Fine pitch down — raw PT period units in Amiga mode. + return (TOP_E, 0xF000 | (x & 0xFFF), None, None) if sub == 0x3: return (TOP_S, 0x1000 | (x << 8), None, None) if sub == 0x4: @@ -563,9 +569,11 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int, if vol_override is not None and vol_override[0] != SEL_SET: vprint(f" ch{ch_idx} row{r}: dropped vol slide " f"(cell already carries explicit Cxx volume)") - elif note_triggers and last_inst > 0: + elif note_triggers and row.inst > 0: vol_sel = SEL_SET vol_value = inst_vols.get(last_inst, 0x3F) + elif note_triggers and last_vol is not None: + vol_sel, vol_value = SEL_SET, last_vol elif retrigger and last_vol is not None: vol_sel, vol_value = SEL_SET, last_vol elif vol_override is not None: diff --git a/s3m2taud.py b/s3m2taud.py index b53dd36..4165037 100644 --- a/s3m2taud.py +++ b/s3m2taud.py @@ -235,12 +235,19 @@ def encode_note(s3m_note: int) -> int: return max(1, min(0xFFFD, val)) -def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: +def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0, + amiga_mode: bool = False) -> tuple: """Return (taud_op, taud_arg16, vol_override, pan_override). vol/pan_override is None or (selector, value). The caller is responsible for resolving ST3 zero-arg recalls before this point — see resolve_st3_recalls(). + + amiga_mode mirrors the inverse of the S3M ``linear_slides`` flag. When + set, E/F coarse pitch-slide arguments are emitted as raw ST3 period units + (the engine applies them directly in period space); when clear they are + quantised to 4096-TET units via ``round(× 64/3)``. Fine/extra-fine + slides and tone portamento (G) are always linear regardless of mode. """ if cmd == 0: return (TOP_NONE, 0, None, None) @@ -265,12 +272,20 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: return (TOP_D, (arg & 0xFF) << 8, None, None) if cmd in (EFF_E, EFF_F): - # Coarse: 1/16 semitone = 64/3 Taud units. Fine/extra-fine: 1/64 semitone = 16/3. + # Coarse: 1/16 semitone = 64/3 Taud units in linear mode; raw ST3 period + # units in Amiga mode (engine consumes them in period space). + # Fine/extra-fine (Exx with hi ∈ {E,F}): 1/64 semitone = 16/3 Taud units + # in linear mode; raw ST3 period units in Amiga mode (engine consumes + # them in period space, applied once per row at tick 0). op = TOP_E if cmd == EFF_E else TOP_F hi = (arg >> 4) & 0xF lo = arg & 0xF if hi in (0xE, 0xF) and lo > 0: + if amiga_mode: + return (op, 0xF000 | (lo & 0xFFF), None, None) return (op, 0xF000 | (round(lo * 16 / 3) & 0xFFF), None, None) + if amiga_mode: + return (op, arg & 0xFFFF, None, None) return (op, round(arg * 64 / 3) & 0xFFFF, None, None) if cmd == EFF_G: @@ -515,7 +530,8 @@ def _default_channel_pan(ch_setting: int) -> int: def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int, - linear_slides: bool, inst_vols: dict = None) -> bytes: + linear_slides: bool, inst_vols: dict = None, + amiga_mode: bool = False) -> bytes: """Build a 512-byte Taud pattern for one S3M channel. Volume column: explicit S3M cell vol → SEL_SET; when a note triggers @@ -547,7 +563,7 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int, and last_note not in (S3M_NOTE_EMPTY, S3M_NOTE_OFF)) op, arg, vol_override, pan_override = encode_effect( - row.effect, row.effect_arg, ch_idx, r) + row.effect, row.effect_arg, ch_idx, r, amiga_mode=amiga_mode) # ── Volume column ── note_triggers = (row.note not in (S3M_NOTE_EMPTY, S3M_NOTE_OFF)) @@ -556,11 +572,15 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int, if vol_override is not None and vol_override[0] != SEL_SET: vprint(f" ch{ch_idx} row{r}: dropped vol slide " f"(cell already carries explicit volume)") - elif note_triggers and last_inst > 0: - # Note trigger with no explicit vol: use instrument default volume - # so prior channel-vol state doesn't bleed through. + elif note_triggers and row.inst > 0: + # Note trigger with a fresh instrument: use that instrument's + # default volume. vol_sel = SEL_SET vol_value = inst_vols.get(last_inst, 0x3F) + elif note_triggers and last_vol is not None: + # Note trigger without instrument: keep the channel's current + # volume rather than resetting to the instrument default. + vol_sel, vol_value = SEL_SET, last_vol elif retrigger and last_vol is not None: # Instrument-only row: re-emit the last known volume so the sample # restarts at the correct level without an explicit note trigger. @@ -778,7 +798,8 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes: for pi in range(P): grid = patterns[pi] for vi, ch in enumerate(active_channels): - pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides, inst_vols) + pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides, + inst_vols, amiga_mode=not h.linear_slides) assert len(pat_bin) == num_taud_pats * PATTERN_BYTES # Deduplicate identical patterns diff --git a/terranmon.txt b/terranmon.txt index 48ca8ee..708fa72 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -1997,7 +1997,7 @@ Sample bin: just raw sample data thrown in there. You need to keep track of star Instrument bin: Registry for 256 instruments, formatted as: Uint32 Sample Pointer Uint16 Sample length - Uint16 Sampling rate at C4 (note number 0x5000. XM: if "relative note" and finetune is used, this value should be directly modified against sample's default sampling rate) + Uint16 Sampling rate at C4 (note number 0x5000) Uint16 Play Start (usually 0 but not always) Uint16 Loop Start (can be smaller than Play Start) Uint16 Loop End @@ -2064,12 +2064,12 @@ Instrument bin: Registry for 256 instruments, formatted as: Uint16 Pitch-pan centre (4096-TET note value) Sint8 Pitch-pan separation (-128..127 full range) Uint8 Pan swing (0..255 full range) - Uint8 Default cutoff (0..255 full range) - Uint8 Default resonance (0..255 full range) - Byte[8] Reserved + Uint8 Default cutoff (0..255 full range. Effect range equals to that of ImpulseTracker -- 128 in IT is equal to 255 in Taud) + Uint8 Default resonance (0..255 full range. Effect range equals to that of ImpulseTracker -- 128 in IT is equal to 255 in Taud) + Uint16 Sample detune (in 4096-TET unit) (XM finetune scale need to be rescaled accordingly) + Byte[6] Reserved TODO: after *2taud.py is done, extend with 25 envelopes and add Pitch/Filter features. 192 bytes per instrument granted (48k space). This is a breaking change. - TODO: add sample finetune (u16) support -- XM compatibility TODO: use it2taud.py to implement pitch/filter -- don't delete rerender code yet Play Data: play data are series of tracker-like instructions, visualised as: @@ -2275,7 +2275,7 @@ Endianness: Little Byte[14]Tracker/Converter signature ## Song Table - * Rows of 16 bytes: + * Rows of 32 bytes: Uint32 Song offset Uint8 Number of voices Uint16 Number of patterns (0 is invalid. pattern bin length = numPats * 8 bytes) @@ -2284,6 +2284,11 @@ 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') + Uint8 Song global volume + * ImpulseTracker has range of 0..128; multiply by (255/128) then round to int + Uint8 Song mixing volume + * ImpulseTracker has range of 0..128; multiply by (255/128) then round to int + Byte[14] Reserved Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted as a song. @@ -2298,7 +2303,7 @@ Endianness: Little For your reference, tracker default tuning at A4 is 439.526 Hz (8363*2^(3/4) / 32) ## Pattern Bin and Cue Sheet - Pattern Bin/Cue Sheet images + RAM image of Pattern Bin/Cue Sheet ## Project Data diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 379d20e..646b1d3 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -127,11 +127,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { const val TRACKER_C3 = 0x4000 // legacy alias (one octave below the new reference) const val TRACKER_C4 = 0x5000 // reference C for instrument samplingRate (terranmon.txt:2000) // Amiga period at TRACKER_C4 for a standard 8363 Hz instrument (NTSC clock 3579545 Hz). - // Reference shifted from C3→C4 (one octave up), so the period halves: 214 → 107. - const val AMIGA_BASE_PERIOD = 107.0 - // Scale factor that converts a Taud coarse-slide unit back to one Amiga period unit. - // Taud coarse unit = round(ST3_unit × 64/3), so the inverse is × 3/64. - const val AMIGA_PERIOD_SCALE = 3.0 / 64.0 + // PT "C-2" period 428 ↔ TSVM TRACKER_C4 ↔ 8363 Hz; mod2taud uses the same convention. + const val AMIGA_BASE_PERIOD = 428.0 } internal val sampleBin = UnsafeHelper.allocate(737280L, this) @@ -1173,13 +1170,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { private fun computePlaybackRate(inst: TaudInst, noteVal: Int): Double = inst.samplingRate.toDouble() / SAMPLING_RATE * 2.0.pow((noteVal - TRACKER_C4) / 4096.0) - // Applies one tick of Amiga-mode pitch slide. slideArg uses the same sign convention as - // linear mode: negative = pitch down (E effect), positive = pitch up (F effect). - // The Taud coarse-slide value is converted back to Amiga period units via AMIGA_PERIOD_SCALE. + // Applies one tick of Amiga-mode pitch slide. When the song is in Amiga tone mode, E/F coarse + // slide arguments are stored as raw tracker period units (the original ProTracker/ST3 byte), + // *not* scaled to 4096-TET — see TAUD_NOTE_EFFECTS.md §1 and §E/F. Sign convention matches + // linear mode: negative = pitch down (E effect), positive = pitch up (F effect), so a positive + // slideArg subtracts from the period (pitch rises). private fun amigaSlide(noteVal: Int, slideArg: Int): Int { val period = AMIGA_BASE_PERIOD * 2.0.pow(-(noteVal - TRACKER_C4).toDouble() / 4096.0) - // Negate slideArg: pitch down (slideArg < 0) → period up, pitch up (slideArg > 0) → period down. - val newPeriod = (period - slideArg * AMIGA_PERIOD_SCALE).coerceAtLeast(1.0) + val newPeriod = (period - slideArg).coerceAtLeast(1.0) return (TRACKER_C4 + 4096.0 * log2(AMIGA_BASE_PERIOD / newPeriod)).roundToInt() } @@ -1319,6 +1317,66 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } } + /** + * Recompute the biquad LPF coefficients for `voice` when its cutoff or + * resonance has changed since the last refresh. Cutoff 0..255 maps + * exponentially from ~110 Hz to ~14 kHz; resonance 0..255 maps linearly + * to Q ∈ [0.5, 6.0]. The filter is disabled at full-open (cutoff ≥ 0xFE + * with no resonance), avoiding the per-sample cost when transparent. + */ + private fun refreshVoiceFilter(voice: Voice) { + val cut = voice.currentCutoff.coerceIn(0, 255) + val res = voice.currentResonance.coerceIn(0, 255) + if (cut == voice.filterCutoffCached && res == voice.filterResonanceCached) return + voice.filterCutoffCached = cut + voice.filterResonanceCached = res + + if (cut >= 0xFE && res == 0) { + voice.filterActive = false + return + } + + // Exponential cutoff: 110 Hz × 2^(cut × log2(14000/110) / 255). + // log2(14000/110) ≈ 6.992, so exponent ≈ cut × 0.0274. + val cutoffHz = 110.0 * 2.0.pow(cut * 6.992 / 255.0) + val nyquist = SAMPLING_RATE * 0.5 - 1.0 + val f0 = cutoffHz.coerceIn(20.0, nyquist) + val Q = 0.5 + (res / 255.0) * 5.5 + + val w0 = 2.0 * PI * f0 / SAMPLING_RATE + val cosW = cos(w0) + val sinW = sin(w0) + val alpha = sinW / (2.0 * Q) + + val b0 = (1.0 - cosW) * 0.5 + val b1 = 1.0 - cosW + val b2 = b0 + val a0 = 1.0 + alpha + val a1 = -2.0 * cosW + val a2 = 1.0 - alpha + + voice.filterB0 = b0 / a0 + voice.filterB1 = b1 / a0 + voice.filterB2 = b2 / a0 + voice.filterA1 = a1 / a0 + voice.filterA2 = a2 / a0 + voice.filterActive = true + } + + /** Apply the cached biquad LPF to one mono sample. Caller must have called + * refreshVoiceFilter at the start of the tick. */ + private fun applyVoiceFilter(voice: Voice, x0: Double): Double { + if (!voice.filterActive) return x0 + val y0 = voice.filterB0 * x0 + + voice.filterB1 * voice.filterX1 + + voice.filterB2 * voice.filterX2 - + voice.filterA1 * voice.filterY1 - + voice.filterA2 * voice.filterY2 + voice.filterX2 = voice.filterX1; voice.filterX1 = x0 + voice.filterY2 = voice.filterY1; voice.filterY1 = y0 + return y0 + } + /** * IT-style auto-vibrato: returns a 4096-TET pitch delta to add to the * playback note for the current tick, and advances the LFO phase. @@ -1443,6 +1501,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // Filter cutoff/resonance defaults — adjusted per-tick by the pf envelope when in filter mode. voice.currentCutoff = if (inst.defaultCutoff > 0) inst.defaultCutoff else 0xFF voice.currentResonance = inst.defaultResonance + voice.filterX1 = 0.0; voice.filterX2 = 0.0 + voice.filterY1 = 0.0; voice.filterY2 = 0.0 + voice.filterCutoffCached = -1 // force coefficient refresh on first tick + voice.filterResonanceCached = -1 voice.noteVal = noteVal voice.basePitch = noteVal voice.playbackRate = computePlaybackRate(inst, noteVal) @@ -1594,7 +1656,11 @@ 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 = (voice.noteVal - mag).coerceIn(0, 0xFFFE); voice.basePitch = voice.noteVal + voice.noteVal = if (ts.amigaMode) + amigaSlide(voice.noteVal, -mag).coerceIn(0, 0xFFFE) + else + (voice.noteVal - mag).coerceIn(0, 0xFFFE) + voice.basePitch = voice.noteVal voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal) } else { voice.slideMode = 1; voice.slideArg = -arg @@ -1604,7 +1670,11 @@ 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 = (voice.noteVal + mag).coerceIn(0, 0xFFFE); voice.basePitch = voice.noteVal + voice.noteVal = if (ts.amigaMode) + amigaSlide(voice.noteVal, mag).coerceIn(0, 0xFFFE) + else + (voice.noteVal + mag).coerceIn(0, 0xFFFE) + voice.basePitch = voice.noteVal voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal) } else { voice.slideMode = 2; voice.slideArg = arg @@ -1872,6 +1942,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.fadeoutVolume = 1.0 voice.autoVibPhase = 0 voice.autoVibTicksSinceTrigger = 0 + voice.filterX1 = 0.0; voice.filterX2 = 0.0 + voice.filterY1 = 0.0; voice.filterY2 = 0.0 voice.rowVolume = applyRetrigVolMod(voice.rowVolume, voice.retrigVolMod) voice.channelVolume = voice.rowVolume } @@ -1895,6 +1967,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.currentCutoff = (baseCut * (voice.envPfValue * 2.0)).toInt().coerceIn(0, 0xFF) } + // Refresh biquad filter coefficients once per tick (only recomputes when changed). + refreshVoiceFilter(voice) + // Volume fadeout: after key-off, decrement by inst.volumeFadeout / 1024 per tick. // The 10-bit fadeout value is split across volumeFadeoutLow + low nibble of fadeoutHighVibDepth. if (voice.keyOff) { @@ -1983,7 +2058,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { for (voice in ts.voices) { if (!voice.active || voice.muted) continue val voiceInst = instruments[voice.instrumentId] - val s = fetchTrackerSample(voice, voiceInst) + val s = applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst)) val instGv = voiceInst.instGlobalVolume / 255.0 // Volume swing bias (random per-trigger, ±randomVolBias of 0..255 units folded into the 0..63 row volume). val swingScale = 1.0 + voice.randomVolBias / 255.0 @@ -2189,9 +2264,24 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var autoVibPhase = 0 // 8-bit phase counter var autoVibTicksSinceTrigger = 0 // for sweep ramp-up - // Filter / cutoff state (engine-side; biquad filter not yet applied to the output). + // Filter / cutoff state — drives the per-voice 2-pole resonant LPF. var currentCutoff = 0xFF // 0..255 (0xFF = open / unfiltered) var currentResonance = 0 // 0..255 + // Biquad state (updated per output sample) and cached coefficients + // (recomputed per tick when cutoff/resonance change). + var filterActive = false + var filterB0 = 1.0 + var filterB1 = 0.0 + var filterB2 = 0.0 + var filterA1 = 0.0 + var filterA2 = 0.0 + var filterX1 = 0.0 + var filterX2 = 0.0 + var filterY1 = 0.0 + var filterY2 = 0.0 + // Snapshot of cutoff/resonance the cached coefficients correspond to. + var filterCutoffCached = -1 + var filterResonanceCached = -1 // Per-trigger random offsets from RV / RP swing (added to base vol/pan). var randomVolBias = 0 // signed