Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 0 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 60 additions & 6 deletions lib/audioeffects/octaver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(),
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down
97 changes: 66 additions & 31 deletions tests/test_cpython_effects_octaver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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.",
Expand Down
Loading