From eeb5c817e6c1a95cb3186c2f54555a1d71788099 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:00:55 -0500 Subject: [PATCH] Octaver: the comparator sees the clock less its slow mean, so a direct offset no longer stops the top of the range (#190) A plucked note rings round an offset of 1 to 2 % of its peak. The Tracking low-pass passes it whole and takes B3's and E4's fundamentals down by 37 and 46 dB, so the clock never crossed zero and the notes played themselves. The clock leg now subtracts its own mean (three first-order poles at 20 Hz, an all-pass at half mix each, negated by a multiply and summed by a mixer). The clock's waveform is otherwise untouched, so the A2 decay, the windows and the peak gain hold. The offset tests flip from the limit to dividing, the fault without the mean is planted, and the docstring's limit and the roadmap bullet go. --- CHANGELOG.md | 13 ++++ ROADMAP.md | 4 -- lib/audioeffects/octaver.py | 66 ++++++++++++++++-- tests/test_cpython_effects_octaver.py | 97 ++++++++++++++++++--------- 4 files changed, 139 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22a08ff..4e820bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,19 @@ there, and are recorded in its changelog. ### Fixed +- `Octaver` divides on an input with a direct offset in it. A plucked note + rings round an offset of 1 to 2 % of its peak, which the Tracking low-pass + passes whole while it takes the fundamental of B3 and E4 down by 37 and + 46 dB: the divider's clock never crossed zero, and those notes played + themselves. The comparator now sees the clock less its slow mean (three + first-order poles at 20 Hz), so its zero follows the clock's own centre and + the clock's waveform is otherwise untouched. B3, E4 and A4 divide with an + offset of 30 % of the peak, where 3 %, 1 % and 0.3 % used to stop them, and + the A2 decay, the tracking windows, the peak gain and the rest of the kit + read as before. The limit the Changed entry above names is gone, and so is + its roadmap bullet. The clock leg gains a splitter, three all-pass poles, a + multiply and a two-voice mixer; its cost on a board is not measured + ([#190](https://github.com/PyDevices/audiocomponents/issues/190)). - `Expander`'s `tail_samples` is finite with Key Listen on. It said 0 while the key filters rang for 2 304 frames or more; it now bounds that ring from Key Low, Key High and the rate (at most about 5 100 frames at 48 kHz), and diff --git a/ROADMAP.md b/ROADMAP.md index add6bd8..c1cd954 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,10 +20,6 @@ the real thing. - An auto-wah (an envelope filter that opens as you play harder) and its pedal-swept sibling, Wah. They share one resonant filter that has to move fast enough to sound smooth. -- Octaver that blocks a direct offset in its input, so the top of its range - (B3 to A4 at patch 0) divides on a signal with a little DC in it. A plain - high-pass on both legs moves the notes' octave levels, so it needs its - corner and phase worked out against the clock first. - SpringReverb, the dispersive "boing" of a spring tank, which a plate or hall reverb cannot give you. - Ensemble, a multi-voice chorus where the number of voices is the effect, and diff --git a/lib/audioeffects/octaver.py b/lib/audioeffects/octaver.py index 5228c35..2e4eba5 100644 --- a/lib/audioeffects/octaver.py +++ b/lib/audioeffects/octaver.py @@ -56,6 +56,25 @@ CLOCK_Q = 0.5 TONE_Q = 0.7071 +#: The clock's slow mean: three first-order low-passes at MEAN_HZ in a row, +#: which `_centre` takes out of the clock before the comparator. A direct +#: offset in the input passes the Tracking low-pass whole where a high +#: note's fundamental does not, so the comparator's zero sat outside the +#: clock; with the mean taken out the zero follows the clock's own centre. +#: Each pole is an all-pass at half mix, which is exactly 1 at DC however +#: low the corner (a low-pass biquad at 10 Hz is not: its coefficients +#: round in single precision and its DC gain reads 0.90 to 1.24). Three +#: poles make what is subtracted fall as the cube of the frequency, so the +#: clock's phase at the note moves by less than a degree (A2) and the square +#: still turns over on the signal's own crossings; a high-pass at the same +#: corner would lead by 10 degrees there and move the octave's level. The +#: corner is a trade between two rows: the mean forgets the attack's step +#: in about 50 ms at 20 Hz, where at 10 Hz a note above the range (E5) kept +#: a pulse in its clock past 125 ms and played for that long, and from +#: 30 Hz the A2 decay's spread and seed-7 level move. +MEAN_HZ = 20.0 +MEAN_POLES = 3 + #: The dividers turn over on the comparator's ticks: one LSB, under TICK. CLOCK_THRESHOLD = 1.0 / 32768.0 CLOCK_HOLD_MS = 1.0 @@ -157,14 +176,12 @@ class Octaver(_component.Component): octave goes silent. Between -90 and -84.5 dBFS Sensitivity changes nothing: that whole stretch is one step of the clock. + A direct offset in the input, as a plucked string rings round, does not + take the top of the range from you: the divider turns over on the + clock's own centre, not on zero. **Limits.** It plays one note at a time: on a power chord (A2 and E3) about two fifths of the octave is intermodulation. - An offset in the input, a direct current, goes through Tracking's - low-pass whole where a high note's fundamental does not, so it ends the - range: at patch 0 an offset of 3 % of a note's peak stops B3, 1 % stops - E4 and 0.3 % stops A4, and what plays is the note itself. - The ranges above are for an input with none. A stereo source is clocked on its mid: with the right channel 45 degrees ahead of the left, the fifth sits 3.2 dB under the octave instead of 4.4, on both channels. @@ -286,6 +303,7 @@ def _build(self, direct=1.0, octave1=0.7, octave2=0.0, tracking_hz=90.0, self._dry = split.tap(0) self._allpass = None self._clock = [] + self._mean = [] signal = self._own(audioroute.Splitter( self._signal_leg(split.tap(1)), taps=2), reset=False) ticks = self._own(audioroute.Splitter( @@ -395,6 +413,7 @@ def _clock_leg(self, tap): section.play(clock) clock = section self._clock.append(section) + clock = self._centre(clock) self._comparator = self._own(audioshaper.Waveshaper( sample_rate=self._sample_rate, channel_count=self._channel_count, curve=self._curve(), @@ -403,6 +422,40 @@ def _clock_leg(self, tap): self._comparator.play(clock) return self._comparator + def _centre(self, clock): + """The clock less its slow mean, for the comparator. + + The mean is `MEAN_POLES` first-order low-passes at `MEAN_HZ`, + negated by a multiply and summed with the clock by a two-voice + mixer, which adds sample by sample. The clock's waveform is + otherwise untouched, so a decaying note reaches the comparator as + before; what moves is where its zero sits, which follows the + offset the clock carries.""" + rate = self._sample_rate + channels = self._channel_count + split = self._own(audioroute.Splitter(clock, taps=2), reset=False) + mean = split.tap(1) + for _ in range(MEAN_POLES): + pole = self._own(audiobiquad.AllPass( + stages=1, frequency=MEAN_HZ, feedback=0.0, mix=0.5, + sample_rate=rate, channel_count=channels)) + pole.play(mean) + mean = pole + self._mean.append(pole) + minus_one = self._own(audiocore.RawSample( + array("h", [-32768] * (2 * channels)), + sample_rate=rate, channel_count=channels), reset=False) + negative = self._own(audiomath.Multiply( + mean, minus_one, mix=1.0, sample_rate=rate, + channel_count=channels)) + # Not reset, as the output mixer: no voice holds a block between + # two pulls once the graph is armed (`_arm`). + centred = self._own(audiomixer.Mixer( + voice_count=2, **self._pcm(BLOCK * channels * 4)), reset=False) + centred.voice[0].play(split.tap(0), loop=True) + centred.voice[1].play(negative, loop=True) + return centred + def _divide(self, tap, order): """A divider on the comparator: a +-1 square at a half (order 1) or a quarter (order 2) of the note, zero while the comparator is @@ -549,7 +602,8 @@ def _arm(self): # block walks a corner move over 20 ms (#162); cleared, it takes a # Tone or Tracking set before the first pull as it stands, as a # fresh build does (audiocomponents#164). - for node in self._clock + [self._tone1, self._tone1b, self._tone2]: + for node in (self._clock + self._mean + + [self._tone1, self._tone1b, self._tone2]): audiocore.reset_buffer(node) def _pull(self): diff --git a/tests/test_cpython_effects_octaver.py b/tests/test_cpython_effects_octaver.py index 065d52d..e69cfeb 100644 --- a/tests/test_cpython_effects_octaver.py +++ b/tests/test_cpython_effects_octaver.py @@ -749,6 +749,14 @@ def _mid(self, tap): return self._mid_node +class NoMean(Octaver): + """T7: the comparator's zero left where it was, on the clock's own + waveform, with no slow mean taken out of the clock.""" + + def _centre(self, clock): + return clock + + class DoubleTone(Octaver): """T6: Tone 1 at twice the hertz its knob shows.""" @@ -1093,38 +1101,64 @@ def test_the_top_of_the_window_divides_without_an_offset(self): self.assertIsNone(t7_verdict(cells, T7_TOP), (rate, level, sens)) - def test_the_phrase_s_own_offset_stops_its_top_notes(self): + def test_the_phrase_s_own_offset_leaves_its_top_notes_dividing(self): """The same phrase as `kit_common.pluck` makes it, with the offset - its loop keeps (1 to 2 % of a note's peak in the last half of its slot): - B3 and E4 play the note itself, and the octave is gone, at -3 dBFS - with the clock well over Sensitivity. This is the limit the card - states, held so that a fix to it is noticed.""" - cells = t7_read(Octaver, 48000, -3, -70.0) + its loop keeps (1 to 2 % of a note's peak in the last half of its + slot): B3 and E4 divide at every level and every Sensitivity that + puts the clock 12 dB over it, as they do without it. Until the + comparator's zero followed the clock's mean they played the note + itself here (audiocomponents#190).""" + for rate in RATES: + for level in kc.LEVELS_DBFS: + for sens in T7_SENSITIVITIES: + cells = t7_read(Octaver, rate, level, sens) + self.assertIsNone(t7_verdict(cells, T7_TOP), + (rate, level, sens)) + + def test_without_the_mean_the_phrase_s_offset_stops_its_top_notes(self): + """The planted fault: the clock handed to the comparator as it + comes. B3 and E4 play the note itself, and the octave is gone, at + -3 dBFS with the clock well over Sensitivity.""" + cells = t7_read(NoMean, 48000, -3, -70.0) for k in T7_TOP: f, margin, octave, level = cells[k] self.assertGreaterEqual(margin, T7_TRACK_MARGIN_DB, f) self.assertLess(octave, -T7_TRACK_DB, f) self.assertGreater(level, -10.0, f) + self.assertIsNotNone(t7_verdict(cells, T7_TOP)) - def test_an_offset_ends_the_range_from_the_top(self): - """A sine at the card's -8.7 dBFS with a direct offset: B3 divides - with 1 % of its peak and stops at 3 %, E4 divides at 0.3 % and - stops at 1 %, A4 divides at 0.1 % and stops at 0.3 %.""" - rate = 48000 + def _offset_read(self, cls, name, fraction, rate=48000): + """Octave 1's f/2 against the note's f, in dB, for a sine at the + card's -8.7 dBFS with a direct offset of `fraction` of its peak.""" n = rate // 2 + f = NOTES[name] + x = [12000.0 * math.sin(2.0 * math.pi * f * i / rate) + + 12000.0 * fraction for i in range(n)] + values = pcm(x) + y = tail_part(render(cls, values, rate, 1, macros=ONLY1)) + heard = [float(v) for v in values[len(values) // 4:]] + return level_db(y, f / 2.0, rate) - level_db(heard, f, rate) + + def test_an_offset_does_not_end_the_range_from_the_top(self): + """A sine at the card's -8.7 dBFS with a direct offset of 3 %, 10 % + and 30 % of its peak: B3, E4 and A4 divide at each, at both rates. + Before the comparator's zero followed the clock's mean, 3 % stopped + B3, 1 % stopped E4 and 0.3 % stopped A4.""" + for rate in RATES: + for name in ("B3", "E4", "A4"): + for fraction in (0.03, 0.1, 0.3): + read = self._offset_read(Octaver, name, fraction, rate) + self.assertGreater(read, -T7_TRACK_DB, + (rate, name, fraction)) + + def test_without_the_mean_an_offset_ends_the_range_from_the_top(self): + """The planted fault again, on sines: B3 divides with 1 % of its + peak and stops at 3 %, E4 divides at 0.3 % and stops at 1 %, A4 + divides at 0.1 % and stops at 0.3 %.""" for name, holds, stops in (("B3", 0.01, 0.03), ("E4", 0.003, 0.01), ("A4", 0.001, 0.003)): - f = NOTES[name] - read = {} - for fraction in (holds, stops): - x = [12000.0 * math.sin(2.0 * math.pi * f * i / rate) - + 12000.0 * fraction for i in range(n)] - values = pcm(x) - y = tail_part(render(Octaver, values, rate, 1, - macros=ONLY1)) - heard = [float(v) for v in values[len(values) // 4:]] - read[fraction] = (level_db(y, f / 2.0, rate) - - level_db(heard, f, rate)) + read = {fraction: self._offset_read(NoMean, name, fraction) + for fraction in (holds, stops)} self.assertGreater(read[holds], -T7_TRACK_DB, (name, read)) self.assertLess(read[stops], -40.0, (name, read)) @@ -1598,15 +1632,16 @@ def test_the_peak_gain(self): ("It plays one note at a time: on a power chord (A2 and E3) about two " "fifths of the octave is intermodulation.", "TheSurface.test_one_note_at_a_time"), - ("An offset in the input, a direct current, goes through Tracking's " - "low-pass whole where a high note's fundamental does not, so it ends " - "the range: at patch 0 an offset of 3 % of a note's peak stops B3, " - "1 % stops E4 and 0.3 % stops A4, and what plays is the note itself.", - ("T7RealMaterial.test_an_offset_ends_the_range_from_the_top", - "T7RealMaterial.test_the_phrase_s_own_offset_stops_its_top_notes")), - ("The ranges above are for an input with none.", - ("T7RealMaterial.test_the_top_of_the_window_divides_without_an_offset", - "T5Range.test_the_windows_mono")), + ("A direct offset in the input, as a plucked string rings round, does " + "not take the top of the range from you: the divider turns over on the " + "clock's own centre, not on zero.", + ("T7RealMaterial.test_the_phrase_s_own_offset_leaves_its_top_notes_" + "dividing", + "T7RealMaterial.test_an_offset_does_not_end_the_range_from_the_top", + "T7RealMaterial.test_without_the_mean_the_phrase_s_offset_stops_its_" + "top_notes", + "T7RealMaterial.test_without_the_mean_an_offset_ends_the_range_from_" + "the_top")), ("A stereo source is clocked on its mid: with the right channel 45 " "degrees ahead of the left, the fifth sits 3.2 dB under the octave " "instead of 4.4, on both channels.",