midi2taud: now reading modulator stuffs

This commit is contained in:
minjaesong
2026-06-18 11:09:08 +09:00
parent aaf84e0fa4
commit 0014942a5f
4 changed files with 258 additions and 29 deletions

View File

@@ -110,6 +110,7 @@ Behaviour (per midi2taud.md):
import argparse import argparse
import array import array
import bisect import bisect
import copy
import math import math
import os import os
import struct import struct
@@ -754,6 +755,12 @@ GEN_KEYRANGE = 43
GEN_VELRANGE = 44 GEN_VELRANGE = 44
GEN_STARTLOOP_COARSE = 45 GEN_STARTLOOP_COARSE = 45
GEN_INITATTEN = 48 # initialAttenuation (cB; per-zone static gain) 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_ENDLOOP_COARSE = 50
GEN_COARSETUNE = 51 GEN_COARSETUNE = 51
GEN_FINETUNE = 52 GEN_FINETUNE = 52
@@ -806,7 +813,10 @@ class SFZone:
'm_delay', 'm_attack', 'm_hold', 'm_decay', 'm_sustain_pc', '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). # 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: class SF2:
@@ -831,26 +841,164 @@ def _gen_amount(oper: int, raw: int) -> int:
return raw return raw
def _parse_bags(bag_data, gen_data, start_bag, end_bag, terminal_gen): def _parse_bags(bag_data, gen_data, mod_data, start_bag, end_bag, terminal_gen):
"""Resolve bags [start_bag, end_bag) into (global_gens, [zone_gens...]). """Resolve bags [start_bag, end_bag) into (global_gens, global_mods,
Each zone_gens is {oper: amount}; zones lacking the terminal generator [(zone_gens, zone_mods)...]). Each zone_gens is {oper: amount}; each
other than a leading global zone are discarded per the SF2 spec.""" zone_mods is a list of (src, dest, amount, amtsrc, trans) modulator tuples
glob = {} (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 = [] zones = []
n_bags = len(bag_data) // 4 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): for bi in range(start_bag, end_bag):
g0 = struct.unpack_from('<H', bag_data, bi*4)[0] g0 = struct.unpack_from('<H', bag_data, bi*4)[0]
m0 = struct.unpack_from('<H', bag_data, bi*4 + 2)[0]
g1 = (struct.unpack_from('<H', bag_data, (bi+1)*4)[0] g1 = (struct.unpack_from('<H', bag_data, (bi+1)*4)[0]
if bi + 1 < n_bags else len(gen_data) // 4) if bi + 1 < n_bags else n_gen)
m1 = (struct.unpack_from('<H', bag_data, (bi+1)*4 + 2)[0]
if bi + 1 < n_bags else n_mod)
gens = {} gens = {}
for gi in range(g0, min(g1, len(gen_data) // 4)): for gi in range(g0, min(g1, n_gen)):
oper, raw = struct.unpack_from('<HH', gen_data, gi*4) oper, raw = struct.unpack_from('<HH', gen_data, gi*4)
gens[oper] = _gen_amount(oper, raw) gens[oper] = _gen_amount(oper, raw)
mods = []
for mi in range(m0, min(m1, n_mod)):
mods.append(struct.unpack_from('<HHhHH', mod_data, mi*10))
if terminal_gen in gens: if terminal_gen in gens:
zones.append(gens) zones.append((gens, mods))
elif bi == start_bag and not zones: elif bi == start_bag and not zones:
glob = gens glob_g, glob_m = gens, mods
return glob, zones return glob_g, glob_m, zones
# ── SF2 modulators (velocity → filter) ────────────────────────────────────────
# Only the filter-cutoff destinations are modelled: GEN_FILTERFC (8) and
# GEN_MODENV2FILT (11). Other modulator destinations are either bare-generator
# defaults the converter already folds (attenuation, pan), or inaudible for the
# spectral problem these solve. Sources other than note-on velocity (key tracking,
# CC) are skipped — they would need a per-note / per-controller patch axis.
# FluidSynth's default vel→filterFc modulator is hard-disabled (fluid_mod.c:471 "S.
# Christian Collins' mod … return 0"); any soundfont modulator IDENTICAL to it must
# therefore contribute nothing. Identity = (src1, amtsrc, dest, trans).
_DEFAULT_VEL2FILTER_ID = (0x0102, 0x0C02, 8, 0)
def _fluid_convex(x: float) -> 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((127i)/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 = (127vel)/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: def parse_sf2(path: str) -> SF2:
@@ -912,14 +1060,20 @@ def parse_sf2(path: str) -> SF2:
s.rate = 8363 s.rate = 8363
sf.shdrs.append(s) 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'] inst_data, ibag, igen = pdta['inst'], pdta['ibag'], pdta['igen']
n_inst = len(inst_data) // 22 - 1 n_inst = len(inst_data) // 22 - 1
inst_zones = [] inst_zones = []
for i in range(n_inst): for i in range(n_inst):
b0 = struct.unpack_from('<H', inst_data, i*22 + 20)[0] b0 = struct.unpack_from('<H', inst_data, i*22 + 20)[0]
b1 = struct.unpack_from('<H', inst_data, (i+1)*22 + 20)[0] b1 = struct.unpack_from('<H', inst_data, (i+1)*22 + 20)[0]
inst_zones.append(_parse_bags(ibag, igen, b0, b1, GEN_SAMPLEID)) inst_zones.append(_parse_bags(ibag, igen, imod, b0, b1, GEN_SAMPLEID))
# Presets # Presets
phdr, pbag, pgen = pdta['phdr'], pdta['pbag'], pdta['pgen'] phdr, pbag, pgen = pdta['phdr'], pdta['pbag'], pdta['pgen']
@@ -932,20 +1086,20 @@ def parse_sf2(path: str) -> SF2:
errors='replace') errors='replace')
preset, bank, bag0 = struct.unpack_from('<HHH', phdr, off+20) preset, bank, bag0 = struct.unpack_from('<HHH', phdr, off+20)
bag1 = struct.unpack_from('<H', phdr, (i+1)*38 + 24)[0] bag1 = struct.unpack_from('<H', phdr, (i+1)*38 + 24)[0]
pglob, pzones = _parse_bags(pbag, pgen, bag0, bag1, GEN_INSTRUMENT) pglob, pglob_m, pzones = _parse_bags(pbag, pgen, pmod, bag0, bag1, GEN_INSTRUMENT)
zones = [] zones = []
for pz_raw in pzones: for pz_raw, pz_mods in pzones:
pz = dict(pglob); pz.update(pz_raw) pz = dict(pglob); pz.update(pz_raw)
ii = pz[GEN_INSTRUMENT] ii = pz[GEN_INSTRUMENT]
if not (0 <= ii < n_inst): if not (0 <= ii < n_inst):
continue continue
iglob, izones = inst_zones[ii] iglob, iglob_m, izones = inst_zones[ii]
pk = pz.get(GEN_KEYRANGE, 0x7F00) pk = pz.get(GEN_KEYRANGE, 0x7F00)
pv = pz.get(GEN_VELRANGE, 0x7F00) pv = pz.get(GEN_VELRANGE, 0x7F00)
pklo, pkhi = pk & 0xFF, (pk >> 8) & 0xFF pklo, pkhi = pk & 0xFF, (pk >> 8) & 0xFF
pvlo, pvhi = pv & 0xFF, (pv >> 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) iz = dict(iglob); iz.update(iz_raw)
si = iz[GEN_SAMPLEID] si = iz[GEN_SAMPLEID]
if not (0 <= si < len(sf.shdrs)): 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). # 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 # 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). # never collapse the folded vol-env to silence (see _SIGNED_GENS note).
z.atten_cb = max(0, min(1440, iz.get(GEN_INITATTEN, 0) # FluidSynth scales the preset+instrument initialAttenuation by 0.4
+ pz.get(GEN_INITATTEN, 0))) # (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 # Static low-pass filter. initialFilterFc is absolute cents (default
# 13500 ≈ open); initialFilterQ is cB of resonance (default 0). # 13500 ≈ open); initialFilterQ is cB of resonance (default 0).
z.filter_fc = iz.get(GEN_FILTERFC, 13500) + pz.get(GEN_FILTERFC, 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)) + pz.get(GEN_RELEASE_MODENV, 0))
z.me2pitch = iz.get(GEN_MODENV2PITCH, 0) + pz.get(GEN_MODENV2PITCH, 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) 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): # 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 # 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 # 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 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, def build_presets(sf: SF2, slot_keys: list, triggers: dict, perc_force,
registry: dict, max_layers: int) -> dict: registry: dict, max_layers: int) -> dict:
"""For each preset (inst_key), partition its SF2 zones into disjoint layers """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 continue
name, zones = res name, zones = res
zones = merge_stereo_zones(zones, sf.shdrs) 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) layer_items, dropped = _partition_layers(zones, registry, max_layers)
if dropped: if dropped:
vprint(f" warning: '{name}': {dropped} zone(s) exceed the " vprint(f" warning: '{name}': {dropped} zone(s) exceed the "
f"{max_layers}-layer cap and were dropped (raise --max-layers)") f"{max_layers}-layer cap and were dropped (raise --max-layers)")
trig = triggers.get(ik, {})
layers = [ti for items in layer_items layers = [ti for items in layer_items
if (ti := _build_layer_instrument(name, items, trig)) is not None] if (ti := _build_layer_instrument(name, items, trig)) is not None]
if not layers and layer_items: if not layers and layer_items:

View File

@@ -258,7 +258,7 @@ MMIO
Graphics-mode attributes Graphics-mode attributes
0b 00ii 000t (t: disable text, i: interlaced mode) 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: On graphics mode 2, the pattern is:
[L1 L2] [L1 L2]
@@ -365,13 +365,13 @@ MMIO
1024..2047 RW 1024..2047 RW
horizontal scroll offset for scanlines horizontal scroll offset for scanlines
2048..4095 RW 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) 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) (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 65536..131071 RW
Draw Instructions 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 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 (C) Jakim 2010
* SWINGIN1 (rename to Swinging Waste) — for demonstrating Monotone compatibility. * SWINGIN1 (rename to Swinging Waste) — for demonstrating Monotone compatibility.
(C) Phoenix/Hornet 2015 (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: Play Data: play data are series of tracker-like instructions, visualised as:

View File

@@ -2336,15 +2336,22 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
* non-meta playback is byte-identical. * non-meta playback is byte-identical.
* *
* [rowVolOverride] is the V-column-derived trigger volume (or -1). For metas it is the * [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; * velocity used to resolve velocity-conditional layers and the layers' note volume. The
* the normal path ignores it to preserve legacy patch-seed semantics. * 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, private fun triggerMetaOrNote(ts: TrackerState, voice: Voice, vi: Int,
noteVal: Int, instId: Int, rowVolOverride: Int) { noteVal: Int, instId: Int, rowVolOverride: Int) {
releaseLayerChildren(ts, vi) releaseLayerChildren(ts, vi)
val inst = if (instId != 0) instruments[instId] else instruments[voice.instrumentId] val inst = if (instId != 0) instruments[instId] else instruments[voice.instrumentId]
if (!inst.isMeta) { 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.layerMixGain = 1.0
voice.layerRelDetune = 0 voice.layerRelDetune = 0
voice.isLayerChild = false voice.isLayerChild = false
@@ -3034,9 +3041,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} else { } else {
applyDuplicateCheck(ts, vi, row.instrment, row.note) applyDuplicateCheck(ts, vi, row.instrment, row.note)
maybeSpawnBackgroundForNNA(ts, voice, vi) maybeSpawnBackgroundForNNA(ts, voice, vi)
// V-column SET value (selector 0) is the trigger velocity; passed so a // V-column SET value (selector 0) is the trigger velocity; passed so both
// Metainstrument resolves velocity-conditional layers correctly. The // Metainstrument layers AND a non-meta instrument's velocity-split Ixmp
// non-meta path inside triggerMetaOrNote ignores it (legacy semantics). // 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 val trigVol = if (row.volumeEff == 0) row.volume else -1
triggerMetaOrNote(ts, voice, vi, row.note, row.instrment, trigVol) triggerMetaOrNote(ts, voice, vi, row.note, row.instrment, trigVol)
} }

View File

@@ -15,6 +15,9 @@ import java.net.URL
* *
* Note that there is no double-slash after the protocol (or scheme) * 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 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. * @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.
* *