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.",