diff --git a/TAUD_NOTE_EFFECTS.md b/TAUD_NOTE_EFFECTS.md index 9e63d6e..ab2c86b 100644 --- a/TAUD_NOTE_EFFECTS.md +++ b/TAUD_NOTE_EFFECTS.md @@ -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.) @@ -1021,6 +1045,13 @@ There is no separate "use fadeout" flag — both extremes share the same field, - 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. diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index f6f0bc5..1b9fc28 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -1704,7 +1704,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 } @@ -1808,8 +1808,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 +1948,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 +2153,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]) diff --git a/assets/disk0/tvdos/bin/taut_helpmsg.js b/assets/disk0/tvdos/bin/taut_helpmsg.js index e8f58e3..211712f 100644 --- a/assets/disk0/tvdos/bin/taut_helpmsg.js +++ b/assets/disk0/tvdos/bin/taut_helpmsg.js @@ -11,15 +11,36 @@ Tags: - align left - create virtual typesetting box. Left anchor: where the text cursor is. Right anchor: end of the line µtone; - replace with the brand string (Microtone) + &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 = `CONTROL NOTATON &bul;a&ddot;z : alphabet without shift-in &bul;A&ddot;Z : alphabet with shift-in &bul;^q : hit 'q' with control key -&bul;^Q : hit 'q' with control and shift key` +&bul;^Q : hit 'q' with control and shift key +` //////////////////////////////////////////////////////////////////////////////////////////////////// @@ -37,7 +59,8 @@ let helpJam = `NOTE JAMMING 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 = `COMMON CONTROLS &bul;O : stop the playback &bul;tab : switch forward a tab &bul;TAB : switch backward a tab -&bul;q : close µtone;` +&bul;q : close µtone; +` //////////////////////////////////////////////////////////////////////////////////////////////////// @@ -79,7 +103,12 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using &bul;<&mdot;>: (panning column) slide left/right &bul;-&mdot;= : (vol/pan col) fine slide down/up &bul;&udlr; : move the viewing cursor by columns and rows -&bul;pg&updn; : go to previous/next cue` +&bul;pg&updn; : go to previous/next cue + +ACCIDENTALS +&demisharp; ♯ &doublesharp; &triplesharp; &quadsharp; &demiflat; ♭ &doubleflat; &tripleflat;  &accuptick;  &accupup;  &accdntick;  &accdndn; +C  c  x  cx xx B  b  bb bbb ^  ^^ v  vv +` //////////////////////////////////////////////////////////////////////////////////////////////////// @@ -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)) diff --git a/mon2taud.py b/mon2taud.py index d83b2cb..b024442 100644 --- a/mon2taud.py +++ b/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 ─────────────────────────────────────────────────────────── @@ -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, diff --git a/terranmon.txt b/terranmon.txt index 27c4a34..69d9a65 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2328,7 +2328,17 @@ TODO: for pan/pf envelopes to play. See byte 15/17/19 spec for the LOOP word bit layout. [x] slumberjack.xm: E6x commands are not processed - [ ] implement linear-freq tone mode (MONOTONE compat) + [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. [ ] milkytracker-style volume ramping (on sample-end only) @@ -2430,9 +2440,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. @@ -2551,9 +2561,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) diff --git a/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt b/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt index 7b740c3..41d7e11 100644 --- a/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt +++ b/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt @@ -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") } diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 1d9edbd..8c08940 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -133,6 +133,12 @@ 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 } // Memory map (terranmon.txt:1985-1997, updated 2026-05-06): @@ -1209,6 +1215,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. @@ -1679,6 +1713,7 @@ 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 @@ -1824,6 +1859,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 @@ -1997,13 +2033,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. @@ -2073,32 +2108,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(0, 0xFFFE) 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(0, 0xFFFE) 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 -> { @@ -2214,6 +2255,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.noteVal = (voice.noteVal + FINETUNE_OFFSET[x]).coerceIn(0, 0xFFFE) 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 } @@ -2299,24 +2341,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(0, 0xFFFE) 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(0, 0xFFFE) + } + 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). @@ -2904,6 +2968,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 @@ -3009,7 +3076,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 @@ -3122,7 +3194,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 } @@ -3157,7 +3229,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