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
11 changes: 11 additions & 0 deletions docs/upstream-diff.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 20 additions & 7 deletions src/shared/audiodsp_feedback_delay.c
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
74 changes: 74 additions & 0 deletions tests/test_cpython_audioecho.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -59,10 +60,12 @@
"""

from array import array
import math
import struct
import unittest

import audiocore
import audiodelays
import audioecho

SAMPLE_RATE = 8000
Expand Down Expand Up @@ -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()
Loading