Compare commits

..

9 Commits

Author SHA1 Message Date
minjaesong
94e3ce55ce zfm: larger scroll peek window 2026-05-03 16:50:49 +09:00
minjaesong
9a9893b9a3 bios: using js version again 2026-05-03 16:50:35 +09:00
minjaesong
789c78f1e7 taud: more fixes 2026-05-03 16:50:24 +09:00
minjaesong
c7e7ee650d fix: no-param note handling divergence 2026-05-03 15:10:36 +09:00
minjaesong
5d968fecf5 fix: ImpulseTracker style instrument filters 2026-05-03 00:44:31 +09:00
minjaesong
aaf3cc28b2 Offset added to Taud instrument format doc 2026-05-03 00:30:57 +09:00
minjaesong
24375727db taud amiga period bug fix (multi-tick Exx/Fxx) 2026-05-02 23:52:58 +09:00
minjaesong
6a7ef670d9 fix: tracker mixer flags not setup properly on fresh boot 2026-05-02 23:43:07 +09:00
minjaesong
1bbf0de381 instrument volume fadeout 2026-05-02 21:13:00 +09:00
16 changed files with 396 additions and 200 deletions

9
.idea/markdown.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
<option name="splitLayout" value="SHOW_EDITOR" />
</component>
</project>

View File

