mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
Compare commits
9 Commits
5e6ac17146
...
94e3ce55ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94e3ce55ce | ||
|
|
9a9893b9a3 | ||
|
|
789c78f1e7 | ||
|
|
c7e7ee650d | ||
|
|
5d968fecf5 | ||
|
|
aaf3cc28b2 | ||
|
|
24375727db | ||
|
|
6a7ef670d9 | ||
|
|
1bbf0de381 |
9
.idea/markdown.xml
generated
Normal file
9
.idea/markdown.xml
generated
Normal 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>
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -33,6 +33,10 @@ Current topics:
|
||||
resonance damping curve, and the **IIR-only 2-pole topology** (NOT a
|
||||
biquad — no feedforward x[n−1] / x[n−2] 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
|
||||
|
||||
|
||||
@@ -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.
@@ -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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"));
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
106
it2taud.py
106
it2taud.py
@@ -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))
|
||||
|
||||
36
mod2taud.py
36
mod2taud.py
@@ -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
|
||||
|
||||
|
||||
57
s3m2taud.py
57
s3m2taud.py
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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/"]},
|
||||
|
||||
Reference in New Issue
Block a user