diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index 18b201b..58c0e81 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -446,5 +446,25 @@ # component builds on renders differently. The pyproject floors rise to # >=0.6.9. # +# Moved to 0c0824e on 2026-10-29, for the sampler's filter, modulation +# envelope and LFOs (audiodsp#337, merged as this commit), which `soundfont` +# now hands every region's own to. CI installs audiodsp from this pin and +# nothing else, so without it play() refuses the new keywords. Named by +# commit; no release carries it yet. What else moved beneath the +# components, by `git log v0.6.9..0c0824e -- src`: audiofeatures is new; +# audiograph becomes an audio sample the pump pulls, gains a clipping +# two-pole filter, an overdrive, an exponential VCA curve and a TANH node, +# and pages its tables; audiobiquad takes buffer_size; and the CircuitPython +# modules are resynced with 11.0.0-alpha.2 (46f78dc). That resync is the one +# that renders differently: the Mixer now scales a voice as +# (x * level) >> 15, rounding toward minus infinity, where it scaled by +# level / 32767 and truncated. Level 1.0 is the exact wire it was, and a +# level below unity moves by up to one LSB. Every effect test that changed +# result was bisected to 46f78dc (all pass at 1877bb9, the commit before). +# MultiTapDelay and AirSpace drop the mono 1 - 2^-15 dry they used to +# dodge the float mixer, the Harmonizer tests model the new arithmetic, and +# AirSpace's get_buffer digests are re-read. A sampler voice that sets none +# of the new keywords renders the bytes it did. +# # -- ref is what CI installs (git+https://...@) -v0.6.9 4de7609268d0ade9aaf652e232a0ffb1bae9e8ec +0c0824e3966998613d8c5314cefb1d752bc70693 0c0824e3966998613d8c5314cefb1d752bc70693 diff --git a/lib/audioeffects/airspace.py b/lib/audioeffects/airspace.py index e6f94f9..07b3390 100644 --- a/lib/audioeffects/airspace.py +++ b/lib/audioeffects/airspace.py @@ -439,15 +439,10 @@ def _restore(self, record): self, (positions, patch_index, (list(kept[0]),))) def _dry_level(self, mix): - """The dry voice's level, min(1, 2 - Mix). At one channel unity is - handed as 1 - 2^-15, which every Mixer this class meets passes - through exactly: CircuitPython's own `audiomixer` turns 1.0 into - 32768 / 32767 and puts the samples nearest the rails one LSB hot. - Two channels keep 1.0 (`MultiTapDelay._dry_level` has why).""" - level = 1.0 if mix <= 1.0 else 2.0 - mix - if level >= 1.0 and self._channel_count == 1: - return 1.0 - 2.0 ** -15 - return level + """The dry voice's level, min(1, 2 - Mix). Unity is Q15 32768, which + a Mixer voice passes through exactly on every lane + (`MultiTapDelay._dry_level` has why).""" + return 1.0 if mix <= 1.0 else 2.0 - mix def _levels(self): mix = self._value(MIX_I) diff --git a/lib/audioeffects/deesser.py b/lib/audioeffects/deesser.py index 8933b48..ed338e2 100644 --- a/lib/audioeffects/deesser.py +++ b/lib/audioeffects/deesser.py @@ -41,14 +41,15 @@ # 902's claim, and this class's, is that the same setting works on a # whisper and a belt. Measured over the Sensitivity macro's whole travel on # one spectral balance at four absolute levels, the reductions agree within -# 0.316 dB across -6, -20 and -40 dBFS - well inside the trait's 1 dB - -# and within 0.366 dB at both HF-only patches. Add a -55 dBFS take and -# the spread reaches 2.643 dB, because at that level the `1 - Range` -# dry voice is a handful of LSB and the 16-bit mixer truncates it: the same -# blend with the gain cell muted already spreads 0.643 to 1.253 dB with no -# detector in the sum. So dossier D1 is demonstrated to -40 dBFS and +# 0.213 dB across -6, -20 and -40 dBFS - well inside the trait's 1 dB - +# and within 0.090 dB at both HF-only patches. Add a -55 dBFS take and +# the spread reaches 1.176 dB at Sensitivity 45 dB, because at that level +# the `1 - Range` dry voice is a handful of LSB and the 16-bit mixer +# rounds it. With the -55 take, patch 3 holds D1 (0.583 dB) and patch 4 +# misses it (1.321 dB). So dossier D1 is demonstrated to -40 dBFS and # disconfirmed at -55; the cause is the palette's int16 path, not the -# relative threshold. +# relative threshold. (CircuitPython 10's float mixer, which truncated +# toward zero, spread 2.643 dB and missed at both HF-only patches.) # * The 902's release is a constant 925 dB/sec - a straight *line* in # dB. `audiodynamics` releases with a one-pole in linear gain, so the rate # can be tuned to the 902's and the shape cannot. `Release` is therefore in diff --git a/lib/audioeffects/distortion.py b/lib/audioeffects/distortion.py index 9130d96..b9b2e93 100644 --- a/lib/audioeffects/distortion.py +++ b/lib/audioeffects/distortion.py @@ -58,7 +58,10 @@ # side, through `Biquad.mix`, and costs no node. Priced sum 1.849 / 3.323 ms # + glue 0.0 -> **P4 <= 35 %, S3 <= 63 %**, re-derived 2026-09-17 from a # graph three nodes smaller than the 2.245 / 4.128 (43 % / 78 %) one the -# board ran. At 22.05 kHz the same graph runs the shaper at x8 and the block +# board ran. Since the integer mixer the two levels are set on two x1 +# `Waveshaper` level nodes, which round, with the mixer voices at an exact 1 +# (`LEVEL_LINE`); those two nodes are not in the priced sum, and no board +# has timed them. At 22.05 kHz the same graph runs the shaper at x8 and the block # is 11.61 ms, so it is 2.868 / 5.126 ms -> **25 % / 45 %**: fewer node # sums per second than 48 kHz, not more. Constructor `oversample=2` is # 26 % / 47 %; it is not a patch. @@ -401,6 +404,16 @@ def _f32(value): ) # END CURVE DS1_ASYM V_SCALE = 10.0 + +#: The Rat's leg levels are set in float, not in the leg mixer. They are +#: below unity and sit ahead of up to 67 dB of gain, and an integer mixer +#: voice floors `x * level`, which is half an LSB of DC on every sample that +#: is not a multiple of the step: at Distortion max a 4 LSB input stood at +#: -0.070 of the output's full scale. A `Waveshaper` at x1 on this two-point +#: straight line, at a `post_gain` of `level * LEVEL_GAIN`, rounds to the +#: nearest LSB instead, so it adds no bias; at a level of 1 it is exact. +LEVEL_LINE = array("h", (-32767, 32767)) +LEVEL_GAIN = 32768.0 / 32767.0 GBW_HZ = 320000.0 R_GAIN = 100000.0 R_BODY_STOCK = 47.0 @@ -709,6 +722,14 @@ def _hp(self, frequency): channel_count=self._channel_count) return self._own(node) + def _level_node(self, level): + node = audioshaper.Waveshaper( + sample_rate=self._sample_rate, + channel_count=self._channel_count, + curve=LEVEL_LINE, oversample=1, pre_gain=1.0, + post_gain=_f32(level * LEVEL_GAIN), mix=1.0, bias=0.0) + return self._own(node) + def _wire(self, voice, source): """Remember what a mixer voice will play. Nothing is attached until `_connect`, because `Mixer.play` pulls a chunk there and then: a @@ -790,11 +811,15 @@ def _install_graph(self, scoop): self._leg_treble = self._hp(1539.0) self._leg_bass.play(legs.tap(0)) self._leg_treble.play(legs.tap(1)) + self._bass_level = self._level_node(1.0) + self._treble_level = self._level_node(0.0) + self._bass_level.play(self._leg_bass) + self._treble_level.play(self._leg_treble) self._leg_mix = self._mixer_node(2) self._leg_mix.voice[0].level = 1.0 - self._leg_mix.voice[1].level = 0.0 - self._wire(self._leg_mix.voice[0], self._leg_bass) - self._wire(self._leg_mix.voice[1], self._leg_treble) + self._leg_mix.voice[1].level = 1.0 + self._wire(self._leg_mix.voice[0], self._bass_level) + self._wire(self._leg_mix.voice[1], self._treble_level) self._gain = self._leg_mix self._gbw = self._lp(1000.0) self._gbw.play(self._gain) @@ -815,6 +840,8 @@ def _install_graph(self, scoop): self._leg_mix = None self._leg_bass = None self._leg_treble = None + self._bass_level = None + self._treble_level = None if not scoop: self._out_hp = None @@ -1120,11 +1147,12 @@ def _push_filter(self): # crossfaded at a/(1+a), so its dry side is the follower. voice 1 is # b*HP(1539) at b/total. The two levels sum to 1 exactly, which is # where the 6 dB of clipper headroom over the old dry-minus-lowpass - # pair comes from. + # pair comes from. They are set on the level nodes, which round, + # and the mixer voices stay at an exact 1 (`LEVEL_LINE`). self._leg_bass.mix = a / (1.0 + a) self._leg_treble.mix = 1.0 - self._leg_mix.voice[0].level = (1.0 + a) / total - self._leg_mix.voice[1].level = b / total + self._bass_level.set(post_gain=_f32((1.0 + a) / total * LEVEL_GAIN)) + self._treble_level.set(post_gain=_f32(b / total * LEVEL_GAIN)) boost = _component.db_to_gain(self.macro(BOOST)) self._shaper.set( pre_gain=total * boost / V_SCALE, diff --git a/lib/audioeffects/multitapdelay.py b/lib/audioeffects/multitapdelay.py index fb6d449..985484a 100644 --- a/lib/audioeffects/multitapdelay.py +++ b/lib/audioeffects/multitapdelay.py @@ -637,29 +637,19 @@ def _refresh(self): self._route() def _dry_level(self, mix): - """The dry voice's level, min(1, 2 - Mix). At one channel unity is - handed as 1 - 2^-15, which every Mixer this class meets passes - through exactly: CircuitPython's stock `audiomixer` turns a level - of 1.0 into 32768 / 32767 and puts the samples nearest the rails - one LSB hot, while 32767 / 32767 is 1. Two channels cannot do that - (the stock Mixer's pan law hands the left lane 32767 x level >> 15, - so 1 - 2^-15 is one LSB cold there), so they keep 1.0, which every - interpreter at AUDIODSP_PIN passes through exactly on both lanes.""" - level = min(1.0, 2.0 - mix) - if level >= 1.0 and self._channel_count == 1: - return 1.0 - 2.0 ** -15 - return level + """The dry voice's level, min(1, 2 - Mix). A Mixer voice scales by + (sample x Q15 level) >> 15 and a level of 1.0 is Q15 32768, so unity + passes every sample through exactly, mono and stereo, on every + interpreter at AUDIODSP_PIN.""" + return min(1.0, 2.0 - mix) def _route(self): """Where the output port points, and what the tap node reads. Mix 0 hands the input adapter out through the port (`_bypass`, by way of `_through`), a width-1 MidSide that passes the source byte - for byte, so it is a - wire on every interpreter: CircuitPython's stock `audiomixer` - scales a voice at level 1.0 by 32768 / 32767, which puts every - sample at |value| >= 32736 one LSB out (audiodsp's own Mixer passes - unity through). The source is read through the adapter at every + for byte, so it is a wire on every interpreter without a Mixer + voice in the way. The source is read through the adapter at every Mix, so whatever part of a buffer it holds plays next either way. Nothing is pulled through the graph while the port is on the adapter, so nothing here may prime from it then: a `play()` would diff --git a/lib/audioeffects/overdrive.py b/lib/audioeffects/overdrive.py index dfd3675..a167cba 100644 --- a/lib/audioeffects/overdrive.py +++ b/lib/audioeffects/overdrive.py @@ -66,6 +66,16 @@ # `audiocore.get_buffer`, charges it too, and renders byte for byte what a # desktop does. # +# **The offset is taken off ahead of the pole too.** On its own the pole's +# float32 state stalls short of zero on a large constant, and at 44.1 kHz +# shipped patch 6 came to rest at -1 LSB in silence once the Level stage +# became an integer mixer. A second `Waveshaper` at x1 on a straight line +# (`OFFSET_LINE`) subtracts the offset exactly, so the pole only ever holds +# a few LSB: silence after a burst of noise or a sine is **0 at every +# shipped patch at 48, 44.1, 22.05 and 96 kHz**. It moves patch 6's render +# by at most 4 LSB and no other patch's at all, adds no latency, and is one +# more x1 node on the clip branch that no board has timed. +# # **Latency: zero samples** at every macro setting. The dry tap is a copy. # `oversample` puts group delay on the *clip branch only*: 3.3 samples at # x4, 3.9 at x8, which is **0.069 ms** at 48 kHz x4, **0.081 ms** at 48 kHz @@ -474,6 +484,19 @@ def shipped_oversample(sample_rate): DC_BLOCK_HZ = 30.0 DC_BLOCK_Q = 0.5 +#: The offset is also taken off *before* the pole, so the pole holds a few +#: LSB rather than thousands. Its float32 recursion cannot step a large +#: constant down by less than one rounding of it: fed shipped patch 6's +#: offset at 44.1 kHz it came to rest at -0.7 LSB rather than on zero, which +#: the Level stage's integer mixer floors to a standing -1 LSB for ever, +#: and a burst of signal leaves it somewhere else in that band. The node +#: that takes it off is a second `Waveshaper` at x1 on a straight line: a +#: two-point curve at a `post_gain` of 32768 / 32767 plays `x + bias` back +#: exactly, to the LSB, for every int16 `x`, so it adds no rounding to the +#: clip branch and no latency. +OFFSET_LINE = array("h", (-32767, 32767)) +OFFSET_GAIN = 32768.0 / 32767.0 + #: How many blocks `_settle_dc` will pull before it gives up. The pole needs #: about 3000 frames at 48 kHz; 64 blocks is 16384 frames, five times that. DC_SETTLE_BLOCKS = 64 @@ -637,6 +660,10 @@ def _build(self, drive=DRIVE_DEFAULT, tone=64.0 / 127.0, self._dc = audiobiquad.Biquad( mode=audiobiquad.HIGH_PASS, frequency=DC_BLOCK_HZ, Q=DC_BLOCK_Q, sample_rate=rate, channel_count=channels) + self._trim = audioshaper.Waveshaper( + sample_rate=rate, channel_count=channels, curve=OFFSET_LINE, + oversample=1, mix=1.0, pre_gain=1.0, post_gain=OFFSET_GAIN, + bias=0.0, hysteresis=0.0) # The clip branch is wired to the split *after* the offset has been # settled, below: a `play()` takes a block from whatever it is # handed, so re-pointing the shaper at `_c4` once the offset is @@ -646,7 +673,8 @@ def _build(self, drive=DRIVE_DEFAULT, tone=64.0 / 127.0, self._hp.play(tap1) self._c4.play(self._hp) self._shaper.play(self._c4) - self._dc.play(self._shaper) + self._trim.play(self._shaper) + self._dc.play(self._trim) circuit = audiomixer.Mixer(voice_count=2, **self._pcm()) self._lp = audiobiquad.Biquad( @@ -672,6 +700,7 @@ def _build(self, drive=DRIVE_DEFAULT, tone=64.0 / 127.0, self._own(self._lp) self._own(circuit, reset=False) self._own(self._dc) + self._own(self._trim) self._own(self._shaper) self._own(self._c4) self._own(self._hp) @@ -699,6 +728,7 @@ def _build(self, drive=DRIVE_DEFAULT, tone=64.0 / 127.0, # zero one - and charged once nothing will touch it again. if self._clip_bias: self._shaper.set(post_gain=0.0) + self._trim.set(bias=0.0) _component.open_level_gates( circuit, [circuit.voice[0], circuit.voice[1]], self._silence) _component.open_level_gates(out, [out.voice[0]], self._silence) @@ -725,6 +755,11 @@ def _shaper_dc(self): return (CURVE[index] * (1.0 - rise) + CURVE[index + 1] * rise) * self._post_gain + def _offset_bias(self): + """The cancel's `bias`: the offset to the nearest LSB, negated, + in the curve's units.""" + return -round(self._shaper_dc()) / 32768.0 + def _settle_dc(self): """Charge the output capacitor before the first real block. @@ -748,6 +783,7 @@ def _settle_dc(self): blocks back, so it runs all `DC_SETTLE_BLOCKS` and the pole lands where the early stop leaves it, to the byte. """ + self._trim.set(bias=self._offset_bias()) if not (self._clip_bias or self._dc_charged): return False self._dc_charged = bool(self._clip_bias) @@ -899,13 +935,15 @@ def _rearm(self, level): self._hp.play(self._tap1) self._c4.play(self._hp) self._shaper.play(self._c4) - self._dc.play(self._shaper) + self._trim.play(self._shaper) + self._dc.play(self._trim) self._lp.play(self._circuit) self._circuit.voice[0].level = 1.0 self._circuit.voice[1].level = 1.0 self._out.voice[0].level = level if self._clip_bias: self._shaper.set(post_gain=0.0) + self._trim.set(bias=0.0) _component.open_level_gates( self._circuit, [self._circuit.voice[0], self._circuit.voice[1]], self._silence) diff --git a/lib/audioeffects/stereowidener.py b/lib/audioeffects/stereowidener.py index 8aa8adb..59f0fbd 100644 --- a/lib/audioeffects/stereowidener.py +++ b/lib/audioeffects/stereowidener.py @@ -16,10 +16,11 @@ try: import audioecho + import audiomath import audiomixer import audioroute except ImportError: # pragma: no cover - a stock board - audioecho = audiomixer = audioroute = None + audioecho = audiomath = audiomixer = audioroute = None #: The Delay span, in ms, swept by ratio. @@ -33,10 +34,23 @@ #: 3/16, exact in every float format, a pitch bend of x1.1875 while it moves. SLEW = 0.1875 -#: The hardest a Mixer voice pans: the right (or left) scale is then exactly -#: 0, where a panning of -1 or +1 leaves one LSB of it. +#: The hardest a Mixer voice pans. The integer mixer clamps any panning to +#: this, and its far channel keeps a scale of 1/32768: a voice at level 1 +#: then hands that channel `x >> 15`, which is -1 for every negative sample +#: and 0 otherwise. `SIGN` and `NEGATE` build the +1 that cancels it. HARD = 32767.0 / 32768.0 +#: The two modulators the cancelling branch multiplies by, in +#: `audiomath.Multiply`'s Q15: 1 turns a sample into the far channel's +#: `x >> 15` (-1 below zero, else 0), and -32768 turns that into +1 or 0, +#: exactly. +SIGN = 1 +NEGATE = -32768 + +#: Frames in each modulator table: more than one, so the native mixer and +#: Multiply never take a table shorter than the word they consume. +TABLE_FRAMES = 16 + #: The frames a mixer pull hands out, and so the frames each voice and each #: Splitter tap take per pull. BLOCK = 256 @@ -143,7 +157,7 @@ class StereoWidener(_component.Component): VERSION = '0.1.0' TIER = _component.AUDIODSP - REQUIRES = ("audioecho", "audioroute") + REQUIRES = ("audioecho", "audiomath", "audioroute") CAPABILITIES = () LATENCY_SAMPLES = 0 @@ -235,17 +249,41 @@ def _build_graph(self): self._hush = self._own(audioroute.MidSide( width=1.0, sample_rate=rate, channel_count=2), reset=False) self._in = self._own(audioroute.Port(self._hush), reset=False) - self._split = self._own(audioroute.Splitter(self._in, taps=2), + # Tap 0 is the lead, tap 1 what the line delays, tap 2 the + # cancelling branch. + self._split = self._own(audioroute.Splitter(self._in, taps=3), reset=False) + # The cancelling branch: +1 on each channel whose sample is below + # zero, else 0. A hard-panned voice at level 1 leaves -1 in its far + # channel on exactly those samples (`HARD`), and this puts it back. + self._sign = self._own(audiomath.Multiply( + self._split.tap(2), self._table(SIGN), mix=1.0, + sample_rate=rate, channel_count=2), reset=False) + self._negate = self._own(audiomath.Multiply( + self._sign, self._table(NEGATE), mix=1.0, + sample_rate=rate, channel_count=2), reset=False) + self._fix = self._own(audioroute.Splitter(self._negate, taps=2), + reset=False) + # What the line delays: the right channel, and an exact zero in the + # left, so the copy's hard pan has nothing negative to leave there. + self._feed = self._own(audiomixer.Mixer( + voice_count=2, buffer_size=BLOCK * 2 * 4, channel_count=2, + bits_per_sample=16, samples_signed=True, sample_rate=rate), + reset=False) + # The Mixer's own reset never reaches it, for the reason `_tail` + # gives. + self._fed = self._own(audioroute.MidSide( + self._feed, width=1.0, sample_rate=rate, channel_count=2), + reset=False) self._line = self._own(audioecho.FeedbackDelay( sample_rate=rate, channel_count=2, max_delay_ms=LINE_MS, delay_frames=1, feedback=0.0, mix=2.0, damping_hz=0.0, cut_hz=0.0, delay_slew=SLEW), reset=self._clear) - self._line.play(self._split.tap(1)) + self._line.play(self._fed) # Not reset by the walk: neither voice holds a block between two # pulls (`_wire`), so there is nothing of its own to clear. self._mixer = self._own(audiomixer.Mixer( - voice_count=2, buffer_size=BLOCK * 2 * 4, channel_count=2, + voice_count=3, buffer_size=BLOCK * 2 * 4, channel_count=2, bits_per_sample=16, samples_signed=True, sample_rate=rate), reset=False) # The Haas output: a width-1 MidSide, byte for byte, whose reset @@ -264,6 +302,14 @@ def _build_graph(self): self._puller = None self._output = self._ms + def _table(self, value): + """A looping stereo modulator that is `value` in both channels.""" + table = audiocore.RawSample( + array("h", [value] * (2 * TABLE_FRAMES)), + sample_rate=self._sample_rate, channel_count=2) + self._tables = getattr(self, "_tables", ()) + (table,) + return table + def _wire(self): """Play the voices, the first time the output reaches the mixer. @@ -277,12 +323,20 @@ def _wire(self): block read at a Delay the line has since left. The level gates are opened first, as `_component.open_level_gates` opens them.""" voices = self._mixer.voice + feeds = self._feed.voice self._levels() - for voice in voices: + # The line's voices: the source panned hard right, and the + # cancelling branch's left, which together leave the left at 0. + feeds[0].level, feeds[0].panning = 1.0, HARD + feeds[1].level, feeds[1].panning = 1.0, -HARD + for voice in voices + feeds: voice.play(self._silence, loop=True) self._pull() + feeds[0].play(self._split.tap(1)) + feeds[1].play(self._fix.tap(0)) voices[0].play(self._split.tap(0)) voices[1].play(self._line) + voices[2].play(self._fix.tap(1)) self._pull() self._in.play(self._ms) self._clear() @@ -350,6 +404,11 @@ def _levels(self): voices = self._mixer.voice voices[0].level, voices[0].panning = lead voices[1].level, voices[1].panning = copy + # The lead at unity panned hard left leaves -1 in the right on + # every negative sample; the cancelling branch, panned hard right, + # adds it back. Anywhere else the lead's right is a real level. + voices[2].level = 1.0 if lead == (1.0, -HARD) else 0.0 + voices[2].panning = HARD # -- applying ------------------------------------------------------ diff --git a/lib/audioinstruments/README.md b/lib/audioinstruments/README.md index 736b6ec..ac99c87 100644 --- a/lib/audioinstruments/README.md +++ b/lib/audioinstruments/README.md @@ -269,7 +269,12 @@ the error names all three. Patch n is Program Change n in bank 0, named from the bank, and Bank Select (CC 0 and 32) reaches the other melodic banks on the next program change. Four macros: Volume, Attack, Release (each scaling the bank's own envelope) and Tune (a semitone either way). -The module docstring says how attenuation is scaled and why. +Each note plays its region's low-pass filter, modulation envelope and two +LFOs, with the SoundFont 2.04 default modulators over them: velocity +darkens a filtered region, the mod wheel and channel pressure add vibrato, +and Control Change 7 and 11 set the level. A bank's own modulators are not +applied. The module docstring has the details, and how attenuation is +scaled and why. Each instrument has 32 voices of its own (`voices=` to `create()` asks for another number, and `inst.voices` says how many it has). For a General MIDI diff --git a/lib/audioinstruments/_sf2.py b/lib/audioinstruments/_sf2.py index fb3adfa..c125c1a 100644 --- a/lib/audioinstruments/_sf2.py +++ b/lib/audioinstruments/_sf2.py @@ -22,8 +22,19 @@ *added* to the instrument's values, except key and velocity ranges, which intersect, and the generators the specification forbids at preset level (sample addresses, sampleModes, exclusiveClass, overridingRootKey, keynum, -velocity), which are ignored there. Modulators are not implemented; the -reader only records whether a bank carries any (`has_modulators`). +velocity), which are ignored there. A bank's own modulators are not read; +the reader only records whether a bank carries any (`has_modulators`). The +specification's default modulators belong to whoever plays the region: +`audioinstruments.soundfont` applies them. + +Besides the volume envelope, a region carries the rest of what a bank says +about a note: the low-pass (`filter_cutoff` in absolute cents and +`filter_q` in centibels, as written, and `filter_hz` and `resonance` in Hz +and dB), the modulation envelope (`mod_delay` ... `mod_release` in seconds +and `mod_sustain` as a level, with `mod_to_pitch` and `mod_to_cutoff` in +cents), the vibrato LFO (`vibrato_delay`, `vibrato_rate` in Hz, +`vibrato_to_pitch` in cents) and the modulation LFO (`lfo_delay`, +`lfo_rate`, `lfo_to_pitch`, `lfo_to_cutoff`, and `lfo_to_volume` in dB). Samples stay on disk. The reader keeps the `smpl` chunk's offset and `read_sample()` reads a region's frames when asked, which is what a board @@ -48,10 +59,28 @@ GEN_STARTLOOP_OFFSET = 2 GEN_ENDLOOP_OFFSET = 3 GEN_START_COARSE = 4 +GEN_MOD_LFO_TO_PITCH = 5 +GEN_VIB_LFO_TO_PITCH = 6 +GEN_MOD_ENV_TO_PITCH = 7 GEN_FILTER_FC = 8 GEN_FILTER_Q = 9 +GEN_MOD_LFO_TO_FILTER_FC = 10 +GEN_MOD_ENV_TO_FILTER_FC = 11 GEN_END_COARSE = 12 +GEN_MOD_LFO_TO_VOLUME = 13 GEN_PAN = 17 +GEN_DELAY_MOD_LFO = 21 +GEN_FREQ_MOD_LFO = 22 +GEN_DELAY_VIB_LFO = 23 +GEN_FREQ_VIB_LFO = 24 +GEN_DELAY_MOD_ENV = 25 +GEN_ATTACK_MOD_ENV = 26 +GEN_HOLD_MOD_ENV = 27 +GEN_DECAY_MOD_ENV = 28 +GEN_SUSTAIN_MOD_ENV = 29 +GEN_RELEASE_MOD_ENV = 30 +GEN_KEYNUM_TO_MOD_ENV_HOLD = 31 +GEN_KEYNUM_TO_MOD_ENV_DECAY = 32 GEN_DELAY_VOL_ENV = 33 GEN_ATTACK_VOL_ENV = 34 GEN_HOLD_VOL_ENV = 35 @@ -87,6 +116,13 @@ # 8.1.3). Everything absent here defaults to zero. _DEFAULTS = { GEN_FILTER_FC: 13500, + GEN_DELAY_MOD_LFO: -12000, + GEN_DELAY_VIB_LFO: -12000, + GEN_DELAY_MOD_ENV: -12000, + GEN_ATTACK_MOD_ENV: -12000, + GEN_HOLD_MOD_ENV: -12000, + GEN_DECAY_MOD_ENV: -12000, + GEN_RELEASE_MOD_ENV: -12000, GEN_DELAY_VOL_ENV: -12000, GEN_ATTACK_VOL_ENV: -12000, GEN_HOLD_VOL_ENV: -12000, @@ -226,6 +262,23 @@ def __init__(self): self.filter_cutoff = 13500 self.filter_q = 0 self.filter_hz = 0.0 + self.resonance = 0.0 + self.mod_delay = 0.0 + self.mod_attack = 0.0 + self.mod_hold = 0.0 + self.mod_decay = 0.0 + self.mod_sustain = 1.0 + self.mod_release = 0.0 + self.mod_to_pitch = 0 + self.mod_to_cutoff = 0 + self.vibrato_delay = 0.0 + self.vibrato_rate = 8.176 + self.vibrato_to_pitch = 0 + self.lfo_delay = 0.0 + self.lfo_rate = 8.176 + self.lfo_to_pitch = 0 + self.lfo_to_cutoff = 0 + self.lfo_to_volume = 0.0 self.generators = None def __repr__(self): @@ -663,4 +716,30 @@ def gen(oper): r.filter_cutoff = gen(GEN_FILTER_FC) r.filter_q = gen(GEN_FILTER_Q) r.filter_hz = cents_to_hz(r.filter_cutoff) + r.resonance = max(0, r.filter_q) / 10.0 + + # The modulation envelope: the volume envelope's timing, its sustain + # in tenths of a percent below full scale (section 8.1.3, #29). + r.mod_delay = timecents_to_seconds(gen(GEN_DELAY_MOD_ENV)) + r.mod_attack = timecents_to_seconds(gen(GEN_ATTACK_MOD_ENV)) + r.mod_hold = timecents_to_seconds( + gen(GEN_HOLD_MOD_ENV) + + (60 - played) * gen(GEN_KEYNUM_TO_MOD_ENV_HOLD)) + r.mod_decay = timecents_to_seconds( + gen(GEN_DECAY_MOD_ENV) + + (60 - played) * gen(GEN_KEYNUM_TO_MOD_ENV_DECAY)) + sustain = gen(GEN_SUSTAIN_MOD_ENV) + r.mod_sustain = 1.0 - min(1000, max(0, sustain)) / 1000.0 + r.mod_release = timecents_to_seconds(gen(GEN_RELEASE_MOD_ENV)) + r.mod_to_pitch = gen(GEN_MOD_ENV_TO_PITCH) + r.mod_to_cutoff = gen(GEN_MOD_ENV_TO_FILTER_FC) + + r.vibrato_delay = timecents_to_seconds(gen(GEN_DELAY_VIB_LFO)) + r.vibrato_rate = cents_to_hz(gen(GEN_FREQ_VIB_LFO)) + r.vibrato_to_pitch = gen(GEN_VIB_LFO_TO_PITCH) + r.lfo_delay = timecents_to_seconds(gen(GEN_DELAY_MOD_LFO)) + r.lfo_rate = cents_to_hz(gen(GEN_FREQ_MOD_LFO)) + r.lfo_to_pitch = gen(GEN_MOD_LFO_TO_PITCH) + r.lfo_to_cutoff = gen(GEN_MOD_LFO_TO_FILTER_FC) + r.lfo_to_volume = gen(GEN_MOD_LFO_TO_VOLUME) / 10.0 return r diff --git a/lib/audioinstruments/soundfont.py b/lib/audioinstruments/soundfont.py index e8d774f..90a0b24 100644 --- a/lib/audioinstruments/soundfont.py +++ b/lib/audioinstruments/soundfont.py @@ -34,6 +34,35 @@ has `SampleBuffer`, the arena's buffers sit outside the collected heap, so a `gc.collect()` doesn't read megabytes of samples looking for pointers. +What a bank says, and what it doesn't. Each note plays with its region's +volume envelope, low-pass filter (initial cutoff and resonance), modulation +envelope (to pitch and cutoff), vibrato LFO and modulation LFO (to pitch, +cutoff and volume), and the default modulators of the SoundFont 2.04 +specification (section 8.4) over them: + +- Note-on velocity to attenuation: the level goes as the square of the + velocity, which is how this instrument has always played velocity and + close to the specification's concave curve. +- Note-on velocity to cutoff: down to 2400 cents (two octaves) lower at + velocity 0, linearly, so a softer note is darker. This applies only to a + region whose own filter is below its open 13500 cents: a region that + doesn't use its filter stays unfiltered at every velocity, and costs + nothing for it. The specification applies it to every region, which makes a + soft note on an unfiltered region darker too (5 kHz at velocity 0); here + such a region plays as the bank wrote it, on the sampler's cheaper path. +- Mod wheel (Control Change 1) to vibrato depth: up to 50 cents at full + wheel, added to the region's own vibrato. Channel pressure does the same. +- Volume (Control Change 7) and Expression (Control Change 11) to + attenuation, each by the same concave curve: the level goes as the square + of each. They start at 100 and 127, General MIDI's reset values, so a + channel that never sends Control Change 7 plays about 4 dB under full. +- Pan (Control Change 10) and the pitch wheel, as described below. + +A bank's own modulators (its `pmod` and `imod` records), including any that +would override these defaults, are not applied; the reader only notes +whether a bank has any. Reverb and chorus sends (Control Change 91 and 93) +have no effect here. + Attenuation. A region's `initialAttenuation` is applied scaled by `ATTENUATION_SCALE` (0.4): the banks in circulation were voiced on players that scale it so, and played per the specification's centibels they come out @@ -46,8 +75,9 @@ added to each region's own pan, so a stereo pair keeps its spread around the new centre, clamped at the edges. Moving it re-pans the voices already sounding, with the engine's ramp, and Control Change 10 on the instrument -drives it, as it does Bank Select; the other GM controllers are the -host's. A program change puts every macro back to the patch's defaults, +drives it, as it does Bank Select. Control Change 1, 7 and 11 and channel +pressure work as the default modulators above; the other GM controllers are +the host's. A program change puts every macro back to the patch's defaults, Pan at centre. Thirty-two engine voices, one per region a note resolves to (a stereo pair @@ -147,7 +177,7 @@ def ticks_diff(a, b): import audioinstruments._sf2 as _sf2 from audioinstruments._support import ( EVENT_NOTE_ON, EVENT_NOTE_OFF, EVENT_PARAMETER, EVENT_PITCH_BEND, - EVENT_CONTROL_CHANGE, key_of, + EVENT_CONTROL_CHANGE, EVENT_CHANNEL_PRESSURE, key_of, ) from audioinstruments._support import Instrument @@ -177,10 +207,28 @@ def _slab(nbytes): BEND_RANGE = 2.0 _CC_BANK_MSB = 0 -_CC_BANK_LSB = 32 +_CC_MOD_WHEEL = 1 +_CC_VOLUME = 7 _CC_PAN = 10 +_CC_EXPRESSION = 11 +_CC_BANK_LSB = 32 _CC_SUSTAIN = 64 +#: The default modulators' amounts (SoundFont 2.04, section 8.4): velocity +#: to cutoff, in cents at velocity 0; the mod wheel and channel pressure to +#: vibrato depth, in cents at full scale. +VELOCITY_TO_CUTOFF = -2400 +MOD_WHEEL_TO_VIBRATO = 50.0 +PRESSURE_TO_VIBRATO = 50.0 + +#: A filter at or above this many absolute cents, with no resonance, is +#: open: the specification's default, 19.9 kHz. +OPEN_CUTOFF = 13500 + +#: General MIDI's reset values for Control Change 7 and 11. +DEFAULT_VOLUME = 100 +DEFAULT_EXPRESSION = 127 + def _bipolar(value0): """A bipolar macro's -1..1 from the 0..1 the event carries: the centre @@ -192,6 +240,24 @@ def _bipolar(value0): return (midi - 64.0) / 64.0 +def _concave(value): + """A 0..127 controller as the gain of the specification's concave, + negative, unipolar curve over 96 dB: the square of its fraction.""" + return (value / 127.0) ** 2 + + +def _voice_filter(region, velocity): + """(cutoff in Hz or None, resonance in dB) for `region` at `velocity` + (0..127), with the default velocity-to-cutoff modulator.""" + cents = region.filter_cutoff + moved = region.mod_to_cutoff or region.lfo_to_cutoff + if cents >= OPEN_CUTOFF and region.resonance <= 0 and not moved: + return None, 0.0 + if cents < OPEN_CUTOFF: + cents += VELOCITY_TO_CUTOFF * (1.0 - velocity / 127.0) + return _sf2.cents_to_hz(cents), region.resonance + + def _panned(region_pan, offset): pan = region_pan + offset if pan < -1.0: @@ -622,10 +688,11 @@ def __init__(self, sample_rate, channel_count=2, self.channel_count = channel_count self.shared = shared #: `slots[i]` is engine voice i: None when free, else `[key, serial, - #: state, exclusive_class, level, pitch, pan, player]`, where `state` - #: is `HELD`, `RELEASING` or `FADED`, `level`, `pitch` and `pan` are - #: the region's own, before the live macros, and `player` is the - #: instrument's that holds it. + #: state, exclusive_class, level, pitch, pan, player, vibrato]`, + #: where `state` is `HELD`, `RELEASING` or `FADED`, `level`, `pitch`, + #: `pan` and `vibrato` (cents) are the region's own, before the live + #: macros and controllers, and `player` is the instrument's that + #: holds it. self.slots = [None] * int(voice_count) self._queue = None # the writes waiting, while deferred self._pending = {} # voice index -> playing, as the queue leaves it @@ -836,6 +903,22 @@ def pan(self): def pan(self, value): self._send(setattr, (self._voice, "pan", value)) + @property + def cutoff(self): + return self._voice.cutoff + + @cutoff.setter + def cutoff(self, value): + self._send(setattr, (self._voice, "cutoff", value)) + + @property + def vibrato_depth(self): + return self._voice.vibrato_depth + + @vibrato_depth.setter + def vibrato_depth(self, value): + self._send(setattr, (self._voice, "vibrato_depth", value)) + # --- the player ------------------------------------------------------------ @@ -865,6 +948,10 @@ def __init__(self, bank, voices, default_bank=0): self.tune = 1.0 self.bend = 1.0 self.pan = 0.0 + self.volume_cc = {} # channel -> Control Change 7 + self.expression = {} # channel -> Control Change 11 + self.wheel = {} # channel -> vibrato cents from the mod wheel + self.pressure = {} # channel -> vibrato cents from pressure # -- preset selection --------------------------------------------------- @@ -952,6 +1039,10 @@ def note_on(self, key, pitch, velocity, detune): serial = pool.serial voices = self.pool.voice detune_ratio = 2.0 ** (detune / 12.0) if detune else 1.0 + channel = key[0] + controllers = self._controllers(channel) + extra_vibrato = (self.wheel.get(channel, 0.0) + + self.pressure.get(channel, 0.0)) # Choke before any of this note's regions start, so a stereo pair # in one class does not stop its own other half. for region in regions: @@ -968,21 +1059,37 @@ def note_on(self, key, pitch, velocity, detune): vel = region.velocity / 127.0 level = (region.attenuation ** ATTENUATION_SCALE) * vel * vel ratio = region.pitch_ratio * detune_ratio + cutoff, resonance = _voice_filter(region, vel * 127.0) i = self._allocate() voices[i].play( pcm, rate=region.sample_rate, pitch=ratio * self.tune * self.bend, loop_start=loop_start, loop_end=loop_end, loop_mode=mode, - level=level * self.volume * pool.gain, + level=level * controllers * self.volume * pool.gain, pan=_panned(region.pan, self.pan), delay=region.delay, attack=region.attack * self.attack_scale, hold=region.hold, decay=region.decay, sustain=region.sustain, - release=region.release * self.release_scale) + release=region.release * self.release_scale, + cutoff=cutoff, resonance=resonance, + mod_delay=region.mod_delay, mod_attack=region.mod_attack, + mod_hold=region.mod_hold, mod_decay=region.mod_decay, + mod_sustain=region.mod_sustain, + mod_release=region.mod_release * self.release_scale, + mod_to_pitch=region.mod_to_pitch, + mod_to_cutoff=region.mod_to_cutoff if cutoff else 0, + vibrato_delay=region.vibrato_delay, + vibrato_rate=region.vibrato_rate, + vibrato_depth=region.vibrato_to_pitch + extra_vibrato, + lfo_delay=region.lfo_delay, lfo_rate=region.lfo_rate, + lfo_to_pitch=region.lfo_to_pitch, + lfo_to_cutoff=region.lfo_to_cutoff if cutoff else 0, + lfo_to_volume=region.lfo_to_volume) self.bank.hold(voices[i], region.sample.index) self.slots[i] = [key, serial, HELD, region.exclusive_class, - level, ratio, region.pan, self] + level, ratio, region.pan, self, + region.vibrato_to_pitch] indices.append(i) self.held[key] = indices if pool.reserve: @@ -1036,11 +1143,29 @@ def sustain(self, channel, down): for key in [k for k in self.sustained if k[0] == channel]: self.release(key) - def relevel(self): + def _controllers(self, channel): + """The gain Control Change 7 and 11 give `channel`.""" + return (_concave(self.volume_cc.get(channel, DEFAULT_VOLUME)) + * _concave(self.expression.get(channel, DEFAULT_EXPRESSION))) + + def relevel(self, channel=None): voices = self.pool.voice for i, slot in enumerate(self.slots): if slot is not None and slot[7] is self and slot[2] != FADED: - voices[i].level = slot[4] * self.volume * self.pool.gain + if channel is not None and slot[0][0] != channel: + continue + voices[i].level = (slot[4] * self._controllers(slot[0][0]) + * self.volume * self.pool.gain) + + def revibrato(self, channel): + """Give `channel`'s sounding notes the mod wheel and pressure's + vibrato over their region's own.""" + extra = self.wheel.get(channel, 0.0) + self.pressure.get(channel, 0.0) + voices = self.pool.voice + for i, slot in enumerate(self.slots): + if (slot is not None and slot[7] is self + and slot[0][0] == channel): + voices[i].vibrato_depth = slot[8] + extra def repitch(self): voices = self.pool.voice @@ -1086,6 +1211,9 @@ def handle_event(self, event_type, channel, note_id, data0, value0, elif event_type == EVENT_PITCH_BEND: self.bend = 2.0 ** (value1 * BEND_RANGE / 12.0) self.repitch() + elif event_type == EVENT_CHANNEL_PRESSURE: + self.pressure[channel] = value0 * PRESSURE_TO_VIBRATO + self.revibrato(channel) elif event_type == EVENT_CONTROL_CHANGE: if data0 == _CC_BANK_MSB: self.bank_msb = int(value0 * 127 + 0.5) @@ -1093,6 +1221,15 @@ def handle_event(self, event_type, channel, note_id, data0, value0, self.bank_lsb = int(value0 * 127 + 0.5) elif data0 == _CC_SUSTAIN: self.sustain(channel, int(value0 * 127 + 0.5) >= 64) + elif data0 == _CC_MOD_WHEEL: + self.wheel[channel] = value0 * MOD_WHEEL_TO_VIBRATO + self.revibrato(channel) + elif data0 == _CC_VOLUME: + self.volume_cc[channel] = int(value0 * 127 + 0.5) + self.relevel(channel) + elif data0 == _CC_EXPRESSION: + self.expression[channel] = int(value0 * 127 + 0.5) + self.relevel(channel) class SoundFontInstrument(Instrument): @@ -1151,6 +1288,9 @@ def program_change(self, index, channel=0, note_id=-1, sample_position=0): def reset(self): self._player.stop_all() self._player.bank_msb = self._player.bank_lsb = 0 + for state in (self._player.volume_cc, self._player.expression, + self._player.wheel, self._player.pressure): + state.clear() super().reset() @property diff --git a/tests/support/lifecycle_known.py b/tests/support/lifecycle_known.py index 86cfd75..763645a 100644 --- a/tests/support/lifecycle_known.py +++ b/tests/support/lifecycle_known.py @@ -132,11 +132,11 @@ ('Distortion', 'E11-', 'P4'): (4, 'b2d5e10e', None, None, R_STATE), ('Distortion', 'E11-', 'P5'): (12, '5be4892b', None, None, R_JUMP), ('Distortion', 'E4-', 'P4'): (8, '8fd46470', None, None, R_STATE), - ('Distortion', 'E4-', 'P5'): (12, '30b1a8d2', None, None, R_JUMP), + ('Distortion', 'E4-', 'P5'): (13, '82c778dc', None, None, R_JUMP), ('Distortion', 'E5-mix0', 'P4'): (44, '14ea31c6', None, None, R_STATE), ('Distortion', 'E5-mix0', 'P5'): (38, '8e927361', None, None, R_JUMP), ('Distortion', 'E6-', 'P4'): (46, '365ceaaa', None, None, R_STATE), - ('Distortion', 'E6-', 'P5'): (77, '18923193', None, None, R_JUMP), + ('Distortion', 'E6-', 'P5'): (77, '842e56c5', None, None, R_JUMP), ('Distortion', 'E9-', 'P4'): (236, 'f3a8ddc3', None, None, R_STATE), ('Distortion', 'E9-', 'P5'): (174, '91062d1d', None, None, R_JUMP), ('DynamicEQ', 'E1-reset@block', 'P4'): (28, '0b10ffa7', None, None, R_STATE), @@ -256,8 +256,8 @@ ('Notch', 'E9-', 'P4'): (35, '8596d61b', None, None, R_STATE), ('Overdrive', 'E1-reset@block', 'P4'): (34, 'aaf11cd2', None, None, R_STATE), ('Overdrive', 'E1-reset@part', 'P4'): (34, '75164359', None, None, R_STATE), - ('Overdrive', 'E10-macros', 'P4'): (4, 'ca627259', None, None, R_STATE), - ('Overdrive', 'E10-patch', 'P4'): (4, 'a20f495c', None, None, R_STATE), + ('Overdrive', 'E10-macros', 'P4'): (2, '0af84d6b', None, None, R_STATE), + ('Overdrive', 'E10-patch', 'P4'): (2, '9ee38023', None, None, R_STATE), ('Overdrive', 'E10-reset', 'P4'): (4, '34caff5e', None, None, R_STATE), ('Overdrive', 'E11-', 'P4'): (8, '294ebbde', None, None, R_STATE), ('Overdrive', 'E11-', 'P5'): (2, '51122eee', None, None, R_JUMP), diff --git a/tests/support/sf2_builder.py b/tests/support/sf2_builder.py index f84e280..ab00b13 100644 --- a/tests/support/sf2_builder.py +++ b/tests/support/sf2_builder.py @@ -215,3 +215,49 @@ def tiny_bank(modulators=()): [(G.GEN_SAMPLE_MODES, 0), (G.GEN_INSTRUMENT, pad)], ]) return b + + +# The filter bank's one sample: a sawtooth with an 84-frame period (262.5 Hz +# at 22050 Hz, near C4), every harmonic present for a low-pass to take. +SAW_PERIOD = 84 + + +def _saw(periods): + return [int((i % SAW_PERIOD) * 600 - 25000) + for i in range(SAW_PERIOD * periods)] + + +def filter_bank(): + """A bank whose regions use the rest of a voice: one looped sawtooth, + played open, through a resonant low-pass, through one a modulation + envelope sweeps, and with a vibrato LFO. See + `tests/test_cpython_soundfont_modulation.py`.""" + b = Bank("Filter Test Bank") + G = sf2 + saw = b.sample("saw", _saw(40), 0, SAW_PERIOD * 40, pitch=60) + loop = (G.GEN_SAMPLE_MODES, 1) + open_ = b.instrument("Open", [[loop, (G.GEN_SAMPLE_ID, saw)]]) + # 9023 absolute cents is 1500 Hz; 60 centibels is 6 dB of resonance. + shut = b.instrument("Filtered", [ + [(G.GEN_FILTER_FC, 9023), (G.GEN_FILTER_Q, 60), loop, + (G.GEN_SAMPLE_ID, saw)]]) + # Closed at 600 Hz, opened three octaves by an envelope that decays to + # nothing over about half a second; a little pitch on the same envelope. + swept = b.instrument("Swept", [ + [(G.GEN_FILTER_FC, 7437), (G.GEN_MOD_ENV_TO_FILTER_FC, 3600), + (G.GEN_MOD_ENV_TO_PITCH, 100), (G.GEN_DECAY_MOD_ENV, -1200), + (G.GEN_SUSTAIN_MOD_ENV, 1000), (G.GEN_RELEASE_MOD_ENV, 0), + (G.GEN_ATTACK_MOD_ENV, -7973), (G.GEN_HOLD_MOD_ENV, -3986), + loop, (G.GEN_SAMPLE_ID, saw)]]) + # A 5 Hz vibrato of 30 cents after a quarter second, and a modulation + # LFO at 2 Hz on pitch, cutoff and volume. + lfo = b.instrument("LFOs", [ + [(G.GEN_VIB_LFO_TO_PITCH, 30), (G.GEN_FREQ_VIB_LFO, -851), + (G.GEN_DELAY_VIB_LFO, -2400), (G.GEN_MOD_LFO_TO_PITCH, -20), + (G.GEN_MOD_LFO_TO_FILTER_FC, 1200), (G.GEN_MOD_LFO_TO_VOLUME, -60), + (G.GEN_FREQ_MOD_LFO, -2438), loop, (G.GEN_SAMPLE_ID, saw)]]) + b.preset("Open", 0, 0, [[(G.GEN_INSTRUMENT, open_)]]) + b.preset("Filtered", 0, 1, [[(G.GEN_INSTRUMENT, shut)]]) + b.preset("Swept", 0, 2, [[(G.GEN_INSTRUMENT, swept)]]) + b.preset("LFOs", 0, 3, [[(G.GEN_INSTRUMENT, lfo)]]) + return b diff --git a/tests/test_cpython_effects_airspace.py b/tests/test_cpython_effects_airspace.py index 05e0246..6f92d9a 100644 --- a/tests/test_cpython_effects_airspace.py +++ b/tests/test_cpython_effects_airspace.py @@ -787,8 +787,6 @@ def test_the_mix_law(self): with self.subTest(channels=channels, mix=mix): set_value(effect, MIX_I, mix) dry, wet = law_levels(mix) - if channels == 1 and dry == 1.0: - dry = 1.0 - 2.0 ** -15 self.assertAlmostEqual(voices[0].level, dry) self.assertAlmostEqual(voices[1].level, wet) effect.deinit() @@ -1202,15 +1200,20 @@ def moves_render(data, rate, channels, pulls, actions, hidden): #: sha1 of `moves_render` at 48 kHz stereo, 40 pulls, LCG noise at 4000 rms #: seed 11, with `get_buffer` present, per scenario in the order above: #: what the class rendered before `_replay` was taken out (07bf68a). +#: Re-read once, when audiodsp took CircuitPython 11.0.0-alpha.2's integer +#: mixer ((x * level) >> 15 for x * level / 32767 truncated): the class's +#: code was the same on both sides, the old digests still reproduce on the +#: audiodsp commit before the change, and the new ones keep the same +#: scenarios equal to each other as the old ones did. GET_BUFFER_RENDERS = ( - "c80ffad8df5fe4a5ae16ec41c51517575377f23c", - "2b4ab24f79b4dfd68f815bdff5f0e5b7e152e055", - "2b4ab24f79b4dfd68f815bdff5f0e5b7e152e055", - "7e03a853d8fddfa0fbc4b9f7efe034611c2fadd9", - "c80ffad8df5fe4a5ae16ec41c51517575377f23c", - "bc5deee2233815d6a61d8db1db2700ac1f5b5676", - "ae7ef874a87e15d9bbc1d6800aaa48015969c45e", - "2b4ab24f79b4dfd68f815bdff5f0e5b7e152e055", + "ee269d59de182b592e1039d0285f5e27b3238b71", + "34021bf776f3789e3813afc73094bb9bef3ecb58", + "34021bf776f3789e3813afc73094bb9bef3ecb58", + "6062fc883a56ca4767f0de40934c9f0accba8f55", + "ee269d59de182b592e1039d0285f5e27b3238b71", + "3621908f5c28db1295ffe8d2c3e2c0c9696d358a", + "08b65156619b4852fa5c0be1ebffc68a1f117cc0", + "34021bf776f3789e3813afc73094bb9bef3ecb58", ) diff --git a/tests/test_cpython_effects_deesser.py b/tests/test_cpython_effects_deesser.py index e9309d5..efa9d33 100644 --- a/tests/test_cpython_effects_deesser.py +++ b/tests/test_cpython_effects_deesser.py @@ -72,12 +72,16 @@ #: patch and cannot dial it (`FrozenCrossover` in `KitChecks`). FROZEN_CORNER_HZ = 500.0 -#: The two patches that ship in HF-only mode. D1's level independence is -#: recorded disconfirmed at both: the crossover is in the audio path there, -#: and the low band it leaves alone carries no reduction, so the balance the +#: The patch at which D1's level independence is recorded disconfirmed: +#: Cymbal Edge, HF-only. The crossover is in the audio path there, and the +#: low band it leaves alone carries no reduction, so the balance the #: detector reads moves with the programme level in a way broadband mode's -#: single stream does not. Measured in `PatchSweep`. -DEESS_HF_ONLY_PATCHES = (3, 4) +#: single stream does not. Measured in `PatchSweep`: 1.321 dB over -6..-55 +#: dBFS. The other HF-only patch, Guitar Pick Noise (3), holds D1 at +#: 0.583 dB since audiodsp took CircuitPython 11's integer mixer, which +#: rounds the -55 dBFS probe's few-LSB dry voice where the float mixer +#: truncated it toward zero (2.262 dB there). +D1_MISSED_PATCHES = (4,) #: The Release-macro fault, as a multiplier on what the class pushes into #: the node. 250 puts every position of the 1-200 ms span past 200 ms, so @@ -766,8 +770,8 @@ def cell(settings): # And the bound the verdict carries: drop the -55 dBFS probe and the # spread collapses at every Sensitivity. The trait holds from -6 to - # -40 dBFS; what fails is the bottom probe, whose own dry blend - # already spreads 0.643-1.253 dB with the gain cell muted (D7). + # -40 dBFS; what fails is the bottom probe, whose dry blend is a + # handful of LSB that the int16 mixer rounds. worst_loud = max(loud.values()) print(" D1 over -6..-40 dBFS alone: worst %.3f dB at Sensitivity " "%.1f dB" % (worst_loud, @@ -1540,17 +1544,15 @@ def _build(self, *arguments, **keywords): del release_position # What the table is held to, and it is not "all green". D1 is - # recorded disconfirmed above Sensitivity 34 dB and it misses at the - # two HF-only patches, which is the disconfirmation meeting a + # recorded disconfirmed above Sensitivity 34 dB and it misses at + # HF-only patch 4, which is the disconfirmation meeting a # setting the class's own author published - the template's "not a # corner case, it is the product". The rows below are the verdict # this pack carries, so a change in either direction fails here. - broadband = [index for index in table - if index not in DEESS_HF_ONLY_PATCHES] - for index in broadband: - self.assertLess(table[index][0], 1.0, - "D1 at broadband patch %d" % index) - for index in DEESS_HF_ONLY_PATCHES: + held = [index for index in table if index not in D1_MISSED_PATCHES] + for index in held: + self.assertLess(table[index][0], 1.0, "D1 at patch %d" % index) + for index in D1_MISSED_PATCHES: self.assertGreater(table[index][0], 1.0, "D1 is recorded disconfirmed at HF-only " "patch %d" % index) diff --git a/tests/test_cpython_effects_distortion.py b/tests/test_cpython_effects_distortion.py index 9afba2b..776e682 100644 --- a/tests/test_cpython_effects_distortion.py +++ b/tests/test_cpython_effects_distortion.py @@ -915,12 +915,18 @@ class TestDistortionGuardsAreNotOnTheSurface(unittest.TestCase): GRID = (0, 16, 32, 48, 64, 80, 96, 112, 127) def _sweep(self, criterion, faulted, held=(), patches=None, - scoop=False): + scoop=False, rat_only=False): """Walk every macro the row does not hold, plus the patches. Returns `(restored, checked, claimed)`: positions where the fault passes the row **and** the clean class passes it, how many were visited, and how many of those the clean class holds. + + `rat_only` leaves out the Character positions that build the DS-1 + graph instead (64 and up), for a fault planted in the Rat's + `_push_filter`, which that graph never calls: there the faulted + build is the clean one, and it can only pass where the clean one + does. """ restored, checked, claimed = [], 0, 0 options = {"character": 1.0} if scoop else {} @@ -928,6 +934,8 @@ def _sweep(self, criterion, faulted, held=(), patches=None, if index in held: continue for position in self.GRID: + if rat_only and index == CHAR and position >= 64: + continue checked += 1 clean = _reading(criterion, Distortion, macros={index: position}, **options) @@ -977,14 +985,23 @@ def test_always_driven_is_red_everywhere_f4_is_claimed(self): `pre_gain` at 8.0, which Body 64 and shipped patch 5 reach from the panel - so the default clause cannot tell them apart there - but the row also says Distortion 0 is clean, and a forced drive is not - clean at Distortion 0 anywhere.""" - restored, checked, claimed = self._sweep(_f4_at, AlwaysDriven) - self.assertEqual(checked, 101) + clean at Distortion 0 anywhere. + + Character 64 and up is the DS-1 graph, which the fault does not + touch. The clean class failed F4 there until the integer mixer: + Distortion 0 read -60.8 dB THD on the float mixer, whose truncation + toward zero added the distortion, and reads -70.8 dB now, inside + the row. So those five positions are left out, not counted green. + """ + restored, checked, claimed = self._sweep(_f4_at, AlwaysDriven, + rat_only=True) + self.assertEqual(checked, 96) self.assertGreater(claimed, 40) self.assertEqual(restored, []) def test_no_drive_is_red_everywhere_f4_is_claimed(self): - restored, _checked, claimed = self._sweep(_f4_at, NoDrive) + restored, _checked, claimed = self._sweep(_f4_at, NoDrive, + rat_only=True) self.assertGreater(claimed, 40) self.assertEqual(restored, []) diff --git a/tests/test_cpython_effects_harmonizer.py b/tests/test_cpython_effects_harmonizer.py index 17da57d..3697d6c 100644 --- a/tests/test_cpython_effects_harmonizer.py +++ b/tests/test_cpython_effects_harmonizer.py @@ -177,20 +177,11 @@ def gains(mix): return (1.0 if mix <= 1.0 else 2.0 - mix), min(mix, 1.0) -def f32(x): - """`x` rounded to single precision.""" - import struct - return struct.unpack("f", struct.pack("f", x))[0] - - def mixer_scale(x, mul): """One sample through the Mixer's voice multiply at integer level `mul` - (32768 is unity): times `mul / 32767` in single precision, truncated - toward zero, clamped.""" - if mul == 32768: - return x - y = int(f32(x * f32(mul / 32767.0))) - return max(-32768, min(32767, y)) + (32768 is unity): CircuitPython 11.0.0-alpha.2's integer kernel, + (x * mul) >> 15, which rounds toward minus infinity, clamped.""" + return max(-32768, min(32767, (x * mul) >> 15)) def level_int(gain): @@ -199,16 +190,16 @@ def level_int(gain): def pan_muls(level, pan): """(left, right) integer levels of a stereo voice at gain `level` and - pan `pan`: a pan right of centre attenuates the left, and the side that - is not attenuated still runs at 32767/32768 of the level at centre.""" + pan `pan`: a pan right of centre attenuates the left, by 32768 - pan in + Q15, and the side that is not attenuated keeps the full level.""" p = int(round(min(32767.0 / 32768, max(-32767.0 / 32768, pan)) * 32768.0)) lvl = level_int(level) left, right = 32768, 32768 if p >= 0: - left = 32767 - p + left = 32768 - p else: - right = 32767 + p + right = 32768 + p return (left * lvl) >> 15, (right * lvl) >> 15 diff --git a/tests/test_cpython_effects_stereowidener.py b/tests/test_cpython_effects_stereowidener.py index e3dcacc..eff3242 100644 --- a/tests/test_cpython_effects_stereowidener.py +++ b/tests/test_cpython_effects_stereowidener.py @@ -172,12 +172,17 @@ def render(cls, values, rate, channels=2, frames=None, moves=None, class CopyFromMid(StereoWidener): - """H1: the copy is built from the mid (`input_pan=+1`), not the right.""" + """H1: the copy is built from the mid (`input_pan=+1`), not the right: + the line is fed both channels, and folds them into its right.""" def _build_graph(self): StereoWidener._build_graph(self) self._line.set(input_pan=1.0) + def _wire(self): + StereoWidener._wire(self) + self._feed.voice[0].panning = 0.0 + class BackwardsPan(StereoWidener): """H2: the lead voice's panning sign flipped, so it turns the left down diff --git a/tests/test_cpython_sf2.py b/tests/test_cpython_sf2.py index d40f8e8..a8dd86c 100644 --- a/tests/test_cpython_sf2.py +++ b/tests/test_cpython_sf2.py @@ -229,6 +229,43 @@ def test_modulators_are_noticed_not_applied(self): self.assertAlmostEqual(bank.regions(128, 0, 38, 100)[0].attenuation, 1.0) + def test_defaults_leave_a_region_plain(self): + # Nothing in the tiny bank uses the rest of a voice: no filter, a + # flat modulation envelope, no LFO depth. + r = self.one(0, 0, 40) + self.assertEqual(r.filter_cutoff, 13500) + self.assertEqual(r.resonance, 0.0) + self.assertEqual((r.mod_to_pitch, r.mod_to_cutoff), (0, 0)) + self.assertEqual((r.vibrato_to_pitch, r.lfo_to_pitch, + r.lfo_to_cutoff, r.lfo_to_volume), (0, 0, 0, 0.0)) + self.assertAlmostEqual(r.mod_sustain, 1.0) + self.assertAlmostEqual(r.mod_attack, 0.001, places=4) + self.assertAlmostEqual(r.vibrato_rate, 8.176, places=3) + self.assertAlmostEqual(r.lfo_delay, 0.001, places=4) + + def test_filter_modulation_envelope_and_lfos(self): + path = os.path.join(self.tmp, "filter.sf2") + sf2_builder.filter_bank().write(path) + with sf2.SoundFont(path) as bank: + r = bank.regions(0, 1, 60, 100)[0] + self.assertEqual((r.filter_cutoff, r.filter_q), (9023, 60)) + self.assertAlmostEqual(r.filter_hz, 1500, delta=2) + self.assertAlmostEqual(r.resonance, 6.0) + r = bank.regions(0, 2, 60, 100)[0] + self.assertEqual((r.mod_to_cutoff, r.mod_to_pitch), (3600, 100)) + self.assertAlmostEqual(r.mod_attack, 0.010, places=4) + self.assertAlmostEqual(r.mod_hold, 0.100, places=4) + self.assertAlmostEqual(r.mod_decay, 0.5, places=4) + self.assertAlmostEqual(r.mod_release, 1.0, places=4) + self.assertAlmostEqual(r.mod_sustain, 0.0) + r = bank.regions(0, 3, 60, 100)[0] + self.assertEqual(r.vibrato_to_pitch, 30) + self.assertAlmostEqual(r.vibrato_rate, 5.0, delta=0.01) + self.assertAlmostEqual(r.vibrato_delay, 0.25, places=4) + self.assertAlmostEqual(r.lfo_rate, 2.0, delta=0.01) + self.assertEqual((r.lfo_to_pitch, r.lfo_to_cutoff), (-20, 1200)) + self.assertAlmostEqual(r.lfo_to_volume, -6.0) + def test_not_a_soundfont(self): path = os.path.join(self.tmp, "junk.sf2") with open(path, "wb") as f: diff --git a/tests/test_cpython_soundfont.py b/tests/test_cpython_soundfont.py index fd0a34f..2676152 100644 --- a/tests/test_cpython_soundfont.py +++ b/tests/test_cpython_soundfont.py @@ -220,7 +220,10 @@ def test_macros_reach_held_voices(self): self.assertAlmostEqual(voice.pitch, ratio * 2 ** (2 / 12.0), places=4) level = player.slots[slot][4] - self.assertAlmostEqual(voice.level, level * 100 / 127.0, places=5) + # Volume at 100 and Control Change 7 at its default 100. + self.assertAlmostEqual(voice.level, + level * 100 / 127.0 * (100 / 127.0) ** 2, + places=5) self.inst.set_macro(0, 0) self.assertEqual(voice.level, 0.0) self.inst.set_macro(1, 127) diff --git a/tests/test_cpython_soundfont_modulation.py b/tests/test_cpython_soundfont_modulation.py new file mode 100644 index 0000000..3129b06 --- /dev/null +++ b/tests/test_cpython_soundfont_modulation.py @@ -0,0 +1,186 @@ +"""`soundfont` playing the rest of what a bank says, on `sf2_builder`'s +filter bank, and the SoundFont 2.04 default modulators. + +Each trait is measured on what the instrument renders, with the bar beside +it: + +- A region that doesn't use its filter plays unfiltered at every velocity + (the voice gets no cutoff), and sounds the same brightness soft or hard. +- Velocity moves a filtered region's cutoff down to two octaves at + velocity 0, and a soft note comes out darker. The brightness of a + sawtooth through a plain low-pass goes about as the square root of the + cutoff, so 1500 Hz against velocity 40's 581 Hz is about 4.1 dB; the + 6 dB resonance adds to it, and 5.1 dB was measured. The bar is 3.5 to + 6.5 dB. +- The modulation envelope sweeps the cutoff: the first tenth of a second is + at least 6 dB brighter than the sustain, where the envelope has gone + (8.6 dB measured). +- The mod wheel and channel pressure each add up to 50 cents of vibrato to + the region's own, on held notes and new ones, and the output's pitch then + swings at least 60 cents peak to peak (88 measured, through 20 ms + windows) where it held within 5 (2.4 measured). +- Control Change 7 and 11 each scale the level by the square of their + fraction: Expression at 64 is 11.9 dB under 127, within 0.2 dB. + +The brightness measure is the RMS of the first difference over the RMS, +in dB, which takes level out. + +A fault planted to show these can fail (2026-10-29): velocity to cutoff +with its sign turned (`velocity / 127.0` for `1.0 - velocity / 127.0`) +failed the velocity, brightness and sweep traits; Control Change 7 and 11 +on a straight line rather than the square failed the level trait. +""" + +import math +import os +import pathlib +import shutil +import sys +import tempfile +import unittest + +import audioinstruments +import audioinstruments.soundfont as soundfont + +SUPPORT = pathlib.Path(__file__).resolve().parent / "support" +if str(SUPPORT) not in sys.path: + sys.path.insert(0, str(SUPPORT)) + +import sf2_builder # noqa: E402 +import sf2_play # noqa: E402 + +SR = 48000 +OPEN, FILTERED, SWEPT, LFOS = 0, 1, 2, 3 + + +def brightness_db(values): + """RMS of the first difference over the RMS, in dB.""" + diff = [values[i] - values[i - 1] for i in range(1, len(values))] + return 20.0 * math.log10(sf2_play.rms(diff) / sf2_play.rms(values)) + + +def pitch_swing_cents(values, window=960): + """Peak-to-peak pitch over `values`, in cents, from rising zero + crossings counted in `window`-frame windows.""" + rates = [] + for start in range(0, len(values) - window, window): + chunk = values[start:start + window] + crossings = [i for i in range(1, window) + if chunk[i - 1] < 0 <= chunk[i]] + if len(crossings) > 2: + rates.append((len(crossings) - 1) + / float(crossings[-1] - crossings[0])) + return 1200.0 * math.log2(max(rates) / min(rates)) + + +class SoundFontModulationTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.mkdtemp(prefix="sf2mod") + cls.path = os.path.join(cls.tmp, "filter.sf2") + sf2_builder.filter_bank().write(cls.path) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmp, ignore_errors=True) + + def setUp(self): + self.inst = audioinstruments.create("soundfont", SR, bank=self.path) + + def tearDown(self): + self.inst.deinit() + + def voice(self, key=60): + slot = self.inst._player.held[(0, key)][0] + return self.inst.output.voice[slot] + + def play(self, program, velocity, seconds, key=60): + self.inst.program_change(program) + self.inst.note_on(key, velocity) + out = sf2_play.render(self.inst, seconds, channel=0) + self.inst.note_off(key) + sf2_play.render(self.inst, 0.3) + return out + + def test_an_open_region_is_never_filtered(self): + self.inst.program_change(OPEN) + for velocity in (127, 30): + self.inst.note_on(60, velocity) + self.assertIsNone(self.voice().cutoff) + self.inst.note_off(60) + hard = brightness_db(self.play(OPEN, 127, 0.3)[4800:]) + soft = brightness_db(self.play(OPEN, 30, 0.3)[4800:]) + self.assertAlmostEqual(hard, soft, delta=0.1) + + def test_velocity_moves_the_cutoff(self): + self.inst.program_change(FILTERED) + for velocity in (127, 64, 1): + self.inst.note_on(60, velocity) + expected = 1500.0 * 2.0 ** (-2.0 * (1.0 - velocity / 127.0)) + self.assertAlmostEqual(self.voice().cutoff, expected, + delta=expected * 0.005) + self.inst.note_off(60) + + def test_velocity_changes_brightness(self): + hard = brightness_db(self.play(FILTERED, 127, 0.3)[4800:]) + soft = brightness_db(self.play(FILTERED, 40, 0.3)[4800:]) + self.assertGreater(hard - soft, 3.5) + self.assertLess(hard - soft, 6.5) + + def test_modulation_envelope_sweeps_the_cutoff(self): + out = self.play(SWEPT, 127, 1.2) + early = brightness_db(out[480:4800]) + late = brightness_db(out[48000:57600]) + self.assertGreater(early - late, 6.0) + + def test_wheel_and_pressure_add_vibrato(self): + self.inst.program_change(LFOS) + self.inst.note_on(60, 100) + voice = self.voice() + self.assertAlmostEqual(voice.vibrato_depth, 30.0) + self.inst.control_change(1, 127) + self.assertAlmostEqual(voice.vibrato_depth, 80.0) + self.inst.channel_pressure(127) + self.assertAlmostEqual(voice.vibrato_depth, 130.0) + self.inst.control_change(1, 0) + self.assertAlmostEqual(voice.vibrato_depth, 80.0) + self.inst.note_on(64, 100) + self.assertAlmostEqual(self.voice(64).vibrato_depth, 80.0) + self.inst.channel_pressure(0) + self.assertAlmostEqual(voice.vibrato_depth, 30.0) + + def test_wheel_vibrato_is_heard(self): + steady = pitch_swing_cents(self.play(OPEN, 100, 0.6)) + self.inst.control_change(1, 127) + swung = pitch_swing_cents(self.play(OPEN, 100, 0.6)) + self.assertLess(steady, 5.0) + self.assertGreater(swung, 60.0) + + def test_volume_and_expression_go_as_the_square(self): + full = sf2_play.rms(self.play(OPEN, 100, 0.2)[2400:]) + self.inst.control_change(11, 64) + half = sf2_play.rms(self.play(OPEN, 100, 0.2)[2400:]) + expected = 40.0 * math.log10(64 / 127.0) + self.assertAlmostEqual(20.0 * math.log10(half / full), expected, + delta=0.2) + # A held note follows, and Volume's default of 100 is in the level. + self.inst.control_change(11, 127) + self.inst.note_on(60, 100) + voice = self.voice() + default = voice.level + self.inst.control_change(7, 127) + self.assertAlmostEqual(default / voice.level, (100 / 127.0) ** 2, + places=5) + self.inst.reset() + self.assertEqual(self.inst._player.volume_cc, {}) + + def test_bank_modulator_defaults_are_the_specifications(self): + self.assertEqual(soundfont.VELOCITY_TO_CUTOFF, -2400) + self.assertEqual(soundfont.MOD_WHEEL_TO_VIBRATO, 50.0) + self.assertEqual((soundfont.DEFAULT_VOLUME, + soundfont.DEFAULT_EXPRESSION), (100, 127)) + + +if __name__ == "__main__": + unittest.main()