@@ -33,6 +33,10 @@ Current topics:
resonance damping curve, and the **IIR-only 2-pole topology** (NOT a
biquad — no feedforward x[n1] / x[n2] terms) that `AudioAdapter.kt` uses
for Taud playback.
- `reference_materials/ft2-clone` — Modernised clone for the original FastTracker 2
- `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
When fetching new references, copy the relevant upstream files verbatim into
a topic folder, write a `README.md` summarising the relevant maths /
@@ -88,12 +92,12 @@ Use the build scripts in `buildapp/`:
### Prerequisites
1. Download JDK 17 runtimes to `~/Documents/openjdk/*` with specific naming:
- `jdk-17.0.1-x86` (Linux AMD64)
- `jdk-17.0.1-arm` (Linux Aarch64)
- `jdk-17.0.1-windows` (Windows AMD64)
- `jdk-17.0.1.jdk-arm` (macOS Apple Silicon)
- `jdk-17.0.1.jdk-x86` (macOS Intel)
1. Download JDK 21 runtimes to `~/Documents/openjdk/*` with specific naming:
- `jdk-21.0.1-x86` (Linux AMD64)
- `jdk-21.0.1-arm` (Linux Aarch64)
- `jdk-21.0.1-windows` (Windows AMD64)
- `jdk-21.0.1.jdk-arm` (macOS Apple Silicon)
- `jdk-21.0.1.jdk-x86` (macOS Intel)
2. Run `jlink` commands to create custom Java runtimes in `out/runtime-*` directories

View File

@@ -563,10 +563,26 @@ Peak at maximum settings: $7F × $FF >> 9 = $3F — the full panning range. Retr
**Plain.** Applies Bitcrusher to the current voice.
- x: clipping mode. 0: clamp, 1: fold, 2: modulus
- x: clipping mode. 0: clamp, 1: fold, 2: wrap
- y: bit depth (1..15). 8..15 has no effect on TSVM audio adapter (already operates on 8 bits)
- z: sample skip (0..255). 0: no skip, 1: use every 2nd samples, 2: use every 3rd samples, ..., 255: use every 256th samples
- `8 0000` will disable the bitcrusher
- `8 x000` will modify the clipping mode shared effect symbol '9'
**Compatibility.** Unique to Taud. No compatible equivalent exists.
**Implementation.** TODO
---
## 9 $x0zz — Overdrive
**Plain.** Amplify the volume.
- x: clipping mode. 0: clamp, 1: fold, 2: wrap
- z: amplification. $00: 1x amplification (no extra volume), $01: 17/16 amplification, $02: 18/16 amplification, $10: 2x amplification (+ 6 dBFS), $F0: 16x amplification, $FF: 16.9375x amplification
- `9 0000` will reset the overdrive
- `9 x000` will modify the clipping mode shared with effect symbol '9'
**Compatibility.** Unique to Taud. No compatible equivalent exists.
@@ -864,9 +880,9 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
## 1 $xx00 — Global behaviour flags
**Plain.** Sets how the mixer should treat the panning. Available flags are:
**Plain.** Sets mixer-wide behaviour flags. Available flags are:
0b 0000 00fp
0b 0000 0mfp
- 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.
@@ -874,6 +890,9 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
- f unset: 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.
- f set: 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.
- m unset: IT fadeout-zero policy. An instrument with stored volume fadeout = 0 does **not** fade out on key-off; the voice plays through until the volume envelope ends it (or never, if there is no envelope).
- m set: FT2 fadeout-zero policy. An instrument with stored volume fadeout = 0 is **cut** on the first tick after key-off (or NNA Note-Fade). Nonzero fadeouts behave identically in both modes — the per-tick decrement is always `fadeout / 65536` in unity-volume units.
**Implementation.**
- Panning-linear:
- L_gain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0

Binary file not shown.

View File

@@ -1,5 +1,5 @@
con.reset_graphics();con.curs_set(0);con.clear();
graphics.resetPalette();graphics.setPalette(0, 0, 0, 0, 0);graphics.setBackground(0,0,0);
graphics.resetPalette();graphics.setPalette(0, 0, 0, 0, 15);graphics.setBackground(0,0,0);
let logo = gzip.decomp(base64.atob("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"));

View File

@@ -856,8 +856,8 @@ function drawControlHint() {
['sep'],
['Sp','Edit'],
['sep'],
['n','Solo'],
['m','Mute'],
['s','Solo'],
['sep'],
['Tab','Panel']
// ['sep'],
@@ -1397,7 +1397,7 @@ function timelineInput(wo, event) {
drawVoiceHeaders(); drawSeparators(separatorStyle); drawAlwaysOnElems(); drawVoiceDetail()
}
else if (keyJustHit && !shiftDown && event.includes(keys.M)) { toggleMute(cursorVox) }
else if (keyJustHit && !shiftDown && event.includes(keys.S)) { toggleSolo(cursorVox) }
else if (keyJustHit && !shiftDown && event.includes(keys.N)) { toggleSolo(cursorVox) }
return
}
@@ -1434,7 +1434,7 @@ function timelineInput(wo, event) {
}
if (keyJustHit && !shiftDown && event.includes(keys.M)) { toggleMute(cursorVox); return }
if (keyJustHit && !shiftDown && event.includes(keys.S)) { toggleSolo(cursorVox); return }
if (keyJustHit && !shiftDown && event.includes(keys.N)) { toggleSolo(cursorVox); return }
if (keysym === "<UP>") { cursorRow -= moveDelta; rowMove = true }
else if (keysym === "<DOWN>") { cursorRow += moveDelta; rowMove = true }
@@ -1933,6 +1933,8 @@ function drawProjectContents(wo) {
Cues: `${song.lastActiveCue}/1024 ($${song.lastActiveCue.hex03()})`,
Notation: pitchTablePresets[PITCH_PRESET_IDX].name,
Flags: `${flagstrbuf} ($${mixerflag.hex02()})`,
GlobalVol: initialGlobalVolume,
MixingVol: initialMixingVolume
}
Object.entries(projMeta).forEach(([key, value], index) => {

View File

@@ -422,6 +422,8 @@ let filenavOninput = (window, event) => {
let keycodes = [event[3],event[4],event[5],event[6],event[7],event[8],event[9],event[10]]
let keycode = keycodes[0]
let scrollPeek = (LIST_HEIGHT / 3)|0
if (keyJustHit && keysym == "q") {
exit = true
}
@@ -430,19 +432,19 @@ let filenavOninput = (window, event) => {
redraw() // this would double-redraw (hence no panel switching) or something if redraw() is not merely a request to do so
}
else if (keysym == "<UP>") {
[cursor[windowMode], scroll[windowMode]] = win.scrollVert(-1, dirFileList[windowMode].length, LIST_HEIGHT, cursor[windowMode], scroll[windowMode], 1)
[cursor[windowMode], scroll[windowMode]] = win.scrollVert(-1, dirFileList[windowMode].length, LIST_HEIGHT, cursor[windowMode], scroll[windowMode], scrollPeek)
drawFilePanel()
}
else if (keysym == "<DOWN>") {
[cursor[windowMode], scroll[windowMode]] = win.scrollVert(+1, dirFileList[windowMode].length, LIST_HEIGHT, cursor[windowMode], scroll[windowMode], 1)
[cursor[windowMode], scroll[windowMode]] = win.scrollVert(+1, dirFileList[windowMode].length, LIST_HEIGHT, cursor[windowMode], scroll[windowMode], scrollPeek)
drawFilePanel()
}
else if (keysym == "<PAGE_UP>") {
[cursor[windowMode], scroll[windowMode]] = win.scrollVert(-LIST_HEIGHT, dirFileList[windowMode].length, LIST_HEIGHT, cursor[windowMode], scroll[windowMode], 1)
[cursor[windowMode], scroll[windowMode]] = win.scrollVert(-LIST_HEIGHT, dirFileList[windowMode].length, LIST_HEIGHT, cursor[windowMode], scroll[windowMode], scrollPeek)
drawFilePanel()
}
else if (keysym == "<PAGE_DOWN>") {
[cursor[windowMode], scroll[windowMode]] = win.scrollVert(+LIST_HEIGHT, dirFileList[windowMode].length, LIST_HEIGHT, cursor[windowMode], scroll[windowMode], 1)
[cursor[windowMode], scroll[windowMode]] = win.scrollVert(+LIST_HEIGHT, dirFileList[windowMode].length, LIST_HEIGHT, cursor[windowMode], scroll[windowMode], scrollPeek)
drawFilePanel()
}
else if (keyJustHit && keycode == 66) { // enter

View File

@@ -96,8 +96,6 @@ class WindowObject {
* @return [new cursor pos, new scroll pos]
*/
function scrollVert(dy, listSize, listHeight, currentCursorPos, currentScrollPos, scrollPeek) {
let peek = 1
// clamp dy
if (currentCursorPos + dy > listSize - 1)
dy = (listSize - 1) - currentCursorPos
@@ -108,13 +106,13 @@ function scrollVert(dy, listSize, listHeight, currentCursorPos, currentScrollPos
// update vertical scroll stats
if (dy != 0) {
let visible = listHeight - 1 - peek
let visible = listHeight - 1 - scrollPeek
if (nextRow - currentScrollPos > visible) {
currentScrollPos = nextRow - visible
}
else if (nextRow - currentScrollPos < 0 + peek) {
currentScrollPos = nextRow - peek // nextRow is less than zero
else if (nextRow - currentScrollPos < 0 + scrollPeek) {
currentScrollPos = nextRow - scrollPeek // nextRow is less than zero
}
// NOTE: future-proofing here -- scroll clamping is moved outside of go-up/go-down
@@ -145,8 +143,6 @@ function scrollVert(dy, listSize, listHeight, currentCursorPos, currentScrollPos
* @return [new cursor pos, new scroll pos]
*/
function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScrollPos, scrollPeek) {
let peek = 1
// clamp dx
if (currentCursorPos + dx > stringSize - 1)
dx = (stringSize - 1) - currentCursorPos
@@ -157,13 +153,13 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
// update vertical scroll stats
if (dx != 0) {
let visible = stringViewSize - 1 - peek
let visible = stringViewSize - 1 - scrollPeek
if (nextCol - currentScrollPos > visible) {
currentScrollPos = nextCol - visible
}
else if (nextCol - currentScrollPos < 0 + peek) {
currentScrollPos = nextCol - peek // nextCol is less than zero
else if (nextCol - currentScrollPos < 0 + scrollPeek) {
currentScrollPos = nextCol - scrollPeek // nextCol is less than zero
}
// NOTE: future-proofing here -- scroll clamping is moved outside of go-up/go-down

View File

@@ -300,9 +300,13 @@ def _it214_decompress_block(payload: bytes, num_samples: int,
delta = _sign_extend(v, width)
is_data = True
elif width < init_width:
# Mode B (mid): `range_count` escape codes centred on signed midpoint.
# Mode B (mid): `range_count` escape codes occupy values (border, border+range_count].
# The encoder simply does NOT emit data values that would collide with this slot —
# it widens first. So values *above* the escape range are sign-extended verbatim,
# not collapsed. Reference: schismtracker fmt/compression.c:103-127 and
# MilkyTracker XModule.cpp:629-640.
# border = (mask >> (init_width-width)) - border_sub, where border_sub
# = range_count / 2. Reference: libxmp it_compress.c, OpenMPT ITTools.cpp.
# = range_count / 2.
# 8-bit: width=7 → border=63-4=59, width=8 → border=127-4=123
# 16-bit: width=7..16 with border_sub=8.
border = (mask >> (init_width - width)) - border_sub
@@ -310,8 +314,6 @@ def _it214_decompress_block(payload: bytes, num_samples: int,
new_w = v - border
width = new_w if new_w < width else new_w + 1 # skip-self
continue
if v > border + range_count:
v -= range_count # collapse escape range out
delta = _sign_extend(v, width)
is_data = True
else:
@@ -467,12 +469,13 @@ class ITInstrument:
__slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume',
'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain',
'pf_envelope', 'pf_env_sustain', 'pf_is_filter',
'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp', 'nna')
'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp', 'nna',
'dct', 'dca')
# vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None
# *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope
# pf_is_filter: bool — pf envelope mode (False = pitch, True = filter)
# ifc / ifr : initial filter cutoff / resonance (0..127, 0 if not set)
# fadeout : 0..1024 (IT FadeOut field, applied per tick after key-off)
# fadeout : 0..1024 (IT FadeOut field; doubled to 0..2048 when written to Taud's 12-bit field)
# pps / ppc : pitch-pan separation (signed -32..+32) and centre note (0..119)
# rv / rp : random volume swing (0..100) / random pan swing (0..64)
# nna : new note action (IT 0=cut, 1=continue, 2=note off, 3=note fade)
@@ -489,6 +492,11 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
inst.name = data[ptr+0x20:ptr+0x3A].rstrip(b'\x00').decode('latin-1', errors='replace')
# NNA at IMPI+0x11 (new format). 0=cut, 1=continue, 2=note off, 3=note fade.
inst.nna = data[ptr + 0x11] & 0x03
# DCT (Duplicate Check Type) and DCA (Duplicate Check Action), per Schism iti.c:80-94.
# DCT: 0=off, 1=note, 2=sample, 3=instrument.
# DCA: 0=note cut, 1=note off, 2=note fade.
inst.dct = data[ptr + 0x12] & 0x03
inst.dca = data[ptr + 0x13] & 0x03
inst.fadeout = struct.unpack_from('<H', data, ptr + 0x14)[0] # 0..1024
# PPS is signed -32..+32; PPC is the centre note (IT note number 0..119, C-5=60).
inst.pps = struct.unpack_from('b', data, ptr + 0x16)[0]
@@ -516,9 +524,12 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
inst.canonical_sample = c5_smp # 1-based sample index, 0 = none
inst.canonical_volume = min(inst.gv, 64)
# Initial filter cutoff/resonance (high bit = enabled, low 7 bits = value)
ifc_raw = data[ptr + 0x39]
ifr_raw = data[ptr + 0x3A]
# Initial filter cutoff/resonance (high bit = enabled, low 7 bits = value).
# Per Schism iti.c struct it_instrument: name[26] occupies 0x20..0x39,
# ifc is at 0x3A, ifr at 0x3B. Off-by-one would silently disable filters
# on every IT instrument because name's last byte is always NUL.
ifc_raw = data[ptr + 0x3A]
ifr_raw = data[ptr + 0x3B]
# Taud uses full 0..255 range (double IT's resolution): IT 0..127 → Taud 0..254,
# IT "off" (high bit clear) → Taud 255.
inst.ifc = (ifc_raw & 0x7F) * 2 if (ifc_raw & 0x80) else 255
@@ -1114,7 +1125,7 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
vol_env, vol_sus, pan_env, pan_sus, pf_env, pf_sus, pf_is_filter,
inst_gv, fadeout, vib_speed, vib_depth, vib_sweep, vib_rate, vib_wave,
default_pan, pps, ppc_taud, pan_swing, vol_swing, ifc, ifr,
sample_detune, nna.
sample_detune, nna, dct, dca.
All optional; missing keys default to neutral values.
Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict).
@@ -1209,7 +1220,7 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
struct.pack_into('<I', inst_bin, base + 0, ptr)
struct.pack_into('<H', inst_bin, base + 4, s_len)
struct.pack_into('<H', inst_bin, base + 6, c2spd)
struct.pack_into('<H', inst_bin, base + 8, 0) # play start
struct.pack_into('<H', inst_bin, base + 8, 0) # play start. IT samples always start playing from zero
struct.pack_into('<H', inst_bin, base + 10, ls)
struct.pack_into('<H', inst_bin, base + 12, le)
inst_bin[base + 14] = flags_byte
@@ -1221,8 +1232,20 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
vol_sus = idata.get('vol_sus', USE_ENV_BIT)
pan_sus = idata.get('pan_sus', 0)
pf_sus = idata.get('pf_sus', 0)
inst_gv = idata.get('inst_gv', 0xFF)
fadeout = idata.get('fadeout', 0) & 0x3FF # 10-bit (low 8 + high 2)
# Sample-mode default IGV: fold sample default vol (Sv) and sample GV
# into Taud's IGV. Instrument-mode supplies inst_gv pre-folded.
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)))
# IT fadeout (file-stored 0..2048; ITTECH practical max ≈ 1024) maps verbatim to
# the Taud 12-bit fadeStep. The player picks divisor 1024 in IT mode (vs 65536
# in FT2 mode) so that one fadeStep unit per tick matches Schism's
# `chan->fadeout_volume -= (stored<<5)<<1` semantics (sndmix.c:331-339,
# effects.c:1261). Clamp defensively to 4095.
fadeout = min(0xFFF, idata.get('fadeout', 0) & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 15, vol_sus & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 17, pan_sus & 0xFFFF)
@@ -1231,8 +1254,9 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
if vol_env:
_write_env(inst_bin, base + 21, vol_env)
else:
# Single-point: hold at sample default volume.
inst_bin[base + 21] = min(getattr(s, 'vol', 63), 63)
# Single-point envelope held at full-scale; the per-sample level is
# carried by IGV (byte 171), so the envelope must be a unit multiplier.
inst_bin[base + 21] = 63
inst_bin[base + 22] = 0
# Force engine to use this single point.
struct.pack_into('<H', inst_bin, base + 15, USE_ENV_BIT)
@@ -1280,7 +1304,13 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
inst_bin[base + 187] = idata.get('vib_depth', 0) & 0xFF
# Byte 188: vibrato rate (0..255 full range, IT samplewise Vir).
inst_bin[base + 188] = idata.get('vib_rate', 0) & 0xFF
# Bytes 189-191: reserved (already zeroed).
# Byte 189: duplicate-check / action (IT-only — bits 0-1 = DCT, bits 2-3 = DCA).
# DCT: 0=off, 1=note, 2=sample, 3=instrument.
# DCA: 0=note cut, 1=note off, 2=note fade.
dct = idata.get('dct', 0) & 0x03
dca = idata.get('dca', 0) & 0x03
inst_bin[base + 189] = (dca << 2) | dct
# Bytes 190-191: reserved (already zeroed).
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
@@ -1303,7 +1333,6 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
rows = chunk_grid[ch_idx] if ch_idx < len(chunk_grid) else [ITRow()] * PATTERN_ROWS
last_inst = 0
last_note_it = -1
last_vol = None
for r, cell in enumerate(rows[:PATTERN_ROWS]):
# ── Resolve vol-col into overrides ──────────────────────────────────
@@ -1337,30 +1366,21 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
note_triggers = (0 <= (cell.note if cell.note >= 0 else -1) <= 119)
# ── Volume column ────────────────────────────────────────────────────
# Priority: explicit cell vol (from vol-col 0-64) > note-trigger default
# > retrigger recall > vol-col slide > main-effect vol override > nop
# 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.
if cell.volcol >= 0 and cell.volcol <= VC_VOL_HI:
vol_sel, vol_value = SEL_SET, min(cell.volcol, 0x3F)
elif note_triggers and cell.inst > 0:
vol_sel = SEL_SET
vol_value = inst_vols.get(last_inst, 0x3F)
elif note_triggers and last_vol is not None:
vol_sel, vol_value = SEL_SET, last_vol
elif (cell.inst > 0 and cell.note < 0
and last_note_it >= 0 and last_vol is not None):
# Instrument-only retrigger: restate last volume
vol_sel, vol_value = SEL_SET, last_vol
elif vol_override is not None:
vol_sel, vol_value = vol_override
elif vs != SEL_FINE or vv != 0:
vol_sel, vol_value = vs, vv
elif vol_override is not None:
vol_sel, vol_value = vol_override
else:
vol_sel, vol_value = SEL_FINE, 0
if cell.note is not None and 0 <= (cell.note if cell.note >= 0 else -1) <= 119:
last_note_it = cell.note
if vol_sel == SEL_SET:
last_vol = vol_value
# ── Pan column ───────────────────────────────────────────────────────
if cell.pan_set is not None:
@@ -1592,15 +1612,18 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
src_smp = samples[si]
proxy[taud_slot] = src_smp
# IT cell-trigger initial volume comes from the sample's default
# volume (Dv, 0..64), not the instrument's global volume.
# 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.
smp_default_vol = min(getattr(src_smp, 'vol', 64), 64)
inst_vols[taud_slot] = min(smp_default_vol, 0x3F)
# IT instrument GV (0..128) and sample GV (0..64) collapse into
# Taud's single instrumentwise GV (0..255). Sample default volume
# is handled separately by inst_vols above.
# 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 * 255 / (128 * 64)))
inst_gv_255 = min(255, round(inst.gv * smp_gv * smp_default_vol * 255
/ (128 * 64 * 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
@@ -1620,14 +1643,14 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
default_pan = 0x80
# Auto-vibrato lives on the canonical sample (not the IT instrument).
# IT samplewise auto-vibrato: Vis (speed 0..64), Vid (depth 0..32),
# IT samplewise auto-vibrato: Vis (speed 0..64), Vid (depth 0..64),
# Vir (rate 0..255 — IT-style ramp-in), Vit (waveform 0..3).
# Taud byte 175 (Vibrato Speed) follows FT2 0..255 scale: rescale Vis.
# Taud byte 187 (Vibrato Depth) is full 0..255: rescale Vid 0..32 → 0..255.
# Taud byte 187 (Vibrato Depth) is full 0..255: rescale Vid 0..64 → 0..255.
# Taud byte 188 (Vibrato Rate) is IT Vir verbatim.
# Taud byte 176 (Vibrato Sweep) is FT2-only — leave 0 for IT.
vib_speed_taud = min(255, round(src_smp.av_speed * 255 / 64))
vib_depth_taud = min(255, round(src_smp.av_depth * 255 / 32))
vib_depth_taud = min(255, round(src_smp.av_depth * 255 / 64))
# IT NNA (0=cut, 1=continue, 2=note off, 3=note fade) →
# Taud NNA (00=note off, 01=cut, 10=continue, 11=fade).
it_to_taud_nna = (0b01, 0b10, 0b00, 0b11)
@@ -1656,6 +1679,8 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
'ifr': inst.ifr,
'sample_detune': 0, # IT samples have no finetune
'nna': nna_taud,
'dct': inst.dct,
'dca': inst.dca,
}
sampleinst_raw, _ = build_sample_inst_bin_it(proxy, instr_data_by_slot)
else:
@@ -1741,6 +1766,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
vprint(f" cue sheet: {len(sheet)}{len(cue_comp)} bytes (gzip)")
# flags byte: bit 1 (f) = Amiga pitch-slide mode (IT linear_slides flag inverted)
# bit 2 (m) cleared: IT fadeout-zero policy — stored fadeout=0 means "no fadeout".
flags_byte = 0x00 if h.linear_slides else 0x02
# 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))

View File

@@ -506,7 +506,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
env_vol = min(s.volume, 63)
# Envelope first point is full-scale; per-sample level is carried by
# IGV (byte 171) so the envelope must contribute a unit multiplier.
env_vol = 63
vol_env_flags = 0x0020 # use-envelope bit
base = taud_idx * 192
@@ -522,7 +524,10 @@ 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
inst_bin[base + 171] = 0xFF # instrument global volume
# 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))
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
@@ -546,15 +551,15 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int,
inst_vols: dict) -> bytes:
"""Build a 512-byte Taud pattern for one MOD channel.
Volume column rules (mirrors s3m2taud):
explicit Cxx vol > note-trigger inst default > instrument-only retrigger
recall > vol_override from effect > no-op.
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.
"""
out = bytearray(PATTERN_BYTES)
rows = grid[ch_idx] if ch_idx < len(grid) else [ModRow()] * MOD_PATTERN_ROWS
last_inst = 0
last_period = 0
last_vol = None
for r, row in enumerate(rows[:MOD_PATTERN_ROWS]):
note_taud = period_to_taud_note(row.period)
note_triggers = (row.period > 0)
@@ -562,10 +567,6 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int,
if row.inst > 0:
last_inst = row.inst
retrigger = (row.inst > 0
and row.period == 0
and last_period > 0)
op, arg, vol_override, pan_override = encode_effect(
row.effect, row.effect_arg, ch_idx, r)
@@ -575,13 +576,6 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int,
if vol_override is not None and vol_override[0] != SEL_SET:
vprint(f" ch{ch_idx} row{r}: dropped vol slide "
f"(cell already carries explicit Cxx volume)")
elif note_triggers and row.inst > 0:
vol_sel = SEL_SET
vol_value = inst_vols.get(last_inst, 0x3F)
elif note_triggers and last_vol is not None:
vol_sel, vol_value = SEL_SET, last_vol
elif retrigger and last_vol is not None:
vol_sel, vol_value = SEL_SET, last_vol
elif vol_override is not None:
vol_sel, vol_value = vol_override
else:
@@ -589,8 +583,6 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int,
if note_triggers:
last_period = row.period
if vol_sel == SEL_SET:
last_vol = vol_value
# ── Pan column ──
if pan_override is not None:
@@ -758,7 +750,9 @@ def assemble_taud(mod: dict) -> bytes:
# ProTracker is Amiga-period-based by definition, so we set the f bit so
# the engine applies coarse pitch slides in period space (recovers PT's
# characteristic non-linear pitch character).
flags_byte = 0x02
# bit 2 (m) set: FT2 fadeout-zero policy — PT has no fadeout, so the stored
# zero on every instrument means "cut on key-off" (unified with S3M imports).
flags_byte = 0x02 | 0x04
song_table = encode_song_entry(
song_offset=song_offset,
num_voices=n_channels,
@@ -771,7 +765,7 @@ def assemble_taud(mod: dict) -> bytes:
pat_bin_comp_size=len(pat_comp),
cue_sheet_comp_size=len(cue_comp),
global_vol=0xFF,
mixing_vol=0xFF,
mixing_vol=180,
)
assert len(song_table) == TAUD_SONG_ENTRY

View File

@@ -495,8 +495,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: hold at instrument volume (clamped to 0x3F).
env_vol = min(inst.volume, 63)
# Volume envelope first point is full-scale; per-sample level is carried
# by IGV (byte 171) so the envelope contributes a unit multiplier.
env_vol = 63
# Vol env-flags: enable use-envelope bit (b=1) so engine reads the single point.
vol_env_flags = 0x0020 # b=bit 5
@@ -514,7 +515,10 @@ 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
inst_bin[base + 171] = 0xFF # instrument global volume
# 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))
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
@@ -544,11 +548,10 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
amiga_mode: bool = False) -> bytes:
"""Build a 512-byte Taud pattern for one S3M channel.
Volume column: explicit S3M cell vol SEL_SET; when a note triggers
with no explicit vol, emit SEL_SET using the instrument's default volume
(looked up from inst_vols, a 1-based inst index → 0..63 volume dict).
M/N/K/L overrides apply only when the cell has no explicit vol and no
note trigger. Otherwise SEL_FINE/0 (no-op).
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.
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.
"""
@@ -557,55 +560,27 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
out = bytearray(PATTERN_BYTES)
rows = s3m_grid[ch_idx] if ch_idx < len(s3m_grid) else [S3MRow()] * PATTERN_ROWS
last_inst = 0 # 1-based; tracks which instrument is loaded on this channel
last_note = S3M_NOTE_EMPTY # last raw S3M note byte that was a real pitch
last_vol = None # last SEL_SET volume value (0-63), for retrigger recall
for r, row in enumerate(rows[:PATTERN_ROWS]):
note = encode_note(row.note)
inst = row.inst # S3M 1-based Taud 1-based
inst = row.inst # S3M 1-based -> Taud 1-based
if row.inst > 0:
last_inst = row.inst
# ── Instrument-only retrigger ──
# Instrument-only row: recall the last volume without touching the note.
retrigger = (row.inst > 0
and row.note == S3M_NOTE_EMPTY
and last_note not in (S3M_NOTE_EMPTY, S3M_NOTE_OFF))
op, arg, vol_override, pan_override = encode_effect(
row.effect, row.effect_arg, ch_idx, r, amiga_mode=amiga_mode)
# ── Volume column ──
note_triggers = (row.note not in (S3M_NOTE_EMPTY, S3M_NOTE_OFF))
# -- Volume column --
if row.vol >= 0:
vol_sel, vol_value = SEL_SET, min(row.vol, 0x3F)
if vol_override is not None and vol_override[0] != SEL_SET:
vprint(f" ch{ch_idx} row{r}: dropped vol slide "
f"(cell already carries explicit volume)")
elif note_triggers and row.inst > 0:
# Note trigger with a fresh instrument: use that instrument's
# default volume.
vol_sel = SEL_SET
vol_value = inst_vols.get(last_inst, 0x3F)
elif note_triggers and last_vol is not None:
# Note trigger without instrument: keep the channel's current
# volume rather than resetting to the instrument default.
vol_sel, vol_value = SEL_SET, last_vol
elif retrigger and last_vol is not None:
# Instrument-only row: re-emit the last known volume so the sample
# restarts at the correct level without an explicit note trigger.
vol_sel, vol_value = SEL_SET, last_vol
elif vol_override is not None:
vol_sel, vol_value = vol_override
else:
vol_sel, vol_value = SEL_FINE, 0 # no-op fine slide
# Track note and volume for future retrigger lookups.
if row.note not in (S3M_NOTE_EMPTY, S3M_NOTE_OFF):
last_note = row.note
if vol_sel == SEL_SET:
last_vol = vol_value
# ── Pan column ──
if pan_override is not None:
pan_sel, pan_value = pan_override
@@ -831,7 +806,9 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
# 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)
flags_byte = 0x00 if h.linear_slides else 0x02
# bit 2 (m) set: FT2 fadeout-zero policy — S3M has no per-instrument fadeout field, so a
# stored zero means "cut on key-off" (matching ST3's lineage from the FT2 family).
flags_byte = (0x00 if h.linear_slides else 0x02) | 0x04
song_table = encode_song_entry(
song_offset=song_offset,
num_voices=C,
@@ -844,7 +821,7 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
pat_bin_comp_size=len(pat_comp),
cue_sheet_comp_size=len(cue_comp),
global_vol=0xFF,
mixing_vol=0xFF,
mixing_vol=180,
)
assert len(song_table) == TAUD_SONG_ENTRY

View File

@@ -1995,30 +1995,29 @@ Memory Space
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample
Instrument bin: Registry for 256 instruments, formatted as:
Uint32 Sample Pointer
Uint16 Sample length
Uint16 Sampling rate at C4 (note number 0x5000)
Uint16 Play Start (usually 0 but not always)
Uint16 Loop Start (can be smaller than Play Start)
Uint16 Loop End
Bit8 Sample Flags
0 Uint32 Sample Pointer
4 Uint16 Sample length
6 Uint16 Sampling rate at C4 (note number 0x5000)
8 Uint16 Play Start (usually 0 but not always)
10 Uint16 Loop Start (can be smaller than Play Start)
12 Uint16 Loop End
14 Bit8 Sample Flags
0b 0000 0spp
pp: loop mode. 0-no loop, 1-loop, 2-backandforth, 3-oneshot (ignores note length unless overridden by other notes)
s: loop is sustain (key-off escapes the loop)
- IT: look for sample's SusLoop flag
Bit16 Volume envelope sustain/loops and flags
15 Bit16 Volume envelope sustain/loops and flags
* Sustain is implemented by enabling 't' flag. FastTracker has no 'Sus Loop' but only 'Sus Point'; use same value for start and end index
0b 0ut sssss pcb eeeee
0b 0ut sssss 0cb eeeee
s: sustain/loop start index
e: sustain/loop end index
b: use envelope
c: envelope carry
p: (IT) fadeout is zero; (XM) fadeout is cut
t: the loop must sustain (key-off escapes the loop)
u: set to enable the sustain/loop
Bit16 Panning envelope sustain/loops and flags
17 Bit16 Panning envelope sustain/loops and flags
* Sustain is implemented by enabling 't' flag
0b 0ut sssss pcb eeeee
s: sustain/loop start index
@@ -2026,11 +2025,11 @@ Instrument bin: Registry for 256 instruments, formatted as:
b: use envelope
c: envelope carry
p: use default pan (see offset 176 "Default pan value" below)
p: use default pan (see offset 177 "Default pan value" below)
t: the loop must sustain (key-off escapes the loop)
u: set to enable the sustain/loop
Bit16 Pitch/Filter envelope sustain/loops and flags
19 Bit16 Pitch/Filter envelope sustain/loops and flags
* Sustain is implemented by enabling 't' flag
0b 0ut sssss mcb eeeee
s: sustain/loop start index
@@ -2042,48 +2041,57 @@ Instrument bin: Registry for 256 instruments, formatted as:
t: the loop must sustain (key-off escapes the loop)
u: set to enable the sustain/loop
Bit16x25 Volume envelopes
21 Bit16x25 Volume envelopes
Byte 1: Volume (00..3F)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
Bit16x25 Panning envelopes
71 Bit16x25 Panning envelopes
Byte 1: Pan (00..FF)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
Bit16x25 Pitch/Filter envelopes
121 Bit16x25 Pitch/Filter envelopes
Byte 1: Value (00..FF)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
Uint8 Instrument Global Volume (0..255)
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
Uint8 Volume Fadeout low bits (IT: 1..256; XM: 0..255)
Bit8 Fadeout and vibrato
172 Uint8 Volume Fadeout low bits
173 Bit8 Fadeout and vibrato
0b 0000 ffff
f: Volume Fadeout high bits
Uint8 Volume swing (0..255 full range)
Uint8 Vibrato speed
* Combined 12-bit fadeout value is the engine's per-tick decrement, in 1/65536 units
(a unity-volume voice silenced over (65536 / fadeout) ticks after key-off).
* Stored 0: behaviour depends on Global Behaviour bit 'm' (see Song Table) —
IT mode (m=0) leaves the voice unfaded; FT2 mode (m=1) cuts on key-off.
* Source-format mapping:
- IT: stored fadeout (0..1024) MUST be doubled on import (taud = it × 2);
Taud's per-tick scale matches FT2 natively, so IT values are scaled to match.
- FT2: stored fadeout (0..0xFFF) is passed through unchanged.
174 Uint8 Volume swing (0..255 full range)
175 Uint8 Vibrato speed
* ImpulseTracker has samplewise vibrato speed (0..64), and they must be taken into account because Taud has no samplewise config
* FastTracker2 has instrumentwise config (0..255)
* The spec follows FastTracker2, and conversion must be performed when importing from FastTracker2
Uint8 Vibrato sweep
176 Uint8 Vibrato sweep
* FastTracker2 instrument config
Uint8 Default pan value (0..255 full range, see offset 17 for the enable flag)
177 Uint8 Default pan value (0..255 full range, see offset 17 for the enable flag)
* ImpulseTracker has samplewise default pan and instrumentwise default pan, and they must be taken into account because Taud has no samplewise config
Uint16 Pitch-pan centre (4096-TET note value)
Sint8 Pitch-pan separation (-128..127 full range)
Uint8 Pan swing (0..255 full range)
Uint8 Default cutoff (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
Uint8 Default resonance (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
Uint16 Sample detune (in 4096-TET unit) (XM finetune scale need to be rescaled accordingly)
Bit8 Instrument Flag
178 Uint16 Pitch-pan centre (4096-TET note value)
180 Sint8 Pitch-pan separation (-128..127 full range)
181 Uint8 Pan swing (0..255 full range)
182 Uint8 Default cutoff (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
183 Uint8 Default resonance (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
184 Uint16 Sample detune (in 4096-TET unit) (FT2 finetune scale need to be rescaled accordingly)
186 Bit8 Instrument Flag
0b 000 www nn
n: New note action. 00: note off, 01: note cut, 10: continue, 11: note fade (arranged differently to IT)
ww: Vibrato waveform (IT: sample config, FT2: instrument config). 00: sine, 01: ramp-down saw, 10: square, 11: random, 100: ramp-up saw (FT2 only)
Uint8 Vibrato Depth (0..255 full range)
187 Uint8 Vibrato Depth (0..255 full range)
* ImpulseTracker has range of 0..32 ON THE SAMPLE SETTINGS; multiply by (255/32) then round to int
* FastTracker2 has range of 0..16; multiply by (255/16) then round to int
Uint8 Vibrato Rate (0..255 full range)
188 Uint8 Vibrato Rate (0..255 full range)
* ImpulseTracker sample config. The spec follows ImpulseTracker precisely
Byte[4] Reserved
189 Byte[3] Reserved
TODO:
@@ -2098,8 +2106,14 @@ TODO:
[x] implement sample loop sustain
"Caveat: on a foreground voice, key-off (row.note == 0x0000) currently sets voice.active = false at AudioAdapter.kt:1713, which silences the channel immediately. Sustain-loop escape therefore only takes effect on background voices spawned by NNA "Note Off" — which matches the IT idiom of layering a new note over a sustained one. Let me know if you also want the foreground key-off to keep the voice playing through fadeout."
[x] cue and pattern compression of the Taud format (taud_common.py, taud.mjs)
[ ] figure out how IT (8 bits) and FT2 (12 bits) handles volume fadeout numbers, and come up with a compatible Taud spec, then implement
[ ] implement bitcrusher (eff sym '8')
[x] figure out how IT (0..256) and FT2 (0..FFF + cut) handles volume fadeout numbers, and come up with a compatible Taud spec, then implement
[x] Pitchbend on Amiga frequency mode sometimes works right, sometimes works wrong. (effect underdelivers) Affects every song with Amiga picth mode, AND ON THE fresh taut.js session only
[x] Fix 4THSYM.it filters
[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
[ ] scale Oxxxx when samples get resampled
[ ] implement bitcrusher and overdrive (eff sym '8' and '9')
Play Data: play data are series of tracker-like instructions, visualised as:
@@ -2314,6 +2328,11 @@ Endianness: Little
Uint16 Current Tuning base note (1..65533). A4 (western default) is 0x5C00. C9 (tracker default) is 0xA000. If zero, assume the tracker default value
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
Uint8 Flags for Global Behaviour (effect symbol '1')
0b 0000 0mfp
p: panning law (0=linear, 1=equal-power)
f: tone mode (0=linear pitch slides, 1=Amiga period slides)
m: fadeout-zero policy (0=IT — stored fadeout 0 means no fadeout;
1=FT2 — stored fadeout 0 means cut on key-off)
Uint8 Song global volume
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
Uint8 Song mixing volume

View File

@@ -132,7 +132,14 @@ class AudioJSR223Delegate(private val vm: VM) {
}
fun setTrackerMixerFlags(playhead: Int, flags: Int) {
getFirstSnd()?.playheads?.get(playhead)?.initialGlobalFlags = flags
getFirstSnd()?.playheads?.get(playhead)?.let { ph ->
ph.initialGlobalFlags = flags
ph.trackerState?.let { ts ->
ts.panLaw = flags and 1
ts.amigaMode = (flags and 2) != 0
ts.fadeoutCutOnZero = (flags and 4) != 0
}
}
}
fun getTrackerMixerFlags(playhead: Int): Int? {

View File

@@ -1170,15 +1170,36 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
inst.samplingRate.toDouble() / SAMPLING_RATE *
2.0.pow((noteVal - MIDDLE_C + inst.sampleDetuneSigned) / 4096.0)
// Convert a 4096-TET noteVal to its Amiga-period equivalent (Double, no rounding).
private fun noteValToAmigaPeriod(noteVal: Int): Double =
AMIGA_BASE_PERIOD * 2.0.pow(-(noteVal - MIDDLE_C).toDouble() / 4096.0)
// Convert an Amiga period (Double) to the nearest 4096-TET noteVal.
private fun amigaPeriodToNoteVal(period: Double): Int =
(MIDDLE_C + 4096.0 * log2(AMIGA_BASE_PERIOD / period)).roundToInt()
// Applies one tick of Amiga-mode pitch slide. When the song is in Amiga tone mode, E/F coarse
// slide arguments are stored as raw tracker period units (the original ProTracker/ST3 byte),
// *not* scaled to 4096-TET — see TAUD_NOTE_EFFECTS.md §1 and §E/F. Sign convention matches
// linear mode: negative = pitch down (E effect), positive = pitch up (F effect), so a positive
// slideArg subtracts from the period (pitch rises).
private fun amigaSlide(noteVal: Int, slideArg: Int): Int {
val period = AMIGA_BASE_PERIOD * 2.0.pow(-(noteVal - MIDDLE_C).toDouble() / 4096.0)
//
// Period state is persisted on the Voice (voice.amigaPeriod) so accumulated period changes
// don't lose sub-noteVal precision via repeated noteVal-int rounding. voice.amigaPeriod < 0
// means the cache is stale and must be reseeded from the current noteVal.
private fun amigaSlideTick(voice: Voice, slideArg: Int): Int {
if (voice.amigaPeriod < 0.0) voice.amigaPeriod = noteValToAmigaPeriod(voice.noteVal)
voice.amigaPeriod = (voice.amigaPeriod - slideArg).coerceAtLeast(1.0)
return amigaPeriodToNoteVal(voice.amigaPeriod)
}
// One-shot Amiga slide that does NOT mutate persistent period state — used for
// fine slides (EFx / FFx) which are applied once per row at tick 0. The next
// multi-tick slide will reseed amigaPeriod from the resulting noteVal.
private fun amigaSlideOnce(noteVal: Int, slideArg: Int): Int {
val period = noteValToAmigaPeriod(noteVal)
val newPeriod = (period - slideArg).coerceAtLeast(1.0)
return (MIDDLE_C + 4096.0 * log2(AMIGA_BASE_PERIOD / newPeriod)).roundToInt()
return amigaPeriodToNoteVal(newPeriod)
}
private fun advanceEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) {
@@ -1448,8 +1469,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (i0 in ls until inst.sampleLoopEnd && inst.funkBit(i0 - ls)) b0 = b0 xor 0x80
if (i1 in ls until inst.sampleLoopEnd && inst.funkBit(i1 - ls)) b1 = b1 xor 0x80
}
val s0 = (b0 - 128) / 128.0
val s1 = (b1 - 128) / 128.0
val s0 = (b0 - 127.5) / 127.5
val s1 = (b1 - 127.5) / 127.5
val sample = s0 + (s1 - s0) * frac
if (voice.forward) {
@@ -1530,10 +1551,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.filterResonanceCached = -1
voice.noteVal = noteVal
voice.basePitch = noteVal
voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new noteVal
voice.playbackRate = computePlaybackRate(inst, noteVal)
if (volOverride >= 0) {
voice.channelVolume = volOverride.coerceIn(0, 0x3F)
}
// Fresh trigger resets channel volume to full ($3F). 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.
voice.channelVolume = if (volOverride >= 0) volOverride.coerceIn(0, 0x3F) else 0x3F
voice.rowVolume = voice.channelVolume
voice.noteWasCut = false
voice.noteFading = false
@@ -1548,6 +1571,63 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.panbrelloRetrig) voice.panbrelloLfoPos = 0
}
/**
* IT-style Duplicate Check (DCT/DCA). Runs *before* NNA on every fresh foreground
* trigger: existing voices on this channel — the foreground itself plus any of its
* own background ghosts — that match the new note under DCT have DCA applied.
* Reference: schismtracker effects.c:1664-1764 (csf_check_nna).
*
* DCT (per existing voice's instrument):
* 1 = note — same noteVal AND same instrumentId
* 2 = sample — same canonical sample (matched by samplePtr+sampleLength)
* 3 = instrument — same instrumentId
* DCA: 0 = note cut, 1 = note off (release sustain), 2 = note fade.
*
* Note: the foreground voice will be replaced by triggerNote() right after this,
* so applying DCA to it is mostly relevant for ghosts spawned *from* it via NNA
* — the ghost is cloned from the (already-DCA-modified) foreground state.
*/
private fun applyDuplicateCheck(ts: TrackerState, channel: Int, newInstId: Int, newNote: Int) {
if (newInstId == 0) return
val newInst = instruments[newInstId]
fun isDuplicate(v: Voice): Boolean {
val existInst = instruments[v.instrumentId]
return when (existInst.duplicateCheckType) {
1 -> v.noteVal == newNote && v.instrumentId == newInstId
2 -> v.instrumentId == newInstId &&
existInst.samplePtr == newInst.samplePtr &&
existInst.sampleLength == newInst.sampleLength
3 -> v.instrumentId == newInstId
else -> false
}
}
fun applyAction(v: Voice) {
val existInst = instruments[v.instrumentId]
when (existInst.duplicateCheckAction) {
0 -> { v.fadeoutVolume = 0.0; v.active = false }
1 -> v.keyOff = true
2 -> v.noteFading = true
}
}
val fg = ts.voices[channel]
if (fg.active && instruments[fg.instrumentId].duplicateCheckType != 0 && isDuplicate(fg)) {
applyAction(fg)
}
val it = ts.backgroundVoices.iterator()
while (it.hasNext()) {
val bg = it.next()
if (bg.sourceChannel != channel || !bg.active) continue
if (instruments[bg.instrumentId].duplicateCheckType == 0) continue
if (!isDuplicate(bg)) continue
applyAction(bg)
if (!bg.active) it.remove()
}
}
/**
* On a fresh foreground trigger, optionally migrate the existing voice into the
* mixer-private background pool per the New Note Action setting (instrument default
@@ -1616,6 +1696,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
v.randomPanBias = src.randomPanBias
v.noteVal = src.noteVal
v.basePitch = src.basePitch
v.amigaPeriod = src.amigaPeriod
v.volEnvOn = src.volEnvOn
v.panEnvOn = src.panEnvOn
v.pfEnvOn = src.pfEnvOn
@@ -1713,7 +1794,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// ── Note ──
val toneG = (row.effect == EffectOp.OP_G)
when (row.note) {
0xFFFF -> {} // no-op
// No note but an instrument byte is present: latch the instrument so
// the *next* note-only trigger picks up the right sample. Trackers
// call this an "instrument-only retrigger"; in MOD/S3M/IT the sample
// keeps playing, but the channel's instrument reference advances.
0xFFFF -> { if (row.instrment != 0) voice.instrumentId = row.instrment }
0x0000 -> { voice.keyOff = true; voice.active = false } // key-off; breaks sustain loop
0xFFFE -> voice.active = false // note cut
else -> {
@@ -1726,8 +1811,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.noteDelayTick = (row.effectArg ushr 8) and 0xF
voice.delayedNote = row.note
voice.delayedInst = row.instrment
voice.delayedVol = if (row.volume >= 0) row.volume else -1
// Only treat the vol cell as an override when it carries SEL_SET;
// SEL_FINE/0 (no-op) and slide selectors must not collapse into
// a SET=0 on the deferred trigger.
voice.delayedVol = if (row.volumeEff == 0) row.volume else -1
} else {
applyDuplicateCheck(ts, vi, row.instrment, row.note)
maybeSpawnBackgroundForNNA(ts, voice, vi)
triggerNote(voice, row.note, row.instrment, -1)
}
@@ -1754,9 +1843,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// 1 $xx00 — Global behaviour flags byte in the high byte (see TAUD_NOTE_EFFECTS.md §1).
// bit 0 (p): 0=linear pan, 1=equal-power pan
// bit 1 (f): 0=linear pitch slides, 1=Amiga-mode pitch slides
// bit 2 (m): fadeout-zero policy. 0=IT (stored 0 ⇒ no fadeout), 1=FT2 (stored 0 ⇒ cut on key-off)
val flags = rawArg ushr 8
ts.panLaw = flags and 1
ts.amigaMode = (flags and 2) != 0
ts.fadeoutCutOnZero = (flags and 4) != 0
}
EffectOp.OP_A -> {
val tr = (rawArg ushr 8) and 0xFF
@@ -1787,13 +1878,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if ((arg and 0xF000) == 0xF000) {
val mag = arg and 0x0FFF
voice.noteVal = if (ts.amigaMode)
amigaSlide(voice.noteVal, -mag).coerceIn(0, 0xFFFE)
amigaSlideOnce(voice.noteVal, -mag).coerceIn(0, 0xFFFE)
else
(voice.noteVal - mag).coerceIn(0, 0xFFFE)
voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0 // reseed on next per-tick slide
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
} else {
voice.slideMode = 1; voice.slideArg = -arg
voice.amigaPeriod = -1.0 // reseed at the start of a fresh multi-tick slide
}
}
EffectOp.OP_F -> {
@@ -1801,13 +1894,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if ((arg and 0xF000) == 0xF000) {
val mag = arg and 0x0FFF
voice.noteVal = if (ts.amigaMode)
amigaSlide(voice.noteVal, mag).coerceIn(0, 0xFFFE)
amigaSlideOnce(voice.noteVal, mag).coerceIn(0, 0xFFFE)
else
(voice.noteVal + mag).coerceIn(0, 0xFFFE)
voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
} else {
voice.slideMode = 2; voice.slideArg = arg
voice.amigaPeriod = -1.0
}
}
EffectOp.OP_G -> {
@@ -1922,6 +2017,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
0x2 -> {
voice.noteVal = (voice.noteVal + FINETUNE_OFFSET[x]).coerceIn(0, 0xFFFE)
voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
}
0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 }
@@ -1997,6 +2093,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Note delay — fire deferred trigger when the requested tick arrives.
// NNA fires now (not at row parse) so that delayed retriggers ghost correctly.
if (voice.noteDelayTick == ts.tickInRow) {
applyDuplicateCheck(ts, vi, voice.delayedInst, voice.delayedNote)
maybeSpawnBackgroundForNNA(ts, voice, vi)
triggerNote(voice, voice.delayedNote, voice.delayedInst, voice.delayedVol)
voice.noteDelayTick = -1
@@ -2007,7 +2104,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Pitch slides (E/F coarse on tick > 0).
if (ts.tickInRow > 0 && (voice.slideMode == 1 || voice.slideMode == 2)) {
voice.noteVal = if (ts.amigaMode)
amigaSlide(voice.noteVal, voice.slideArg).coerceIn(0, 0xFFFE)
amigaSlideTick(voice, voice.slideArg).coerceIn(0, 0xFFFE)
else
(voice.noteVal + voice.slideArg).coerceIn(0, 0xFFFE)
voice.basePitch = voice.noteVal
@@ -2023,6 +2120,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.noteVal = target; voice.tonePortaTarget = -1
}
voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0 // tone porta works in linear noteVal space; reseed period
}
// Volume slides (D coarse on tick > 0).
@@ -2121,32 +2219,48 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Auto-vibrato (instrument-supplied sample LFO) — added on top of pitchToMixer.
val autoVibDelta = advanceAutoVibrato(voice, inst)
// Pitch envelope contribution: env value 0..1, 0.5 = unity. -32..+32
// semitone range maps to ±32 × 4096/12 ≈ ±10923 4096-TET units.
// Pitch envelope contribution: env value 0..1, 0.5 = unity.
// IT pitch envelope max is ±16 semitones (Schism sndmix.c:455-462 indexes
// linear_slide_up_table[abs(envpitch)] where envpitch ∈ [-256,+256] and
// table[255] = 65536·2^(255/192) ≈ 2.504, i.e. 15.94 semitones).
val pitchEnvDelta = if (voice.hasPfEnv && voice.pfEnvOn && !voice.envPfIsFilter)
((voice.envPfValue - 0.5) * 2.0 * 32.0 * 4096.0 / 12.0).toInt()
((voice.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
else 0
val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(0, 0xFFFE)
voice.playbackRate = computePlaybackRate(inst, finalPitch)
// Filter envelope (filter mode): scale current cutoff by env value (0..1, 0.5 = unity).
// Filter envelope (filter mode): scale baseCut by envValue (0..1, 0.5 = unity).
// Schism filters.c:80-86 computes `cutoff_used = chan->cutoff * (flt_modifier+256)/256`
// where flt_modifier = (env_value_0..64 - 32) * 8. Mapping TSVM's [0..1] env to Schism's
// [-256..+256] modifier and accounting for our pre-doubled defaultCutoff (it2taud.py
// stores IFC*2 in 0..254) gives `currentCutoff = baseCut * envPfValue` — at unity (0.5)
// the filter sits at IFC, at max (1.0) it opens to 2*IFC, at min (0.0) it closes.
// If the instrument has no initial cutoff (255 = off), the envelope drives the filter
// from the maximum active value (254) so the filter can become audible during the note.
if (voice.hasPfEnv && voice.pfEnvOn && voice.envPfIsFilter) {
val baseCut = if (inst.defaultCutoff < 255) inst.defaultCutoff else 254
voice.currentCutoff = (baseCut * (voice.envPfValue * 2.0)).toInt().coerceIn(0, 254)
voice.currentCutoff = (baseCut * voice.envPfValue).toInt().coerceIn(0, 254)
}
// Refresh biquad filter coefficients once per tick (only recomputes when changed).
refreshVoiceFilter(voice)
// Volume fadeout: after key-off OR Note-Fade NNA, decrement by inst.volumeFadeout / 1024 per tick.
// The 10-bit fadeout value is split across volumeFadeoutLow + low nibble of fadeoutHigh.
// Volume fadeout: after key-off OR Note-Fade NNA, decrement per tick.
// The 12-bit fadeStep is split across volumeFadeoutLow + low nibble of fadeoutHigh.
// Divisor selects per-tracker semantics:
// FT2 mode (fadeoutCutOnZero=true): fadeStep / 65536 per tick — matches FT2 .XM (16-bit accumulator, decrement = stored).
// IT mode (fadeoutCutOnZero=false): fadeStep / 1024 per tick — matches Schism (sndmix.c:331-339 + effects.c:1261:
// accumulator 65536, decrement = (stored<<5)<<1 = stored·64).
// Stored 0: FT2 mode cuts on key-off; IT mode leaves voice playing (no fade).
if (voice.keyOff || voice.noteFading) {
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8)
if (fadeStep > 0) {
voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / 1024.0).coerceAtLeast(0.0)
val divisor = if (ts.fadeoutCutOnZero) 65536.0 else 1024.0
voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / divisor).coerceAtLeast(0.0)
if (voice.fadeoutVolume <= 0.0) voice.active = false
} else if (ts.fadeoutCutOnZero) {
voice.active = false
}
}
@@ -2198,20 +2312,25 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (bg.keyOff || bg.noteFading) {
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8)
if (fadeStep > 0) {
bg.fadeoutVolume = (bg.fadeoutVolume - fadeStep / 1024.0).coerceAtLeast(0.0)
val divisor = if (ts.fadeoutCutOnZero) 65536.0 else 1024.0
bg.fadeoutVolume = (bg.fadeoutVolume - fadeStep / divisor).coerceAtLeast(0.0)
} else if (ts.fadeoutCutOnZero) {
bg.active = false
bgIt.remove()
continue
}
}
// Auto-vibrato keeps running on backgrounds — it's an instrument-intrinsic LFO.
val autoVibDelta = advanceAutoVibrato(bg, inst)
val pitchEnvDelta = if (bg.hasPfEnv && bg.pfEnvOn && !bg.envPfIsFilter)
((bg.envPfValue - 0.5) * 2.0 * 32.0 * 4096.0 / 12.0).toInt()
((bg.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
else 0
val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(0, 0xFFFE)
bg.playbackRate = computePlaybackRate(inst, finalPitch)
// Filter-mode pf envelope: same scaling rule as foreground.
if (bg.hasPfEnv && bg.pfEnvOn && bg.envPfIsFilter) {
val baseCut = if (inst.defaultCutoff < 255) inst.defaultCutoff else 254
bg.currentCutoff = (baseCut * (bg.envPfValue * 2.0)).toInt().coerceIn(0, 254)
bg.currentCutoff = (baseCut * bg.envPfValue).toInt().coerceIn(0, 254)
}
refreshVoiceFilter(bg)
// Reap fully-faded ghosts so the pool stays drained.
@@ -2252,6 +2371,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
}
}
private fun Double.sqrt() = Math.sqrt(this)
internal fun generateTrackerAudio(playhead: Playhead): ByteArray? {
val ts = playhead.trackerState ?: return null
@@ -2289,7 +2410,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val swingScale = 1.0 + voice.randomVolBias / 255.0
// Volume envelope is bypassed (treated as unity) when S $77 has disabled it.
val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0
val vol = effEnvVol * voice.fadeoutVolume * voice.rowVolume / 63.0 *
val vol = effEnvVol.sqrt() * voice.fadeoutVolume * (voice.rowVolume / 63.0).sqrt() *
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
val pan = if (voice.hasPanEnv && voice.panEnvOn) {
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
@@ -2319,7 +2440,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val instGv = bgInst.instGlobalVolume / 255.0
val swingScale = 1.0 + bg.randomVolBias / 255.0
val effEnvVol = if (bg.volEnvOn) bg.envVolume else 1.0
val vol = effEnvVol * bg.fadeoutVolume * bg.rowVolume / 63.0 *
val vol = effEnvVol.sqrt() * bg.fadeoutVolume * (bg.rowVolume / 63.0).sqrt() *
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
val pan = if (bg.hasPanEnv && bg.panEnvOn) {
val envPanRaw = (bg.envPan * 255.0).roundToInt().coerceIn(0, 255)
@@ -2568,6 +2689,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Pitch state (4096-TET units, signed when slid).
var noteVal = 0xFFFF // The currently sounding base note (no per-row vibrato/arp added)
var basePitch = 0x4000 // Saved pre-effect pitch for vibrato/arp/glissando overlay
// Amiga-mode period state, persisted across ticks so multi-tick E/F slides don't lose
// sub-noteVal precision through repeated round-trip rounding (see amigaSlideTick).
// -1.0 means "needs reseed from current noteVal".
var amigaPeriod: Double = -1.0
// Per-row effect state (set in applyTrackerRow, consumed by applyTrackerTick).
var rowEffect = 0
@@ -2662,6 +2787,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Global mixer config (effect 1).
var panLaw = 0 // 0 = linear balance (default), 1 = equal-power
var amigaMode = false // false = linear pitch slides, true = Amiga period-space slides
var fadeoutCutOnZero = false // false = IT (stored 0 ⇒ no fadeout); true = FT2 (stored 0 ⇒ cut on key-off)
// Pending row-end events (set during a row by B/C; consumed at row end).
var pendingOrderJump = -1 // -1 = none; otherwise the order index to jump to
@@ -2772,7 +2898,11 @@ 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.amigaMode = (byte and 2) != 0 }
trackerState?.let { ts ->
ts.panLaw = byte and 1
ts.amigaMode = (byte and 2) != 0
ts.fadeoutCutOnZero = (byte and 4) != 0
}
}
8 -> { bpm = byte + 24 }
9 -> { tickRate = byte }
@@ -2807,6 +2937,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
ts.finePatternDelayExtra = 0
ts.panLaw = initialGlobalFlags and 1
ts.amigaMode = (initialGlobalFlags and 2) != 0
ts.fadeoutCutOnZero = (initialGlobalFlags and 4) != 0
ts.voices.forEach {
it.active = false
it.channelVolume = 0x3F
@@ -2920,7 +3051,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
* waveform: 0=sine, 1=ramp-down, 2=square, 3=random, 4=ramp-up (FT2)
* 187 u8 vibrato depth (0..255 full range)
* 188 u8 vibrato rate (0..255 full range — IT samplewise Vir)
* 189..191 byte[3] reserved
* 189 u8 duplicate-check / action (IT-only — 0b 0000 aadd)
* dd = DCT (Duplicate Check Type) 0=off, 1=note, 2=sample, 3=instrument
* aa = DCA (Duplicate Check Action) 0=note cut, 1=note off, 2=note fade
* 190..191 byte[2] reserved
*/
data class TaudInst(
var index: Int,
@@ -2953,7 +3087,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var sampleDetune: Int, // bytes 184-185 (signed 4096-TET stored as u16)
var instrumentFlag: Int, // byte 186 (NNA + vibrato waveform)
var vibratoDepth: Int, // byte 187 (0..255 full range)
var vibratoRate: Int // byte 188 (IT samplewise Vir)
var vibratoRate: Int, // byte 188 (IT samplewise Vir)
var dupCheckFlag: Int // byte 189 (DCT bits 0-1, DCA bits 2-3)
) {
constructor(index: Int) : this(
index, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF,
@@ -2961,7 +3096,7 @@ 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
)
/** Sample-flag byte 14 bit 2 — when set, the sample loop is a sustain loop:
@@ -2975,9 +3110,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val vibratoWaveform: Int get() = (instrumentFlag ushr 2) and 0x07
/** Sample detune as a signed 4096-TET delta. */
val sampleDetuneSigned: Int get() = sampleDetune.toShort().toInt()
/** Duplicate Check Type — 0=off, 1=note, 2=sample, 3=instrument (IT semantics). */
val duplicateCheckType: Int get() = dupCheckFlag and 0x03
/** 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 189..191 (3 bytes per instrument).
private val reserved = ByteArray(3)
// Reserved padding at offsets 190..191 (2 bytes per instrument).
private val reserved = ByteArray(2)
// 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.
@@ -3059,7 +3198,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
186 -> instrumentFlag.toByte()
187 -> vibratoDepth.toByte()
188 -> vibratoRate.toByte()
in 189..191 -> reserved[offset - 189]
189 -> dupCheckFlag.toByte()
in 190..191 -> reserved[offset - 190]
else -> throw InternalError("Bad offset $offset")
}
@@ -3114,7 +3254,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
186 -> { instrumentFlag = byte and 0xFF }
187 -> { vibratoDepth = byte and 0xFF }
188 -> { vibratoRate = byte and 0xFF }
in 189..191 -> { reserved[offset - 189] = byte.toByte() }
189 -> { dupCheckFlag = byte and 0x0F } // DCT (bits 0-1) + DCA (bits 2-3)
in 190..191 -> { reserved[offset - 190] = byte.toByte() }
else -> throw InternalError("Bad offset $offset")
}
}

View File

@@ -87,7 +87,7 @@ object OEMBios : VMProgramRom(File("./assets/bios/TBMBIOS.js"))
object QuickBios : VMProgramRom(File("./assets/bios/quick.js"))
object BasicBios : VMProgramRom(File("./assets/bios/basicbios.js"))
object TandemBios : VMProgramRom(File("./assets/bios/tandemport.js"))
object TsvmBios : VMProgramRom(File("./assets/bios/tsvmbios.bin"))
object TsvmBios : VMProgramRom(File("./assets/bios/tsvmbios.js"))
object BasicRom : VMProgramRom(File("./assets/bios/basic.bin"))
object WPBios : VMProgramRom(File("./assets/bios/wp.js"))
object OpenBios : VMProgramRom(File("./assets/bios/openbios.js"))

View File

@@ -576,7 +576,7 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
"assetsdir":"./assets",
"ramsize":8388608,
"cardslots":8,
"roms":["./assets/bios/tsvmbios.bin"],
"roms":["./assets/bios/tsvmbios.js"],
"com1":{"cls":"net.torvald.tsvm.peripheral.TestDiskDrive", "args":[0, "./assets/disk0/"]},
"com2":{"cls":"net.torvald.tsvm.peripheral.HttpModem", "args":[1024, -1]},
"com3":{"cls":"net.torvald.tsvm.peripheral.TestDiskDrive", "args":[0, "./assets/diskMediabin/"]},