mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
Compare commits
9 Commits
e49140902b
...
9f01bdfee9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f01bdfee9 | ||
|
|
3c57e33f8f | ||
|
|
935fbe04a6 | ||
|
|
6b02d73600 | ||
|
|
8e6f597e9b | ||
|
|
ed3bbb6ffe | ||
|
|
27b0f2e63f | ||
|
|
dcd191b734 | ||
|
|
d706f27e18 |
@@ -37,6 +37,7 @@ Current topics:
|
||||
- `reference_materials/impulse-tracker` — The original source code for ImpulseTracker
|
||||
- `reference_materials/MilkyTracker` — FastTracker 2 compatible tracker
|
||||
- `reference_materials/schismtracker` — Open-source re-implementation of ImpulseTracker
|
||||
- `reference_materials/pt2-clone` — Open-source re-implementation of ProTracker 2
|
||||
|
||||
When fetching new references, copy the relevant upstream files verbatim into
|
||||
a topic folder, write a `README.md` summarising the relevant maths /
|
||||
|
||||
@@ -69,7 +69,9 @@ Most effects recall their last non-zero argument when re-issued with $0000. Unli
|
||||
- **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.
|
||||
Every other memory-carrying effect (D, I, J, K, L, N, O, P, Q, and others) has a private slot.
|
||||
|
||||
**Effects without recall (literal zero).** A few effects do *not* recall on $0000 — the argument is taken at face value. **M** (set channel volume), **V** (set global volume), and the volume- / panning-column SET selectors all behave this way: writing `M $0000` or `V $0000` is a literal "set to silence", not a memory recall. Converters lifting from source trackers that *do* share memory (notably ST3, where the `$00` argument may cohabit with D/E/F/etc.'s shared slot) MUST eagerly resolve the recall to an explicit value before emitting, since the Taud engine takes M / V arguments verbatim.
|
||||
|
||||
## 7. Opcode and argument format
|
||||
|
||||
@@ -179,7 +181,7 @@ Coarse and fine modes are distinguished by the high nibble of the argument:
|
||||
- **MONOTONE source** (Taud `ff = 2`):
|
||||
- MONOTONE `2xx` → Taud `E $00xx` **verbatim** (Hz/tick). The engine converts the stored pitch to frequency, subtracts the argument, and converts back. MONOTONE has no fine-slide form; converters never emit `E $Fxxx` for ff=2 sources.
|
||||
|
||||
The mode flag therefore controls **two** decoder behaviours simultaneously: (a) which numeric scale the converter should have used when emitting coarse arguments, and (b) which arithmetic the engine performs on those arguments per tick. Converters MUST set bits 1-2 (`ff`) of the song-table flags byte to match the units they emit, and MUST NOT mix scales within one Taud song.
|
||||
The mode flag therefore controls **two** decoder behaviours simultaneously: (a) which numeric scale the converter should have used when emitting coarse arguments, and (b) which arithmetic the engine performs on those arguments per tick. Converters MUST set bits 0-1 (`ff`) of the song-table flags byte to match the units they emit, and MUST NOT mix scales within one Taud song.
|
||||
|
||||
Because E and F share memory in Taud (narrower than ST3's broad shared memory), an ST3 song that used `E00` or `F00` to recall a D, G, or Q argument will break on import; the converter must eagerly resolve ST3 recalls into explicit Taud arguments rather than relying on memory.
|
||||
|
||||
@@ -404,21 +406,155 @@ The `tick_within_row mod 3` counter resets every row start (so every row begins
|
||||
|
||||
## 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.
|
||||
**Plain.** Continues the 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. The K command is implemented sorely for tracker compatibility — new compositions should prefer an explicit `H $0000` (vibrato recall) plus a volume-column slide (`1.$xy` / `2.$xy`), which carries the same semantics with one less hidden dependency.
|
||||
|
||||
**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.
|
||||
**Compatibility.** ST3 / IT `Kxy` map directly to Taud `K $xy00`: the source's `xy` argument byte goes verbatim into the high byte of the Taud argument. ProTracker / FT2 / XM `6xy` map identically. Source-tracker memory cohorts that share K's argument with D (notably the ST3 single-slot shared memory and IT's D/K/L vol-slide cohort) MUST be resolved eagerly by the converter — emit explicit arguments rather than relying on cohort sharing, since Taud's K has its own private slot.
|
||||
|
||||
**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.
|
||||
**Implementation.** On row parse:
|
||||
|
||||
```
|
||||
on row parse (K):
|
||||
raw = (arg >> 8) & 0xFF # the xy nibble pair lives in the high byte
|
||||
if raw == 0: raw = memory_K
|
||||
else: memory_K = raw
|
||||
voice.vibratoActive = true # H/U speed and depth come from memory_HU
|
||||
hi_nib = (raw >> 4) & 0xF
|
||||
lo_nib = raw & 0xF
|
||||
# Slide direction: high nibble = up, low nibble = down. Both non-zero ⇒ down wins (ST3 quirk).
|
||||
if hi_nib != 0 and lo_nib == 0:
|
||||
slide_per_tick = +hi_nib
|
||||
elif lo_nib != 0:
|
||||
slide_per_tick = -lo_nib
|
||||
else:
|
||||
slide_per_tick = 0
|
||||
|
||||
on every tick (including tick 0):
|
||||
apply vibrato update with memory_HU.speed / memory_HU.depth (see §H)
|
||||
|
||||
on tick > 0:
|
||||
channel_volume = clamp(channel_volume + slide_per_tick, 0, $3F)
|
||||
row_volume = channel_volume
|
||||
```
|
||||
|
||||
K has its own memory slot (private). The slide always uses the per-tick form — `K $FF00` does **not** trigger a fine slide; the argument's `$F` nibbles are interpreted as `$F`-magnitude per-tick slides (down wins), matching ST3's K and IT's K semantics.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
**Plain.** Continues the 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. Like K, L is implemented sorely for tracker compatibility — new compositions should prefer an explicit `G $0000` plus a volume-column slide.
|
||||
|
||||
**Compatibility.** ST3 `Lxy` maps directly. Like K, L must be equivalently implemented as `G $0000` plus a volume-column slide.
|
||||
**Compatibility.** ST3 / IT `Lxy` map directly to Taud `L $xy00`. ProTracker / FT2 / XM `5xy` map identically. As with K, source cohort recalls (ST3 shared memory; IT D/K/L vol-slide cohort) MUST be resolved eagerly by the converter; Taud's L has its own private slot.
|
||||
|
||||
**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.
|
||||
**Implementation.** Identical machinery to K with `G` swapped for the LFO update:
|
||||
|
||||
```
|
||||
on row parse (L):
|
||||
raw = (arg >> 8) & 0xFF
|
||||
if raw == 0: raw = memory_L
|
||||
else: memory_L = raw
|
||||
# Tone portamento target is set by the row's note (see §G); G's stored speed (memory_G) drives the slide.
|
||||
hi_nib = (raw >> 4) & 0xF
|
||||
lo_nib = raw & 0xF
|
||||
if hi_nib != 0 and lo_nib == 0:
|
||||
slide_per_tick = +hi_nib
|
||||
elif lo_nib != 0:
|
||||
slide_per_tick = -lo_nib
|
||||
else:
|
||||
slide_per_tick = 0
|
||||
|
||||
on tick > 0:
|
||||
apply tone-portamento step using memory_G.speed (see §G)
|
||||
channel_volume = clamp(channel_volume + slide_per_tick, 0, $3F)
|
||||
row_volume = channel_volume
|
||||
```
|
||||
|
||||
L has its own memory slot (private), separate from K's and from D's.
|
||||
|
||||
---
|
||||
|
||||
## M $xx00 — Set channel volume to $xx
|
||||
|
||||
**Plain.** Sets the channel's persistent base volume to `$xx`, in the same 6-bit `$00..$3F` range as a note's default volume. Unlike a volume-column SET (which only writes the *row* volume on a re-triggering row), M overwrites the channel's stored base volume so the change persists across subsequent rows that don't carry an explicit vol-column SET.
|
||||
|
||||
**Compatibility.** IT `Mxx` maps directly: the source byte is taken **verbatim** with a clamp to `$3F` (IT's $40 cap snaps down by one). ST3 has no native M; OpenMPT/Schism's S3M-with-IT-extensions does, and the same verbatim-with-clamp rule applies on import. M has **no memory** — `M $0000` is a literal "set channel volume to silence", not a recall. Source-tracker shared-memory recalls (e.g., ST3's single-slot shared memory) MUST be eagerly resolved by the converter before emit.
|
||||
|
||||
**Implementation.**
|
||||
|
||||
```
|
||||
on row parse (M):
|
||||
new_vol = (arg >> 8) & 0xFF
|
||||
if new_vol > 0x3F: new_vol = 0x3F
|
||||
channel_volume = new_vol
|
||||
row_volume = new_vol
|
||||
```
|
||||
|
||||
The change takes effect on tick 0 of the row. There is no slide form; for that, use N. The low byte of M's argument is reserved.
|
||||
|
||||
---
|
||||
|
||||
## N $xy00 — Channel volume slide
|
||||
|
||||
**Plain.** Slides the channel's persistent base volume by `$xy` per non-first tick (or once on tick 0 for fine forms). Encoding is identical to D (see §D), but the slide acts on `channel_volume` rather than the per-row note volume — so the change persists into following rows that don't reissue N. Range and clipping match D: `$00..$3F`.
|
||||
|
||||
**Compatibility.** IT `Nxy` maps directly to Taud `N $xy00` (high byte = source argument byte, verbatim). ST3 has no native N. N's encoding sub-forms mirror D exactly:
|
||||
|
||||
- `N $0y00` — coarse slide down by `$y` per non-first tick.
|
||||
- `N $x000` — coarse slide up by `$x` per non-first tick.
|
||||
- `N $Fy00` — fine slide down by `$y` on tick 0 only (with the same `$FF` "fine up by $F" quirk as D).
|
||||
- `N $xF00` — fine slide up by `$x` on tick 0 only.
|
||||
|
||||
**Memory.** N has its own private slot, separate from D's. `N $0000` recalls the last N argument and re-applies it in its original sub-form (coarse vs fine, up vs down).
|
||||
|
||||
**Implementation.** Identical to D, with `channel_volume` substituted for the per-row volume target. After every step the result is clamped to `$00..$3F` and `row_volume` is forced to track `channel_volume` so subsequent ticks' mixing reflects the slid value:
|
||||
|
||||
```
|
||||
on row parse (N):
|
||||
raw = (arg >> 8) & 0xFF
|
||||
if raw == 0: raw = memory_N
|
||||
else: memory_N = raw
|
||||
decode raw exactly as D does (FF / F0 / Fy / xF / 0y / x0 → fine-up-F / coarse / fine forms)
|
||||
schedule per-tick (or apply once) on channel_volume; row_volume = channel_volume after each step
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P $xy00 — Channel panning slide
|
||||
|
||||
**Plain.** Slides the channel's persistent pan by `$xy` per non-first tick (or once on tick 0 for fine forms). Encoding is layered on D's structural skeleton, but the *direction* of each nibble follows the IT panning convention: the low nibble of the high byte slides **right**, the high nibble of the high byte slides **left**. Pan ranges over the full 8-bit space (`$00`..`$FF`, $80 centre); P writes the persistent `channel_pan` so the change persists across rows.
|
||||
|
||||
**Compatibility.** IT `Pxy` maps directly to Taud `P $xy00` (high byte = source argument byte, verbatim). ST3 has no native P. The four sub-forms are:
|
||||
|
||||
- `P $0y00` — slide right by `$y` per non-first tick.
|
||||
- `P $x000` — slide left by `$x` per non-first tick.
|
||||
- `P $Fy00` — fine slide right by `$y` on tick 0 only.
|
||||
- `P $xF00` — fine slide left by `$x` on tick 0 only.
|
||||
|
||||
The `$FF` corner case (`P $FF00`) follows the D / N quirk: it is interpreted as "fine slide left by `$F`" (the high-nibble form wins when both nibbles are `$F`).
|
||||
|
||||
**Memory.** P has its own private slot, separate from D / N. `P $0000` recalls the last P argument and re-applies it in its original sub-form.
|
||||
|
||||
**Implementation.**
|
||||
|
||||
```
|
||||
on row parse (P):
|
||||
raw = (arg >> 8) & 0xFF
|
||||
if raw == 0: raw = memory_P
|
||||
else: memory_P = raw
|
||||
hi_nib = (raw >> 4) & 0xF
|
||||
lo_nib = raw & 0xF
|
||||
if raw == 0xFF or (hi_nib == 0xF and lo_nib == 0): apply fine-left-by-F on tick 0
|
||||
elif hi_nib == 0xF and lo_nib != 0: apply fine-right-by-lo_nib on tick 0
|
||||
elif lo_nib == 0xF and hi_nib != 0: apply fine-left-by-hi_nib on tick 0
|
||||
elif hi_nib == 0 and lo_nib != 0: per-tick: channel_pan += lo_nib (right)
|
||||
elif lo_nib == 0 and hi_nib != 0: per-tick: channel_pan -= hi_nib (left)
|
||||
|
||||
on every per-tick or fine step:
|
||||
channel_pan = clamp(channel_pan ± step, 0, 0xFF)
|
||||
row_pan = channel_pan >> 2 # 6-bit pan value used by the mixer
|
||||
```
|
||||
|
||||
The mixer reads `channel_pan` (8-bit) directly through the same path as `S $80xx`. P slides interact additively with panbrello (Y) and the panning column's slide selectors, but P has the highest precedence on `channel_pan` because it writes the persistent value rather than a per-row delta.
|
||||
|
||||
---
|
||||
|
||||
@@ -839,9 +975,7 @@ The background pool is reaped when a ghost's `fadeoutVolume` drops to zero or it
|
||||
|
||||
**Plain.** Sets the channel pan to `$xx`, with $00 being full left and $FF being full right. $80 is centre. When this command and panning column's Set Pan are both present, this command takes precedence.
|
||||
|
||||
**Compatibility.** IT `Xxx` maps directly. ST3 `S8x` uses a 4-bit value.
|
||||
1. 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.
|
||||
2. convert to PanEff: ST3 `S8x` → PanEff `0.yy`, where `yy = round(4.2 * x)`
|
||||
**Compatibility.** IT `Xxx` maps directly. 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.
|
||||
|
||||
@@ -924,11 +1058,10 @@ funk_table[16] = { 0, 5, 6, 7, 8, $A, $B, $D, $10, $13, $16, $1A, $20, $2B, $40,
|
||||
|
||||
on every tick (when S $Fxxxx is active with x != 0):
|
||||
funk_accumulator += funk_length
|
||||
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
|
||||
if funk_accumulator >= $80: # hard reset, drops residual
|
||||
funk_accumulator = 0
|
||||
funk_write_pos = (funk_write_pos + 1) mod loop_length # pre-increment
|
||||
funk_mask[funk_write_pos] = funk_mask[funk_write_pos] XOR 1
|
||||
|
||||
on sample byte read during loop playback:
|
||||
raw_byte = sample_data[offset_in_loop]
|
||||
@@ -938,7 +1071,7 @@ on sample byte read during loop playback:
|
||||
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**.
|
||||
`S $F000` clears `funk_accumulator` but leaves `funk_mask` intact (the accumulated inversion pattern persists). **On every fresh note trigger**, `funk_write_pos` resets to 0 (matching PT2's `n_wavestart = n_loopstart`); `funk_accumulator` and `funk_speed` persist across notes. The `funk_mask` itself is **only cleared on cue-start reset** (i.e. song-start / stop-and-replay) — within a single playback session it accumulates as PT2's destructive in-place edits would, but a clean replay always reproduces the same audio without needing to reload the song from disk.
|
||||
|
||||
---
|
||||
|
||||
@@ -980,16 +1113,13 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
|
||||
|
||||
**Plain.** Sets mixer-wide behaviour flags. Available flags are:
|
||||
|
||||
0b 0000 0ffp
|
||||
|
||||
- p unset: Linear panning mode (tracker-accurate). Centre panning gets 3 dB boost. Default setting.
|
||||
- p set: Equal-power panning mode. L/R amplitude is at 0.707 when centre-panned.
|
||||
0b 0000 00ff
|
||||
|
||||
- ff = 0: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
|
||||
- ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
|
||||
- ff = 2: Linear-frequency tone mode (MONOTONE compat). **E, F, and G arguments are stored as Hz/tick** (a signed change in audible frequency per song tick), and the engine converts the channel's stored 4096-TET pitch back to a frequency, adds/subtracts the argument, then converts back to 4096-TET. Reference is fixed at 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, which matches MONOTONE's MT_PLAY.PAS `notesHz` table (A0 = 27.5 Hz, equal-temperament). Unlike Amiga mode, *all three* slide effects use the new arithmetic — Monotone's `1xx`, `2xx`, and `3xx` are all in Hz/tick (see MTSRC/MT_PLAY.PAS:606-630).
|
||||
|
||||
(Bit 2 is reserved. It previously held an `m` "fadeout-zero policy" flag intended to swap between IT and FT2 semantics for `storedFadeout = 0`. That flag was removed once both trackers were verified to share identical "stored 0 ⇒ no fade" semantics — see schismtracker `player/sndmix.c:330-342` and ft2-clone `src/ft2_replayer.c:1467-1481`. Fadeout scaling now lives in the converters; see "Volume Fadeout" below.)
|
||||
(Bits 2-7 are reserved. Bit 2 previously held an `m` "fadeout-zero policy" flag intended to swap between IT and FT2 semantics for `storedFadeout = 0`. That flag was removed once both trackers were verified to share identical "stored 0 ⇒ no fade" semantics — see schismtracker `player/sndmix.c:330-342` and ft2-clone `src/ft2_replayer.c:1467-1481`. Fadeout scaling now lives in the converters; see "Volume Fadeout" below.)
|
||||
|
||||
### Volume Fadeout
|
||||
|
||||
@@ -1034,10 +1164,7 @@ There is no separate "use fadeout" flag — both extremes share the same field,
|
||||
- **MOD / S3M / MON**: source has no instrument-level fadeout. Converter writes Taud `0`. Notes retire on sample-end or pattern note-cut.
|
||||
|
||||
**Implementation.**
|
||||
- Panning-linear:
|
||||
- L_gain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0
|
||||
- R_gain = if (pan < 0x80) pan / 128.0 else 1.0
|
||||
- Panning-equal-power:
|
||||
- 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:
|
||||
@@ -1062,14 +1189,14 @@ There is no separate "use fadeout" flag — both extremes share the same field,
|
||||
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 $00xx` (Amiga mode, `f` set) | Portamento up; raw PT period units, applied in period space |
|
||||
| `2 $xx` | `E $00xx` (Amiga mode, `f` set) | Portamento down; raw PT period units, applied in period space |
|
||||
| `3 $xx` | `G round($0xxx × 64/3)` | Portamento to note; G is always linear (4096-TET units) regardless of mode |
|
||||
| `4 $xy` | `H $xxyy` | Vibrato; nibble-repeat each byte. |
|
||||
| `5 $xy` | `L $xy00` | Combined portamento + volume slide (see compatibility note) |
|
||||
| `6 $xy` | `K $xy00` | Combined vibrato + volume slide (see compatibility note) |
|
||||
| `5 $xy` | `L $xy00` | Combined portamento + volume slide; argument byte verbatim (PT `500` recall is resolved to the previous 5xy by the converter, then emitted as L $xy00) |
|
||||
| `6 $xy` | `K $xy00` | Combined vibrato + volume slide; argument byte verbatim (PT `600` recall is resolved to the previous 6xy by the converter, then emitted as K $xy00) |
|
||||
| `7 $xy` | `R $xxyy` | Tremolo; nibble-repeat |
|
||||
| `8 $xx` | `S $80xx` or panning column `0.$xx` | Fine pan |
|
||||
| `9 $xx` | `O $xx00` | Sample offset |
|
||||
@@ -1092,7 +1219,7 @@ This table maps each PT effect to its Taud equivalent. Arguments follow PT's two
|
||||
| `E $Cx` | `S $Cx00` | Note cut |
|
||||
| `E $Dx` | `S $Dx00` | Note delay |
|
||||
| `E $Ex` | `S $Ex00` | Pattern delay |
|
||||
| `E $Fx` | `S $Fx00` | Funk repeat |
|
||||
| `E $Fx` | `S $Fyyy` | Funk repeat, where `yyy = funk_table[x]` |
|
||||
| `F $xx` (xx < $20) | `A $xx00` | Set speed |
|
||||
| `F $xx` (xx ≥ $20) | `T $(xx−$18)00` | Set tempo |
|
||||
|
||||
@@ -1104,6 +1231,8 @@ These quirks of ST3 are worth preserving or flagging when importing S3M files in
|
||||
|
||||
**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.
|
||||
|
||||
**M / N / P (channel volume and panning).** S3M files produced by IT-aware tools embed M (set channel volume), N (channel volume slide), and P (channel panning slide) using the IT semantics described in §M / §N / §P. These are emitted verbatim into Taud (with M's argument byte clamped to $3F). N and P each have private memory; M is literal-zero. ST3 itself never wrote M / N / P, so legacy S3M files contain none.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -111,12 +111,12 @@ G:"Portamento ",
|
||||
H:"Vibrato ",
|
||||
I:"Tremor ",
|
||||
J:"Arpeggio ",
|
||||
K:"UNIMPLEMENTED", // Volume slide+Vibrato. Use H0000 and VolEff instead
|
||||
L:"UNIMPLEMENTED", // Volume slide+Portamento. Use G0000 and VolEff instead
|
||||
M:"UNIMPLEMENTED", // IT: Set channel volume. Use VolEff instead
|
||||
N:"UNIMPLEMENTED", // IT: Channel volume slide. Use VolEff instead
|
||||
K:"Vibrafade ",
|
||||
L:"Portafade ",
|
||||
M:"Channel vol ",
|
||||
N:"Chan.volslide",
|
||||
O:"Sample offset",
|
||||
P:"UNIMPLEMENTED", // IT: panning slide. Use PanEff instead
|
||||
P:"Chan.panslide",
|
||||
Q:"Retrigger ",
|
||||
R:"Tremolo ",
|
||||
S:"Special ",
|
||||
@@ -130,12 +130,12 @@ S6:"Fine delay ",
|
||||
S7:"Note action ",
|
||||
S8:"Channel pan ", // Taud: 8-bit channel panning
|
||||
S9:"UNIMPLEMENTED", // IT: Sound control
|
||||
SA:"UNIMPLEMENTED", // SC3: Stereo control. IT: Sample offset high twobyte (not applicable because Taud has 64k limit)
|
||||
SA:"UNIMPLEMENTED", // ST3: Stereo control. IT: Sample offset high twobyte (not applicable because Taud has 64k limit)
|
||||
SB:"Pattern loop ",
|
||||
SC:"Note cut ",
|
||||
SD:"Note delay ",
|
||||
SE:"Pattern delay",
|
||||
SF:"Funk it ",
|
||||
SF:"Funk repeat ",
|
||||
T:"Tempo ",
|
||||
U:"Fine vibrato ",
|
||||
V:"Global volume",
|
||||
@@ -534,7 +534,7 @@ function loadTaud(filePath, songIndex) {
|
||||
|
||||
return {
|
||||
filePath, version, numSongs, numVoices, numPats,
|
||||
bpm: (bpmStored + 24) & 0xFF, tickRate,
|
||||
bpm: bpmStored + 25, tickRate,
|
||||
patterns, cues, lastActiveCue
|
||||
}
|
||||
}
|
||||
@@ -1791,7 +1791,7 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
let bpm = audio.getBPM(PLAYHEAD) // best-effort starting tempo
|
||||
let speed = audio.getTickRate(PLAYHEAD)
|
||||
let globalVol = 0xFF
|
||||
let panLaw = 0, toneMode = 0 // toneMode: 0=linear, 1=Amiga, 2=linear-freq, 3=reserved
|
||||
let toneMode = 0 // 0=linear, 1=Amiga, 2=linear-freq, 3=reserved
|
||||
|
||||
let memEF = 0, memG = 0
|
||||
let memHU = { speed: 0, depth: 0 }
|
||||
@@ -1826,11 +1826,14 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
const isGRow = (effop === OP_G)
|
||||
const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD)
|
||||
// Track whether this row reloads the channel's default volume. Engine:
|
||||
// triggerNote() resets channelVolume to 0x3F only when the row carries an
|
||||
// instrument byte; a note-only retrigger (inst === 0) inherits the
|
||||
// channel's existing volume. Tone-porta rows follow the same rule —
|
||||
// an instrument byte on a porta row reloads default vol (matches
|
||||
// schism csf_instrument_change inst_column branch).
|
||||
// triggerNote() (and the tone-porta-with-inst branch in advanceRow)
|
||||
// seed channelVolume from the instrument's Default Note Volume (byte
|
||||
// 196) — only when the row carries an instrument byte; a note-only
|
||||
// retrigger (inst === 0) inherits the channel's existing volume.
|
||||
// Tone-porta rows follow the same rule (matches schism
|
||||
// csf_instrument_change inst_column branch, effects.c:1302).
|
||||
// The simulator approximates the seed as 0x3F (legacy fallback) — see
|
||||
// the longer note below the reload block for the limitation.
|
||||
let reloadDefaultVol = false
|
||||
if (note !== 0xFFFF && note !== 0xFFFE) {
|
||||
if (note === 0x0000) {
|
||||
@@ -1858,7 +1861,12 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
// Pan: simulator does not track per-instrument default pan, so it never resets
|
||||
// panAbs on trigger — this naturally matches the "stay at old value when inst === 0"
|
||||
// half of the policy. The engine-side default-pan reload (gated on inst !== 0)
|
||||
// is invisible here.
|
||||
// is invisible here. Same limitation now applies to default volume: the engine
|
||||
// seeds rowVolume from the instrument's byte-196 "Default Note Volume" since
|
||||
// 2026-05-09 (terranmon §171, §196), but the simulator has no instrument-byte
|
||||
// access, so it falls back to 0x3F — equivalent to the legacy "DNV unset"
|
||||
// path. Tracker UI displays may therefore show a slightly off row volume on
|
||||
// fresh triggers when the instrument carries a reduced DNV.
|
||||
if (reloadDefaultVol) volAbs = 0x3F
|
||||
|
||||
// Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET).
|
||||
@@ -1901,9 +1909,7 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
if (effop !== 0 || effarg !== 0) {
|
||||
if (effop === OP_1) {
|
||||
const flags = (effarg >>> 8) & 0xFF
|
||||
panLaw = flags & 1
|
||||
toneMode = (flags >>> 1) & 3
|
||||
// bit 2 reserved (was 'm' fadeout-zero policy; removed)
|
||||
toneMode = flags & 3
|
||||
}
|
||||
else if (effop === OP_8) {
|
||||
const x = (effarg >>> 12) & 0xF
|
||||
@@ -2007,7 +2013,7 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
else if (effop === OP_T) {
|
||||
const hi = (effarg >>> 8) & 0xFF
|
||||
if (hi !== 0) {
|
||||
bpm = Math.max(24, Math.min(280, hi + 0x18))
|
||||
bpm = Math.max(25, Math.min(280, hi + 0x19))
|
||||
} else {
|
||||
const low = effarg & 0xFF
|
||||
if ((low & 0xF0) === 0x00 || (low & 0xF0) === 0x10) memTSlide = low
|
||||
@@ -2041,7 +2047,7 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
|
||||
return { lastNote, lastInst, volAbs, panAbs, pitchOff,
|
||||
bpm, speed, globalVol,
|
||||
panLaw, toneMode,
|
||||
toneMode,
|
||||
bitcrushDepth, bitcrushSkip, overdriveAmp, clipMode,
|
||||
glissandoOn, vibratoWave, tremoloWave, panbrelloWave,
|
||||
memEF, memG, memHU, memR, memY,
|
||||
@@ -2242,19 +2248,8 @@ function drawProjectContents(wo) {
|
||||
for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colBackPtn, 255)
|
||||
|
||||
let mixerflag = initialTrackerMixerflags
|
||||
let flagStrSelected = []
|
||||
let flagstr = [
|
||||
['Linear pan','EquNrg pan'],
|
||||
['Linear pitch','Amiga pitch', 'Linear freq', ''], // TODO MONOTONE uses linear-freq pitch
|
||||
]
|
||||
for (let i = 0; i < flagstr.length; i++) {
|
||||
if (i != 1 && 1 != 2) {
|
||||
let s = flagstr[i][(mixerflag >>> i) & 1 != 0]
|
||||
flagStrSelected.push(s)
|
||||
}
|
||||
}
|
||||
let toneMode = (mixerflag >>> 1) & 3
|
||||
flagStrSelected.splice(1, 0, flagstr[1][toneMode])
|
||||
let toneModeStr = ['Linear pitch','Amiga pitch','Linear freq',''][mixerflag & 3]
|
||||
let flagStrSelected = [toneModeStr]
|
||||
|
||||
|
||||
let projMeta = {
|
||||
|
||||
@@ -10,7 +10,15 @@ const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSV
|
||||
const TAUD_VERSION = 1
|
||||
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + sig(14)
|
||||
const TAUD_SONG_ENTRY = 32 // see encodeSongEntry / decodeSongEntry below
|
||||
const SAMPLEINST_SIZE = 786432 // 737280 sample + 49152 instrument (256 × 192)
|
||||
// Sample+instrument image: 8 MB sample pool (banked, 16 × 512 K) + 64 K instrument bin = 8256 kB total.
|
||||
// (terranmon.txt:1985-1997, 2533-2564 — bank-switched via MMIO 46.)
|
||||
const SAMPLE_BANK_SIZE = 524288 // 512 K — size of the sample-bin window
|
||||
const SAMPLE_BANK_COUNT = 16 // 16 banks × 512 K = 8 MB
|
||||
const SAMPLEBIN_SIZE = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT // 8 MB
|
||||
const INSTBIN_SIZE = 65536 // 256 inst × 256 bytes
|
||||
const SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE // 8454144 = 8256 kB
|
||||
const SAMPLEBIN_WINDOW_OFFSET = 0 // peripheral memory window for the active sample bank
|
||||
const INSTBIN_WINDOW_OFFSET = 720896 // peripheral memory offset of instrument bin
|
||||
const PATTERN_SIZE = 512 // bytes per pattern (64 rows × 8 bytes)
|
||||
const NUM_PATTERNS_MAX = 256
|
||||
const NUM_CUES = 1024
|
||||
@@ -88,18 +96,14 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
}
|
||||
|
||||
// -- 4. Decompress and upload sample+instrument bin -----------------------
|
||||
let decompPtr = sys.malloc(SAMPLEINST_SIZE)
|
||||
gzip.decompFromTo(filePtr + pos, compressedSize, decompPtr)
|
||||
// The decompressed image is 8256 kB (8 MB samples bank-major + 64 K instruments)
|
||||
// which exceeds the 8 MB user-space cap, so we route through a hardware helper
|
||||
// that decompresses straight into the adapter's native sample/instrument
|
||||
// storage instead of staging a buffer in user memory.
|
||||
audio.uploadSampleInstBlob(filePtr + pos, compressedSize)
|
||||
audio.setSampleBank(0)
|
||||
pos += compressedSize
|
||||
|
||||
// Write decompressed data to peripheral memory (backwards addressing:
|
||||
// peripheral byte k lives at memBase - k).
|
||||
for (let i = 0; i < SAMPLEINST_SIZE; i++) {
|
||||
// TODO use sys.memcpy
|
||||
sys.poke(memBase - i, sys.peek(decompPtr + i))
|
||||
}
|
||||
sys.free(decompPtr)
|
||||
|
||||
// -- 5. Parse song-table entry for the requested song --------------------
|
||||
let entryOff = pos + songIndex * TAUD_SONG_ENTRY
|
||||
let songOffset = _peekU32LE(filePtr, entryOff)
|
||||
@@ -173,14 +177,19 @@ function captureTrackerDataToFile(outFile) {
|
||||
const baseAddr = audio.getBaseAddr()
|
||||
|
||||
// -- 1. Compress sample+instrument bin ------------------------------------
|
||||
// sys.memcpy(negative_src, positive_dst, len) copies peripheral byte k from
|
||||
// (memBase - k) into (sampleInstBuf + k).
|
||||
let sampleInstBuf = sys.malloc(SAMPLEINST_SIZE)
|
||||
sys.memcpy(memBase, sampleInstBuf, SAMPLEINST_SIZE)
|
||||
|
||||
let compBuf = sys.malloc(SAMPLEINST_SIZE + 4096) // headroom for incompressible data
|
||||
let compressedSize = gzip.compFromTo(sampleInstBuf, SAMPLEINST_SIZE, compBuf)
|
||||
sys.free(sampleInstBuf)
|
||||
// The 8256 kB raw image (8 MB samples + 64 K instruments) cannot fit in the
|
||||
// 8 MB user space, so we hand the entire compress step to a hardware helper
|
||||
// that reads directly out of the adapter's native sample/instrument storage.
|
||||
// Realistic sample data compresses well under both gzip and zstd; we cap the
|
||||
// destination at "uncompressed size + 8 K" headroom which suffices for any
|
||||
// sane musical content.
|
||||
const COMP_BUF_CAP = 1024 * 1024 * 4 // 4 MiB cap for compressed sample+inst blob
|
||||
let compBuf = sys.malloc(COMP_BUF_CAP)
|
||||
let compressedSize = audio.captureSampleInstBlob(compBuf, COMP_BUF_CAP)
|
||||
if (compressedSize > COMP_BUF_CAP) {
|
||||
sys.free(compBuf)
|
||||
throw Error("taud: compressed sample+inst blob exceeded " + COMP_BUF_CAP + " bytes (got " + compressedSize + ")")
|
||||
}
|
||||
|
||||
// -- 2. Find last non-empty pattern in bank 0 (all-zero = uninitialized) --
|
||||
let numPatsActual = 0
|
||||
|
||||
321
it2taud.py
321
it2taud.py
@@ -35,26 +35,27 @@ Effect support:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from taud_common import (
|
||||
set_verbose, vprint,
|
||||
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
|
||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
|
||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT,
|
||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
|
||||
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
|
||||
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y,
|
||||
TOP_J, TOP_K, TOP_L, TOP_M, TOP_N, TOP_O, TOP_P, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y,
|
||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||
EFF_A, EFF_B, EFF_C, EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J,
|
||||
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
|
||||
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry, nearest_minifloat,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects_per_slot,
|
||||
encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob,
|
||||
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
|
||||
build_project_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -702,39 +703,80 @@ def encode_note_it(it_note: int) -> int:
|
||||
|
||||
# ── Vol-column decoder ────────────────────────────────────────────────────────
|
||||
|
||||
def decode_volcol(vc: int):
|
||||
"""Return (vol_sel, vol_value, pan_set, aux_effect) or None for each field."""
|
||||
def decode_volcol(vc: int, recall_volslide: int = 0):
|
||||
"""Return (vol_sel, vol_value, pan_set, aux_effect) or None for each field.
|
||||
|
||||
IT vol-col x=0 means "recall last value" for the relevant memory cohort
|
||||
(Schism player/effects.c:2097-2137 — Ax/Bx/Cx/Dx share `mem_vc_volslide`,
|
||||
a per-channel slot separate from the main column's D memory; Ex/Fx share
|
||||
`mem_pitchslide` with the main effect column; Gx shares `mem_portanote`
|
||||
with main G; Hx uses the channel's vibrato state). For pitch/porta/
|
||||
vibrato we emit Taud E/F/G/H with arg=0 so Taud's own private (E/F-cohort,
|
||||
G, H/U-cohort) memory recalls naturally. For volume slides Taud has no
|
||||
recall in the volume column, so the converter passes `recall_volslide`
|
||||
(the per-channel A/B/C/D shared memory tracked by build_pattern_it) and
|
||||
substitutes it when x=0.
|
||||
"""
|
||||
if vc < 0: # not set
|
||||
return SEL_FINE, 0, None, None
|
||||
if vc <= VC_VOL_HI:
|
||||
return SEL_SET, min(vc, 0x3F), None, None
|
||||
if VC_FVUP_LO <= vc <= VC_FVUP_HI:
|
||||
mag = vc - VC_FVUP_LO + 1 # 1..10
|
||||
mag = vc - VC_FVUP_LO # 0..9 — Schism fmt/it.c:234
|
||||
if mag == 0:
|
||||
mag = recall_volslide
|
||||
if mag == 0:
|
||||
return SEL_FINE, 0, None, None
|
||||
return SEL_FINE, (mag & 0x1F) | 0x20, None, None # fine up
|
||||
if VC_FVDN_LO <= vc <= VC_FVDN_HI:
|
||||
mag = vc - VC_FVDN_LO + 1
|
||||
mag = vc - VC_FVDN_LO
|
||||
if mag == 0:
|
||||
mag = recall_volslide
|
||||
if mag == 0:
|
||||
return SEL_FINE, 0, None, None
|
||||
return SEL_FINE, mag & 0x1F, None, None # fine down
|
||||
if VC_VUP_LO <= vc <= VC_VUP_HI:
|
||||
return SEL_UP, vc - VC_VUP_LO + 1, None, None
|
||||
mag = vc - VC_VUP_LO
|
||||
if mag == 0:
|
||||
mag = recall_volslide
|
||||
if mag == 0:
|
||||
return SEL_FINE, 0, None, None
|
||||
return SEL_UP, mag, None, None
|
||||
if VC_VDN_LO <= vc <= VC_VDN_HI:
|
||||
return SEL_DOWN, vc - VC_VDN_LO + 1, None, None
|
||||
mag = vc - VC_VDN_LO
|
||||
if mag == 0:
|
||||
mag = recall_volslide
|
||||
if mag == 0:
|
||||
return SEL_FINE, 0, None, None
|
||||
return SEL_DOWN, mag, None, None
|
||||
if VC_PDN_LO <= vc <= VC_PDN_HI:
|
||||
# Pitch slide down: each unit = 4 ST3 coarse units (1/16 semitone each)
|
||||
units = (vc - VC_PDN_LO + 1) * 4
|
||||
# IT vol-col Ex slides pitch down by 4×e raw IT period units (Schism
|
||||
# player/effects.c:294-298). e=0 recalls mem_pitchslide; emit
|
||||
# E $0000 so Taud's E/F-cohort memory supplies the value.
|
||||
e = vc - VC_PDN_LO
|
||||
units = e * 4
|
||||
return SEL_FINE, 0, None, (EFF_E, units & 0xFF)
|
||||
if VC_PUP_LO <= vc <= VC_PUP_HI:
|
||||
units = (vc - VC_PUP_LO + 1) * 4
|
||||
f = vc - VC_PUP_LO
|
||||
units = f * 4
|
||||
return SEL_FINE, 0, None, (EFF_F, units & 0xFF)
|
||||
if VC_PAN_LO <= vc <= VC_PAN_HI:
|
||||
pan64 = vc - VC_PAN_LO # 0..64
|
||||
pan6 = min(0x3F, round(pan64 * 63 / 64))
|
||||
return SEL_FINE, 0, pan6, None
|
||||
if VC_TPORTA_LO <= vc <= VC_TPORTA_HI:
|
||||
spd = VC_TPORTA_TABLE[vc - VC_TPORTA_LO]
|
||||
# IT Gg tone-porta speed: VC_TPORTA_TABLE[0]=0 → g=0 recalls
|
||||
# mem_portanote. Emit G $0000; Taud's private G memory recalls.
|
||||
g = vc - VC_TPORTA_LO
|
||||
spd = VC_TPORTA_TABLE[g]
|
||||
return SEL_FINE, 0, None, (EFF_G, spd & 0xFF)
|
||||
if VC_VIB_LO <= vc <= VC_VIB_HI:
|
||||
depth = vc - VC_VIB_LO + 1 # 1..10
|
||||
return SEL_FINE, 0, None, (EFF_H, depth & 0x0F)
|
||||
# IT Hh sets vibrato depth (low nybble only) and runs vibrato with
|
||||
# the channel's current vibrato_speed (Schism player/effects.c:391-398
|
||||
# via fx_vibrato). h=0 keeps the existing depth; emit H $0000 so
|
||||
# Taud's H/U cohort memory supplies both speed and depth.
|
||||
h = vc - VC_VIB_LO
|
||||
return SEL_FINE, 0, None, (EFF_H, h & 0x0F)
|
||||
return SEL_FINE, 0, None, None
|
||||
|
||||
|
||||
@@ -809,22 +851,28 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
return (TOP_J, (J_SEMI_TABLE[hi_semi] << 8) | J_SEMI_TABLE[lo_semi], None, None)
|
||||
|
||||
if cmd == EFF_K:
|
||||
return (TOP_H, 0x0000, d_arg_to_col(arg), None)
|
||||
# K = vibrato continuation + vol slide; emitted verbatim. IT's D/K/L
|
||||
# shared cohort is already resolved upstream by resolve_it_recalls.
|
||||
return (TOP_K, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_L:
|
||||
return (TOP_G, 0x0000, d_arg_to_col(arg), None)
|
||||
# L = tone-porta continuation + vol slide; emitted verbatim.
|
||||
return (TOP_L, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_M:
|
||||
return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None)
|
||||
# M = set channel volume; literal byte (no recall). Clamp IT $40 → $3F.
|
||||
return (TOP_M, (min(arg, 0x3F) & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_N:
|
||||
return (TOP_NONE, 0, d_arg_to_col(arg), None)
|
||||
# N = channel volume slide; D-style encoding.
|
||||
return (TOP_N, (arg & 0xFF) << 8, None, 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))
|
||||
# P = channel panning slide; D-style encoding (low nib = right, high nib = left).
|
||||
return (TOP_P, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_Q:
|
||||
return (TOP_Q, (arg & 0xFF) << 8, None, None)
|
||||
@@ -859,7 +907,7 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
|
||||
if cmd == EFF_T:
|
||||
if arg >= 0x20:
|
||||
return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None)
|
||||
return (TOP_T, ((arg - 0x19) & 0xFF) << 8, None, None)
|
||||
return (TOP_T, arg & 0xFF, None, None)
|
||||
|
||||
if cmd == EFF_V:
|
||||
@@ -1044,25 +1092,60 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
||||
sample_detune, nna, dct, dca.
|
||||
All optional; missing keys default to neutral values.
|
||||
|
||||
Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict).
|
||||
Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict, slot_ratios) where
|
||||
slot_ratios maps Taud slot index → effective TOP_O scale (combined
|
||||
global × per-sample resample ratio).
|
||||
"""
|
||||
pcm_list = [(i, s) for i, s in enumerate(samples_or_proxy)
|
||||
if s is not None and s.sample_data]
|
||||
|
||||
def _scale_sample(s, r):
|
||||
s.sample_data = resample_linear(s.sample_data, r)
|
||||
s.length = len(s.sample_data)
|
||||
s.loop_beg = max(0, int(s.loop_beg * r))
|
||||
s.loop_end = max(0, min(int(s.loop_end * r), s.length))
|
||||
s.sus_beg = max(0, int(s.sus_beg * r))
|
||||
s.sus_end = max(0, min(int(s.sus_end * r), s.length))
|
||||
s.c5_speed = max(1, int(s.c5_speed * r))
|
||||
|
||||
# ── Pass 1: global pool-overflow resample (8 MB cap) ────────────────────
|
||||
total = sum(len(s.sample_data) for _, s in pcm_list)
|
||||
ratio = 1.0
|
||||
global_ratio = 1.0
|
||||
if total > SAMPLEBIN_SIZE:
|
||||
ratio = SAMPLEBIN_SIZE / total
|
||||
vprint(f" info: sample bin overflow ({total} bytes); resampling all by {ratio:.4f}")
|
||||
global_ratio = SAMPLEBIN_SIZE / total
|
||||
vprint(f" info: sample bin overflow ({total} bytes); resampling all by {global_ratio:.4f}")
|
||||
seen_g = set()
|
||||
for _, s in pcm_list:
|
||||
new_data = resample_linear(s.sample_data, ratio)
|
||||
s.sample_data = new_data
|
||||
s.length = len(new_data)
|
||||
s.loop_beg = max(0, int(s.loop_beg * ratio))
|
||||
s.loop_end = max(0, min(int(s.loop_end * ratio), s.length))
|
||||
s.sus_beg = max(0, int(s.sus_beg * ratio))
|
||||
s.sus_end = max(0, min(int(s.sus_end * ratio), s.length))
|
||||
s.c5_speed = max(1, int(s.c5_speed * ratio))
|
||||
if id(s) in seen_g:
|
||||
continue
|
||||
seen_g.add(id(s))
|
||||
_scale_sample(s, global_ratio)
|
||||
|
||||
# ── Pass 2: per-sample u16 cap (each sample must fit in 65535 bytes) ────
|
||||
# The Taud instrument record stores the sample length as u16, and TOP_O
|
||||
# offsets address up to 0xFF00 bytes — anything longer would silently
|
||||
# truncate at load time and over-shoot O-jumps. Resample only the
|
||||
# over-long samples and remember each one's individual ratio so the
|
||||
# caller can rescale TOP_O args per channel rather than globally.
|
||||
per_sample_ratio = {} # id(s) → per-sample ratio (after global)
|
||||
seen_p = set()
|
||||
for _, s in pcm_list:
|
||||
if id(s) in seen_p:
|
||||
continue
|
||||
seen_p.add(id(s))
|
||||
if len(s.sample_data) > SAMPLE_LEN_LIMIT:
|
||||
r = SAMPLE_LEN_LIMIT / len(s.sample_data)
|
||||
vprint(f" info: '{s.name}' exceeds {SAMPLE_LEN_LIMIT}-byte cap "
|
||||
f"({len(s.sample_data)}); resampling by {r:.4f}")
|
||||
_scale_sample(s, r)
|
||||
per_sample_ratio[id(s)] = r
|
||||
|
||||
# Effective slot → ratio for TOP_O rescaling. Slots sharing a sample
|
||||
# object (IT use_instruments mode) get the same ratio.
|
||||
slot_ratios = {}
|
||||
for slot_idx, s in pcm_list:
|
||||
slot_ratios[slot_idx] = global_ratio * per_sample_ratio.get(id(s), 1.0)
|
||||
ratio = slot_ratios
|
||||
|
||||
sample_bin = bytearray(SAMPLEBIN_SIZE)
|
||||
offsets = {}
|
||||
@@ -1158,14 +1241,21 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
||||
pan_env_sus = idata.get('pan_env_sus', 0)
|
||||
pf_env_loop = idata.get('pf_env_loop', 0)
|
||||
pf_env_sus = idata.get('pf_env_sus', 0)
|
||||
# Sample-mode default IGV: fold sample default vol (Sv) and sample GV
|
||||
# into Taud's IGV. Instrument-mode supplies inst_gv pre-folded.
|
||||
# Sample-mode default IGV is now a pure continuous multiplier
|
||||
# (sample.gv only — there is no inst.gv in IT sample mode). The
|
||||
# samplewise default vol (Sv) is carried separately by byte 196.
|
||||
# Instrument-mode supplies both inst_gv and default_note_vol pre-
|
||||
# computed in the upstream proxy walk.
|
||||
if 'inst_gv' in idata:
|
||||
inst_gv = idata['inst_gv']
|
||||
else:
|
||||
smp_vol_default = min(getattr(s, 'vol', 64), 64)
|
||||
smp_gv_default = min(getattr(s, 'gv', 64), 64)
|
||||
inst_gv = min(255, round(smp_vol_default * smp_gv_default * 255 / (64 * 64)))
|
||||
inst_gv = min(255, round(smp_gv_default * 255 / 64))
|
||||
if 'default_note_vol' in idata:
|
||||
default_note_vol = idata['default_note_vol']
|
||||
else:
|
||||
smp_vol_default = min(getattr(s, 'vol', 64), 64)
|
||||
default_note_vol = min(255, round(smp_vol_default * 255 / 64))
|
||||
# IT fadeout (file-stored 0..1024 per ITTECH; some loaders accept up to 2048) maps
|
||||
# verbatim to Taud's 12-bit fadeStep. Schism's per-tick decrement is stored / 1024 of
|
||||
# unit volume (sndmix.c:331-339, effects.c:1261: accumulator 65536, decrement
|
||||
@@ -1245,7 +1335,11 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
||||
dct = idata.get('dct', 0) & 0x03
|
||||
dca = idata.get('dca', 0) & 0x03
|
||||
inst_bin[base + 195] = (dca << 2) | dct
|
||||
# Bytes 196..255: reserved (already zeroed).
|
||||
# Byte 196: default note volume (per-trigger seed for rowVolume when
|
||||
# no V column accompanies a fresh trigger). Replaces the old "fold
|
||||
# sample.vol into IGV" trick — see terranmon byte 196 / TODO §2350.
|
||||
inst_bin[base + 196] = default_note_vol & 0xFF
|
||||
# Bytes 197..255: reserved (already zeroed).
|
||||
|
||||
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
|
||||
|
||||
@@ -1267,10 +1361,41 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
|
||||
out = bytearray(PATTERN_BYTES)
|
||||
rows = chunk_grid[ch_idx] if ch_idx < len(chunk_grid) else [ITRow()] * PATTERN_ROWS
|
||||
last_note_it = -1
|
||||
# IT shares one mem_vc_volslide across A/B/C/D vol-col commands (Schism
|
||||
# player/effects.c:2099-2131). Track it locally so x=0 resolves to the
|
||||
# last explicit value within the chunk.
|
||||
mem_vc_volslide = 0
|
||||
|
||||
for r, cell in enumerate(rows[:PATTERN_ROWS]):
|
||||
# ── Resolve vol-col into overrides ──────────────────────────────────
|
||||
vs, vv, pan_from_vc, aux_eff = decode_volcol(cell.volcol)
|
||||
# Update mem_vc_volslide before decode so a fresh non-zero on this
|
||||
# row stays visible for any later x=0 in the same channel.
|
||||
if (VC_FVUP_LO <= cell.volcol <= VC_VDN_HI):
|
||||
raw_mag = (cell.volcol - VC_FVUP_LO) % 10
|
||||
if raw_mag != 0:
|
||||
mem_vc_volslide = raw_mag
|
||||
vs, vv, pan_from_vc, aux_eff = decode_volcol(cell.volcol, mem_vc_volslide)
|
||||
|
||||
# ── Slot juggling: combine D + G/H into L/K when both are present ──
|
||||
# When the main effect is a pure vol-slide (D) and the vol-col aux is
|
||||
# tone-porta (G) or vibrato depth (H), Taud has dedicated combined
|
||||
# opcodes that capture both: L $xy00 (porta + vol slide) and K $xy00
|
||||
# (vibrato + vol slide). Without this swap the vol-col aux would be
|
||||
# dropped because the main slot is occupied.
|
||||
if aux_eff is not None and cell.effect == EFF_D and cell.effect_arg != 0:
|
||||
aux_op, aux_arg = aux_eff
|
||||
d_arg = cell.effect_arg & 0xFF
|
||||
if aux_op == EFF_G:
|
||||
cell.effect, cell.effect_arg = EFF_L, d_arg
|
||||
aux_eff = None
|
||||
elif aux_op == EFF_H:
|
||||
# K runs vibrato with current memory_HU; vol-col Hh's depth
|
||||
# update is lost (warn so the trade-off is visible).
|
||||
cell.effect, cell.effect_arg = EFF_K, d_arg
|
||||
aux_eff = None
|
||||
if (aux_arg & 0xF) != 0:
|
||||
vprint(f" ch{ch_idx} row{r}: D+Hh→K, depth update "
|
||||
f"{aux_arg & 0xF} folded into K vibrato recall")
|
||||
|
||||
# If vol-col provides an aux effect and cell has no main effect, use it
|
||||
if aux_eff is not None and cell.effect == 0:
|
||||
@@ -1278,7 +1403,7 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
|
||||
aux_eff = None
|
||||
elif aux_eff is not None:
|
||||
vprint(f" ch{ch_idx} row{r}: dropped vol-col aux effect "
|
||||
f"(main effect slot occupied)")
|
||||
f"(main effect slot occupied: cmd={cell.effect:02X} arg={cell.effect_arg:02X})")
|
||||
aux_eff = None
|
||||
|
||||
# If vol-col has a pan override
|
||||
@@ -1298,9 +1423,10 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
|
||||
|
||||
# ── Volume column ────────────────────────────────────────────────────
|
||||
# Priority: explicit cell vol (vol-col 0-64) > vol-col slide > main-
|
||||
# effect vol override > nop. The per-instrument default volume is
|
||||
# baked into IGV (byte 171), so the engine resolves note-trigger
|
||||
# default volume itself; the converter no longer emits SEL_SET=Sv.
|
||||
# effect vol override > nop. Per-trigger default volume now lives
|
||||
# in byte 196 of the instrument record (DNV); the engine seeds
|
||||
# rowVolume from it when this row has no V column, so the converter
|
||||
# still doesn't need to emit SEL_SET=Sv on plain trigger rows.
|
||||
if cell.volcol >= 0 and cell.volcol <= VC_VOL_HI:
|
||||
vol_sel, vol_value = SEL_SET, min(cell.volcol, 0x3F)
|
||||
elif vs != SEL_FINE or vv != 0:
|
||||
@@ -1448,7 +1574,8 @@ def _active_channels(h: ITHeader, patterns_rows: list) -> list:
|
||||
return active
|
||||
|
||||
def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
patterns_rows: list, decompress: bool) -> bytes:
|
||||
patterns_rows: list, decompress: bool,
|
||||
with_project_data: bool = True) -> bytes:
|
||||
# ── Resolve IT recalls ───────────────────────────────────────────────────
|
||||
vprint(" resolving IT recalls…")
|
||||
resolve_it_recalls(patterns_rows, h.order_list, 64, h.link_gef,
|
||||
@@ -1528,19 +1655,23 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
continue
|
||||
src_smp = samples[si]
|
||||
proxy[taud_slot] = src_smp
|
||||
# IT cell-trigger initial volume comes from the sample's default
|
||||
# volume (Sv, 0..64). It is folded into the Taud instrument's IGV
|
||||
# (byte 171) along with IT inst.gv (0..128) and sample gv (0..64),
|
||||
# so the engine applies all three as a single multiplier on every
|
||||
# fresh trigger. inst_vols is retained only for legacy callers.
|
||||
# IT splits per-sample volume into TWO concepts that Taud now
|
||||
# carries in two separate bytes:
|
||||
# * inst.gv (0..128) * sample.gv (0..64) — continuous multiplier
|
||||
# on every output sample (matches Schism's
|
||||
# `chan->instrument_volume = (psmp->global_volume * penv->global_volume) >> 7`,
|
||||
# csndfile.c:1317). Goes to byte 171 (IGV).
|
||||
# * sample.vol (Sv, 0..64) — per-trigger seed for chan->volume,
|
||||
# replaceable by an explicit V column on the same row (Schism
|
||||
# effects.c:1302, :1432, :1819). Goes to byte 196 (DNV).
|
||||
# Folding sample.vol into IGV (the pre-2026-05-09 layout) caused
|
||||
# any V-column override on a sample with default vol < 64 to be
|
||||
# attenuated a second time — see terranmon §2350.
|
||||
smp_default_vol = min(getattr(src_smp, 'vol', 64), 64)
|
||||
inst_vols[taud_slot] = min(smp_default_vol, 0x3F)
|
||||
|
||||
# IT inst.gv (0..128) * sample.gv (0..64) * sample.vol (0..64)
|
||||
# collapse into Taud's single instrumentwise IGV (0..255).
|
||||
smp_gv = min(getattr(src_smp, 'gv', 64), 64)
|
||||
inst_gv_255 = min(255, round(inst.gv * smp_gv * smp_default_vol * 255
|
||||
/ (128 * 64 * 64)))
|
||||
inst_gv_255 = min(255, round(inst.gv * smp_gv * 255 / (128 * 64)))
|
||||
default_note_vol_255 = min(255, round(smp_default_vol * 255 / 64))
|
||||
|
||||
# IT pitch-pan centre: note number 0..119 (C-5 = 60). The Taud
|
||||
# representation is the absolute 4096-TET note value used in patterns
|
||||
@@ -1584,6 +1715,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
'pf_env_loop': inst.pf_env_loop,
|
||||
'pf_env_sus': inst.pf_env_sus,
|
||||
'inst_gv': inst_gv_255,
|
||||
'default_note_vol': default_note_vol_255,
|
||||
'fadeout': inst.fadeout,
|
||||
'vib_speed': vib_speed_taud,
|
||||
'vib_depth': vib_depth_taud,
|
||||
@@ -1615,15 +1747,14 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
|
||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||
comp_size = len(compressed)
|
||||
vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)")
|
||||
|
||||
# ── BPM / speed ──────────────────────────────────────────────────────────
|
||||
speed, tempo = find_initial_bpm_speed(patterns_rows, h.order_list,
|
||||
h.initial_speed, h.initial_tempo)
|
||||
tempo = max(24, min(280, tempo))
|
||||
bpm_stored = (tempo - 24) & 0xFF
|
||||
tempo = max(25, min(280, tempo))
|
||||
bpm_stored = (tempo - 25) & 0xFF
|
||||
vprint(f" initial speed={speed}, tempo={tempo} BPM")
|
||||
|
||||
# ── Pattern bin ──────────────────────────────────────────────────────────
|
||||
@@ -1643,8 +1774,13 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
pat_bin += build_pattern_it(cg, ch, default_pans[vi], inst_vols,
|
||||
amiga_mode=not h.linear_slides)
|
||||
|
||||
# Rescale TOP_O sample-offset args if samples were globally downsampled.
|
||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||
# Rescale TOP_O sample-offset args per channel using the active slot's
|
||||
# ratio (combined global + per-sample). Walks pat_bin in cue-major /
|
||||
# channel-minor order, tracking the most recent inst byte seen on each
|
||||
# channel — must run before deduplication so the channel state stays
|
||||
# linear.
|
||||
pat_bin = rescale_offset_effects_per_slot(
|
||||
bytes(pat_bin), len(taud_cue_list), C, sample_ratio)
|
||||
|
||||
orig_count = len(taud_cue_list) * C
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||
@@ -1691,25 +1827,17 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
|
||||
# ── Header ───────────────────────────────────────────────────────────────
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, 1]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
b'\x00\x00\x00\x00' +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
# Compress pattern bin and cue sheet (per Taud spec)
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
||||
cue_comp = compress_blob(bytes(sheet), "cue sheet")
|
||||
|
||||
# flags byte: bit 1 (f) = Amiga pitch-slide mode (IT linear_slides flag inverted).
|
||||
# bit 2 was the old 'm' fadeout-zero policy flag and is now reserved (always 0); fadeout
|
||||
# scaling is done per-instrument in this converter — see the fadeout pass-through below.
|
||||
flags_byte = 0x00 if h.linear_slides else 0x02
|
||||
# flags byte: bits 0-1 (ff) = tone mode. ff=1 (Amiga period slides) when IT's
|
||||
# linear_slides flag is clear; ff=0 otherwise. Pan law is fixed engine-wide to
|
||||
# the equal-energy — no `p` bit any more. Bit 2 was the old 'm' fadeout-zero
|
||||
# policy flag and is now reserved (always 0); fadeout scaling is done per-instrument
|
||||
# in this converter — see the fadeout pass-through below.
|
||||
flags_byte = 0x00 if h.linear_slides else 0x01
|
||||
# IT global/mix volumes are 0..128; rescale to Taud's 0..255 (clamped).
|
||||
global_vol_taud = min(0xFF, round(h.global_vol * 255 / 128))
|
||||
mixing_vol_taud = min(0xFF, round(h.mix_vol * 255 / 128))
|
||||
@@ -1729,7 +1857,36 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
# Project Data (optional). IT distinguishes instruments from samples, so
|
||||
# both INam and SNam can carry distinct content. Slot 0 is unused, so the
|
||||
# tables are 1-indexed with an empty slot-0 entry.
|
||||
proj_data = b''
|
||||
proj_off = 0
|
||||
if with_project_data:
|
||||
inst_names = [''] + [(inst.name if inst is not None else '')
|
||||
for inst in instruments[:255]]
|
||||
smp_names = [''] + [(s.name if s is not None else '')
|
||||
for s in samples[:255]]
|
||||
proj_data = build_project_data(
|
||||
project_name=h.title,
|
||||
instrument_names=inst_names,
|
||||
sample_names=smp_names,
|
||||
)
|
||||
if proj_data:
|
||||
proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \
|
||||
+ len(pat_comp) + len(cue_comp)
|
||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, 1]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
struct.pack('<I', proj_off) +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
@@ -1742,6 +1899,9 @@ def main():
|
||||
ap.add_argument('-v', '--verbose', action='store_true')
|
||||
ap.add_argument('--no-decompress', action='store_true',
|
||||
help='Treat compressed IT samples as silent (debug)')
|
||||
ap.add_argument('--no-project-data', action='store_true',
|
||||
help='Omit the optional Project Data section '
|
||||
'(song / instrument / sample names)')
|
||||
args = ap.parse_args()
|
||||
set_verbose(args.verbose)
|
||||
|
||||
@@ -1761,7 +1921,8 @@ def main():
|
||||
patterns_rows = parse_patterns(data, h)
|
||||
|
||||
taud = assemble_taud(h, samples, instruments, patterns_rows,
|
||||
decompress=not args.no_decompress)
|
||||
decompress=not args.no_decompress,
|
||||
with_project_data=not args.no_project_data)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(taud)
|
||||
|
||||
113
mod2taud.py
113
mod2taud.py
@@ -7,9 +7,9 @@ Usage:
|
||||
Limits:
|
||||
- Up to 20 MOD channels (excess disabled; hard error if pattern count
|
||||
× channel count > 4095).
|
||||
- Sample bin is 737280 bytes; if all samples together exceed this, every
|
||||
sample is globally resampled down (with c2spd adjusted) so pitch is
|
||||
preserved.
|
||||
- Sample bin is 8 MB (8388608 bytes); if all samples together exceed
|
||||
this, every sample is globally resampled down (with c2spd adjusted)
|
||||
so pitch is preserved.
|
||||
|
||||
Effect support:
|
||||
Full PT effect dispatch per TAUD_NOTE_EFFECTS.md "ProTracker to Taud
|
||||
@@ -24,7 +24,6 @@ Effect support:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
@@ -40,7 +39,8 @@ from taud_common import (
|
||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
encode_song_entry,
|
||||
encode_song_entry, compress_blob,
|
||||
build_project_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -266,12 +266,17 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
|
||||
return (TOP_H, ((hi * 0x11) << 8) | (lo * 0x11), None, None)
|
||||
|
||||
if cmd == 0x5:
|
||||
# Tone porta + vol slide → Taud L (engine splits internally).
|
||||
return (TOP_G, 0x0000, d_arg_to_col(arg), None)
|
||||
# Tone porta + vol slide → Taud L verbatim. PT's 500 recall is already
|
||||
# collapsed by resolve_pt_recalls; if the source had no prior 5xy the
|
||||
# resolved arg is 0, which Taud's L $0000 then recalls from L's own
|
||||
# private memory. Emitting a real L (rather than the previous
|
||||
# G+vol-col split) preserves the slide on rows that also carry a
|
||||
# vol-column SET (e.g., a Cxx fold) — see TAUD_NOTE_EFFECTS.md §L.
|
||||
return (TOP_L, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == 0x6:
|
||||
# Vibrato + vol slide → Taud K.
|
||||
return (TOP_H, 0x0000, d_arg_to_col(arg), None)
|
||||
# Vibrato + vol slide → Taud K verbatim (same rationale as 0x5).
|
||||
return (TOP_K, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == 0x7:
|
||||
hi = (arg >> 4) & 0xF
|
||||
@@ -363,7 +368,7 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
|
||||
if arg == 0:
|
||||
return (TOP_NONE, 0, None, None)
|
||||
return (TOP_A, (arg & 0xFF) << 8, None, None)
|
||||
return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None)
|
||||
return (TOP_T, ((arg - 0x19) & 0xFF) << 8, None, None)
|
||||
|
||||
return (TOP_NONE, 0, None, None)
|
||||
|
||||
@@ -517,8 +522,9 @@ def build_sample_inst_bin(samples: list) -> tuple:
|
||||
le = min(s.loop_end, 65535)
|
||||
loop_mode = 1 if (s.flags & 1) else 0
|
||||
flags_byte = loop_mode & 0x3
|
||||
# Envelope first point is full-scale; per-sample level is carried by
|
||||
# IGV (byte 171) so the envelope must contribute a unit multiplier.
|
||||
# Envelope first point is full-scale; per-trigger initial level is
|
||||
# carried by Default Note Volume (byte 196) so the envelope must
|
||||
# contribute a unit multiplier.
|
||||
env_vol = 63
|
||||
# MOD has no envelopes; vol LOOP word b=1 just so the engine evaluates
|
||||
# the unit envelope, plus P=1 (envelope present) for consistency with
|
||||
@@ -540,14 +546,16 @@ def build_sample_inst_bin(samples: list) -> tuple:
|
||||
struct.pack_into('<H', inst_bin, base + 19, 0)
|
||||
inst_bin[base + 21] = env_vol
|
||||
inst_bin[base + 22] = 0
|
||||
# Instrument Global Volume carries the MOD sample's default volume (0..64 → 0..255).
|
||||
# The pattern builder no longer emits SEL_SET=Sv on note triggers; the engine
|
||||
# multiplies by IGV instead, so the per-instrument level lives here.
|
||||
inst_bin[base + 171] = min(0xFF, round(min(s.volume, 64) * 255 / 64))
|
||||
# MOD has no continuous instrumentwise volume scaler — its `s.volume`
|
||||
# (0..64) is purely the per-trigger initial value. Byte 171 (IGV)
|
||||
# stays at full and byte 196 (DNV) carries the per-instrument default.
|
||||
# Pre-2026-05-09 layout folded s.volume into IGV — see terranmon §2350.
|
||||
inst_bin[base + 171] = 0xFF # IGV: continuous unity
|
||||
inst_bin[base + 177] = 0x80 # default pan = centre (unused; pan env "p" flag not set)
|
||||
inst_bin[base + 182] = 0xFF # filter cutoff = off
|
||||
inst_bin[base + 183] = 0xFF # filter resonance = off
|
||||
inst_bin[base + 186] = 1 # NNA: note cut
|
||||
inst_bin[base + 196] = min(0xFF, round(min(s.volume, 64) * 255 / 64)) # DNV
|
||||
|
||||
vprint(f" instrument[{taud_idx}] '{s.name}' ptr={ptr} c2spd={s.c2spd} "
|
||||
f"vol={s.volume} loop=({ls},{le},{'on' if loop_mode else 'off'})")
|
||||
@@ -568,9 +576,9 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int,
|
||||
"""Build a 512-byte Taud pattern for one MOD channel.
|
||||
|
||||
Volume column: explicit Cxx → SEL_SET; effect-folded vol slide → vol_override;
|
||||
otherwise SEL_FINE/0 (no-op). Per-instrument default volume lives in IGV
|
||||
(byte 171) and is applied by the engine on every fresh trigger — the
|
||||
converter no longer has to emit SEL_SET=Sv to scale notes.
|
||||
otherwise SEL_FINE/0 (no-op). Per-instrument default volume lives in DNV
|
||||
(byte 196) and is consulted by the engine when the trigger row has no V
|
||||
column — the converter doesn't need to emit SEL_SET=Sv on plain triggers.
|
||||
"""
|
||||
out = bytearray(PATTERN_BYTES)
|
||||
rows = grid[ch_idx] if ch_idx < len(grid) else [ModRow()] * MOD_PATTERN_ROWS
|
||||
@@ -671,7 +679,7 @@ def find_initial_bpm_speed(patterns: list, order_list: list) -> tuple:
|
||||
return speed, tempo
|
||||
|
||||
|
||||
def assemble_taud(mod: dict) -> bytes:
|
||||
def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes:
|
||||
samples = mod['samples']
|
||||
patterns = mod['patterns']
|
||||
order_list = mod['order_list']
|
||||
@@ -713,26 +721,17 @@ def assemble_taud(mod: dict) -> bytes:
|
||||
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(samples)
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
|
||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||
comp_size = len(compressed)
|
||||
vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)")
|
||||
|
||||
speed, tempo = find_initial_bpm_speed(patterns, order_list)
|
||||
tempo = max(24, min(280, tempo))
|
||||
bpm_stored = (tempo - 24) & 0xFF
|
||||
tempo = max(25, min(280, tempo))
|
||||
bpm_stored = (tempo - 25) & 0xFF
|
||||
vprint(f" initial speed={speed}, tempo(BPM)={tempo}")
|
||||
|
||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, 1]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
b'\x00\x00\x00\x00' +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
vprint(" building pattern bin…")
|
||||
inst_vols = {
|
||||
@@ -761,18 +760,16 @@ def assemble_taud(mod: dict) -> bytes:
|
||||
cue_sheet = build_cue_sheet(order_list, n_patterns, n_channels, pat_remap)
|
||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
||||
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
||||
cue_comp = compress_blob(bytes(cue_sheet), "cue sheet")
|
||||
|
||||
# ProTracker is Amiga-period-based by definition, so we set the f bit so
|
||||
# ProTracker is Amiga-period-based by definition, so we set ff=1 (bits 0-1) so
|
||||
# the engine applies coarse pitch slides in period space (recovers PT's
|
||||
# characteristic non-linear pitch character).
|
||||
# bit 2 reserved (was 'm' fadeout-zero policy; removed). PT has no instrument-level
|
||||
# fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire
|
||||
# on sample-end or pattern note-cut instead, which matches PT semantics.
|
||||
flags_byte = 0x02
|
||||
# characteristic non-linear pitch character). Pan law is fixed to the
|
||||
# equal-energy engine-wide. PT has no instrument-level fadeout, so every Taud
|
||||
# instrument carries fadeout=0 ("no fade") — notes retire on sample-end or
|
||||
# pattern note-cut instead, which matches PT semantics.
|
||||
flags_byte = 0x01
|
||||
song_table = encode_song_entry(
|
||||
song_offset=song_offset,
|
||||
num_voices=n_channels,
|
||||
@@ -789,7 +786,32 @@ def assemble_taud(mod: dict) -> bytes:
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
# Project Data (optional). MOD samples *are* its instruments — the names
|
||||
# populate both INam and SNam (1-based; slot 0 empty).
|
||||
proj_data = b''
|
||||
proj_off = 0
|
||||
if with_project_data:
|
||||
names = [''] + [s.name for s in samples[:255]]
|
||||
proj_data = build_project_data(
|
||||
project_name=mod['title'],
|
||||
instrument_names=names,
|
||||
sample_names=names,
|
||||
)
|
||||
if proj_data:
|
||||
proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \
|
||||
+ len(pat_comp) + len(cue_comp)
|
||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, 1]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
struct.pack('<I', proj_off) +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
@@ -801,6 +823,9 @@ def main():
|
||||
ap.add_argument('output', help='Output .taud file')
|
||||
ap.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Print conversion details to stderr')
|
||||
ap.add_argument('--no-project-data', action='store_true',
|
||||
help='Omit the optional Project Data section '
|
||||
'(song / instrument / sample names)')
|
||||
args = ap.parse_args()
|
||||
|
||||
set_verbose(args.verbose)
|
||||
@@ -815,7 +840,7 @@ def main():
|
||||
vprint(f" orders={len(mod['order_list'])}, patterns={mod['n_patterns']}, "
|
||||
f"samples={sum(1 for s in mod['samples'] if s.sample_data)}")
|
||||
|
||||
taud = assemble_taud(mod)
|
||||
taud = assemble_taud(mod, with_project_data=not args.no_project_data)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(taud)
|
||||
|
||||
79
mon2taud.py
79
mon2taud.py
@@ -22,7 +22,6 @@ Limits: numVoices ≤ 20, numPatterns × numVoices ≤ 4095.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import struct
|
||||
import sys
|
||||
|
||||
@@ -35,7 +34,8 @@ from taud_common import (
|
||||
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_E, TOP_F, TOP_G, TOP_H, TOP_J,
|
||||
SEL_SET, SEL_FINE,
|
||||
J_SEMI_TABLE,
|
||||
encode_cue, deduplicate_patterns, encode_song_entry,
|
||||
encode_cue, deduplicate_patterns, encode_song_entry, compress_blob,
|
||||
build_project_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -53,12 +53,12 @@ MON_EFFECT_LETTERS = ['0', '1', '2', '3', '4', 'B', 'D', 'F']
|
||||
MON_NOTE_C4 = 40
|
||||
|
||||
# Global behaviour flags byte (Taud Effect 1 / song-table byte 15):
|
||||
# bit 0 (p) : pan law — leave 0 (linear) for tracker accuracy
|
||||
# bits 1-2 (ff): tone mode — 2 = linear-frequency (Hz/tick)
|
||||
# bits 0-1 (ff): tone mode — 2 = linear-frequency (Hz/tick)
|
||||
# Selecting ff=2 makes the engine interpret 1xx/2xx/3xx slide arguments in
|
||||
# audible Hz at the A4=440 Hz reference, matching Monotone's MT_PLAY.PAS
|
||||
# `Frequency:=Frequency±parm1` arithmetic (see MTSRC/MT_PLAY.PAS:606-630).
|
||||
GLOBAL_FLAGS_LINEAR_FREQ = 0b100
|
||||
# Panning law is fixed to the equal-energy — there is no `p` bit any more.
|
||||
GLOBAL_FLAGS_LINEAR_FREQ = 0b10
|
||||
|
||||
|
||||
# ── Taud container ───────────────────────────────────────────────────────────
|
||||
@@ -212,11 +212,15 @@ def build_sample_inst_bin() -> bytes:
|
||||
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch-env flags (P=0 → mixer skips)
|
||||
inst_bin[base + 21] = 63 # vol env pt 0 = full
|
||||
inst_bin[base + 22] = 0
|
||||
inst_bin[base + 171] = 0xA0 # IGV
|
||||
inst_bin[base + 171] = 0xA0 # IGV (square-wave headroom)
|
||||
inst_bin[base + 177] = 0x80 # default pan = centre
|
||||
inst_bin[base + 182] = 0xFF # filter cutoff off
|
||||
inst_bin[base + 183] = 0xFF # filter resonance off
|
||||
inst_bin[base + 186] = 0x01 # NNA: cut
|
||||
# Monotone has no per-sample default volume concept (only one synth
|
||||
# voice, no V column overrides). Set DNV to full so triggers get the
|
||||
# full 0x3F rowVolume; the IGV above provides the actual attenuation.
|
||||
inst_bin[base + 196] = 0xFF # DNV: full
|
||||
|
||||
return bytes(sample_bin) + bytes(inst_bin)
|
||||
|
||||
@@ -299,7 +303,7 @@ def find_initial_speed(patterns: list, order_list: list, num_voices: int) -> int
|
||||
|
||||
# ── Top-level assembly ───────────────────────────────────────────────────────
|
||||
|
||||
def assemble_taud(mon: dict) -> bytes:
|
||||
def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes:
|
||||
num_voices = mon['num_voices']
|
||||
patterns = mon['patterns']
|
||||
order_list = mon['order_list']
|
||||
@@ -324,9 +328,8 @@ def assemble_taud(mon: dict) -> bytes:
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw = build_sample_inst_bin()
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||
comp_size = len(compressed)
|
||||
vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)")
|
||||
|
||||
vprint(" building pattern bin…")
|
||||
pat_bin = bytearray()
|
||||
@@ -346,30 +349,18 @@ def assemble_taud(mon: dict) -> bytes:
|
||||
cue_sheet = build_cue_sheet(order_list, num_voices, pat_remap)
|
||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
||||
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
||||
cue_comp = compress_blob(bytes(cue_sheet), "cue sheet")
|
||||
|
||||
# Header: magic, version, num_songs=1, comp_size of sample+inst, projOff=0, sig.
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC
|
||||
+ bytes([TAUD_VERSION, 1])
|
||||
+ struct.pack('<I', comp_size)
|
||||
+ b'\x00\x00\x00\x00'
|
||||
+ sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||
|
||||
# BPM 150 + ticks=mon_speed → row rate = 60/mon_speed (matches Monotone).
|
||||
bpm_stored = 150 - 24
|
||||
# Linear-frequency tone mode (ff=2) so 1xx/2xx/3xx Hz/tick semantics survive verbatim;
|
||||
# pan law stays 0 (linear), bit 2 stays 0 (reserved). Monotone has no instrument-level
|
||||
# fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire on
|
||||
# sample-end or pattern note-cut instead.
|
||||
bpm_stored = 150 - 25
|
||||
# Linear-frequency tone mode (ff=2) so 1xx/2xx/3xx Hz/tick semantics survive verbatim.
|
||||
# Pan law is fixed engine-wide to the equal-energy (no flag). Monotone has no
|
||||
# instrument-level fadeout, so every Taud instrument carries fadeout=0 ("no fade") —
|
||||
# notes retire on sample-end or pattern note-cut instead.
|
||||
flags_byte = GLOBAL_FLAGS_LINEAR_FREQ
|
||||
|
||||
song_table = encode_song_entry(
|
||||
@@ -388,7 +379,32 @@ def assemble_taud(mon: dict) -> bytes:
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
# Project Data (optional). Monotone has no title, no user instruments and
|
||||
# no per-sample names, but we still emit one identifying entry so the
|
||||
# synthesised square slot is documented.
|
||||
proj_data = b''
|
||||
proj_off = 0
|
||||
if with_project_data:
|
||||
proj_data = build_project_data(
|
||||
instrument_names=['', 'PC speaker square'],
|
||||
sample_names=['', 'PC speaker square'],
|
||||
)
|
||||
if proj_data:
|
||||
proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \
|
||||
+ len(pat_comp) + len(cue_comp)
|
||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||
|
||||
# Header: magic, version, num_songs=1, comp_size of sample+inst, projOff, sig.
|
||||
header = (
|
||||
TAUD_MAGIC
|
||||
+ bytes([TAUD_VERSION, 1])
|
||||
+ struct.pack('<I', comp_size)
|
||||
+ struct.pack('<I', proj_off)
|
||||
+ sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
@@ -400,6 +416,9 @@ def main():
|
||||
ap.add_argument('output', help='Output .taud file')
|
||||
ap.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Print conversion details to stderr')
|
||||
ap.add_argument('--no-project-data', action='store_true',
|
||||
help='Omit the optional Project Data section '
|
||||
'(song / instrument / sample names)')
|
||||
args = ap.parse_args()
|
||||
|
||||
set_verbose(args.verbose)
|
||||
@@ -412,7 +431,7 @@ def main():
|
||||
vprint(f" songLen={mon['song_len']}, voices={mon['num_voices']}, "
|
||||
f"patterns={mon['n_patterns']}, orders={len(mon['order_list'])}")
|
||||
|
||||
taud = assemble_taud(mon)
|
||||
taud = assemble_taud(mon, with_project_data=not args.no_project_data)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(taud)
|
||||
|
||||
126
s3m2taud.py
126
s3m2taud.py
@@ -7,9 +7,9 @@ Usage:
|
||||
Limits:
|
||||
- Up to 20 S3M channels (excess disabled; hard error if pattern count
|
||||
× channel count > 4095).
|
||||
- Sample bin is 737280 bytes; if all samples together exceed this, every
|
||||
sample is globally resampled down (with c2spd adjusted) so pitch is
|
||||
preserved.
|
||||
- Sample bin is 8 MB (8388608 bytes); if all samples together exceed
|
||||
this, every sample is globally resampled down (with c2spd adjusted)
|
||||
so pitch is preserved.
|
||||
- AdLib instruments are skipped.
|
||||
|
||||
Effect support:
|
||||
@@ -25,7 +25,6 @@ Effect support:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
@@ -37,14 +36,15 @@ from taud_common import (
|
||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
|
||||
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
|
||||
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y,
|
||||
TOP_J, TOP_K, TOP_L, TOP_M, TOP_N, TOP_O, TOP_P, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y,
|
||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||
EFF_A, EFF_B, EFF_C, EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J,
|
||||
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
|
||||
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry,
|
||||
normalise_sample, encode_song_entry, compress_blob,
|
||||
build_project_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -305,25 +305,28 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
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)
|
||||
# K = vibrato continuation + vol slide; emitted verbatim. ST3's shared
|
||||
# memory cohort is already resolved upstream by resolve_st3_recalls.
|
||||
return (TOP_K, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_L:
|
||||
# L = tone-porta continuation + vol slide; split similarly.
|
||||
return (TOP_G, 0x0000, d_arg_to_col(arg), None)
|
||||
# L = tone-porta continuation + vol slide; emitted verbatim.
|
||||
return (TOP_L, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_M:
|
||||
return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None)
|
||||
# M = set channel volume; literal byte (no recall). Clamp ST3/IT $40 → $3F.
|
||||
return (TOP_M, (min(arg, 0x3F) & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_N:
|
||||
return (TOP_NONE, 0, d_arg_to_col(arg), None)
|
||||
# N = channel volume slide; D-style encoding.
|
||||
return (TOP_N, (arg & 0xFF) << 8, None, 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))
|
||||
# P = channel panning slide; D-style encoding (low nib = right, high nib = left).
|
||||
return (TOP_P, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_Q:
|
||||
return (TOP_Q, (arg & 0xFF) << 8, None, None)
|
||||
@@ -349,7 +352,7 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
|
||||
if cmd == EFF_T:
|
||||
if arg >= 0x20:
|
||||
return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None)
|
||||
return (TOP_T, ((arg - 0x19) & 0xFF) << 8, None, None)
|
||||
# OpenMPT slide forms: $0y down per tick, $1y up per tick.
|
||||
return (TOP_T, arg & 0xFF, None, None)
|
||||
|
||||
@@ -496,8 +499,9 @@ def build_sample_inst_bin(instruments: list) -> tuple:
|
||||
loop_mode = 1 if (inst.flags & 1) else 0
|
||||
flags_byte = loop_mode & 0x3 # 0b 0000 00pp
|
||||
|
||||
# Volume envelope first point is full-scale; per-sample level is carried
|
||||
# by IGV (byte 171) so the envelope contributes a unit multiplier.
|
||||
# Volume envelope first point is full-scale; per-trigger initial level
|
||||
# is carried by Default Note Volume (byte 196), so the envelope
|
||||
# contributes a unit multiplier.
|
||||
env_vol = 63
|
||||
# Vol LOOP word: P=1 (envelope present) | b=1 (use envelope) — no actual
|
||||
# loop / sustain. P added 2026-05-06 alongside the pan/pf gate spec
|
||||
@@ -520,14 +524,17 @@ def build_sample_inst_bin(instruments: list) -> tuple:
|
||||
# Volume env point 0: hold at env_vol indefinitely (offset minifloat = 0 → hold).
|
||||
inst_bin[base + 21] = env_vol
|
||||
inst_bin[base + 22] = 0
|
||||
# Instrument Global Volume carries the S3M instrument's default volume (0..64 → 0..255).
|
||||
# The pattern builder no longer emits SEL_SET=Sv on note triggers; the engine
|
||||
# multiplies by IGV instead, so the per-instrument level lives here.
|
||||
inst_bin[base + 171] = min(0xFF, round(min(inst.volume, 64) * 255 / 64))
|
||||
# S3M has no continuous instrumentwise volume scaler — its `inst.volume`
|
||||
# (0..64) is purely the per-trigger initial value, equivalent to IT's
|
||||
# sample.vol. So byte 171 (IGV) stays at full and byte 196 (DNV)
|
||||
# carries the per-instrument default. Pre-2026-05-09 layout folded
|
||||
# inst.volume into IGV — see terranmon §2350.
|
||||
inst_bin[base + 171] = 0xFF # IGV: continuous unity
|
||||
inst_bin[base + 177] = 0x80 # default pan = centre (unused; pan env "p" flag not set)
|
||||
inst_bin[base + 182] = 0xFF # filter cutoff = off
|
||||
inst_bin[base + 183] = 0xFF # filter resonance = off
|
||||
inst_bin[base + 186] = 1 # NNA: note cut
|
||||
inst_bin[base + 196] = min(0xFF, round(min(inst.volume, 64) * 255 / 64)) # DNV
|
||||
|
||||
vprint(f" instrument[{base // INST_STRIDE}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
|
||||
if inst.c2spd > 65535:
|
||||
@@ -555,8 +562,9 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
|
||||
|
||||
Volume column: explicit S3M cell vol -> SEL_SET; M/N/K/L vol slides folded
|
||||
by encode_effect -> vol_override; otherwise SEL_FINE/0 (no-op). Per-
|
||||
instrument default volume lives in IGV (byte 171) and is applied by the
|
||||
engine on every fresh trigger, so the converter no longer emits SEL_SET=Sv.
|
||||
instrument default volume lives in DNV (byte 196) and is consulted by
|
||||
the engine when the trigger row has no V column, so the converter
|
||||
doesn't need to emit SEL_SET=Sv on plain trigger rows.
|
||||
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.
|
||||
"""
|
||||
@@ -716,7 +724,8 @@ def find_initial_bpm_speed(patterns: list, order_list: list,
|
||||
return speed, tempo
|
||||
|
||||
|
||||
def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
def assemble_taud(h: S3MHeader, instruments: list, patterns: list,
|
||||
with_project_data: bool = True) -> bytes:
|
||||
# Determine active channels (bit7 clear = enabled)
|
||||
active_channels = [i for i, cs in enumerate(h.channel_settings)
|
||||
if i < 32 and not (cs & 0x80)][:NUM_VOICES]
|
||||
@@ -749,31 +758,21 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
|
||||
# Compress
|
||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||
comp_size = len(compressed)
|
||||
vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)")
|
||||
|
||||
# Initial BPM / speed
|
||||
speed, tempo = find_initial_bpm_speed(patterns, h.order_list,
|
||||
h.initial_speed, h.initial_tempo)
|
||||
tempo = max(24, min(280, tempo))
|
||||
bpm_stored = (tempo - 24) & 0xFF
|
||||
tempo = max(25, min(280, tempo))
|
||||
bpm_stored = (tempo - 25) & 0xFF
|
||||
vprint(f" initial speed={speed}, tempo(BPM)={tempo}")
|
||||
|
||||
# Song offset = header(32) + compressed + song_table(8)
|
||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||
num_taud_pats = P * C
|
||||
|
||||
# Header (32 bytes): magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(4)+sig(14)
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, 1]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
b'\x00\x00\x00\x00' +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
# Pattern bin: for each s3m pattern, for each active channel, 512 bytes
|
||||
vprint(" building pattern bin…")
|
||||
@@ -807,17 +806,17 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
||||
|
||||
# Compress pattern bin and cue sheet (per Taud spec)
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
||||
cue_comp = compress_blob(bytes(cue_sheet), "cue sheet")
|
||||
|
||||
# Song table row (32 bytes; see encode_song_entry).
|
||||
# flags byte: bit 1 (f) = Amiga pitch-slide mode (mirrors the S3M linear_slides flag inverted).
|
||||
# bit 2 reserved (was 'm' fadeout-zero policy; removed). S3M has no instrument-level
|
||||
# fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire on
|
||||
# sample-end or pattern note-cut effects (SCx) instead, which matches ST3 semantics.
|
||||
flags_byte = (0x00 if h.linear_slides else 0x02)
|
||||
# flags byte: bits 0-1 (ff) = tone mode. ff=1 (Amiga period slides) when S3M's
|
||||
# linear_slides flag is clear; ff=0 otherwise. Pan law is fixed engine-wide to
|
||||
# the equal-energy — no `p` bit any more. Bit 2 reserved (was 'm' fadeout-zero
|
||||
# policy; removed). S3M has no instrument-level fadeout, so every Taud instrument
|
||||
# carries fadeout=0 ("no fade") — notes retire on sample-end or pattern note-cut
|
||||
# effects (SCx) instead, which matches ST3 semantics.
|
||||
flags_byte = (0x00 if h.linear_slides else 0x01)
|
||||
song_table = encode_song_entry(
|
||||
song_offset=song_offset,
|
||||
num_voices=C,
|
||||
@@ -834,7 +833,34 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
# Project Data (optional). S3M instruments and samples share the same slot
|
||||
# space, so the names go into both INam and SNam (1-based; slot 0 empty).
|
||||
proj_data = b''
|
||||
proj_off = 0
|
||||
if with_project_data:
|
||||
names = [''] + [(inst.name if inst is not None else '')
|
||||
for inst in instruments[:255]]
|
||||
proj_data = build_project_data(
|
||||
project_name=h.title,
|
||||
instrument_names=names,
|
||||
sample_names=names,
|
||||
)
|
||||
if proj_data:
|
||||
proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \
|
||||
+ len(pat_comp) + len(cue_comp)
|
||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||
|
||||
# Header (32 bytes): magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14)
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, 1]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
struct.pack('<I', proj_off) +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
@@ -846,6 +872,9 @@ def main():
|
||||
ap.add_argument('output', help='Output .taud file')
|
||||
ap.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Print conversion details to stderr')
|
||||
ap.add_argument('--no-project-data', action='store_true',
|
||||
help='Omit the optional Project Data section '
|
||||
'(song / instrument / sample names)')
|
||||
args = ap.parse_args()
|
||||
|
||||
set_verbose(args.verbose)
|
||||
@@ -861,7 +890,8 @@ def main():
|
||||
instruments = parse_instruments(data, h)
|
||||
patterns = parse_patterns(data, h)
|
||||
|
||||
taud = assemble_taud(h, instruments, patterns)
|
||||
taud = assemble_taud(h, instruments, patterns,
|
||||
with_project_data=not args.no_project_data)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(taud)
|
||||
|
||||
186
taud_common.py
186
taud_common.py
@@ -7,9 +7,16 @@ pattern deduper, sample normaliser) that all three converters used to
|
||||
duplicate verbatim.
|
||||
"""
|
||||
|
||||
import gzip as _gzip
|
||||
import struct
|
||||
import sys
|
||||
|
||||
try:
|
||||
import zstandard as _zstd
|
||||
_ZSTD_CCTX = _zstd.ZstdCompressor(level=22)
|
||||
except ImportError:
|
||||
_ZSTD_CCTX = None
|
||||
|
||||
|
||||
# ── Verbose logging (shared across converters via set_verbose) ───────────────
|
||||
|
||||
@@ -24,6 +31,37 @@ def vprint(*a, **kw) -> None:
|
||||
print(*a, **kw, file=sys.stderr)
|
||||
|
||||
|
||||
# ── Compression (gzip vs zstd; whichever is smaller) ─────────────────────────
|
||||
#
|
||||
# The Taud loader sniffs the 4-byte magic of every compressed slot and routes
|
||||
# to GZIPInputStream or ZstdInputStream accordingly (CompressorDelegate.kt:148-149),
|
||||
# so each blob can independently pick whichever codec compresses it smaller.
|
||||
|
||||
def best_compress(payload: bytes) -> tuple:
|
||||
"""Return (compressed_bytes, method) for the smaller of gzip/zstd output.
|
||||
|
||||
Method is "gzip" or "zstd". Falls back to gzip when the `zstandard`
|
||||
package is not installed.
|
||||
"""
|
||||
gz = _gzip.compress(payload, compresslevel=9, mtime=0)
|
||||
if _ZSTD_CCTX is None:
|
||||
return gz, "gzip"
|
||||
zs = _ZSTD_CCTX.compress(payload)
|
||||
if len(zs) < len(gz):
|
||||
return zs, "zstd"
|
||||
return gz, "gzip"
|
||||
|
||||
|
||||
def compress_blob(payload: bytes, label: str) -> bytes:
|
||||
"""Compress `payload` with whichever of gzip/zstd is smaller; vprint stats; return bytes.
|
||||
|
||||
`label` is the human-readable name in the verbose log line, e.g. "sample+inst bin".
|
||||
"""
|
||||
out, method = best_compress(payload)
|
||||
vprint(f" {label}: {len(payload)} → {len(out)} bytes ({method})")
|
||||
return out
|
||||
|
||||
|
||||
# ── Taud container constants ─────────────────────────────────────────────────
|
||||
|
||||
TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
|
||||
@@ -34,9 +72,16 @@ TAUD_VERSION = 1
|
||||
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14)
|
||||
TAUD_SONG_ENTRY = 32 # full spec entry (see encode_song_entry)
|
||||
INST_RECORD_SIZE = 256 # widened 2026-05-06 (was 192). 256 inst × 256 = 64K.
|
||||
SAMPLEBIN_SIZE = 720896 # was 737280; 16K reallocated to inst bin (terranmon.txt:1985-1997)
|
||||
# Sample+instrument image (terranmon.txt:1985-1997, 2533-2564 — updated 2026-05-08).
|
||||
# Sample pool is now 8 MB, banked through MMIO 46 in 16 × 512 K windows.
|
||||
# Converters write the pool bank-major (bank 0's 512 K first, then bank 1's, ...);
|
||||
# the runtime decompresses the whole blob straight into native peripheral storage,
|
||||
# so converters just lay out an 8 MB linear array as if banking didn't exist.
|
||||
SAMPLE_BANK_SIZE = 524288 # 512 K per bank
|
||||
SAMPLE_BANK_COUNT = 16 # 16 banks × 512 K = 8 MB
|
||||
SAMPLEBIN_SIZE = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT # 8 MB
|
||||
INSTBIN_SIZE = INST_RECORD_SIZE * 256 # 65536 = 64K
|
||||
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE
|
||||
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE # 8454144 = 8256 kB
|
||||
PATTERN_ROWS = 64
|
||||
PATTERN_BYTES = PATTERN_ROWS * 8 # 512
|
||||
NUM_PATTERNS_MAX = 4095
|
||||
@@ -44,6 +89,12 @@ NUM_CUES = 1024
|
||||
CUE_SIZE = 32
|
||||
NUM_VOICES = 20
|
||||
|
||||
# Per-sample length cap. Taud instrument records carry the sample length as
|
||||
# a u16 (terranmon.txt:2001+ — bytes 4..5), so any single sample must fit in
|
||||
# 65535 bytes. Converters resample over-long samples individually after the
|
||||
# global pool-overflow pass and rescale the affected channel's TOP_O args.
|
||||
SAMPLE_LEN_LIMIT = 65535
|
||||
|
||||
# Note word sentinels
|
||||
NOTE_NOP = 0xFFFF
|
||||
NOTE_KEYOFF = 0x0000
|
||||
@@ -76,7 +127,10 @@ TOP_I = 0x12
|
||||
TOP_J = 0x13
|
||||
TOP_K = 0x14
|
||||
TOP_L = 0x15
|
||||
TOP_M = 0x16
|
||||
TOP_N = 0x17
|
||||
TOP_O = 0x18
|
||||
TOP_P = 0x19
|
||||
TOP_Q = 0x1A
|
||||
TOP_R = 0x1B
|
||||
TOP_S = 0x1C
|
||||
@@ -231,6 +285,44 @@ def rescale_offset_effects(pat_bin: bytes, ratio: float) -> bytes:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def rescale_offset_effects_per_slot(pat_bin: bytes,
|
||||
num_cues: int,
|
||||
num_channels: int,
|
||||
slot_ratios: dict) -> bytes:
|
||||
"""Scale TOP_O args using a per-slot ratio map.
|
||||
|
||||
`pat_bin` is laid out as `num_cues × num_channels` consecutive
|
||||
PATTERN_BYTES (=512) blocks, channel-minor within each cue. For each
|
||||
channel, walk the rows in cue order and track the most recently
|
||||
written slot byte (row offset 2). When a TOP_O effect appears, scale
|
||||
its arg by `slot_ratios[active_slot]`, falling back to ratio 1.0 if
|
||||
the slot is unknown (e.g. row hits an O before any inst byte has
|
||||
selected a sample for the channel).
|
||||
"""
|
||||
if not pat_bin or not slot_ratios:
|
||||
return pat_bin
|
||||
if all(r == 1.0 for r in slot_ratios.values()):
|
||||
return pat_bin
|
||||
out = bytearray(pat_bin)
|
||||
active = [0] * num_channels
|
||||
for cue in range(num_cues):
|
||||
for ch in range(num_channels):
|
||||
block = (cue * num_channels + ch) * PATTERN_BYTES
|
||||
for row in range(PATTERN_ROWS):
|
||||
rb = block + row * 8
|
||||
inst = out[rb + 2]
|
||||
if inst != 0:
|
||||
active[ch] = inst
|
||||
if out[rb + 5] == TOP_O:
|
||||
ratio = slot_ratios.get(active[ch], 1.0)
|
||||
if ratio != 1.0:
|
||||
arg = out[rb + 6] | (out[rb + 7] << 8)
|
||||
arg = max(0, min(0xFFFF, int(arg * ratio + 0.5)))
|
||||
out[rb + 6] = arg & 0xFF
|
||||
out[rb + 7] = (arg >> 8) & 0xFF
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def encode_cue(patterns12: list, instruction) -> bytearray:
|
||||
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers.
|
||||
|
||||
@@ -319,6 +411,96 @@ def encode_song_entry(song_offset: int, num_voices: int, num_patterns: int,
|
||||
return entry
|
||||
|
||||
|
||||
# ── Project Data section (terranmon.txt:2601+) ───────────────────────────────
|
||||
|
||||
PROJECT_DATA_MAGIC = bytes([0x1E, 0x54, 0x61, 0x75, 0x64, 0x50, 0x72, 0x4A]) # \x1ETaudPrJ
|
||||
PROJECT_DATA_HEADER_SIZE = 16 # 8-byte magic + 8 reserved
|
||||
|
||||
|
||||
def _name_table_blob(names) -> bytes:
|
||||
"""Encode a list of names (slot-indexed; slot 0 is left empty in source) as
|
||||
0x1E-separated UTF-8 bytes. Trailing empty slots are trimmed to save space.
|
||||
Returns b'' when every name is empty.
|
||||
"""
|
||||
if not names:
|
||||
return b''
|
||||
end = len(names)
|
||||
while end > 0 and not names[end - 1]:
|
||||
end -= 1
|
||||
if end == 0:
|
||||
return b''
|
||||
return b'\x1E'.join((n or '').encode('utf-8', 'replace') for n in names[:end])
|
||||
|
||||
|
||||
def build_project_data(*, project_name: str = '',
|
||||
author: str = '',
|
||||
copyright_str: str = '',
|
||||
sample_names=None,
|
||||
instrument_names=None,
|
||||
pattern_names=None,
|
||||
song_metadata=None) -> bytes:
|
||||
"""Build the optional PROJECT DATA section payload.
|
||||
|
||||
Returns the full block (8-byte magic + 8 reserved bytes + concatenated
|
||||
FourCC sections), or b'' when there's nothing to write so the caller can
|
||||
leave the header's projOff field at zero.
|
||||
|
||||
`sample_names` / `instrument_names` / `pattern_names` are slot-indexed
|
||||
lists (entry 0 is typically empty since slot 0 is reserved); they are
|
||||
encoded as 0x1E-separated UTF-8 strings inside SNam / INam / pNam blocks.
|
||||
|
||||
`song_metadata` is an optional list of dicts, one per song:
|
||||
{ 'index': int (0..255),
|
||||
'notation': int = 0,
|
||||
'beat_pri': int = 4,
|
||||
'beat_sec': int = 16,
|
||||
'name': str = '',
|
||||
'composer': str = '',
|
||||
'copyright': str = '' }
|
||||
"""
|
||||
sections = []
|
||||
|
||||
def add(fourcc: bytes, payload: bytes) -> None:
|
||||
if not payload:
|
||||
return
|
||||
sections.append(fourcc + struct.pack('<I', len(payload)) + payload)
|
||||
|
||||
if project_name:
|
||||
add(b'PNam', project_name.encode('utf-8', 'replace'))
|
||||
if author:
|
||||
add(b'PCom', author.encode('utf-8', 'replace'))
|
||||
if copyright_str:
|
||||
add(b'PCpr', copyright_str.encode('utf-8', 'replace'))
|
||||
|
||||
add(b'INam', _name_table_blob(instrument_names))
|
||||
add(b'SNam', _name_table_blob(sample_names))
|
||||
add(b'pNam', _name_table_blob(pattern_names))
|
||||
|
||||
if song_metadata:
|
||||
smet = bytearray()
|
||||
for entry in song_metadata:
|
||||
idx = entry.get('index', 0) & 0xFF
|
||||
notation = entry.get('notation', 0) & 0xFFFF
|
||||
beat_pri = entry.get('beat_pri', 4) & 0xFF
|
||||
beat_sec = entry.get('beat_sec', 16) & 0xFF
|
||||
name_b = entry.get('name', '').encode('utf-8', 'replace') + b'\x00'
|
||||
comp_b = entry.get('composer', '').encode('utf-8', 'replace') + b'\x00'
|
||||
copr_b = entry.get('copyright', '').encode('utf-8', 'replace') + b'\x00'
|
||||
payload = (struct.pack('<HBB', notation, beat_pri, beat_sec)
|
||||
+ name_b + comp_b + copr_b)
|
||||
smet.append(idx)
|
||||
smet += struct.pack('<I', len(payload))
|
||||
smet += payload
|
||||
add(b'sMet', bytes(smet))
|
||||
|
||||
if not sections:
|
||||
return b''
|
||||
|
||||
return PROJECT_DATA_MAGIC + b'\x00' * 8 + b''.join(sections)
|
||||
|
||||
|
||||
# ── Sample normalisation ─────────────────────────────────────────────────────
|
||||
|
||||
def normalise_sample(raw: bytes, signed: bool, is_16bit: bool,
|
||||
is_stereo: bool, name: str) -> bytes:
|
||||
"""Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed."""
|
||||
|
||||
109
terranmon.txt
109
terranmon.txt
@@ -1985,18 +1985,14 @@ Synchronisation between playheads are not guaranteed. Do not play music in multi
|
||||
|
||||
Memory Space
|
||||
|
||||
0..720895 RW: Sample bin (704k)
|
||||
0..524287 RW: Sample bin window (512k)
|
||||
720896..786431 RW: Instrument bin (256 instruments, 256 bytes each; instrument 0 does nothing; 64k)
|
||||
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)
|
||||
983040..1048575 RW: TAD Decode Output (64k)
|
||||
|
||||
(Layout note 2026-05-06: sample bin shrunk by 16k and instrument bin widened
|
||||
by the same amount so all downstream dispatch ranges keep their existing
|
||||
anchors at 786432. Total memory space stays at exactly 1 MiB.)
|
||||
|
||||
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample
|
||||
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample. Actual sample memory is 8 MB and are banked. Write to MMIO address 46 to switch banks.
|
||||
|
||||
Instrument bin: Registry for 256 instruments, formatted as:
|
||||
|
||||
@@ -2145,9 +2141,23 @@ from source.
|
||||
Byte 1: Value (00..FF)
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
|
||||
171 Uint8 Instrument Global Volume (0..255)
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
- ImpulseTracker also has samplewise default volume (0..64) and samplewise global volume (0..64), and they must be taken into account because Taud has no samplewise config, following the ImpulseTracker spec
|
||||
* FastTracker2 has range of 0..64; multiply by (255/64) then round to int
|
||||
* Continuous multiplier applied on every output sample (matches IT's
|
||||
`chan->instrument_volume`, see Schism player/csndfile.c:1317 and
|
||||
player/sndmix.c:1171). Independent of the volume column / Mxx /
|
||||
Nxx — those operate on rowVolume/channelVolume, while IGV scales
|
||||
the final mix unconditionally.
|
||||
* ImpulseTracker has separate `inst.gv` (0..128) and samplewise
|
||||
`sample.gv` (0..64). Since Taud has no samplewise record, fold
|
||||
the two factors into a single 0..255 value:
|
||||
taud_igv = round(inst.gv * sample.gv * 255 / (128 * 64))
|
||||
The samplewise `sample.vol` (0..64) is NOT folded here — it is the
|
||||
per-trigger default chan_volume in IT (replaceable by V column),
|
||||
and Taud carries it in byte 196 ("Default Note Volume"). Folding
|
||||
it here was the cause of the "low-number voleffs are too quiet"
|
||||
regression (TODO §2350, fixed 2026-05-09).
|
||||
* FastTracker2 has range of 0..64 with no instrumentwise multiplier
|
||||
beyond it; multiply by (255/64) and round. The XM samplewise
|
||||
volume goes into byte 196.
|
||||
172 Uint8 Volume Fadeout low bits
|
||||
173 Bit8 Volume Fadeout high bits
|
||||
0b 0000 ffff
|
||||
@@ -2271,7 +2281,27 @@ from source.
|
||||
triggerNote. So when DCA flags the foreground voice, the NNA-ghost it
|
||||
spawns inherits that DCA-modified state (e.g. noteFading carries over).
|
||||
- The new note then triggers normally on the foreground channel.
|
||||
196..255 Reserved (60 bytes free for future per-instrument fields)
|
||||
196 Uint8 Default Note Volume (0..255)
|
||||
* Per-trigger default for `channelVolume` / `rowVolume` when the row
|
||||
carries a fresh note + instrument byte but no explicit volume column
|
||||
(matches IT's `chan->volume = psmp->volume` on note-on, Schism
|
||||
player/effects.c:1302 and :1432). The 8-bit value rescales to
|
||||
Taud's 0..63 row volume range:
|
||||
row_default = round(default_note_volume * 63 / 255)
|
||||
Any explicit V column on the trigger row OVERRIDES this — i.e.
|
||||
rowVolume = vol_value, exactly mirroring IT's "V column replaces
|
||||
chan->volume" rule.
|
||||
* Source-format mapping:
|
||||
- IT: taud_dnv = round(sample.vol * 255 / 64) # 0..64 → 0..255
|
||||
- XM: taud_dnv = round(sample.volume * 255 / 64) # 0..64 → 0..255
|
||||
- S3M: taud_dnv = round(min(inst.volume, 64) * 255 / 64)
|
||||
- MOD: taud_dnv = round(min(sample.volume, 64) * 255 / 64)
|
||||
* .taud files written before 2026-05-09 stored sample.vol folded into
|
||||
byte 171 (IGV) and left this byte zero. Engines reading those older
|
||||
files SHOULD treat default_note_volume == 0 as "field not present"
|
||||
and fall back to row_default = 63 — preserving the pre-fix behaviour
|
||||
for legacy files where IGV already carries sample.vol.
|
||||
197..255 Reserved (59 bytes free for future per-instrument fields)
|
||||
|
||||
|
||||
|
||||
@@ -2293,7 +2323,6 @@ TODO:
|
||||
[x] 4THSYM.it: pitchbend is wrong, some notes keep playing (loudly!) even if new notes are emitted
|
||||
[x] `*2taud.py`: some notes are emitted with wrong volume-set command. Tested with GSLINGER.mod: on order 0x15 channel 1, mod2taud.py emits volume 8 -- also many of the effects are dropped. Suggested solution: currently all converters write default volume to the voleff when original modules (.mod/.s3m/.it) specify nothing; we should also write nothing and let the engine resolve the value just like other trackers do (also we now have "Instrument Global Volume" on instrument definition unlike the other time). This bug may affecting other formats, not just mod2taud.py, as well
|
||||
[x] nearly_there_.mod: `C#5 SD300 / ... / C-5 SD200 / A#4 / G#4 (at tickspeed 4)`: every `C-5 SD200` (there are four occurances) gets skipped
|
||||
[ ] low-number voleffs are too quiet (needs elaboration and test cases)
|
||||
[x] scale Oxxxx when samples get resampled
|
||||
[x] implement bitcrusher and overdrive (eff sym '8' and '9')
|
||||
[x] note trigger with inst and note fx set (e.g. portamento) but no volume set is not getting their default volume but getting what was before instead (SATELL.taud ptn 23) -- and simulateRowState() of taut.js always shows old volume instead of default volume, regardless of note fx's existence
|
||||
@@ -2346,11 +2375,39 @@ TODO:
|
||||
(`drawOrdersRowAt`) and per-column (`drawOrdersVoiceColumnAt`) helpers,
|
||||
replacing the full-panel redraw on every keystroke.
|
||||
[x] volume and panning policy to match note effect policy: when note is "retriggerred" (note command with instrument specified), the volume/pan must take default value; if not (note command with instrument 0) the volume/pan must stay at the old value. Make both audio engine and taut.js simulator changes.
|
||||
[ ] xm volume column commands (+x, -x, Dx, Lx, Mx, Px, Rx, Sx, Ux, Vx) are completely ignored
|
||||
[x] xm volume column commands (+x, -x, Dx, Lx, Mx, Px, Rx, Sx, Ux, Vx) are completely ignored
|
||||
[x] theday.xm order 0x28, channel 6..8 has 'note trigger with inst 1 but no volume -> key-off -> set-volume to 0x20 -> key-off -> set-volume to 0x10 -> key-off -> ...' and it sounds like gating: key-off silences the output, set-volume turns on the output again; notably, this behaviour only works when volume envelope is turned off (any fadeouts progress normally). FT2's keyOff (ft2_replayer.c:411-435) zeroes realVol/outVol when the volume envelope is disabled — IT/Schism does not, and Taud's engine follows IT semantics (no fade when fadeStep == 0). Resolved in xm2taud.py: a pre-pass tracks per-channel bound XM instrument across the order-list walk, and any key-off cell whose bound instrument has vol_env_type & XM_ENV_ON == 0 is paired with `SEL_SET vol=0` in the same row. A subsequent vol-col SET on the channel restores audibility — exactly mirroring FT2's outVol/realVol gate without diverging the engine. Engine semantics stay IT-pure.
|
||||
[ ] remove panning mode selection and replace global panning rule to 3 dB rule (not the equal energy)
|
||||
[ ] FT2/MOD double effects (5xx, 6xx) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py)
|
||||
[x] FT2/MOD double effects with 00 as arg (500, 600) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py). This is justified because the volume effects rely on memory when 00 is given, and said memory effect only get recalled when NoteFx is used. TAUD_NOTE_EFFECTS already has detailed implementation notes. Mark those two commands as implemented sorely for tracker compatibility.
|
||||
Also document then implement `Mxx` (set channel volume, not just a note: 0x00 to 0x3F) `Nxy` (channel volume slide: similar to Dxy, but applies to the current channel's volume, not just a note) `Pxy` (channel panning slide. Similar to Dxx: P0y - to the right, Px0 - to the left, PFy - fine pan right, PxF - fine pan left) effects
|
||||
[x] 8 MB sample RAM via 512k banks
|
||||
[x] remove panning mode selection and replace global panning rule to equal energy, also move the 'ff' flags to bit 0..1
|
||||
[x] low-number voleffs are too quiet (resolved 2026-05-09).
|
||||
Root cause: the converters folded IT `sample.vol` into IGV (byte 171),
|
||||
and the engine multiplied by IGV continuously — so any V-column override
|
||||
on a sample with default vol < 64 was attenuated a second time, while
|
||||
IT/Schism's V column replaces `chan->volume` outright (sample.vol does
|
||||
not feature in the continuous `instrument_volume` factor — see
|
||||
player/csndfile.c:1317 and player/sndmix.c:1171).
|
||||
Fix: split the two concepts apart. Byte 171 (IGV) is now pure
|
||||
`inst.gv * sample.gv` continuous multiplier; new byte 196 ("Default
|
||||
Note Volume") carries `sample.vol` and is consulted by triggerNote
|
||||
when no V column is present. Engine + all four `*2taud` converters
|
||||
updated; legacy `.taud` files (byte 196 == 0) fall back to the
|
||||
previous "row volume default = 63" behaviour.
|
||||
|
||||
TODO - list of demo songs that MUST ship with Microtone:
|
||||
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
||||
(C) Skaven 1998
|
||||
* Slumberjack — for demonstrating XM-compatible instrument definitions
|
||||
(C) raina 2005
|
||||
* Space Debris — MOD with tons of effects
|
||||
(C) Captain/Image 1991
|
||||
* Changing Waves — for Funk Repeat emulation
|
||||
(C) 4mat/orb 2023
|
||||
* Aboriginal Derivatives — for demonstrating Monotone compatibility.
|
||||
(C) Jakim 2010
|
||||
* SWINGIN1 (rename to Swinging Waste) — for demonstrating Monotone compatibility.
|
||||
(C) Phoenix/Hornet 2015
|
||||
|
||||
Play Data: play data are series of tracker-like instructions, visualised as:
|
||||
|
||||
@@ -2405,6 +2462,7 @@ Audio Adapter MMIO
|
||||
44 RW: TAD Decoder Status
|
||||
Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
|
||||
45 RW: Select PCM Bin for playhead (writing causes side effects)
|
||||
46 RW: Select current sample bank for tracker, exposed at memory space 0..524287
|
||||
|
||||
64..2367 RW: MP2 Decoded Samples (unsigned 8-bit stereo)
|
||||
2368..4095 RW: MP2 Frame to be decoded
|
||||
@@ -2450,14 +2508,14 @@ Play Head Flags
|
||||
Byte 2
|
||||
- PCM Mode: Write non-zero value to start uploading; always 0 when read
|
||||
- Tracker Mode: Global mixer flags. Maps directly to Taud effect symbol '1'
|
||||
0b 0000 0ffp
|
||||
p: panning mode (0: linear, 1: equal-power)
|
||||
0b 0000 00ff
|
||||
ff: pitchshift mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
|
||||
Tracker command may change the mixer state, but the changes WILL NOT BE REFLECTED BACK.
|
||||
Starting a new song will use whatever written to this register. In other words, changes
|
||||
made by songs will not persist.
|
||||
Panning law is fixed to the equal-energy; there is no runtime selection.
|
||||
Byte 3 (Tracker Mode)
|
||||
- BPM (24 to 279. Play Data will change this register)
|
||||
- BPM (25 to 280. Play Data will change this register)
|
||||
Byte 4 (Tracker Mode)
|
||||
- Tick Rate (Play Data will change this register)
|
||||
|
||||
@@ -2487,8 +2545,8 @@ Play Head Flags
|
||||
Table of 3.5 Minifloat values (CSV).
|
||||
Rebiased 2026-05-07 so the smallest non-zero step is 1/256 s and the maximum
|
||||
is 15.75 s — every cell is the original LUT value divided by 8. Chosen for
|
||||
tracker envelopes: a single song tick (≈ 8.9 ms at BPM 280, ≈ 41.7 ms at
|
||||
BPM 24) now lands within ±17 % of an LUT entry across the whole supported
|
||||
tracker envelopes: a single song tick (≈ 8.9 ms at BPM 280, ≈ 100 ms at
|
||||
BPM 25) now lands within ±17 % of an LUT entry across the whole supported
|
||||
BPM range; the previous bias was ±150 % at common BPMs.
|
||||
,000,001,010,011,100,101,110,111,MSB
|
||||
00000,0,0.125,0.25,0.5,1,2,4,8
|
||||
@@ -2561,22 +2619,21 @@ Endianness: Little
|
||||
Uint32 Offset to Project Data. Zero if Project Data is nonexistent
|
||||
Byte[14]Tracker/Converter signature
|
||||
|
||||
## Sample and Instrument bin image
|
||||
8256 kB when decompressed. First 8 MB holds samples.
|
||||
|
||||
## Song Table
|
||||
* Rows of 32 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=279)
|
||||
Uint8 Initial BPM (bias of -25. 0x00=25, 0xFF=280)
|
||||
Uint8 Initial Tickrate (0 is invalid)
|
||||
Uint16 Current Tuning base note (1..65533). A4 (western default) is 0x5C00. C9 (tracker default) is 0xA000. If zero, assume the tracker default value
|
||||
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
|
||||
Uint8 Flags for Global Behaviour (effect symbol '1')
|
||||
0b 0000 0ffp
|
||||
p: panning law (0: linear, 1: equal-power)
|
||||
0b 0000 00ff
|
||||
ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
|
||||
(bit 2 reserved — was 'm' fadeout-zero policy, removed; fadeout
|
||||
scaling now lives entirely in the converter — see byte 172/173
|
||||
of the instrument record for engine semantics)
|
||||
Uint8 Song global volume
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
Uint8 Song mixing volume
|
||||
@@ -2740,7 +2797,7 @@ The halt instruction (byte value 0x01 at cue offset 30) is placed on the last ac
|
||||
|
||||
## 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.
|
||||
S3M BPM is stored as a raw decimal value. Taud's initial BPM byte uses a bias of -25 (byte 0x00 = 25 BPM, 0xFF = 280 BPM). Conversion: taud_byte = bpm - 25. 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
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
|
||||
fun startSampleUpload(playhead: Int) { getPlayhead(playhead)?.pcmUpload = true }
|
||||
|
||||
fun setBPM(playhead: Int, bpm: Int) { getPlayhead(playhead)?.bpm = (bpm - 24).and(255) + 24 }
|
||||
fun setBPM(playhead: Int, bpm: Int) { getPlayhead(playhead)?.bpm = (bpm - 25).and(255) + 25 }
|
||||
fun getBPM(playhead: Int) = getPlayhead(playhead)?.bpm
|
||||
|
||||
fun setTickRate(playhead: Int, rate: Int) { getPlayhead(playhead)?.tickRate = rate and 255 }
|
||||
@@ -135,10 +135,10 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
getFirstSnd()?.playheads?.get(playhead)?.let { ph ->
|
||||
ph.initialGlobalFlags = flags
|
||||
ph.trackerState?.let { ts ->
|
||||
ts.panLaw = flags and 1
|
||||
ts.toneMode = (flags ushr 1) and 3
|
||||
// bit 2 reserved (was 'm' fadeout-zero policy; removed — see AudioAdapter.kt
|
||||
// and TAUD_NOTE_EFFECTS.md §1 "Volume Fadeout")
|
||||
ts.toneMode = flags and 3
|
||||
// Bits 2-7 reserved. Bit 2 was the old 'm' fadeout-zero policy; removed.
|
||||
// Pan law is fixed to the equal-energy engine-wide — no flag bit any more.
|
||||
// See AudioAdapter.kt and TAUD_NOTE_EFFECTS.md §1.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,61 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
|
||||
fun getBaseAddr(): Int? = getFirstSnd()?.let { return it.vm.findPeriSlotNum(it)?.times(-131072)?.minus(1) }
|
||||
fun getMemAddr(): Int? = getFirstSnd()?.let { return it.vm.findPeriSlotNum(it)?.times(-1048576)?.minus(1) }
|
||||
|
||||
/** Switch the sample-bin window (peripheral memory 0..524287) to bank `bank` (0..15).
|
||||
* The 8 MB sample pool is organised as 16 × 512 K banks; only the selected bank
|
||||
* is visible through the window. (terranmon.txt:1985-1997, MMIO 46.) */
|
||||
fun setSampleBank(bank: Int) { getFirstSnd()?.mmio_write(46L, bank.toByte()) }
|
||||
fun getSampleBank(): Int? = getFirstSnd()?.sampleBank
|
||||
|
||||
/** Decompress a Taud sample+instrument blob (gzip or zstd) directly into the
|
||||
* audio adapter's 8 MB sample pool and 64 K instrument bin, bypassing the user
|
||||
* memory staging buffer. The decompressed payload must be exactly
|
||||
* `SAMPLE_BIN_TOTAL + 65536` bytes (8 MB samples followed by 64 K instruments).
|
||||
*
|
||||
* Needed because user space is capped at 8 MB and cannot hold the full 8256 kB
|
||||
* decompressed image as a contiguous buffer. */
|
||||
fun uploadSampleInstBlob(srcPtr: Int, srcLen: Int): Int {
|
||||
val snd = getFirstSnd() ?: return 0
|
||||
val inbytes = ByteArray(srcLen) { vm.peek(srcPtr.toLong() + it)!! }
|
||||
val bytes = CompressorDelegate.decomp(inbytes)
|
||||
val sampleSize = AudioAdapter.SAMPLE_BIN_TOTAL.toInt()
|
||||
val instSize = 65536
|
||||
if (bytes.size < sampleSize + instSize) return 0
|
||||
UnsafeHelper.memcpyRaw(
|
||||
bytes, UnsafeHelper.getArrayOffset(bytes),
|
||||
null, snd.sampleBin.ptr,
|
||||
sampleSize.toLong()
|
||||
)
|
||||
for (i in 0 until instSize) {
|
||||
snd.instruments[i / 256].setByte(i % 256, bytes[sampleSize + i].toInt() and 0xFF)
|
||||
}
|
||||
return bytes.size
|
||||
}
|
||||
|
||||
/** Compress the audio adapter's full 8 MB sample pool + 64 K instrument bin
|
||||
* (8256 kB total) and write the resulting gzip/zstd blob to user-memory `dstPtr`.
|
||||
* Returns the compressed size. The caller must ensure `dstMaxLen` is large
|
||||
* enough; for incompressible noise the worst case is ~8.3 MB which exceeds
|
||||
* user space — but realistic sample data compresses easily. */
|
||||
fun captureSampleInstBlob(dstPtr: Int, dstMaxLen: Int): Int {
|
||||
val snd = getFirstSnd() ?: return 0
|
||||
val sampleSize = AudioAdapter.SAMPLE_BIN_TOTAL.toInt()
|
||||
val instSize = 65536
|
||||
val raw = ByteArray(sampleSize + instSize)
|
||||
UnsafeHelper.memcpyRaw(
|
||||
null, snd.sampleBin.ptr,
|
||||
raw, UnsafeHelper.getArrayOffset(raw),
|
||||
sampleSize.toLong()
|
||||
)
|
||||
for (i in 0 until instSize) {
|
||||
raw[sampleSize + i] = snd.instruments[i / 256].getByte(i % 256)
|
||||
}
|
||||
val compressed = CompressorDelegate.comp(raw)
|
||||
val n = minOf(compressed.size, dstMaxLen)
|
||||
for (i in 0 until n) vm.poke((dstPtr + i).toLong(), compressed[i])
|
||||
return compressed.size
|
||||
}
|
||||
fun mp2Init() = getFirstSnd()?.mmio_write(40L, 16)
|
||||
fun mp2Decode() = getFirstSnd()?.mmio_write(40L, 1)
|
||||
fun mp2InitThenDecode() = getFirstSnd()?.mmio_write(40L, 17)
|
||||
|
||||
@@ -812,7 +812,9 @@ class VM(
|
||||
if (fromRel + len > 1048576) return null
|
||||
|
||||
return if (dev is AudioAdapter) {
|
||||
if (relPtrInDev(fromRel, len, 0, 114687)) dev.sampleBin.ptr + fromRel - 0
|
||||
// Sample-bin window: 0..524287 maps into the 8 MB pool through MMIO 46.
|
||||
if (relPtrInDev(fromRel, len, 0, 524287))
|
||||
dev.sampleBin.ptr + dev.sampleBank.toLong() * AudioAdapter.SAMPLE_BANK_SIZE + fromRel
|
||||
else null
|
||||
}
|
||||
else if (dev is GraphicsAdapter) {
|
||||
|
||||
@@ -62,7 +62,9 @@ class VMJSR223Delegate(private val vm: VM) {
|
||||
// System.err.println("MEMORY dev=${dev.typestring}, fromIndex=$fromIndex, fromRel=$fromRel")
|
||||
|
||||
return if (dev is AudioAdapter) {
|
||||
if (relPtrInDev(fromRel, len, 0, 114687)) dev.sampleBin.ptr + fromRel - 0
|
||||
// Sample-bin window: 0..524287 maps into the 8 MB pool through MMIO 46.
|
||||
if (relPtrInDev(fromRel, len, 0, 524287))
|
||||
dev.sampleBin.ptr + dev.sampleBank.toLong() * AudioAdapter.SAMPLE_BANK_SIZE + fromRel
|
||||
else null
|
||||
}
|
||||
else if (dev is GraphicsAdapter) {
|
||||
|
||||
@@ -12,12 +12,12 @@ import net.torvald.tsvm.ThreeFiveMiniUfloat
|
||||
import net.torvald.tsvm.VM
|
||||
import net.torvald.tsvm.toInt
|
||||
import java.io.ByteArrayInputStream
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
private class RenderRunnable(val playhead: AudioAdapter.Playhead) : Runnable {
|
||||
private fun printdbg(msg: Any) {
|
||||
@@ -144,13 +144,26 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// 8 ms at 32 kHz — long enough to bury the click, short enough not to read as fade.
|
||||
// Applied on sample end only (preserves attack transients on note start).
|
||||
const val RAMP_OUT_SAMPLES = 256
|
||||
|
||||
// Sample bin: 8 MB total, banked through a 512 K window at peripheral
|
||||
// memory 0..524287. MMIO 46 holds the currently-exposed bank index.
|
||||
const val SAMPLE_BANK_SIZE: Long = 524288L // 512 K
|
||||
const val SAMPLE_BANK_COUNT: Int = 16 // 16 × 512 K = 8 MB
|
||||
const val SAMPLE_BIN_TOTAL: Long = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT
|
||||
const val SAMPLE_BANK_MASK: Int = SAMPLE_BANK_COUNT - 1
|
||||
}
|
||||
|
||||
// Memory map (terranmon.txt:1985-1997, updated 2026-05-06):
|
||||
// 0..720895 sample bin (704K, was 737280)
|
||||
// Memory map (terranmon.txt:1985-1997, updated 2026-05-08):
|
||||
// 0..524287 sample bin window (512K — exposes one bank of 8 MB pool)
|
||||
// 524288..720895 reserved (no-op on access)
|
||||
// 720896..786431 instrument bin (256 inst × 256 bytes = 64K)
|
||||
// 786432.. play data 1 / 2 / TAD blocks (anchors unchanged)
|
||||
internal val sampleBin = UnsafeHelper.allocate(720896L, this)
|
||||
//
|
||||
// Backing sample memory is 8 MB, banked in 16 × 512K pages. MMIO 46 holds
|
||||
// the currently-exposed bank index (0..15); reads/writes through the window
|
||||
// hit `sampleBin[sampleBank * 524288 + offset]`.
|
||||
internal val sampleBin = UnsafeHelper.allocate(SAMPLE_BIN_TOTAL, this)
|
||||
@Volatile var sampleBank: Int = 0 // 0..15, controls the 0..524287 window
|
||||
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>
|
||||
@@ -322,7 +335,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
override fun peek(addr: Long): Byte {
|
||||
return when (val adi = addr.toInt()) {
|
||||
in 0..720895 -> sampleBin[addr]
|
||||
in 0..524287 -> sampleBin[sampleBank * SAMPLE_BANK_SIZE + addr]
|
||||
in 524288..720895 -> 0 // reserved
|
||||
in 720896..786431 -> (adi - 720896).let { instruments[it / 256].getByte(it % 256) }
|
||||
in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) }
|
||||
in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) }
|
||||
@@ -336,7 +350,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val adi = addr.toInt()
|
||||
val bi = byte.toUint()
|
||||
when (adi) {
|
||||
in 0..720895 -> { sampleBin[addr] = byte }
|
||||
in 0..524287 -> { sampleBin[sampleBank * SAMPLE_BANK_SIZE + addr] = byte }
|
||||
in 524288..720895 -> { /* reserved */ }
|
||||
in 720896..786431 -> (adi - 720896).let { instruments[it / 256].setByte(it % 256, bi) }
|
||||
in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) }
|
||||
in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) }
|
||||
@@ -358,6 +373,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
43 -> tadQuality.toByte()
|
||||
44 -> tadBusy.toInt().toByte()
|
||||
45 -> selectedPcmBin.toByte()
|
||||
46 -> sampleBank.toByte()
|
||||
in 64..2367 -> mediaDecodedBin[addr - 64]
|
||||
in 2368..4095 -> mediaFrameBin[addr - 2368]
|
||||
in 4096..4097 -> 0
|
||||
@@ -393,6 +409,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
tadQuality = bi.coerceIn(0, 5)
|
||||
}
|
||||
45 -> selectedPcmBin = bi % 4
|
||||
46 -> sampleBank = bi and SAMPLE_BANK_MASK
|
||||
in 64..2367 -> { mediaDecodedBin[addr - 64] = byte }
|
||||
in 2368..4095 -> { mediaFrameBin[addr - 2368] = byte }
|
||||
in 32768..65535 -> { (adi - 32768).let {
|
||||
@@ -1171,7 +1188,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
const val OP_J = 0x13
|
||||
const val OP_K = 0x14
|
||||
const val OP_L = 0x15
|
||||
const val OP_M = 0x16
|
||||
const val OP_N = 0x17
|
||||
const val OP_O = 0x18
|
||||
const val OP_P = 0x19
|
||||
const val OP_Q = 0x1A
|
||||
const val OP_R = 0x1B
|
||||
const val OP_S = 0x1C
|
||||
@@ -1616,19 +1636,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val sampleLen = inst.sampleLength.coerceAtLeast(1)
|
||||
val loopStart = inst.sampleLoopStart.toDouble()
|
||||
val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0)
|
||||
val binMax = 720895 // sampleBin is 720896 bytes (0..720895)
|
||||
val binMax = (SAMPLE_BIN_TOTAL - 1).toInt() // 8 MB pool, addressed via samplePtr directly (not banked)
|
||||
|
||||
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
|
||||
val i1 = (i0 + 1).coerceAtMost(sampleLen - 1)
|
||||
val frac = voice.samplePos - i0.toDouble()
|
||||
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.
|
||||
// S$Fx funk repeat: bit-invert (XOR 0xFF) bytes whose loop-relative index
|
||||
// is set in funkMask. Mirrors PT2's `*p = -1 - *p` (full bitwise NOT) — the
|
||||
// mask is a non-destructive overlay so the source sample stays pristine.
|
||||
// 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
|
||||
if (i0 in ls until inst.sampleLoopEnd && inst.funkBit(i0 - ls)) b0 = b0 xor 0xFF
|
||||
if (i1 in ls until inst.sampleLoopEnd && inst.funkBit(i1 - ls)) b1 = b1 xor 0xFF
|
||||
}
|
||||
val s0 = (b0 - 127.5) / 127.5
|
||||
val s1 = (b1 - 127.5) / 127.5
|
||||
@@ -1681,6 +1703,20 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* Trigger-time default rowVolume seed derived from the instrument's
|
||||
* Default Note Volume (byte 196). Pre-2026-05-09 .taud files left this
|
||||
* byte zero; treating 0 as "field not present" and falling back to 0x3F
|
||||
* keeps legacy behaviour. Used by both [triggerNote] and the tone-porta
|
||||
* + instrument-byte path in [advanceRow] — both must seed identically
|
||||
* (Schism player/effects.c:1302 writes `chan->volume = psmp->volume`
|
||||
* unconditionally on inst-column rows, regardless of porta).
|
||||
*/
|
||||
private fun rowVolumeFromDefault(inst: TaudInst): Int {
|
||||
val dnv = inst.defaultNoteVolume
|
||||
return if (dnv == 0) 0x3F else (dnv * 63 + 127) / 255
|
||||
}
|
||||
|
||||
private fun triggerNote(voice: Voice, noteVal: Int, instId: Int, volOverride: Int) {
|
||||
if (instId != 0) voice.instrumentId = instId
|
||||
val inst = instruments[voice.instrumentId]
|
||||
@@ -1717,6 +1753,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// Auto-vibrato sweep ramp restarts on every fresh trigger.
|
||||
voice.autoVibPhase = 0
|
||||
voice.autoVibTicksSinceTrigger = 0
|
||||
// Funk repeat (S$Fx): PT2 resets n_wavestart to n_loopstart on every fresh
|
||||
// note trigger (pt2_replayer.c:1094, 1100). funkSpeed and funkAccumulator
|
||||
// persist across notes, matching PT2.
|
||||
voice.funkWritePos = 0
|
||||
// Random vol/pan swing biases — seeded once per trigger (range determined by inst.volumeSwing/panSwing).
|
||||
voice.randomVolBias = if (inst.volumeSwing != 0)
|
||||
(Math.random() * (2 * inst.volumeSwing + 1)).toInt() - inst.volumeSwing else 0
|
||||
@@ -1753,14 +1793,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new noteVal
|
||||
voice.linearFreq = -1.0 // ditto for linear-freq mode (toneMode == 2)
|
||||
voice.playbackRate = computePlaybackRate(inst, noteVal)
|
||||
// Fresh trigger resets channel volume to full ($3F) ONLY when the row carried an
|
||||
// instrument byte; a note-only retrigger (instId == 0) inherits the channel's existing
|
||||
// volume so the user can sustain a held volume across re-triggered notes. Per-instrument
|
||||
// scaling lives in instGlobalVolume (byte 171), which the mixer applies as a multiplier.
|
||||
// Converters therefore no longer need to emit SEL_SET=Sv on note-trigger rows.
|
||||
// Fresh trigger seeds rowVolume from the per-instrument "default note volume"
|
||||
// (byte 196) when the row carried an instrument byte but no explicit V column —
|
||||
// matching IT's `chan->volume = psmp->volume` rule (Schism player/effects.c:1302
|
||||
// and :1432). Pre-2026-05-09 .taud files left byte 196 zero and folded sample.vol
|
||||
// into IGV instead; treating 0 as "field not present" and falling back to 0x3F
|
||||
// preserves legacy behaviour. A note-only retrigger (instId == 0) inherits the
|
||||
// channel's existing volume so held-volume sustains keep working across retriggers.
|
||||
// Continuous per-instrument scaling lives in instGlobalVolume (byte 171), which the
|
||||
// mixer applies independently of this seed.
|
||||
voice.channelVolume = when {
|
||||
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
|
||||
instId != 0 -> 0x3F
|
||||
instId != 0 -> rowVolumeFromDefault(inst)
|
||||
else -> voice.channelVolume
|
||||
}
|
||||
voice.rowVolume = voice.channelVolume
|
||||
@@ -2006,7 +2050,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
voice.rowEffectArg = row.effectArg
|
||||
|
||||
// ── Note ──
|
||||
val toneG = (row.effect == EffectOp.OP_G)
|
||||
// OP_L (combined porta + vol slide) also takes a tone-porta target without retriggering,
|
||||
// mirroring G's behaviour — the L command continues the porta started by an earlier G.
|
||||
val toneG = (row.effect == EffectOp.OP_G || row.effect == EffectOp.OP_L)
|
||||
when (row.note) {
|
||||
// No note but an instrument byte is present: latch the instrument so
|
||||
// the *next* note-only trigger picks up the right sample. Trackers
|
||||
@@ -2032,11 +2078,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// so an in-progress fadeout from the prior note does not bleed
|
||||
// into the porta'd note. fadeoutVolume is reset to unity so a
|
||||
// volume-column SET on this row is heard at face value rather
|
||||
// than scaled by the decayed tail.
|
||||
// than scaled by the decayed tail. The seed must use the new
|
||||
// instrument's Default Note Volume (byte 196) — hard-coding
|
||||
// 0x3F here would push samples with a reduced default vol up
|
||||
// to full level on every porta-with-inst row (e.g.
|
||||
// nearly_there_.mod ord 0x1B ch 4 r49 jumped from ~35 to 63
|
||||
// and the bump persisted through the following vibrato rows).
|
||||
if (row.instrment != 0) {
|
||||
voice.instrumentId = row.instrment
|
||||
voice.channelVolume = 0x3F
|
||||
voice.rowVolume = 0x3F
|
||||
val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId])
|
||||
voice.channelVolume = seedVol
|
||||
voice.rowVolume = seedVol
|
||||
voice.keyOff = false
|
||||
voice.noteFading = false
|
||||
voice.fadeoutVolume = 1.0
|
||||
@@ -2077,12 +2129,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
EffectOp.OP_NONE -> {}
|
||||
EffectOp.OP_1 -> {
|
||||
// 1 $xx00 — Global behaviour flags byte in the high byte (see TAUD_NOTE_EFFECTS.md §1).
|
||||
// bit 0 (p): 0=linear pan, 1=equal-power pan
|
||||
// bits 1-2 (ff): 0=linear pitch, 1=Amiga period, 2=linear frequency (Hz/tick),
|
||||
// bits 0-1 (ff): 0=linear pitch, 1=Amiga period, 2=linear frequency (Hz/tick),
|
||||
// 3=reserved
|
||||
// Panning law is fixed to the equal-energy; no runtime selection.
|
||||
val flags = rawArg ushr 8
|
||||
ts.panLaw = flags and 1
|
||||
ts.toneMode = (flags ushr 1) and 3
|
||||
ts.toneMode = flags and 3
|
||||
}
|
||||
EffectOp.OP_8 -> {
|
||||
// 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §8.
|
||||
@@ -2211,7 +2262,83 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
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_K -> {
|
||||
// K $xy00 — vibrato continuation + per-tick volume slide. xy lives in the high
|
||||
// byte; $00 recalls K's private memory (TAUD_NOTE_EFFECTS.md §K). Vibrato uses
|
||||
// the H/U memory cohort (no retrigger from K alone). Slide direction: high nibble
|
||||
// = up, low nibble = down; both non-zero ⇒ down wins (ST3 quirk).
|
||||
val raw = (rawArg ushr 8) and 0xFF
|
||||
val arg = if (raw != 0) raw.also { voice.mem.k = it } else voice.mem.k
|
||||
val hi = (arg ushr 4) and 0xF
|
||||
val lo = arg and 0xF
|
||||
voice.vibratoActive = true
|
||||
voice.vibratoFineShift = 6
|
||||
when {
|
||||
lo != 0 -> { voice.volColSlideDown = lo } // down wins
|
||||
hi != 0 -> { voice.volColSlideUp = hi }
|
||||
}
|
||||
}
|
||||
EffectOp.OP_L -> {
|
||||
// L $xy00 — tone-portamento continuation + per-tick volume slide. xy lives in the
|
||||
// high byte; $00 recalls L's private memory (TAUD_NOTE_EFFECTS.md §L). The porta
|
||||
// target was set in the row's note-handling block (toneG includes OP_L); the
|
||||
// porta speed is recalled from G's memory so a prior G's rate carries forward.
|
||||
val raw = (rawArg ushr 8) and 0xFF
|
||||
val arg = if (raw != 0) raw.also { voice.mem.l = it } else voice.mem.l
|
||||
val hi = (arg ushr 4) and 0xF
|
||||
val lo = arg and 0xF
|
||||
voice.tonePortaSpeed = voice.mem.g
|
||||
when {
|
||||
lo != 0 -> { voice.volColSlideDown = lo }
|
||||
hi != 0 -> { voice.volColSlideUp = hi }
|
||||
}
|
||||
}
|
||||
EffectOp.OP_M -> {
|
||||
// M $xx00 — set channel volume to the high byte (literal, no recall). IT $40 is
|
||||
// clamped to Taud's $3F cap. See TAUD_NOTE_EFFECTS.md §M.
|
||||
val newVol = ((rawArg ushr 8) and 0xFF).coerceAtMost(0x3F)
|
||||
voice.channelVolume = newVol
|
||||
voice.rowVolume = newVol
|
||||
}
|
||||
EffectOp.OP_N -> {
|
||||
// N $xy00 — channel-volume slide. Same nibble decoding as D but writes the
|
||||
// persistent channelVolume so the change carries past this row.
|
||||
val arg = resolveArg(rawArg, voice.mem.n).also { if (rawArg != 0) voice.mem.n = it }
|
||||
val hi = (arg ushr 8) and 0xFF
|
||||
val lo = hi and 0x0F
|
||||
val hin = (hi ushr 4) and 0x0F
|
||||
when {
|
||||
hi == 0xFF || hi == 0xF0 -> { voice.channelVolume = (voice.channelVolume + 0xF).coerceAtMost(0x3F); voice.rowVolume = voice.channelVolume }
|
||||
hin == 0xF && lo != 0 -> { voice.channelVolume = (voice.channelVolume - lo).coerceAtLeast(0); voice.rowVolume = voice.channelVolume }
|
||||
lo == 0xF && hin != 0 -> { voice.channelVolume = (voice.channelVolume + hin).coerceAtMost(0x3F); voice.rowVolume = voice.channelVolume }
|
||||
hin == 0 && lo != 0 -> { voice.volColSlideDown = lo } // coarse down per non-first tick
|
||||
lo == 0 && hin != 0 -> { voice.volColSlideUp = hin } // coarse up per non-first tick
|
||||
}
|
||||
}
|
||||
EffectOp.OP_P -> {
|
||||
// P $xy00 — channel-panning slide. D-style nibble layout, but the IT panning
|
||||
// direction convention applies: low nibble = right, high nibble = left.
|
||||
val arg = resolveArg(rawArg, voice.mem.p).also { if (rawArg != 0) voice.mem.p = it }
|
||||
val hi = (arg ushr 8) and 0xFF
|
||||
val lo = hi and 0x0F // low nibble of high byte → right
|
||||
val hin = (hi ushr 4) and 0x0F // high nibble of high byte → left
|
||||
when {
|
||||
hi == 0xFF || hi == 0xF0 -> { // FF / F0 quirk: fine left by F (high-nib form wins)
|
||||
voice.channelPan = (voice.channelPan - 0xF).coerceAtLeast(0)
|
||||
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
|
||||
}
|
||||
hin == 0xF && lo != 0 -> { // fine right by lo on tick 0
|
||||
voice.channelPan = (voice.channelPan + lo).coerceAtMost(0xFF)
|
||||
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
|
||||
}
|
||||
lo == 0xF && hin != 0 -> { // fine left by hin on tick 0
|
||||
voice.channelPan = (voice.channelPan - hin).coerceAtLeast(0)
|
||||
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
|
||||
}
|
||||
hin == 0 && lo != 0 -> { voice.panColSlideRight = lo } // coarse right per non-first tick
|
||||
lo == 0 && hin != 0 -> { voice.panColSlideLeft = hin } // coarse left per non-first tick
|
||||
}
|
||||
}
|
||||
EffectOp.OP_O -> {
|
||||
val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it }
|
||||
val inst = instruments[voice.instrumentId]
|
||||
@@ -2246,7 +2373,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val hi = (rawArg ushr 8) and 0xFF
|
||||
if (hi != 0) {
|
||||
val tempoByte = hi
|
||||
playhead.bpm = (tempoByte + 0x18).coerceIn(24, 280)
|
||||
playhead.bpm = (tempoByte + 0x19).coerceIn(25, 280)
|
||||
} else {
|
||||
val low = rawArg and 0xFF
|
||||
when (low and 0xF0) {
|
||||
@@ -2575,8 +2702,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// 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)
|
||||
val tempoByte = (playhead.bpm - 0x19 + voice.tempoSlideDir * voice.tempoSlideAmount).coerceIn(0, 0xFF)
|
||||
playhead.bpm = (tempoByte + 0x19).coerceIn(25, 280)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2590,16 +2717,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
|
||||
// Funk repeat (S$Fxxxx) — advance bit-mask per tick on instruments with active funkSpeed.
|
||||
// Matches PT2 updateFunk (pt2_replayer.c:278-297): hard-reset accumulator on overflow
|
||||
// (NOT subtract — drops residual), and pre-increment the write pointer before flipping
|
||||
// so the first invert after a fresh trigger lands on loop-relative byte 1.
|
||||
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 += voice.funkSpeed
|
||||
while (voice.funkAccumulator >= 0x80) {
|
||||
voice.funkAccumulator -= 0x80
|
||||
if (voice.funkAccumulator >= 0x80) {
|
||||
voice.funkAccumulator = 0
|
||||
val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1)
|
||||
inst.toggleFunkBit(voice.funkWritePos % loopLen)
|
||||
voice.funkWritePos = (voice.funkWritePos + 1) % loopLen
|
||||
inst.toggleFunkBit(voice.funkWritePos)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2714,18 +2844,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
||||
(voice.channelPan + envPanRaw - 128 + voice.randomPanBias).coerceIn(0, 255)
|
||||
} else (voice.channelPan + voice.randomPanBias).coerceIn(0, 255)
|
||||
val lGain: Double
|
||||
val rGain: Double
|
||||
when (ts.panLaw) {
|
||||
1 -> { // equal-power: constant loudness at centre (0.707 each)
|
||||
lGain = cos(PI * pan / 512.0)
|
||||
rGain = sin(PI * pan / 512.0)
|
||||
}
|
||||
else -> { // linear balance (tracker default): centre gives 0 dB on both channels
|
||||
lGain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0
|
||||
rGain = if (pan < 0x80) pan / 128.0 else 1.0
|
||||
}
|
||||
}
|
||||
// equal-energy pan law
|
||||
val lGain = cos(PI * pan / 512.0)
|
||||
val rGain = sin(PI * pan / 512.0)
|
||||
// Sample-end ramp-out: snapshot gain, advance the ramp, deactivate at zero.
|
||||
val rampGain = if (voice.rampOutSamples > 0) {
|
||||
val g = voice.rampOutGain
|
||||
@@ -2752,18 +2873,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val envPanRaw = (bg.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
||||
(bg.channelPan + envPanRaw - 128 + bg.randomPanBias).coerceIn(0, 255)
|
||||
} else (bg.channelPan + bg.randomPanBias).coerceIn(0, 255)
|
||||
val lGain: Double
|
||||
val rGain: Double
|
||||
when (ts.panLaw) {
|
||||
1 -> {
|
||||
lGain = cos(PI * pan / 512.0)
|
||||
rGain = sin(PI * pan / 512.0)
|
||||
}
|
||||
else -> {
|
||||
lGain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0
|
||||
rGain = if (pan < 0x80) pan / 128.0 else 1.0
|
||||
}
|
||||
}
|
||||
val lGain = cos(PI * pan / 512.0)
|
||||
val rGain = sin(PI * pan / 512.0)
|
||||
val rampGain = if (bg.rampOutSamples > 0) {
|
||||
val g = bg.rampOutGain
|
||||
bg.rampOutGain -= bg.rampOutStep
|
||||
@@ -2947,6 +3058,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var q: Int = 0
|
||||
var tslide: Int = 0
|
||||
var w: Int = 0
|
||||
// K, L, N, P: each its own private slot. K and L store the high-byte
|
||||
// (xy nibble pair) of the most recent non-zero argument; N and P
|
||||
// store the same high-byte and let the per-tick form recover via
|
||||
// identical decoding to D. (M has no recall — literal-zero — so no
|
||||
// slot is needed.)
|
||||
var k: Int = 0
|
||||
var l: Int = 0
|
||||
var n: Int = 0
|
||||
var p: Int = 0
|
||||
}
|
||||
|
||||
class Voice {
|
||||
@@ -3144,8 +3264,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var firstRow = true
|
||||
val voices = Array(20) { Voice() }
|
||||
|
||||
// Global mixer config (effect 1).
|
||||
var panLaw = 0 // 0 = linear balance (default), 1 = equal-power
|
||||
// Global mixer config (effect 1). Panning law is fixed to the equal-energy.
|
||||
// Tone-slide mode for E / F / G effects (terranmon.txt §Song Table flags byte):
|
||||
// 0 = linear pitch slides (4096-TET units, default)
|
||||
// 1 = Amiga period slides (raw PT period units, applied in period space)
|
||||
@@ -3188,7 +3307,7 @@ 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 = 125, // BPM, derived from tempoByte + 24. Spec default $65 ⇒ 125 BPM.
|
||||
var bpm: Int = 125, // BPM, derived from tempoByte + 25. Spec default $64 ⇒ 125 BPM.
|
||||
var tickRate: Int = 6,
|
||||
var pcmUpload: Boolean = false,
|
||||
var patBank1: Int = 0,
|
||||
@@ -3236,7 +3355,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
5 -> masterPan.toByte()
|
||||
6 -> (isPcmMode.toInt(7) or isPlaying.toInt(4) or pcmQueueSizeIndex.and(15)).toByte()
|
||||
7 -> initialGlobalFlags.toByte()
|
||||
8 -> (bpm - 24).toByte()
|
||||
8 -> (bpm - 25).toByte()
|
||||
9 -> tickRate.toByte()
|
||||
else -> throw InternalError("Bad offset $index")
|
||||
}
|
||||
@@ -3263,20 +3382,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
7 -> if (isPcmMode) { pcmUpload = true } else {
|
||||
initialGlobalFlags = byte
|
||||
trackerState?.let { ts ->
|
||||
ts.panLaw = byte and 1
|
||||
ts.toneMode = (byte ushr 1) and 3
|
||||
ts.toneMode = byte and 3
|
||||
}
|
||||
}
|
||||
8 -> { bpm = byte + 24 }
|
||||
8 -> { bpm = byte + 25 }
|
||||
9 -> { tickRate = byte }
|
||||
else -> throw InternalError("Bad offset $index")
|
||||
}
|
||||
}
|
||||
|
||||
/*fun getSamplingRate() = 30000 - ((bpm - 24).and(255) or tickRate.and(255).shl(8)).toShort().toInt()
|
||||
/*fun getSamplingRate() = 30000 - ((bpm - 25).and(255) or tickRate.and(255).shl(8)).toShort().toInt()
|
||||
fun setSamplingRate(rate: Int) {
|
||||
val rateDiff = (rate.coerceIn(0, 95535) - 30000).toShort().toInt()
|
||||
bpm = rateDiff.and(255) + 24
|
||||
bpm = rateDiff.and(255) + 25
|
||||
tickRate = rateDiff.ushr(8).and(255)
|
||||
}*/
|
||||
|
||||
@@ -3298,8 +3416,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
ts.patternDelayRemaining = 0; ts.patternDelayActive = false
|
||||
ts.sexWinningChannel = -1
|
||||
ts.finePatternDelayExtra = 0
|
||||
ts.panLaw = initialGlobalFlags and 1
|
||||
ts.toneMode = (initialGlobalFlags ushr 1) and 3
|
||||
ts.toneMode = initialGlobalFlags and 3
|
||||
ts.voices.forEach {
|
||||
it.active = false
|
||||
it.channelVolume = 0x3F
|
||||
@@ -3318,6 +3435,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
it.noteFading = false
|
||||
}
|
||||
ts.backgroundVoices.clear()
|
||||
// Funk repeat (S$Fx): drop every per-instrument inversion mask so that
|
||||
// stop-and-replay starts from a clean cue-initial state. The masks accumulate
|
||||
// within a single playback (matching PT2's destructive-but-stable behaviour);
|
||||
// here we snapshot back to "no inversions yet" so a fresh play is reproducible
|
||||
// without needing to reload the song from disk.
|
||||
parent.instruments.forEach { it.funkMask = null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3428,7 +3551,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
* 193..194 u16 pitch/filter envelope SUSTAIN word
|
||||
* 195 u8 duplicate-check / action (relocated from old offset 189)
|
||||
* bits 0-1 = DCT, bits 2-3 = DCA
|
||||
* 196..255 reserved (60 bytes)
|
||||
* 196 u8 default note volume (0..255 → 0..63 on read).
|
||||
* Per-trigger seed for rowVolume when the row carries
|
||||
* a fresh note + instrument byte but no V column. 0
|
||||
* means "legacy file, fall back to 0x3F" (pre-2026-05-09
|
||||
* files folded sample.vol into IGV instead).
|
||||
* 197..255 reserved (59 bytes)
|
||||
*/
|
||||
data class TaudInst(
|
||||
var index: Int,
|
||||
@@ -3465,7 +3593,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var volEnvSustainWord: Int, // bytes 189-190 (SUSTAIN word)
|
||||
var panEnvSustainWord: Int, // bytes 191-192
|
||||
var pfEnvSustainWord: Int, // bytes 193-194
|
||||
var dupCheckFlag: Int // byte 195 (relocated from 189)
|
||||
var dupCheckFlag: Int, // byte 195 (relocated from 189)
|
||||
var defaultNoteVolume: Int // byte 196 — per-trigger rowVolume default
|
||||
) {
|
||||
constructor(index: Int) : this(
|
||||
index, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF,
|
||||
@@ -3473,7 +3602,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
Array(25) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) },
|
||||
Array(25) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) },
|
||||
0, 0, 0, 0, 0, 0x80, 0x5000, 0, 0, 0xFF, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0
|
||||
)
|
||||
|
||||
/** Sample-flag byte 14 bit 2 — when set, the sample loop is a sustain loop:
|
||||
@@ -3492,21 +3622,31 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
/** Duplicate Check Action — 0=note cut, 1=note off, 2=note fade. */
|
||||
val duplicateCheckAction: Int get() = (dupCheckFlag ushr 2) and 0x03
|
||||
|
||||
// Reserved padding at offsets 196..255 (60 bytes per instrument).
|
||||
private val reserved = ByteArray(60)
|
||||
// Reserved padding at offsets 197..255 (59 bytes per instrument).
|
||||
// Byte 196 is the new "default note volume" field — see triggerNote.
|
||||
private val reserved = ByteArray(59)
|
||||
|
||||
// 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.
|
||||
// Mask is sized for the loop length at allocation time; if the loop bounds change
|
||||
// (e.g. a new song reuses this instrument slot with different sample data) the old
|
||||
// mask is stale and must be discarded — otherwise indexing past its end crashes the
|
||||
// render thread with ArrayIndexOutOfBoundsException.
|
||||
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 expectedSize = (len + 7) / 8
|
||||
var mask = funkMask
|
||||
if (mask == null || mask.size != expectedSize) {
|
||||
mask = ByteArray(expectedSize).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)
|
||||
if (mask.size != (len + 7) / 8) { funkMask = null; return false }
|
||||
val idx = loopOffset.coerceIn(0, len - 1)
|
||||
return (mask[idx / 8].toInt() ushr (idx and 7)) and 1 != 0
|
||||
}
|
||||
@@ -3582,7 +3722,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
193 -> pfEnvSustainWord.toByte()
|
||||
194 -> pfEnvSustainWord.ushr(8).toByte()
|
||||
195 -> dupCheckFlag.toByte()
|
||||
in 196..255 -> reserved[offset - 196]
|
||||
196 -> defaultNoteVolume.toByte()
|
||||
in 197..255 -> reserved[offset - 197]
|
||||
else -> throw InternalError("Bad offset $offset")
|
||||
}
|
||||
|
||||
@@ -3644,7 +3785,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
193 -> { pfEnvSustainWord = (pfEnvSustainWord and 0xff00) or byte }
|
||||
194 -> { pfEnvSustainWord = (pfEnvSustainWord and 0x00ff) or (byte shl 8) }
|
||||
195 -> { dupCheckFlag = byte and 0x0F }
|
||||
in 196..255 -> { reserved[offset - 196] = byte.toByte() }
|
||||
196 -> { defaultNoteVolume = byte and 0xFF }
|
||||
in 197..255 -> { reserved[offset - 197] = byte.toByte() }
|
||||
else -> throw InternalError("Bad offset $offset")
|
||||
}
|
||||
}
|
||||
|
||||
221
xm2taud.py
221
xm2taud.py
@@ -6,9 +6,13 @@ Usage:
|
||||
|
||||
Limits:
|
||||
- Up to 20 XM channels (excess unused).
|
||||
- Sample bin is 737280 bytes; if all samples together exceed this,
|
||||
every sample is globally resampled down (with c2spd adjusted) so
|
||||
pitch is preserved, mirroring it2taud / mod2taud.
|
||||
- Sample bin is 8 MB (8388608 bytes); if all samples together exceed
|
||||
this, every sample is globally resampled down (with c2spd adjusted)
|
||||
so pitch is preserved, mirroring it2taud / mod2taud. Any individual
|
||||
sample whose 8-bit-mono form still exceeds the u16 length cap
|
||||
(SAMPLE_LEN_LIMIT bytes) is then resampled selectively to fit, and
|
||||
TOP_O sample-offset args on the affected channel are rescaled
|
||||
per-slot.
|
||||
- Multi-sample instruments use the sample selected by the *current
|
||||
note's* keymap entry; the converter materialises one Taud
|
||||
instrument slot per (XM instrument, sample-in-instrument) pair.
|
||||
@@ -37,7 +41,6 @@ Reference:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
@@ -45,16 +48,18 @@ import sys
|
||||
from taud_common import (
|
||||
set_verbose, vprint,
|
||||
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
|
||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
|
||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT,
|
||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
|
||||
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
|
||||
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y,
|
||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry, nearest_minifloat,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects_per_slot,
|
||||
encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob,
|
||||
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
|
||||
build_project_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -490,12 +495,19 @@ def encode_effect_xm(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
return (TOP_H, ((hi * 0x11) << 8) | (lo * 0x11), None, None)
|
||||
|
||||
if cmd == 0x05:
|
||||
# Tone porta + vol slide → Taud L (G + d_arg vol slide override).
|
||||
return (TOP_G, 0x0000, d_arg_to_col(arg), None)
|
||||
# Tone porta + vol slide → Taud L verbatim. The XM source byte goes
|
||||
# straight into L's high byte; the engine handles the combined
|
||||
# porta-continuation + vol-slide semantics natively (see
|
||||
# TAUD_NOTE_EFFECTS.md §L). XM's 500 (arg = 0) recall is honoured by
|
||||
# Taud's L $0000 recall against L's own private memory, so a 500 row
|
||||
# plays the previously emitted slide rate. This avoids the volume-
|
||||
# column collision that the H+vol-col split form caused on rows
|
||||
# already carrying a vol-column SET.
|
||||
return (TOP_L, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == 0x06:
|
||||
# Vibrato + vol slide → Taud K (H + d_arg vol slide override).
|
||||
return (TOP_H, 0x0000, d_arg_to_col(arg), None)
|
||||
# Vibrato + vol slide → Taud K verbatim (same rationale as 0x05).
|
||||
return (TOP_K, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == 0x07:
|
||||
hi = (arg >> 4) & 0xF
|
||||
@@ -587,8 +599,8 @@ def encode_effect_xm(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
return (TOP_NONE, 0, None, None)
|
||||
if arg < 0x20:
|
||||
return (TOP_A, (arg & 0xFF) << 8, None, None)
|
||||
# Tempo: Taud T uses bias of -24 in stored form; mirror it2taud:
|
||||
return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None)
|
||||
# Tempo: Taud T uses bias of -25 in stored form; mirror it2taud:
|
||||
return (TOP_T, ((arg - 0x19) & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == 0x10:
|
||||
# Set global volume 0..64 → Taud V (×4 to fit 0..255).
|
||||
@@ -896,24 +908,60 @@ def _xm_sample_to_proxy(inst: XMInstrument, samp: XMSample,
|
||||
def build_sample_inst_bin_xm(proxies: list) -> tuple:
|
||||
"""proxies: list (1-indexed; slot 0 unused) of _XMSampleProxy | None.
|
||||
|
||||
Returns (sampleinst_bin, offsets_dict, ratio).
|
||||
Returns (sampleinst_bin, offsets_dict, slot_ratios) where slot_ratios
|
||||
maps Taud slot index → effective TOP_O scale (combined global ×
|
||||
per-sample resample ratio).
|
||||
"""
|
||||
pcm_list = [(i, s) for i, s in enumerate(proxies)
|
||||
if s is not None and s.sample_data]
|
||||
|
||||
def _scale_sample(s, r):
|
||||
s.sample_data = resample_linear(s.sample_data, r)
|
||||
s.length = len(s.sample_data)
|
||||
s.loop_begin = max(0, int(s.loop_begin * r))
|
||||
s.loop_end = max(0, min(int(s.loop_end * r), s.length))
|
||||
s.c2spd = max(1, int(s.c2spd * r))
|
||||
|
||||
# ── Pass 1: global pool-overflow resample (8 MB cap) ────────────────────
|
||||
total = sum(len(s.sample_data) for _, s in pcm_list)
|
||||
ratio = 1.0
|
||||
global_ratio = 1.0
|
||||
if total > SAMPLEBIN_SIZE:
|
||||
ratio = SAMPLEBIN_SIZE / total
|
||||
global_ratio = SAMPLEBIN_SIZE / total
|
||||
vprint(f" info: sample bin overflow ({total} bytes); "
|
||||
f"resampling all by {ratio:.4f}")
|
||||
f"resampling all by {global_ratio:.4f}")
|
||||
seen_g = set()
|
||||
for _, s in pcm_list:
|
||||
new_data = resample_linear(s.sample_data, ratio)
|
||||
s.sample_data = new_data
|
||||
s.length = len(new_data)
|
||||
s.loop_begin = max(0, int(s.loop_begin * ratio))
|
||||
s.loop_end = max(0, min(int(s.loop_end * ratio), s.length))
|
||||
s.c2spd = max(1, int(s.c2spd * ratio))
|
||||
if id(s) in seen_g:
|
||||
continue
|
||||
seen_g.add(id(s))
|
||||
_scale_sample(s, global_ratio)
|
||||
|
||||
# ── Pass 2: per-sample u16 cap (each sample must fit in 65535 bytes) ────
|
||||
# The Taud instrument record stores the sample length as u16, and TOP_O
|
||||
# offsets address up to 0xFF00 bytes — anything longer would silently
|
||||
# truncate at load time and over-shoot O-jumps. Resample only the
|
||||
# over-long samples and remember each one's individual ratio so the
|
||||
# caller can rescale TOP_O args per channel rather than globally.
|
||||
per_sample_ratio = {} # id(s) → per-sample ratio (after global)
|
||||
seen_p = set()
|
||||
for _, s in pcm_list:
|
||||
if id(s) in seen_p:
|
||||
continue
|
||||
seen_p.add(id(s))
|
||||
if len(s.sample_data) > SAMPLE_LEN_LIMIT:
|
||||
r = SAMPLE_LEN_LIMIT / len(s.sample_data)
|
||||
vprint(f" info: '{s.name}' exceeds {SAMPLE_LEN_LIMIT}-byte cap "
|
||||
f"({len(s.sample_data)}); resampling by {r:.4f}")
|
||||
_scale_sample(s, r)
|
||||
per_sample_ratio[id(s)] = r
|
||||
|
||||
# Effective slot → ratio for TOP_O rescaling. XM keymaps can route
|
||||
# several Taud slots to the same _XMSampleProxy (one slot per XM
|
||||
# sample-in-instrument), so they share the same per-sample ratio.
|
||||
slot_ratios = {}
|
||||
for slot_idx, s in pcm_list:
|
||||
slot_ratios[slot_idx] = global_ratio * per_sample_ratio.get(id(s), 1.0)
|
||||
ratio = slot_ratios
|
||||
|
||||
sample_bin = bytearray(SAMPLEBIN_SIZE)
|
||||
offsets = {}
|
||||
@@ -965,8 +1013,9 @@ def build_sample_inst_bin_xm(proxies: list) -> tuple:
|
||||
|
||||
# Resolve envelope LOOP / SUSTAIN words from the proxy. When XM has no
|
||||
# envelope, fall back to a single-point unit envelope (vol LOOP word
|
||||
# b=1 plus P=1 for consistency) and rely on IGV for level. Pan stays
|
||||
# zero so the engine sees P=0 there and skips envelope-driven pan.
|
||||
# b=1 plus P=1 for consistency) and rely on DNV (byte 196) for the
|
||||
# per-trigger initial level. Pan stays zero so the engine sees P=0
|
||||
# there and skips envelope-driven pan.
|
||||
if s.vol_env_pts is not None:
|
||||
vol_env_loop = s.vol_env_loop_word
|
||||
vol_env_sus = s.vol_env_sus_word
|
||||
@@ -1015,8 +1064,14 @@ def build_sample_inst_bin_xm(proxies: list) -> tuple:
|
||||
inst_bin[base + 121 + k * 2] = 0x80
|
||||
inst_bin[base + 121 + k * 2 + 1] = 0x00
|
||||
|
||||
# IGV: XM volume 0..64 → 0..255
|
||||
inst_bin[base + 171] = min(0xFF, round(s.volume * 255 / 64))
|
||||
# XM has no continuous instrumentwise volume scaler — `s.volume` (0..64)
|
||||
# is purely the per-trigger initial value (FT2 ft2_replayer.c handles
|
||||
# this exactly the same as IT does with sample.vol). So byte 171 (IGV)
|
||||
# stays at full unity and byte 196 (DNV) carries the per-instrument
|
||||
# default. Pre-2026-05-09 layout folded s.volume into IGV — see
|
||||
# terranmon §2350.
|
||||
inst_bin[base + 171] = 0xFF # IGV: continuous unity
|
||||
inst_bin[base + 196] = min(0xFF, round(s.volume * 255 / 64)) # DNV
|
||||
# Fadeout: 12-bit. Low 8 bits at +172, high 4 bits at +173.
|
||||
inst_bin[base + 172] = s.fadeout & 0xFF
|
||||
inst_bin[base + 173] = (s.fadeout >> 8) & 0x0F
|
||||
@@ -1073,6 +1128,32 @@ def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int,
|
||||
# Pan slide via vol-col D/E (encoded as pan_override below)
|
||||
vc_pan_override = _xm_volcol_pan_override(cell.volcol)
|
||||
|
||||
# ── Slot juggling for combined effects ──────────────────────────────
|
||||
# XM main 0x0A (vol slide → TOP_D) + vol-col Mx (porta → TOP_G aux)
|
||||
# combine cleanly into Taud L (porta + vol slide). Same for
|
||||
# vol-col Bx/Ax (vibrato → TOP_H aux) → Taud K (vibrato + vol slide).
|
||||
# Without this swap the vol-col aux would be dropped because the main
|
||||
# slot is already occupied by D. The combined K/L take their slide
|
||||
# nibbles directly from the source D arg (high byte of XM 0x0A),
|
||||
# matching the encoding used by main XM effects 5 (→ L) and 6 (→ K).
|
||||
if (aux_eff is not None and cell.effect == 0x0A
|
||||
and cell.effect_arg != 0):
|
||||
aux_op, aux_arg = aux_eff
|
||||
d_arg = cell.effect_arg & 0xFF
|
||||
if aux_op == TOP_G:
|
||||
# XM A + vol-col M → Taud L verbatim. Porta speed already
|
||||
# lives in Taud's private G memory (vol-col aux → G $00xx).
|
||||
cell.effect, cell.effect_arg = 0x05, d_arg
|
||||
aux_eff = None
|
||||
elif aux_op == TOP_H:
|
||||
# XM A + vol-col B (vibrato depth) → Taud K. K reuses
|
||||
# memory_HU; the vol-col Bx depth update is lost.
|
||||
cell.effect, cell.effect_arg = 0x06, d_arg
|
||||
aux_eff = None
|
||||
if (aux_arg & 0xFF) != 0:
|
||||
vprint(f" ch{ch_idx} row{r}: A+Bx→K, depth update "
|
||||
f"{aux_arg & 0xFF:02X} folded into K vibrato recall")
|
||||
|
||||
# ── Main effect translation ─────────────────────────────────────────
|
||||
op, arg16, vol_override, pan_override = encode_effect_xm(
|
||||
cell.effect, cell.effect_arg, ch_idx, r, amiga_mode=amiga_mode)
|
||||
@@ -1088,7 +1169,7 @@ def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int,
|
||||
aux_eff = None
|
||||
else:
|
||||
vprint(f" ch{ch_idx} row{r}: dropped vol-col aux effect "
|
||||
f"(main effect slot occupied)")
|
||||
f"(main effect slot occupied: cmd={cell.effect:02X} arg={cell.effect_arg:02X})")
|
||||
|
||||
# ── Note ────────────────────────────────────────────────────────────
|
||||
note_taud = NOTE_NOP
|
||||
@@ -1172,11 +1253,12 @@ def _active_channels_xm(h: XMHeader, patterns: list) -> list:
|
||||
|
||||
# ── Main assembly ─────────────────────────────────────────────────────────────
|
||||
|
||||
def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
|
||||
def assemble_taud(h: XMHeader, patterns: list, instruments: list,
|
||||
with_project_data: bool = True) -> bytes:
|
||||
# XM envelope frames advance once per row tick. Tick rate is derived
|
||||
# from BPM the same way ProTracker derives it: ticks_per_sec = BPM × 2/5
|
||||
# (matches MilkyTracker's tick clock and it2taud's ticks_per_sec).
|
||||
tempo_for_envs = max(24, min(280, h.default_bpm if h.default_bpm > 0 else 125))
|
||||
tempo_for_envs = max(25, min(280, h.default_bpm if h.default_bpm > 0 else 125))
|
||||
ticks_per_sec = max(1.0, tempo_for_envs * 2.0 / 5.0)
|
||||
|
||||
# ── Build XM-instrument → list of Taud slot proxies ─────────────────────
|
||||
@@ -1223,15 +1305,14 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
|
||||
# ── Sample / instrument bin ─────────────────────────────────────────────
|
||||
vprint(f" building sample/inst bin… ({len(proxies) - 1} sample slots used)")
|
||||
sampleinst_raw, _, sample_ratio = build_sample_inst_bin_xm(proxies)
|
||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||
comp_size = len(compressed)
|
||||
vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)")
|
||||
|
||||
# ── Tempo / speed ───────────────────────────────────────────────────────
|
||||
speed = h.default_speed if h.default_speed > 0 else 6
|
||||
tempo = h.default_bpm if h.default_bpm > 0 else 125
|
||||
tempo = max(24, min(280, tempo))
|
||||
bpm_stored = (tempo - 24) & 0xFF
|
||||
tempo = max(25, min(280, tempo))
|
||||
bpm_stored = (tempo - 25) & 0xFF
|
||||
vprint(f" initial speed={speed}, tempo={tempo} BPM")
|
||||
|
||||
# ── Channels / cue list ─────────────────────────────────────────────────
|
||||
@@ -1291,7 +1372,13 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
|
||||
resolve_inst_slot,
|
||||
amiga_mode=not h.linear_freq,
|
||||
keyoff_zero_rows=row_marks)
|
||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||
# Rescale TOP_O sample-offset args per channel using the active slot's
|
||||
# ratio (combined global + per-sample). Walks pat_bin in cue-major /
|
||||
# channel-minor order, tracking the most recent inst byte seen on each
|
||||
# channel — must run before deduplication so the channel state stays
|
||||
# linear.
|
||||
pat_bin = rescale_offset_effects_per_slot(
|
||||
bytes(pat_bin), len(taud_cue_list), C, sample_ratio)
|
||||
|
||||
orig_count = len(taud_cue_list) * C
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||
@@ -1334,26 +1421,18 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
|
||||
# ── Header / song table ─────────────────────────────────────────────────
|
||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, 1]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
b'\x00\x00\x00\x00' +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
||||
cue_comp = compress_blob(bytes(sheet), "cue sheet")
|
||||
|
||||
# Flags byte:
|
||||
# bit 1 (f) = Amiga pitch-slide mode (set when XM uses Amiga period table).
|
||||
# bit 2 = reserved (was 'm' fadeout-zero policy; removed). XM fadeout values are
|
||||
# now scaled per-instrument above (÷32 with round-to-nearest), so the
|
||||
# engine sees Taud-native units and uses its single divisor of 1024.
|
||||
flags_byte = (0x00 if h.linear_freq else 0x02)
|
||||
# bits 0-1 (ff) = tone mode. ff=1 (Amiga period slides) when XM uses the Amiga
|
||||
# period table; ff=0 otherwise. Pan law is fixed engine-wide to
|
||||
# the equal-energy — no `p` bit any more.
|
||||
# bit 2 = reserved (was 'm' fadeout-zero policy; removed). XM fadeout values
|
||||
# are now scaled per-instrument above (÷32 with round-to-nearest), so
|
||||
# the engine sees Taud-native units and uses its single divisor of 1024.
|
||||
flags_byte = (0x00 if h.linear_freq else 0x01)
|
||||
song_table = encode_song_entry(
|
||||
song_offset=song_offset,
|
||||
num_voices=C,
|
||||
@@ -1370,7 +1449,37 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
# Project Data (optional). XM nests samples under instruments and the
|
||||
# converter creates one Taud slot per (xm_inst, sample) pair, so SNam is
|
||||
# populated from the per-Taud-slot proxies and INam carries the parent
|
||||
# XM-level instrument names (1-based; slot 0 empty).
|
||||
proj_data = b''
|
||||
proj_off = 0
|
||||
if with_project_data:
|
||||
inst_names = [''] + [(inst.name if inst is not None else '')
|
||||
for inst in instruments[:255]]
|
||||
smp_names = [''] + [(p.name if p is not None else '')
|
||||
for p in proxies[1:256]]
|
||||
proj_data = build_project_data(
|
||||
project_name=h.title,
|
||||
instrument_names=inst_names,
|
||||
sample_names=smp_names,
|
||||
)
|
||||
if proj_data:
|
||||
proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \
|
||||
+ len(pat_comp) + len(cue_comp)
|
||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, 1]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
struct.pack('<I', proj_off) +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
@@ -1382,6 +1491,9 @@ def main():
|
||||
ap.add_argument('input', help='Input .xm file')
|
||||
ap.add_argument('output', help='Output .taud file')
|
||||
ap.add_argument('-v', '--verbose', action='store_true')
|
||||
ap.add_argument('--no-project-data', action='store_true',
|
||||
help='Omit the optional Project Data section '
|
||||
'(song / instrument / sample names)')
|
||||
args = ap.parse_args()
|
||||
set_verbose(args.verbose)
|
||||
|
||||
@@ -1400,7 +1512,8 @@ def main():
|
||||
patterns, after_patterns = parse_patterns(data, h, patterns_off)
|
||||
instruments, _after = parse_instruments(data, h, after_patterns)
|
||||
|
||||
taud = assemble_taud(h, patterns, instruments)
|
||||
taud = assemble_taud(h, patterns, instruments,
|
||||
with_project_data=not args.no_project_data)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(taud)
|
||||
|
||||
Reference in New Issue
Block a user