Skip to content

Commit e3fc87c

Browse files
HEnquistclaude
andcommitted
Use a length-adaptive linear crossfade to hide Slip corrections
Replace the fixed 8-frame smootherstep crossfade with a linear ramp whose length adapts to the chunk size, up to 128 frames. Listening tests on sustained pure tones (the worst case) showed the slip artefact is governed by the peak retiming rate of the crossfade: its spectral spread scales with 1/length, so a longer fade keeps the disturbance narrow and masked by the signal. Any velocity shaping that peaks (smootherstep is 1.875x steeper in the middle than its average) or concentrates the retiming (end-loaded curves, a hard cut) only widens it. A linear ramp is the minimum-peak and minimum-energy monotonic fade, so the old 8-frame smootherstep was in practice barely better than a hard cut. - Drop the FADE compile-time table; compute the linear weight inline. - Replace the CROSSFADE_LEN constant with MAX_CROSSFADE_LEN (128) plus crossfade_len_for(chunk), stored per instance and recomputed on resize/reset. Large chunks get the full 128-frame fade; small chunks shrink it instead of being rejected, lowering the minimum chunk size from 18 to 4. - Update the docs to match, and adjust tests for the narrower sustainable ratio range at the longer fade. Also add examples/gen_test_tones.py, the stepped-tone generator used to audition the crossfade. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e912257 commit e3fc87c

2 files changed

Lines changed: 186 additions & 69 deletions

File tree

examples/gen_test_tones.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env python3
2+
"""Generate a "song" of stepped pure sine tones as raw little-endian f64.
3+
4+
The output format matches what the `adjust_ratio_f64` example reads and writes:
5+
interleaved 64-bit floats, little-endian, no header, two channels.
6+
7+
The pitch steps through a major scale from `--root`, four octaves up, one note
8+
every `--seconds`. Sustained pure tones are the worst case for hearing a
9+
resampler's artefacts, and stepping them as a scale is easier on the ears than
10+
octave leaps. A short raised-cosine fade at every note boundary keeps the pitch
11+
changes click-free, so the only discontinuities in the file are the ones the
12+
resampler introduces.
13+
14+
The Slip resampler makes one single-frame correction each time the accumulated
15+
drift reaches a whole frame, i.e.
16+
17+
corrections_per_second = (offset_ppm / 1e6) * fs
18+
19+
so this script also prints the ppm offset to feed the example for a chosen,
20+
easily audible correction rate (default 2 Hz).
21+
22+
Example:
23+
python examples/gen_test_tones.py
24+
cargo run --release --example adjust_ratio_f64 \\
25+
SlipFixedOutput test_tones_f64_2ch.raw out.raw 2 45
26+
"""
27+
import argparse
28+
import math
29+
import struct
30+
import sys
31+
32+
CHANNELS = 2 # interleaved stereo, both channels identical
33+
OCTAVES = 4 # the scale ascends this many octaves from the root
34+
FADE_MS = 15.0 # raised-cosine fade at each note edge, for click-free boundaries
35+
36+
37+
def main():
38+
p = argparse.ArgumentParser(description=__doc__,
39+
formatter_class=argparse.RawDescriptionHelpFormatter)
40+
p.add_argument("-o", "--out", default="test_tones_f64_2ch.raw",
41+
help="output raw f64 file (default: %(default)s)")
42+
p.add_argument("--fs", type=float, default=44100.0,
43+
help="sample rate in Hz (default: %(default)s)")
44+
p.add_argument("--seconds", type=float, default=2.0,
45+
help="duration of each note in seconds (default: %(default)s)")
46+
p.add_argument("--amp", type=float, default=0.5,
47+
help="tone amplitude, 0..1 (default: %(default)s, ~-6 dBFS)")
48+
p.add_argument("--root", type=float, default=130.813,
49+
help="scale root frequency in Hz (default: %(default)s, ~C3)")
50+
p.add_argument("--slip-hz", type=float, default=2.0,
51+
help="target Slip correction rate to compute the ppm for (default: %(default)s)")
52+
args = p.parse_args()
53+
54+
fs = args.fs
55+
n_per_tone = int(round(args.seconds * fs))
56+
fade = min(int(round(FADE_MS * 1e-3 * fs)), n_per_tone // 2)
57+
58+
# A major scale ascending over OCTAVES, ending on the top root. Semitone
59+
# offsets within an octave: do re mi fa sol la ti.
60+
major = [0, 2, 4, 5, 7, 9, 11]
61+
semitones = [o * 12 + s for o in range(OCTAVES) for s in major]
62+
semitones.append(OCTAVES * 12) # finish on the octave
63+
freqs = [args.root * 2.0 ** (st / 12.0) for st in semitones]
64+
65+
# Build one channel: each note is a sine faded to silence at both edges.
66+
mono = []
67+
for f in freqs:
68+
w = 2.0 * math.pi * f / fs
69+
for i in range(n_per_tone):
70+
s = args.amp * math.sin(w * i)
71+
if fade > 0:
72+
if i < fade:
73+
s *= 0.5 - 0.5 * math.cos(math.pi * i / fade)
74+
elif i >= n_per_tone - fade:
75+
j = n_per_tone - 1 - i
76+
s *= 0.5 - 0.5 * math.cos(math.pi * j / fade)
77+
mono.append(s)
78+
79+
# Interleave identical channels and pack as little-endian f64.
80+
packer = struct.Struct("<" + "d" * CHANNELS)
81+
with open(args.out, "wb") as fh:
82+
buf = bytearray()
83+
for s in mono:
84+
buf += packer.pack(*([s] * CHANNELS))
85+
if len(buf) >= 1 << 20:
86+
fh.write(buf)
87+
buf = bytearray()
88+
fh.write(buf)
89+
90+
total_frames = len(mono)
91+
ppm = args.slip_hz * 1e6 / fs
92+
print(f"Wrote {args.out}")
93+
print(f" {len(freqs)} notes x {args.seconds:g} s = {total_frames / fs:.1f} s, "
94+
f"{CHANNELS} ch, fs = {fs:g} Hz")
95+
print(f" frequencies (Hz): {', '.join(f'{f:.1f}' for f in freqs)}")
96+
print()
97+
print(f"For a {args.slip_hz:g} Hz Slip correction rate use offset = {ppm:.1f} ppm:")
98+
print(f" cargo run --release --example adjust_ratio_f64 "
99+
f"SlipFixedOutput {args.out} out.raw {CHANNELS} {round(ppm)}")
100+
101+
102+
if __name__ == "__main__":
103+
sys.exit(main())

0 commit comments

Comments
 (0)