Compare commits

..

3 Commits

Author SHA1 Message Date
minjaesong
db3ffdedb6 more docs 2026-05-21 03:52:51 +09:00
minjaesong
5b9b96c8de 2taud.py: fix: stereo samples not converting correctly 2026-05-21 02:55:41 +09:00
minjaesong
8e8374ba99 taut: volume and pan meter on playback 2026-05-18 21:01:35 +09:00
6 changed files with 271 additions and 124 deletions

View File

@@ -1,9 +1,55 @@
# Taud Tracker Effect Command Reference # 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), ImpulseTracker (IT) or ProTracker (PT), and implementation details for engine writers. 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), ImpulseTracker (IT), FastTracker 2 (FT2) or ProTracker (PT), and implementation details for engine writers.
--- ---
## 0. Tracker Terminologies
This manual extensively uses "tracker lingo" that may not sound intuitive to the modern DAW users. This section covers some of the tracker lingo to get the concepts better understood for those who have never used trackers.
* **Pattern.** A rectangular block of rows × channels, conceptually similar to a MIDI clip in a DAW but on a strict grid: at most one note event per row per channel. Patterns have a fixed row count (typically 64), and the entire song is assembled by sequencing patterns rather than by placing clips on a continuous timeline.
* **Cue list** (also called *order list* in other trackers). The song-level playlist of pattern indices that defines playback order. The same pattern can appear in many cue slots — editing the pattern updates every occurrence. There is no continuous timeline; the song's runtime is whatever the cue list yields, navigated by effects B (jump) and C (break). Some trackers use one cue slot that spans the entire channels; Taud uses per-channel cues.
* **Channel / Voice.** A vertical column within every pattern, fixed in count for the whole song (closer in spirit to a mixer channel than a DAW track). Each channel plays at most one note at a time; chords need multiple channels. Channels persist their state — volume, pan, vibrato phase, filter — across pattern boundaries.
* **Row.** One horizontal slot within a pattern, at most one note event per channel. A row's duration is `speed × tick_duration` — see Speed and Tempo below.
* **Ticks.** A row spans several ticks dictated by a "tick rate". All note effects happen on those ticks while playing. Some effects (notably sliding effects, excluding fine slides) require more than one ticks for operation, and **they will not get applied when tick rate is set to 1.**
* **Speed vs. Tempo.** Two independent timing knobs. **Speed** (effect A) is the number of ticks per row; **tempo** (effect T) sets the duration of one tick, conventionally expressed as BPM. To slow the song globally without changing how often per-tick effects update, lower the tempo. To give per-tick effects more iterations per row (denser vibrato, longer slides per row), raise the speed. The default is speed 6, tempo $64 → 125 BPM → 50 Hz tick rate → 120 ms per row.
* **Effect column.** Each cell can carry one effect command (opcode + 16-bit argument) that fires on its row. Unlike a DAW automation lane, effects are inline with the notes — there is no continuous curve, only discrete per-row events that compose with the engine's tick loop.
* **Volume column / panning column.** Two extra mini-lanes per cell, each carrying its own 6-bit value + 2-bit selector (set / slide-up / slide-down / fine-slide). They run alongside the main effect column, so a single cell can carry both a main effect *and* a volume-column slide.
* **Effect memory / recall.** Most effects remember their last non-zero argument; re-issuing the same effect with `$0000` recalls and re-applies it. This is how trackers express "continue that slide" without re-typing the rate every row. Each effect has either a private memory slot or shares one with a small cohort of related effects (see §6).
* **Fine slides** are basically "relatively set something" operations. They apply delta on the first tick of the row only.
* **Instruments vs. samples.** Notes don't reference a sample directly — they reference an **instrument**, which wraps a sample with envelopes (volume / pan / pitch), a default note volume, an NNA (New Note Action; see below), and a fadeout setting. The same sample can be wrapped by several instruments with different envelopes, much like a sampler patch in a DAW.
* **Sample loops.** Held notes don't work the way a DAW sustain pedal does. The sample itself contains a loop region (loop_start..loop_end) that the playhead replays endlessly until the note is released or cut — "sustain" comes from the sample data, not from a held key.
* **Note off, note cut, note fade.** Three distinct ways a note ends. **Note cut** (`^^^` or S$Cx) silences instantly. **Note off** (`===` or an NNA = NoteOff) releases the sustain loop and lets the volume envelope's release segment play out, then fades. **Note fade** keeps the sustain loop running but begins the fadeout decay — for soft tail-offs that still sound sustained.
* **NNA — New Note Action.** What happens to a still-playing note when a fresh note arrives on the same channel. Options are Cut (drop the old voice), Continue (let it ring through), Note Off (release it), or Note Fade (begin fadeout). The displaced voice becomes a background **ghost** voice — still audible but no longer addressable from the pattern. This is the tracker's substitute for polyphony across DAW MIDI clips.
* **Portamento.** Automatic pitch glide toward a target note (effect G). A row carrying both a note *and* a G does **not** re-trigger the sample; instead the note becomes the target and the already-sounding sample slides into it. Distinct from generic pitch slides (E/F), which move pitch by a fixed amount per tick with no target.
* **Vibrato / tremolo / panbrello.** Per-channel LFOs applied to pitch (H, U), volume (R), and panning (Y) respectively. Each has independent speed, depth, and waveform. These are not DAW automation envelopes — they're cyclic modulators, more like a synth's LFO knob.
* **Arpeggio.** A chip tune staple: rapidly cycle one channel between three pitches across consecutive ticks to fake a chord on a single voice (effect J). At the default 50 Hz tick rate the cycle is fast enough to perceive as a chord rather than three separate notes.
* **Sample offset.** Start sample playback partway into the sample data rather than at byte 0 (effect O). Common uses: trigger a long sample mid-attack to skip a slow onset, or pick a different drum hit from a multi-sample bank.
* **Pattern jump / break / loop.** Three flow-control tools without a direct DAW analog. **B** jumps to a cue index; **C** breaks out of the current pattern into a specific row of the *next* one in the cue list; **S$Bx** sets a per-channel loop point and repeats the bracketed range a fixed number of times. They operate on the cue list, not on a timeline. This pattern-wise flow control (including delays. see below) applies to the entire channels; there will be no divergence where one channel loops but other channels don't.
* **Pattern delay / fine pattern delay.** **S$Ex** repeats the current row N additional times (notes don't re-trigger across repetitions, but tick-0 events do); **S$6x** extends the current row by N additional ticks without repeating it. Together they let composers stretch row timing locally without touching global speed or tempo.
* **Volume fadeout.** A linear per-tick volume decay applied after key-off (or NNA Note-Fade). For sustained instruments whose volume envelope holds non-zero forever, the fadeout is the *only* mechanism that eventually retires the voice — without a stored fadeout, key-off lets such voices ring indefinitely.
## 1. Sound device ## 1. Sound device
- **Bit depth:** 8-bit unsigned throughout, including the final mixdown. - **Bit depth:** 8-bit unsigned throughout, including the final mixdown.
@@ -927,12 +973,13 @@ S is a multiplexing opcode; the **high nibble of the high byte** selects the sub
# S $0x00 — Amiga LPF/LED Switch # S $0x00 — Amiga LPF/LED Switch
**Plain.** `$0100` turns filter off; `$0000` turns it on. The parameter of the filter is somewhat dependent on the current interpolation mode: follows Amiga 1200 LPF on 1200 mode, Amiga 500 LPF on 500 mode. For other interpolation modes, this command is no-op. (see § Effects That Modifies Global Behaviour) **Plain.** `$0100` turns filter off; `$0000` turns it on. The parameter of the filter is dependent on the current interpolation mode: follows Amiga 1200 LPF on 1200 mode, Amiga 500 LPF on 500 mode. For other interpolation modes, this command is no-op. (see § Effects that modifies global behaviour)
**Compatibility.** ST3/IT `S00`/`S01` and PT `E00`/`E01` maps directly. To actually hear the effect, the interpolation mode must be set to one of the two Amiga modes. **Compatibility.** ST3/IT `S00`/`S01` and PT `E00`/`E01` maps directly. To actually hear the effect, the interpolation mode must be set to one of the two Amiga modes.
**Implementation.** Per-playhead boolean `ledFilterOn` (default off). Writes from row are gated on `interpolationMode ∈ {Amiga 500, Amiga 1200}`; in linear / no-interp / default modes the filter chain is bypassed entirely so the toggle is a silent no-op. The post-mix LPF chain runs on the stereo bus (left/right state per playhead) before dithering: in Amiga 500 mode a 1-pole RC LPF (R = 360 Ω, C = 0.1 µF, fc ≈ 4421 Hz) is always applied; in Amiga 1200 mode that LPF is bypassed (cutoff ~34 kHz, well above 32 kHz Nyquist — matches `pt2_paula.c`). When the LED toggle is on, an additional 2-pole Sallen-Key LPF (R1=R2=10 kΩ, C1=6800 pF, C2=3900 pF, fc ≈ 3091 Hz, Q ≈ 0.660) is run after the mode LPF. Coefficients precomputed once at SAMPLING_RATE; recurrence follows musicdsp.org #38 with `pt2_rcfilters.c` parameter mapping. **Implementation.** Per-playhead boolean `ledFilterOn` (default off). Writes from row are gated on `interpolationMode ∈ {Amiga 500, Amiga 1200}`; in linear / no-interp / default modes the filter chain is bypassed entirely so the toggle is a silent no-op. The post-mix LPF chain runs on the stereo bus (left/right state per playhead) before dithering: in Amiga 500 mode a 1-pole RC LPF (R = 360 Ω, C = 0.1 µF, fc ≈ 4421 Hz) is always applied; in Amiga 1200 mode that LPF is bypassed (cutoff ~34 kHz, well above 32 kHz Nyquist — matches `pt2_paula.c`). When the LED toggle is on, an additional 2-pole Sallen-Key LPF (R1=R2=10 kΩ, C1=6800 pF, C2=3900 pF, fc ≈ 3091 Hz, Q ≈ 0.660) is run after the mode LPF. Coefficients precomputed once at SAMPLING_RATE; recurrence follows musicdsp.org #38 with `pt2_rcfilters.c` parameter mapping.
---
## S $1x00 — PT/ST3/IT Glissando control ## S $1x00 — PT/ST3/IT Glissando control
@@ -1151,7 +1198,7 @@ Q retrigger counters do **not** reset between SEx repetitions.
--- ---
## S $Fxxx — Funk repeat with speed $xxx (non-destructive) ## S $Fxxx — Funk repeat (Invert loop) with speed $xxx (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. **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.
@@ -1211,7 +1258,7 @@ NOTE: **`3.00` — is No-op**. When Set Pan and S $80xx are both present, S-comm
--- ---
# Effects That Modifies Global Behaviour # Effects that modifies global behaviour
Effects in this section modifies the behaviour of the mixer. Primary intention of the commands is to provide switches for legacy tracker and modern DAW behaviours. Effects in this section modifies the behaviour of the mixer. Primary intention of the commands is to provide switches for legacy tracker and modern DAW behaviours.
@@ -1229,69 +1276,8 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
- rrr = 1: No interpolation. - rrr = 1: No interpolation.
- rrr = 2: Amiga 500 interpolation. - rrr = 2: Amiga 500 interpolation.
- rrr = 3: Amiga 1200 interpolation. - rrr = 3: Amiga 1200 interpolation.
- rrr = 4: SNES 4-tap Gaussian - rrr = 4: SNES 4-tap gaussian.
- rrr = 5: Preserve delta modulation (linear intp.) - rrr = 5: NES DPCM simulation.
### Volume Fadeout
Taud's volume fadeout is a single linear decay applied per song tick after key-off (or NNA Note-Fade). It is **the only retirement mechanism** for sustained voices when the volume envelope holds non-zero or has no terminating zero node — without a non-zero stored fadeout, such voices play forever.
The 12-bit stored fadeout lives at instrument-record bytes 172 (low 8 bits) and 173 (low nibble = high 4 bits; high nibble reserved). Range 0..4095. The engine maintains a per-voice `fadeoutVolume ∈ [0, 1]` initialised to 1.0 on note-on, and once per song tick while the voice is keyed off:
```
fadeoutVolume -= storedFadeout / 1024.0
clamp fadeoutVolume to [0, 1]
if fadeoutVolume == 0: voice deactivates
```
Boundary semantics:
| `storedFadeout` | Behaviour |
| --- | --- |
| `0` | No fade. Voice plays at envelope-driven volume indefinitely. |
| `1..1023` | Graduated fade — completes in `1024 / storedFadeout` ticks. |
| `1024` | Exact 1-tick cut. The canonical "kill on key-off" value. |
| `1025..4095` | Also a 1-tick cut (clamped at 0). Headroom for converter robustness. |
There is no separate "use fadeout" flag — both extremes share the same field, exactly as in the IT and XM file formats.
**Tick-rate worked example** (default 50 Hz, BPM 125, speed 6):
- `storedFadeout = 1` → fade ≈ 20.5 s
- `storedFadeout = 32` → fade ≈ 640 ms
- `storedFadeout = 1024` → ~20 ms (one tick)
**Converter unit conversion.** Source trackers each expose fadeout in their own unit; converters scale the source value into Taud's 0..4095 field.
- **IT** (`it2taud.py`): IT files store fadeout as a 16-bit field at instrument-record offset `0x14`, range 0..1024 per ITTECH (some loaders accept up to 2048). Schism's per-tick decrement is `stored / 1024` — identical to Taud's unit. **Pass-through with clamp:**
```python
taud_fadeout = min(it_fadeout & 0xFFFF, 0x0FFF)
```
- **FT2 / XM** (`xm2taud.py`): XM files store fadeout as a 16-bit field. Spec range 0..0xFFF; MilkyTracker writes up to 32767 to encode the "cut" UI slider position (`SectionInstruments.cpp:499-500`). FT2's per-tick decrement is `stored / 32768` — to match Taud's `stored / 1024` rate, **divide source by 32 (round-to-nearest):**
```python
taud_fadeout = min((xm_fadeout + 16) // 32, 0x0FFF)
```
XM stored 1..15 round to Taud 0; the originals were >11 min at 50 Hz — effectively no-fade anyway. Stored 32 → Taud 1 (~20 s). Stored 32767 (Milky cut sentinel) → Taud 1024 (1-tick cut).
- **MOD / S3M / MON**: source has no instrument-level fadeout. Converter writes Taud `0`. Notes retire on sample-end or pattern note-cut.
**Implementation.**
- Panning (equal-energy):
- L_gain = cos(πx / 512.0)
- R_gain = sin(πx / 512.0)
- Amiga tone (both coarse and fine E/F pitch slides). The `slideArg` is a **raw tracker period-unit count** (no scaling), with sign matching linear mode (negative for E, positive for F). Coarse slides apply on every non-first tick; fine slides apply once on tick 0 — the per-step arithmetic is identical:
- AMIGA_BASE_PERIOD = 428.0 (period at the Taud reference pitch C4 for a standard 8363 Hz instrument, NTSC clock — identical to PT "C-2" period 428)
- period = AMIGA_BASE_PERIOD × 2^((noteVal C4) / 4096)
- period_new = period slideArg (E subtracts pitch ⇒ adds period; F adds pitch ⇒ subtracts period)
- noteVal_new = C4 + 4096 × log2(AMIGA_BASE_PERIOD / period_new)
- Linear-frequency tone (E / F / G in Hz/tick). The `slideArg` is a **signed Hz delta per tick** at the audible reference 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, identical to the value MONOTONE stores in its 1xx/2xx/3xx commands. Sign convention matches linear/Amiga modes (negative for E, positive for F):
- LINEAR_FREQ_C4_HZ = 261.625565... (12-TET, so A4 = 440 Hz exactly)
- freq = LINEAR_FREQ_C4_HZ × 2^((noteVal C4) / 4096)
- freq_new = max(freq + slideArg, 1.0)
- noteVal_new = C4 + 4096 × log2(freq_new / LINEAR_FREQ_C4_HZ)
- For tone portamento (G), `tonePortaSpeed` is also in Hz/tick: each tick walks `freq` toward `noteValToFreq(target)` by `±tonePortaSpeed` until the target frequency is reached.
- Like Amiga mode, the per-voice intermediate frequency is cached across ticks (no round-trip rounding) and reseeded on note trigger, S$2x finetune, fine slides, and the start of a fresh multi-tick coarse slide.
**Initialisation from the song table.** The same flags byte is stored in the song-table entry (see file format §Song Table). A Taud player should write this byte to MMIO playhead register 7 before starting playback; the mixer then applies it as the initial state on every reset, and subsequent in-pattern `1` effects may override it.
--- ---
@@ -1367,4 +1353,71 @@ These quirks of ST3 are worth preserving or flagging when importing S3M files in
--- ---
# Miscellaneous implementation details
This section documents important implementation details that are not covered by sections above.
## Volume fadeout
Taud's volume fadeout is a single linear decay applied per song tick after key-off (or NNA Note-Fade). It is **the only retirement mechanism** for sustained voices when the volume envelope holds non-zero or has no terminating zero node — without a non-zero stored fadeout, such voices play forever.
The 12-bit stored fadeout lives at instrument-record bytes 172 (low 8 bits) and 173 (low nibble = high 4 bits; high nibble reserved). Range 0..4095. The engine maintains a per-voice `fadeoutVolume ∈ [0, 1]` initialised to 1.0 on note-on, and once per song tick while the voice is keyed off:
```
fadeoutVolume -= storedFadeout / 1024.0
clamp fadeoutVolume to [0, 1]
if fadeoutVolume == 0: voice deactivates
```
Boundary semantics:
| `storedFadeout` | Behaviour |
| --- | --- |
| `0` | No fade. Voice plays at envelope-driven volume indefinitely. |
| `1..1023` | Graduated fade — completes in `1024 / storedFadeout` ticks. |
| `1024` | Exact 1-tick cut. The canonical "kill on key-off" value. |
| `1025..4095` | Also a 1-tick cut (clamped at 0). Headroom for converter robustness. |
There is no separate "use fadeout" flag — both extremes share the same field, exactly as in the IT and XM file formats.
**Tick-rate worked example** (default 50 Hz, BPM 125, speed 6):
- `storedFadeout = 1` → fade ≈ 20.5 s
- `storedFadeout = 32` → fade ≈ 640 ms
- `storedFadeout = 1024` → ~20 ms (one tick)
**Converter unit conversion.** Source trackers each expose fadeout in their own unit; converters scale the source value into Taud's 0..4095 field.
- **IT** (`it2taud.py`): IT files store fadeout as a 16-bit field at instrument-record offset `0x14`, range 0..1024 per ITTECH (some loaders accept up to 2048). Schism's per-tick decrement is `stored / 1024` — identical to Taud's unit. **Pass-through with clamp:**
```python
taud_fadeout = min(it_fadeout & 0xFFFF, 0x0FFF)
```
- **FT2 / XM** (`xm2taud.py`): XM files store fadeout as a 16-bit field. Spec range 0..0xFFF; MilkyTracker writes up to 32767 to encode the "cut" UI slider position (`SectionInstruments.cpp:499-500`). FT2's per-tick decrement is `stored / 32768` — to match Taud's `stored / 1024` rate, **divide source by 32 (round-to-nearest):**
```python
taud_fadeout = min((xm_fadeout + 16) // 32, 0x0FFF)
```
XM stored 1..15 round to Taud 0; the originals were >11 min at 50 Hz — effectively no-fade anyway. Stored 32 → Taud 1 (~20 s). Stored 32767 (Milky cut sentinel) → Taud 1024 (1-tick cut).
- **MOD / S3M / MON**: source has no instrument-level fadeout. Converter writes Taud `0`. Notes retire on sample-end or pattern note-cut.
**Implementation.**
- Panning (equal-energy):
- L_gain = cos(πx / 512.0)
- R_gain = sin(πx / 512.0)
- Amiga tone (both coarse and fine E/F pitch slides). The `slideArg` is a **raw tracker period-unit count** (no scaling), with sign matching linear mode (negative for E, positive for F). Coarse slides apply on every non-first tick; fine slides apply once on tick 0 — the per-step arithmetic is identical:
- AMIGA_BASE_PERIOD = 428.0 (period at the Taud reference pitch C4 for a standard 8363 Hz instrument, NTSC clock — identical to PT "C-2" period 428)
- period = AMIGA_BASE_PERIOD × 2^((noteVal C4) / 4096)
- period_new = period slideArg (E subtracts pitch ⇒ adds period; F adds pitch ⇒ subtracts period)
- noteVal_new = C4 + 4096 × log2(AMIGA_BASE_PERIOD / period_new)
- Linear-frequency tone (E / F / G in Hz/tick). The `slideArg` is a **signed Hz delta per tick** at the audible reference 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, identical to the value MONOTONE stores in its 1xx/2xx/3xx commands. Sign convention matches linear/Amiga modes (negative for E, positive for F):
- LINEAR_FREQ_C4_HZ = 261.625565... (12-TET, so A4 = 440 Hz exactly)
- freq = LINEAR_FREQ_C4_HZ × 2^((noteVal C4) / 4096)
- freq_new = max(freq + slideArg, 1.0)
- noteVal_new = C4 + 4096 × log2(freq_new / LINEAR_FREQ_C4_HZ)
- For tone portamento (G), `tonePortaSpeed` is also in Hz/tick: each tick walks `freq` toward `noteValToFreq(target)` by `±tonePortaSpeed` until the target frequency is reached.
- Like Amiga mode, the per-voice intermediate frequency is cached across ticks (no round-trip rounding) and reseeded on note trigger, S$2x finetune, fine slides, and the start of a fresh multi-tick coarse slide.
**Initialisation from the song table.** The same flags byte is stored in the song-table entry (see file format §Song Table). A Taud player should write this byte to MMIO playhead register 7 before starting playback; the mixer then applies it as the initial state on every reset, and subsequent in-pattern `1` effects may override it.
---
End of reference. End of reference.

View File

@@ -984,6 +984,8 @@ const colRowNumEmph1 = 225
const colRowNumEmph2 = 155 const colRowNumEmph2 = 155
const colStatus = 253 const colStatus = 253
const colVoiceHdr = 230 const colVoiceHdr = 230
const colVoiceHdrMuted = 249
const colVoiceHdrMutedCursorUp = 180
const colSep = 252 const colSep = 252
const colPushBtnBack = 143 const colPushBtnBack = 143
const colTabBarBack = 187 const colTabBarBack = 187
@@ -1001,10 +1003,11 @@ const colBLACK = 240
// Voice-header playback meters (volume bar grows from centre out; pan bar = centre tick + dot). // Voice-header playback meters (volume bar grows from centre out; pan bar = centre tick + dot).
// Pixels are drawn beneath text — only the glyph foregrounds occlude the bars, so the bars sit // Pixels are drawn beneath text — only the glyph foregrounds occlude the bars, so the bars sit
// on rows 0 and (cellH - 1) where the 7×14 glyph has the least foreground. // on rows 0 and (cellH - 1) where the 7×14 glyph has the least foreground.
const METER_VOL_COL = 41 // colHighlight const METER_VOL_COL = colVol
const METER_PAN_TICK_COL = 6 // colColumnSep const METER_PAN_COL = 214
const METER_PAN_DOT_COL = 239 // colWHITE const METER_VOL_TICK_COL = 127
const METER_BAR_PAD = 2 // px gap from cell edges (each side) const METER_PAN_TICK_COL = 198
const METER_BAR_PAD = 0 // px gap from cell edges (each side)
const METER_TRANSPARENT = 255 const METER_TRANSPARENT = 255
let separatorStyle = 0 let separatorStyle = 0
@@ -1184,20 +1187,22 @@ function drawSeparators(style) {
} }
} }
const voiceHdrColByFlags = [colStatus, colVoiceHdr, colVoiceHdrMuted, colVoiceHdrMutedCursorUp] // default, cursorUp, muted, cursorUp+muted
function drawVoiceHeaders() { function drawVoiceHeaders() {
fillLine(PTNVIEW_OFFSET_Y - 1, colVoiceHdr, 255) fillLine(PTNVIEW_OFFSET_Y - 1, colStatus, 255)
const cue = song.cues[cueIdx] const cue = song.cues[cueIdx]
for (let c = 0; c < VOCSIZE_TIMELINE_FULL; c++) { for (let c = 0; c < VOCSIZE_TIMELINE_FULL; c++) {
const voice = voiceOff + c const voice = voiceOff + c
const x = PTNVIEW_OFFSET_X + COLSIZE_TIMELINE_FULL * c const x = PTNVIEW_OFFSET_X + COLSIZE_TIMELINE_FULL * c
con.move(PTNVIEW_OFFSET_Y - 1, x) con.move(PTNVIEW_OFFSET_Y - 1, x)
if (voice >= song.numVoices) { if (voice >= song.numVoices) {
con.color_pair(colVoiceHdr, 255) con.color_pair(colStatus, 255)
print(` `.substring(0, COLSIZE_TIMELINE_FULL)) print(` `.substring(0, COLSIZE_TIMELINE_FULL))
} else { } else {
const isCursor = (voice === cursorVox) const isCursor = (voice === cursorVox)
const isMuted = voiceMutes[voice] const isMuted = voiceMutes[voice]
con.color_pair(isMuted ? 249 : colVoiceHdr, isCursor ? colHighlight : 255) con.color_pair(voiceHdrColByFlags[isMuted*2 + isCursor], 255)
const ptnIdx = cue.ptns[voice] const ptnIdx = cue.ptns[voice]
const vlabel = `V${(voice+1).dec02()}` const vlabel = `V${(voice+1).dec02()}`
const plabel = (ptnIdx === CUE_EMPTY) ? '---' : ptnIdx.hex03() const plabel = (ptnIdx === CUE_EMPTY) ? '---' : ptnIdx.hex03()
@@ -1220,6 +1225,7 @@ function drawVoiceHeaders() {
// Per-slot cache of last-drawn meter state: { voice, vol, pan } or null when slot is clear. // Per-slot cache of last-drawn meter state: { voice, vol, pan } or null when slot is clear.
// Indexed by slot index 0..VOCSIZE_TIMELINE_FULL-1 (never grows beyond 20 slots in practice). // Indexed by slot index 0..VOCSIZE_TIMELINE_FULL-1 (never grows beyond 20 slots in practice).
const meterPrevSlot = new Array(20).fill(null) const meterPrevSlot = new Array(20).fill(null)
const meterThickness = 2
function invalidateVoiceMeters() { function invalidateVoiceMeters() {
for (let i = 0; i < meterPrevSlot.length; i++) meterPrevSlot[i] = null for (let i = 0; i < meterPrevSlot.length; i++) meterPrevSlot[i] = null
@@ -1228,12 +1234,10 @@ function invalidateVoiceMeters() {
// Wipe the pixel strip used by the voice-header meters back to transparent (255). // Wipe the pixel strip used by the voice-header meters back to transparent (255).
// Called when leaving the Timeline panel or when playback stops. // Called when leaving the Timeline panel or when playback stops.
function clearVoiceMeters() { function clearVoiceMeters() {
const yTop = (PTNVIEW_OFFSET_Y - 2) * CELL_PH const yPan = (PTNVIEW_OFFSET_Y - 2) * CELL_PH
const yBot = (PTNVIEW_OFFSET_Y - 1) * CELL_PH - 1 const yVol = (PTNVIEW_OFFSET_Y - 1) * CELL_PH - meterThickness
for (let x = 0; x < SCRPW; x++) { graphics.plotRect(0, yPan, SCRPW, meterThickness, METER_TRANSPARENT)
graphics.plotPixel(x, yTop, METER_TRANSPARENT) graphics.plotRect(0, yVol, SCRPW, meterThickness, METER_TRANSPARENT)
graphics.plotPixel(x, yBot, METER_TRANSPARENT)
}
invalidateVoiceMeters() invalidateVoiceMeters()
} }
@@ -1241,31 +1245,35 @@ function clearVoiceMeters() {
* Repaint the per-voice volume and pan indicators in the voice-header row. * Repaint the per-voice volume and pan indicators in the voice-header row.
* Volume: horizontal bar growing from the cell centre outward, length ∝ effective tracker * Volume: horizontal bar growing from the cell centre outward, length ∝ effective tracker
* volume (after envelopes, fadeout, vol-column/D/tremolo ramps, per-voice fader). Drawn on * volume (after envelopes, fadeout, vol-column/D/tremolo ramps, per-voice fader). Drawn on
* the cell's bottom pixel row. * the bottom strip of the header row.
* Pan: centre tick + a single dot offset by (pan-128)/128 × halfWidth. Drawn on the cell's * Pan: horizontal bar stemming from the cell centre, signed length ∝ (pan-128)/128. Drawn
* top pixel row. * on the top strip of the header row.
* Both strips get a centre tick drawn on top of the bar.
* Only redraws slots whose (voice, volPix, panPix) tuple has changed since the last call, * Only redraws slots whose (voice, volPix, panPix) tuple has changed since the last call,
* so the work per frame stays bounded by actual movement. * so the work per frame stays bounded by actual movement.
*/ */
function drawVoiceMeters() { function drawVoiceMeters() {
if (playbackMode === PLAYMODE_NONE || currentPanel !== VIEW_TIMELINE) return if (playbackMode === PLAYMODE_NONE || currentPanel !== VIEW_TIMELINE) return
const yPan = (PTNVIEW_OFFSET_Y - 2) * CELL_PH // top pixel of header row const yPan = (PTNVIEW_OFFSET_Y - 2) * CELL_PH // top edge of pan strip
const yVol = (PTNVIEW_OFFSET_Y - 1) * CELL_PH - 1 // bottom pixel of header row const yVol = (PTNVIEW_OFFSET_Y - 1) * CELL_PH - meterThickness // top edge of vol strip
const slotPW = COLSIZE_TIMELINE_FULL * CELL_PW const slotPW = COLSIZE_TIMELINE_FULL * CELL_PW
const halfW = (slotPW >>> 1) - METER_BAR_PAD // Skip the leftmost cell of every slot — it's a text-mode separator whose background
// colour paints on top of the framebuffer and would clip any meter pixels there.
const drawW = slotPW - CELL_PW
const halfW = (drawW >>> 1) - METER_BAR_PAD
const stripW = drawW - 2 * METER_BAR_PAD + 1
for (let c = 0; c < VOCSIZE_TIMELINE_FULL; c++) { for (let c = 0; c < VOCSIZE_TIMELINE_FULL; c++) {
const voice = voiceOff + c const voice = voiceOff + c
const slotX0 = (PTNVIEW_OFFSET_X + COLSIZE_TIMELINE_FULL * c - 1) * CELL_PW const slotX0 = (PTNVIEW_OFFSET_X + COLSIZE_TIMELINE_FULL * c) * CELL_PW
const xCenter = slotX0 + (slotPW >>> 1) const xCenter = slotX0 + (drawW >>> 1)
const xStrip = slotX0 + METER_BAR_PAD
const prev = meterPrevSlot[c] const prev = meterPrevSlot[c]
if (voice >= song.numVoices) { if (voice >= song.numVoices) {
if (prev !== null) { if (prev !== null) {
for (let x = slotX0 + METER_BAR_PAD; x < slotX0 + slotPW - METER_BAR_PAD; x++) { graphics.plotRect(xStrip, yPan, stripW, meterThickness, METER_TRANSPARENT)
graphics.plotPixel(x, yPan, METER_TRANSPARENT) graphics.plotRect(xStrip, yVol, stripW, meterThickness, METER_TRANSPARENT)
graphics.plotPixel(x, yVol, METER_TRANSPARENT)
}
meterPrevSlot[c] = null meterPrevSlot[c] = null
} }
continue continue
@@ -1282,19 +1290,20 @@ function drawVoiceMeters() {
if (prev !== null && prev.voice === voice && prev.vol === volPix && prev.pan === panPix) continue if (prev !== null && prev.voice === voice && prev.vol === volPix && prev.pan === panPix) continue
// Clear both bar strips in this slot before redrawing. // Clear both bar strips in this slot before redrawing.
for (let x = slotX0 + METER_BAR_PAD; x < slotX0 + slotPW - METER_BAR_PAD; x++) { graphics.plotRect(xStrip, yPan, stripW, meterThickness, METER_TRANSPARENT)
graphics.plotPixel(x, yPan, METER_TRANSPARENT) graphics.plotRect(xStrip, yVol, stripW, meterThickness, METER_TRANSPARENT)
graphics.plotPixel(x, yVol, METER_TRANSPARENT)
}
// Volume bar (grows from centre out). Silent voices show no bar. // Volume bar (grows from centre out). Silent voices show no bar.
if (volPix > 0) { if (volPix > 0) {
for (let dx = -volPix; dx <= volPix; dx++) { graphics.plotRect(xCenter - volPix, yVol, 2 * volPix + 1, meterThickness, METER_VOL_COL)
graphics.plotPixel(xCenter + dx, yVol, METER_VOL_COL)
}
} }
// Pan bar: faint centre tick, bright dot at pan position. // Pan bar (stems from centre, direction = sign of panPix). Centred pan shows no bar.
graphics.plotPixel(xCenter, yPan, METER_PAN_TICK_COL) if (panPix !== 0) {
graphics.plotPixel(xCenter + panPix, yPan, METER_PAN_DOT_COL) const px0 = (panPix > 0) ? xCenter : xCenter + panPix
graphics.plotRect(px0, yPan, Math.abs(panPix) + 1, meterThickness, METER_PAN_COL)
}
// Centre ticks, drawn on top of the bars.
graphics.plotRect(xCenter-1, yPan, 3, meterThickness, METER_PAN_TICK_COL)
graphics.plotRect(xCenter-1, yVol, 3, meterThickness, METER_VOL_TICK_COL)
meterPrevSlot[c] = { voice: voice, vol: volPix, pan: panPix } meterPrevSlot[c] = { voice: voice, vol: volPix, pan: panPix }
} }

View File

@@ -294,11 +294,11 @@ def _it214_decompress_block(payload: bytes, num_samples: int,
return out return out
def it214_decompress(blob: bytes, smp_offset: int, num_samples: int, def _it214_decompress_channel(blob: bytes, pos: int, num_samples: int,
is_16bit: bool, is_it215: bool) -> bytes: is_16bit: bool, is_it215: bool) -> tuple:
"""Decode IT2.14/IT2.15 compressed sample data. Returns raw PCM bytes (signed).""" """Decode one channel of IT2.14/IT2.15 compressed data. Returns
(raw PCM bytes, next position after consumed blocks)."""
block_size = 0x4000 if is_16bit else 0x8000 block_size = 0x4000 if is_16bit else 0x8000
pos = smp_offset
out_samples = [] out_samples = []
while len(out_samples) < num_samples: while len(out_samples) < num_samples:
@@ -318,9 +318,24 @@ def it214_decompress(blob: bytes, smp_offset: int, num_samples: int,
result = bytearray(len(out_samples) * 2) result = bytearray(len(out_samples) * 2)
for i, s in enumerate(out_samples): for i, s in enumerate(out_samples):
struct.pack_into('<h', result, i * 2, max(-32768, min(32767, s))) struct.pack_into('<h', result, i * 2, max(-32768, min(32767, s)))
return bytes(result) return bytes(result), pos
else: else:
return bytes(s & 0xFF for s in out_samples) return bytes(s & 0xFF for s in out_samples), pos
def it214_decompress(blob: bytes, smp_offset: int, num_samples: int,
is_16bit: bool, is_it215: bool,
is_stereo: bool = False) -> bytes:
"""Decode IT2.14/IT2.15 compressed sample data. Returns raw PCM bytes
(signed). For stereo samples, returns the left channel block followed
by the right channel block (matching IT's on-disk SF_SS layout)."""
left, pos = _it214_decompress_channel(blob, smp_offset, num_samples,
is_16bit, is_it215)
if not is_stereo:
return left
right, _ = _it214_decompress_channel(blob, pos, num_samples,
is_16bit, is_it215)
return left + right
# ── IT sample parser ────────────────────────────────────────────────────────── # ── IT sample parser ──────────────────────────────────────────────────────────
@@ -384,7 +399,7 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
try: try:
is_it215 = bool(s.cvt & 0x04) is_it215 = bool(s.cvt & 0x04)
raw = it214_decompress(data, s.smp_point, s.length, raw = it214_decompress(data, s.smp_point, s.length,
s.is_16bit, is_it215) s.is_16bit, is_it215, s.is_stereo)
s.sample_data = normalise_sample(raw, True, s.sample_data = normalise_sample(raw, True,
s.is_16bit, s.is_stereo, s.name) s.is_16bit, s.is_stereo, s.name)
s.length = len(s.sample_data) s.length = len(s.sample_data)

View File

@@ -592,7 +592,7 @@ def build_sample_inst_bin(samples: list) -> tuple:
# PT hard-pans channels in LRRL order: 0=L 1=R 2=R 3=L (and tile for >4). # PT hard-pans channels in LRRL order: 0=L 1=R 2=R 3=L (and tile for >4).
def _default_channel_pan(ch_idx: int) -> int: def _default_channel_pan(ch_idx: int) -> int:
side = (ch_idx % 4) side = (ch_idx % 4)
return 16 if side in (0, 3) else 47 return 8 if side in (0, 3) else 55
def build_pattern(grid: list, ch_idx: int, default_pan: int, def build_pattern(grid: list, ch_idx: int, default_pan: int,

View File

@@ -614,31 +614,44 @@ def build_project_data(*, project_name: str = '',
def normalise_sample(raw: bytes, signed: bool, is_16bit: bool, def normalise_sample(raw: bytes, signed: bool, is_16bit: bool,
is_stereo: bool, name: str) -> bytes: is_stereo: bool, name: str) -> bytes:
"""Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed.""" """Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed.
Stereo samples are stored as a split (non-interleaved) layout — the full
left channel block followed by the full right channel block — matching the
on-disk format used by IT, S3M, and XM (Schism's SF_SS).
"""
out = [] out = []
stride = (2 if is_16bit else 1) * (2 if is_stereo else 1) bps = 2 if is_16bit else 1
i = 0 chans = 2 if is_stereo else 1
while i + stride <= len(raw): n_frames = len(raw) // (bps * chans)
chan_bytes = n_frames * bps
for i in range(n_frames):
if is_16bit: if is_16bit:
if is_stereo: if is_stereo:
l16 = struct.unpack_from('<h', raw, i)[0] l16 = struct.unpack_from('<h', raw, i*2)[0]
r16 = struct.unpack_from('<h', raw, i+2)[0] r16 = struct.unpack_from('<h', raw, chan_bytes + i*2)[0]
s = (l16 + r16) >> 1 s = (l16 + r16) >> 1
else: else:
s = struct.unpack_from('<h', raw, i)[0] s = struct.unpack_from('<h', raw, i*2)[0]
v = (s >> 8) + 128 v = (s >> 8) + 128
else: else:
if is_stereo: if is_stereo:
l8 = raw[i]; r8 = raw[i+1] l8 = raw[i]
raw_s = (l8 + r8) // 2 r8 = raw[chan_bytes + i]
if signed:
l_s = l8 - 256 if l8 >= 0x80 else l8
r_s = r8 - 256 if r8 >= 0x80 else r8
v = ((l_s + r_s) >> 1) + 128
else:
v = (l8 + r8) >> 1
else: else:
raw_s = raw[i] raw_s = raw[i]
if signed: if signed:
v = (raw_s ^ 0x80) & 0xFF v = (raw_s ^ 0x80) & 0xFF
else: else:
v = raw_s v = raw_s
out.append(v & 0xFF) out.append(v & 0xFF)
i += stride
if is_16bit or is_stereo: if is_16bit or is_stereo:
vprint(f" info: '{name}' converted to unsigned 8-bit mono ({len(out)} samples)") vprint(f" info: '{name}' converted to unsigned 8-bit mono ({len(out)} samples)")
return bytes(out) return bytes(out)

View File

@@ -149,6 +149,42 @@ class GraphicsJSR223Delegate(private val vm: VM) {
} }
} }
fun plotRect(x: Int, y: Int, w: Int, h: Int, colour: Int) {
val xs = min(x, x+w).toLong()
val xe = max(x, x+w).toLong()
val ys = min(y, y+h).toLong()
val ye = max(y, y+h).toLong()
getFirstGPU()?.let {
for (py in ys until ye) {
for (px in xs until xe) {
if (px in 0 until it.config.width && py in 0 until it.config.height) {
it.poke(py * it.config.width + px, colour.toByte())
}
}
}
it.applyDelay()
}
}
fun plotRect2(x: Int, y: Int, w: Int, h: Int, colour: Int) {
val xs = min(x, x+w).toLong()
val xe = max(x, x+w).toLong()
val ys = min(y, y+h).toLong()
val ye = max(y, y+h).toLong()
getFirstGPU()?.let {
for (py in ys until ye) {
for (px in xs until xe) {
if (px in 0 until it.config.width && py in 0 until it.config.height) {
it.poke(262144 + py * it.config.width + px, colour.toByte())
}
}
}
it.applyDelay()
}
}
fun plotPixelMode1(x: Int, y: Int, colour: Int, plane: Int) { fun plotPixelMode1(x: Int, y: Int, colour: Int, plane: Int) {
getFirstGPU()?.let { getFirstGPU()?.let {
val planesize = it.config.width * it.config.height / 4 val planesize = it.config.width * it.config.height / 4
@@ -159,6 +195,27 @@ class GraphicsJSR223Delegate(private val vm: VM) {
} }
} }
fun plotRectMode1(x: Int, y: Int, w: Int, h: Int, colour: Int, plane: Int) {
val xs = min(x, x+w).toLong()
val xe = max(x, x+w).toLong()
val ys = min(y, y+h).toLong()
val ye = max(y, y+h).toLong()
getFirstGPU()?.let {
val halfW = it.config.width / 2
val halfH = it.config.height / 2
val planesize = it.config.width * it.config.height / 4
for (py in ys until ye) {
for (px in xs until xe) {
if (px in 0 until halfW && py in 0 until halfH) {
it.poke(py * halfW + px + planesize * plane, colour.toByte())
}
}
}
it.applyDelay()
}
}
/** /**
* Sets absolute position of scrolling * Sets absolute position of scrolling
*/ */