mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
Compare commits
2 Commits
ebba33a5c3
...
2ffdf32c91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ffdf32c91 | ||
|
|
a28fcbcefc |
@@ -45,6 +45,15 @@ mix = sample × note_vol × channel_vol × global_vol >> normalisation_shift
|
||||
|
||||
with saturation applied before the 8-bit stereo output.
|
||||
|
||||
`note_vol` and `channel_vol` are **two independent multiplicative axes** mirroring IT's `chan->volume` and `chan->global_volume`:
|
||||
|
||||
- **`note_vol`** is the per-note axis. It is reset on every note re-trigger to the instrument's Default Note Volume (instrument-record byte 196). It is the target of the volume column (selectors 0 / 1 / 2 / 3), the D / K / L volume slides, and the Q retrigger volume modifier. It survives across rows until the next re-trigger.
|
||||
- **`channel_vol`** is the per-channel axis. It is **not** reset by note re-triggers — once set, it persists through any number of fresh notes on that channel. It is the target of M (set) and N (slide) only.
|
||||
|
||||
The engine carries a third per-tick value, `row_vol`, which is the mixer-facing volume for the current tick. At every row boundary `row_vol` rebases to `note_vol`; per-tick modulators (tremolo R, tremor I) write `row_vol` only, so their effect dies cleanly at row end. Per-note slides (D, K, L, vol-col) write **both** `note_vol` and `row_vol` so the per-note baseline carries forward.
|
||||
|
||||
Because the two axes are independent, an `M $4000` (set channel volume to full) issued after a `0.$02` (vol-col SET = 2) leaves the per-note volume untouched at 2 — the channel keeps playing quietly. Conversely, an `N` slide can fade out a channel's overall level while a vol-col SET on a fresh trigger sets the per-note baseline at full.
|
||||
|
||||
## 4. Rows, ticks, patterns, cues
|
||||
|
||||
A pattern is a rectangular grid of rows and channels; each cell holds one note event. Playback divides each row into `speed` ticks (effect A); tempo (effect T) sets the duration of one tick. At 125 BPM and speed 6, one row takes 120 ms and one tick 20 ms. Songs play patterns in a cue sequence; effects B and C navigate this sequence.
|
||||
@@ -57,6 +66,7 @@ A pattern is a rectangular grid of rows and channels; each cell holds one note e
|
||||
| Tempo byte | $64 (125 BPM; see effect T for the $19 offset) |
|
||||
| Global volume | $80 (mid-scale) |
|
||||
| Channel volume | $3F (full) |
|
||||
| Note volume | $3F (full; reseeded from instrument's Default Note Volume on every re-trigger) |
|
||||
| Pan (all channels) | $80 (centre) |
|
||||
| cue index | $0000 |
|
||||
|
||||
@@ -115,39 +125,39 @@ Opcodes are single base-36 digits (0-9, then A-Z); arguments are 16-bit hexadeci
|
||||
|
||||
## D $xy00 — Volume slide (multiple forms)
|
||||
|
||||
D's 16-bit argument encodes four mutually exclusive modes using the top nibble and the following byte. All forms operate on the channel's current volume and clip to $00..$3F after each step.
|
||||
D's 16-bit argument encodes four mutually exclusive modes using the top nibble and the following byte. **All forms operate on `note_vol`** (the per-note axis described in §3, analog of IT `chan->volume`) and clip to $00..$3F after each step. The slid value persists into following rows until the next re-trigger; `channel_vol` is **not** touched by D — for the per-channel axis, use N.
|
||||
|
||||
### D $0y00 — Volume slide down by $y per non-first tick
|
||||
|
||||
**Plain.** Each tick after tick 0, volume decreases by $y. A D $0400 at speed 8 reduces volume by $1C over the row.
|
||||
**Plain.** Each tick after tick 0, `note_vol` decreases by $y. A D $0400 at speed 8 reduces volume by $1C over the row.
|
||||
|
||||
**Compatibility.** ST3 `Dx0` (volume slide down) maps to Taud `D $0x00`. The ST3 volume cap was $40; Taud's is $3F — a very high-volume sample reaching $40 in ST3 will snap to $3F in Taud.
|
||||
|
||||
**Implementation.** On ticks > 0, subtract the low nibble of the high byte from `channel_volume`; clamp at $00. Memory is private to D and is keyed on the full original byte (so D $0000 recalls whatever form last ran).
|
||||
**Implementation.** On ticks > 0, subtract the low nibble of the high byte from `note_vol`; clamp at $00; mirror `row_vol = note_vol`. Memory is private to D and is keyed on the full original byte (so D $0000 recalls whatever form last ran).
|
||||
|
||||
### D $x000 — Volume slide up by $x per non-first tick
|
||||
|
||||
**Plain.** Each tick after tick 0, volume increases by $x. Capped at $3F.
|
||||
**Plain.** Each tick after tick 0, `note_vol` increases by $x. Capped at $3F.
|
||||
|
||||
**Compatibility.** ST3 `D0y` (volume slide up) maps to Taud `D $y000`.
|
||||
|
||||
**Implementation.** On ticks > 0, add the high nibble of the high byte to `channel_volume`; clamp at $3F.
|
||||
**Implementation.** On ticks > 0, add the high nibble of the high byte to `note_vol`; clamp at $3F; mirror `row_vol = note_vol`.
|
||||
|
||||
### D $Fy00 — Fine volume slide down by $y on tick 0
|
||||
|
||||
**Plain.** Applies a one-shot volume reduction of $y on tick 0 only. Independent of speed. A D $FF00 behaves as a fine slide up by $F (so a request for "down by F" is reinterpreted; see below).
|
||||
**Plain.** Applies a one-shot `note_vol` reduction of $y on tick 0 only. Independent of speed. A D $FF00 behaves as a fine slide up by $F (so a request for "down by F" is reinterpreted; see below).
|
||||
|
||||
**Compatibility.** ST3 `DFy` maps directly. The $FF edge case is preserved: ST3 treats `DFF` as fine slide up by $F rather than fine slide down by $F, and Taud follows suit.
|
||||
|
||||
**Implementation.** On tick 0 only, subtract the low nibble of the high byte from `channel_volume`. If the low nibble is $0, treat as fine-slide-up by $F. If the high byte is $FF, treat as fine-slide-up by $F.
|
||||
**Implementation.** On tick 0 only, subtract the low nibble of the high byte from `note_vol`; mirror `row_vol = note_vol`. If the low nibble is $0, treat as fine-slide-up by $F. If the high byte is $FF, treat as fine-slide-up by $F.
|
||||
|
||||
### D $xF00 — Fine volume slide up by $x on tick 0
|
||||
|
||||
**Plain.** One-shot volume increase of $x on tick 0 only.
|
||||
**Plain.** One-shot `note_vol` increase of $x on tick 0 only.
|
||||
|
||||
**Compatibility.** ST3 `DxF` maps directly. Volume cap is $3F, lower than ST3's $40.
|
||||
|
||||
**Implementation.** On tick 0 only, add the high nibble to `channel_volume`; clamp at $3F.
|
||||
**Implementation.** On tick 0 only, add the high nibble to `note_vol`; clamp at $3F; mirror `row_vol = note_vol`.
|
||||
|
||||
---
|
||||
|
||||
@@ -346,15 +356,17 @@ on row parse (I):
|
||||
|
||||
on every tick:
|
||||
if phase == ON:
|
||||
play at full channel volume
|
||||
play at the unmodulated row_vol (no gating)
|
||||
tick_in_phase += 1
|
||||
if tick_in_phase >= on_time: phase = OFF; tick_in_phase = 0
|
||||
else:
|
||||
force output volume to 0 (base volume preserved for later effects)
|
||||
row_vol = 0 # transient gate; note_vol / channel_vol are preserved
|
||||
tick_in_phase += 1
|
||||
if tick_in_phase >= off_time: phase = ON; tick_in_phase = 0
|
||||
```
|
||||
|
||||
The OFF-phase gate writes `row_vol` only; `note_vol` and `channel_vol` are untouched, so the per-row rebase (`row_vol = note_vol` at row start) restores the audible level cleanly when tremor stops.
|
||||
|
||||
A zero `$xx` or `$yy` input becomes 1 tick after the `+1`, never zero.
|
||||
|
||||
---
|
||||
@@ -432,11 +444,11 @@ on every tick (including tick 0):
|
||||
apply vibrato update with memory_HU.speed / memory_HU.depth (see §H)
|
||||
|
||||
on tick > 0:
|
||||
channel_volume = clamp(channel_volume + slide_per_tick, 0, $3F)
|
||||
row_volume = channel_volume
|
||||
note_vol = clamp(note_vol + slide_per_tick, 0, $3F)
|
||||
row_vol = note_vol
|
||||
```
|
||||
|
||||
K has its own memory slot (private). The slide always uses the per-tick form — `K $FF00` does **not** trigger a fine slide; the argument's `$F` nibbles are interpreted as `$F`-magnitude per-tick slides (down wins), matching ST3's K and IT's K semantics.
|
||||
The slide writes the per-note axis (same as D); `channel_vol` is untouched. K has its own memory slot (private). The slide always uses the per-tick form — `K $FF00` does **not** trigger a fine slide; the argument's `$F` nibbles are interpreted as `$F`-magnitude per-tick slides (down wins), matching ST3's K and IT's K semantics.
|
||||
|
||||
---
|
||||
|
||||
@@ -465,17 +477,17 @@ on row parse (L):
|
||||
|
||||
on tick > 0:
|
||||
apply tone-portamento step using memory_G.speed (see §G)
|
||||
channel_volume = clamp(channel_volume + slide_per_tick, 0, $3F)
|
||||
row_volume = channel_volume
|
||||
note_vol = clamp(note_vol + slide_per_tick, 0, $3F)
|
||||
row_vol = note_vol
|
||||
```
|
||||
|
||||
L has its own memory slot (private), separate from K's and from D's.
|
||||
The slide writes the per-note axis (same as D); `channel_vol` is untouched. L has its own memory slot (private), separate from K's and from D's.
|
||||
|
||||
---
|
||||
|
||||
## M $xx00 — Set channel volume to $xx
|
||||
|
||||
**Plain.** Sets the channel's persistent base volume to `$xx`, in the same 6-bit `$00..$3F` range as a note's default volume. Unlike a volume-column SET (which only writes the *row* volume on a re-triggering row), M overwrites the channel's stored base volume so the change persists across subsequent rows that don't carry an explicit vol-column SET.
|
||||
**Plain.** Sets the per-channel volume axis (`channel_vol`, see §3) to `$xx`, in the same 6-bit `$00..$3F` range as a note's default volume. M is the analog of IT's `Mxx`, which writes `chan->global_volume` — it does **not** disturb the per-note volume (`note_vol`) set by the volume column or seeded from the instrument default. A vol-col SET of $02 on a note row followed by an `M $4000` on the next row therefore plays the channel at `2/63 × $3F/63 ≈ 3%` of full, *not* at full — exactly as IT would.
|
||||
|
||||
**Compatibility.** IT `Mxx` maps directly: the source byte is taken **verbatim** with a clamp to `$3F` (IT's $40 cap snaps down by one). ST3 has no native M; OpenMPT/Schism's S3M-with-IT-extensions does, and the same verbatim-with-clamp rule applies on import. M has **no memory** — `M $0000` is a literal "set channel volume to silence", not a recall. Source-tracker shared-memory recalls (e.g., ST3's single-slot shared memory) MUST be eagerly resolved by the converter before emit.
|
||||
|
||||
@@ -485,17 +497,19 @@ L has its own memory slot (private), separate from K's and from D's.
|
||||
on row parse (M):
|
||||
new_vol = (arg >> 8) & 0xFF
|
||||
if new_vol > 0x3F: new_vol = 0x3F
|
||||
channel_volume = new_vol
|
||||
row_volume = new_vol
|
||||
channel_vol = new_vol
|
||||
# note_vol and row_vol are NOT touched. The mixer multiplies channel_vol
|
||||
# into the per-voice gain via the volume-ramp target, so the change is
|
||||
# heard from this tick onwards without nuking the per-note volume.
|
||||
```
|
||||
|
||||
The change takes effect on tick 0 of the row. There is no slide form; for that, use N. The low byte of M's argument is reserved.
|
||||
The change takes effect on tick 0 of the row (the next mixer ramp window picks it up). There is no slide form; for that, use N. The low byte of M's argument is reserved.
|
||||
|
||||
---
|
||||
|
||||
## N $xy00 — Channel volume slide
|
||||
|
||||
**Plain.** Slides the channel's persistent base volume by `$xy` per non-first tick (or once on tick 0 for fine forms). Encoding is identical to D (see §D), but the slide acts on `channel_volume` rather than the per-row note volume — so the change persists into following rows that don't reissue N. Range and clipping match D: `$00..$3F`.
|
||||
**Plain.** Slides the per-channel volume axis (`channel_vol`, see §3 and §M) by `$xy` per non-first tick (or once on tick 0 for fine forms). Encoding is identical to D (see §D), but the slide acts on `channel_vol` — independent of `note_vol`, so vol-col SET / D-slide state on the per-note axis survives across an N. The change persists into following rows that don't reissue N. Range and clipping match D: `$00..$3F`.
|
||||
|
||||
**Compatibility.** IT `Nxy` maps directly to Taud `N $xy00` (high byte = source argument byte, verbatim). ST3 has no native N. N's encoding sub-forms mirror D exactly:
|
||||
|
||||
@@ -506,7 +520,7 @@ The change takes effect on tick 0 of the row. There is no slide form; for that,
|
||||
|
||||
**Memory.** N has its own private slot, separate from D's. `N $0000` recalls the last N argument and re-applies it in its original sub-form (coarse vs fine, up vs down).
|
||||
|
||||
**Implementation.** Identical to D, with `channel_volume` substituted for the per-row volume target. After every step the result is clamped to `$00..$3F` and `row_volume` is forced to track `channel_volume` so subsequent ticks' mixing reflects the slid value:
|
||||
**Implementation.** Identical to D, with `channel_vol` substituted for `note_vol`. After every step the result is clamped to `$00..$3F`. `note_vol` and `row_vol` are **not** touched — the mixer multiplies `channel_vol` into the per-voice gain via the volume-ramp target, so the change is heard within the row without disturbing the per-note baseline:
|
||||
|
||||
```
|
||||
on row parse (N):
|
||||
@@ -514,7 +528,7 @@ on row parse (N):
|
||||
if raw == 0: raw = memory_N
|
||||
else: memory_N = raw
|
||||
decode raw exactly as D does (FF / F0 / Fy / xF / 0y / x0 → fine-up-F / coarse / fine forms)
|
||||
schedule per-tick (or apply once) on channel_volume; row_volume = channel_volume after each step
|
||||
schedule per-tick (or apply once) on channel_vol — never touch note_vol / row_vol
|
||||
```
|
||||
|
||||
---
|
||||
@@ -576,7 +590,7 @@ The mixer reads `channel_pan` (8-bit) directly through the same path as `S $80xx
|
||||
|
||||
ProTracker `E9x` is equivalent to Taud `Q $0x00` (retrigger only, no volume change).
|
||||
|
||||
**Implementation.** A per-channel tick counter advances every tick, including tick 0. When it reaches `$y`, the sample retriggers (keeping current pitch), the counter resets to 0, and the volume modifier `$x` applies. The counter resets only when a row has **no** Q command; successive Q rows share and advance the counter.
|
||||
**Implementation.** A per-channel tick counter advances every tick, including tick 0. When it reaches `$y`, the sample retriggers (keeping current pitch), the counter resets to 0, and the volume modifier `$x` applies to `note_vol` (the per-note axis — IT's `chan->volume`). `channel_vol` is untouched. The counter resets only when a row has **no** Q command; successive Q rows share and advance the counter.
|
||||
|
||||
The volume modifier table, **computed with arithmetic (no LUT)**, is:
|
||||
|
||||
@@ -613,10 +627,12 @@ on row parse (R):
|
||||
on every tick (including tick 0):
|
||||
sine = ModSinusTable[(lfo_pos >> 2) & $3F]
|
||||
vol_delta = (sine × memory_R.depth) >> 9
|
||||
applied_vol = clamp(base_vol + vol_delta, 0, $3F)
|
||||
row_vol = clamp(note_vol + vol_delta, 0, $3F) # modulate around the per-note axis
|
||||
lfo_pos = (lfo_pos + memory_R.speed × 4) & $FF
|
||||
```
|
||||
|
||||
The LFO bias is added to `note_vol` (per-note axis, mirroring IT's tremolo on `chan->volume`) and the result lands in `row_vol`, never written back into `note_vol` itself — so the row-end rebase reseats `row_vol` cleanly and tremolo dies on the next row without leaving residue. `channel_vol` is unaffected.
|
||||
|
||||
Peak at maximum settings: $7F × $FF >> 9 = $3F — the full volume range. Retrigger behaviour tracks the S $4x waveform nibble bit 2: cleared means retrigger on new note, set means preserve LFO position.
|
||||
|
||||
---
|
||||
@@ -1086,16 +1102,16 @@ on sample byte read during loop playback:
|
||||
|
||||
# Volume column effects
|
||||
|
||||
Each cell carries a 6-bit value field plus a 2-bit selector field for the volume column. The four selectors are:
|
||||
Each cell carries a 6-bit value field plus a 2-bit selector field for the volume column. **All four selectors target `note_vol`** — the per-note volume axis (§3, analog of IT's `chan->volume`). The per-channel axis (`channel_vol`) is reachable only via the M / N effects in the main effect column. The four selectors are:
|
||||
|
||||
- **`0.$xx` — Set volume** to `$xx` (6-bit, $00..$3F). Equivalent to a note's default volume.
|
||||
- **`1.$xx` — Volume slide up** by `$xx` per non-first tick (4-bit). Volume clamps at $3F.
|
||||
- **`2.$xx` — Volume slide down** by `$xx` per non-first tick (4-bit). Volume clamps at $00.
|
||||
- **`3.$Sx` — Fine volume slide** on tick 0 only. The high bit `$S` of the value selects direction (0 = down, 1 = up); the low 4 bits `$x` ($0..$F) are the magnitude. Equivalent in scale to `D $xF00` / `D $Fy00` but with a 5-bit cap. Fires once per row regardless of speed.
|
||||
- **`0.$xx` — Set note_vol** to `$xx` (6-bit, $00..$3F). Equivalent in effect to seeding the note with a different default volume; persists across rows until the next re-trigger.
|
||||
- **`1.$xx` — note_vol slide up** by `$xx` per non-first tick (4-bit). Clamps at $3F. The slid value persists into following rows.
|
||||
- **`2.$xx` — note_vol slide down** by `$xx` per non-first tick (4-bit). Clamps at $00. The slid value persists into following rows.
|
||||
- **`3.$Sx` — Fine note_vol slide** on tick 0 only. The high bit `$S` of the value selects direction (0 = down, 1 = up); the low 4 bits `$x` ($0..$F) are the magnitude. Equivalent in scale to `D $xF00` / `D $Fy00` but with a 5-bit cap. Fires once per row regardless of speed.
|
||||
|
||||
Volume-column effects do not consume the main effect slot; a cell can carry both (for instance, a tone portamento in the effect slot and a volume slide in the volume column).
|
||||
Volume-column effects do not consume the main effect slot; a cell can carry both (for instance, a tone portamento in the effect slot and a volume slide in the volume column). Because the volume column writes the per-note axis, an `M $xx00` on the same or following row sets the per-channel axis independently — the two multiply at the mixer (see §3 / §M).
|
||||
|
||||
When the converter folds an ST3 K, L, M, or N effect into the volume column, the slide-up / slide-down nibbles map to selectors 1 / 2 (clamped to 6 bits — values above $3F clip).
|
||||
When the converter folds an ST3 K, L, M, or N effect into the volume column, the slide-up / slide-down nibbles map to selectors 1 / 2 (clamped to 6 bits — values above $3F clip). Note that *converted* M and N still target `note_vol` here (vol-col semantics) — to preserve the original per-channel intent, emit them in the main effect column instead.
|
||||
|
||||
NOTE: **`3.00` — is No-op**
|
||||
|
||||
|
||||
@@ -1800,7 +1800,8 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
]
|
||||
|
||||
let lastNote = 0xFFFF, lastInst = 0
|
||||
let volAbs = 0x3F // 6-bit channel volume
|
||||
let volAbs = 0x3F // 6-bit per-note volume (engine: noteVolume axis;
|
||||
// M / N's per-channel axis is not modelled here)
|
||||
let panAbs = 0x80 // 8-bit channel pan (engine width); centre = $80
|
||||
let pitchOff = 0, portaTarget = -1
|
||||
let bpm = audio.getBPM(PLAYHEAD) // best-effort starting tempo
|
||||
@@ -1840,15 +1841,16 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
// Note column
|
||||
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:
|
||||
// Track whether this row reloads the per-note default volume. Engine:
|
||||
// triggerNote() (and the tone-porta-with-inst branch in advanceRow)
|
||||
// seed channelVolume from the instrument's Default Note Volume (byte
|
||||
// 196) — 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 (matches schism
|
||||
// csf_instrument_change inst_column branch, effects.c:1302).
|
||||
// The simulator approximates the seed as 0x3F (legacy fallback) — see
|
||||
// the longer note below the reload block for the limitation.
|
||||
// seed noteVolume from the instrument's Default Note Volume (byte 196)
|
||||
// — only when the row carries an instrument byte; a note-only retrigger
|
||||
// (inst === 0) inherits the channel's existing note volume. Tone-porta
|
||||
// rows follow the same rule (matches schism csf_instrument_change
|
||||
// inst_column branch, effects.c:1302). The per-channel axis
|
||||
// (channelVolume, set by Mxx / Nxx) is NOT reset on re-trigger and is
|
||||
// not tracked by this simulator. The simulator approximates the seed
|
||||
// as 0x3F (legacy fallback) — see the longer note below.
|
||||
let reloadDefaultVol = false
|
||||
if (note !== 0xFFFF && note !== 0xFFFE) {
|
||||
if (note === 0x0000) {
|
||||
@@ -1877,10 +1879,10 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
// 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. Same limitation now applies to default volume: the engine
|
||||
// seeds rowVolume from the instrument's byte-196 "Default Note Volume" since
|
||||
// seeds noteVolume from the instrument's byte-196 "Default Note Volume" since
|
||||
// 2026-05-09 (terranmon §171, §196), but the simulator has no instrument-byte
|
||||
// access, so it falls back to 0x3F — equivalent to the legacy "DNV unset"
|
||||
// path. Tracker UI displays may therefore show a slightly off row volume on
|
||||
// path. Tracker UI displays may therefore show a slightly off note volume on
|
||||
// fresh triggers when the instrument carries a reduced DNV.
|
||||
if (reloadDefaultVol) volAbs = 0x3F
|
||||
|
||||
|
||||
@@ -1360,9 +1360,10 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
||||
dct = idata.get('dct', 0) & 0x03
|
||||
dca = idata.get('dca', 0) & 0x03
|
||||
inst_bin[base + 195] = (dca << 2) | dct
|
||||
# Byte 196: default note volume (per-trigger seed for rowVolume when
|
||||
# no V column accompanies a fresh trigger). Replaces the old "fold
|
||||
# sample.vol into IGV" trick — see terranmon byte 196 / TODO §2350.
|
||||
# Byte 196: default note volume (per-trigger seed for the engine's
|
||||
# noteVolume axis when no V column accompanies a fresh trigger).
|
||||
# Replaces the old "fold sample.vol into IGV" trick — see terranmon
|
||||
# byte 196 / TODO §2350.
|
||||
inst_bin[base + 196] = default_note_vol & 0xFF
|
||||
# Bytes 197..255: reserved (already zeroed).
|
||||
|
||||
@@ -1450,7 +1451,7 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
|
||||
# Priority: explicit cell vol (vol-col 0-64) > vol-col slide > main-
|
||||
# effect vol override > nop. Per-trigger default volume now lives
|
||||
# in byte 196 of the instrument record (DNV); the engine seeds
|
||||
# rowVolume from it when this row has no V column, so the converter
|
||||
# noteVolume from it when this row has no V column, so the converter
|
||||
# still doesn't need to emit SEL_SET=Sv on plain trigger rows.
|
||||
if cell.volcol >= 0 and cell.volcol <= VC_VOL_HI:
|
||||
vol_sel, vol_value = SEL_SET, min(cell.volcol, 0x3F)
|
||||
|
||||
@@ -220,8 +220,8 @@ def build_sample_inst_bin() -> bytes:
|
||||
inst_bin[base + 183] = 0xFF # filter resonance off
|
||||
inst_bin[base + 186] = 0x01 # NNA: cut
|
||||
# Monotone has no per-sample default volume concept (only one synth
|
||||
# voice, no V column overrides). Set DNV to full so triggers get the
|
||||
# full 0x3F rowVolume; the IGV above provides the actual attenuation.
|
||||
# voice, no V column overrides). Set DNV to full so triggers seed
|
||||
# noteVolume at 0x3F; the IGV above provides the actual attenuation.
|
||||
inst_bin[base + 196] = 0xFF # DNV: full
|
||||
|
||||
return bytes(sample_bin) + bytes(inst_bin)
|
||||
|
||||
@@ -2144,8 +2144,9 @@ from source.
|
||||
* Continuous multiplier applied on every output sample (matches IT's
|
||||
`chan->instrument_volume`, see Schism player/csndfile.c:1317 and
|
||||
player/sndmix.c:1171). Independent of the volume column / Mxx /
|
||||
Nxx — those operate on rowVolume/channelVolume, while IGV scales
|
||||
the final mix unconditionally.
|
||||
Nxx — the volume column writes the per-note axis (noteVolume),
|
||||
Mxx/Nxx write the per-channel axis (channelVolume); IGV scales
|
||||
the final mix unconditionally and is orthogonal to both.
|
||||
* ImpulseTracker has separate `inst.gv` (0..128) and samplewise
|
||||
`sample.gv` (0..64). Since Taud has no samplewise record, fold
|
||||
the two factors into a single 0..255 value:
|
||||
@@ -2282,15 +2283,17 @@ from source.
|
||||
spawns inherits that DCA-modified state (e.g. noteFading carries over).
|
||||
- The new note then triggers normally on the foreground channel.
|
||||
196 Uint8 Default Note Volume (0..255)
|
||||
* Per-trigger default for `channelVolume` / `rowVolume` when the row
|
||||
carries a fresh note + instrument byte but no explicit volume column
|
||||
(matches IT's `chan->volume = psmp->volume` on note-on, Schism
|
||||
* Per-trigger default for the per-note volume axis (`noteVolume` in
|
||||
the engine, analog of IT's `chan->volume`) when the row carries a
|
||||
fresh note + instrument byte but no explicit volume column (matches
|
||||
IT's `chan->volume = psmp->volume` on note-on, Schism
|
||||
player/effects.c:1302 and :1432). The 8-bit value rescales to
|
||||
Taud's 0..63 row volume range:
|
||||
row_default = round(default_note_volume * 63 / 255)
|
||||
Any explicit V column on the trigger row OVERRIDES this — i.e.
|
||||
rowVolume = vol_value, exactly mirroring IT's "V column replaces
|
||||
chan->volume" rule.
|
||||
Taud's 0..63 note-volume range:
|
||||
note_default = round(default_note_volume * 63 / 255)
|
||||
Any explicit V column SET on the trigger row OVERRIDES this — i.e.
|
||||
noteVolume = vol_value, exactly mirroring IT's "V column replaces
|
||||
chan->volume" rule. The per-channel axis (`channelVolume`, set by
|
||||
Mxx / Nxx) is independent and is NOT reset on re-trigger.
|
||||
* Source-format mapping:
|
||||
- IT: taud_dnv = round(sample.vol * 255 / 64) # 0..64 → 0..255
|
||||
- XM: taud_dnv = round(sample.volume * 255 / 64) # 0..64 → 0..255
|
||||
@@ -2472,6 +2475,11 @@ Audio Adapter MMIO
|
||||
2368..4095 RW: MP2 Frame to be decoded
|
||||
4096..4097 RO: MP2 Frame guard bytes; always return 0 on read
|
||||
|
||||
4098..4353 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #0
|
||||
4354..4609 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #1
|
||||
4610..4865 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #2
|
||||
4866..5121 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #3
|
||||
|
||||
Sound Hardware Info
|
||||
- Sampling rate: 32000 Hz
|
||||
- Bit depth: 8 bits/sample, unsigned
|
||||
|
||||
@@ -91,11 +91,22 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
|
||||
fun getTrackerRow(playhead: Int) = getPlayhead(playhead)?.trackerState?.rowIndex ?: 0
|
||||
|
||||
/** Mute is now a thin wrapper over the per-voice fader: muting writes 255 (silence),
|
||||
* unmuting clears the fader back to 0 (unity). Callers that want a partial attenuation
|
||||
* should use setVoiceFader directly. */
|
||||
fun setVoiceMute(playhead: Int, voice: Int, muted: Boolean) {
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.muted = muted
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.fader = if (muted) 255 else 0
|
||||
}
|
||||
fun getVoiceMute(playhead: Int, voice: Int): Boolean =
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.muted ?: false
|
||||
(getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.fader ?: 0) == 255
|
||||
|
||||
/** Externally-controlled per-voice fader. 0 = unity, 255 = silence; values are masked to 8 bits.
|
||||
* Mirrors MMIO 4098.. (256 bytes per playhead, first 20 entries map to live voice slots). */
|
||||
fun setVoiceFader(playhead: Int, voice: Int, fader: Int) {
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.fader = fader and 255
|
||||
}
|
||||
fun getVoiceFader(playhead: Int, voice: Int): Int =
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.fader ?: 0
|
||||
|
||||
/** Set the starting row for the next play call, resetting per-row timing and silencing active voices. */
|
||||
fun setTrackerRow(playhead: Int, row: Int) {
|
||||
|
||||
@@ -151,11 +151,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
const val RAMP_OUT_SAMPLES = 256
|
||||
// Volume-change anti-click ramp: voleff/notefx (volume column, D vol-slides,
|
||||
// tremor, tremolo, retrig vol-mod, fine slides etc.) mutate Voice.rowVolume
|
||||
// mid-note. The mixer ramps the actual applied gain across [VOL_RAMP_SAMPLES]
|
||||
// output samples to mask the discontinuity. ~2 ms at 32 kHz — short enough
|
||||
// not to smear tremolo at fast speeds, long enough to bury per-tick slide
|
||||
// steps. Bypassed on fresh note triggers (triggerNote snaps currentMixVolume
|
||||
// to target) so attack transients pass through untouched.
|
||||
// and M / N mutate Voice.channelVolume mid-note. The mixer ramps the actual
|
||||
// applied gain (combining both axes) across [VOL_RAMP_SAMPLES] output samples
|
||||
// to mask the discontinuity. ~2 ms at 32 kHz — short enough not to smear
|
||||
// tremolo at fast speeds, long enough to bury per-tick slide steps. Bypassed
|
||||
// on fresh note triggers (triggerNote snaps currentMixVolume to target) so
|
||||
// attack transients pass through untouched.
|
||||
const val VOL_RAMP_SAMPLES = 64
|
||||
|
||||
// Sample bin: 8 MB total, banked through a 512 K window at peripheral
|
||||
@@ -454,6 +455,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
in 64..2367 -> mediaDecodedBin[addr - 64]
|
||||
in 2368..4095 -> mediaFrameBin[addr - 2368]
|
||||
in 4096..4097 -> 0
|
||||
// Per-voice fader (0 = unity, 255 = silence): 256 bytes per playhead, only the first
|
||||
// 20 entries map to live voice slots; the rest read 0.
|
||||
in 4098..5121 -> {
|
||||
val off = adi - 4098
|
||||
val ph = off ushr 8 // playhead index 0..3
|
||||
val v = off and 0xFF // voice index 0..255
|
||||
if (v < 20) (playheads[ph].trackerState?.voices?.getOrNull(v)?.fader ?: 0).toByte()
|
||||
else 0.toByte()
|
||||
}
|
||||
in 32768..65535 -> (adi - 32768).let {
|
||||
cueSheet[it / 32].read(it % 32)
|
||||
}
|
||||
@@ -487,6 +497,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
45 -> selectedPcmBin = bi % 4
|
||||
46 -> sampleBank = bi and SAMPLE_BANK_MASK
|
||||
// Per-voice fader writes: see mmio_read for layout. Indices 20..255 are accepted
|
||||
// but ignored so software can stride 256 bytes per playhead without bounds-checking.
|
||||
in 4098..5121 -> {
|
||||
val off = adi - 4098
|
||||
val ph = off ushr 8
|
||||
val v = off and 0xFF
|
||||
if (v < 20) {
|
||||
playheads[ph].trackerState?.voices?.getOrNull(v)?.fader = bi
|
||||
}
|
||||
}
|
||||
in 64..2367 -> { mediaDecodedBin[addr - 64] = byte }
|
||||
in 2368..4095 -> { mediaFrameBin[addr - 2368] = byte }
|
||||
in 32768..65535 -> { (adi - 32768).let {
|
||||
@@ -1812,15 +1832,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
/**
|
||||
* Per-sample volume-ramp tick. Smooths [Voice.currentMixVolume] toward
|
||||
* `rowVolume / 63.0` over [VOL_RAMP_SAMPLES] samples whenever the mixer
|
||||
* detects a discrepancy. Discrepancies arise from voleff/notefx that
|
||||
* mutate rowVolume mid-note (volume column SET / fine slides, D
|
||||
* vol-slide tick, vol-column slide tick, tremor gating, tremolo,
|
||||
* retrig vol-mod, S$80 cuts, etc.). Fresh triggers bypass this by
|
||||
* snapping currentMixVolume in [triggerNote], so attacks are unsmoothed.
|
||||
* `(rowVolume / 63.0) × (channelVolume / 63.0)` over [VOL_RAMP_SAMPLES]
|
||||
* samples whenever the mixer detects a discrepancy. Discrepancies arise
|
||||
* from voleff/notefx that mutate rowVolume mid-note (volume column SET /
|
||||
* fine slides, D vol-slide tick, vol-column slide tick, tremor gating,
|
||||
* tremolo, retrig vol-mod, S$80 cuts, etc.) AND from channel-volume
|
||||
* changes (M / N) — both factors share one ramp so a per-channel slide
|
||||
* during a per-note slide doesn't double-step. Fresh triggers bypass
|
||||
* this by snapping currentMixVolume in [triggerNote], so attacks are
|
||||
* unsmoothed.
|
||||
*/
|
||||
private fun advanceVolumeRamp(voice: Voice) {
|
||||
val target = voice.rowVolume / 63.0
|
||||
val target = (voice.rowVolume / 63.0) * (voice.channelVolume / 63.0)
|
||||
// Deferred key-on snap: triggerNote arms this so the first mixer sample after a
|
||||
// fresh trigger re-syncs to the post-row rowVolume (already adjusted by any
|
||||
// V-column SET / fine slide on the same row). Bypasses the ramp entirely.
|
||||
@@ -1847,13 +1870,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
* Pulled out so S$Dx (note delay) can defer the same logic to a later tick.
|
||||
*/
|
||||
/**
|
||||
* Trigger-time default rowVolume seed derived from the instrument's
|
||||
* Trigger-time default noteVolume seed derived from the instrument's
|
||||
* Default Note Volume (byte 196). Pre-2026-05-09 .taud files left this
|
||||
* byte zero; treating 0 as "field not present" and falling back to 0x3F
|
||||
* keeps legacy behaviour. Used by both [triggerNote] and the tone-porta
|
||||
* + instrument-byte path in [advanceRow] — both must seed identically
|
||||
* (Schism player/effects.c:1302 writes `chan->volume = psmp->volume`
|
||||
* unconditionally on inst-column rows, regardless of porta).
|
||||
* unconditionally on inst-column rows, regardless of porta). Sets
|
||||
* noteVolume only — channelVolume (IT chan->global_volume) survives.
|
||||
*/
|
||||
private fun rowVolumeFromDefault(inst: TaudInst): Int {
|
||||
val dnv = inst.defaultNoteVolume
|
||||
@@ -1941,21 +1965,23 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
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 seeds rowVolume from the per-instrument "default note volume"
|
||||
// Fresh trigger seeds noteVolume from the per-instrument "default note volume"
|
||||
// (byte 196) when the row carried an instrument byte but no explicit V column —
|
||||
// matching IT's `chan->volume = psmp->volume` rule (Schism player/effects.c:1302
|
||||
// and :1432). Pre-2026-05-09 .taud files left byte 196 zero and folded sample.vol
|
||||
// into IGV instead; treating 0 as "field not present" and falling back to 0x3F
|
||||
// preserves legacy behaviour. A note-only retrigger (instId == 0) inherits the
|
||||
// channel's existing volume so held-volume sustains keep working across retriggers.
|
||||
// channel's existing note volume so held-volume sustains keep working across
|
||||
// retriggers. channelVolume is deliberately NOT reset here — IT keeps
|
||||
// chan->global_volume across sample changes, so M / N writes persist.
|
||||
// Continuous per-instrument scaling lives in instGlobalVolume (byte 171), which the
|
||||
// mixer applies independently of this seed.
|
||||
voice.channelVolume = when {
|
||||
voice.noteVolume = when {
|
||||
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
|
||||
instId != 0 -> rowVolumeFromDefault(inst)
|
||||
else -> voice.channelVolume
|
||||
else -> voice.noteVolume
|
||||
}
|
||||
voice.rowVolume = voice.channelVolume
|
||||
voice.rowVolume = voice.noteVolume
|
||||
// Defer the anti-click ramp snap to the next mixer sample. applyVolColumn and
|
||||
// applyEffectRow run *after* triggerNote in applyTrackerRow and frequently
|
||||
// override rowVolume on the same row (e.g., a key-on row carrying a V column
|
||||
@@ -2065,11 +2091,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
private fun ghostVoice(src: Voice, channel: Int): Voice {
|
||||
val v = Voice()
|
||||
v.active = true
|
||||
v.muted = src.muted
|
||||
v.fader = src.fader
|
||||
v.instrumentId = src.instrumentId
|
||||
v.samplePos = src.samplePos
|
||||
v.playbackRate = src.playbackRate
|
||||
v.forward = src.forward
|
||||
v.noteVolume = src.noteVolume
|
||||
v.channelVolume = src.channelVolume
|
||||
v.rowVolume = src.rowVolume
|
||||
v.channelPan = src.channelPan
|
||||
@@ -2147,17 +2174,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
private fun applyVolColumn(voice: Voice, value: Int, sel: Int) {
|
||||
// value is the 6-bit cell field; sel is the 2-bit selector. See TAUD_NOTE_EFFECTS.md
|
||||
// §"Volume column effects" for the multi-selector encoding.
|
||||
// SET / fine writes to noteVolume (per-note, persistent across rows until re-trigger);
|
||||
// rowVolume is mirrored so the change is audible immediately for this row's mixing.
|
||||
// channelVolume is left alone — vol-col is the per-note volume axis, M / N drive the
|
||||
// independent per-channel axis (TAUD_NOTE_EFFECTS.md §3).
|
||||
when (sel) {
|
||||
0 -> { voice.channelVolume = value.coerceIn(0, 0x3F); voice.rowVolume = voice.channelVolume }
|
||||
0 -> { voice.noteVolume = value.coerceIn(0, 0x3F); voice.rowVolume = voice.noteVolume }
|
||||
1 -> voice.volColSlideUp = value
|
||||
2 -> voice.volColSlideDown = value
|
||||
3 -> {
|
||||
if (value == 0) return
|
||||
|
||||
val mag = value and 0x1F
|
||||
voice.rowVolume = if ((value and 0x20) != 0) (voice.rowVolume + mag).coerceAtMost(0x3F)
|
||||
else (voice.rowVolume - mag).coerceAtLeast(0)
|
||||
voice.channelVolume = voice.rowVolume
|
||||
voice.noteVolume = if ((value and 0x20) != 0) (voice.noteVolume + mag).coerceAtMost(0x3F)
|
||||
else (voice.noteVolume - mag).coerceAtLeast(0)
|
||||
voice.rowVolume = voice.noteVolume
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2211,8 +2242,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
voice.wSlideDir = 0
|
||||
voice.volColSlideUp = 0; voice.volColSlideDown = 0
|
||||
voice.panColSlideRight = 0; voice.panColSlideLeft = 0
|
||||
voice.nSlideDir = 0
|
||||
voice.rowEffect = row.effect
|
||||
voice.rowEffectArg = row.effectArg
|
||||
// Per-tick modulators (tremolo R, tremor I, per-tick D/N coarse slides, etc.) write
|
||||
// rowVolume directly to take effect within the row. At every row boundary rowVolume
|
||||
// is rebased to the persistent noteVolume so the next row starts from the per-note
|
||||
// baseline — any tremolo dip / tremor gate from the previous tick is forgotten, but
|
||||
// a D-slide's per-tick mutations of noteVolume itself survive (D writes both).
|
||||
voice.rowVolume = voice.noteVolume
|
||||
|
||||
// ── Note ──
|
||||
// OP_L (combined porta + vol slide) also takes a tone-porta target without retriggering,
|
||||
@@ -2234,7 +2272,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
if (row.instrment != 0) {
|
||||
voice.instrumentId = row.instrment
|
||||
val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId])
|
||||
voice.channelVolume = seedVol
|
||||
voice.noteVolume = seedVol
|
||||
voice.rowVolume = seedVol
|
||||
voice.keyOff = false
|
||||
voice.noteFading = false
|
||||
@@ -2269,7 +2307,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
if (row.instrment != 0) {
|
||||
voice.instrumentId = row.instrment
|
||||
val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId])
|
||||
voice.channelVolume = seedVol
|
||||
voice.noteVolume = seedVol
|
||||
voice.rowVolume = seedVol
|
||||
voice.keyOff = false
|
||||
voice.noteFading = false
|
||||
@@ -2370,14 +2408,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
if (ts.pendingRowJump < 0) ts.pendingRowJump = rawArg.coerceIn(0, 63)
|
||||
}
|
||||
EffectOp.OP_D -> {
|
||||
// D is the per-note volume slide (analog of IT D). Fine forms write noteVolume
|
||||
// immediately; coarse forms arm slideMode for the per-tick handler below, which
|
||||
// walks noteVolume too so the per-note volume persists into following rows.
|
||||
val arg = resolveArg(rawArg, voice.mem.d).also { if (rawArg != 0) voice.mem.d = it }
|
||||
val hi = (arg ushr 8) and 0xFF
|
||||
val lo = hi and 0x0F
|
||||
val hin = (hi ushr 4) and 0x0F
|
||||
when {
|
||||
hi == 0xFF || hi == 0xF0 -> { voice.rowVolume = (voice.rowVolume + 0xF).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } // $FF00 / $F000 quirk: fine up by F (TAUD_NOTE_EFFECTS.md §D)
|
||||
hin == 0xF && lo != 0 -> { voice.rowVolume = (voice.rowVolume - lo).coerceAtLeast(0); voice.channelVolume = voice.rowVolume } // $Fy00 fine down by y
|
||||
lo == 0xF && hin != 0 -> { voice.rowVolume = (voice.rowVolume + hin).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } // $xF00 fine up by x
|
||||
hi == 0xFF || hi == 0xF0 -> { voice.noteVolume = (voice.noteVolume + 0xF).coerceAtMost(0x3F); voice.rowVolume = voice.noteVolume } // $FF00 / $F000 quirk: fine up by F (TAUD_NOTE_EFFECTS.md §D)
|
||||
hin == 0xF && lo != 0 -> { voice.noteVolume = (voice.noteVolume - lo).coerceAtLeast(0); voice.rowVolume = voice.noteVolume } // $Fy00 fine down by y
|
||||
lo == 0xF && hin != 0 -> { voice.noteVolume = (voice.noteVolume + hin).coerceAtMost(0x3F); voice.rowVolume = voice.noteVolume } // $xF00 fine up by x
|
||||
hin == 0 && lo != 0 -> { voice.slideMode = 5; voice.slideArg = -lo } // $0y00 coarse down per non-first tick
|
||||
lo == 0 && hin != 0 -> { voice.slideMode = 5; voice.slideArg = hin } // $x000 coarse up per non-first tick
|
||||
}
|
||||
@@ -2478,24 +2519,27 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
EffectOp.OP_M -> {
|
||||
// M $xx00 — set channel volume to the high byte (literal, no recall). IT $40 is
|
||||
// clamped to Taud's $3F cap. See TAUD_NOTE_EFFECTS.md §M.
|
||||
val newVol = ((rawArg ushr 8) and 0xFF).coerceAtMost(0x3F)
|
||||
voice.channelVolume = newVol
|
||||
voice.rowVolume = newVol
|
||||
// clamped to Taud's $3F cap. M writes the per-channel volume axis only and does
|
||||
// NOT touch noteVolume / rowVolume — the per-note volume set by vol-col SET (or
|
||||
// seeded from the instrument default on the trigger row) survives across this M.
|
||||
// The mixer multiplies channelVolume into the gain via the volume-ramp target,
|
||||
// so the change is heard immediately on this row. See TAUD_NOTE_EFFECTS.md §M.
|
||||
voice.channelVolume = ((rawArg ushr 8) and 0xFF).coerceAtMost(0x3F)
|
||||
}
|
||||
EffectOp.OP_N -> {
|
||||
// N $xy00 — channel-volume slide. Same nibble decoding as D but writes the
|
||||
// persistent channelVolume so the change carries past this row.
|
||||
// N $xy00 — channel-volume slide. Same nibble decoding as D but writes only the
|
||||
// persistent channelVolume; noteVolume / rowVolume are untouched so per-note
|
||||
// volume state (vol-col SET, D slides) survives an N.
|
||||
val arg = resolveArg(rawArg, voice.mem.n).also { if (rawArg != 0) voice.mem.n = it }
|
||||
val hi = (arg ushr 8) and 0xFF
|
||||
val lo = hi and 0x0F
|
||||
val hin = (hi ushr 4) and 0x0F
|
||||
when {
|
||||
hi == 0xFF || hi == 0xF0 -> { voice.channelVolume = (voice.channelVolume + 0xF).coerceAtMost(0x3F); voice.rowVolume = voice.channelVolume }
|
||||
hin == 0xF && lo != 0 -> { voice.channelVolume = (voice.channelVolume - lo).coerceAtLeast(0); voice.rowVolume = voice.channelVolume }
|
||||
lo == 0xF && hin != 0 -> { voice.channelVolume = (voice.channelVolume + hin).coerceAtMost(0x3F); voice.rowVolume = voice.channelVolume }
|
||||
hin == 0 && lo != 0 -> { voice.volColSlideDown = lo } // coarse down per non-first tick
|
||||
lo == 0 && hin != 0 -> { voice.volColSlideUp = hin } // coarse up per non-first tick
|
||||
hi == 0xFF || hi == 0xF0 -> voice.channelVolume = (voice.channelVolume + 0xF).coerceAtMost(0x3F)
|
||||
hin == 0xF && lo != 0 -> voice.channelVolume = (voice.channelVolume - lo).coerceAtLeast(0)
|
||||
lo == 0xF && hin != 0 -> voice.channelVolume = (voice.channelVolume + hin).coerceAtMost(0x3F)
|
||||
hin == 0 && lo != 0 -> voice.nSlideDir = -lo // coarse down per non-first tick
|
||||
lo == 0 && hin != 0 -> voice.nSlideDir = hin // coarse up per non-first tick
|
||||
}
|
||||
}
|
||||
EffectOp.OP_P -> {
|
||||
@@ -2689,9 +2733,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
if (!voice.active && voice.noteDelayTick < 0) continue
|
||||
val inst = instruments[voice.instrumentId]
|
||||
|
||||
// Note cut.
|
||||
// Note cut. Zero noteVolume / rowVolume (silence this note) but leave channelVolume
|
||||
// alone — IT's note cut stops the sample, it doesn't reset chan->global_volume.
|
||||
if (voice.cutAtTick == ts.tickInRow) {
|
||||
voice.rowVolume = 0; voice.channelVolume = 0
|
||||
voice.noteVolume = 0; voice.rowVolume = 0
|
||||
voice.noteWasCut = true
|
||||
}
|
||||
|
||||
@@ -2754,19 +2799,24 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
}
|
||||
|
||||
// Volume slides (D coarse on tick > 0).
|
||||
// Volume slides (D coarse on tick > 0). D walks the per-note volume; rowVolume
|
||||
// tracks it so the change is audible this tick and rebases on next row entry.
|
||||
if (ts.tickInRow > 0 && voice.slideMode == 5) {
|
||||
voice.rowVolume = (voice.rowVolume + voice.slideArg).coerceIn(0, 0x3F)
|
||||
voice.channelVolume = voice.rowVolume
|
||||
voice.noteVolume = (voice.noteVolume + voice.slideArg).coerceIn(0, 0x3F)
|
||||
voice.rowVolume = voice.noteVolume
|
||||
}
|
||||
|
||||
// Volume-column slides (selectors 1/2 — per non-first tick).
|
||||
// Volume-column slides (selectors 1/2 — per non-first tick) and N coarse slide.
|
||||
// Vol-col writes noteVolume; N writes channelVolume — they target independent axes.
|
||||
if (ts.tickInRow > 0) {
|
||||
if (voice.volColSlideUp != 0) {
|
||||
voice.rowVolume = (voice.rowVolume + voice.volColSlideUp).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume
|
||||
voice.noteVolume = (voice.noteVolume + voice.volColSlideUp).coerceAtMost(0x3F); voice.rowVolume = voice.noteVolume
|
||||
}
|
||||
if (voice.volColSlideDown != 0) {
|
||||
voice.rowVolume = (voice.rowVolume - voice.volColSlideDown).coerceAtLeast(0); voice.channelVolume = voice.rowVolume
|
||||
voice.noteVolume = (voice.noteVolume - voice.volColSlideDown).coerceAtLeast(0); voice.rowVolume = voice.noteVolume
|
||||
}
|
||||
if (voice.nSlideDir != 0) {
|
||||
voice.channelVolume = (voice.channelVolume + voice.nSlideDir).coerceIn(0, 0x3F)
|
||||
}
|
||||
if (voice.panColSlideRight != 0) {
|
||||
voice.channelPan = (voice.channelPan + voice.panColSlideRight).coerceAtMost(0xFF)
|
||||
@@ -2801,11 +2851,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
pitchToMixer = (semis * 4096 / 12).coerceIn(1, 0xFFFD)
|
||||
}
|
||||
|
||||
// Tremolo (R) — modulates output volume around base.
|
||||
// Tremolo (R) — modulates rowVolume around the per-note volume base. IT's tremolo
|
||||
// operates on chan->volume (per-note), not chan->global_volume, so the LFO bias is
|
||||
// added to noteVolume rather than channelVolume. The result lands in rowVolume only,
|
||||
// so noteVolume itself is unaffected and tremolo dies cleanly when the row ends
|
||||
// (per-row rowVolume rebase) — which is what existing IT modules expect.
|
||||
if (voice.tremoloActive) {
|
||||
val sine = lfoSample(voice.tremoloLfoPos, voice.tremoloWave)
|
||||
val volDelta = (sine * voice.mem.rDepth) shr 9
|
||||
voice.rowVolume = (voice.channelVolume + volDelta).coerceIn(0, 0x3F)
|
||||
voice.rowVolume = (voice.noteVolume + volDelta).coerceIn(0, 0x3F)
|
||||
voice.tremoloLfoPos = (voice.tremoloLfoPos + voice.mem.rSpeed * 4) and 0xFF
|
||||
}
|
||||
|
||||
@@ -2842,8 +2896,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
voice.autoVibPhase = 0
|
||||
voice.autoVibTicksSinceTrigger = 0
|
||||
voice.filterY1 = 0.0; voice.filterY2 = 0.0
|
||||
voice.rowVolume = applyRetrigVolMod(voice.rowVolume, voice.retrigVolMod)
|
||||
voice.channelVolume = voice.rowVolume
|
||||
voice.noteVolume = applyRetrigVolMod(voice.noteVolume, voice.retrigVolMod)
|
||||
voice.rowVolume = voice.noteVolume
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3037,9 +3091,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val gvol = playhead.globalVolume / 255.0
|
||||
val mvol = playhead.mixingVolume / 255.0
|
||||
for (voice in ts.voices) {
|
||||
if (!voice.active || voice.muted) {
|
||||
// Keep the soundscope flat between notes / while muted so the AudioMenu
|
||||
// does not show stale waveform data once the voice goes silent.
|
||||
if (!voice.active || voice.fader == 255) {
|
||||
// Keep the soundscope flat between notes / while fully faded (incl. host mute)
|
||||
// so the AudioMenu does not show stale waveform data once the voice goes silent.
|
||||
voice.scopeBuffer[voice.scopeWritePos] = 0f
|
||||
voice.scopeWritePos = (voice.scopeWritePos + 1) and (SCOPE_BUFFER_SIZE - 1)
|
||||
continue
|
||||
@@ -3059,10 +3113,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// triggers snap currentMixVolume to target (in triggerNote) so attacks
|
||||
// are passed through unramped.
|
||||
advanceVolumeRamp(voice)
|
||||
// External per-voice fader (0 = unity, 255 = silence). Folded into perVoiceGain
|
||||
// so the soundscope reflects what the user hears after the fader is applied.
|
||||
val faderGain = (255 - voice.fader) / 255.0
|
||||
// Split the gain stack so the soundscope can see the voice amplitude independently
|
||||
// of the playhead-wide faders (master / mixing / global volume).
|
||||
val perVoiceGain = effEnvVol * voice.fadeoutVolume * voice.currentMixVolume *
|
||||
swingScale * instGv
|
||||
swingScale * instGv * faderGain
|
||||
val globalGain = gvol * mvol * playhead.masterVolume / 255.0
|
||||
val vol = perVoiceGain * globalGain
|
||||
val pan = if (voice.hasPanEnv && voice.panEnvOn) {
|
||||
@@ -3092,7 +3149,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// 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.
|
||||
for (bg in ts.backgroundVoices) {
|
||||
if (!bg.active || bg.muted) continue
|
||||
if (!bg.active || bg.fader == 255) continue
|
||||
val bgInst = instruments[bg.instrumentId]
|
||||
val s = applyTaudVoiceFx(bg, applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst, ts.interpolationMode)))
|
||||
val instGv = bgInst.instGlobalVolume / 255.0
|
||||
@@ -3103,8 +3160,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// can leave currentMixVolume mid-ramp from the foreground's last change —
|
||||
// keep advancing so the inherited ramp completes cleanly.
|
||||
advanceVolumeRamp(bg)
|
||||
// External fader snapshotted at ghost time (see ghostVoice). Subsequent host
|
||||
// changes to the source slot's fader don't affect already-ghosted voices.
|
||||
val faderGain = (255 - bg.fader) / 255.0
|
||||
val vol = effEnvVol * bg.fadeoutVolume * bg.currentMixVolume *
|
||||
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
|
||||
swingScale * gvol * mvol * instGv * faderGain * playhead.masterVolume / 255.0
|
||||
val pan = if (bg.hasPanEnv && bg.panEnvOn) {
|
||||
val envPanRaw = (bg.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
||||
(bg.channelPan + envPanRaw - 128 + bg.randomPanBias).coerceIn(0, 255)
|
||||
@@ -3341,8 +3401,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
class Voice {
|
||||
var active = false
|
||||
var muted = false
|
||||
var instrumentId = 0
|
||||
// Externally-controlled 256-step attenuator (MMIO 4098.., AudioJSR223Delegate.setVoiceFader).
|
||||
// 0 = unity, 255 = silence — and 255 is also the "mute" sentinel that setVoiceMute writes,
|
||||
// so there is only one piece of host-owned per-voice state. Not touched by row events /
|
||||
// tracker effects; survives note triggers because the host owns it. Cleared back to 0 only
|
||||
// by resetParams() (full playhead reset).
|
||||
var fader = 0
|
||||
var samplePos = 0.0
|
||||
var playbackRate = 1.0
|
||||
var forward = true
|
||||
@@ -3362,23 +3426,35 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// also breaks the volume envelope's sustain loop). Both paths feed the same fade decay.
|
||||
var noteFading = false
|
||||
|
||||
// Volumes: channel volume is the persistent base; rowVolume tracks per-tick output (set per row from channel volume + volume column).
|
||||
// Two-volume model (TAUD_NOTE_EFFECTS.md §3): mix = sample × note_vol × channel_vol × …
|
||||
// noteVolume — per-note volume (analog of IT chan->volume). Reset on note re-trigger
|
||||
// from the instrument's Default Note Volume; written by vol-col SET / fine,
|
||||
// D / K / L vol slides, vol-col slides, Q retrig vol mod. Persists across
|
||||
// rows until the next re-trigger.
|
||||
// channelVolume — per-channel volume (analog of IT chan->global_volume). Written by M / N.
|
||||
// NOT reset by note re-trigger — the channel keeps its base volume across
|
||||
// sample changes, mirroring IT's chan->global_volume semantics.
|
||||
// rowVolume — per-tick mixer-facing volume. Reset to noteVolume at the start of every
|
||||
// row, then modulated by tremolo / tremor / per-tick slides.
|
||||
// Mixer gain ≈ (rowVolume / 63) × (channelVolume / 63).
|
||||
var noteVolume = 0x3F // $00..$3F (default full)
|
||||
var channelVolume = 0x3F // $00..$3F (default full)
|
||||
var rowVolume = 63 // $00..$3F effective output volume after slides
|
||||
var rowVolume = 63 // $00..$3F effective output volume after slides
|
||||
var channelPan = 0x80 // 8-bit; $80 centre. Cell column packs into 6-bit, S$80xx writes the full 8-bit.
|
||||
var rowPan = 32 // 6-bit pan used by mixer, derived from channelPan
|
||||
|
||||
// Anti-click volume ramp. The mixer feeds [currentMixVolume] (smoothed copy of
|
||||
// rowVolume/63) into the per-voice gain stack instead of rowVolume directly so
|
||||
// that voleff/notefx-driven steps (vol column, D slides, tremor, tremolo, retrig
|
||||
// vol-mod, fine slides) ramp across [VOL_RAMP_SAMPLES] samples rather than
|
||||
// jumping. triggerNote() arms [snapMixVolume] so the next mixer sample re-syncs
|
||||
// currentMixVolume to rowVolume/63 — bypassing the ramp on key-on attacks.
|
||||
// The snap is deferred (not applied inside triggerNote) because applyVolColumn
|
||||
// and applyEffectRow run *after* triggerNote in applyTrackerRow and may lower
|
||||
// rowVolume on the same row (e.g., a key-on with a low V column value); snapping
|
||||
// immediately in triggerNote would leave currentMixVolume at 1.0 and force a
|
||||
// ramp-down to the new low target, producing an audible transient spike.
|
||||
// (rowVolume/63) × (channelVolume/63)) into the per-voice gain stack so that
|
||||
// voleff/notefx-driven steps (vol column, D slides, tremor, tremolo, retrig
|
||||
// vol-mod, fine slides) AND M / N channel-volume changes ramp across
|
||||
// [VOL_RAMP_SAMPLES] samples rather than jumping. triggerNote() arms
|
||||
// [snapMixVolume] so the next mixer sample re-syncs currentMixVolume to the
|
||||
// post-row target — bypassing the ramp on key-on attacks. The snap is deferred
|
||||
// (not applied inside triggerNote) because applyVolColumn and applyEffectRow
|
||||
// run *after* triggerNote in applyTrackerRow and may lower rowVolume on the
|
||||
// same row (e.g., a key-on with a low V column value); snapping immediately
|
||||
// in triggerNote would leave currentMixVolume at 1.0 and force a ramp-down
|
||||
// to the new low target, producing an audible transient spike.
|
||||
var currentMixVolume = 1.0
|
||||
var volRampSamples = 0
|
||||
var volRampStep = 0.0
|
||||
@@ -3531,10 +3607,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var wSlideAmount = 0
|
||||
|
||||
// Volume / pan column slides (selectors 1/2/3 from TAUD_NOTE_EFFECTS.md §"Volume column effects").
|
||||
// These per-tick slides modify noteVolume (the per-note axis); N has its own accumulator
|
||||
// below because it modifies channelVolume (the per-channel axis) instead.
|
||||
var volColSlideUp = 0
|
||||
var volColSlideDown = 0
|
||||
var panColSlideRight = 0
|
||||
var panColSlideLeft = 0
|
||||
// N coarse slide: signed delta applied to channelVolume per non-first tick. Re-armed by
|
||||
// each N row, cleared at row start (along with the other slide accumulators).
|
||||
var nSlideDir = 0
|
||||
|
||||
// Bitcrusher (effect 8) and Overdrive (effect 9) — Taud-only voice FX.
|
||||
// clipMode is shared between both effects: 0=clamp, 1=fold, 2=wrap. See TAUD_NOTE_EFFECTS.md §8/§9.
|
||||
@@ -3745,6 +3826,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
ts.amigaLEDStateL.fill(0.0); ts.amigaLEDStateR.fill(0.0)
|
||||
ts.voices.forEach {
|
||||
it.active = false
|
||||
it.noteVolume = 0x3F
|
||||
it.channelVolume = 0x3F
|
||||
it.rowVolume = 0x3F
|
||||
it.currentMixVolume = 1.0
|
||||
@@ -3761,7 +3843,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
it.funkSpeed = 0
|
||||
it.funkAccumulator = 0
|
||||
it.funkWritePos = 0
|
||||
it.muted = false
|
||||
it.fader = 0
|
||||
it.nnaOverride = -1
|
||||
it.volEnvOn = true; it.panEnvOn = true; it.pfEnvOn = true
|
||||
it.noteFading = false
|
||||
|
||||
Reference in New Issue
Block a user