diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 74a8bc0..f43a962 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -52,6 +52,8 @@ doubledntick:"\u009D", /* special notes */ keyoff:"\u00A0\u00B1\u00B1\u00A1", notecut:"\u00A4\u00A4\u00A4\u00A4", +notefade:"~~~~", +notefastfade:"\u0084127u".repeat(4), /* special effects */ volset:'',//MIDDOT, @@ -494,7 +496,7 @@ function retuneAllPatterns(newIdx, method) { for (let row = 0; row < ROWS_PER_PAT; row++) { const off = 8 * row const note = ptn[off] | (ptn[off+1] << 8) - if (note === 0x0000 || note === 0x0001 || note === 0x0002 || (note >= 0x0010 && note <= 0x001F)) continue + if (note >= 0x0000 && note <= 0x001F) continue // Use the full absolute pitch as tonic; the modular ops // in _cadTension / _harmonicCost normalise it. tonic = note @@ -504,7 +506,7 @@ function retuneAllPatterns(newIdx, method) { for (let row = 0; row < ROWS_PER_PAT; row++) { const off = 8 * row const note = ptn[off] | (ptn[off+1] << 8) - if (note === 0x0000 || note === 0x0001 || note === 0x0002 || (note >= 0x0010 && note <= 0x001F)) continue + if (note >= 0x0000 && note <= 0x001F) continue const origAbs = note let newAbs if ((method === 'delta' || method === 'cadence' || method === 'harmonic') && prevOrigAbs >= 0) { @@ -589,6 +591,8 @@ function noteToStr(note) { if (note === 0x0000) return sym.middot.repeat(4) if (note === 0x0001) return sym.keyoff if (note === 0x0002) return sym.notecut + if (note === 0x0003) return sym.notefade + if (note === 0x0004) return sym.notefastfade if (note >= 0x0010 && note <= 0x001F) return ('Int' + (note & 0xF).toString(16).toUpperCase()).padEnd(4) const preset = pitchTablePresets[PITCH_PRESET_IDX] if (preset.table.length === 0) return note.hex04() diff --git a/assets/disk0/tvdos/bin/tautfont.kra b/assets/disk0/tvdos/bin/tautfont.kra index 581e41a..0176d87 100644 --- a/assets/disk0/tvdos/bin/tautfont.kra +++ b/assets/disk0/tvdos/bin/tautfont.kra @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:65425d45b5df036aa32b32989693fa887ec28299eab3718b1a0fb0b758a25a5a -size 156979 +oid sha256:36069679e302a9e66fead61a41b6a7efa735038f6d2324f8c0380887d425c36f +size 157134 diff --git a/assets/disk0/tvdos/bin/tautfont_low.chr b/assets/disk0/tvdos/bin/tautfont_low.chr index 5af11c6..66182cd 100644 Binary files a/assets/disk0/tvdos/bin/tautfont_low.chr and b/assets/disk0/tvdos/bin/tautfont_low.chr differ diff --git a/assets/disk0/tvdos/include/typesetter.mjs b/assets/disk0/tvdos/include/typesetter.mjs index f00c736..6051f36 100644 --- a/assets/disk0/tvdos/include/typesetter.mjs +++ b/assets/disk0/tvdos/include/typesetter.mjs @@ -70,7 +70,7 @@ function expandEntities(s) { .replaceAll('&udlr;', '\u008428u\u008429u') .replaceAll('&keyoffsym;', '\u00A0\u00B1\u00B1\u00A1') .replaceAll('¬ecutsym;', '\u00A4\u00A4\u00A4\u00A4') - .replaceAll(' ', '\u007F') + .replaceAll(' ', ' ') .replaceAll('­', '') .replaceAll('<', '<') .replaceAll('>', '>') @@ -105,7 +105,7 @@ function expandEntities(s) { // Width accounting: // - ANSI escapes (`\x1B[...m`) : 0 visible chars // - TSVM unicode escapes (`\u0084..u`) : 1 visible char -// - non-breaking space (\u007F) : 1 visible char (consumed as part of a word) +// - non-breaking space (' ') : 1 visible char (consumed as part of a word) // - soft hyphen (\u00AD) : dropped (not implemented as a break point) // - everything else : 1 visible char function tokenise(line) { diff --git a/midi2taud.py b/midi2taud.py index 59cde72..6d02062 100644 --- a/midi2taud.py +++ b/midi2taud.py @@ -46,6 +46,11 @@ Behaviour (per midi2taud.md): moves to a background ghost on the next trigger and dies over its own release time. Voice budget defaults to 16 columns (--max-voices); overflow releases the oldest pedal-held or soonest-ending note early, not cut. + * SF2 exclusiveClass (gen 57) is honoured on the percussion channel: a new note + in a class chokes any ringing note of the same class (e.g. a closed hi-hat + silences a ringing open hi-hat), matching FluidSynth's kill-by-exclusive-class. + The choke is the new fast note-fade (note 0x0004, ~0.3 s) emitted at the next + same-class onset; without it long percussion tails wash over the whole beat. * Sub-row timing is carried by S $Dx note delays (one row = `--speed` ticks, default 6; one beat = `--rpb` rows, default 4 → 1/24-beat grid). MIDI tempo changes map to T $xx00 set-tempo effects; channel volume / @@ -66,7 +71,7 @@ from taud_common import ( TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY, SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT, PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES, - NOTE_NOP, NOTE_KEYOFF, TAUD_C4, + NOTE_NOP, NOTE_KEYOFF, NOTE_FASTFADE, TAUD_C4, TOP_G, TOP_M, TOP_S, TOP_T, SEL_SET, SEL_FINE, CUE_INST_NOP, CUE_INST_HALT, @@ -220,7 +225,7 @@ def parse_midi(path: str): class Note: __slots__ = ('ch', 'key', 'vel', 'start_ft', 'end_ft', 'inst_key', - 'bend0', 'slot', 'voice', 'drum', 'pedal_ft') + 'bend0', 'slot', 'voice', 'drum', 'pedal_ft', 'excl_cut_ft') def __init__(self, ch, key, vel, start_ft, inst_key, bend0): self.ch = ch self.key = key @@ -233,6 +238,7 @@ class Note: self.voice = -1 self.drum = (inst_key[0] == 'd') self.pedal_ft = None # physical key-up time when only the pedal holds it + self.excl_cut_ft = None # ft at which a same-exclusiveClass note chokes this one class _ChState: @@ -434,6 +440,7 @@ GEN_FILTERFC = 8 # initialFilterFc (absolute cents; default 13500 = GEN_FILTERQ = 9 # initialFilterQ (cB of resonance; default 0) GEN_MODENV2FILT = 11 # modEnvToFilterFc (signed cents at full mod-env) GEN_END_COARSE = 12 +GEN_EXCLUSIVECLASS = 57 # drum mutual-exclusion group (instrument-level; 0 = none) GEN_PAN = 17 GEN_DELAY_MODENV = 25 GEN_ATTACK_MODENV = 26 @@ -502,7 +509,9 @@ class SFZone: 'atten_cb', 'filter_fc', 'filter_q', # modulation envelope (drives pitch and/or filter) + its targets. 'm_delay', 'm_attack', 'm_hold', 'm_decay', 'm_sustain_pc', - 'm_release', 'me2pitch', 'me2filt') + 'm_release', 'me2pitch', 'me2filt', + # exclusiveClass (gen 57): drum mutual-exclusion group (0 = none). + 'excl_class') class SF2: @@ -714,6 +723,11 @@ def parse_sf2(path: str) -> SF2: + pz.get(GEN_RELEASE_MODENV, 0)) z.me2pitch = iz.get(GEN_MODENV2PITCH, 0) + pz.get(GEN_MODENV2PITCH, 0) z.me2filt = iz.get(GEN_MODENV2FILT, 0) + pz.get(GEN_MODENV2FILT, 0) + # exclusiveClass is instrument-level and NON-additive (SF2.04 §8.1.2 #57): + # a new note in class C kills sounding notes of the same class on the same + # channel (FluidSynth fluid_synth_kill_by_exclusive_class). Drum kits use it + # so a closed hi-hat (42) chokes a ringing open hi-hat (46). + z.excl_class = iz.get(GEN_EXCLUSIVECLASS, 0) z.a_start = (s.start + iz.get(GEN_START_OFF, 0) + 32768 * iz.get(GEN_START_COARSE, 0)) z.a_end = (s.end + iz.get(GEN_END_OFF, 0) @@ -811,6 +825,54 @@ def merge_stereo_zones(zones: list, shdrs: list) -> list: return out +def apply_exclusive_class(song, sf, perc_force): + """SF2 exclusiveClass (gen 57): starting a note in class C kills any ringing note + of the same class on the same channel — FluidSynth's + fluid_synth_kill_by_exclusive_class (fluid_synth.c:5453). GM drum kits use it so a + closed hi-hat (key 42) chokes a ringing open hi-hat (key 46); without it the open + hi-hat's multi-second tail washes over the whole beat and buries the other hits. + + Resolve each percussion note's exclusiveClass from the SF2 zone it plays, then within + each (channel, class) serialise the chokes: every note is cut at the next note of the + same class that starts strictly later. `emit_cells` emits a fast note-fade + (NOTE_FASTFADE) at that point and `allocate_voices` keeps the choked voice foreground + until then. Drum channel only — GM melodic presets do not set gen 57, and a hard choke + would fight the melodic key-off/release machinery.""" + zone_cache = {} + def excl_of(n): + if not n.drum: + return 0 + zones = zone_cache.get(n.inst_key) + if zones is None: + res = resolve_preset(sf, n.inst_key, perc_force) + zones = merge_stereo_zones(res[1], sf.shdrs) if res else [] + zone_cache[n.inst_key] = zones + # SF2 zone selection: first zone whose key/velocity rect contains the note. + for z in zones: + if z.keylo <= n.key <= z.keyhi and z.vello <= n.vel <= z.velhi: + return z.excl_class + return 0 + + groups = {} + for n in song.notes: + c = excl_of(n) + if c: + groups.setdefault((n.ch, c), []).append(n) + + n_cut = 0 + for notes in groups.values(): + notes.sort(key=lambda n: n.start_ft) + for i, n in enumerate(notes): + for j in range(i + 1, len(notes)): + if notes[j].start_ft > n.start_ft: # next strictly-later onset chokes n + n.excl_cut_ft = notes[j].start_ft + n_cut += 1 + break + if n_cut: + vprint(f" exclusiveClass: {n_cut} percussion choke(s) across " + f"{len(groups)} group(s)") + + def _rect_of_zone(z: SFZone): """Zone key/vel ranges → Taud (pitch_lo, pitch_hi, vol_lo, vol_hi). Pitch bounds sit on half-semitone boundaries so triggers carrying an @@ -1763,6 +1825,14 @@ def allocate_voices(notes: list, speed: int, max_voices: int) -> int: end_row = srow + 1 # ghost carries the ring else: end_row = max(srow + 1, n.end_ft // speed) # free at key-off row + if n.excl_cut_ft is not None: + # exclusiveClass choke: hold the voice through the choke row so this note stays + # FOREGROUND until then (the fast-fade cell must land on it, not a ghost), and so + # the choking same-class note cannot reuse this column at the choke row. + crow = n.excl_cut_ft // speed + if crow <= srow: + crow = srow + 1 + end_row = max(end_row, crow + 1) n.voice = v v_end[v], v_slot[v], v_note[v] = end_row, n.slot, n if stolen: @@ -1848,6 +1918,32 @@ def emit_cells(song: Song, insts: dict, speed: int, rpb: int, if skipped_offs: vprint(f" info: {skipped_offs} key-off(s) absorbed by same-row retriggers") + # ── Pass 2b: exclusiveClass chokes (fast note-fade) ── + # The choked note holds its voice through the choke row (allocate_voices), so the + # NOTE_FASTFADE lands on it while it is still foreground. The next same-class note + # plays on a different column, so this never collides with a fresh trigger. + for n in notes: + if n.excl_cut_ft is None: + continue + srow = n.start_ft // speed + row, tick = n.excl_cut_ft // speed, n.excl_cut_ft % speed + if row <= srow: # choke within the trigger row → round up one row + row = srow + 1 + tick = 0 + c = cells.get((n.voice, row)) + if c is None: + c = _cell(cells, n.voice, row) + c['note'] = NOTE_FASTFADE + if tick > 0: + c['eff'] = (TOP_S, 0xD000 | (tick << 8)) + c['prio'] = PRIO_DELAY + elif c['note'] in (NOTE_NOP, NOTE_KEYOFF): + c['note'] = NOTE_FASTFADE # choke supersedes a natural key-off + if tick > 0 and c['eff'] is None: + c['eff'] = (TOP_S, 0xD000 | (tick << 8)) + c['prio'] = PRIO_DELAY + # else: row already holds a fresh trigger — that note cuts/NNAs this one anyway. + # ── Pass 3: pitch-bend portamento segments ── # One linear segment per row: the cell carries the exact 4096-TET target # plus G at units/tick sized to land on it by row end (G slides on the @@ -1995,6 +2091,8 @@ def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list, for n in song.notes: n.start_ft -= shift_ft n.end_ft -= shift_ft + if n.excl_cut_ft is not None: + n.excl_cut_ft -= shift_ft eps_units = args.bend_epsilon * 4096.0 / 1200.0 cells, n_voices, total_rows, bpm0 = emit_cells( @@ -2175,6 +2273,9 @@ def main(): sf = parse_sf2(args.soundfont) vprint(f" {len(sf.presets)} preset(s), {len(sf.shdrs)} sample header(s)") + # SF2 exclusiveClass percussion choking (closed hi-hat silences open hi-hat, etc.). + apply_exclusive_class(song, sf, args.perc_force_mapping) + # Presets in first-use order; triggers keyed by the exact (noteVal-with-initial- # bend, vol6) pair the patterns will carry, so layer trimming sees precisely what # the engine matches at runtime. diff --git a/taud_common.py b/taud_common.py index 6a9b707..7f7634c 100644 --- a/taud_common.py +++ b/taud_common.py @@ -99,6 +99,8 @@ SAMPLE_LEN_LIMIT = 65535 NOTE_NOP = 0x0000 NOTE_KEYOFF = 0x0001 NOTE_CUT = 0x0002 +# 0x0003 reserved for Impulse Tracker Note Fade. +NOTE_FASTFADE = 0x0004 # ~0.3 s note-fade (SF2 exclusiveClass choke; fluid_voice_kill_excl) TAUD_C4 = 0x5000 # The audio engine's Middle C # Cue sheet instruction byte (cue offset 30; offset 31 = arg byte for 2-byte forms). diff --git a/terranmon.txt b/terranmon.txt index bf7df0e..11b4c57 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2783,7 +2783,22 @@ TODO: [x] Faithful .sf2 "release segment": Set NNA to 'Note Fade' (incl. drumkits), and make sure Volume Fadeout to have a correct number derived from the SF2 timecent unit (it seems SF2 defines envelope floor as 100 dB; needs check) * DONE 2026-06-14. Floor CONFIRMED 100 dB (sfspec24.txt:1934-1941: releaseVolEnv ramps a constant 100 dB per its timecent value, "until 100dB attenuation were reached"). midi2taud.py now: (a) byte 186 NNA = Note Fade (0b11) for every instrument incl. drum kits (was melodic Key-Lift 0b100000 / drum Continue 0b10); (b) the vol-env no longer carries a release leg — it ENDS at the sustain node and the engine holds there on key-off (AudioAdapter.kt:1697-1701 holds a non-zero terminator, doesn't cut); (c) Volume Fadeout (base bytes 172-173 AND per-patch Ixmp 'x' block) = the release segment, fade_sec = releaseVolEnv·(1000−sus_cb)/1000 (the sustain-level → 100 dB-floor time), fadeStep = 2560/(fade_sec·bpm0) so the linear fade completes in that wall-clock time. Per-patch 'x' now also emits when only the release differs (faithful per-layer release). The engine's Key-Lift feature is unchanged (still used by KeyLiftTest); midi2taud simply stopped emitting it. See _zone_fadeout / _adsr_to_env in midi2taud.py. [x] SF2 filter still sounds way too muffled? - [ ] Drum notes get eaten (E2M1.mid) + [x] Drum notes get eaten (E2M1.mid) + * DONE 2026-06-14. Root cause: midi2taud ignored SF2 exclusiveClass (gen 57). SGM's + STANDARD 1 puts the closed/pedal/open hi-hats (keys 42/44/46) in class 1; without the + choke the open hi-hat's multi-second tail (≈4.9 s) washes over the whole beat and + buries the other hits. FluidSynth kills same-class voices on note-on + (fluid_synth_kill_by_exclusive_class → fluid_voice_kill_excl, a fast −2000-timecent + release ≈ 0.3 s). Fix in two parts: (a) midi2taud.py parses gen 57, and per drum + channel chokes each note at the next same-class onset — emitting the new fast-fade + note 0x0004 (apply_exclusive_class + emit_cells pass 2b; allocate_voices holds the + choked voice foreground until the choke); (b) the engine implements note 0x0004 + (startFastFade): a ~0.3 s note-fade while the sample keeps playing, distinct from + ^^CUT's hard stop. Headless coverage: devtests/ixmp/FastFadeTest. Verified on + E2M1 (open hat no longer rings >0.3 s; crashes, not in a class, still ring out). + [ ] midi2taud: toggleable option for disabling filter for percussions [default: on] + - Anything on bank 127 and 128 (usually asso siated with ch 10) + - GeneralMIDI instruments 113..128 [ ] auto-set optimal-ish Tickspeed and RPB using MIDI Time Signature events and note analysis. Break pattern when Time Signature changes. Time Signature @@ -2808,6 +2823,19 @@ TODO: - Inst > Gen.2 > filter: IT/SF mode toggle - Samples playblobs: only active for actually playing samples - Samples playcursor: true cursors for actually playing samples + [ ] implement note-fade (0x0003) and wire it to it2taud + [ ] taut.js quit with TypeError: Cannot read property 'terminatorIdx' of undefined: + TypeError: Cannot read property 'terminatorIdx' of undefined + at drawEnvelopeCursor (:5312:22) + at drawInstrumentsContents (:4919:41) + at Object.onClick (:4984:17) + at dispatchMouseEvent (:6275:53) + at :6633:13 + at Object.withEvent (:1168:27) + at _tvdosExec$microtone$tvdos$bin$taut$js (:6632:11) + at execApp (:1457:16) + at Object.execute (:893:38) + at :867:31 TODO - list of demo songs that MUST ship with Microtone: * 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes @@ -2833,8 +2861,10 @@ notes are tuned as 4096 Tone-Equal Temperament. Tuning is set per-sample using t Special values: note 0x0000: no-op -note 0x0001: key-off -note 0x0002: note cut +note 0x0001: key-off (====) +note 0x0002: note cut (^^^^) +note 0x0003: note fade (~~~~) +note 0x0004: fast fade (vvvv) note 0x0010..0x001F: Interrupt 0..F (notation: Int0..IntF) — reserved interrupt slots; engine has no default handler. inst 0: no instrument change diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 0083b69..460c1d7 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -150,6 +150,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // 8 ms at 32 kHz — long enough to bury the click, short enough not to read as fade. // Applied on sample end only (preserves attack transients on note start). const val RAMP_OUT_SAMPLES = 256 + // Fast note-fade (note word 0x0004): a quick choke for SF2 exclusiveClass (e.g. a + // closed hi-hat silencing a ringing open hi-hat). FluidSynth's kill uses + // GEN_VOLENVRELEASE = -2000 timecents ≈ 0.315 s (fluid_voice.c:1404); the voice keeps + // playing while fadeoutVolume ramps to zero over this time, then deactivates. + const val FAST_FADE_SEC = 0.3 // Volume-change anti-click ramp: voleff/notefx (volume column, D vol-slides, // tremor, tremolo, retrig vol-mod, fine slides etc.) mutate Voice.rowVolume // and M / N mutate Voice.channelVolume mid-note. The mixer ramps the actual @@ -2198,6 +2203,22 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.rampOutStep = 1.0 / RAMP_OUT_SAMPLES } + /** + * Fast note-fade (note word 0x0004 — SF2 exclusiveClass choke). Starts an immediate + * note-fade that drives fadeoutVolume from 1.0 to 0.0 over [FAST_FADE_SEC] while the + * sample keeps advancing (unlike ^^CUT's hard stop, and far quicker than the + * instrument's own release fadeout). The per-tick fadeout step (subtracted as + * fadeStep/1024 each song tick at bpm·0.4 Hz) is sized to the current tempo so the + * fade lands on [FAST_FADE_SEC] regardless of BPM. Mirrors FluidSynth's + * fluid_voice_kill_excl (a −2000-timecent release). No-op on an inactive voice. + */ + private fun startFastFade(voice: Voice, playhead: Playhead) { + if (!voice.active) return + voice.noteFading = true + val ticks = (FAST_FADE_SEC * playhead.bpm * 0.4).coerceAtLeast(1.0) + voice.activeFadeoutStep = (1024.0 / ticks).roundToInt().coerceIn(1, 0xFFF) + } + /** * Per-sample volume-ramp tick. Smooths [Voice.currentMixVolume] toward * `(rowVolume / 63.0) × (channelVolume / 63.0)` over [VOL_RAMP_SAMPLES] @@ -2906,6 +2927,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.delayedInst = 0; voice.delayedVol = -1 } else { voice.active = false; cutLayerChildren(ts, vi) } // note cut (immediate) } + // Fast note-fade (SF2 exclusiveClass choke): begin a ~0.3 s fade. Honours a + // sub-row S$Dx delay the same way KEY_OFF / note-cut do. + 0x0004 -> { + val dTick = if ((row.effect == EffectOp.OP_S) && ((row.effectArg ushr 12) and 0xF) == 0xD) + (row.effectArg ushr 8) and 0xF else 0 + if (dTick > 0) { + voice.noteDelayTick = dTick; voice.delayedNote = 0x0004 + voice.delayedInst = 0; voice.delayedVol = -1 + } else { + startFastFade(voice, playhead) + } + } + // 0x0003 (IT-style slow note fade, "~~~~") not yet implemented; 0x0005..0x000F reserved. in 0x0003..0x000F -> { /* reserved sentinel range, no engine handler */ } in 0x0010..0x001F -> { /* Int0..IntF: reserved interrupt slots, no engine handler yet */ } else -> { @@ -3397,6 +3431,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { applyKeyLift(voice, instruments[voice.instrumentId]) } 0x0002 -> { voice.active = false; cutLayerChildren(ts, vi) } // delayed note cut + 0x0004 -> startFastFade(voice, playhead) // delayed fast fade else -> { applyDuplicateCheck(ts, vi, voice.delayedInst, voice.delayedNote) maybeSpawnBackgroundForNNA(ts, voice, vi)