From 0014942a5ffd14ead490685095dcfdb636b5612c Mon Sep 17 00:00:00 2001 From: minjaesong Date: Thu, 18 Jun 2026 11:09:08 +0900 Subject: [PATCH] midi2taud: now reading modulator stuffs --- midi2taud.py | 256 ++++++++++++++++-- terranmon.txt | 8 +- .../torvald/tsvm/peripheral/AudioAdapter.kt | 20 +- .../net/torvald/tsvm/peripheral/HttpModem.kt | 3 + 4 files changed, 258 insertions(+), 29 deletions(-) diff --git a/midi2taud.py b/midi2taud.py index 620272c..194df09 100644 --- a/midi2taud.py +++ b/midi2taud.py @@ -110,6 +110,7 @@ Behaviour (per midi2taud.md): import argparse import array import bisect +import copy import math import os import struct @@ -754,6 +755,12 @@ GEN_KEYRANGE = 43 GEN_VELRANGE = 44 GEN_STARTLOOP_COARSE = 45 GEN_INITATTEN = 48 # initialAttenuation (cB; per-zone static gain) +# EMU8k/10k hardware (and therefore FluidSynth) scales the initialAttenuation GENERATOR +# value set at preset and instrument level by 0.4 before using it — fluid_defsfont.c +# EMU_ATTENUATION_FACTOR / case GEN_ATTENUATION. Applying the full SF2 cB makes every +# attenuated instrument ~2.5× too quiet in cB vs FluidSynth (e.g. a 200 cB zone is +# −8 dB in FluidSynth but −20 dB raw), so instrument-to-instrument balance is wrong. +EMU_ATTENUATION_FACTOR = 0.4 GEN_ENDLOOP_COARSE = 50 GEN_COARSETUNE = 51 GEN_FINETUNE = 52 @@ -806,7 +813,10 @@ class SFZone: 'm_delay', 'm_attack', 'm_hold', 'm_decay', 'm_sustain_pc', 'm_release', 'me2pitch', 'me2filt', # exclusiveClass (gen 57): drum mutual-exclusion group (0 = none). - 'excl_class') + 'excl_class', + # SF2 velocity→filter modulators (fc_mods, me2_mods); see + # _zone_velocity_filter_mods / _split_velocity_filter. + 'vel_filter_mods') class SF2: @@ -831,26 +841,164 @@ def _gen_amount(oper: int, raw: int) -> int: return raw -def _parse_bags(bag_data, gen_data, start_bag, end_bag, terminal_gen): - """Resolve bags [start_bag, end_bag) into (global_gens, [zone_gens...]). - Each zone_gens is {oper: amount}; zones lacking the terminal generator - other than a leading global zone are discarded per the SF2 spec.""" - glob = {} +def _parse_bags(bag_data, gen_data, mod_data, start_bag, end_bag, terminal_gen): + """Resolve bags [start_bag, end_bag) into (global_gens, global_mods, + [(zone_gens, zone_mods)...]). Each zone_gens is {oper: amount}; each + zone_mods is a list of (src, dest, amount, amtsrc, trans) modulator tuples + (the 10-byte SFModList record). Zones lacking the terminal generator other + than a leading global zone are discarded per the SF2 spec; a leading bag with + no terminal gen is the global zone (its gens AND mods apply to every zone).""" + glob_g, glob_m = {}, [] zones = [] n_bags = len(bag_data) // 4 + n_gen = len(gen_data) // 4 + n_mod = len(mod_data) // 10 for bi in range(start_bag, end_bag): g0 = struct.unpack_from(' float: + """FluidSynth fluid_convex over a 0..128 index (gentables/fluid_convex.cpp): + convex(i) = 1 + (400/960)·log10(i/127), clamped to [0, 1].""" + if x <= 0.0: + return 0.0 + if x >= 127.0: + return 1.0 + return 1.0 + (400.0 / 960.0) * math.log10(x / 127.0) + + +def _fluid_concave(x: float) -> float: + """FluidSynth fluid_concave: the convex mirror, concave(i) = −(400/960)·log10((127−i)/127).""" + if x <= 0.0: + return 0.0 + if x >= 127.0: + return 1.0 + return -(400.0 / 960.0) * math.log10((127.0 - x) / 127.0) + + +def _mod_src_transform(oper: int, vel: int) -> float: + """Map a velocity-source modulator operator to its normalised value at MIDI + note-on velocity `vel`, matching fluid_mod_transform_source_value (range 128, + val_norm = vel/128, inv_norm = (127−vel)/128). Only velocity sources reach + here. A NONE source (oper 0) returns 1.0 (the amount-source identity).""" + if oper == 0: + return 1.0 + direction = (oper >> 8) & 1 # D: 0 = positive, 1 = negative + polarity = (oper >> 9) & 1 # P: 0 = unipolar, 1 = bipolar + typ = (oper >> 10) & 0x3F # 0 linear, 1 concave, 2 convex, 3 switch + rng = 128.0 + val_norm = vel / rng + inv_norm = 1.0 - 1.0 / rng - val_norm + base = inv_norm if direction else val_norm + if polarity == 0: # unipolar + if typ == 3: # switch + return 1.0 if base >= 0.5 else 0.0 + if typ == 1: + return min(_fluid_concave(rng * base), (rng - 1) / rng) + if typ == 2: + return min(_fluid_convex(rng * base), (rng - 1) / rng) + return base # linear + # bipolar + b = base if base == (rng - 1) / rng else -1.0 + 2.0 * base + if typ == 3: + return 1.0 if b >= 0.0 else -1.0 + if typ == 1: + return min(_fluid_concave(rng * b), (rng - 1) / rng) if b >= 0 else -_fluid_concave(-rng * b) + if typ == 2: + return min(_fluid_convex(rng * b), (rng - 1) / rng) if b >= 0 else -_fluid_convex(-rng * b) + return b + + +def _combine_mods(inst_glob, inst_local, pre_glob, pre_local): + """Combine modulator lists into the effective per-voice set, following + FluidSynth's load order (fluid_voice add-mod modes): instrument global then + local OVERWRITE identical modulators (replace the amount); preset global then + local ADD (sum the amount for identical, else append). Identity is + (src1, dest, amtsrc, trans) — every field except the amount.""" + order = [] + final = {} + def ident(m): # m = (src, dest, amt, amtsrc, trans) + return (m[0], m[1], m[3], m[4]) + def overwrite(m): + k = ident(m) + if k in final: + final[k] = (m[0], m[1], m[2], m[3], m[4]) + else: + final[k] = m; order.append(k) + def add(m): + k = ident(m) + if k in final: + p = final[k] + final[k] = (p[0], p[1], p[2] + m[2], p[3], p[4]) + else: + final[k] = m; order.append(k) + for m in inst_glob: overwrite(m) + for m in inst_local: overwrite(m) + for m in pre_glob: add(m) + for m in pre_local: add(m) + return [final[k] for k in order] + + +def _zone_velocity_filter_mods(inst_glob, inst_local, pre_glob, pre_local): + """Resolve a zone's velocity→filter modulators into (fc_mods, me2_mods), + each a list of (amount, src1, amtsrc) evaluated later per velocity. Keeps only + note-on-velocity-sourced modulators targeting initialFilterFc (8) and + modEnvToFilterFc (11), drops zero-amount and default-vel2filter-identity ones.""" + fc_mods, me2_mods = [], [] + for (src, dest, amt, amtsrc, trans) in _combine_mods(inst_glob, inst_local, + pre_glob, pre_local): + if dest not in (8, 11) or amt == 0: + continue + if (src, amtsrc, dest, trans) == _DEFAULT_VEL2FILTER_ID: + continue # FluidSynth disables this identity + if (src & 0x80) or (src & 0x7F) != 2: + continue # only note-on velocity (GC index 2) + # amount source must be NONE or velocity to evaluate statically; skip CC/other. + if amtsrc != 0 and ((amtsrc & 0x80) or (amtsrc & 0x7F) != 2): + continue + (fc_mods if dest == 8 else me2_mods).append((amt, src, amtsrc)) + return (fc_mods, me2_mods) + + +def _eval_zone_filter_at(z: 'SFZone', vel: int): + """(filter_fc, me2filt) for zone `z` at MIDI velocity `vel`, with its + velocity→filter modulators folded onto the base generators.""" + fc_mods, me2_mods = z.vel_filter_mods + fc = z.filter_fc + sum(amt * _mod_src_transform(src, vel) + * _mod_src_transform(asrc, vel) for amt, src, asrc in fc_mods) + me2 = z.me2filt + sum(amt * _mod_src_transform(src, vel) + * _mod_src_transform(asrc, vel) for amt, src, asrc in me2_mods) + return fc, me2 def parse_sf2(path: str) -> SF2: @@ -912,14 +1060,20 @@ def parse_sf2(path: str) -> SF2: s.rate = 8363 sf.shdrs.append(s) - # Instruments: index → (global_gens, [zone_gens]) + # Modulators (imod/pmod) are optional per chunk presence; default to empty so + # banks without them parse unchanged. Used for SF2 velocity→filter modulators + # (see _zone_velocity_filter_mods) that FluidSynth applies but bare generators miss. + imod = pdta.get('imod', b'') + pmod = pdta.get('pmod', b'') + + # Instruments: index → (global_gens, global_mods, [(zone_gens, zone_mods)]) inst_data, ibag, igen = pdta['inst'], pdta['ibag'], pdta['igen'] n_inst = len(inst_data) // 22 - 1 inst_zones = [] for i in range(n_inst): b0 = struct.unpack_from(' SF2: errors='replace') preset, bank, bag0 = struct.unpack_from('> 8) & 0xFF pvlo, pvhi = pv & 0xFF, (pv >> 8) & 0xFF - for iz_raw in izones: + for iz_raw, iz_mods in izones: iz = dict(iglob); iz.update(iz_raw) si = iz[GEN_SAMPLEID] if not (0 <= si < len(sf.shdrs)): @@ -996,8 +1150,12 @@ def parse_sf2(path: str) -> SF2: # initialAttenuation: per-zone static gain in cB (preset adds to inst). # Clamped to the SF2 spec range [0, 1440] so any out-of-range value can # never collapse the folded vol-env to silence (see _SIGNED_GENS note). - z.atten_cb = max(0, min(1440, iz.get(GEN_INITATTEN, 0) - + pz.get(GEN_INITATTEN, 0))) + # FluidSynth scales the preset+instrument initialAttenuation by 0.4 + # (EMU_ATTENUATION_FACTOR) before clamping to the SF2 [0, 1440] cB range; + # match it so instrument volumes line up with FluidSynth's rendering. + z.atten_cb = max(0, min(1440, EMU_ATTENUATION_FACTOR + * (iz.get(GEN_INITATTEN, 0) + + pz.get(GEN_INITATTEN, 0)))) # Static low-pass filter. initialFilterFc is absolute cents (default # 13500 ≈ open); initialFilterQ is cB of resonance (default 0). z.filter_fc = iz.get(GEN_FILTERFC, 13500) + pz.get(GEN_FILTERFC, 0) @@ -1018,6 +1176,12 @@ 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) + # SF2 velocity→filter modulators (FluidSynth applies them; bare generators + # do not). Folded per-velocity in _split_velocity_filter so each velocity band + # gets the cutoff / mod-env-to-filter FluidSynth would compute (the GeneralUser-GS + # "muffled" fix). Combined inst(overwrite)+preset(add) per SF2.04 §9.5. + z.vel_filter_mods = _zone_velocity_filter_mods(iglob_m, iz_mods, + pglob_m, pz_mods) # 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 @@ -1450,6 +1614,57 @@ def _build_layer_instrument(name: str, items: list, trig: dict): return ti +def _v6_to_midi_velocity(v6: int) -> int: + """Representative MIDI note-on velocity (1..127) for a Taud volume level v6 + (0..63). Inverse of the converter's round(vel·63/127) trigger mapping.""" + return max(1, min(127, round(v6 * 127.0 / 63.0))) + + +# Cap on velocity bands a single filtered zone is split into. Bounds patch growth +# so a velocity-rich song cannot blow a sustained instrument past the engine's +# ~192-patch/instrument cap (which would silently drop bands → wrong-sample fallback, +# the same failure mode as the meta velocity-patch bug). 12 bands ≈ 5-v6 (~550-cent) +# brightness steps — finer than perceptible on a sustained note. +MAX_VEL_BANDS = 12 + + +def _split_velocity_filter(zones: list, trig: dict) -> list: + """Expand zones carrying velocity→filter modulators into per-velocity-band + copies so each band gets the cutoff / mod-env-to-filter FluidSynth computes at + that velocity. Bands tile the distinct trigger volumes (v6) actually played for + this instrument — so only velocities the song uses become patches (the rest are + pruned anyway) — grouped into at most [MAX_VEL_BANDS] contiguous buckets. Zones + without velocity→filter modulators pass through untouched.""" + v6s = sorted({v6 for (_nv, v6) in trig}) + out = [] + for z in zones: + fc_mods, me2_mods = z.vel_filter_mods + if not fc_mods and not me2_mods: + out.append(z) + continue + zlo6 = round(z.vello * 63 / 127) + zhi6 = round(z.velhi * 63 / 127) + played = [v6 for v6 in v6s if zlo6 <= v6 <= zhi6] + if not played: # nothing played in this zone's vel range + out.append(z) + continue + gsize = max(1, (len(played) + MAX_VEL_BANDS - 1) // MAX_VEL_BANDS) + for i in range(0, len(played), gsize): + grp = played[i:i + gsize] + lo6, hi6 = grp[0], grp[-1] + sub = copy.copy(z) # __slots__ shallow copy + # MIDI velocity sub-range whose round(·63/127) maps back into this v6 bucket, + # clipped to the zone's own velrange so adjacent bands stay disjoint. + mlo = max(z.vello, math.ceil((lo6 - 0.5) * 127.0 / 63.0)) + mhi = min(z.velhi, math.floor((hi6 + 0.5) * 127.0 / 63.0 - 1e-9)) + if mlo > mhi: + mlo = mhi = max(z.vello, min(z.velhi, _v6_to_midi_velocity((lo6 + hi6) // 2))) + sub.vello, sub.velhi = mlo, mhi + sub.filter_fc, sub.me2filt = _eval_zone_filter_at(z, _v6_to_midi_velocity((lo6 + hi6) // 2)) + out.append(sub) + return out + + def build_presets(sf: SF2, slot_keys: list, triggers: dict, perc_force, registry: dict, max_layers: int) -> dict: """For each preset (inst_key), partition its SF2 zones into disjoint layers @@ -1466,11 +1681,12 @@ def build_presets(sf: SF2, slot_keys: list, triggers: dict, perc_force, continue name, zones = res zones = merge_stereo_zones(zones, sf.shdrs) + trig = triggers.get(ik, {}) + zones = _split_velocity_filter(zones, trig) layer_items, dropped = _partition_layers(zones, registry, max_layers) if dropped: vprint(f" warning: '{name}': {dropped} zone(s) exceed the " f"{max_layers}-layer cap and were dropped (raise --max-layers)") - trig = triggers.get(ik, {}) layers = [ti for items in layer_items if (ti := _build_layer_instrument(name, items, trig)) is not None] if not layers and layer_items: diff --git a/terranmon.txt b/terranmon.txt index 5fc209a..9e78e3a 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -258,7 +258,7 @@ MMIO Graphics-mode attributes 0b 00ii 000t (t: disable text, i: interlaced mode) - When interlace is enabled (i > 0), the layers are overlaind in the checkerboard pattern, allowing blending. + When interlace is enabled (i > 0), the layers are overlaid in the checkerboard pattern, allowing blending. On graphics mode 2, the pattern is: [L1 L2] @@ -365,13 +365,13 @@ MMIO 1024..2047 RW horizontal scroll offset for scanlines 2048..4095 RW - !!NEW!! Font ROM Mapping Area + Font ROM Mapping Area Format is always 8x16 pixels, 1bpp ROM format (so that it would be YY_CHR-Compatible) (designer's note: it's still useful to divide the char rom to two halves, lower half being characters ROM and upper half being symbols ROM) 65536..131071 RW Draw Instructions -Text-mode-font-ROM is immutable and does not belong to VRAM +Font ROMs are immutable (must be uploaded as a whole) and does not belong to VRAM Even in the text mode framebuffer is still being drawn onto the screen, and the texts are drawn on top of it -------------------------------------------------------------------------------- @@ -2913,6 +2913,8 @@ TODO - list of demo songs that MUST ship with Microtone: (C) Jakim 2010 * SWINGIN1 (rename to Swinging Waste) — for demonstrating Monotone compatibility. (C) Phoenix/Hornet 2015 + * Keep On Rolling — for MIDI and SoundFont capability. + (C) Trolley Trev Play Data: play data are series of tracker-like instructions, visualised as: diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 3c1ad24..fb7d770 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -2336,15 +2336,22 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { * non-meta playback is byte-identical. * * [rowVolOverride] is the V-column-derived trigger volume (or -1). For metas it is the - * velocity used to resolve velocity-conditional layers and the layers' note volume; - * the normal path ignores it to preserve legacy patch-seed semantics. + * velocity used to resolve velocity-conditional layers and the layers' note volume. The + * normal path also forwards it so a non-meta instrument's velocity-split Ixmp patches + * resolve on the ACTUAL trigger velocity, not the default-note-volume seed: without this + * every trigger probes [resolvePatch] at the byte-196 default (≈63), so any velocity tile + * the song never hits at full velocity falls through to the instrument's base/canonical + * sample. For an SF2 drum kit (one non-meta instrument, base = most-hit patch = usually a + * hi-hat) that means a kick/snare never struck at max velocity audibly plays the hi-hat. + * When there is no V column (rowVolOverride == -1) the seed is unchanged, so classic + * tracker content — which has no velocity-split Ixmp patches — is byte-identical. */ private fun triggerMetaOrNote(ts: TrackerState, voice: Voice, vi: Int, noteVal: Int, instId: Int, rowVolOverride: Int) { releaseLayerChildren(ts, vi) val inst = if (instId != 0) instruments[instId] else instruments[voice.instrumentId] if (!inst.isMeta) { - triggerNote(voice, noteVal, instId, -1) // legacy path, unchanged + triggerNote(voice, noteVal, instId, rowVolOverride) // honour V-column velocity for patch lookup voice.layerMixGain = 1.0 voice.layerRelDetune = 0 voice.isLayerChild = false @@ -3034,9 +3041,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } else { applyDuplicateCheck(ts, vi, row.instrment, row.note) maybeSpawnBackgroundForNNA(ts, voice, vi) - // V-column SET value (selector 0) is the trigger velocity; passed so a - // Metainstrument resolves velocity-conditional layers correctly. The - // non-meta path inside triggerMetaOrNote ignores it (legacy semantics). + // V-column SET value (selector 0) is the trigger velocity; passed so both + // Metainstrument layers AND a non-meta instrument's velocity-split Ixmp + // patches resolve on the real velocity (see triggerMetaOrNote). -1 when the + // row carries no SET volume, leaving the default-note-volume seed in place. val trigVol = if (row.volumeEff == 0) row.volume else -1 triggerMetaOrNote(ts, voice, vi, row.note, row.instrment, trigVol) } diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/HttpModem.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/HttpModem.kt index 9ee41ad..a3dcf7d 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/HttpModem.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/HttpModem.kt @@ -15,6 +15,9 @@ import java.net.URL * * Note that there is no double-slash after the protocol (or scheme) * + * Supported HTTP request methods: + * - GET + * * @param artificialDelayBlockSize How many bytes should be retrieved in a single block-read * @param artificialDelayWaitTime Delay in milliseconds between the block-reads. Put positive value in milliseconds to add a delay, zero or negative value to NOT add one. *