mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 13:38:30 +09:00
Compare commits
4 Commits
bef85f6e2f
...
b2faab9377
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2faab9377 | ||
|
|
e833d75b2c | ||
|
|
f84ea5e68a | ||
|
|
5374ca43c3 |
767
TAUD_NOTE_EFFECTS.md
Normal file
767
TAUD_NOTE_EFFECTS.md
Normal file
@@ -0,0 +1,767 @@
|
||||
# Taud Tracker Effect Command Reference
|
||||
|
||||
Taud is a tracker-style music format derived from ScreamTracker 3's pattern command set, extended to 16-bit effect arguments and a 4096-tone equal-temperament pitch grid. This document defines every effect command a Taud engine must implement. Each command entry has three parts: a plain explanation for composers, compatibility notes for converting patterns from ScreamTracker 3 (ST3) or ProTracker (PT), and implementation details for engine writers.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sound device
|
||||
|
||||
- **Bit depth:** 8-bit unsigned throughout, including the final mixdown.
|
||||
- **Sample rate:** fixed at 32000 Hz.
|
||||
- **Output channels:** strictly stereo; the mix bus always produces a two-channel frame even for mono-source samples.
|
||||
|
||||
Internal accumulators may widen to 16 or 32 bits during mixing and effect computation, but stored samples and final output are 8-bit.
|
||||
|
||||
## 2. Pitch system — 4096-TET
|
||||
|
||||
One octave spans **4096 pitch units** ($1000 exactly). A 12-TET semitone therefore equals **4096 ÷ 12 ≈ 341.333 units** (≈ $0155.55), which is not an integer; this irrationality is a deliberate consequence of choosing a microtonal native grid. Implementations store channel pitch as a signed integer in Taud units, and convert to playback rate using
|
||||
|
||||
```
|
||||
playback_rate = reference_rate × 2 ^ (pitch_units / 4096)
|
||||
```
|
||||
|
||||
Commonly used intervals in Taud units are listed below; all are rounded to the nearest integer.
|
||||
|
||||
| Interval | Units (exact) | Hex (rounded) |
|
||||
|---|---|---|
|
||||
| Octave | 4096 | $1000 |
|
||||
| Perfect fifth (7 ST) | 2389.33 | $0955 |
|
||||
| Tritone (6 ST) | 2048 | $0800 |
|
||||
| Major third (4 ST) | 1365.33 | $0555 |
|
||||
| Minor third (3 ST) | 1024 | $0400 |
|
||||
| 1 semitone | 341.33 | $0155 |
|
||||
| 1/8 semitone (1 finetune) | 42.67 | $002B |
|
||||
| 1/16 semitone | 21.33 | $0015 |
|
||||
| 1/64 semitone | 5.33 | $0005 |
|
||||
| 1 cent (1/100 semitone) | 3.41 | $0003 |
|
||||
|
||||
## 3. Volume system
|
||||
|
||||
Per-note and per-channel volume runs from **$00 (silent) to $3F (full)**, a 6-bit range narrower than ST3's 0..$40. Global volume (effect V) runs 0..$FF; this wider range lets the mix bus scale the summed channel output without disturbing individual note volumes. The per-frame mix chain per channel is
|
||||
|
||||
```
|
||||
mix = sample × note_vol × channel_vol × global_vol >> normalisation_shift
|
||||
```
|
||||
|
||||
with saturation applied before the 8-bit stereo output.
|
||||
|
||||
## 4. Rows, ticks, patterns, orders
|
||||
|
||||
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 an order sequence; effects B and C navigate this sequence.
|
||||
|
||||
## 5. Default parameters at song start
|
||||
|
||||
| Parameter | Value |
|
||||
|---|---|
|
||||
| Speed | $06 (6 ticks/row) |
|
||||
| Tempo byte | $65 (125 BPM; see effect T for the $18 offset) |
|
||||
| Global volume | $80 (mid-scale) |
|
||||
| Channel volume | $3F (full) |
|
||||
| Pan (all channels) | $80 (centre) |
|
||||
| Order index | $0000 |
|
||||
|
||||
## 6. Effect memory groups
|
||||
|
||||
Most effects recall their last non-zero argument when re-issued with $0000. Unlike ST3, which shares one memory slot across most effects, Taud groups memories into four cohorts plus private slots:
|
||||
|
||||
- **E and F share one slot** (pitch slide down and up). Issuing E $0000 recalls the last E-or-F argument and re-applies it as a down-slide; F $0000 does the same as an up-slide.
|
||||
- **G has its own slot** (tone portamento).
|
||||
- **H and U share one slot** (vibrato speed and depth are jointly recalled; the last-written values persist across both commands).
|
||||
- **R has its own slot** (tremolo).
|
||||
|
||||
Every other memory-carrying effect (D, I, J, K, L, O, Q, and others) has a private slot.
|
||||
|
||||
## 7. Opcode and argument format
|
||||
|
||||
Opcodes are single base-36 digits (0-9, then A-Z); arguments are 16-bit hexadecimal values prefixed with `$`. A cell is notated `OPCODE $HHLL` where HH is the high byte and LL is the low byte. Where an effect partitions its argument into sub-fields (for instance, H's speed and depth), the split is spelled out in the command description.
|
||||
|
||||
---
|
||||
|
||||
# The effects
|
||||
|
||||
## A $xx00 — Set tick speed to $xx
|
||||
|
||||
**Plain.** Sets how many ticks each row contains. Lower values make rows shorter and per-tick effects (slides, vibrato) develop faster; higher values stretch the row and give effects more iterations.
|
||||
|
||||
**Compatibility.** ST3 `Axx` maps one-to-one: Taud `A $xx00`. ST3 `A00` is a no-op; Taud `A $0000` is likewise ignored. ProTracker `Fxx` with `xx < $20` maps to Taud `A $xx00`; `Fxx` with `xx ≥ $20` maps to T instead (see T).
|
||||
|
||||
**Implementation.** If the high byte is non-zero, write it to `ticks_per_row`; the low byte is reserved and must be zero. The change takes effect from the row on which the A command appears. There is no memory for A.
|
||||
|
||||
---
|
||||
|
||||
## B $xxyy — Jump to order $xxyy
|
||||
|
||||
**Plain.** Finishes the current row, then continues playback at row 0 of the pattern at order position $xxyy. Use this to create song-level jumps, loops, or branching structures.
|
||||
|
||||
**Compatibility.** ST3 `Bxx` jumps to an 8-bit order and maps to Taud `B $00xx`. The extended 16-bit range means Taud songs may have up to $10000 order entries.
|
||||
|
||||
**Implementation.** On the last tick of the current row, set the next order index to the argument and the next row to 0. If the argument exceeds the song length, wrap to the song's defined restart position (order $0000 by default). Jumps are detected by a visited `(order, row)` set so that pathological loops do not prevent song-length computation, though they do not interrupt actual playback. There is no memory for B.
|
||||
|
||||
**Simultaneous B and C on the same row.** If a B command appears in the same row as a C command (on any channel), both fire: B chooses the order, C chooses the row within that order. If the two commands appear on different channels, channel priority is **ascending channel index** — the lowest-numbered channel carrying either effect wins its parameter. If both appear on the same channel row (only possible if one is a volume-column equivalent), the effect column takes precedence.
|
||||
|
||||
---
|
||||
|
||||
## C $xxyy — Break pattern to row $xxyy
|
||||
|
||||
**Plain.** Finishes the current row, then skips ahead to row $xxyy of the **next** pattern in the order sequence.
|
||||
|
||||
**Compatibility.** ST3 stores `Cxx` as **BCD** (so on-disk `$10` means decimal row 10); Taud stores the argument as plain binary. When converting from ST3, decode with `row = (byte >> 4) × 10 + (byte & $0F)`. Valid ST3 source bytes are those representing decimal 0..63; out-of-range BCD bytes should clamp to row 0 on import. When exporting back to ST3, encode with `byte = ((row / 10) << 4) | (row % 10)`, clamped at row 63.
|
||||
|
||||
**Implementation.** On the last tick of the current row, advance the order index by 1 (or honour a co-occurring B), then set the next row to the argument. If the argument exceeds the destination pattern's row count, start the destination pattern at row 0. There is no memory for C.
|
||||
|
||||
---
|
||||
|
||||
## D — 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 $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.
|
||||
|
||||
**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).
|
||||
|
||||
### D $x000 — Volume slide up by $x per non-first tick
|
||||
|
||||
**Plain.** Each tick after tick 0, volume 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.
|
||||
|
||||
### 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).
|
||||
|
||||
**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.
|
||||
|
||||
### D $xF00 — Fine volume slide up by $x on tick 0
|
||||
|
||||
**Plain.** One-shot volume 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.
|
||||
|
||||
---
|
||||
|
||||
## E $xxxx — Pitch slide down by $xxxx (linear)
|
||||
|
||||
**Plain.** Lowers the channel's pitch by the argument per tick. Taud's pitch slides are **linear in the 4096-TET grid** — the slide value is subtracted directly from the stored pitch, without any period-table indirection. A coarse slide uses the full value range; a fine slide applies only once per row; an extra-fine slide is not provided (the 16-bit argument already gives microtonal precision below 1/64 semitone).
|
||||
|
||||
Coarse and fine modes are distinguished by the high nibble of the argument:
|
||||
|
||||
- `E $0001..$EFFF` — coarse slide: subtracts the full argument from pitch each tick after tick 0. A slide of $0155 drops pitch by one semitone per tick.
|
||||
- `E $F000..$FFFF` — fine slide: on tick 0 only, subtracts `arg & $0FFF` from pitch.
|
||||
- `E $0000` — recalls the last E-or-F argument and applies it as a down-slide, preserving the original form (coarse or fine).
|
||||
|
||||
**Compatibility.** This is **the single intentionally ST3-incompatible command in Taud**. ST3 pitch slides operate on Amiga periods or linear slide units; Taud operates directly on 4096-TET pitch units. Conversion from ST3 linear-mode slides uses 1 ST3 slide unit ≈ $0005 Taud units (1/64 semitone):
|
||||
|
||||
- ST3 `Exx` coarse (where `xx < $E0`) → Taud `E $00xx × $0015` (one ST3 coarse unit = 1/16 semitone ≈ $0015 Taud units).
|
||||
- ST3 `EFx` fine → Taud `E $F0xx × $0015` with appropriate range packing.
|
||||
- ST3 `EEx` extra-fine → Taud `E $F0xx × $0005` (one ST3 extra-fine unit = 1/64 semitone ≈ $0005 Taud units).
|
||||
|
||||
ST3 Amiga-mode slides do not have a clean conversion and should be treated as linear-mode equivalents during import.
|
||||
|
||||
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.
|
||||
|
||||
**Implementation.** Per-tick processing:
|
||||
|
||||
```
|
||||
on row start:
|
||||
raw = arg
|
||||
if raw == 0: raw = memory_EF
|
||||
else: memory_EF = raw
|
||||
if (raw & $F000) == $F000: # fine
|
||||
pitch -= (raw & $0FFF)
|
||||
mode_this_row = FINE
|
||||
else: # coarse
|
||||
slide_amount_this_row = raw
|
||||
mode_this_row = COARSE
|
||||
|
||||
on tick > 0:
|
||||
if mode_this_row == COARSE:
|
||||
pitch -= slide_amount_this_row
|
||||
```
|
||||
|
||||
Glissando control (S $1x) snaps the output pitch to the nearest semitone after every slide application; see S $1x.
|
||||
|
||||
---
|
||||
|
||||
## F $xxxx — Pitch slide up by $xxxx (linear)
|
||||
|
||||
**Plain.** Raises the channel's pitch by the argument per tick, with the same mode-selection scheme as E. Coarse, fine, and memory behaviour are identical in form but inverted in direction.
|
||||
|
||||
**Compatibility.** Same as E. ST3 `Fxx` coarse, `FFx` fine, and `FEx` extra-fine convert with the same scaling factors ($0015 and $0005). F and E share one memory slot in Taud.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
**Compatibility.** ST3 `Gxx` uses an 8-bit value in period-table units; convert to Taud using the same $0015-per-unit 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.
|
||||
|
||||
**Implementation.**
|
||||
|
||||
```
|
||||
on row parse:
|
||||
if row has note and G effect:
|
||||
target_pitch = period_for(note)
|
||||
# do NOT re-trigger sample
|
||||
if arg != 0:
|
||||
memory_G = arg
|
||||
speed_this_row = memory_G
|
||||
|
||||
on tick > 0:
|
||||
if target_pitch set:
|
||||
delta = sign(target_pitch - pitch) × speed_this_row
|
||||
pitch += delta
|
||||
if sign crossed target: pitch = target_pitch; target_pitch = None
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## H $xxyy — Vibrato with speed $xx and depth $yy
|
||||
|
||||
**Plain.** Modulates pitch with a low-frequency oscillator (LFO). `$xx` is the LFO speed (high byte), `$yy` is the depth (low byte). On H rows the LFO accumulator advances at `$xx × 4` per tick through a 256-entry lookup of the selected waveform (see S $3x). The current pitch offset is added to the channel's base pitch for the duration of each tick.
|
||||
|
||||
**Compatibility.** ST3 `Hxy` uses 4-bit nibbles for speed and depth; convert by nibble-repeating each into Taud's bytes: ST3 `H27` → Taud `H $2277`. This preserves the effective LFO rate and peak depth. H and U share memory in Taud (they did in ST3 too).
|
||||
|
||||
Unlike ProTracker, ST3 vibrato fires on tick 0 as well; Taud follows ST3.
|
||||
|
||||
**Implementation.** The reference sine table is OpenMPT's 64-entry 8-bit table, indexed `pos >> 2` through a 256-entry logical LFO (equivalently, a 256-sample 4×-oversampled sine peaking at ±$7F):
|
||||
|
||||
```
|
||||
ModSinusTable[64] =
|
||||
00 0C 19 25 31 3C 47 51 5A 62 6A 70 75 7A 7D 7E
|
||||
7F 7E 7D 7A 75 70 6A 62 5A 51 47 3C 31 25 19 0C
|
||||
00 F4 E7 DB CF C4 B9 AF A6 9E 96 90 8B 86 83 82
|
||||
81 82 83 86 8B 90 96 9E A6 AF B9 C4 CF DB E7 F4
|
||||
```
|
||||
|
||||
Per row/tick:
|
||||
|
||||
```
|
||||
on row parse (H):
|
||||
if (arg >> 8) != 0: memory_HU.speed = arg >> 8
|
||||
if (arg & $FF) != 0: memory_HU.depth = arg & $FF
|
||||
|
||||
on every tick (including tick 0):
|
||||
sine = ModSinusTable[(lfo_pos >> 2) & $3F] # signed -$80..+$7F
|
||||
pitch_delta = (sine × memory_HU.depth) >> 6
|
||||
applied_pitch = base_pitch + pitch_delta
|
||||
lfo_pos = (lfo_pos + memory_HU.speed × 4) & $FF
|
||||
```
|
||||
|
||||
At maximum speed and depth ($FFFF), peak `pitch_delta` is `$7F × $FF >> 6 ≈ $1FA` — about 1.5 semitones. On a fresh note, if the current LFO waveform retrigger bit is clear (S $3x with $x < $4), `lfo_pos` resets to 0. When the waveform is "random", a fresh random value is drawn every tick rather than read from the table.
|
||||
|
||||
---
|
||||
|
||||
## U $xxyy — Fine vibrato with speed $xx and depth $yy
|
||||
|
||||
**Plain.** Same LFO as H but four times finer in pitch — useful for subtle microtonal warbles.
|
||||
|
||||
**Compatibility.** ST3 `Uxy` uses nibbles; nibble-repeat each to convert. U shares memory with H.
|
||||
|
||||
**Implementation.** Identical to H except the shift is 8 instead of 6:
|
||||
|
||||
```
|
||||
pitch_delta = (sine × memory_HU.depth) >> 8
|
||||
```
|
||||
|
||||
Peak at maximum settings: $7F × $FF >> 8 ≈ $7E, about 0.4 semitone — exactly a quarter of H's peak.
|
||||
|
||||
---
|
||||
|
||||
## I $xxyy — Tremor with on-time $xx and off-time $yy
|
||||
|
||||
**Plain.** Rapidly gates the channel on and off. Volume plays normally for `$xx + 1` ticks, then mutes for `$yy + 1` ticks, repeating. Counters persist across rows and only reset on a fresh I row with a new argument.
|
||||
|
||||
**Compatibility.** ST3 `Ixy` uses nibbles (`$xy`) with the same semantics; convert by nibble-repeating each into Taud bytes: ST3 `I47` → Taud `I $4477`. The `+1` behaviour on both counters comes from ProTracker and is preserved throughout. Memory is private.
|
||||
|
||||
**Implementation.**
|
||||
|
||||
```
|
||||
on row parse (I):
|
||||
if arg != 0: memory_I = arg
|
||||
on_time = ((memory_I >> 8) & $FF) + 1
|
||||
off_time = ( memory_I & $FF) + 1
|
||||
|
||||
on every tick:
|
||||
if phase == ON:
|
||||
play at full channel volume
|
||||
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)
|
||||
tick_in_phase += 1
|
||||
if tick_in_phase >= off_time: phase = ON; tick_in_phase = 0
|
||||
```
|
||||
|
||||
A zero `$xx` or `$yy` input becomes 1 tick after the `+1`, never zero.
|
||||
|
||||
---
|
||||
|
||||
## J $xxyy — Microtonal arpeggio with offsets $xx00 and $yy00
|
||||
|
||||
**Plain.** Cycles the playing pitch through three values across consecutive ticks: the note, the note plus `$xx00` Taud units, and the note plus `$yy00` Taud units, repeating. At the default 50 Hz tick rate (speed 6, 125 BPM), this produces a classic chord-arpeggio effect; because Taud's grid is 4096-TET, the intervals can be microtonal.
|
||||
|
||||
The encoding places each 8-bit offset byte into the **high byte** of a 16-bit pitch delta, giving 256 discrete intervals per arp voice with a resolution of $0100 ≈ 0.75 semitone per step. This is coarser than E/F's 16-bit slides, but adequate for arpeggios and well-suited to non-12-TET intervals.
|
||||
|
||||
**Compatibility.** ST3 `Jxy` uses nibbles as 12-TET semitones; Taud uses bytes as $0100-scaled 4096-TET offsets. The conversion is therefore lossy — 12-TET intervals that are not multiples of 3 semitones incur ±25 cent rounding error. The table below gives the best Taud byte for each 12-TET semitone offset:
|
||||
|
||||
| Semitones | Taud byte | Taud units | Error (cents) |
|
||||
|---|---|---|---|
|
||||
| 0 | $00 | 0 | 0 |
|
||||
| 1 | $01 | 256 | −25 |
|
||||
| 2 | $03 | 768 | +25 |
|
||||
| 3 | $04 | 1024 | 0 |
|
||||
| 4 | $05 | 1280 | −25 |
|
||||
| 5 | $07 | 1792 | +25 |
|
||||
| 6 | $08 | 2048 | 0 |
|
||||
| 7 | $09 | 2304 | −25 |
|
||||
| 8 | $0B | 2816 | +25 |
|
||||
| 9 | $0C | 3072 | 0 |
|
||||
| 10 | $0D | 3328 | −25 |
|
||||
| 11 | $0F | 3840 | +25 |
|
||||
| 12 | $10 | 4096 | 0 |
|
||||
|
||||
For example, ST3 `J37` (minor chord) imports as Taud `J $0409`; ST3 `J47` (major chord) as Taud `J $0509`. Memory is private and stores the full 16-bit argument.
|
||||
|
||||
**Implementation.**
|
||||
|
||||
```
|
||||
on row parse (J):
|
||||
if arg != 0: memory_J = arg
|
||||
off1 = (memory_J >> 8) & $FF # high byte
|
||||
off2 = memory_J & $FF # low byte
|
||||
|
||||
on every tick:
|
||||
selector = tick_within_row mod 3
|
||||
if selector == 0: voice_pitch = base_pitch
|
||||
elif selector == 1: voice_pitch = base_pitch + (off1 << 8)
|
||||
elif selector == 2: voice_pitch = base_pitch + (off2 << 8)
|
||||
```
|
||||
|
||||
The `tick_within_row mod 3` counter resets every row start (so every row begins at `base_pitch`). A subsequent E/F slide after a J row resumes from the last arpeggiated voice's pitch, not from `base_pitch` — this mirrors ST3's `kST3PortaAfterArpeggio` quirk and is deliberately preserved.
|
||||
|
||||
---
|
||||
|
||||
## K $xy00 — Dual: vibrato continuation and volume slide $xy
|
||||
|
||||
**Plain.** **Unimplemented**. On ST3, continues a previously started vibrato (H or U) without retriggering it, while applying a volume slide of `$xy` per non-first tick. Fine volume slides are not available in this form.
|
||||
|
||||
**Compatibility.** ST3 `Kxy` maps directly. Implementations must convert K to an explicit pair of commands: `H $0000` (continue with stored speed/depth) combined with volume-column command `1.$xy` (volume slide), and emit both.
|
||||
|
||||
**Implementation.** Execute the per-tick vibrato update as if an H command were active with argument $0000 (recall), then execute a D $0y00 or $x000 slide using the bytes of the K argument: high nibble as up-slide, low nibble as down-slide. If both nibbles are non-zero, down-slide takes precedence (matching ST3). K has no memory of its own; it uses H/U's stored speed and depth.
|
||||
|
||||
---
|
||||
|
||||
## L $xy00 — Dual: tone portamento continuation and volume slide $xy
|
||||
|
||||
**Plain.** **Unimplemented**. On ST3, continues a previously started tone portamento (G) without retriggering, while applying a volume slide of `$xy` per non-first tick. Fine volume slides are not available here.
|
||||
|
||||
**Compatibility.** ST3 `Lxy` maps directly. Like K, L must be equivalently implemented as `G $0000` plus a volume-column slide.
|
||||
|
||||
**Implementation.** Execute the per-tick G update (recalling G's stored speed), then the D slide as in K. L has no memory of its own.
|
||||
|
||||
---
|
||||
|
||||
## O $xxyy — Set sample offset to $xxyy
|
||||
|
||||
**Plain.** On the row where it appears, jumps the sample playhead to byte $xxyy of the sample data. If the sample is looped and the requested offset exceeds the loop end, the offset wraps around through the loop as if playback had reached that point naturally.
|
||||
|
||||
**Compatibility.** ST3 `Oxx` is 8-bit, addressing offset `xx × $100`. On import, copy the ST3 byte into Taud's high byte and zero the low byte: Taud `O $xx00`. ProTracker `9xx` maps identically. The Taud 16-bit form allows byte-precise seeking within samples larger than $100 bytes. Memory is private.
|
||||
|
||||
**Implementation.** On the row start, set the sample playhead to `arg` (in bytes, relative to the sample's start). Apply the loop-wrap calculation if the sample has loop points and `arg > loop_end`: `arg = loop_start + ((arg - loop_start) mod loop_length)`. The O command does not retrigger the sample; it only relocates the playhead for an already-triggered note.
|
||||
|
||||
---
|
||||
|
||||
## Q $xy00 — Retrigger note every $y ticks with volume modifier $x
|
||||
|
||||
**Plain.** Retriggers the currently playing sample at an interval of `$y` ticks, optionally modifying its volume on each retrigger according to `$x`. The retrigger interval runs across rows until a new Q with a different `$y` or no Q at all.
|
||||
|
||||
**Compatibility.** ST3 `Qxy` maps directly. The **`$y == 0` behaviour is preserved from ST3**: the entire effect is ignored (no retrigger, and memory is not updated). Memory is private.
|
||||
|
||||
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.
|
||||
|
||||
The volume modifier table, **computed with arithmetic (no LUT)**, is:
|
||||
|
||||
| $x | Action | $x | Action |
|
||||
|---|---|---|---|
|
||||
| 0 | no change | 8 | no change |
|
||||
| 1 | vol − $01 | 9 | vol + $01 |
|
||||
| 2 | vol − $02 | A | vol + $02 |
|
||||
| 3 | vol − $04 | B | vol + $04 |
|
||||
| 4 | vol − $08 | C | vol + $08 |
|
||||
| 5 | vol − $10 | D | vol + $10 |
|
||||
| 6 | vol × 2 / 3 | E | vol × 3 / 2 |
|
||||
| 7 | vol × 1 / 2 | F | vol × 2 |
|
||||
|
||||
Multiplicative cases use integer arithmetic: `vol × 2 / 3` is `(vol × 2) / 3` (truncated); `vol × 3 / 2` is `(vol × 3) / 2`; `vol × 1 / 2` is `vol >> 1`; `vol × 2` is `vol << 1`. All results clip to $00..$3F after.
|
||||
|
||||
A note previously silenced by a cut (`^^^` or `SCx` earlier in the row) is not retriggered, matching ST3's `kST3RetrigAfterNoteCut` rule.
|
||||
|
||||
---
|
||||
|
||||
## R $xxyy — Tremolo with speed $xx and depth $yy
|
||||
|
||||
**Plain.** Modulates volume with an LFO, symmetrically with H's pitch modulation. `$xx` is LFO speed, `$yy` depth; the waveform is selected by S $4x.
|
||||
|
||||
**Compatibility.** ST3 `Rxy` uses nibbles; convert by nibble-repeat. ST3's volume cap is $40; Taud's is $3F — very deep tremolo that would have briefly clipped at $40 in ST3 may clip slightly earlier in Taud. R has its own memory slot (not shared with H/U).
|
||||
|
||||
**Implementation.** Identical machinery to H with a larger shift to fit the narrower volume range:
|
||||
|
||||
```
|
||||
on row parse (R):
|
||||
if (arg >> 8) != 0: memory_R.speed = arg >> 8
|
||||
if (arg & $FF) != 0: memory_R.depth = arg & $FF
|
||||
|
||||
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)
|
||||
lfo_pos = (lfo_pos + memory_R.speed × 4) & $FF
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## T $xxyy — Tempo set or tempo slide
|
||||
|
||||
Taud splits T by which byte carries the value:
|
||||
|
||||
### T $xx00 (high byte non-zero) — Set tempo
|
||||
|
||||
**Plain.** Sets the Taud tempo byte to `$xx`. The resulting BPM is `$xx + $18`: Taud byte $00 → 24 BPM, $65 → 125 BPM (default), $FF → 279 BPM.
|
||||
|
||||
**Compatibility.** ST3 `Txx` (where `xx ∈ $20..$FF`) stores BPM directly; convert with `taud_byte = xx − $18`. Taud byte $08 corresponds to ST3's minimum BPM of 32; Taud bytes below $08 are inexpressible in ST3 and should round up to $08 (BPM 32) when exporting. OpenMPT's extended tempo slides (`T $0x` down, `T $1x` up) in S3M files map to Taud T $00xx — see below.
|
||||
|
||||
ProTracker `Fxx` with `xx ≥ $20` maps to Taud `T $(xx − $18)00`; `Fxx` with `xx < $20` maps to A (speed) instead.
|
||||
|
||||
**Implementation.** If the high byte is non-zero, set `tempo_byte = arg >> 8`; derive `BPM = tempo_byte + $18`; compute tick duration as `samples_per_tick = 32000 × 5 / (BPM × 2) = 80000 / BPM` (integer truncated) at the fixed 32000 Hz output rate. Example: BPM 125 → 640 samples per tick; BPM 24 → 3333 samples per tick; BPM 279 → 286 samples per tick. There is no memory for set-tempo.
|
||||
|
||||
### T $00xy (high byte zero) — Tempo slide
|
||||
|
||||
**Plain.** Adjusts the tempo continuously during the row. `$00_0y` (low nibble under a zero high nibble within the low byte) slides BPM down by `$y` per non-first tick; `$00_1y` slides up. Out-of-range encodings ($00_20 through $00_FF) are reserved and behave as no-ops.
|
||||
|
||||
**Compatibility.** ST3 itself has only the set form; the slide forms originate in the OpenMPT/Schism extension of S3M. On export to strict ST3, slide forms are unrepresentable and should be approximated as an equivalent set-tempo on a later row.
|
||||
|
||||
**Implementation.**
|
||||
|
||||
```
|
||||
on row parse (T with high byte == 0):
|
||||
low = arg & $FF
|
||||
if (low & $F0) == $00:
|
||||
slide_dir = DOWN
|
||||
slide_amount = low & $0F
|
||||
elif (low & $F0) == $10:
|
||||
slide_dir = UP
|
||||
slide_amount = low & $0F
|
||||
else:
|
||||
ignore row
|
||||
|
||||
on tick > 0 (if slide armed):
|
||||
if slide_dir == DOWN: tempo_byte = max($00, tempo_byte - slide_amount)
|
||||
else: tempo_byte = min($FF, tempo_byte + slide_amount)
|
||||
recompute samples_per_tick for next tick
|
||||
```
|
||||
|
||||
A tempo slide's memory slot is separate from the set-tempo path and is private to T-slide.
|
||||
|
||||
---
|
||||
|
||||
## V $xx00 — Set global volume to $xx
|
||||
|
||||
**Plain.** Sets the global mix bus volume (0..$FF). $00 is silence; $FF is full. The default is $80.
|
||||
|
||||
**Compatibility.** ST3's global volume is 0..$40; convert with `taud_v = st3_v × 4`, clamped at $FF. On export, `st3_v = taud_v >> 2`, clamped at $40.
|
||||
|
||||
**Implementation.** Write the high byte to `global_volume` on the row the command appears. The low byte is reserved. ST3's `kST3NoMutedChannels` rule applies: V on a muted channel is ignored by ST3; for strict-compatible playback Taud follows suit, but new Taud compositions should avoid muting channels that carry global effects.
|
||||
|
||||
---
|
||||
|
||||
# The S subcommand family
|
||||
|
||||
S is a multiplexing opcode; the **high nibble of the high byte** selects the sub-effect, and the remainder is the sub-argument.
|
||||
|
||||
## S $1x00 — Glissando control
|
||||
|
||||
**Plain.** `$1000` turns glissando off; `$1100` turns it on. When on, tone portamento (G) output is quantised to the nearest semitone ($0155 approximation) before being sent to the mixer. The internal G pitch counter still advances smoothly; only the audible pitch steps. **This command is implemented sorely for ST3 compatibility.**
|
||||
|
||||
**Compatibility.** ST3 `S10`/`S11` maps directly. In Taud, "nearest semitone" uses the best integer approximation: round `pitch / $155` to the nearest integer, multiply by $155; equivalently, `snapped = (pitch + $AB) / $155 × $155`. Because $155 is an approximation of 4096/12, accumulated rounding across many octaves will drift by up to a few cents; this is documented behaviour and intentional given the microtonal grid.
|
||||
|
||||
**Implementation.** Maintain a per-channel boolean `glissando_on`. When G updates `pitch`, if `glissando_on` is set, compute `display_pitch = round(pitch × 12 / 4096) × 4096 / 12` (using integer division with rounding) and send `display_pitch` to the mixer; otherwise send `pitch` directly.
|
||||
|
||||
---
|
||||
|
||||
## S $2x00 — Set fine-tune
|
||||
|
||||
**Plain.** Overrides the current note's fine-tune by applying a fixed 4096-TET offset. The index `$x` selects one of sixteen predefined pitch offsets, following ScreamTracker 3's Hz-based fine-tune table but expressed directly in Taud units. This command is implemented for ST3 compatibility.
|
||||
|
||||
**Compatibility.** The index scheme matches ST3 exactly: `$8` is the baseline (no change), `$0..$7` are progressively flatter, `$9..$F` are progressively sharper. The Hz reference values come from the ST3 User's Manual and are reproduced here for auditability; the Taud offset is `log2(Hz / 8363) × 4096`, rounded to the nearest integer. **Format converters are advised to apply offset to the note value directly.**
|
||||
|
||||
| $x | Reference Hz | Taud offset |
|
||||
|---|---|---|
|
||||
| $0 | 7895 | −$0154 |
|
||||
| $1 | 7941 | −$0132 |
|
||||
| $2 | 7985 | −$0111 |
|
||||
| $3 | 8046 | −$00E4 |
|
||||
| $4 | 8107 | −$00B8 |
|
||||
| $5 | 8169 | −$008B |
|
||||
| $6 | 8232 | −$005D |
|
||||
| $7 | 8280 | −$003B |
|
||||
| $8 | 8363 | $0000 |
|
||||
| $9 | 8413 | +$0023 |
|
||||
| $A | 8463 | +$0046 |
|
||||
| $B | 8529 | +$0074 |
|
||||
| $C | 8581 | +$0098 |
|
||||
| $D | 8651 | +$00C8 |
|
||||
| $E | 8723 | +$00F9 |
|
||||
| $F | 8757 | +$0110 |
|
||||
|
||||
ProTracker `E5x` maps to Taud `S $2x00` with the same index meaning.
|
||||
|
||||
**Implementation.** On the row, look up the offset from the table and add it to the channel's base pitch before any other per-tick effect processes. The offset persists until another S $2x command or a note-reset event.
|
||||
|
||||
---
|
||||
|
||||
## S $3x00 — Vibrato LFO waveform
|
||||
|
||||
**Plain.** Selects the shape of the vibrato (H and U) oscillator.
|
||||
|
||||
| $x | Waveform | Retrigger on new note? |
|
||||
|---|---|---|
|
||||
| $0 | Sine | Yes |
|
||||
| $1 | Ramp down (sawtooth) | Yes |
|
||||
| $2 | Square | Yes |
|
||||
| $3 | Random | Yes |
|
||||
| $4 | Sine | No |
|
||||
| $5 | Ramp down | No |
|
||||
| $6 | Square | No |
|
||||
| $7 | Random | No |
|
||||
|
||||
**Compatibility.** ST3 `S3x` maps directly.
|
||||
|
||||
**Implementation.** Store `vibrato_waveform = $x & $3` and `vibrato_retrigger = (($x & $4) == 0)` for the channel. The ramp-down shape is `$7F − ((pos & $3F) << 2)` across one logical cycle; the square shape is `sign(sine(pos)) × $7F`; random draws a fresh `rand() & $FF − $80` every tick. On a new note, if `vibrato_retrigger` is true, reset `lfo_pos = 0`.
|
||||
|
||||
---
|
||||
|
||||
## S $4x00 — Tremolo LFO waveform
|
||||
|
||||
**Plain.** Selects the shape of the tremolo (R) oscillator; value encoding is identical to S $3x.
|
||||
|
||||
**Compatibility.** ST3 `S4x` maps directly. ProTracker `E7x` maps to Taud `S $4x00`.
|
||||
|
||||
**Implementation.** As for S $3x, but applied to R's separate state (`tremolo_waveform`, `tremolo_retrigger`, and tremolo `lfo_pos`).
|
||||
|
||||
---
|
||||
|
||||
## S $80xx — Set channel pan position
|
||||
|
||||
**Plain.** Sets the channel pan to `$xx`, with $00 being full left and $FF being full right. $80 is centre.
|
||||
|
||||
**Compatibility.** ST3 `S8x` uses a 4-bit value; convert by nibble-repeat: ST3 `S83` → Taud `S $8033`. Panning column command `0.$xx` has the same semantics and is the preferred form when a pan column is available in the pattern. ProTracker `8xx` (fine pan) and `E8x` (coarse pan) both map into Taud's 8-bit pan — the ProTracker 8-bit form maps directly; the 4-bit form nibble-repeats.
|
||||
|
||||
**Implementation.** Write `channel_pan = arg & $FF`. The pan value is applied at the mixer: `left_gain = (($FF − pan) × $100) >> 8`, `right_gain = (pan × $100) >> 8`, with both applied before the global volume stage.
|
||||
|
||||
---
|
||||
|
||||
## S $Bx00 — Pattern loop
|
||||
|
||||
**Plain.** Sets a loop point and loops within a pattern. `S $B000` marks the current row as the loop start (per channel, not per song); `S $Bx00` with $x > 0 returns playback to the saved row and plays the intervening range `$x` more times (so `$B200` plays the loop twice total beyond the initial pass).
|
||||
|
||||
**Compatibility.** ST3 `SBx` maps directly. ProTracker `E6x` maps to Taud `S $Bx00`.
|
||||
|
||||
ST3 has a long-documented bug where pattern delay (SEx) inside a pattern-loop range causes the loop counter to decrement multiple times per visit, producing unintended behaviour. **Taud fixes this bug.** On import, ST3 songs that relied on the bug will loop fewer times in Taud. Converters that want bit-exact ST3 playback should emit a warning when SBx and SEx appear in the same channel within a loop range, or optionally flatten loops by duplicating rows.
|
||||
|
||||
**Implementation.** State per channel: `loop_start_row` (defaulting to 0 at each pattern entry) and `loop_count` (defaulting to 0).
|
||||
|
||||
```
|
||||
on row event (S $Bx00):
|
||||
x = (arg >> 8) & $0F
|
||||
if x == 0:
|
||||
loop_start_row = current_row
|
||||
else:
|
||||
if loop_count == 0:
|
||||
loop_count = x
|
||||
jump next_row -> loop_start_row
|
||||
else:
|
||||
loop_count -= 1
|
||||
if loop_count > 0:
|
||||
jump next_row -> loop_start_row
|
||||
# else loop_count hits 0 on its own; fall through to next row
|
||||
|
||||
on pattern change: loop_start_row = 0; loop_count = 0
|
||||
```
|
||||
|
||||
The crucial bug fix relative to ST3: the loop-counter decrement happens **once per actual row playback**, not once per tick-0 invocation. When SBx shares a row with SEx (pattern delay), the pattern-delay machinery replays the row as a unit, but the SBx state machine treats the whole delay group as a single visit. Implement this by gating the SBx decrement on `pattern_delay_repetition == 0`.
|
||||
|
||||
---
|
||||
|
||||
## S $Cx00 — Note cut in $x ticks
|
||||
|
||||
**Plain.** Silences the note on tick `$x` of the current row by forcing the channel's output volume to 0. The sample continues running internally, so a later volume-change or retrigger event can resume audio.
|
||||
|
||||
**Compatibility.** ST3 `SCx` maps directly. ProTracker `ECx` also maps directly. ST3 ignores `SC0` (treats it as no cut at all); Taud preserves this.
|
||||
|
||||
**Implementation.** On tick `$x`, set `output_volume = 0` but leave `base_volume` unchanged. If `$x ≥ speed`, the cut never fires. If `$x == 0`, the command is ignored. Set the `note_was_cut` flag so a later Q retrigger on the same row is suppressed.
|
||||
|
||||
---
|
||||
|
||||
## S $Dx00 — Note delay for $x ticks
|
||||
|
||||
**Plain.** Delays the triggering of the note (and any co-row instrument, offset, and volume event) until tick `$x`. Until then, any currently playing note continues.
|
||||
|
||||
**Compatibility.** ST3 `SDx` maps directly. ProTracker `EDx` also maps directly. `SD0` plays the note normally on tick 0. If `$x ≥ speed`, the note never plays on this row and does not carry over to the next row.
|
||||
|
||||
**Implementation.** On row parse, defer the note-trigger event (including sample selection, volume, offset, and any volume-column effect) until tick `$x`. On tick `$x`, execute the deferred trigger. When combined with pattern delay (S $Ex00), the deferred trigger re-fires at the start of each row repetition — matching ST3's `kRowDelayWithNoteDelay` behaviour.
|
||||
|
||||
---
|
||||
|
||||
## S $Ex00 — Pattern delay for $x row-repeats
|
||||
|
||||
**Plain.** Repeats the current row `$x` additional times (so `$x = 0` means no repeat and the row plays once; `$x = 3` means the row plays four times total). Notes do not retrigger across repetitions, but per-tick effects re-run and tick-0 events (fine slides, delayed notes) re-fire on each repetition.
|
||||
|
||||
**Compatibility.** ST3 `SEx` maps directly. ProTracker `EEx` also maps directly. Simultaneous SEx on multiple channels: ST3 uses the first SEx in **pan order** (L1..L8 then R1..R8); **Taud uses the first SEx in ascending channel-index order** for predictability. Converters that encounter ST3 songs relying on the pan-order rule should emit a warning.
|
||||
|
||||
Q retrigger counters do **not** reset between SEx repetitions.
|
||||
|
||||
**Implementation.** Row duration becomes `speed × (1 + arg_x)` ticks. Treat each repetition as a fresh row for tick-0 purposes (so fine slides, delayed notes, and the like re-trigger), but do not reset arpeggio, vibrato, or tremolo LFO positions, and do not decrement SBx's loop counter more than once across the whole delay block.
|
||||
|
||||
---
|
||||
|
||||
## S $Fx00 — Funk repeat with speed $x (non-destructive)
|
||||
|
||||
**Plain.** Produces a hiss-like progressive inversion of the sample loop, toggling individual bytes over time for a gritty textural effect. Setting `$x = 0` turns the effect off; higher `$x` advances the inversion faster.
|
||||
|
||||
**Compatibility.** ProTracker `EFx` is destructive — it XORs bytes directly in the sample data, permanently corrupting the sample. **Taud's implementation is non-destructive**: the XOR is applied at playback time through a per-instrument bit-mask, leaving source samples pristine. ST3 does not implement SFx at all and will parse Taud's S $Fx00 as a no-op; converters targeting ST3 should drop the effect. ProTracker `EFx` imports directly as Taud `S $Fx00`.
|
||||
|
||||
**Implementation.** Each instrument carries a `funk_mask` bit array, one bit per byte of the loop region, all zero at song start. A per-channel counter `funk_accumulator` and a per-channel `funk_write_pos` track progress.
|
||||
|
||||
```
|
||||
funk_table[16] = { 0, 5, 6, 7, 8, $A, $B, $D, $10, $13, $16, $1A, $20, $2B, $40, $80 }
|
||||
|
||||
on every tick (when S $Fx00 is active with x != 0):
|
||||
funk_accumulator += funk_table[x]
|
||||
while funk_accumulator >= $80:
|
||||
funk_accumulator -= $80
|
||||
bit = funk_mask[funk_write_pos]
|
||||
funk_mask[funk_write_pos] = bit XOR 1
|
||||
funk_write_pos = (funk_write_pos + 1) mod loop_length
|
||||
|
||||
on sample byte read during loop playback:
|
||||
raw_byte = sample_data[offset_in_loop]
|
||||
if funk_mask[offset_in_loop] == 1:
|
||||
output_byte = raw_byte XOR $FF
|
||||
else:
|
||||
output_byte = raw_byte
|
||||
```
|
||||
|
||||
`S $F000` clears `funk_accumulator` but leaves `funk_mask` intact (so the accumulated inversion pattern persists until the instrument is reset). On a fresh note or instrument-change event, Taud optionally resets `funk_mask` to all zero; this is a per-implementation choice, but the recommended default is **reset on instrument-change, preserve on pure note retrigger**.
|
||||
|
||||
---
|
||||
|
||||
# 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:
|
||||
|
||||
- **`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 (6-bit). Volume clamps at $3F.
|
||||
- **`2.$xx` — Volume slide down** by `$xx` per non-first tick (6-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 5 bits `$x` ($00..$1F) 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).
|
||||
|
||||
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: **`3.00` — is No-op**
|
||||
|
||||
---
|
||||
|
||||
# Panning column effects
|
||||
|
||||
The panning column uses the same 6-bit value + 2-bit selector layout:
|
||||
|
||||
- **`0.$xx` — Set pan** (6-bit, $00..$3F mapped onto the channel's 8-bit pan space; $01 = full left, $1F = centre-left, $20 = centre-right, $3F = full right). For 8-bit precision use `S $80xx` instead.
|
||||
- **`1.$xx` — Pan slide right** by `$xx` per non-first tick.
|
||||
- **`2.$xx` — Pan slide left** by `$xx` per non-first tick.
|
||||
- **`3.$Sx` — Fine pan slide** on tick 0 only, same direction-bit encoding as the volume column's selector 3.
|
||||
|
||||
NOTE: **`3.00` — is No-op**
|
||||
|
||||
---
|
||||
|
||||
# ProTracker to Taud conversion table
|
||||
|
||||
This table maps each PT effect to its Taud equivalent. Arguments follow PT's two-nibble form and expand to Taud's 16-bit form as shown.
|
||||
|
||||
| PT effect | Taud effect | Notes |
|
||||
|---|---|---|
|
||||
| `0 $xy` | `J $xxyy` | Arpeggio; nibble-repeat each byte. See the 12-TET → Taud table above for conversion losses |
|
||||
| `1 $xx` | `F $0xxx × $0015` | Portamento up; ST3 slide unit = 1/16 semitone |
|
||||
| `2 $xx` | `E $0xxx × $0015` | Portamento down |
|
||||
| `5 $xy` | `L $xy00` | Combined portamento + volume slide |
|
||||
| `6 $xy` | `K $xy00` | Combined vibrato + volume slide |
|
||||
| `7 $xy` | `R $xxyy` | Tremolo; nibble-repeat |
|
||||
| `8 $xx` | `S $80xx` or panning column `0.$xx` | Fine pan |
|
||||
| `9 $xx` | `O $xx00` | Sample offset |
|
||||
| `A $xy` | Volume column `1.$xy` | Volume slide |
|
||||
| `B $xx` | `B $00xx` | Position jump |
|
||||
| `C $xx` | Volume column `0.$xx` | Set volume |
|
||||
| `D $xx` | `C $00xx` (after BCD decode) | Pattern break |
|
||||
| `E $3x` | `S $1x00` | Glissando control |
|
||||
| `E $4x` | `S $3x00` | Vibrato waveform |
|
||||
| `E $5x` | `S $2x00` | Set fine-tune |
|
||||
| `E $6x` | `S $Bx00` | Pattern loop |
|
||||
| `E $7x` | `S $4x00` | Tremolo waveform |
|
||||
| `E $8x` | `S $80xx` or panning column `0.$xx` | Coarse pan (nibble-repeat) |
|
||||
| `E $9x` | `Q $0x00` | Retrigger |
|
||||
| `E $Cx` | `S $Cx00` | Note cut |
|
||||
| `E $Dx` | `S $Dx00` | Note delay |
|
||||
| `E $Ex` | `S $Ex00` | Pattern delay |
|
||||
| `E $Fx` | `S $Fx00` | Funk repeat |
|
||||
| `F $xx` (xx < $20) | `A $xx00` | Set speed |
|
||||
| `F $xx` (xx ≥ $20) | `T $(xx−$18)00` | Set tempo |
|
||||
|
||||
---
|
||||
|
||||
# ScreamTracker 3 conversion notes
|
||||
|
||||
These quirks of ST3 are worth preserving or flagging when importing S3M files into Taud:
|
||||
|
||||
**Shared memory across effects.** In ST3, a single memory slot backs D, E, F, I, J, K, L, Q, R, and S. A `$00` argument on any of these recalls whichever effect last wrote a non-zero argument. Taud narrows this to four cohorts (EF / G / HU / R) plus private slots. The converter must **eagerly resolve ST3 recalls** — walking the pattern in playback order, tracking the shared memory value, and emitting explicit Taud arguments wherever an ST3 recall crosses a cohort boundary. Otherwise a Taud player will either recall the wrong value or recall $0000.
|
||||
|
||||
**Cxx BCD encoding.** ST3 stores pattern-break row numbers as BCD on disk (`$10` means decimal 10). Taud uses binary. Decode on import; encode on export. Out-of-range BCD bytes (decimal 64 or higher) clamp to row 0.
|
||||
|
||||
**Tempo range.** ST3 accepts tempos $20..$FF (BPM 32..255); Taud accepts bytes $00..$FF (BPM 24..279). Imported ST3 tempos must be shifted down by $18; Taud tempos below $08 and above $E7 cannot be represented in ST3 and should clamp on export.
|
||||
|
||||
**SBx + SEx interaction.** ST3 miscounts loop iterations when pattern delay is active inside a pattern loop; Taud fixes this. Songs that depended on the bug for their intended playback will loop fewer times in Taud. Flag such songs on import.
|
||||
|
||||
**Simultaneous SEx priority.** ST3 uses pan order (L1..L8, R1..R8); Taud uses ascending channel-index order. Rare; flag on import if multiple channels carry SEx in the same row.
|
||||
|
||||
**Muted channels.** ST3 skips all effect processing on muted channels (no volume change, no tempo change, no jumps); Taud follows this rule for strict compatibility but recommends that new compositions avoid muting channels that carry global effects.
|
||||
|
||||
**Volume cap.** ST3's volume caps at $40; Taud's at $3F. Notes that reached $40 in ST3 (a rare edge) will play marginally quieter in Taud.
|
||||
|
||||
**Global volume scale.** ST3's 0..$40 maps to Taud's 0..$FF with a ×4 scale on import, truncated ÷4 on export.
|
||||
|
||||
**Linear pitch slides.** ST3's slide arithmetic is period-based (Amiga) or linear-table-indexed; Taud's is purely linear in 4096-TET units. ST3 songs in linear mode convert cleanly via the $0015-per-unit coarse and $0005-per-unit extra-fine constants; Amiga-mode slides change character slightly because the non-linearity of period math is not replicated.
|
||||
|
||||
**Default tempo byte.** Taud's default $65 equals 125 BPM under the $18 offset; this is not the same as ST3's `$7D` default, which maps to Taud `$65` after subtracting $18. Converters must remap on both import and export.
|
||||
|
||||
---
|
||||
|
||||
End of reference.
|
||||
@@ -10,6 +10,10 @@ const PLAYHEAD = 0
|
||||
|
||||
println("Playing "+fullFilePath.full)
|
||||
|
||||
audio.resetParams(PLAYHEAD)
|
||||
audio.purgeQueue(PLAYHEAD)
|
||||
audio.stop(PLAYHEAD)
|
||||
|
||||
taud.uploadTaudFile(fullFilePath.full, 0, PLAYHEAD)
|
||||
audio.setMasterVolume(PLAYHEAD, 255)
|
||||
audio.setMasterPan(PLAYHEAD, 128)
|
||||
|
||||
@@ -47,7 +47,7 @@ instBytes[4] = 0x00; instBytes[5] = 0x7D; // samplingRate = 32000
|
||||
instBytes[10] = 0x00; instBytes[11] = 0x01; // sampleLoopEnd = 256 (whole sample)
|
||||
instBytes[12] = 1; // loopMode = 1 (forward)
|
||||
instBytes[16] = 255; instBytes[17] = 0; // envelope: vol=255, hold
|
||||
audio.uploadInstrument(0, instBytes);
|
||||
audio.uploadInstrument(1, instBytes);
|
||||
|
||||
// -- 3. Piano-roll builder -----------------------------------------------------
|
||||
// Source convention: C1=0, C2=12, C3=24, C4=36 (i.e. C3=24, octave every 12).
|
||||
@@ -148,7 +148,7 @@ for (var p = 0; p < numPatterns; p++) {
|
||||
var off = r * 8;
|
||||
patBytes[off] = noteVal & 0xFF;
|
||||
patBytes[off + 1] = (noteVal >> 8) & 0xFF;
|
||||
patBytes[off + 2] = 0; // instrument 0
|
||||
patBytes[off + 2] = 1; // instrument 1
|
||||
patBytes[off + 3] = 63; // volume
|
||||
patBytes[off + 4] = 31; // pan (centre)
|
||||
}
|
||||
|
||||
366
s3m2taud.py
366
s3m2taud.py
@@ -5,18 +5,20 @@ Usage:
|
||||
python3 s3m2taud.py input.s3m output.taud [-v]
|
||||
|
||||
Limits:
|
||||
- Up to 15 S3M channels (excess disabled; hard error if pattern count
|
||||
× channel count > 256).
|
||||
- Up to 20 S3M channels (excess disabled; hard error if pattern count
|
||||
× channel count > 4095).
|
||||
- Sample bin is 770048 bytes; if all samples together exceed this, every
|
||||
sample is globally resampled down (with c2spd adjusted) so pitch is
|
||||
preserved.
|
||||
- AdLib instruments are skipped.
|
||||
- Effects mapped: D (vol-slide), E/F (pitch slide, rough approx),
|
||||
SC (note-cut), A (initial speed), T (initial BPM). Others dropped.
|
||||
|
||||
Pitch-slide approximation:
|
||||
Amiga-period mode: taud_arg ≈ s3m_arg * 2 (mid-register heuristic)
|
||||
Linear-slide mode: taud_arg = s3m_arg * 4 (exact)
|
||||
Effect support:
|
||||
Full A..Z dispatch per TAUD_NOTE_EFFECTS.md "ProTracker to Taud conversion
|
||||
table" and "ScreamTracker 3 conversion notes". ST3 shared-memory recalls
|
||||
(D/E/F/I/J/K/L/Q/R/S with $00 arg) are eagerly resolved per channel.
|
||||
Cxx is BCD-decoded. K/L are split into H $0000 / G $0000 + volume-column
|
||||
slide. M/N/X/P fold into volume / pan columns. W (global vol slide) and
|
||||
Y (panbrello) are dropped with a -v warning.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -93,6 +95,44 @@ NOTE_KEYOFF = 0x0000
|
||||
NOTE_CUT = 0xFFFE
|
||||
TAUD_C3 = 0x4000
|
||||
|
||||
# Taud effect opcode bytes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23)
|
||||
TOP_NONE = 0x00
|
||||
TOP_A = 0x0A # set tick speed
|
||||
TOP_B = 0x0B # jump to order
|
||||
TOP_C = 0x0C # break to row
|
||||
TOP_D = 0x0D # volume slide
|
||||
TOP_E = 0x0E # pitch slide down
|
||||
TOP_F = 0x0F # pitch slide up
|
||||
TOP_G = 0x10 # tone porta
|
||||
TOP_H = 0x11 # vibrato
|
||||
TOP_I = 0x12 # tremor
|
||||
TOP_J = 0x13 # microtonal arpeggio
|
||||
TOP_K = 0x14 # vibrato + vol slide (engine no-op; converter splits)
|
||||
TOP_L = 0x15 # tone porta + vol slide (engine no-op; converter splits)
|
||||
TOP_O = 0x18 # sample offset
|
||||
TOP_Q = 0x1A # retrigger
|
||||
TOP_R = 0x1B # tremolo
|
||||
TOP_S = 0x1C # sub-effects
|
||||
TOP_T = 0x1D # tempo set/slide
|
||||
TOP_U = 0x1E # fine vibrato
|
||||
TOP_V = 0x1F # global volume
|
||||
|
||||
# Volume / pan column selectors (2-bit field, packed into top of vol/pan byte).
|
||||
SEL_SET = 0 # 6-bit value: set vol / pan
|
||||
SEL_UP = 1 # 6-bit per-tick slide up / right
|
||||
SEL_DOWN = 2 # 6-bit per-tick slide down / left
|
||||
SEL_FINE = 3 # 1-bit dir + 5-bit magnitude, fired on tick 0
|
||||
|
||||
# 12-TET semitone → Taud J-arpeggio byte (high byte of pitch delta).
|
||||
# byte = round(semitone * 4096 / 12 / 256) = round(semitone * 4 / 3).
|
||||
J_SEMI_TABLE = [0x00, 0x01, 0x03, 0x04, 0x05, 0x07, 0x08, 0x09,
|
||||
0x0B, 0x0C, 0x0D, 0x0F, 0x10, 0x11, 0x13, 0x14]
|
||||
|
||||
# ST3's single shared memory slot backs these effects.
|
||||
ST3_SHARED_EFFECTS = frozenset({
|
||||
EFF_D, EFF_E, EFF_F, EFF_I, EFF_J, EFF_K, EFF_L, EFF_Q, EFF_R, EFF_S
|
||||
})
|
||||
|
||||
|
||||
# ── S3M parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -296,31 +336,202 @@ def encode_note(s3m_note: int) -> int:
|
||||
return max(1, min(0xFFFD, val))
|
||||
|
||||
|
||||
def encode_effect(cmd: int, arg: int, linear: bool) -> tuple:
|
||||
"""Return (taud_op, taud_arg16) or (0, 0) for no-op."""
|
||||
def _d_arg_to_col(arg: int):
|
||||
"""Convert an ST3 D-style two-nibble vol/pan slide arg into a column override.
|
||||
|
||||
Returns (selector, value) or None for no-op. Volume column treats
|
||||
selector 1 as up / 2 as down; pan column reuses 1 = right, 2 = left.
|
||||
"""
|
||||
if arg == 0:
|
||||
return None
|
||||
hi = (arg >> 4) & 0xF
|
||||
lo = arg & 0xF
|
||||
if hi == 0xF and lo > 0:
|
||||
return (SEL_FINE, lo & 0x1F) # fine slide down (dir bit 0)
|
||||
if lo == 0xF and hi > 0:
|
||||
return (SEL_FINE, (hi & 0x1F) | 0x20) # fine slide up (dir bit 1)
|
||||
if hi > 0 and lo == 0:
|
||||
return (SEL_UP, hi)
|
||||
if lo > 0 and hi == 0:
|
||||
return (SEL_DOWN, lo)
|
||||
# Both nibbles non-zero, neither $F → ambiguous; ST3 prefers up.
|
||||
return (SEL_UP, hi)
|
||||
|
||||
|
||||
def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
|
||||
"""Return (taud_op, taud_arg16, vol_override, pan_override).
|
||||
|
||||
vol/pan_override is None or (selector, value). The caller is responsible
|
||||
for resolving ST3 zero-arg recalls before this point — see
|
||||
resolve_st3_recalls().
|
||||
"""
|
||||
if cmd == 0:
|
||||
return (TOP_NONE, 0, None, None)
|
||||
|
||||
if cmd == EFF_A:
|
||||
if arg == 0:
|
||||
return (TOP_NONE, 0, None, None)
|
||||
return (TOP_A, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_B:
|
||||
return (TOP_B, arg & 0xFF, None, None)
|
||||
|
||||
if cmd == EFF_C:
|
||||
# ST3 stores break-row as BCD: $10 means decimal 10.
|
||||
bcd_row = ((arg >> 4) & 0xF) * 10 + (arg & 0xF)
|
||||
if bcd_row >= PATTERN_ROWS:
|
||||
bcd_row = 0
|
||||
return (TOP_C, bcd_row & 0xFF, None, None)
|
||||
|
||||
if cmd == EFF_D:
|
||||
# Volume slide: same nibble layout
|
||||
return (0x0A, arg & 0xFF)
|
||||
if cmd == EFF_E:
|
||||
# Porta down
|
||||
if linear:
|
||||
targ = min(arg * 4, 0xFFFF)
|
||||
else:
|
||||
targ = min(arg * 2, 0xFFFF)
|
||||
return (0x02, targ)
|
||||
if cmd == EFF_F:
|
||||
# Porta up
|
||||
if linear:
|
||||
targ = min(arg * 4, 0xFFFF)
|
||||
else:
|
||||
targ = min(arg * 2, 0xFFFF)
|
||||
return (0x01, targ)
|
||||
# D-style four-form arg passed through verbatim in the high byte.
|
||||
return (TOP_D, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd in (EFF_E, EFF_F):
|
||||
# ST3 slide unit = 1/16 semitone = $0015 Taud units (per spec PT table).
|
||||
op = TOP_E if cmd == EFF_E else TOP_F
|
||||
hi = (arg >> 4) & 0xF
|
||||
lo = arg & 0xF
|
||||
if hi == 0xF and lo > 0:
|
||||
return (op, 0xF000 | ((lo * 0x15) & 0xFFF), None, None)
|
||||
if hi == 0xE and lo > 0:
|
||||
return (op, 0xF000 | ((lo * 0x05) & 0xFFF), None, None)
|
||||
return (op, (arg * 0x15) & 0xFFFF, None, None)
|
||||
|
||||
if cmd == EFF_G:
|
||||
return (TOP_G, (arg * 0x15) & 0xFFFF, None, None)
|
||||
|
||||
if cmd in (EFF_H, EFF_I, EFF_R, EFF_U):
|
||||
op = {EFF_H: TOP_H, EFF_I: TOP_I, EFF_R: TOP_R, EFF_U: TOP_U}[cmd]
|
||||
hi = (arg >> 4) & 0xF
|
||||
lo = arg & 0xF
|
||||
return (op, ((hi * 0x11) << 8) | (lo * 0x11), None, None)
|
||||
|
||||
if cmd == EFF_J:
|
||||
hi_semi = (arg >> 4) & 0xF
|
||||
lo_semi = arg & 0xF
|
||||
return (TOP_J, (J_SEMI_TABLE[hi_semi] << 8) | J_SEMI_TABLE[lo_semi],
|
||||
None, None)
|
||||
|
||||
if cmd == EFF_K:
|
||||
# K = vibrato continuation + vol slide; engine treats K as no-op.
|
||||
# Split into: H $0000 (recall vibrato from HU memory) + vol-col slide.
|
||||
return (TOP_H, 0x0000, _d_arg_to_col(arg), None)
|
||||
|
||||
if cmd == EFF_L:
|
||||
# L = tone-porta continuation + vol slide; split similarly.
|
||||
return (TOP_G, 0x0000, _d_arg_to_col(arg), None)
|
||||
|
||||
if cmd == EFF_M:
|
||||
return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None)
|
||||
|
||||
if cmd == EFF_N:
|
||||
return (TOP_NONE, 0, _d_arg_to_col(arg), None)
|
||||
|
||||
if cmd == EFF_O:
|
||||
return (TOP_O, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_P:
|
||||
return (TOP_NONE, 0, None, _d_arg_to_col(arg))
|
||||
|
||||
if cmd == EFF_Q:
|
||||
return (TOP_Q, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_S:
|
||||
sub = (arg >> 4) & 0xF
|
||||
val = arg & 0xF
|
||||
if sub == 0xC: # SC - note cut
|
||||
return (0xEC, val)
|
||||
return (0x00, 0x0000)
|
||||
if sub in (0x1, 0x2, 0x3, 0x4, 0xB, 0xC, 0xD, 0xE, 0xF):
|
||||
return (TOP_S, (sub << 12) | (val << 8), None, None)
|
||||
if sub == 0x8:
|
||||
# Coarse pan: nibble-repeat into Taud's S $80xx full-8-bit pan.
|
||||
return (TOP_S, 0x8000 | (val * 0x11), None, None)
|
||||
# S0/S5/S6/S7/S9/SA: filter, NNA, sound-control, stereo — drop silently.
|
||||
return (TOP_NONE, 0, None, None)
|
||||
|
||||
if cmd == EFF_T:
|
||||
if arg >= 0x20:
|
||||
return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None)
|
||||
# OpenMPT slide forms: $0y down per tick, $1y up per tick.
|
||||
return (TOP_T, arg & 0xFF, None, None)
|
||||
|
||||
if cmd == EFF_V:
|
||||
return (TOP_V, (min(arg * 4, 0xFF) & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_W:
|
||||
vprint(f" dropped W{arg:02X} (global vol slide) at ch{ch} row{row}")
|
||||
return (TOP_NONE, 0, None, None)
|
||||
|
||||
if cmd == EFF_X:
|
||||
return (TOP_NONE, 0, None, (SEL_SET, min(arg >> 2, 0x3F)))
|
||||
|
||||
if cmd == EFF_Y:
|
||||
vprint(f" dropped Y{arg:02X} (panbrello) at ch{ch} row{row}")
|
||||
return (TOP_NONE, 0, None, None)
|
||||
|
||||
if cmd == EFF_Z:
|
||||
return (TOP_NONE, 0, None, None)
|
||||
|
||||
return (TOP_NONE, 0, None, None)
|
||||
|
||||
|
||||
def resolve_st3_recalls(patterns: list, order_list: list, num_channels: int) -> None:
|
||||
"""In-place: replace ST3 zero-arg recalls with the last non-zero arg.
|
||||
|
||||
ST3 backs D/E/F/I/J/K/L/Q/R/S with a single per-channel memory slot.
|
||||
Taud's narrower cohort model can't recover this, so we eagerly resolve
|
||||
by walking patterns in order-list order and rewriting recall args.
|
||||
|
||||
Limitation: patterns reused across multiple order entries are mutated
|
||||
once (with the memory state from their first visit); subsequent visits
|
||||
may differ from ST3 if cross-pattern memory state changed in between.
|
||||
"""
|
||||
last_arg = [0] * num_channels
|
||||
for order in order_list:
|
||||
if order >= S3M_ORDER_END:
|
||||
break
|
||||
if order >= len(patterns):
|
||||
continue
|
||||
grid = patterns[order]
|
||||
for r in range(PATTERN_ROWS):
|
||||
for ch in range(num_channels):
|
||||
if ch >= len(grid):
|
||||
continue
|
||||
row = grid[ch][r]
|
||||
if row.effect in ST3_SHARED_EFFECTS:
|
||||
if row.effect_arg == 0:
|
||||
row.effect_arg = last_arg[ch]
|
||||
else:
|
||||
last_arg[ch] = row.effect_arg
|
||||
|
||||
|
||||
def warn_st3_quirks(patterns: list, order_list: list, num_channels: int) -> None:
|
||||
"""Emit -v warnings for ST3 quirks Taud handles differently."""
|
||||
seen_pats = set()
|
||||
for order in order_list:
|
||||
if order >= S3M_ORDER_END:
|
||||
break
|
||||
if order >= len(patterns) or order in seen_pats:
|
||||
continue
|
||||
seen_pats.add(order)
|
||||
grid = patterns[order]
|
||||
for ch in range(min(num_channels, len(grid))):
|
||||
saw_sbx = saw_sex = False
|
||||
for r in range(PATTERN_ROWS):
|
||||
row = grid[ch][r]
|
||||
if row.effect == EFF_S:
|
||||
sub = (row.effect_arg >> 4) & 0xF
|
||||
if sub == 0xB: saw_sbx = True
|
||||
elif sub == 0xE: saw_sex = True
|
||||
if saw_sbx and saw_sex:
|
||||
vprint(f" warning: pattern {order} ch{ch} mixes SBx and SEx "
|
||||
f"(Taud fixes the ST3 loop-counter bug; loop count may differ)")
|
||||
for r in range(PATTERN_ROWS):
|
||||
sex_channels = [ch for ch in range(min(num_channels, len(grid)))
|
||||
if grid[ch][r].effect == EFF_S
|
||||
and ((grid[ch][r].effect_arg >> 4) & 0xF) == 0xE]
|
||||
if len(sex_channels) > 1:
|
||||
vprint(f" warning: pattern {order} row {r} SEx on multiple "
|
||||
f"channels {sex_channels} (Taud uses ascending channel order)")
|
||||
|
||||
|
||||
# ── Taud builders ────────────────────────────────────────────────────────────
|
||||
@@ -384,6 +595,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
|
||||
# Build instrument bin (256 × 64 bytes)
|
||||
inst_bin = bytearray(INSTBIN_SIZE)
|
||||
for i, inst in enumerate(instruments):
|
||||
taud_idx = i + 1
|
||||
if i >= 256:
|
||||
break
|
||||
if inst is None or inst.itype != S3M_TYPE_PCM:
|
||||
@@ -399,7 +611,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
|
||||
loop_mode = 1 if (inst.flags & 1) else 0
|
||||
flags_byte = (ptr_hi << 4) | (loop_mode & 0x3) # hhhh 00pp
|
||||
|
||||
base = i * 64
|
||||
base = taud_idx * 64
|
||||
struct.pack_into('<H', inst_bin, base + 0, ptr_lo)
|
||||
struct.pack_into('<H', inst_bin, base + 2, s_len)
|
||||
struct.pack_into('<H', inst_bin, base + 4, c2spd)
|
||||
@@ -413,7 +625,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
|
||||
inst_bin[base + 17] = 0 # offset minifloat = 0 → hold
|
||||
|
||||
|
||||
vprint(f" instrument '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
|
||||
vprint(f" instrument[{base // 64}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
|
||||
if inst.c2spd > 65535:
|
||||
vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')")
|
||||
|
||||
@@ -433,24 +645,66 @@ def _default_channel_pan(ch_setting: int) -> int:
|
||||
|
||||
|
||||
def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
|
||||
linear_slides: bool) -> bytes:
|
||||
"""Build a 512-byte Taud pattern for one S3M channel."""
|
||||
linear_slides: bool, inst_vols: dict = None) -> bytes:
|
||||
"""Build a 512-byte Taud pattern for one S3M channel.
|
||||
|
||||
Volume column: explicit S3M cell vol → SEL_SET; when a note triggers
|
||||
with no explicit vol, emit SEL_SET using the instrument's default volume
|
||||
(looked up from inst_vols, a 1-based inst index → 0..63 volume dict).
|
||||
M/N/K/L overrides apply only when the cell has no explicit vol and no
|
||||
note trigger. Otherwise SEL_FINE/0 (no-op).
|
||||
Pan column: row 0 emits SEL_SET = default_pan to position the channel;
|
||||
other rows default to SEL_FINE/0 unless an X/P/etc effect overrides.
|
||||
"""
|
||||
if inst_vols is None:
|
||||
inst_vols = {}
|
||||
out = bytearray(PATTERN_BYTES)
|
||||
rows = s3m_grid[ch_idx] if ch_idx < len(s3m_grid) else [S3MRow()] * PATTERN_ROWS
|
||||
last_inst = 0 # 1-based; tracks which instrument is loaded on this channel
|
||||
for r, row in enumerate(rows[:PATTERN_ROWS]):
|
||||
note = encode_note(row.note)
|
||||
inst = max(0, row.inst - 1) # S3M 1-based → Taud 0-based
|
||||
vol = min(row.vol, 63) if row.vol >= 0 else 63
|
||||
pan = default_pan
|
||||
op, arg = encode_effect(row.effect, row.effect_arg, linear_slides)
|
||||
if row.effect != 0 and op == 0:
|
||||
eff_name = chr(ord('A') + row.effect - 1) if 1 <= row.effect <= 26 else '?'
|
||||
vprint(f" dropped effect {eff_name}{row.effect_arg:02X} at ch{ch_idx} row{r}")
|
||||
note = encode_note(row.note)
|
||||
inst = row.inst # S3M 1-based → Taud 1-based
|
||||
|
||||
if row.inst > 0:
|
||||
last_inst = row.inst
|
||||
|
||||
op, arg, vol_override, pan_override = encode_effect(
|
||||
row.effect, row.effect_arg, ch_idx, r)
|
||||
|
||||
# ── Volume column ──
|
||||
note_triggers = (row.note not in (S3M_NOTE_EMPTY, S3M_NOTE_OFF))
|
||||
if row.vol >= 0:
|
||||
vol_sel, vol_value = SEL_SET, min(row.vol, 0x3F)
|
||||
if vol_override is not None and vol_override[0] != SEL_SET:
|
||||
vprint(f" ch{ch_idx} row{r}: dropped vol slide "
|
||||
f"(cell already carries explicit volume)")
|
||||
elif note_triggers and last_inst > 0:
|
||||
# Note trigger with no explicit vol: use instrument default volume
|
||||
# so prior channel-vol state doesn't bleed through.
|
||||
vol_sel = SEL_SET
|
||||
vol_value = inst_vols.get(last_inst, 0x3F)
|
||||
elif vol_override is not None:
|
||||
vol_sel, vol_value = vol_override
|
||||
else:
|
||||
vol_sel, vol_value = SEL_FINE, 0 # no-op fine slide
|
||||
|
||||
# ── Pan column ──
|
||||
if pan_override is not None:
|
||||
pan_sel, pan_value = pan_override
|
||||
elif r == 0:
|
||||
# Position channel to its default pan once per pattern (row 0).
|
||||
pan_sel, pan_value = SEL_SET, default_pan & 0x3F
|
||||
else:
|
||||
pan_sel, pan_value = SEL_FINE, 0
|
||||
|
||||
vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6)
|
||||
pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6)
|
||||
|
||||
base = r * 8
|
||||
struct.pack_into('<H', out, base + 0, note)
|
||||
out[base + 2] = inst & 0xFF
|
||||
out[base + 3] = vol & 0x3F
|
||||
out[base + 4] = pan & 0x3F
|
||||
out[base + 3] = vol_byte
|
||||
out[base + 4] = pan_byte
|
||||
out[base + 5] = op & 0xFF
|
||||
struct.pack_into('<H', out, base + 6, arg & 0xFFFF)
|
||||
return bytes(out)
|
||||
@@ -500,20 +754,25 @@ def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int,
|
||||
sheet[c*CUE_SIZE : c*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0)
|
||||
|
||||
cue_idx = 0
|
||||
last_active = -1
|
||||
for order in order_list:
|
||||
if order == S3M_ORDER_END or cue_idx >= NUM_CUES:
|
||||
break
|
||||
if order == S3M_ORDER_SKIP:
|
||||
cue_idx += 1
|
||||
continue
|
||||
orig = [order * num_channels + v for v in range(num_channels)]
|
||||
pats = [pat_remap[p] if pat_remap else p for p in orig]
|
||||
sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue(pats, 0)
|
||||
last_active = cue_idx
|
||||
cue_idx += 1
|
||||
|
||||
# Halt at end
|
||||
if cue_idx < NUM_CUES:
|
||||
sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0x01)
|
||||
# Halt on the last active cue (instruction byte at offset 30), so the
|
||||
# engine stops immediately after that pattern completes with no silent gap.
|
||||
if last_active >= 0:
|
||||
sheet[last_active * CUE_SIZE + 30] = 0x01
|
||||
elif cue_idx < NUM_CUES:
|
||||
# Edge case: no active cues at all — halt at cue 0.
|
||||
sheet[30] = 0x01
|
||||
|
||||
return bytes(sheet)
|
||||
|
||||
@@ -555,6 +814,13 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
|
||||
vprint(f" channels: {C}, s3m patterns: {P}, taud patterns: {P*C}")
|
||||
|
||||
# Resolve ST3 shared-memory recalls (D/E/F/I/J/K/L/Q/R/S with $00 arg)
|
||||
# before any per-row encoding, so cohort-aware Taud effects see explicit
|
||||
# arguments. Mutates patterns in place.
|
||||
vprint(" resolving ST3 shared-memory recalls…")
|
||||
resolve_st3_recalls(patterns, h.order_list, 32)
|
||||
warn_st3_quirks(patterns, h.order_list, 32)
|
||||
|
||||
# Build sample+instrument bin
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw, _offsets = build_sample_inst_bin(instruments)
|
||||
@@ -590,11 +856,17 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
# Pattern bin: for each s3m pattern, for each active channel, 512 bytes
|
||||
vprint(" building pattern bin…")
|
||||
default_pans = [_default_channel_pan(h.channel_settings[ch]) for ch in active_channels]
|
||||
# 1-based inst index → default volume (0..63) for note-trigger vol injection.
|
||||
inst_vols = {
|
||||
i + 1: min(inst.volume, 0x3F)
|
||||
for i, inst in enumerate(instruments)
|
||||
if inst is not None and inst.itype == S3M_TYPE_PCM
|
||||
}
|
||||
pat_bin = bytearray()
|
||||
for pi in range(P):
|
||||
grid = patterns[pi]
|
||||
for vi, ch in enumerate(active_channels):
|
||||
pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides)
|
||||
pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides, inst_vols)
|
||||
assert len(pat_bin) == num_taud_pats * PATTERN_BYTES
|
||||
|
||||
# Deduplicate identical patterns
|
||||
|
||||
@@ -1986,7 +1986,7 @@ Synchronisation between playheads are not guaranteed. Do not play music in multi
|
||||
Memory Space
|
||||
|
||||
0..770047 RW: Sample bin (752k)
|
||||
770048..786431 RW: Instrument bin (256 instruments, 64 bytes each; 16k)
|
||||
770048..786431 RW: Instrument bin (256 instruments, 64 bytes each; instrument 0 does nothing; 16k)
|
||||
786432..851967 RW: Play data 1 (currently exposed bank; 64k)
|
||||
851968..917503 RW: Play data 2 (currently exposed bank; 64k)
|
||||
917504..983039 RW: TAD Input Buffer (64k)
|
||||
@@ -2022,6 +2022,8 @@ note 0xFFFF: no-op
|
||||
note 0xFFFE: note cut
|
||||
note 0x0000: key-off
|
||||
|
||||
inst 0: no instrument change
|
||||
|
||||
|
||||
Sound Adapter MMIO
|
||||
|
||||
@@ -2167,6 +2169,10 @@ Table of 3.5 Minifloat values (CSV)
|
||||
11111,0.96875,1.96875,3.9375,7.875,15.75,31.5,63,126
|
||||
LSB
|
||||
|
||||
## Tracker Note Effects
|
||||
|
||||
Tracker Note Effects has been moved to `TAUD_NOTE_EFFECTS.md`
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Taud serialisation format
|
||||
@@ -2199,7 +2205,7 @@ Rows of 16 bytes:
|
||||
Uint32 Song offset
|
||||
Uint8 Number of voices
|
||||
Uint16 Number of patterns (0 is invalid. pattern bin length = numPats * 8 bytes)
|
||||
Uint8 Initial BPM (bias of -24. 0x00=24, 0xFF=280)
|
||||
Uint8 Initial BPM (bias of -24. 0x00=24, 0xFF=279)
|
||||
Uint8 Initial Tickrate(0 is invalid)
|
||||
Byte[7] Reserved for future versions
|
||||
|
||||
@@ -2207,6 +2213,72 @@ Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
S3M (ScreamTracker 3) to Taud conversion notes
|
||||
(Implemented in s3m2taud.py)
|
||||
|
||||
## Instrument indexing
|
||||
|
||||
S3M instrument numbers are 1-based on disk and in pattern cells. Taud's cell instrument byte preserves this: 0 means "no instrument change, reuse whatever was last loaded on this channel"; 1..255 select an instrument slot. The converter passes the raw S3M instrument byte through unchanged (no subtract-1). The instrument bin is written at base = instrument_index * 64, with slot 0 left as an empty/silent entry.
|
||||
|
||||
## Effect encoding
|
||||
|
||||
Taud opcodes are base-36 digit values: digits 0..9 map to bytes 0x00..0x09; letters A..Z map to bytes 0x0A..0x23. Effects are encoded into a 1-byte opcode plus a 2-byte argument.
|
||||
|
||||
## ST3 shared-memory recall (pre-pass)
|
||||
|
||||
ST3 backs effects D, E, F, I, J, K, L, Q, R, and S with a single per-channel memory slot. A $00 argument on any of these recalls the last non-zero argument. Taud uses narrower per-cohort memory, so the converter walks patterns in order-list order (per channel) and replaces every $00-arg recall with the current slot value before encoding. Patterns reused by multiple order entries are mutated once on their first visit; later visits may diverge from the ST3 original if cross-pattern memory state changed, but this is acceptable for typical usage.
|
||||
|
||||
## Cxx BCD decode
|
||||
|
||||
ST3 stores pattern-break row numbers as BCD on disk ($10 means decimal row 10, not hex row 16). The converter decodes: row = (byte >> 4) * 10 + (byte & 0xF). Values that decode to 64 or above clamp to row 0.
|
||||
|
||||
## Pitch slide unit
|
||||
|
||||
ST3's coarse slide unit is 1/16 of a semitone. One semitone in Taud's 4096-TET grid is 4096/12 ≈ 341.33 units. One 1/16 semitone ≈ 21.33 units ≈ $0015. All E/F/G coarse arguments are therefore multiplied by $0015. Fine slide forms ($Fx, $Ex) are packed into Taud's $F0xx fine form after the same per-step scale.
|
||||
|
||||
## J arpeggio (12-TET to 4096-TET)
|
||||
|
||||
ST3 Jxy nibbles are 12-TET semitone offsets (0..15). Taud's J argument uses the high byte of a 16-bit pitch delta; one byte = 256 units ≈ 0.75 semitones.
|
||||
|
||||
Conversion: byte = round(semitones * 4 / 3).
|
||||
|
||||
The full lookup table:
|
||||
|
||||
Semitones 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
||||
Taud byte $00 $01 $03 $04 $05 $07 $08 $09 $0B $0C $0D $0F $10 $11 $13 $14
|
||||
|
||||
## K and L effects
|
||||
|
||||
The engine treats K and L as no-ops. The converter splits each into two parts:
|
||||
K → effect column H $0000 (recall vibrato from HU memory) plus a volume-column slide derived from K's argument
|
||||
L → effect column G $0000 plus the same volume-column slide. If the S3M cell already carries an explicit volume-column byte, the slide half is dropped with a -v warning.
|
||||
|
||||
## M, N (channel volume), X, P (pan) folding
|
||||
|
||||
M (set channel volume) and N (channel-vol slide) fold into the volume column. X (set pan) and P (pan slide) fold into the pan column. These effects consume no space in the effect slot. W (global vol slide) and Y (panbrello) are dropped with a -v warning.
|
||||
|
||||
## Volume column defaults
|
||||
|
||||
When a note trigger is present in a cell with no explicit S3M volume byte, the converter emits SEL_SET (selector 0) with the instrument's default volume. This prevents the channel's prior volume state from persisting into a fresh note. Cells with no note trigger and no explicit volume emit SEL_FINE value 0 (fine slide of 0 = no-op), which leaves channel volume unchanged.
|
||||
|
||||
## Pan column defaults
|
||||
|
||||
Row 0 of every pattern emits SEL_SET with the channel's default pan (derived from the S3M channel-setting byte: channels 0-7 → left ($10), channels 8-15 → right ($2F), otherwise centre ($1F)). All other rows emit SEL_FINE value 0 (no-op) unless an X, P, or S$8x effect overrides.
|
||||
|
||||
## Cue sheet halt placement
|
||||
|
||||
The halt instruction (byte value 0x01 at cue offset 30) is placed on the last active cue entry, not in a separate empty cue appended after it. This ensures playback stops immediately after the last pattern row completes, with no silent 64-row gap.
|
||||
|
||||
## Tempo mapping
|
||||
|
||||
S3M BPM is stored as a raw decimal value. Taud's initial BPM byte uses a bias of -24 (byte 0x00 = 24 BPM, 0xFF = 279 BPM). Conversion: taud_byte = bpm - 24. The converter also scans row 0 of the first pattern in the order list for A (set speed) and T (set tempo) effects and uses those values in preference to the S3M header defaults.
|
||||
|
||||
## Global volume
|
||||
|
||||
ST3 global volume is 0..$40; Taud's is 0..$FF. Import scale: Taud_vol = ST3_vol × 4 (clamped to $FF).
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
RomBank / RamBank
|
||||
|
||||
Endianness: Little
|
||||
|
||||
@@ -125,7 +125,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
|
||||
internal val sampleBin = UnsafeHelper.allocate(770048L, this)
|
||||
internal val instruments = Array(256) { TaudInst() }
|
||||
internal val instruments = Array(256) { TaudInst(it) }
|
||||
internal val playdata = Array(4096) { Array(64) { TaudPlayData(0xFFFF, 0, 0, 0, 32, 0, 0, 0) } }
|
||||
internal val playheads: Array<Playhead>
|
||||
internal val cueSheet = Array(1024) { PlayCue() }
|
||||
@@ -1080,14 +1080,80 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
//=========================================================================
|
||||
// Tracker Engine
|
||||
//
|
||||
// Effect codes (non-canonical MVP; spec is silent on values):
|
||||
// 0x00: no effect
|
||||
// 0x01 arg: pitch slide up, arg = 4096-TET units per tick
|
||||
// 0x02 arg: pitch slide down, arg = 4096-TET units per tick
|
||||
// 0x0A arg: volume slide, high nibble = up/tick, low nibble = down/tick
|
||||
// 0xEC arg: note cut at tick (arg & 0xFF)
|
||||
// Effect opcodes follow base-36 digit values (see TAUD_NOTE_EFFECTS.md):
|
||||
// 0x00 : no effect
|
||||
// 0x0A..0x23 : letters A..Z (A=0x0A speed, B=0x0B order jump,
|
||||
// C=0x0C pattern break, D=0x0D vol slide, E=0x0E pitch
|
||||
// down, F=0x0F pitch up, G=0x10 tone porta,
|
||||
// H=0x11 vibrato, I=0x12 tremor, J=0x13 arpeggio,
|
||||
// K=0x14 K, L=0x15 L, O=0x18 sample offset,
|
||||
// Q=0x1A retrig, R=0x1B tremolo, S=0x1C subcommands,
|
||||
// T=0x1D tempo, U=0x1E fine vibrato, V=0x1F global vol).
|
||||
// K (0x14) and L (0x15) are intentionally no-op in the engine — the
|
||||
// converter is required to split them into a recall-only H/G plus a
|
||||
// volume-column slide cell.
|
||||
//=========================================================================
|
||||
|
||||
// 64-entry signed sine table (OpenMPT-style). See TAUD_NOTE_EFFECTS.md §H.
|
||||
private val MOD_SIN_TABLE = intArrayOf(
|
||||
0x00, 0x0C, 0x19, 0x25, 0x31, 0x3C, 0x47, 0x51,
|
||||
0x5A, 0x62, 0x6A, 0x70, 0x75, 0x7A, 0x7D, 0x7E,
|
||||
0x7F, 0x7E, 0x7D, 0x7A, 0x75, 0x70, 0x6A, 0x62,
|
||||
0x5A, 0x51, 0x47, 0x3C, 0x31, 0x25, 0x19, 0x0C,
|
||||
0x00, -0x0C, -0x19, -0x25, -0x31, -0x3C, -0x47, -0x51,
|
||||
-0x5A, -0x62, -0x6A, -0x70, -0x75, -0x7A, -0x7D, -0x7E,
|
||||
-0x7F, -0x7E, -0x7D, -0x7A, -0x75, -0x70, -0x6A, -0x62,
|
||||
-0x5A, -0x51, -0x47, -0x3C, -0x31, -0x25, -0x19, -0x0C
|
||||
)
|
||||
|
||||
// Funk repeat advance table (S $Fx00). See TAUD_NOTE_EFFECTS.md §S$Fx.
|
||||
private val FUNK_TABLE = intArrayOf(
|
||||
0, 5, 6, 7, 8, 0xA, 0xB, 0xD, 0x10, 0x13, 0x16, 0x1A, 0x20, 0x2B, 0x40, 0x80
|
||||
)
|
||||
|
||||
// ST3-style fine-tune Hz reference offsets in 4096-TET units (S $2x00).
|
||||
private val FINETUNE_OFFSET = intArrayOf(
|
||||
-0x0154, -0x0132, -0x0111, -0x00E4, -0x00B8, -0x008B, -0x005D, -0x003B,
|
||||
0x0000, 0x0023, 0x0046, 0x0074, 0x0098, 0x00C8, 0x00F9, 0x0110
|
||||
)
|
||||
|
||||
// LFO sample for vibrato/tremolo waveforms; pos is the 8-bit phase accumulator.
|
||||
// See TAUD_NOTE_EFFECTS.md §S$3x for shape semantics.
|
||||
private fun lfoSample(pos: Int, wave: Int): Int {
|
||||
val idx = (pos ushr 2) and 0x3F
|
||||
return when (wave and 3) {
|
||||
0 -> MOD_SIN_TABLE[idx] // sine
|
||||
1 -> 0x7F - (idx shl 2) // ramp down
|
||||
2 -> if (idx < 32) 0x7F else -0x7F // square
|
||||
else -> ((Math.random() * 256).toInt() and 0xFF) - 0x80 // random
|
||||
}
|
||||
}
|
||||
|
||||
// Effect opcode constants (base-36 digit values).
|
||||
// Letters A..Z map to 0x0A..0x23 (digit value 10..35).
|
||||
private object EffectOp {
|
||||
const val OP_NONE = 0x00
|
||||
const val OP_A = 0x0A
|
||||
const val OP_B = 0x0B
|
||||
const val OP_C = 0x0C
|
||||
const val OP_D = 0x0D
|
||||
const val OP_E = 0x0E
|
||||
const val OP_F = 0x0F
|
||||
const val OP_G = 0x10
|
||||
const val OP_H = 0x11
|
||||
const val OP_I = 0x12
|
||||
const val OP_J = 0x13
|
||||
const val OP_K = 0x14
|
||||
const val OP_L = 0x15
|
||||
const val OP_O = 0x18
|
||||
const val OP_Q = 0x1A
|
||||
const val OP_R = 0x1B
|
||||
const val OP_S = 0x1C
|
||||
const val OP_T = 0x1D
|
||||
const val OP_U = 0x1E
|
||||
const val OP_V = 0x1F
|
||||
}
|
||||
|
||||
private fun computePlaybackRate(inst: TaudInst, noteVal: Int): Double =
|
||||
inst.samplingRate.toDouble() / SAMPLING_RATE * 2.0.pow((noteVal - TRACKER_C3) / 4096.0)
|
||||
|
||||
@@ -1114,6 +1180,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
|
||||
private fun fetchTrackerSample(voice: Voice, inst: TaudInst): Double {
|
||||
if (inst.index == 0) return 0.0
|
||||
|
||||
val basePtr = inst.samplePtr
|
||||
val sampleLen = inst.sampleLength.coerceAtLeast(1)
|
||||
val loopStart = inst.sampleLoopStart.toDouble()
|
||||
@@ -1123,8 +1191,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
|
||||
val i1 = (i0 + 1).coerceAtMost(sampleLen - 1)
|
||||
val frac = voice.samplePos - i0.toDouble()
|
||||
val s0 = (sampleBin[(basePtr + i0).coerceAtMost(binMax).toLong()].toUint() - 128) / 128.0
|
||||
val s1 = (sampleBin[(basePtr + i1).coerceAtMost(binMax).toLong()].toUint() - 128) / 128.0
|
||||
var b0 = sampleBin[(basePtr + i0).coerceAtMost(binMax).toLong()].toUint()
|
||||
var b1 = sampleBin[(basePtr + i1).coerceAtMost(binMax).toLong()].toUint()
|
||||
// S$Fx funk repeat: XOR the high bit of bytes whose loop-relative index
|
||||
// is set in funkMask. Only meaningful when the sample has a loop region.
|
||||
if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) {
|
||||
val ls = inst.sampleLoopStart
|
||||
if (i0 in ls until inst.sampleLoopEnd && inst.funkBit(i0 - ls)) b0 = b0 xor 0x80
|
||||
if (i1 in ls until inst.sampleLoopEnd && inst.funkBit(i1 - ls)) b1 = b1 xor 0x80
|
||||
}
|
||||
val s0 = (b0 - 128) / 128.0
|
||||
val s1 = (b1 - 128) / 128.0
|
||||
val sample = s0 + (s1 - s0) * frac
|
||||
|
||||
if (voice.forward) {
|
||||
@@ -1142,8 +1219,72 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
return sample
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a fresh note on [voice]: load the instrument, reset sample position, kick off the envelope.
|
||||
* Pulled out so S$Dx (note delay) can defer the same logic to a later tick.
|
||||
*/
|
||||
private fun triggerNote(voice: Voice, noteVal: Int, instId: Int, volOverride: Int) {
|
||||
if (instId != 0) voice.instrumentId = instId
|
||||
val inst = instruments[voice.instrumentId]
|
||||
voice.tonePortaTarget = -1 // fresh note trigger cancels any running porta
|
||||
voice.samplePos = inst.samplePlayStart.toDouble()
|
||||
voice.forward = true
|
||||
voice.active = true
|
||||
voice.envIndex = 0
|
||||
voice.envTimeSec = 0.0
|
||||
voice.envVolume = inst.envelopes[0].volume / 255.0
|
||||
voice.noteVal = noteVal
|
||||
voice.basePitch = noteVal
|
||||
voice.playbackRate = computePlaybackRate(inst, noteVal)
|
||||
if (volOverride >= 0) {
|
||||
voice.channelVolume = volOverride.coerceIn(0, 0x3F)
|
||||
}
|
||||
voice.rowVolume = voice.channelVolume
|
||||
voice.noteWasCut = false
|
||||
// Vibrato/tremolo retrigger: reset LFO position when waveform requests it.
|
||||
if (voice.vibratoRetrig) voice.vibratoLfoPos = 0
|
||||
if (voice.tremoloRetrig) voice.tremoloLfoPos = 0
|
||||
}
|
||||
|
||||
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.
|
||||
when (sel) {
|
||||
0 -> { voice.channelVolume = value.coerceIn(0, 0x3F); voice.rowVolume = voice.channelVolume }
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPanColumn(voice: Voice, value: Int, sel: Int) {
|
||||
when (sel) {
|
||||
0 -> { voice.channelPan = (value shl 2) or (value ushr 4); voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63) }
|
||||
1 -> voice.panColSlideRight = value
|
||||
2 -> voice.panColSlideLeft = value
|
||||
3 -> {
|
||||
if (value == 0) return
|
||||
|
||||
val mag = value and 0x1F
|
||||
voice.channelPan = if ((value and 0x20) != 0) (voice.channelPan + mag).coerceAtMost(0xFF)
|
||||
else (voice.channelPan - mag).coerceAtLeast(0)
|
||||
voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyTrackerRow(ts: TrackerState, playhead: Playhead) {
|
||||
val cue = cueSheet[ts.cuePos]
|
||||
// Reset row-scope state before scanning channels.
|
||||
if (!ts.patternDelayActive) ts.sexWinningChannel = -1
|
||||
|
||||
for (vi in 0..19) {
|
||||
val patNum = cue.patterns[vi]
|
||||
if (patNum == 0xFFF) continue
|
||||
@@ -1151,56 +1292,383 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val row = playdata[patIdx][ts.rowIndex]
|
||||
val voice = ts.voices[vi]
|
||||
|
||||
voice.rowVolume = row.volume
|
||||
voice.rowPan = row.pan
|
||||
// Reset per-row transient state.
|
||||
voice.cutAtTick = -1
|
||||
voice.pitchSlideAmount = 0.0
|
||||
voice.volSlidePerTick = 0
|
||||
|
||||
when (row.effect) {
|
||||
0x01 -> voice.pitchSlideAmount = row.effectArg.toDouble()
|
||||
0x02 -> voice.pitchSlideAmount = -row.effectArg.toDouble()
|
||||
0x0A -> { val a = row.effectArg and 0xFF; voice.volSlidePerTick = ((a ushr 4) and 0xF) - (a and 0xF) }
|
||||
0xEC -> voice.cutAtTick = row.effectArg and 0xFF
|
||||
}
|
||||
voice.noteDelayTick = -1
|
||||
voice.slideMode = 0
|
||||
voice.slideArg = 0
|
||||
voice.arpActive = false
|
||||
voice.tremorOn = 0
|
||||
voice.vibratoActive = false
|
||||
voice.tremoloActive = false
|
||||
voice.retrigActive = false
|
||||
voice.tempoSlideDir = 0
|
||||
voice.volColSlideUp = 0; voice.volColSlideDown = 0
|
||||
voice.panColSlideRight = 0; voice.panColSlideLeft = 0
|
||||
voice.rowEffect = row.effect
|
||||
voice.rowEffectArg = row.effectArg
|
||||
|
||||
// ── Note ──
|
||||
val toneG = (row.effect == EffectOp.OP_G)
|
||||
when (row.note) {
|
||||
0xFFFF -> {} // no-op: continue current note unchanged
|
||||
0x0000 -> voice.active = false // key-off (TODO: trigger envelope release phase)
|
||||
0xFFFE -> voice.active = false // note cut: immediate silence
|
||||
0xFFFF -> {} // no-op
|
||||
0x0000 -> voice.active = false // key-off (TODO release envelope)
|
||||
0xFFFE -> voice.active = false // note cut
|
||||
else -> {
|
||||
val inst = instruments[row.instrment]
|
||||
voice.instrumentId = row.instrment
|
||||
voice.samplePos = inst.samplePlayStart.toDouble()
|
||||
voice.forward = true
|
||||
voice.active = true
|
||||
voice.envIndex = 0
|
||||
voice.envTimeSec = 0.0
|
||||
voice.envVolume = inst.envelopes[0].volume / 255.0
|
||||
voice.noteVal = row.note
|
||||
voice.playbackRate = computePlaybackRate(inst, row.note)
|
||||
if (toneG && voice.active) {
|
||||
// Tone porta: target the note, do not retrigger sample.
|
||||
voice.tonePortaTarget = row.note
|
||||
} else if ((row.effect == EffectOp.OP_S) && ((row.effectArg ushr 12) and 0xF) == 0xD) {
|
||||
// Note delay: defer trigger to the requested tick.
|
||||
voice.noteDelayTick = (row.effectArg ushr 8) and 0xF
|
||||
voice.delayedNote = row.note
|
||||
voice.delayedInst = row.instrment
|
||||
voice.delayedVol = if (row.volume >= 0) row.volume else -1
|
||||
} else {
|
||||
triggerNote(voice, row.note, row.instrment, -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Volume column (selectors per TAUD_NOTE_EFFECTS.md) ──
|
||||
// The cell already separates value (volume) and selector (volumeEff).
|
||||
applyVolColumn(voice, row.volume, row.volumeEff)
|
||||
applyPanColumn(voice, row.pan, row.panEff)
|
||||
|
||||
// ── Effect column ──
|
||||
applyEffectRow(ts, playhead, voice, vi, row.effect, row.effectArg)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a non-zero argument or recall from the cohort memory and return the effective arg. */
|
||||
private fun resolveArg(arg: Int, mem: Int): Int = if (arg != 0) arg else mem
|
||||
|
||||
private fun applyEffectRow(ts: TrackerState, playhead: Playhead, voice: Voice, vi: Int, op: Int, rawArg: Int) {
|
||||
when (op) {
|
||||
EffectOp.OP_NONE -> {}
|
||||
EffectOp.OP_A -> {
|
||||
val tr = (rawArg ushr 8) and 0xFF
|
||||
if (tr != 0) playhead.tickRate = tr
|
||||
}
|
||||
EffectOp.OP_B -> {
|
||||
// Highest-priority B wins for the row (lowest channel index in spec); first-set wins by ascending channel scan.
|
||||
if (ts.pendingOrderJump < 0) ts.pendingOrderJump = rawArg.coerceIn(0, 1023)
|
||||
}
|
||||
EffectOp.OP_C -> {
|
||||
if (ts.pendingRowJump < 0) ts.pendingRowJump = rawArg.coerceIn(0, 63)
|
||||
}
|
||||
EffectOp.OP_D -> {
|
||||
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 -> { voice.rowVolume = (voice.rowVolume + 0xF).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } // DFF quirk: fine up by F
|
||||
hin == 0xF && lo != 0 -> { voice.rowVolume = (voice.rowVolume - lo).coerceAtLeast(0); voice.channelVolume = voice.rowVolume }
|
||||
lo == 0xF && hin != 0 -> { voice.rowVolume = (voice.rowVolume + hin).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume }
|
||||
hin == 0 && lo != 0 -> { voice.slideMode = 5; voice.slideArg = -lo } // slide down per non-first tick
|
||||
lo == 0 && hin != 0 -> { voice.slideMode = 5; voice.slideArg = hin } // slide up per non-first tick
|
||||
}
|
||||
}
|
||||
EffectOp.OP_E -> {
|
||||
val arg = resolveArg(rawArg, voice.mem.ef).also { if (rawArg != 0) voice.mem.ef = it }
|
||||
if ((arg and 0xF000) == 0xF000) {
|
||||
val mag = arg and 0x0FFF
|
||||
voice.noteVal = (voice.noteVal - mag).coerceIn(0, 0xFFFE); voice.basePitch = voice.noteVal
|
||||
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
|
||||
} else {
|
||||
voice.slideMode = 1; voice.slideArg = -arg
|
||||
}
|
||||
}
|
||||
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 = (voice.noteVal + mag).coerceIn(0, 0xFFFE); voice.basePitch = voice.noteVal
|
||||
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
|
||||
} else {
|
||||
voice.slideMode = 2; voice.slideArg = arg
|
||||
}
|
||||
}
|
||||
EffectOp.OP_G -> {
|
||||
val arg = resolveArg(rawArg, voice.mem.g).also { if (rawArg != 0) voice.mem.g = it }
|
||||
voice.tonePortaSpeed = arg
|
||||
// tonePortaTarget was set in note-handling block above (or remains -1).
|
||||
}
|
||||
EffectOp.OP_H -> {
|
||||
val sp = (rawArg ushr 8) and 0xFF
|
||||
val dp = rawArg and 0xFF
|
||||
if (sp != 0) voice.mem.huSpeed = sp
|
||||
if (dp != 0) voice.mem.huDepth = dp
|
||||
voice.vibratoActive = true
|
||||
voice.vibratoFineShift = 6
|
||||
}
|
||||
EffectOp.OP_I -> {
|
||||
val arg = resolveArg(rawArg, voice.mem.i).also { if (rawArg != 0) voice.mem.i = it }
|
||||
voice.tremorOn = 1
|
||||
voice.tremorOnTime = ((arg ushr 8) and 0xFF) + 1
|
||||
voice.tremorOffTime = (arg and 0xFF) + 1
|
||||
}
|
||||
EffectOp.OP_J -> {
|
||||
val arg = resolveArg(rawArg, voice.mem.j).also { if (rawArg != 0) voice.mem.j = it }
|
||||
voice.arpActive = true
|
||||
voice.arpOff1 = (arg ushr 8) and 0xFF
|
||||
voice.arpOff2 = arg and 0xFF
|
||||
}
|
||||
EffectOp.OP_K, EffectOp.OP_L -> {} // engine no-op by design (converter splits them)
|
||||
EffectOp.OP_O -> {
|
||||
val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it }
|
||||
val inst = instruments[voice.instrumentId]
|
||||
var off = arg
|
||||
if (inst.loopMode != 0 && inst.sampleLoopEnd > inst.sampleLoopStart && off > inst.sampleLoopEnd) {
|
||||
val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1)
|
||||
off = inst.sampleLoopStart + ((off - inst.sampleLoopStart) % loopLen)
|
||||
}
|
||||
voice.samplePos = off.toDouble()
|
||||
}
|
||||
EffectOp.OP_Q -> {
|
||||
val arg = resolveArg(rawArg, voice.mem.q)
|
||||
val y = arg and 0xFF
|
||||
if (y != 0) {
|
||||
voice.mem.q = arg
|
||||
voice.retrigInterval = y
|
||||
voice.retrigVolMod = (arg ushr 8) and 0xF
|
||||
voice.retrigActive = true
|
||||
// Counter persists across rows per spec.
|
||||
}
|
||||
// y == 0 → entire effect ignored, even memory (spec).
|
||||
}
|
||||
EffectOp.OP_R -> {
|
||||
val sp = (rawArg ushr 8) and 0xFF
|
||||
val dp = rawArg and 0xFF
|
||||
if (sp != 0) voice.mem.rSpeed = sp
|
||||
if (dp != 0) voice.mem.rDepth = dp
|
||||
voice.tremoloActive = true
|
||||
}
|
||||
EffectOp.OP_S -> applySEffect(ts, voice, vi, rawArg)
|
||||
EffectOp.OP_T -> {
|
||||
val hi = (rawArg ushr 8) and 0xFF
|
||||
if (hi != 0) {
|
||||
val tempoByte = hi
|
||||
playhead.bpm = (tempoByte + 0x18).coerceIn(24, 280)
|
||||
} else {
|
||||
val low = rawArg and 0xFF
|
||||
when (low and 0xF0) {
|
||||
0x00 -> { voice.tempoSlideDir = -1; voice.tempoSlideAmount = low and 0x0F; voice.mem.tslide = low }
|
||||
0x10 -> { voice.tempoSlideDir = +1; voice.tempoSlideAmount = low and 0x0F; voice.mem.tslide = low }
|
||||
}
|
||||
}
|
||||
}
|
||||
EffectOp.OP_U -> {
|
||||
val sp = (rawArg ushr 8) and 0xFF
|
||||
val dp = rawArg and 0xFF
|
||||
if (sp != 0) voice.mem.huSpeed = sp
|
||||
if (dp != 0) voice.mem.huDepth = dp
|
||||
voice.vibratoActive = true
|
||||
voice.vibratoFineShift = 8
|
||||
}
|
||||
EffectOp.OP_V -> {
|
||||
val hi = (rawArg ushr 8) and 0xFF
|
||||
playhead.globalVolume = hi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySEffect(ts: TrackerState, voice: Voice, vi: Int, arg: Int) {
|
||||
val sub = (arg ushr 12) and 0xF
|
||||
val x = (arg ushr 8) and 0xF
|
||||
when (sub) {
|
||||
0x1 -> voice.glissandoOn = (x != 0)
|
||||
0x2 -> {
|
||||
voice.noteVal = (voice.noteVal + FINETUNE_OFFSET[x]).coerceIn(0, 0xFFFE)
|
||||
voice.basePitch = voice.noteVal
|
||||
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
|
||||
}
|
||||
0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 }
|
||||
0x4 -> { voice.tremoloWave = x and 3; voice.tremoloRetrig = (x and 4) == 0 }
|
||||
0x8 -> {
|
||||
// S$80xx — full 8-bit pan; arg low byte is the value.
|
||||
voice.channelPan = arg and 0xFF
|
||||
voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63)
|
||||
}
|
||||
0xB -> {
|
||||
if (x == 0) voice.loopStartRow = ts.rowIndex
|
||||
else {
|
||||
if (voice.loopCount == 0) {
|
||||
voice.loopCount = x
|
||||
ts.pendingRowJump = voice.loopStartRow
|
||||
} else if (!ts.patternDelayActive) {
|
||||
voice.loopCount--
|
||||
if (voice.loopCount > 0) ts.pendingRowJump = voice.loopStartRow
|
||||
}
|
||||
}
|
||||
}
|
||||
0xC -> if (x != 0) voice.cutAtTick = x
|
||||
0xD -> {} // handled in note section above (note delay)
|
||||
0xE -> {
|
||||
// Pattern delay — first SEx in ascending channel order wins.
|
||||
if (ts.sexWinningChannel < 0) {
|
||||
ts.sexWinningChannel = vi
|
||||
ts.patternDelayRemaining = x
|
||||
}
|
||||
}
|
||||
0xF -> { voice.funkSpeed = x; if (x == 0) voice.funkAccumulator = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyTrackerTick(ts: TrackerState, playhead: Playhead) {
|
||||
val tickSec = 2.5 / playhead.bpm
|
||||
for (voice in ts.voices) {
|
||||
if (!voice.active) continue
|
||||
if (!voice.active && voice.noteDelayTick < 0) continue
|
||||
val inst = instruments[voice.instrumentId]
|
||||
if (voice.cutAtTick == ts.tickInRow) { voice.active = false; continue }
|
||||
if (voice.pitchSlideAmount != 0.0) {
|
||||
voice.noteVal = (voice.noteVal + voice.pitchSlideAmount).toInt().coerceIn(0, 0xFFFE)
|
||||
voice.playbackRate = computePlaybackRate(inst, voice.noteVal)
|
||||
|
||||
// Note cut.
|
||||
if (voice.cutAtTick == ts.tickInRow) {
|
||||
voice.rowVolume = 0; voice.channelVolume = 0
|
||||
voice.noteWasCut = true
|
||||
}
|
||||
if (voice.volSlidePerTick != 0) {
|
||||
voice.rowVolume = (voice.rowVolume + voice.volSlidePerTick).coerceIn(0, 63)
|
||||
|
||||
// Note delay — fire deferred trigger when the requested tick arrives.
|
||||
if (voice.noteDelayTick == ts.tickInRow) {
|
||||
triggerNote(voice, voice.delayedNote, voice.delayedInst, voice.delayedVol)
|
||||
voice.noteDelayTick = -1
|
||||
}
|
||||
|
||||
if (!voice.active) { advanceEnvelope(voice, inst, tickSec); continue }
|
||||
|
||||
// Pitch slides (E/F coarse on tick > 0).
|
||||
if (ts.tickInRow > 0 && (voice.slideMode == 1 || voice.slideMode == 2)) {
|
||||
voice.noteVal = (voice.noteVal + voice.slideArg).coerceIn(0, 0xFFFE)
|
||||
voice.basePitch = voice.noteVal
|
||||
}
|
||||
|
||||
// Tone portamento (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
|
||||
}
|
||||
voice.basePitch = voice.noteVal
|
||||
}
|
||||
|
||||
// Volume slides (D coarse on tick > 0).
|
||||
if (ts.tickInRow > 0 && voice.slideMode == 5) {
|
||||
voice.rowVolume = (voice.rowVolume + voice.slideArg).coerceIn(0, 0x3F)
|
||||
voice.channelVolume = voice.rowVolume
|
||||
}
|
||||
|
||||
// Volume-column slides (selectors 1/2 — per non-first tick).
|
||||
if (ts.tickInRow > 0) {
|
||||
if (voice.volColSlideUp != 0) {
|
||||
voice.rowVolume = (voice.rowVolume + voice.volColSlideUp).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume
|
||||
}
|
||||
if (voice.volColSlideDown != 0) {
|
||||
voice.rowVolume = (voice.rowVolume - voice.volColSlideDown).coerceAtLeast(0); voice.channelVolume = voice.rowVolume
|
||||
}
|
||||
if (voice.panColSlideRight != 0) {
|
||||
voice.channelPan = (voice.channelPan + voice.panColSlideRight).coerceAtMost(0xFF)
|
||||
voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63)
|
||||
}
|
||||
if (voice.panColSlideLeft != 0) {
|
||||
voice.channelPan = (voice.channelPan - voice.panColSlideLeft).coerceAtLeast(0)
|
||||
voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63)
|
||||
}
|
||||
}
|
||||
|
||||
// Tremor (I) — gates output volume.
|
||||
if (voice.tremorOn != 0) {
|
||||
voice.tremorTickInPhase++
|
||||
val limit = if (voice.tremorPhaseOn) voice.tremorOnTime else voice.tremorOffTime
|
||||
if (voice.tremorTickInPhase >= limit) { voice.tremorTickInPhase = 0; voice.tremorPhaseOn = !voice.tremorPhaseOn }
|
||||
if (!voice.tremorPhaseOn) voice.rowVolume = 0
|
||||
}
|
||||
|
||||
// Vibrato (H/U) — applied as base-pitch overlay.
|
||||
var pitchToMixer = voice.noteVal
|
||||
if (voice.vibratoActive) {
|
||||
val sine = lfoSample(voice.vibratoLfoPos, voice.vibratoWave)
|
||||
val pitchDelta = (sine * voice.mem.huDepth) shr voice.vibratoFineShift
|
||||
pitchToMixer = (voice.noteVal + pitchDelta).coerceIn(0, 0xFFFE)
|
||||
voice.vibratoLfoPos = (voice.vibratoLfoPos + voice.mem.huSpeed * 4) and 0xFF
|
||||
}
|
||||
|
||||
// Glissando (S$1x) — snap pitchToMixer to nearest semitone but leave noteVal smooth.
|
||||
if (voice.glissandoOn) {
|
||||
val semis = ((pitchToMixer * 12 + 2048) / 4096)
|
||||
pitchToMixer = (semis * 4096 / 12).coerceIn(0, 0xFFFE)
|
||||
}
|
||||
|
||||
// Tremolo (R) — modulates output volume around base.
|
||||
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.tremoloLfoPos = (voice.tremoloLfoPos + voice.mem.rSpeed * 4) and 0xFF
|
||||
}
|
||||
|
||||
// Arpeggio (J) — overrides pitchToMixer for this tick (overlay on basePitch).
|
||||
if (voice.arpActive) {
|
||||
val voiceIdx = ts.tickInRow % 3
|
||||
val arpDelta = when (voiceIdx) { 1 -> voice.arpOff1 shl 8; 2 -> voice.arpOff2 shl 8; else -> 0 }
|
||||
pitchToMixer = (voice.basePitch + arpDelta).coerceIn(0, 0xFFFE)
|
||||
voice.lastArpVoice = voiceIdx
|
||||
}
|
||||
|
||||
// Q retrigger.
|
||||
if (voice.retrigActive && !voice.noteWasCut) {
|
||||
voice.retrigCounter++
|
||||
if (voice.retrigCounter >= voice.retrigInterval) {
|
||||
voice.retrigCounter = 0
|
||||
voice.samplePos = instruments[voice.instrumentId].samplePlayStart.toDouble()
|
||||
voice.envIndex = 0; voice.envTimeSec = 0.0
|
||||
voice.rowVolume = applyRetrigVolMod(voice.rowVolume, voice.retrigVolMod)
|
||||
voice.channelVolume = voice.rowVolume
|
||||
}
|
||||
}
|
||||
|
||||
// Update playback rate from final pitchToMixer.
|
||||
voice.playbackRate = computePlaybackRate(inst, pitchToMixer)
|
||||
|
||||
advanceEnvelope(voice, inst, tickSec)
|
||||
}
|
||||
|
||||
// Tempo slide — applied once per tick at the playhead level (any channel that armed it).
|
||||
for (voice in ts.voices) {
|
||||
if (voice.tempoSlideDir != 0 && ts.tickInRow > 0) {
|
||||
val tempoByte = (playhead.bpm - 0x18 + voice.tempoSlideDir * voice.tempoSlideAmount).coerceIn(0, 0xFF)
|
||||
playhead.bpm = (tempoByte + 0x18).coerceIn(24, 280)
|
||||
}
|
||||
}
|
||||
|
||||
// Funk repeat (S$Fx) — advance bit-mask per tick on instruments with active funkSpeed.
|
||||
for (voice in ts.voices) {
|
||||
if (voice.funkSpeed == 0 || !voice.active) continue
|
||||
val inst = instruments[voice.instrumentId]
|
||||
if (inst.sampleLoopEnd <= inst.sampleLoopStart) continue
|
||||
voice.funkAccumulator += FUNK_TABLE[voice.funkSpeed and 0xF]
|
||||
while (voice.funkAccumulator >= 0x80) {
|
||||
voice.funkAccumulator -= 0x80
|
||||
val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1)
|
||||
inst.toggleFunkBit(voice.funkWritePos % loopLen)
|
||||
voice.funkWritePos = (voice.funkWritePos + 1) % loopLen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyRetrigVolMod(vol: Int, x: Int): Int = when (x and 0xF) {
|
||||
0, 8 -> vol
|
||||
1 -> vol - 0x01; 2 -> vol - 0x02; 3 -> vol - 0x04; 4 -> vol - 0x08; 5 -> vol - 0x10
|
||||
6 -> vol * 2 / 3
|
||||
7 -> vol shr 1
|
||||
9 -> vol + 0x01; 0xA -> vol + 0x02; 0xB -> vol + 0x04; 0xC -> vol + 0x08; 0xD -> vol + 0x10
|
||||
0xE -> vol * 3 / 2
|
||||
0xF -> vol shl 1
|
||||
else -> vol
|
||||
}.coerceIn(0, 0x3F)
|
||||
|
||||
private fun advanceTrackerCue(ts: TrackerState, playhead: Playhead) {
|
||||
val instr = cueSheet[ts.cuePos].instruction
|
||||
if (instr is PlayInstHalt) { playhead.isPlaying = false; return }
|
||||
@@ -1214,8 +1682,6 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
internal fun generateTrackerAudio(playhead: Playhead): ByteArray? {
|
||||
val ts = playhead.trackerState ?: return null
|
||||
val bpm = playhead.bpm
|
||||
val spt = SAMPLING_RATE * 2.5 / bpm
|
||||
|
||||
val out = ByteArray(TRACKER_CHUNK * 2)
|
||||
|
||||
@@ -1225,6 +1691,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
|
||||
for (n in 0 until TRACKER_CHUNK) {
|
||||
// Recompute samples-per-tick every iteration since T/T-slide can mutate BPM mid-row.
|
||||
val spt = SAMPLING_RATE * 2.5 / playhead.bpm
|
||||
ts.samplesIntoTick += 1.0
|
||||
if (ts.samplesIntoTick >= spt) {
|
||||
ts.samplesIntoTick -= spt
|
||||
@@ -1232,32 +1700,75 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
ts.tickInRow++
|
||||
if (ts.tickInRow >= playhead.tickRate) {
|
||||
ts.tickInRow = 0
|
||||
ts.rowIndex++
|
||||
if (ts.rowIndex >= 64) {
|
||||
ts.rowIndex = 0
|
||||
advanceTrackerCue(ts, playhead)
|
||||
}
|
||||
applyTrackerRow(ts, playhead)
|
||||
advanceRow(ts, playhead)
|
||||
}
|
||||
}
|
||||
|
||||
var mixL = 0.0
|
||||
var mixR = 0.0
|
||||
val gvol = playhead.globalVolume / 255.0
|
||||
for (voice in ts.voices) {
|
||||
if (!voice.active) continue
|
||||
val s = fetchTrackerSample(voice, instruments[voice.instrumentId])
|
||||
val vol = voice.envVolume * voice.rowVolume / 63.0 * playhead.masterVolume / 255.0
|
||||
val vol = voice.envVolume * voice.rowVolume / 63.0 * gvol * playhead.masterVolume / 255.0
|
||||
mixL += s * vol * (63 - voice.rowPan) / 63.0
|
||||
mixR += s * vol * voice.rowPan / 63.0
|
||||
}
|
||||
|
||||
out[n * 2] = ((mixL.coerceIn(-1.0, 1.0) * 127 + 128).toInt()).toByte()
|
||||
out[n * 2 + 1] = ((mixR.coerceIn(-1.0, 1.0) * 127 + 128).toInt()).toByte()
|
||||
ts.mixLeft[n] = mixL.toFloat().coerceIn(-1.0f, 1.0f)
|
||||
ts.mixRight[n] = mixR.toFloat().coerceIn(-1.0f, 1.0f)
|
||||
}
|
||||
|
||||
pcm32fToPcm8(ts.mixLeft, ts.mixRight, TRACKER_CHUNK)
|
||||
for (n in 0 until TRACKER_CHUNK) {
|
||||
out[n * 2] = tadDecodedBin[n * 2L]
|
||||
out[n * 2 + 1] = tadDecodedBin[n * 2L + 1]
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance to the next row. Resolves pending B/C jumps and pattern-delay repeats.
|
||||
* Called once when [TrackerState.tickInRow] has just wrapped past [Playhead.tickRate].
|
||||
*/
|
||||
private fun advanceRow(ts: TrackerState, playhead: Playhead) {
|
||||
// Pattern delay (S$Ex): replay the same row patternDelayRemaining more times.
|
||||
if (ts.patternDelayRemaining > 0) {
|
||||
ts.patternDelayRemaining--
|
||||
ts.patternDelayActive = true
|
||||
applyTrackerRow(ts, playhead)
|
||||
return
|
||||
}
|
||||
ts.patternDelayActive = false
|
||||
|
||||
val pendingB = ts.pendingOrderJump
|
||||
val pendingC = ts.pendingRowJump
|
||||
ts.pendingOrderJump = -1
|
||||
ts.pendingRowJump = -1
|
||||
|
||||
when {
|
||||
pendingB >= 0 -> {
|
||||
ts.cuePos = pendingB.coerceAtMost(1023)
|
||||
ts.rowIndex = if (pendingC >= 0) pendingC else 0
|
||||
playhead.position = ts.cuePos
|
||||
}
|
||||
pendingC >= 0 -> {
|
||||
// Pattern break — advance order by one (or honour cue's own instruction), then jump to row.
|
||||
advanceTrackerCue(ts, playhead)
|
||||
ts.rowIndex = pendingC.coerceIn(0, 63)
|
||||
}
|
||||
else -> {
|
||||
ts.rowIndex++
|
||||
if (ts.rowIndex >= 64) {
|
||||
ts.rowIndex = 0
|
||||
advanceTrackerCue(ts, playhead)
|
||||
}
|
||||
}
|
||||
}
|
||||
applyTrackerRow(ts, playhead)
|
||||
}
|
||||
|
||||
internal data class PlayCue(
|
||||
val patterns: IntArray = IntArray(20) { 0xFFF },
|
||||
var instruction: PlayInstruction = PlayInstNop
|
||||
@@ -1323,21 +1834,118 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
internal object PlayInstHalt : PlayInstruction(0)
|
||||
internal object PlayInstNop : PlayInstruction(0)
|
||||
|
||||
/** Per-channel effect memory cohorts and private slots (TAUD_NOTE_EFFECTS.md §6). */
|
||||
class MemorySlots {
|
||||
// Shared E/F (pitch slide). Stores the raw arg; mode is recovered from arg layout.
|
||||
var ef: Int = 0
|
||||
// G (tone porta) — private speed.
|
||||
var g: Int = 0
|
||||
// Shared H/U vibrato — separate speed and depth fields persist across both.
|
||||
var huSpeed: Int = 0
|
||||
var huDepth: Int = 0
|
||||
// R (tremolo) — private speed and depth.
|
||||
var rSpeed: Int = 0
|
||||
var rDepth: Int = 0
|
||||
// Private slots
|
||||
var d: Int = 0
|
||||
var i: Int = 0
|
||||
var j: Int = 0
|
||||
var o: Int = 0
|
||||
var q: Int = 0
|
||||
var tslide: Int = 0
|
||||
}
|
||||
|
||||
class Voice {
|
||||
var active = false
|
||||
var instrumentId = 0
|
||||
var samplePos = 0.0
|
||||
var playbackRate = 1.0
|
||||
var forward = true
|
||||
var rowVolume = 63
|
||||
var rowPan = 32
|
||||
|
||||
// Volumes: channel volume is the persistent base; rowVolume tracks per-tick output (set per row from channel volume + volume column).
|
||||
var channelVolume = 0x3F // $00..$3F (default full)
|
||||
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
|
||||
|
||||
var envIndex = 0
|
||||
var envTimeSec = 0.0
|
||||
var envVolume = 1.0
|
||||
var noteVal = 0xFFFF
|
||||
var pitchSlideAmount = 0.0 // 4096-TET units per tick; +up, -down
|
||||
var volSlidePerTick = 0
|
||||
|
||||
// Pitch state (4096-TET units, signed when slid).
|
||||
var noteVal = 0xFFFF // The currently sounding base note (no per-row vibrato/arp added)
|
||||
var basePitch = 0x4000 // Saved pre-effect pitch for vibrato/arp/glissando overlay
|
||||
|
||||
// Per-row effect state (set in applyTrackerRow, consumed by applyTrackerTick).
|
||||
var rowEffect = 0
|
||||
var rowEffectArg = 0
|
||||
var slideMode = 0 // 0 = none, 1 = pitch coarse-down, 2 = pitch coarse-up, 3 = porta, 4 = vol-slide modes packed in slideArg
|
||||
var slideArg = 0 // generic slide arg (volume nibbles or pitch units per tick)
|
||||
var tonePortaTarget = -1 // -1 if inactive
|
||||
var tonePortaSpeed = 0
|
||||
var arpOff1 = 0
|
||||
var arpOff2 = 0
|
||||
var arpActive = false
|
||||
var lastArpVoice = 0 // 0 / 1 / 2 — which arp voice we ended on (J-after-arp pitch carry)
|
||||
var tremorOn = 0 // 0 = inactive, 1 = active row (use I args)
|
||||
var tremorOnTime = 1
|
||||
var tremorOffTime = 1
|
||||
var tremorPhaseOn = true
|
||||
var tremorTickInPhase = 0
|
||||
|
||||
// Vibrato (H / U) — uses memHU.
|
||||
var vibratoActive = false
|
||||
var vibratoLfoPos = 0 // 8-bit phase
|
||||
var vibratoWave = 0 // 0..3
|
||||
var vibratoRetrig = true
|
||||
var vibratoFineShift = 6 // 6 for H, 8 for U
|
||||
|
||||
// Tremolo (R) — uses memR.
|
||||
var tremoloActive = false
|
||||
var tremoloLfoPos = 0
|
||||
var tremoloWave = 0
|
||||
var tremoloRetrig = true
|
||||
|
||||
// Glissando flag (S$1x).
|
||||
var glissandoOn = false
|
||||
|
||||
// Q retrigger.
|
||||
var retrigCounter = 0
|
||||
var retrigInterval = 0
|
||||
var retrigVolMod = 0
|
||||
var retrigActive = false
|
||||
|
||||
// Note delay (S$Dx) — buffered trigger (-1 = no delay).
|
||||
var noteDelayTick = -1
|
||||
var delayedNote = 0
|
||||
var delayedInst = 0
|
||||
var delayedVol = -1
|
||||
|
||||
// Note cut (S$Cx).
|
||||
var cutAtTick = -1
|
||||
var noteWasCut = false // suppresses Q retrigger after cut
|
||||
|
||||
// Funk repeat (S$Fx) — non-destructive bit XOR mask is per-instrument; per-channel state tracks accumulator + write pointer.
|
||||
var funkSpeed = 0 // 0 = off
|
||||
var funkAccumulator = 0
|
||||
var funkWritePos = 0
|
||||
|
||||
// Pattern loop (S$Bx) — per-channel state.
|
||||
var loopStartRow = 0
|
||||
var loopCount = 0
|
||||
|
||||
// Tempo slide (T $00xy) — per-channel because T is a per-channel effect, but we apply globally via playhead.
|
||||
var tempoSlideDir = 0 // 0 = none, -1 = down, +1 = up
|
||||
var tempoSlideAmount = 0
|
||||
|
||||
// Volume / pan column slides (selectors 1/2/3 from TAUD_NOTE_EFFECTS.md §"Volume column effects").
|
||||
var volColSlideUp = 0
|
||||
var volColSlideDown = 0
|
||||
var panColSlideRight = 0
|
||||
var panColSlideLeft = 0
|
||||
|
||||
// Effect-recall memory for this voice.
|
||||
val mem = MemorySlots()
|
||||
}
|
||||
|
||||
class TrackerState {
|
||||
@@ -1347,6 +1955,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var samplesIntoTick = 0.0
|
||||
var firstRow = true
|
||||
val voices = Array(20) { Voice() }
|
||||
|
||||
// Pending row-end events (set during a row by B/C; consumed at row end).
|
||||
var pendingOrderJump = -1 // -1 = none; otherwise the order index to jump to
|
||||
var pendingRowJump = -1 // -1 = none; otherwise the row index for the next pattern
|
||||
|
||||
// Pattern-delay state (S$Ex) — number of additional row-repetitions remaining.
|
||||
var patternDelayRemaining = 0
|
||||
var patternDelayActive = false // true while inside a delay block (gates SBx decrement)
|
||||
|
||||
// Channel index of the SEx that won this row (lowest channel wins ties).
|
||||
var sexWinningChannel = -1
|
||||
|
||||
// Pre-allocated mix buffers for dither path (reused each audio chunk).
|
||||
val mixLeft = FloatArray(TRACKER_CHUNK)
|
||||
val mixRight = FloatArray(TRACKER_CHUNK)
|
||||
}
|
||||
|
||||
class Playhead(
|
||||
@@ -1358,11 +1981,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var masterVolume: Int = 0,
|
||||
var masterPan: Int = 128,
|
||||
// var samplingRateMult: ThreeFiveMiniUfloat = ThreeFiveMiniUfloat(32),
|
||||
var bpm: Int = 120,
|
||||
var bpm: Int = 125, // BPM, derived from tempoByte + 24. Spec default $65 ⇒ 125 BPM.
|
||||
var tickRate: Int = 6,
|
||||
var pcmUpload: Boolean = false,
|
||||
var patBank1: Int = 0,
|
||||
var patBank2: Int = 0,
|
||||
var globalVolume: Int = 0x80, // 8-bit, default $80 (spec §5). Mutated by V $xx00.
|
||||
|
||||
var pcmQueue: Queue<ByteArray> = Queue<ByteArray>(),
|
||||
var pcmQueueSizeIndex: Int = 0,
|
||||
@@ -1443,10 +2067,29 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
pcmUploadLength = 0
|
||||
isPlaying = false
|
||||
pcmQueueSizeIndex = 2
|
||||
// Spec §5 defaults — applied on every reset so song-start state is well-defined.
|
||||
bpm = 125
|
||||
tickRate = 6
|
||||
globalVolume = 0x80
|
||||
trackerState?.let { ts ->
|
||||
ts.cuePos = 0; ts.rowIndex = 0; ts.tickInRow = 0
|
||||
ts.samplesIntoTick = 0.0; ts.firstRow = true
|
||||
ts.voices.forEach { it.active = false }
|
||||
ts.pendingOrderJump = -1; ts.pendingRowJump = -1
|
||||
ts.patternDelayRemaining = 0; ts.patternDelayActive = false
|
||||
ts.sexWinningChannel = -1
|
||||
ts.voices.forEach {
|
||||
it.active = false
|
||||
it.channelVolume = 0x3F
|
||||
it.rowVolume = 0x3F
|
||||
it.channelPan = 0x80
|
||||
it.rowPan = 32
|
||||
it.glissandoOn = false
|
||||
it.loopStartRow = 0
|
||||
it.loopCount = 0
|
||||
it.funkSpeed = 0
|
||||
it.funkAccumulator = 0
|
||||
it.funkWritePos = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1509,6 +2152,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
data class TaudInstVolEnv(var volume: Int, var offset: ThreeFiveMiniUfloat)
|
||||
data class TaudInst(
|
||||
var index: Int,
|
||||
|
||||
var samplePtr: Int, // 20-bit number
|
||||
var sampleLength: Int,
|
||||
var samplingRate: Int,
|
||||
@@ -1519,7 +2164,23 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var loopMode: Int,
|
||||
var envelopes: Array<TaudInstVolEnv> // first int: volume (0..255), second int: offsets (minifloat indices)
|
||||
) {
|
||||
constructor() : this(0, 0, 0, 0, 0, 0, 0, Array(24) { TaudInstVolEnv(0, ThreeFiveMiniUfloat(0)) })
|
||||
constructor(index: Int) : this(index, 0, 0, 0, 0, 0, 0, 0, Array(24) { TaudInstVolEnv(0, ThreeFiveMiniUfloat(0)) })
|
||||
|
||||
// Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region.
|
||||
// Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact.
|
||||
var funkMask: ByteArray? = null
|
||||
fun toggleFunkBit(loopOffset: Int) {
|
||||
val len = (sampleLoopEnd - sampleLoopStart).coerceAtLeast(1)
|
||||
val mask = funkMask ?: ByteArray((len + 7) / 8).also { funkMask = it }
|
||||
val idx = loopOffset.coerceIn(0, len - 1)
|
||||
mask[idx / 8] = (mask[idx / 8].toInt() xor (1 shl (idx and 7))).toByte()
|
||||
}
|
||||
fun funkBit(loopOffset: Int): Boolean {
|
||||
val mask = funkMask ?: return false
|
||||
val len = (sampleLoopEnd - sampleLoopStart).coerceAtLeast(1)
|
||||
val idx = loopOffset.coerceIn(0, len - 1)
|
||||
return (mask[idx / 8].toInt() ushr (idx and 7)) and 1 != 0
|
||||
}
|
||||
|
||||
fun getByte(offset: Int): Byte = when (offset) {
|
||||
0 -> samplePtr.toByte()
|
||||
|
||||
Binary file not shown.
@@ -2,6 +2,7 @@ package net.torvald.tsvm
|
||||
|
||||
import com.badlogic.gdx.Audio
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.Input
|
||||
import com.badlogic.gdx.Input.Buttons
|
||||
import com.badlogic.gdx.graphics.Color
|
||||
import com.badlogic.gdx.graphics.g2d.SpriteBatch
|
||||
@@ -13,6 +14,7 @@ import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_WELL
|
||||
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT
|
||||
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.TINY
|
||||
import net.torvald.tsvm.peripheral.AudioAdapter
|
||||
import java.util.BitSet
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
@@ -25,6 +27,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
|
||||
// Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub), 3=cuesheet detail
|
||||
private val scopeMode = IntArray(4)
|
||||
private val scopeScrollHorz = IntArray(4)
|
||||
|
||||
override fun show() {
|
||||
}
|
||||
@@ -33,8 +36,10 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
}
|
||||
|
||||
private var guiClickLatched = arrayOf(false, false, false, false, false, false, false, false)
|
||||
private var guiKeypressLatched = BitSet(256)
|
||||
|
||||
override fun update() {
|
||||
// mouse clicks
|
||||
if (Gdx.input.isButtonPressed(Buttons.LEFT)) {
|
||||
if (!guiClickLatched[Buttons.LEFT]) {
|
||||
val mx = Gdx.input.x - x
|
||||
@@ -45,7 +50,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||
val syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeMode[3 - i] = (scopeMode[3 - i] + 1) % 4
|
||||
scopeMode[3 - i] = (scopeMode[3 - i] + 1) and 3
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -57,9 +62,78 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
else {
|
||||
guiClickLatched[Buttons.LEFT] = false
|
||||
}
|
||||
if (Gdx.input.isButtonPressed(Buttons.RIGHT)) {
|
||||
if (!guiClickLatched[Buttons.RIGHT]) {
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
if (mx in 117..629) {
|
||||
for (i in 0..3) {
|
||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||
val syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeMode[3 - i] = (scopeMode[3 - i] - 1) and 3
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guiClickLatched[Buttons.RIGHT] = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
guiClickLatched[Buttons.RIGHT] = false
|
||||
}
|
||||
|
||||
// keyboard left/right
|
||||
if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
|
||||
if (!guiKeypressLatched[Input.Keys.LEFT]) {
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
if (mx in 117..629) {
|
||||
for (i in 0..3) {
|
||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||
val syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeScrollHorz[3 - i] = (scopeScrollHorz[3 - i] - 1).coerceIn(0, 14)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guiKeypressLatched[Input.Keys.LEFT] = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
guiKeypressLatched[Input.Keys.LEFT] = false
|
||||
}
|
||||
if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
|
||||
if (!guiKeypressLatched[Input.Keys.RIGHT]) {
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
if (mx in 117..629) {
|
||||
for (i in 0..3) {
|
||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||
val syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeScrollHorz[3 - i] = (scopeScrollHorz[3 - i] + 1).coerceIn(0, 14)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guiKeypressLatched[Input.Keys.RIGHT] = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
guiKeypressLatched[Input.Keys.RIGHT] = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private val COL_SOUNDSCOPE_BACK = Color(0x081c08ff.toInt())
|
||||
private val COL_SOUNDSCOPE_FORE = Color(0x80f782ff.toInt())
|
||||
private val COL_TRACKER_ROW = Color(0x103010ff.toInt())
|
||||
@@ -164,7 +238,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
FONT.draw(batch, "Tickrate", x, y + 6*FONT.H)
|
||||
|
||||
batch.color = COL_ACTIVE3
|
||||
FONT.drawRalign(batch, "${ahead.trackerState?.cuePos}:${ahead.trackerState?.rowIndex?.toString(16)?.uppercase()?.padStart(2,'0')}", x + 84, y + 2*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.trackerState?.cuePos?.toString(16)?.uppercase()?.padStart(2,'0')}:${ahead.trackerState?.rowIndex?.toString()?.uppercase()?.padStart(2,'0')}", x + 84, y + 2*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.masterVolume}", x + 84, y + 3*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.masterPan}", x + 84, y + 4*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.bpm}", x + 84, y + 5*FONT.H)
|
||||
@@ -187,7 +261,9 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
private fun bipolarCeil(d: Double) = (if (d >= 0.0) ceil(d) else floor(d)).toInt()
|
||||
private fun bipolarFloor(d: Double) = (if (d >= 0.0) floor(d) else ceil(d)).toInt()
|
||||
|
||||
private val VOX_PER_VIEW = arrayOf(5,8,16)
|
||||
private val VOX_PER_VIEW = arrayOf(6,20,20)
|
||||
private val VOL_SYM = arrayOf('@','^','&',' ')
|
||||
private val PAN_SYM = arrayOf('@','<','>',' ')
|
||||
|
||||
private fun drawSoundscope(audio: AudioAdapter, ahead: AudioAdapter.Playhead, batch: SpriteBatch, index: Int, x: Float, y: Float) {
|
||||
val gdxadev = ahead.audioDevice
|
||||
@@ -272,8 +348,8 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
var cx = x
|
||||
// cursor + cue number
|
||||
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
|
||||
TINY.draw(batch, "${if (here) ">" else " "}${ci.toString(16).padStart(3, '0').uppercase()}|", cx, ry)
|
||||
cx += 5 * TINY.W
|
||||
TINY.draw(batch, "${ci.toString(16).padStart(3, '0').uppercase()}|", cx, ry)
|
||||
cx += 4 * TINY.W
|
||||
|
||||
// voice pattern numbers
|
||||
for (vi in 0 until 20) {
|
||||
@@ -328,7 +404,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
*/
|
||||
|
||||
// Pattern index for each voice in current cue
|
||||
val cuePats = IntArray(VOICES) { vi -> readCuePat12(audio, cuePos, vi) }
|
||||
val cuePats = IntArray(20) { vi -> readCuePat12(audio, cuePos, vi) }
|
||||
|
||||
// Pattern rows (right area, 8 rows centred on current row)
|
||||
// Layout: > rr NOTE in E.Vo E.Pn Eff ffff [voice1 …]
|
||||
@@ -349,22 +425,33 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
|
||||
// cursor + row number (drawn once per row)
|
||||
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
|
||||
TINY.draw(batch, if (here) ">" else " ", cx, ry)
|
||||
cx += TINY.W
|
||||
// TINY.draw(batch, if (here) ">" else " ", cx, ry)
|
||||
// cx += TINY.W
|
||||
TINY.draw(batch, ri.toString().padStart(2, '0').uppercase(), cx, ry)
|
||||
cx += 2 * TINY.W
|
||||
|
||||
for (vi in 0 until VOICES) {
|
||||
for (vi in scopeScrollHorz[index] until (VOICES + scopeScrollHorz[index]).coerceAtMost(19)) {
|
||||
val pat12 = cuePats[vi]
|
||||
if (pat12 == 0xFFF) {
|
||||
// disabled voice — dimmed placeholder, same width as a live voice
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
TINY.draw(batch, "(NO PATTERN DATA OR REACHED THE END OF THE SONG) ", cx, ry)
|
||||
if (vi == scopeScrollHorz[index]) {
|
||||
// disabled voice — dimmed placeholder, same width as a live voice
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
TINY.draw(
|
||||
batch,
|
||||
"(NO PATTERN DATA OR REACHED THE END OF THE SONG) ",
|
||||
cx,
|
||||
ry
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val localPat = pat12 and 0xFF
|
||||
val base = if (localPat < 128) 786432L + localPat * 512 + ri * 8
|
||||
else 851968L + (localPat - 128) * 512 + ri * 8
|
||||
|
||||
// perform correct bank change
|
||||
audio.mmio_write(2, (pat12 ushr 8).toByte())
|
||||
audio.mmio_write(3, (pat12 ushr 8).toByte())
|
||||
|
||||
val noteLo = audio.peek(base + 0).toUint()
|
||||
val noteHi = audio.peek(base + 1).toUint()
|
||||
val noteVal = noteLo or (noteHi shl 8)
|
||||
@@ -387,6 +474,10 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
0xFFFE -> "^^^^"
|
||||
else -> noteVal.toString(16).uppercase().padStart(4, '0')
|
||||
}
|
||||
var instStr = instr.toString(16).padStart(2, '0').uppercase()
|
||||
if (instr == 0) {
|
||||
instStr = "@@"
|
||||
}
|
||||
|
||||
// note
|
||||
batch.color = if (here) Color.WHITE else COL_NOTE
|
||||
@@ -394,28 +485,53 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
cx += 4 * TINY.W
|
||||
// instrument
|
||||
batch.color = if (here) Color.WHITE else COL_INST
|
||||
TINY.draw(batch, instr.toString(16).padStart(2, '0').uppercase(), cx, ry)
|
||||
TINY.draw(batch, instStr, cx, ry)
|
||||
cx += 2 * TINY.W
|
||||
if (scopeMode[index] == 0) {
|
||||
// volume
|
||||
batch.color = if (here) Color.WHITE else COL_VOL
|
||||
TINY.draw(batch, "$volEff.${vol.toString().padStart(2, '0')}", cx, ry)
|
||||
cx += 4 * TINY.W
|
||||
var text = if (volByte == 0xC0) "@@@" else "${VOL_SYM[volEff]}${vol.toString().padStart(2, '0')}"
|
||||
// is this fine slide?
|
||||
if (volEff == 3 && vol != 0) {
|
||||
val dir = if (vol and 32 == 1) '+' else '-'
|
||||
text = "$dir${(vol and 31).toString().padStart(2,'0').uppercase()}"
|
||||
}
|
||||
TINY.draw(batch, text, cx, ry)
|
||||
cx += 3 * TINY.W
|
||||
}
|
||||
else if (scopeMode[index] == 1) {
|
||||
batch.color = if (here) Color.WHITE else COL_VOL
|
||||
TINY.draw(batch, vol.toString().padStart(2, '0'), cx, ry)
|
||||
cx += 2 * TINY.W
|
||||
}
|
||||
// pan
|
||||
if (scopeMode[index] == 0) {
|
||||
var text = if (panByte == 0xC0) "@@@" else "${PAN_SYM[panEff]}${pan.toString().padStart(2, '0')}"
|
||||
// is this fine slide?
|
||||
if (panEff == 3 && pan != 0) {
|
||||
val dir = if (pan and 32 == 1) '+' else '-'
|
||||
text = "$dir${(pan and 31).toString().padStart(2,'0').uppercase()}"
|
||||
}
|
||||
batch.color = if (here) Color.WHITE else COL_PAN
|
||||
TINY.draw(batch, "$panEff.${pan.toString().padStart(2, '0')}", cx, ry)
|
||||
cx += 4 * TINY.W
|
||||
TINY.draw(batch, text, cx, ry)
|
||||
cx += 3 * TINY.W
|
||||
}
|
||||
if (scopeMode[index] < 2) {
|
||||
if (scopeMode[index] == 0) {
|
||||
var effSymStr = eff.toString(36).uppercase()
|
||||
var effArgStr = effArg.toString(16).padStart(4, '0').uppercase()
|
||||
|
||||
if (eff == 0 && effArg == 0) {
|
||||
effSymStr = "@@"
|
||||
effArgStr = "@@@@"
|
||||
}
|
||||
|
||||
// effect opcode
|
||||
batch.color = if (here) Color.WHITE else COL_EFF
|
||||
TINY.draw(batch, eff.toString(16).padStart(2, '0').uppercase(), cx, ry)
|
||||
cx += 2 * TINY.W
|
||||
TINY.draw(batch, effSymStr, cx, ry)
|
||||
cx += 1 * TINY.W
|
||||
// effect argument
|
||||
batch.color = if (here) Color.WHITE else COL_EFFARG
|
||||
TINY.draw(batch, effArg.toString(16).padStart(4, '0').uppercase(), cx, ry)
|
||||
TINY.draw(batch, effArgStr, cx, ry)
|
||||
cx += 4 * TINY.W
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +346,8 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
drawMenu(fbatch, (panelsX - 1f) * windowWidth, (panelsY - 1f) * windowHeight)
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun drawVMtoCanvas(delta: Float, vm: VM?, pposX: Int, pposY: Int) {
|
||||
// assuming the reference adapter of 560x448
|
||||
val xoff = pposX * windowWidth.toFloat()
|
||||
|
||||
Reference in New Issue
Block a user