diff --git a/CHANGELOG.md b/CHANGELOG.md index 99ce2ee..cd8245d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ there, and are recorded in its changelog. and sits 3 dB lower (-3.97 dB), so a worst-phase tone driven into it still reads -0.96 dB TP. The docstring now says which patches an S3 cannot play. +- **`Octaver` patch 6, *Both octaves - lean*:** Both octaves for an + ESP32-S3, where patch 1 cannot keep up. It has the same balance of the + note and the two octaves, 2 dB louder (Direct at 1, both octaves at + 0.87); turn the level after it down 2 dB and you hear Both octaves. The + docstring says so. - **`MultibandCompressor`'s docstring says to build it with `bands=2` on an ESP32-S3**, where no three-band patch keeps up (107 % of a block; two bands is 71 %). No patch can do this instead: every macro position builds diff --git a/lib/audioeffects/rebuilt/octaver.py b/lib/audioeffects/rebuilt/octaver.py index bc2898c..197f535 100644 --- a/lib/audioeffects/rebuilt/octaver.py +++ b/lib/audioeffects/rebuilt/octaver.py @@ -169,6 +169,11 @@ class Octaver(_component.Component): A control that jumps makes the output step: move it in small steps from the host if you need it smooth. + **On an ESP32-S3, Both octaves can't keep up and the sound breaks up; + play Both octaves - lean instead.** It has the same balance of your + note and the two octaves, 2 dB louder: turn the level after it down + 2 dB and you hear Both octaves. + **Wire, latency, tail.** With both octaves at 0 and Direct at 1 the output is the input, byte for byte, however the host's buffers are cut. @@ -215,6 +220,10 @@ class Octaver(_component.Component): #: `_component.macro_of` of the card's settings: 0.7 is 89, 0.8 is 102, #: 0.6 is 76; Tracking 90/50/200 Hz is 45/12/89; Tone 1 324/250/700 Hz #: is 50/39/82; Tone 2 70.3 Hz is 24; Sensitivity -80 dBFS is 25. + #: Patch 6 is patch 1 with Direct at 1 and both octaves at 111 (0.874), + #: the same balance 1.9 dB up. A mixer voice at exactly 1 passes its + #: samples through with no multiply, which is the 0.07 ms an ESP32-S3 + #: lacks for patch 1, where all three levels are under 1. PATCHES = { 0: ("Octave down under the dry", (127, 89, 0, 45, 50, 24, 25)), 1: ("Both octaves", (102, 89, 89, 45, 50, 24, 25)), @@ -222,6 +231,7 @@ class Octaver(_component.Component): 3: ("Bass guitar", (127, 102, 0, 12, 39, 24, 25)), 4: ("High on the neck", (127, 89, 0, 89, 82, 24, 25)), 5: ("Two octaves down alone", (0, 0, 127, 45, 50, 24, 25)), + 6: ("Both octaves - lean", (127, 111, 111, 45, 50, 24, 25)), } def _build(self, direct=1.0, octave1=0.7, octave2=0.0, tracking_hz=90.0, diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index 3cd8798..954e90f 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -88,7 +88,7 @@ #: A scope narrows a row; give each kind at most once, in either order: #: #: - a tuple of patches, the configurations the row covers (None for the -#: constructor defaults): ("Octaver", "E5-", "P1", (1, 2, 5)); +#: constructor defaults): ("Octaver", "E5-", "P1", (1, 2, 5, 6)); #: - a string naming the interpreters it holds on, `sys.implementation.name`, #: comma-separated: ("PitchShifter", "E10-reset", "P4", "circuitpython"). #: @@ -383,6 +383,9 @@ ("Octaver", "E6-p5", "P1"): "the event changes to patch 5, where Octave 1 at 0 is a level move," " not the wire, so the output is not the source by design", + ("Octaver", "E6-p6", "P1"): + "the event changes to patch 6, where Octave 1 at 0 is a level move," + " not the wire, so the output is not the source by design", ("Octaver", "E12-p1", "P1"): "the move before the first pull is to patch 1, where Octave 1 at 0" " is a level move, not the wire, so the output is not the source by" @@ -395,11 +398,15 @@ "the move before the first pull is to patch 5, where Octave 1 at 0" " is a level move, not the wire, so the output is not the source by" " design", + ("Octaver", "E12-p6", "P1"): + "the move before the first pull is to patch 6, where Octave 1 at 0" + " is a level move, not the wire, so the output is not the source by" + " design", ("Octaver", "E11-mix+patch", "P1"): - "the event ends on patch 5, where Octave 1 at 0 is a level move," + "the event ends on patch 6, where Octave 1 at 0 is a level move," " not the wire, so the output is not the source by design", ("Octaver", "E11-3moves", "P1"): - "the event ends on patch 5, where Octave 1 at 0 is a level move," + "the event ends on patch 6, where Octave 1 at 0 is a level move," " not the wire, so the output is not the source by design", ("Octaver", "E5-", "P5"): "family limit, disclosed (audiocomponents#117): a control that jumps" @@ -688,12 +695,12 @@ for _event in CONTROL_EVENTS: DECLARED[(_name, _event, "P5")] = _why -#: At patches 1, 2 and 5 Octave 1 at 0 is a level move, not the wire, so P1 -#: (Mix 0 is the source) does not apply there, on any event. +#: At patches 1, 2, 5 and 6 Octave 1 at 0 is a level move, not the wire, so +#: P1 (Mix 0 is the source) does not apply there, on any event. for _event in ("E1-", "E2-", "E4-", "E5-", "E6-", "E8-", "E9-", "E10-", "E11-", "E12-"): - DECLARED[("Octaver", _event, "P1", (1, 2, 5))] = ( - "at patches 1, 2 and 5 Octave 2 is up or Direct is under 1, so" + DECLARED[("Octaver", _event, "P1", (1, 2, 5, 6))] = ( + "at patches 1, 2, 5 and 6 Octave 2 is up or Direct is under 1, so" " Octave 1 at 0 is a level move, not the wire, and the output is" " not the source by design") diff --git a/tests/test_cpython_effects_octaver.py b/tests/test_cpython_effects_octaver.py index e0a2094..5963dd7 100644 --- a/tests/test_cpython_effects_octaver.py +++ b/tests/test_cpython_effects_octaver.py @@ -1218,6 +1218,37 @@ def test_a_patch_builds_as_its_values(self): render(Octaver, values, rate, 1, options=self._values_of(patch))) + def test_the_lean_patch_is_both_octaves_2_db_louder(self): + # Patch 6 is patch 1 with Direct at 1, so the mixer passes the dry + # through with no multiply, and the octaves raised to keep patch + # 1's balance. Turned down by patch 1's Direct level it renders + # patch 1, to the octave levels' rounding on the 128-step grid. + name, grid = Octaver.PATCHES[6] + self.assertEqual(name, "Both octaves - lean") + self.assertEqual(Octaver.PATCHES[1][0], "Both octaves") + one = Octaver.PATCHES[1][1] + self.assertEqual(grid[DIRECT], 127) + self.assertEqual(grid[OCT1], grid[OCT2]) + self.assertEqual(grid[TRACKING:], one[TRACKING:]) + down = one[DIRECT] / 127.0 + self.assertAlmostEqual(20.0 * math.log10(down), -1.90, places=2) + self.assertLess(abs(grid[OCT1] * down - one[OCT1]), 0.2) + for rate in RATES: + for channels in (1, 2): + with self.subTest(rate=rate, channels=channels): + values = self._material(rate, channels) + both = render(Octaver, values, rate, channels, + options={"patch": 1}) + lean = render(Octaver, values, rate, channels, + options={"patch": 6}) + peak = max(abs(v) for v in both) + self.assertGreater(peak, 1000) + self.assertLess(max(lean), 32767) + self.assertGreater(min(lean), -32768) + worst = max(abs(lean[i] * down - both[i]) + for i in range(len(both))) + self.assertLessEqual(worst, 0.003 * peak + 3.0) + def test_a_patch_before_the_first_pull_renders_as_fresh(self): # Arming plays only silence, so the all-pass has heard nothing yet: # a Tracking move before the first pull lands at once, not walked @@ -1410,6 +1441,12 @@ def test_the_peak_gain(self): ("A control that jumps makes the output step: move it in small steps " "from the host if you need it smooth.", "DECLARED:Octaver,E4-,P5"), + ("On an ESP32-S3, Both octaves can't keep up and the sound breaks up; " + "play Both octaves - lean instead.", + "TheSurface.test_the_lean_patch_is_both_octaves_2_db_louder"), + ("It has the same balance of your note and the two octaves, 2 dB " + "louder: turn the level after it down 2 dB and you hear Both octaves.", + "TheSurface.test_the_lean_patch_is_both_octaves_2_db_louder"), ("With both octaves at 0 and Direct at 1 the output is the input, byte " "for byte, however the host's buffers are cut.", ("TheSurface.test_the_wire_is_the_input",