diff --git a/docs/upstream-diff.md b/docs/upstream-diff.md index 7857bb82..bbf1c5b4 100644 --- a/docs/upstream-diff.md +++ b/docs/upstream-diff.md @@ -1646,6 +1646,17 @@ that no arrangement of the existing nodes reaches, and the program's vision too short for the whole 16 ms stretch matches a note rather than a chord and never searches on. + Nor does a splice where the other tap reads 8 LSB or less on average over + the stretch (audiodsp#290). There, the last of a fading repeat or silence, + rounding alone makes every match look poor, and a whole window's move had + nothing to match: it left the starting tap a window off the material that + came next. A 40 ms burst shifted once by `PitchShift` and repeated through + the loop at 22.05 kHz, -5 semitones, a 30 ms window and Feedback 0.3 came + back 17.6 to 20.0 dB under what Feedback leaves a repeat, where the burst + began 51 to 55 ms in; it is within 3.7 dB now (E20). Every chord E18 and + E19 hold reads as it did, as does the 450-chord sweep above: a held chord + reads far above the floor. + Turning it off no longer steps back, and turning it on again no longer resumes a stale phase (audiodsp#177). Once the shifter has run, `loop_semitones=0` keeps the two-tap read and lets the crossfade run on at diff --git a/src/shared/audiodsp_feedback_delay.c b/src/shared/audiodsp_feedback_delay.c index 625108c0..87e68594 100644 --- a/src/shared/audiodsp_feedback_delay.c +++ b/src/shared/audiodsp_feedback_delay.c @@ -529,11 +529,20 @@ static void feedback_delay_run( // match and keeps its level (E17 reads the same to 0.01 dB), and a delay too // short for the whole stretch, which matches a note rather than a chord, // never searches on. A splice that does costs twice the search. +// +// Nor does a splice where the other tap reads 8 LSB or less on average over +// the stretch (ALIGN_FLOOR): the last of a fading repeat, or silence, where +// rounding alone makes every match poor. A whole window's move there had +// nothing to match, and it left the tap reading a window off the material +// that came next: a 40 ms burst at 22.05 kHz, -5 semitones, a 30 ms window +// and Feedback 0.3 came back on its third pass 19 dB under the level +// Feedback leaves it, where before the far search it read 2.5 dB over. #define AUDIODSP_FEEDBACK_DELAY_ALIGN_LENGTH 48 #define AUDIODSP_FEEDBACK_DELAY_ALIGN_STEP_DIVISOR 3000u #define AUDIODSP_FEEDBACK_DELAY_ALIGN_STRIDE 6 #define AUDIODSP_FEEDBACK_DELAY_ALIGN_DIVISOR 40u #define AUDIODSP_FEEDBACK_DELAY_ALIGN_POOR 8 +#define AUDIODSP_FEEDBACK_DELAY_ALIGN_FLOOR 8 static int64_t align_score(const int16_t *line, uint32_t length, uint32_t channels, uint32_t write_frame, int32_t other, int32_t candidate, @@ -671,15 +680,19 @@ static float splice_align(const audiodsp_feedback_delay_config_t *config, } } // Nothing near fits: search on, once, out to the far span, where the - // whole stretch can be read from all of it. + // whole stretch can be read from all of it, unless the other tap + // reads next to nothing. if (reach < far_span && pace == whole_pace && base - far_span >= low && base + far_span <= high && - found && best_score > 0 && - best_score * AUDIODSP_FEEDBACK_DELAY_ALIGN_POOR > - align_level(state->line, length, state->write_frame, against, - pace)) { - reach = far_span; - continue; + found && best_score > 0) { + const int64_t level = align_level(state->line, length, + state->write_frame, against, pace); + if (level > (int64_t)AUDIODSP_FEEDBACK_DELAY_ALIGN_FLOOR * + AUDIODSP_FEEDBACK_DELAY_ALIGN_LENGTH && + best_score * AUDIODSP_FEEDBACK_DELAY_ALIGN_POOR > level) { + reach = far_span; + continue; + } } break; } diff --git a/tests/test_cpython_audioecho.py b/tests/test_cpython_audioecho.py index b9d9002c..4d41ac07 100644 --- a/tests/test_cpython_audioecho.py +++ b/tests/test_cpython_audioecho.py @@ -32,6 +32,7 @@ | E17 | A held note through `loop_semitones` plays its shifted pitch, C2 to C6, up and down, at 48, 44.1 and 22.05 kHz | the line at f * r within 3 dB of the note's own level | | E18 | A held chord through `loop_semitones` keeps every note's shifted line | each within 5.7 dB of the note's own level, PitchShift's worst on the same chords | | E19 | E18 on chords and intervals no splice within half a window fits: A minor, E minor and D minor in the low register, a major sixth, a major seventh | E18's bar, 5.7 dB | +| E20 | A short burst shifted by `audiodelays.PitchShift` and repeated through `loop_semitones` with feedback keeps each repeat's level, wherever the burst falls against the splices | each repeat's energy at least Feedback squared of the one before, less 6 dB | **E10 is audiodsp#153.** The feedback write rounded to nearest, so a repeat x came back as round(feedback * x) and every |x| <= 0.5 / (1 - feedback) was its @@ -59,10 +60,12 @@ """ from array import array +import math import struct import unittest import audiocore +import audiodelays import audioecho SAMPLE_RATE = 8000 @@ -1240,5 +1243,76 @@ def test_held_chords_keep_every_note(self): self.assertGreaterEqual(level, -5.7) + +@unittest.skipIf(np is None, "E20 measures energy with numpy") +class FadingRepeatSpliceTest(unittest.TestCase): + """E20, audiodsp#290. A burst shifted once by `audiodelays.PitchShift`, + then repeated through `loop_semitones` with feedback, as a pitch shifter + with feedback wires the two. Each repeat should come back Feedback + squared quieter in energy than the one before. When E19's far search + also ran where the other tap read only the last few LSB of a fading + repeat, it moved the starting tap a whole window off the burst that came + next: at 22.05 kHz, -5 semitones, a 30 ms window and Feedback 0.3, a + repeat came back 17.6 and 20.0 dB under that where the burst began 51 + and 55 ms in. The far search now needs the other tap to read more than + 8 LSB on average. The bar is 6 dB; the worst here is 3.7.""" + + RATE = 22050 + FEEDBACK = 0.3 + #: The 256ths read rate nearest -5 semitones. + STEP = 192 + WINDOW_MS = 30.0 + DELAY_MS = 100.0 + + def _repeats(self, start, hz=379.1, phase=1.0, laps=3): + """Each repeat's energy over the one before's, in dB, against + Feedback squared.""" + rate = self.RATE + gate = int(0.04 * rate) + n = np.arange(gate) + burst = np.rint(10 ** (-8 / 20) * 32767 * np.hanning(gate) * + np.sin(2 * np.pi * hz * n / rate + phase)) + window = int(self.WINDOW_MS * rate / 1000 + 0.5) + overlap = int(rate * 4 / 3000 + 0.5) + lap = int(self.DELAY_MS * rate / 1000 + 0.5) + (window + overlap) // 2 + frames = int(start + gate + (laps + 1) * lap + 2048) + values = np.zeros(frames) + values[start:start + gate] = burst + shift = audiodelays.PitchShift( + semitones=12 * math.log((self.STEP + 0.5) / 256, 2), mix=1.0, + window=window, overlap=overlap, buffer_size=512, + channel_count=1, sample_rate=rate, bits_per_sample=16, + samples_signed=True) + shift.play(audiocore.RawSample( + array("h", values.astype(np.int16).tolist()), sample_rate=rate)) + # The loop reads half a window later once its splicer has run, so + # it is set half a window short and repeats every `lap`. + node = audioecho.FeedbackDelay( + sample_rate=rate, channel_count=1, max_delay_ms=300.0, + delay_frames=lap - window // 2, feedback=self.FEEDBACK, mix=2.0, + loop_semitones=12 * math.log(self.STEP / 256, 2), + loop_window_ms=window * 1000.0 / rate) + node.play(shift) + out = array("h") + while len(out) < frames: + out.frombytes(bytes(audiocore.get_buffer(node)[1])) + rendered = np.array(out[:frames], dtype=float) + centre = start + gate / 2 + (window + overlap) / 2 + energy = [] + for k in range(1, laps + 1): + mid = centre + k * lap + piece = rendered[int(mid - lap / 2):int(mid + lap / 2)] + energy.append(10 * math.log10((piece ** 2).sum() + 1)) + want = 20 * math.log10(self.FEEDBACK) + return [energy[k + 1] - energy[k] - want for k in range(laps - 1)] + + def test_each_repeat_keeps_its_level(self): + # Across one whole splice period (two windows), in 96-frame steps. + for start in range(256, 1580, 96): + for repeat, level in enumerate(self._repeats(start), 2): + with self.subTest(start=start, repeat=repeat): + self.assertGreaterEqual(level, -6.0) + + if __name__ == "__main__": unittest.main()