diff --git a/midi2taud.py b/midi2taud.py index e03eddf..22184a7 100644 --- a/midi2taud.py +++ b/midi2taud.py @@ -1526,10 +1526,29 @@ class Patch: 'default_cutoff': cut_s, 'default_resonance': res_s, 'initial_attenuation': att_s} - if filt_s is not None and filt_differs: - d['filter_env'] = filt_s - if pit_s is not None and pit_s != pit_c: - d['pitch_env'] = pit_s + if filt_differs: + if filt_s is not None: + d['filter_env'] = filt_s + elif filt_c is not None: + # This patch has NO filter env but the base/canonical zone DOES. Without an + # explicit override the voice INHERITS the base record's filter env (the + # engine's resolveActiveEnvelopes falls a patch through to the base pf-slots), + # and since the canonical of a shared layer is the most-HIT drum (e.g. the + # kick, ~6kHz me2filt sweep), an unfiltered patch like the open hi-hat gets + # that sweep applied → "incredibly lowpassed". Emit an absent filter-env block + # (PRESENT bit clear) so the engine sets hasFilterEnv=false and, with this + # patch's 'x' cutoff already = off, bypasses the filter entirely. + d['filter_env'] = {'loop': ENV_PF_FILTER, 'sustain': 0, + 'nodes': [(0, 0)] * 25} + if pit_s != pit_c: + if pit_s is not None: + d['pitch_env'] = pit_s + elif pit_c is not None: + # Same inherited-env hazard as the filter leg above: a patch with no pitch + # env must explicitly override the base's, or it inherits the canonical's + # modEnvToPitch sweep (e.g. an 808 tom's pitch drop bleeding onto a co-layered + # drum). Absent pitch-env block: m-bit clear (pitch role) + PRESENT bit clear. + d['pitch_env'] = {'loop': 0, 'sustain': 0, 'nodes': [(0, 0)] * 25} return d diff --git a/taud_common.py b/taud_common.py index e4fd60d..9fbfbc8 100644 --- a/taud_common.py +++ b/taud_common.py @@ -8,6 +8,7 @@ duplicate verbatim. """ import gzip as _gzip +import math import struct import sys @@ -263,22 +264,113 @@ def d_arg_to_col(arg: int): return (SEL_UP, hi) -def resample_linear(data: bytes, ratio: float) -> bytes: - """Resample bytes by ratio (< 1 = downsample) using linear interpolation.""" - if not data: +_SINC_TABLE_CACHE = {} +_KAISER_BETA = 8.0 # ~-70 dB stop-band, near-flat pass-band to the corner + +def _bessel_i0(x: float) -> float: + """Modified Bessel function of the first kind, order 0 (for the Kaiser window).""" + s = 1.0 + t = 1.0 + k = 1 + while True: + t *= (x * x) / (4.0 * k * k) + s += t + if t < 1e-12 * s: + return s + k += 1 + +def _windowed_sinc_table(cutoff: float, half_width: int, phases: int): + """Precompute a polyphase Kaiser-windowed-sinc kernel. + + `cutoff` is the low-pass corner in cycles/input-sample (<= 0.5); for a + downsample it is half the TARGET rate so the kernel doubles as the + anti-alias filter. Each of `phases` sub-sample positions gets a row of + `2*half_width` taps, DC-normalised to unity gain so a constant signal + (e.g. the u8 128 silence bias, or any DC offset) passes through unchanged. + Cached: the same (cutoff, width, phases) recurs across a whole sample pool. + """ + key = (round(cutoff, 6), half_width, phases) + cached = _SINC_TABLE_CACHE.get(key) + if cached is not None: + return cached + n_taps = 2 * half_width + inv_i0 = 1.0 / _bessel_i0(_KAISER_BETA) + table = [] + for p in range(phases): + frac = p / phases + row = [0.0] * n_taps + s = 0.0 + for k in range(n_taps): + t = k - (half_width - 1) # tap index relative to left sample + x = t - frac # distance from the output position + a = 2.0 * cutoff * x + sinc = 1.0 if a == 0.0 else math.sin(math.pi * a) / (math.pi * a) + # Kaiser window over [-half_width, +half_width] + r = x / half_width # -1..1 + win = _bessel_i0(_KAISER_BETA * math.sqrt(max(0.0, 1.0 - r * r))) * inv_i0 + c = sinc * win + row[k] = c + s += c + inv = 1.0 / s if s else 1.0 + table.append([c * inv for c in row]) + _SINC_TABLE_CACHE[key] = table + return table + + +def resample_bandlimited(data: bytes, ratio: float) -> bytes: + """Resample u8 bytes by `ratio` (< 1 = downsample) with a band-limited + windowed-sinc kernel. + + Replaces the old linear interpolator, whose triangular kernel has a sinc^2 + response that rolled off the top octave (~3.4 dB @ 15 kHz, worse near the + source Nyquist) and audibly muffled bright percussion and any SF2 sample + downsampled to the 65535-frame / 32 kHz fit — e.g. the GeneralUser-GS open + hi-hat, ~3.6% spectral-centroid loss on a single pass, compounding on the + sub-32 kHz fit and 8 MB-pool re-resample paths. The windowed sinc stays + flat to the post-resample Nyquist and anti-aliases on the way down (cutoff + follows `ratio`). DC-normalised so the u8 128 bias is preserved; output is + CLAMPED (not wrapped like the old `& 0xFF`) because sinc ringing can + overshoot 0..255. + """ + if not data or ratio == 1.0: return data - n_out = max(1, int(len(data) * ratio)) - out = bytearray(n_out) + n_in = len(data) + n_out = max(1, int(n_in * ratio)) + cutoff = 0.5 * min(1.0, ratio) # anti-alias corner (== target Nyquist) + # More taps when downsampling so the (wider) sinc keeps the same lobe count and the + # stop-band stays steep at the lower corner. 16 half-taps at unity matches a scipy + # polyphase resample to within ~0.3% spectral centroid; capped for the rare 8 MB pass. + half_width = max(8, min(24, round(12.0 / min(1.0, ratio)))) + PHASES = 512 # power of two → cheap phase index + table = _windowed_sinc_table(cutoff, half_width, PHASES) + out = bytearray(n_out) + inv_ratio = 1.0 / ratio + last = n_in - 1 + n_taps = 2 * half_width for i in range(n_out): - src = i / ratio + src = i * inv_ratio i0 = int(src) frac = src - i0 - i1 = min(i0 + 1, len(data) - 1) - v = data[i0] * (1.0 - frac) + data[i1] * frac - out[i] = int(v + 0.5) & 0xFF + row = table[int(frac * PHASES) & (PHASES - 1)] + base = i0 - (half_width - 1) + acc = 0.0 + for k in range(n_taps): + idx = base + k + if idx < 0: + idx = 0 + elif idx > last: + idx = last + acc += data[idx] * row[k] + v = int(acc + 0.5) + out[i] = 0 if v < 0 else (255 if v > 255 else v) return bytes(out) +# Back-compat alias: every *2taud converter imports `resample_linear` by name. +# It is now band-limited (the name is kept to avoid churn across six converters). +resample_linear = resample_bandlimited + + def rescale_offset_effects(pat_bin: bytes, ratio: float) -> bytes: """Scale TOP_O sample-offset args in raw pattern bytes by `ratio`.