«Paulstretch for SuperCollider» by jpdrecourt

on 07 Apr'20 03:46 in ambientdronestretching

This is a port of the basic Paulstretch algorithm to SuperCollider (no onset detection). Mono version, for stereo, use 2 instances hard panned. The sound buffer needs to be mono too, so use Buffer.readChannel to extract separate channels. The stretch parameter is modulatable. That allows for phasing effects if using more than one instance.

Thanks to Paul for his feedback! Check his work at http://www.paulnasca.com/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
(
SynthDef(\paulstretchMono, { |out = 0, bufnum, envBufnum, pan = 0, stretch = 50, window = 0.25, amp = 1|
	// Paulstretch for SuperCollider
	// Based on the Paul's Extreme Sound Stretch algorithm by Nasca Octavian PAUL
	// https://github.com/paulnasca/paulstretch_python/blob/master/paulstretch_steps.png
	//
	// By Jean-Philippe Drecourt
	// http://drecourt.com
	// April 2020
	//
	// Arguments:
	// out: output bus (stereo output)
	// bufnum: the sound buffer. Must be Mono. (Use 2 instances with Buffer.readChannel for stereo)
	// envBufnum: The grain envelope buffer created as follows:
	//// envBuf = Buffer.alloc(s, s.sampleRate, 1);
	//// envSignal = Signal.newClear(s.sampleRate).waveFill({|x| (1 - x.pow(2)).pow(1.25)}, -1.0, 1.0);
	//// envBuf.loadCollection(envSignal);
	// pan: Equal power panning, useful for stereo use.
	// stretch: stretch factor (modulatable)
	// window: the suggested grain size, will be resized to closest fft window size
	// amp: amplification
	var trigPeriod, sig, chain, trig, pos, fftSize;
	// Calculating fft buffer size according to suggested window size
	fftSize = 2**floor(log2(window*SampleRate.ir));
	// Grain parameters
	// The grain is the exact length of the FFT window
	trigPeriod = fftSize/SampleRate.ir;
	trig = Impulse.ar(1/trigPeriod);
	pos = Demand.ar(trig, 0, demandUGens: Dseries(0, trigPeriod/stretch));
	// Extraction of 2 consecutive grains
	// Both grains need to be treated together for superposition afterwards
	sig = [GrainBuf.ar(1, trig, trigPeriod, bufnum, 1, pos, envbufnum: envBufnum),
		GrainBuf.ar(1, trig, trigPeriod, bufnum, 1, pos + (trigPeriod/(2*stretch)), envbufnum: envBufnum)]*amp;
	// FFT magic
	sig = sig.collect({ |item, i|
		chain = FFT(LocalBuf(fftSize), item, hop: 1.0, wintype: -1);
		// PV_Diffuser is only active if its trigger is 1
		// And it needs to be reset for each grain to get the smooth envelope
		chain = PV_Diffuser(chain, 1 - trig);
		item = IFFT(chain, wintype: -1);
	});
	// Reapply the grain envelope because the FFT phase randomization removes it
	sig = sig*PlayBuf.ar(1, envBufnum, 1/(trigPeriod), loop:1);
	// Delay second grain by half a grain length for superposition
	sig[1] = DelayC.ar(sig[1], trigPeriod/2, trigPeriod/2);
	// Panned output
	Out.ar(out, Pan2.ar(Mix.new(sig), pan));
}).add;
)

// Example
({
	var envBuf, envSignal, buffer;
	buffer = Buffer.read(s, Platform.resourceDir +/+ "sounds/a11wlk01.wav");
	// The grain envelope
	envBuf = Buffer.alloc(s, s.sampleRate, 1);
	envSignal = Signal.newClear(s.sampleRate).waveFill({|x| (1 - x.pow(2)).pow(1.25)}, -1.0, 1.0);
	envBuf.loadCollection(envSignal);
	s.sync();
	// Runs indefinitely
	Synth(\paulstretchMono, [\bufnum, buffer.bufnum, \envBufnum, envBuf.bufnum]);
}.fork;
)
raw 2804 chars (focus & ctrl+a+c to copy)
reception
comments
Jonathan Segel user 22 Aug'20 13:24

oh, I love this sound. Paulstretch is great, now we need to make thonk, remember that one? Altiverb people, I believe.

tedthtrumpet user 26 Aug'20 15:10

thonk+  yes! I used to massively over-use that back in the day…

kevinsmcfarland user 21 Jul'22 17:13

Thanks so much for this, I'm finding this port very useful! However, I found a bug that should be a quick fix.

I noticed that the stretch factor doesn't yield consistent results, such that longer buffers yield results significantly shorter in duration than expected, correlating exactly with the duration of the buffer, in seconds. (For instance, a 4-second buffer yields a duration 4 times shorter than expected, and notably, when the stretch factor is set to 1, it plays 4x faster than the original.) I think this is why:

pos returns values that represent the time points of grains as they are to be sampled from the buffer, measured in seconds.

However, pos as it is needed by GrainBuf should be a value scaled between 0 and 1, representing a time point between the beginning and end of the buffer.

My fix was to simply add an argument bufdur which is calculated after buffer is defined (with a little bit of latency):

0.1.wait; bufdur = buffer.numFrames / buffer.sampleRate;

which is then passed to the synthDef with these alterations to pos and sig:

pos = Demand.ar(trig, 0, demandUGens: Dseries(0, trigPeriod/(stretch*bufdur)));

sig = [GrainBuf.ar(1, trig, trigPeriod, bufnum, 1, pos, envbufnum: envBufnum), GrainBuf.ar(1, trig, trigPeriod, bufnum, 1, pos + (trigPeriod/(2*stretch*bufdur)), envbufnum: envBufnum)]*amp;

It sounds like this fixed the problem, although it would be great if bufdur could be generated as soon as info about the buffer could be returned instead of my amateurish 0.1.wait; line. Also if I'm missing something feel free to correct, still learning here.

rd user 03 Aug'22 14:06

nice work. thanks kindly. ps. there's a BufDur.kr(bufnum) Ugen you can use for this, instead of passing the value in.