mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
Compare commits
5 Commits
94e3ce55ce
...
89d3c5d776
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89d3c5d776 | ||
|
|
517d0ad9a7 | ||
|
|
9524bf36e0 | ||
|
|
8e17256224 | ||
|
|
ac409bf961 |
@@ -561,32 +561,106 @@ Peak at maximum settings: $7F × $FF >> 9 = $3F — the full panning range. Retr
|
||||
|
||||
## 8 $xyzz — Bitcrusher
|
||||
|
||||
**Plain.** Applies Bitcrusher to the current voice.
|
||||
**Plain.** Applies a bitcrusher to the current voice. The crusher has two independent stages — a sample-rate reducer (`zz`, sample-and-hold) and a bit-depth quantiser (`y`) — and shares its clipping mode (`x`) with effect 9 (Overdrive). The two stages are orthogonal: enabling either is sufficient to engage the effect, and either can be active alone.
|
||||
|
||||
- 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'
|
||||
- **x — clipping mode** (shared with effect 9): `0` clamp (hard limit at ±1.0), `1` fold (ping-pong around ±1.0; values outside the range mirror back symmetrically), `2` wrap (saw-tooth wrap mod 2; ±1 are fixed points so no DC step at the boundary). Values 3..F are reserved and treated as clamp.
|
||||
- **y — bit depth**, range $1..$F. `0` disables the quantiser stage. `1` reduces the voice to a 1-bit (sign-only) signal. `8..F` are accepted but produce no audible quantisation, since TSVM's mix bus is already 8-bit; they are reserved for future hardware revisions.
|
||||
- **zz — sample skip**, range $00..$FF. `0` disables skip; non-zero N holds the post-quantiser output for N additional output samples (i.e. emit one fresh sample every N+1). The held value is the bitcrusher's *output*, so the sample-and-hold is downstream of the quantiser and the shared clipper.
|
||||
- `8 $0000` disables both stages and resets the shared clipping mode to clamp.
|
||||
- `8 $x000` updates only the shared clipping mode and leaves the active depth/skip undisturbed — useful for switching between clamp/fold/wrap mid-pattern without retyping the whole argument. The same form on effect 9 has identical semantics.
|
||||
|
||||
**Compatibility.** Unique to Taud. No compatible equivalent exists.
|
||||
**Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory: every cell that names effect 8 must spell out its full argument (apart from the `$x000` shorthand described above). `8 $1100` ⇒ 1-bit, no skip, fold-clipped — a useful sanity check pattern.
|
||||
|
||||
**Implementation.** TODO
|
||||
**Implementation.** Per-voice state: `bitcrusherDepth` (0..15; 0 = quantiser off), `bitcrusherSkip` (0..255), `bitcrusherCounter` (mod skip+1), `bitcrusherHeld` (last emitted sample), and `clipMode` (0..2, shared with effect 9). On row parse:
|
||||
|
||||
```
|
||||
on row parse (8 $xyzz):
|
||||
voice.clipMode = x & 3
|
||||
if arg == $0000:
|
||||
voice.bitcrusherDepth = 0
|
||||
voice.bitcrusherSkip = 0
|
||||
voice.bitcrusherCounter = 0
|
||||
else if y == 0 and zz == 0:
|
||||
# x000 — clip-mode-only update; preserve depth/skip/counter
|
||||
pass
|
||||
else:
|
||||
voice.bitcrusherDepth = y
|
||||
voice.bitcrusherSkip = zz
|
||||
voice.bitcrusherCounter = 0
|
||||
```
|
||||
|
||||
On every output sample, after `applyVoiceFilter` and *after* the overdrive stage of effect 9:
|
||||
|
||||
```
|
||||
on output sample (per voice):
|
||||
if voice.bitcrusherCounter == 0:
|
||||
s' = sample # post-overdrive input
|
||||
if 1 ≤ voice.bitcrusherDepth ≤ 7:
|
||||
s' = clip(s', voice.clipMode) # ensure in-range before quantising
|
||||
levels = (1 << voice.bitcrusherDepth) - 1
|
||||
q = round((s' + 1) × 0.5 × levels) # nearest integer; clamp to [0, levels]
|
||||
s' = (q / levels) × 2 - 1
|
||||
voice.bitcrusherHeld = s'
|
||||
out = s'
|
||||
else:
|
||||
out = voice.bitcrusherHeld
|
||||
if voice.bitcrusherSkip > 0:
|
||||
voice.bitcrusherCounter = (voice.bitcrusherCounter + 1) mod (voice.bitcrusherSkip + 1)
|
||||
```
|
||||
|
||||
The clipper is shared between effects 8 and 9 and is implemented as a single helper:
|
||||
|
||||
```
|
||||
clip(x, mode):
|
||||
if mode == 1: # fold (triangle)
|
||||
while x > +1: x = 2 - x
|
||||
while x < -1: x = -2 - x
|
||||
return x
|
||||
if mode == 2: # wrap (saw, period 2)
|
||||
v = ((x + 1) mod 2 + 2) mod 2
|
||||
return v - 1
|
||||
return clamp(x, -1, +1) # mode 0 (and reserved values)
|
||||
```
|
||||
|
||||
The voice-FX state is preserved verbatim by the NNA-ghost copier, so the post-NNA tail of a note keeps the same timbre as the foreground voice that spawned it.
|
||||
|
||||
---
|
||||
|
||||
## 9 $x0zz — Overdrive
|
||||
|
||||
**Plain.** Amplify the volume.
|
||||
**Plain.** Amplifies the voice's post-filter signal and routes it through the shared clipper. With `x = 0` (clamp) the effect is a hard-knee soft-clipping distortion; with `x = 1` (fold) it becomes a wave-folder; with `x = 2` (wrap) it produces aggressive aliased fuzz with sawtooth-style discontinuities at the rails. Volume is *not* re-normalised after clipping — `9 $00FF` clamp-clipped plays at roughly the same loudness as the dry voice once everything saturates. The middle nibble is reserved and must be zero.
|
||||
|
||||
- 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'
|
||||
- **x — clipping mode** (shared with effect 8): `0` clamp, `1` fold, `2` wrap (see effect 8 for the precise transfer functions). Values 3..F are reserved and treated as clamp.
|
||||
- **zz — amplification index**, range $00..$FF. The applied gain is `(16 + zz) / 16`, so `$00` is 1.0× (effect inactive), `$10` is 2.0× (+6 dBFS), `$F0` is 16.0× (+24 dBFS), and `$FF` is 16.9375× (≈ +24.55 dBFS).
|
||||
- `9 $0000` resets the overdrive (gain returns to unity, the stage stops processing) **and** resets the shared clipping mode to clamp.
|
||||
- `9 $x000` updates only the shared clipping mode and leaves the active amplification undisturbed — symmetric with `8 $x000`.
|
||||
|
||||
**Compatibility.** Unique to Taud. No compatible equivalent exists.
|
||||
**Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory.
|
||||
|
||||
**Implementation.** TODO
|
||||
**Implementation.** Per-voice state: `overdriveAmp` (0..255; 0 = effect off) and `clipMode` (shared with effect 8). On row parse:
|
||||
|
||||
```
|
||||
on row parse (9 $x0zz):
|
||||
voice.clipMode = x & 3
|
||||
if arg == $0000:
|
||||
voice.overdriveAmp = 0
|
||||
else if zz == 0:
|
||||
# x000 — clip-mode-only update; preserve amp
|
||||
pass
|
||||
else:
|
||||
voice.overdriveAmp = zz
|
||||
```
|
||||
|
||||
On every output sample, after `applyVoiceFilter` and *before* the bitcrusher stage of effect 8:
|
||||
|
||||
```
|
||||
on output sample (per voice):
|
||||
if voice.overdriveAmp > 0:
|
||||
sample = sample × (16 + voice.overdriveAmp) / 16
|
||||
sample = clip(sample, voice.clipMode)
|
||||
```
|
||||
|
||||
When both effects 8 and 9 are active on the same voice the chain is **filter → overdrive (×gain → clip) → bitcrusher (bit-depth quantise → sample-skip hold)**. Because the clipper is shared, changing `clipMode` from either effect propagates to the other on the next sample — there is one mode per voice, not one per stage.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ const fxNames = {
|
||||
'6':"UNIMPLEMENTED",
|
||||
'7':"UNIMPLEMENTED",
|
||||
'8':"Bitcrusher ",
|
||||
'9':"UNIMPLEMENTED",
|
||||
'9':"Overdrive ",
|
||||
A:"Tick speed ",
|
||||
B:"Jump to order",
|
||||
C:"Break pattern",
|
||||
@@ -116,17 +116,17 @@ P:"UNIMPLEMENTED", // IT: panning slide. Use PanEff instead
|
||||
Q:"Retrigger ",
|
||||
R:"Tremolo ",
|
||||
S:"Special ",
|
||||
S0:"UNIMPLEMENTED", // PT: Set audio filter.
|
||||
S0:"UNIMPLEMENTED", // PT: Set audio filter
|
||||
S1:"Gliss. ctrl ",
|
||||
S2:"Sample tune ",
|
||||
S3:"Vibrato LFO ",
|
||||
S4:"Tremolo LFO ",
|
||||
S5:"Panbrello LFO",
|
||||
S6:"UNIMPLEMENTED", // IT: Fine pattern delay.
|
||||
S7:"UNIMPLEMENTED", // IT: misc. functions
|
||||
S8:"Channel pan ", // Taud: 8-bit channel panning.
|
||||
S9:"UNIMPLEMENTED", // IT: Sound control.
|
||||
SA:"UNIMPLEMENTED", // SC3: Stereo control. IT: Sample offset high twobyte.
|
||||
S6:"Fine delay ",
|
||||
S7:"Note action ",
|
||||
S8:"Channel pan ", // Taud: 8-bit channel panning
|
||||
S9:"UNIMPLEMENTED", // IT: Sound control
|
||||
SA:"UNIMPLEMENTED", // SC3: Stereo control. IT: Sample offset high twobyte (not applicable because Taud has 64k limit)
|
||||
SB:"Pattern loop ",
|
||||
SC:"Note cut ",
|
||||
SD:"Note delay ",
|
||||
@@ -135,10 +135,10 @@ SF:"Funk it ",
|
||||
T:"Tempo ",
|
||||
U:"Fine vibrato ",
|
||||
V:"Global volume",
|
||||
W:"UNIMPLEMENTED", // IT: Global volume slide.
|
||||
X:"UNIMPLEMENTED", // IT: 8-bit channel panning. Use PanEff or S80xx instead
|
||||
W:"G.Vol Slide ",
|
||||
X:"UNIMPLEMENTED", // IT: 8-bit channel panning. Use S80xx instead
|
||||
Y:"Panbrello ",
|
||||
Z:"UNIMPLEMENTED", // IT: MIDI macro.
|
||||
Z:"UNIMPLEMENTED", // IT: MIDI macro
|
||||
}
|
||||
const panFxNames = {
|
||||
0:"Set to",
|
||||
@@ -1052,54 +1052,111 @@ function drawVoiceDetail(isVerticalLayout = false, ptn = null, activeRow = -1, c
|
||||
if (voleff == 0xC0) { voleffop1 = 999; voleffarg1 = '' }
|
||||
if (paneff == 0xC0) { paneffop1 = 999; paneffarg1 = '' }
|
||||
|
||||
const lines = []
|
||||
lines.push({ label: 'Note ', value: `${noteToStr(note)} ($${note.hex04()})`, fg: colNote })
|
||||
lines.push({ label: 'Inst ', value: inst === 0 ? '---' : ('$'+inst.hex02()), fg: colInst })
|
||||
lines.push({ label: 'Vx ', value: `${volFxNames[voleffop1]} ${voleffarg1}`, fg: colVol })
|
||||
lines.push({ label: 'Px ', value: `${panFxNames[paneffop1]} ${paneffarg1}`, fg: colPan })
|
||||
lines.push({ label: 'Fx ', value: fxName.trimEnd(), fg: colEffOp })
|
||||
lines.push({ label: 'FxOp ', value: fx, fg: colEffOp })
|
||||
lines.push({ label: 'FxArg', value: `$${effarg.hex04()}`, fg: colEffArg })
|
||||
// Two-column, two-section layout. Upper section: this row's cell fields,
|
||||
// split L (Note/Inst/Vx/Px) / R (Fx/FxOp/FxArg). Lower section: cumulative
|
||||
// engine state, packed in column-major order across both columns.
|
||||
const colW = Math.floor(detailW / 2)
|
||||
const col1X = dx
|
||||
const col2X = dx + colW
|
||||
const labelW = 6
|
||||
const valW1 = colW - labelW - 2
|
||||
const valW2 = (detailW - colW) - labelW - 2
|
||||
|
||||
if (cumState !== null) {
|
||||
lines.push({ label: '------', value: '', fg: colSep })
|
||||
lines.push({ label: 'L.Note', value: noteToStr(cumState.lastNote), fg: colNote })
|
||||
lines.push({ label: 'L.Inst', value: cumState.lastInst === 0 ? '---' : ('$'+cumState.lastInst.hex02()), fg: colInst })
|
||||
lines.push({ label: 'Vol ', value: `$${cumState.volAbs.hex02()}`, fg: colVol })
|
||||
lines.push({ label: 'Pan ', value: `$${cumState.panAbs.hex02()}`, fg: colPan })
|
||||
const drawLine = (y, x, line, valWidth) => {
|
||||
con.move(y, x)
|
||||
con.color_pair(colStatus, 255)
|
||||
print((line.label + ' ').substring(0, labelW) + ' ')
|
||||
con.color_pair(line.fg, 255)
|
||||
const v = (line.value + ' '.repeat(valWidth + 1))
|
||||
print(v.substring(0, valWidth + 1))
|
||||
}
|
||||
const blankLine = (y, x, width) => {
|
||||
con.move(y, x)
|
||||
con.color_pair(colBackPtn, 255)
|
||||
print(' '.repeat(width))
|
||||
}
|
||||
|
||||
const upperLeft = [
|
||||
{ label: 'Note ', value: `${noteToStr(note)} ($${note.hex04()})`, fg: colNote },
|
||||
{ label: 'Inst ', value: inst === 0 ? '---' : ('$'+inst.hex02()), fg: colInst },
|
||||
{ label: 'Vx ', value: `${volFxNames[voleffop1]} ${voleffarg1}`, fg: colVol },
|
||||
{ label: 'Px ', value: `${panFxNames[paneffop1]} ${paneffarg1}`, fg: colPan },
|
||||
]
|
||||
const upperRight = [
|
||||
{ label: 'Fx ', value: fxName.trimEnd(), fg: colEffOp },
|
||||
{ label: 'FxOp ', value: fx, fg: colEffOp },
|
||||
{ label: 'FxArg', value: `$${effarg.hex04()}`, fg: colEffArg },
|
||||
]
|
||||
const upperHeight = Math.max(upperLeft.length, upperRight.length)
|
||||
|
||||
for (let i = 0; i < upperHeight; i++) {
|
||||
const y = PTNVIEW_OFFSET_Y + i
|
||||
if (i < upperLeft.length) drawLine(y, col1X, upperLeft[i], valW1)
|
||||
else blankLine(y, col1X, colW)
|
||||
if (i < upperRight.length) drawLine(y, col2X, upperRight[i], valW2)
|
||||
else blankLine(y, col2X, detailW - colW)
|
||||
}
|
||||
|
||||
// Section divider
|
||||
const sepY = PTNVIEW_OFFSET_Y + upperHeight
|
||||
con.move(sepY, dx)
|
||||
con.color_pair(colSep, 255)
|
||||
print('\u00C4'.repeat(detailW))
|
||||
|
||||
// Lower section: cumulative state.
|
||||
const lowerY0 = sepY + 1
|
||||
const lowerH = PTNVIEW_HEIGHT - upperHeight - 1
|
||||
let cumLines = []
|
||||
if (cumState !== null && lowerH > 0) {
|
||||
const _apo = Math.abs(cumState.pitchOff)
|
||||
const _psgn = cumState.pitchOff > 0 ? '+' : cumState.pitchOff < 0 ? '-' : '='
|
||||
const _absN = (cumState.lastNote !== 0xFFFF && cumState.pitchOff !== 0)
|
||||
? noteToStr(Math.max(0, Math.min(0xFFFE, cumState.lastNote + cumState.pitchOff))) + ' '
|
||||
: ''
|
||||
lines.push({ label: 'Pitch ', value: `${_absN}(${_psgn}$${_apo.hex04()})`, fg: colNote })
|
||||
lines.push({ label: `E${MIDDOT}F `, value: `$${cumState.memEF.hex04()}`, fg: colEffArg })
|
||||
lines.push({ label: 'G ', value: `$${cumState.memG.hex04()}`, fg: colEffArg })
|
||||
lines.push({ label: `H${MIDDOT}U `, value: `$${cumState.memHU.speed.hex02()}/$${cumState.memHU.depth.hex02()}`, fg: colEffArg })
|
||||
lines.push({ label: 'R ', value: `$${cumState.memR.speed.hex02()}/$${cumState.memR.depth.hex02()}`, fg: colEffArg })
|
||||
lines.push({ label: 'Y ', value: `$${cumState.memY.speed.hex02()}/$${cumState.memY.depth.hex02()}`, fg: colEffArg })
|
||||
lines.push({ label: 'D ', value: `$${cumState.memD.hex04()}`, fg: colEffArg })
|
||||
lines.push({ label: 'I ', value: `$${cumState.memI.hex04()}`, fg: colEffArg })
|
||||
lines.push({ label: 'J ', value: `$${cumState.memJ.hex04()}`, fg: colEffArg })
|
||||
lines.push({ label: 'O ', value: `$${cumState.memO.hex04()}`, fg: colEffArg })
|
||||
lines.push({ label: 'Q ', value: `$${cumState.memQ.hex04()}`, fg: colEffArg })
|
||||
lines.push({ label: 'Tslid ', value: `$${cumState.memTSlide.hex02()}`, fg: colEffArg })
|
||||
const _clipNm = ['clamp','fold','wrap','wrap'][cumState.clipMode]
|
||||
const _bcStr = (cumState.bitcrushDepth === 0 && cumState.bitcrushSkip === 0)
|
||||
? 'off'
|
||||
: `d${cumState.bitcrushDepth.toString(16).toUpperCase()}/s$${cumState.bitcrushSkip.hex02()}`
|
||||
const _odStr = (cumState.overdriveAmp === 0) ? 'off' : `$${cumState.overdriveAmp.hex02()}`
|
||||
|
||||
cumLines = [
|
||||
{ label: 'L.Note', value: noteToStr(cumState.lastNote), fg: colNote },
|
||||
{ label: 'L.Inst', value: cumState.lastInst === 0 ? '---' : ('$'+cumState.lastInst.hex02()), fg: colInst },
|
||||
{ label: 'Vol ', value: `$${cumState.volAbs.hex02()}`, fg: colVol },
|
||||
{ label: 'Pan ', value: `$${cumState.panAbs.hex02()}`, fg: colPan },
|
||||
{ label: 'Pitch ', value: `${_absN}(${_psgn}$${_apo.hex04()})`, fg: colNote },
|
||||
{ label: 'BPM ', value: `${cumState.bpm}`, fg: colStatus },
|
||||
{ label: 'Spd ', value: `${cumState.speed}`, fg: colStatus },
|
||||
{ label: 'GVol ', value: `$${cumState.globalVol.hex02()}`, fg: colStatus },
|
||||
{ label: `E${MIDDOT}F `, value: `$${cumState.memEF.hex04()}`, fg: colEffArg },
|
||||
{ label: 'G ', value: `$${cumState.memG.hex04()}`, fg: colEffArg },
|
||||
{ label: `H${MIDDOT}U `, value: `$${cumState.memHU.speed.hex02()}/$${cumState.memHU.depth.hex02()}`, fg: colEffArg },
|
||||
{ label: 'R ', value: `$${cumState.memR.speed.hex02()}/$${cumState.memR.depth.hex02()}`, fg: colEffArg },
|
||||
{ label: 'Y ', value: `$${cumState.memY.speed.hex02()}/$${cumState.memY.depth.hex02()}`, fg: colEffArg },
|
||||
{ label: 'D ', value: `$${cumState.memD.hex04()}`, fg: colEffArg },
|
||||
{ label: 'I ', value: `$${cumState.memI.hex04()}`, fg: colEffArg },
|
||||
{ label: 'J ', value: `$${cumState.memJ.hex04()}`, fg: colEffArg },
|
||||
{ label: 'O ', value: `$${cumState.memO.hex04()}`, fg: colEffArg },
|
||||
{ label: 'Q ', value: `$${cumState.memQ.hex04()}`, fg: colEffArg },
|
||||
{ label: 'Tslid ', value: `$${cumState.memTSlide.hex02()}`, fg: colEffArg },
|
||||
{ label: 'W ', value: `$${cumState.memW.hex04()}`, fg: colEffArg },
|
||||
{ label: 'BCrsh ', value: _bcStr, fg: colEffArg },
|
||||
{ label: 'OvDrv ', value: _odStr, fg: colEffArg },
|
||||
{ label: 'Clip ', value: _clipNm, fg: colEffArg },
|
||||
]
|
||||
}
|
||||
|
||||
const showCount = Math.min(lines.length, PTNVIEW_HEIGHT)
|
||||
for (let i = 0; i < showCount; i++) {
|
||||
const y = PTNVIEW_OFFSET_Y + i
|
||||
const line = lines[i]
|
||||
con.move(y, dx)
|
||||
con.color_pair(colStatus, 255)
|
||||
print((line.label + ' ').substring(0, 6) + ' ')
|
||||
con.color_pair(line.fg, 255)
|
||||
print((line.value + ' '.repeat(detailW)).substring(0, detailW - 8))
|
||||
}
|
||||
for (let i = showCount; i < PTNVIEW_HEIGHT; i++) {
|
||||
con.move(PTNVIEW_OFFSET_Y + i, dx)
|
||||
con.color_pair(colBackPtn, 255)
|
||||
print(' '.repeat(detailW))
|
||||
// Column-major fill: cap per-column height to lowerH, drop overflow.
|
||||
const perCol = Math.min(lowerH, Math.ceil(cumLines.length / 2))
|
||||
const totShow = Math.min(cumLines.length, perCol * 2)
|
||||
for (let i = 0; i < perCol; i++) {
|
||||
const yL = lowerY0 + i
|
||||
const idxL = i
|
||||
const idxR = perCol + i
|
||||
if (idxL < totShow) drawLine(yL, col1X, cumLines[idxL], valW1)
|
||||
else blankLine(yL, col1X, colW)
|
||||
if (idxR < totShow) drawLine(yL, col2X, cumLines[idxR], valW2)
|
||||
else blankLine(yL, col2X, detailW - colW)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1574,24 +1631,50 @@ function getActiveRowForDetail() {
|
||||
return (playbackMode !== PLAYMODE_NONE) ? pbRow : patternGridRow
|
||||
}
|
||||
|
||||
// Walk pattern rows 0..uptoRow and accumulate effect-memory cohort state
|
||||
// Walk pattern rows 0..uptoRow and accumulate engine-visible cohort state.
|
||||
// Mirrors AudioAdapter.kt applyTrackerRow / applyEffectRow / applySEffect for the
|
||||
// state surfaced in the voice-detail panel. Out of scope: B/C control flow,
|
||||
// SEx pattern delay, SBx pattern loop, NNA / past-note actions, envelope toggles.
|
||||
function simulateRowState(ptnDat, uptoRow) {
|
||||
const OP_A = 10
|
||||
const OP_1 = 1, OP_8 = 8, OP_9 = 9, OP_A = 10
|
||||
const OP_D = 13, OP_E = 14, OP_F = 15, OP_G = 16
|
||||
const OP_H = 17, OP_I = 18, OP_J = 19, OP_O = 24
|
||||
const OP_Q = 26, OP_R = 27, OP_T = 29, OP_U = 30, OP_Y = 34
|
||||
const OP_Q = 26, OP_R = 27, OP_S = 28, OP_T = 29
|
||||
const OP_U = 30, OP_V = 31, OP_W = 32, OP_Y = 34
|
||||
|
||||
// ST3-style finetune offsets, mirrors AudioAdapter.kt FINETUNE_OFFSET
|
||||
const FINETUNE_OFFSET = [
|
||||
-0x0154, -0x0132, -0x0111, -0x00E4, -0x00B8, -0x008B, -0x005D, -0x003B,
|
||||
0x0000, 0x0023, 0x0046, 0x0074, 0x0098, 0x00C8, 0x00F9, 0x0110
|
||||
]
|
||||
|
||||
let lastNote = 0xFFFF, lastInst = 0
|
||||
let volAbs = 0x3F, panAbs = 0x20
|
||||
let volAbs = 0x3F // 6-bit channel volume
|
||||
let panAbs = 0x80 // 8-bit channel pan (engine width); centre = $80
|
||||
let pitchOff = 0, portaTarget = -1
|
||||
let speed = audio.getTickRate(PLAYHEAD) // not always going to be correct but it should be mostly
|
||||
let bpm = audio.getBPM(PLAYHEAD) // best-effort starting tempo
|
||||
let speed = audio.getTickRate(PLAYHEAD)
|
||||
let globalVol = 0xFF
|
||||
let panLaw = 0, amigaMode = false, fadeoutCutOnZero = false
|
||||
|
||||
let memEF = 0, memG = 0
|
||||
let memHU = { speed: 0, depth: 0 }
|
||||
let memR = { speed: 0, depth: 0 }
|
||||
let memY = { speed: 0, depth: 0 }
|
||||
let memD = 0, memI = 0, memJ = 0, memO = 0, memQ = 0, memTSlide = 0
|
||||
let memD = 0, memI = 0, memJ = 0, memO = 0, memQ = 0, memTSlide = 0, memW = 0
|
||||
|
||||
// Bitcrusher / overdrive (clipMode shared between OP_8 and OP_9)
|
||||
let bitcrushDepth = 0, bitcrushSkip = 0
|
||||
let overdriveAmp = 0
|
||||
let clipMode = 0
|
||||
|
||||
// S-effect state
|
||||
let glissandoOn = false
|
||||
let vibratoWave = 0, tremoloWave = 0, panbrelloWave = 0
|
||||
|
||||
const clampV = v => Math.max(0, Math.min(0x3F, v | 0))
|
||||
const clampP = v => Math.max(0, Math.min(0xFF, v | 0))
|
||||
const clampG = v => Math.max(0, Math.min(0xFF, v | 0))
|
||||
|
||||
const limit = Math.min(uptoRow, ROWS_PER_PAT - 1)
|
||||
for (let row = 0; row <= limit; row++) {
|
||||
@@ -1603,53 +1686,88 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
const effop = ptnDat[off+5]
|
||||
const effarg = ptnDat[off+6] | (ptnDat[off+7] << 8)
|
||||
|
||||
// Notes on a portamento row (G) become the slide target; they don't retrigger
|
||||
// Note column
|
||||
const isGRow = (effop === OP_G)
|
||||
const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD)
|
||||
if (note !== 0xFFFF && note !== 0xFFFE) {
|
||||
if (!isGRow) {
|
||||
if (note === 0x0000) {
|
||||
// key-off; sample stays referenced
|
||||
} else if (isGRow) {
|
||||
portaTarget = note
|
||||
} else if (isNoteDelay) {
|
||||
// Delayed trigger: latched but doesn't fire on this row's first tick.
|
||||
// For "state at end of row" treat as if it triggered.
|
||||
lastNote = note
|
||||
pitchOff = 0
|
||||
portaTarget = -1
|
||||
} else {
|
||||
portaTarget = note
|
||||
lastNote = note
|
||||
pitchOff = 0
|
||||
portaTarget = -1
|
||||
}
|
||||
}
|
||||
if (inst !== 0) lastInst = inst
|
||||
|
||||
// Volume column: set OR slide (0xC0 = 3.00 nop is the empty sentinel, not 0x00)
|
||||
const volop = (voleff >>> 6) & 3
|
||||
const volefarg = voleff & 63
|
||||
// Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET).
|
||||
const rowHasS80 = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0x8)
|
||||
|
||||
// Volume column. voleff = (sel<<6) | value6. $C0 = sel 3 / value 0 = empty nop.
|
||||
const volSel = (voleff >>> 6) & 3
|
||||
const volVal = voleff & 63
|
||||
if (voleff !== 0xC0) {
|
||||
if (volop === 0) {
|
||||
volAbs = volefarg
|
||||
} else if (volop === 1) {
|
||||
volAbs = clampV(volAbs + (volefarg & 15) * (speed - 1))
|
||||
} else if (volop === 2) {
|
||||
volAbs = clampV(volAbs - (volefarg & 15) * (speed - 1))
|
||||
} else if (volop === 3 && volefarg !== 0) {
|
||||
if (volefarg >= 32) volAbs = clampV(volAbs + (volefarg & 15)) // fine slide up
|
||||
else volAbs = clampV(volAbs - (volefarg & 15)) // fine slide down
|
||||
if (volSel === 0) {
|
||||
volAbs = volVal
|
||||
} else if (volSel === 1) {
|
||||
volAbs = clampV(volAbs + volVal * (speed - 1)) // engine: per non-first tick
|
||||
} else if (volSel === 2) {
|
||||
volAbs = clampV(volAbs - volVal * (speed - 1))
|
||||
} else if (volSel === 3 && volVal !== 0) {
|
||||
const mag = volVal & 0x1F
|
||||
if ((volVal & 0x20) !== 0) volAbs = clampV(volAbs + mag) // fine up
|
||||
else volAbs = clampV(volAbs - mag) // fine down
|
||||
}
|
||||
}
|
||||
|
||||
// Pan column: set OR slide (0xC0 = 3.00 nop is the empty sentinel, not 0x00)
|
||||
const panop = (paneff >>> 6) & 3
|
||||
const panefarg = paneff & 63
|
||||
// Pan column. Same encoding as volume. Engine pan is 8-bit; SET expands 6→8 by replicating bits.
|
||||
const panSel = (paneff >>> 6) & 3
|
||||
const panVal = paneff & 63
|
||||
if (paneff !== 0xC0) {
|
||||
if (panop === 0) {
|
||||
panAbs = panefarg
|
||||
} else if (panop === 1) {
|
||||
panAbs = clampV(panAbs + (panefarg & 15) * (speed - 1))
|
||||
} else if (panop === 2) {
|
||||
panAbs = clampV(panAbs - (panefarg & 15) * (speed - 1))
|
||||
} else if (panop === 3 && panefarg !== 0) {
|
||||
if (panefarg >= 32) panAbs = clampV(panAbs + (panefarg & 15))
|
||||
else panAbs = clampV(panAbs - (panefarg & 15))
|
||||
if (panSel === 0) {
|
||||
if (!rowHasS80) panAbs = ((panVal << 2) | (panVal >>> 4)) & 0xFF
|
||||
} else if (panSel === 1) {
|
||||
panAbs = clampP(panAbs + panVal * (speed - 1))
|
||||
} else if (panSel === 2) {
|
||||
panAbs = clampP(panAbs - panVal * (speed - 1))
|
||||
} else if (panSel === 3 && panVal !== 0) {
|
||||
const mag = panVal & 0x1F
|
||||
if ((panVal & 0x20) !== 0) panAbs = clampP(panAbs + mag)
|
||||
else panAbs = clampP(panAbs - mag)
|
||||
}
|
||||
}
|
||||
|
||||
if (effop !== 0 || effarg !== 0) {
|
||||
if (effop === OP_A) {
|
||||
if (effop === OP_1) {
|
||||
const flags = (effarg >>> 8) & 0xFF
|
||||
panLaw = flags & 1
|
||||
amigaMode = (flags & 2) !== 0
|
||||
fadeoutCutOnZero = (flags & 4) !== 0
|
||||
}
|
||||
else if (effop === OP_8) {
|
||||
const x = (effarg >>> 12) & 0xF
|
||||
const y = (effarg >>> 8) & 0xF
|
||||
const z = effarg & 0xFF
|
||||
clipMode = x & 3
|
||||
if (effarg === 0) { bitcrushDepth = 0; bitcrushSkip = 0 }
|
||||
else if (y !== 0 || z !== 0) { bitcrushDepth = y; bitcrushSkip = z }
|
||||
}
|
||||
else if (effop === OP_9) {
|
||||
const x = (effarg >>> 12) & 0xF
|
||||
const z = effarg & 0xFF
|
||||
clipMode = x & 3
|
||||
if (effarg === 0) overdriveAmp = 0
|
||||
else if (z !== 0) overdriveAmp = z
|
||||
}
|
||||
else if (effop === OP_A) {
|
||||
if ((effarg >>> 8) !== 0) speed = (effarg >>> 8)
|
||||
}
|
||||
else if (effop === OP_D) {
|
||||
@@ -1658,16 +1776,16 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
const hb = (raw >>> 8) & 0xFF
|
||||
const hiNib = (hb >>> 4) & 0xF
|
||||
const loNib = hb & 0xF
|
||||
if (hiNib === 0xF) {
|
||||
// $Fy00 fine slide down, but $F000/$FF00 → fine slide up by $F
|
||||
if (hb === 0xFF || loNib === 0) volAbs = clampV(volAbs + 0xF)
|
||||
else volAbs = clampV(volAbs - loNib)
|
||||
} else if (loNib === 0xF) {
|
||||
volAbs = clampV(volAbs + hiNib) // $xF00 fine slide up
|
||||
if (hb === 0xFF || hb === 0xF0) {
|
||||
volAbs = clampV(volAbs + 0xF) // $FF00 / $F000 quirk
|
||||
} else if (hiNib === 0xF && loNib !== 0) {
|
||||
volAbs = clampV(volAbs - loNib) // $Fy00 fine down
|
||||
} else if (loNib === 0xF && hiNib !== 0) {
|
||||
volAbs = clampV(volAbs + hiNib) // $xF00 fine up
|
||||
} else if (hiNib === 0 && loNib !== 0) {
|
||||
volAbs = clampV(volAbs - loNib * (speed - 1)) // $0y00 coarse down
|
||||
volAbs = clampV(volAbs - loNib * (speed - 1)) // $0y00 coarse down
|
||||
} else if (hiNib !== 0 && loNib === 0) {
|
||||
volAbs = clampV(volAbs + hiNib * (speed - 1)) // $x000 coarse up
|
||||
volAbs = clampV(volAbs + hiNib * (speed - 1)) // $x000 coarse up
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1698,27 +1816,83 @@ function simulateRowState(ptnDat, uptoRow) {
|
||||
}
|
||||
else if (effop === OP_H || effop === OP_U) {
|
||||
const spd = (effarg >>> 8) & 0xFF; const dep = effarg & 0xFF
|
||||
if (spd !== 0) memHU.speed = spd; if (dep !== 0) memHU.depth = dep
|
||||
if (spd !== 0) memHU.speed = spd
|
||||
if (dep !== 0) memHU.depth = dep
|
||||
}
|
||||
else if (effop === OP_R) {
|
||||
const spd = (effarg >>> 8) & 0xFF; const dep = effarg & 0xFF
|
||||
if (spd !== 0) memR.speed = spd; if (dep !== 0) memR.depth = dep
|
||||
if (spd !== 0) memR.speed = spd
|
||||
if (dep !== 0) memR.depth = dep
|
||||
}
|
||||
else if (effop === OP_Y) {
|
||||
const spd = (effarg >>> 8) & 0xFF; const dep = effarg & 0xFF
|
||||
if (spd !== 0) memY.speed = spd; if (dep !== 0) memY.depth = dep
|
||||
if (spd !== 0) memY.speed = spd
|
||||
if (dep !== 0) memY.depth = dep
|
||||
}
|
||||
else if (effop === OP_I) { if (effarg !== 0) memI = effarg }
|
||||
else if (effop === OP_J) { if (effarg !== 0) memJ = effarg }
|
||||
else if (effop === OP_O) { if (effarg !== 0) memO = effarg }
|
||||
else if (effop === OP_Q) { if (effarg !== 0) memQ = effarg }
|
||||
else if (effop === OP_T) { if ((effarg >>> 8) === 0 && effarg !== 0) memTSlide = effarg }
|
||||
else if (effop === OP_S) {
|
||||
const sub = (effarg >>> 12) & 0xF
|
||||
const x = (effarg >>> 8) & 0xF
|
||||
if (sub === 0x1) {
|
||||
glissandoOn = (x !== 0)
|
||||
} else if (sub === 0x2) {
|
||||
pitchOff += FINETUNE_OFFSET[x]
|
||||
} else if (sub === 0x3) {
|
||||
vibratoWave = x & 3
|
||||
} else if (sub === 0x4) {
|
||||
tremoloWave = x & 3
|
||||
} else if (sub === 0x5) {
|
||||
panbrelloWave = x & 3
|
||||
} else if (sub === 0x8) {
|
||||
panAbs = effarg & 0xFF // S$80xx full 8-bit pan SET
|
||||
}
|
||||
// 0x6/0x7/0xB/0xC/0xD/0xE/0xF — out of scope (control flow / per-tick / NNA).
|
||||
}
|
||||
else if (effop === OP_T) {
|
||||
const hi = (effarg >>> 8) & 0xFF
|
||||
if (hi !== 0) {
|
||||
bpm = Math.max(24, Math.min(280, hi + 0x18))
|
||||
} else {
|
||||
const low = effarg & 0xFF
|
||||
if ((low & 0xF0) === 0x00 || (low & 0xF0) === 0x10) memTSlide = low
|
||||
// bpm slide accumulates per-tick in the engine; not modelled at row granularity
|
||||
}
|
||||
}
|
||||
else if (effop === OP_V) {
|
||||
globalVol = (effarg >>> 8) & 0xFF
|
||||
}
|
||||
else if (effop === OP_W) {
|
||||
const raw = (effarg !== 0) ? (memW = effarg) : memW
|
||||
if (raw !== 0) {
|
||||
const hb = (raw >>> 8) & 0xFF
|
||||
const hiNib = (hb >>> 4) & 0xF
|
||||
const loNib = hb & 0xF
|
||||
if (hb === 0xFF || hb === 0xF0) {
|
||||
globalVol = clampG(globalVol + 0xF)
|
||||
} else if (hiNib === 0xF && loNib !== 0) {
|
||||
globalVol = clampG(globalVol - loNib)
|
||||
} else if (loNib === 0xF && hiNib !== 0) {
|
||||
globalVol = clampG(globalVol + hiNib)
|
||||
} else if (hiNib === 0 && loNib !== 0) {
|
||||
globalVol = clampG(globalVol - loNib * (speed - 1))
|
||||
} else if (hiNib !== 0 && loNib === 0) {
|
||||
globalVol = clampG(globalVol + hiNib * (speed - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { lastNote, lastInst, volAbs, panAbs, pitchOff,
|
||||
bpm, speed, globalVol,
|
||||
panLaw, amigaMode, fadeoutCutOnZero,
|
||||
bitcrushDepth, bitcrushSkip, overdriveAmp, clipMode,
|
||||
glissandoOn, vibratoWave, tremoloWave, panbrelloWave,
|
||||
memEF, memG, memHU, memR, memY,
|
||||
memD, memI, memJ, memO, memQ, memTSlide }
|
||||
memD, memI, memJ, memO, memQ, memTSlide, memW }
|
||||
}
|
||||
|
||||
function drawPatternListColumn() {
|
||||
|
||||
13
it2taud.py
13
it2taud.py
@@ -52,7 +52,7 @@ from taud_common import (
|
||||
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
|
||||
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry,
|
||||
)
|
||||
|
||||
@@ -1314,7 +1314,7 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
||||
|
||||
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
|
||||
|
||||
return bytes(sample_bin) + bytes(inst_bin), offsets
|
||||
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio
|
||||
|
||||
|
||||
# ── Pattern builder ───────────────────────────────────────────────────────────
|
||||
@@ -1682,7 +1682,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
'dct': inst.dct,
|
||||
'dca': inst.dca,
|
||||
}
|
||||
sampleinst_raw, _ = build_sample_inst_bin_it(proxy, instr_data_by_slot)
|
||||
sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy, instr_data_by_slot)
|
||||
else:
|
||||
# Samples referenced directly; proxy is samples list (0-based, slot 0 unused)
|
||||
proxy = [None] + list(samples)
|
||||
@@ -1691,7 +1691,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
for i, s in enumerate(samples)
|
||||
if s is not None
|
||||
}
|
||||
sampleinst_raw, _ = build_sample_inst_bin_it(proxy)
|
||||
sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy)
|
||||
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
|
||||
@@ -1723,8 +1723,11 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
pat_bin += build_pattern_it(cg, ch, default_pans[vi], inst_vols,
|
||||
amiga_mode=not h.linear_slides)
|
||||
|
||||
# Rescale TOP_O sample-offset args if samples were globally downsampled.
|
||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||
|
||||
orig_count = len(taud_cue_list) * C
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
||||
f"({orig_count - num_taud_pats} deduplicated)")
|
||||
|
||||
|
||||
23
mod2taud.py
23
mod2taud.py
@@ -39,7 +39,7 @@ from taud_common import (
|
||||
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
|
||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
encode_song_entry,
|
||||
)
|
||||
|
||||
@@ -287,7 +287,17 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
|
||||
return (TOP_O, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == 0xA:
|
||||
return (TOP_NONE, 0, d_arg_to_col(arg), None)
|
||||
# Route Axy via Taud's effect-column D so it can coexist with a Cxx
|
||||
# SET on the same row. (Vol-column slide selectors share the cell with
|
||||
# the SET selector — when both Cxx and Axy land on a trigger row the
|
||||
# vol-col slot can only encode one, and the slide gets dropped, losing
|
||||
# 5 ticks of slide per row.) Resolution-time A00 is already collapsed
|
||||
# to a concrete arg in resolve_pt_recalls; a remaining 0 means truly
|
||||
# no-op (memory was empty), so emit nothing rather than D 00 (which
|
||||
# would recall TSVM's D memory).
|
||||
if arg == 0:
|
||||
return (TOP_NONE, 0, None, None)
|
||||
return (TOP_D, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == 0xB:
|
||||
return (TOP_B, arg & 0xFF, None, None)
|
||||
@@ -536,7 +546,7 @@ def build_sample_inst_bin(samples: list) -> tuple:
|
||||
vprint(f" instrument[{taud_idx}] '{s.name}' ptr={ptr} c2spd={s.c2spd} "
|
||||
f"vol={s.volume} loop=({ls},{le},{'on' if loop_mode else 'off'})")
|
||||
|
||||
return bytes(sample_bin) + bytes(inst_bin), offsets
|
||||
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio
|
||||
|
||||
|
||||
# ── Pattern build ────────────────────────────────────────────────────────────
|
||||
@@ -694,7 +704,7 @@ def assemble_taud(mod: dict) -> bytes:
|
||||
relocate_late_note_delays(patterns, order_list, n_channels, init_speed)
|
||||
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw, _offsets = build_sample_inst_bin(samples)
|
||||
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(samples)
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
|
||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||
@@ -732,9 +742,12 @@ def assemble_taud(mod: dict) -> bytes:
|
||||
pat_bin += build_pattern(grid, ch, default_pan, inst_vols)
|
||||
assert len(pat_bin) == n_patterns * n_channels * PATTERN_BYTES
|
||||
|
||||
# Rescale TOP_O sample-offset args if samples were globally downsampled.
|
||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||
|
||||
vprint(" deduplicating patterns…")
|
||||
orig_count = n_patterns * n_channels
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
||||
f"({orig_count - num_taud_pats} deduplicated)")
|
||||
|
||||
|
||||
11
s3m2taud.py
11
s3m2taud.py
@@ -43,7 +43,7 @@ from taud_common import (
|
||||
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
|
||||
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry,
|
||||
)
|
||||
|
||||
@@ -528,7 +528,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
|
||||
if inst.c2spd > 65535:
|
||||
vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')")
|
||||
|
||||
return bytes(sample_bin) + bytes(inst_bin), offsets
|
||||
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio
|
||||
|
||||
|
||||
def _default_channel_pan(ch_setting: int) -> int:
|
||||
@@ -740,7 +740,7 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
|
||||
# Build sample+instrument bin
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw, _offsets = build_sample_inst_bin(instruments)
|
||||
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(instruments)
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
|
||||
# Compress
|
||||
@@ -787,10 +787,13 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
inst_vols, amiga_mode=not h.linear_slides)
|
||||
assert len(pat_bin) == num_taud_pats * PATTERN_BYTES
|
||||
|
||||
# Rescale TOP_O sample-offset args if samples were globally downsampled.
|
||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||
|
||||
# Deduplicate identical patterns
|
||||
vprint(" deduplicating patterns…")
|
||||
orig_count = num_taud_pats
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique ({orig_count - num_taud_pats} deduplicated)")
|
||||
|
||||
# Cue sheet (using remapped pattern indices)
|
||||
|
||||
@@ -131,6 +131,27 @@ def resample_linear(data: bytes, ratio: float) -> bytes:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def rescale_offset_effects(pat_bin: bytes, ratio: float) -> bytes:
|
||||
"""Scale TOP_O sample-offset args in raw pattern bytes by `ratio`.
|
||||
|
||||
Each row is 8 bytes; byte 5 is the effect opcode, bytes 6-7 are the
|
||||
little-endian 16-bit arg (= byte offset into the sample). When the
|
||||
sample bin overflows and every sample is downsampled globally, the
|
||||
offset commands must shrink the same amount or O-jumps land past
|
||||
the new end of sample.
|
||||
"""
|
||||
if ratio == 1.0 or not pat_bin:
|
||||
return pat_bin
|
||||
out = bytearray(pat_bin)
|
||||
for i in range(0, len(out) - 7, 8):
|
||||
if out[i + 5] == TOP_O:
|
||||
arg = out[i + 6] | (out[i + 7] << 8)
|
||||
arg = max(0, min(0xFFFF, int(arg * ratio + 0.5)))
|
||||
out[i + 6] = arg & 0xFF
|
||||
out[i + 7] = (arg >> 8) & 0xFF
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def encode_cue(patterns12: list, instruction: int) -> bytearray:
|
||||
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers."""
|
||||
pats = list(patterns12) + [0xFFF] * NUM_VOICES
|
||||
|
||||
@@ -2112,8 +2112,9 @@ TODO:
|
||||
[x] 4THSYM.it: pitchbend is wrong, some notes keep playing (loudly!) even if new notes are emitted
|
||||
[x] `*2taud.py`: some notes are emitted with wrong volume-set command. Tested with GSLINGER.mod: on order 0x15 channel 1, mod2taud.py emits volume 8 -- also many of the effects are dropped. Suggested solution: currently all converters write default volume to the voleff when original modules (.mod/.s3m/.it) specify nothing; we should also write nothing and let the engine resolve the value just like other trackers do (also we now have "Instrument Global Volume" on instrument definition unlike the other time). This bug may affecting other formats, not just mod2taud.py, as well
|
||||
[x] nearly_there_.mod: `C#5 SD300 / ... / C-5 SD200 / A#4 / G#4 (at tickspeed 4)`: every `C-5 SD200` (there are four occurances) gets skipped
|
||||
[ ] scale Oxxxx when samples get resampled
|
||||
[ ] implement bitcrusher and overdrive (eff sym '8' and '9')
|
||||
[ ] low-number voleffs are too quiet (needs elaboration and test cases)
|
||||
[x] scale Oxxxx when samples get resampled
|
||||
[x] implement bitcrusher and overdrive (eff sym '8' and '9')
|
||||
|
||||
|
||||
Play Data: play data are series of tracker-like instructions, visualised as:
|
||||
|
||||
@@ -1093,6 +1093,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
//
|
||||
// Effect opcodes follow base-36 digit values (see TAUD_NOTE_EFFECTS.md):
|
||||
// 0x00 : no effect
|
||||
// 0x08, 0x09 : Taud-only voice FX (8 = bitcrusher, 9 = overdrive; see §8/§9).
|
||||
// 0x0A..0x23 : letters A..Z (A=0x0A speed, B=0x0B order jump,
|
||||
// C=0x0C pattern break, D=0x0D vol slide, E=0x0E pitch
|
||||
// down, F=0x0F pitch up, G=0x10 tone porta,
|
||||
@@ -1141,6 +1142,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
private object EffectOp {
|
||||
const val OP_NONE = 0x00
|
||||
const val OP_1 = 0x01
|
||||
const val OP_8 = 0x08
|
||||
const val OP_9 = 0x09
|
||||
const val OP_A = 0x0A
|
||||
const val OP_B = 0x0B
|
||||
const val OP_C = 0x0C
|
||||
@@ -1408,6 +1411,71 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
return y0
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Taud's voice-level overdrive (effect 9) and bitcrusher (effect 8) to a
|
||||
* post-filter sample in [-1, 1]. Call once per output sample, per active voice.
|
||||
*
|
||||
* Order is overdrive → shared clipper → bitcrusher (sample-rate reduce → bit depth quantise).
|
||||
* If neither effect is engaged the input is returned unchanged. See TAUD_NOTE_EFFECTS.md §8/§9.
|
||||
*/
|
||||
private fun applyTaudVoiceFx(voice: Voice, sample: Double): Double {
|
||||
var s = sample
|
||||
val overdriveOn = voice.overdriveAmp > 0
|
||||
// 8..15 collapses to a no-op on TSVM's 8-bit mixdown, but we still allow the bit field to
|
||||
// ride alongside an active sample-skip — only depth in 1..7 actually quantises.
|
||||
val depthQuantises = voice.bitcrusherDepth in 1..7
|
||||
val skipActive = voice.bitcrusherSkip > 0
|
||||
val crushActive = depthQuantises || skipActive
|
||||
|
||||
if (overdriveOn) {
|
||||
s *= (16 + voice.overdriveAmp) / 16.0
|
||||
s = clipSample(s, voice.clipMode)
|
||||
}
|
||||
|
||||
if (crushActive) {
|
||||
if (voice.bitcrusherCounter == 0) {
|
||||
if (depthQuantises) {
|
||||
val levels = (1 shl voice.bitcrusherDepth) - 1
|
||||
val clipped = clipSample(s, voice.clipMode).coerceIn(-1.0, 1.0)
|
||||
val q = kotlin.math.floor((clipped + 1.0) * 0.5 * levels + 0.5)
|
||||
.coerceIn(0.0, levels.toDouble())
|
||||
s = (q / levels) * 2.0 - 1.0
|
||||
}
|
||||
voice.bitcrusherHeld = s
|
||||
} else {
|
||||
s = voice.bitcrusherHeld
|
||||
}
|
||||
if (skipActive) {
|
||||
voice.bitcrusherCounter = (voice.bitcrusherCounter + 1) % (voice.bitcrusherSkip + 1)
|
||||
} else {
|
||||
voice.bitcrusherCounter = 0
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared clipper for effects 8 and 9. Modes: 0 clamp, 1 fold (triangle), 2 wrap (sawtooth).
|
||||
* Inputs outside [-1, 1] are folded/wrapped back into range; well-behaved samples pass through.
|
||||
*/
|
||||
private fun clipSample(x: Double, mode: Int): Double = when (mode and 3) {
|
||||
1 -> {
|
||||
// Ping-pong fold around ±1. Loops handle arbitrary overdrive ratios up to 16.94×
|
||||
// without runaway: each iteration shrinks |v| by 2, so worst-case ~5 passes.
|
||||
var v = x
|
||||
while (v > 1.0) v = 2.0 - v
|
||||
while (v < -1.0) v = -2.0 - v
|
||||
v
|
||||
}
|
||||
2 -> {
|
||||
// Period-2 wrap, mapped so that x = ±1 land on themselves (no DC step at boundary).
|
||||
var v = ((x + 1.0) % 2.0)
|
||||
if (v < 0.0) v += 2.0
|
||||
v - 1.0
|
||||
}
|
||||
else -> x.coerceIn(-1.0, 1.0) // mode 0 (and any reserved value) — clamp
|
||||
}
|
||||
|
||||
/**
|
||||
* IT-style auto-vibrato: returns a 4096-TET pitch delta to add to the
|
||||
* playback note for the current tick, and advances the LFO phase.
|
||||
@@ -1701,6 +1769,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
v.panEnvOn = src.panEnvOn
|
||||
v.pfEnvOn = src.pfEnvOn
|
||||
v.noteFading = src.noteFading
|
||||
// Voice-FX state (effects 8/9): preserve so the NNA-ghosted tail keeps the same timbre.
|
||||
v.clipMode = src.clipMode
|
||||
v.bitcrusherDepth = src.bitcrusherDepth
|
||||
v.bitcrusherSkip = src.bitcrusherSkip
|
||||
v.bitcrusherCounter = src.bitcrusherCounter
|
||||
v.bitcrusherHeld = src.bitcrusherHeld
|
||||
v.overdriveAmp = src.overdriveAmp
|
||||
v.sourceChannel = channel
|
||||
return v
|
||||
}
|
||||
@@ -1849,6 +1924,46 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
ts.amigaMode = (flags and 2) != 0
|
||||
ts.fadeoutCutOnZero = (flags and 4) != 0
|
||||
}
|
||||
EffectOp.OP_8 -> {
|
||||
// 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §8.
|
||||
// x = clipping mode (shared with effect 9): 0 clamp, 1 fold, 2 wrap.
|
||||
// y = bit depth 1..15 (0 disables quantiser; 8..15 no-op on TSVM 8-bit output).
|
||||
// zz = sample-skip count 0..255.
|
||||
// 8 $0000 disables the bitcrusher entirely.
|
||||
// 8 $x000 only updates the shared clipping mode (does not disturb depth/skip).
|
||||
val x = (rawArg ushr 12) and 0xF
|
||||
val y = (rawArg ushr 8) and 0xF
|
||||
val z = rawArg and 0xFF
|
||||
voice.clipMode = x and 3
|
||||
if (rawArg == 0) {
|
||||
voice.bitcrusherDepth = 0
|
||||
voice.bitcrusherSkip = 0
|
||||
voice.bitcrusherCounter = 0
|
||||
} else if (y == 0 && z == 0) {
|
||||
// x000 — clip mode only, leave bitcrusher state alone.
|
||||
} else {
|
||||
voice.bitcrusherDepth = y
|
||||
voice.bitcrusherSkip = z
|
||||
voice.bitcrusherCounter = 0
|
||||
}
|
||||
}
|
||||
EffectOp.OP_9 -> {
|
||||
// 9 $x0zz — Overdrive. See TAUD_NOTE_EFFECTS.md §9.
|
||||
// x = clipping mode (shared with effect 8): 0 clamp, 1 fold, 2 wrap.
|
||||
// zz = amplification index 0..255; gain = (16 + zz) / 16 ⇒ $00=1×, $10=2×, $FF≈16.94×.
|
||||
// 9 $0000 disables the overdrive entirely.
|
||||
// 9 $x000 only updates the shared clipping mode.
|
||||
val x = (rawArg ushr 12) and 0xF
|
||||
val z = rawArg and 0xFF
|
||||
voice.clipMode = x and 3
|
||||
if (rawArg == 0) {
|
||||
voice.overdriveAmp = 0
|
||||
} else if (z == 0) {
|
||||
// x000 — clip mode only.
|
||||
} else {
|
||||
voice.overdriveAmp = z
|
||||
}
|
||||
}
|
||||
EffectOp.OP_A -> {
|
||||
val tr = (rawArg ushr 8) and 0xFF
|
||||
if (tr != 0) playhead.tickRate = tr
|
||||
@@ -1866,11 +1981,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val lo = hi and 0x0F
|
||||
val hin = (hi ushr 4) and 0x0F
|
||||
when {
|
||||
hi == 0xFF -> { voice.rowVolume = (voice.rowVolume + 0xF).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } // DFF quirk: fine up by F
|
||||
hin == 0xF && lo != 0 -> { voice.rowVolume = (voice.rowVolume - lo).coerceAtLeast(0); voice.channelVolume = voice.rowVolume }
|
||||
lo == 0xF && hin != 0 -> { voice.rowVolume = (voice.rowVolume + hin).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume }
|
||||
hin == 0 && lo != 0 -> { voice.slideMode = 5; voice.slideArg = -lo } // slide down per non-first tick
|
||||
lo == 0 && hin != 0 -> { voice.slideMode = 5; voice.slideArg = hin } // slide up per non-first tick
|
||||
hi == 0xFF || hi == 0xF0 -> { voice.rowVolume = (voice.rowVolume + 0xF).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } // $FF00 / $F000 quirk: fine up by F (TAUD_NOTE_EFFECTS.md §D)
|
||||
hin == 0xF && lo != 0 -> { voice.rowVolume = (voice.rowVolume - lo).coerceAtLeast(0); voice.channelVolume = voice.rowVolume } // $Fy00 fine down by y
|
||||
lo == 0xF && hin != 0 -> { voice.rowVolume = (voice.rowVolume + hin).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } // $xF00 fine up by x
|
||||
hin == 0 && lo != 0 -> { voice.slideMode = 5; voice.slideArg = -lo } // $0y00 coarse down per non-first tick
|
||||
lo == 0 && hin != 0 -> { voice.slideMode = 5; voice.slideArg = hin } // $x000 coarse up per non-first tick
|
||||
}
|
||||
}
|
||||
EffectOp.OP_E -> {
|
||||
@@ -2371,8 +2486,6 @@ 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
|
||||
|
||||
@@ -2404,13 +2517,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
for (voice in ts.voices) {
|
||||
if (!voice.active || voice.muted) continue
|
||||
val voiceInst = instruments[voice.instrumentId]
|
||||
val s = applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst))
|
||||
val s = applyTaudVoiceFx(voice, applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst)))
|
||||
val instGv = voiceInst.instGlobalVolume / 255.0
|
||||
// Volume swing bias (random per-trigger, ±randomVolBias of 0..255 units folded into the 0..63 row volume).
|
||||
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.sqrt() * voice.fadeoutVolume * (voice.rowVolume / 63.0).sqrt() *
|
||||
val vol = effEnvVol * voice.fadeoutVolume * (voice.rowVolume / 63.0) *
|
||||
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)
|
||||
@@ -2436,11 +2549,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
for (bg in ts.backgroundVoices) {
|
||||
if (!bg.active || bg.muted) continue
|
||||
val bgInst = instruments[bg.instrumentId]
|
||||
val s = applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst))
|
||||
val s = applyTaudVoiceFx(bg, applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst)))
|
||||
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.sqrt() * bg.fadeoutVolume * (bg.rowVolume / 63.0).sqrt() *
|
||||
val vol = effEnvVol * bg.fadeoutVolume * (bg.rowVolume / 63.0) *
|
||||
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)
|
||||
@@ -2772,6 +2885,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var panColSlideRight = 0
|
||||
var panColSlideLeft = 0
|
||||
|
||||
// Bitcrusher (effect 8) and Overdrive (effect 9) — Taud-only voice FX.
|
||||
// clipMode is shared between both effects: 0=clamp, 1=fold, 2=wrap. See TAUD_NOTE_EFFECTS.md §8/§9.
|
||||
var clipMode = 0
|
||||
// Bitcrusher: depth in 1..15 (0 = quantiser disabled; 8..15 are no-op for TSVM 8-bit output).
|
||||
var bitcrusherDepth = 0
|
||||
// Bitcrusher: sample-skip count. 0 = no skip, N = hold post-FX output for N additional samples.
|
||||
var bitcrusherSkip = 0
|
||||
var bitcrusherCounter = 0 // sample-rate-reduction counter, mod (skip + 1)
|
||||
var bitcrusherHeld = 0.0 // last emitted post-quantisation value, held when skipping
|
||||
// Overdrive: 0 = disabled. Otherwise gain = (16 + amp) / 16, range 17/16..271/16 (≈16.94×).
|
||||
var overdriveAmp = 0
|
||||
|
||||
// Effect-recall memory for this voice.
|
||||
val mem = MemorySlots()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user