«FFT additive oversampling (graphical demo of the sampling theorem)» by jamshark70

on 08 Jun'17 09:51 in guitheoryfouriertransformsampling theoremoversamplinguserview

I originally wrote this to demonstrate what sampled audio really represents -- that is, if a series of samples represents the one and only band-limited function that passes through the sampled values, we could obtain the band-limited function by adding up the cosines given by a Fourier transform. Further, doing it additively, we could select ranges of frequencies and see, interactively, each frequency band's influence on the final waveform.

  • The Gibbs effect is obviously visible for any sequences of samples that have discontinuities in the value or slope (e.g. non-bandlimited sawtooth or pulse waves).

x = Env([0, 0.75, -1, 1, 0], [0.1, 0.01, 0.4, 0.2]).discretize(128);

  • Inter-sample distortion is clearly visible for 0 dBFS pulse waves.

( var stream = Pstutter(Pseq([24, 8], inf), Pseq([1, -1], inf)).asStream; x = Signal.fill(128, stream); x.plot; )

  • If you use a rectangular window and the window can't play continuously as a cycle, there will be a discontinuity from the end of the window to the beginning. The Gibbs effect is obvious here, too. This is good to demonstrate to students why phase vocoders should pretty much always use a windowing function (e.g. Hanning).

x = Signal.fill(128, { |i| sin(i / 128 * 2pi * 1.1) });

  • Try lots of input signals. It's quite dramatic how the partials reinforce each other in the right places, and cancel in the right places, and always add up.

BTW this example has a lot of UserView tricks. Note, for example, that to do the animation, I had to set a state variable outside the scope of the drawFunc, and 'refresh' the UserView to update the frame.

Usage:

  1. Set 'x' to a Signal containing 128 or 256 values.

  2. Run the long code block.

  3. The range slider chooses a band of frequencies to include -- the audio equivalent is a pair of PV_BrickWall filters.

  4. The left-hand button will add partials at timed intervals.

  5. The right-hand button will add one partial, and animate the way that the new partial "bends" the waveform. This is really instructive!

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
// Set 'x' to the input waveform
(
var stream = Pstutter(64, Pseq([1, -1], inf))/*.drop(16)*/.asStream;
x = Signal.fill(128, stream);
x.plot;
)

// Then run this whole block
(
var factor = 8,
waitTime = 0.3,
scale = 0.3,

sigSize = x.size * factor,
sig,
intermediateSig = nil, interp = 0,
w = Window("inverse Fourier", Rect(400, 200, 800, 600)).front,
m, uv, r, fftSize, halfSize;
var indexRange, indexLNum, indexRNum, runButton, stepButton,
rangeSpec,
rangeFunc = { |view| [rangeSpec.map(view.lo).asInteger, rangeSpec.map(view.hi).asInteger] };

var allCosines;

y = x.fft(Signal.newClear(x.size), Signal.fftCosTable(x.size));
y = y.asPolar;

fftSize = y.rho.size;
halfSize = fftSize div: 2;
rangeSpec = [0, halfSize, \lin, 1, 0].asSpec;

allCosines = Array.fill(fftSize div: 2 + 1, { |j|
	var mag = y.rho[j], phase = y.theta[j],
	magScale = mag / fftSize, phaseScale = 2pi * j / sigSize;
	if(j > 0 and: { j < (fftSize div: 2) } ) { magScale = magScale * 2 };
	Signal.fill(sigSize, { |i|
		cos((i * phaseScale) + phase) * magScale;
	});
});

sig = allCosines[0];

w.layout = VLayout(
	uv = UserView(),
	View().maxHeight_(60).layout_(
		VLayout(
			HLayout(
				indexLNum = NumberBox().fixedWidth_(80), // .fixedSize_(Size(80, 20)),
				indexRNum = NumberBox().fixedWidth_(80), // .fixedSize_(Size(80, 20)),
				indexRange = RangeSlider().orientation_(\horizontal)
			).margins_(2),
			// indexView = LayoutValueSlider(initValue: 0, spec: [0, fftSize div: 2, \lin, 1, 0]),
			HLayout(
				nil,
				runButton = Button().fixedWidth_(80),
				stepButton = Button().fixedWidth_(80),
				nil
			).margins_(2)
		).margins_(2)
	)
);  // display all

runButton.states_([["stopped"], ["running", Color.black, Color(0.7, 1, 0.7)]])
.action_({ |view|
	if(view.value > 0) {
		// indexView.enabled = false;
		[indexRange, indexLNum, indexRNum].do(_.enabled = false);
		r.play;
	} {
		// indexView.enabled = true;
		[indexRange, indexLNum, indexRNum].do(_.enabled = true);
		r.stop;
	};
});

stepButton.states_([["step"]])
.action_({ |view|
	var lo, hi;
	#lo, hi = rangeFunc.(indexRange);
	if(hi < (fftSize div: 2)) {
		hi = hi + 1;
		// j = indexView.hi.asInteger;
		if(y.rho[hi] < 0.0001) {
			sig = allCosines[lo .. hi].sum;
			indexRange.hi = rangeSpec.unmap(hi);
			indexRNum.value = hi;
			uv.refresh;
		} {
			{
				indexRange.hi = rangeSpec.unmap(hi);
				indexRNum.value = hi;
				[indexRange, indexLNum, indexRNum].do(_.enabled = false);
				// indexRange.enabled = false;
				view.enabled = false;
				intermediateSig = allCosines[hi];
				forBy(0.0, 1.0, 0.01, { |frac|
					interp = frac;
					uv.refresh;
					if(frac == 0.0) { 0.5.wait } { 0.02.wait };
				});
				intermediateSig = nil;
				sig = allCosines[lo .. hi].sum;
				uv.refresh;
				view.enabled_(true).focus(true);
				[indexRange, indexLNum, indexRNum].do(_.enabled = true);
				// indexView.enabled = true;
			}.fork(AppClock);
		};
	};
});

indexRange.action_({ |view|
	var lo, hi;
	#lo, hi = rangeFunc.(view);
	sig = allCosines[lo .. hi].sum;
	uv.refresh;
	indexLNum.value = lo;
	indexRNum.value = hi;
}).setSpan(0, 0);

indexLNum.action = { |view|
	var v = view.value;
	if(v.inclusivelyBetween(0, indexRNum.value)) {
		indexRange.activeLo_(rangeSpec.unmap(v));
	};
};

indexRNum.action = { |view|
	var v = view.value;
	if(v.inclusivelyBetween(indexLNum.value, halfSize)) {
		indexRange.activeHi_(rangeSpec.unmap(v));
	};
};


uv.drawFunc = { |view|
	var bounds = view.bounds,
	height = bounds.height, width = bounds.width,
	scaleY = height * (0.5 - scale), scaleX;
	Pen.color_(Color.gray(0.7))
	.moveTo(Point(0, scaleY)).lineTo(Point(width, scaleY))
	.moveTo(Point(0, height - scaleY)).lineTo(Point(width, height - scaleY))
	.moveTo(Point(0, height * 0.5)).lineTo(Point(width, height * 0.5))
	.stroke;
	if(factor > 1) {
		scaleX = width / x.size;
		x.size.do { |i|
			i = i * scaleX;
			Pen.moveTo(Point(i, scaleY)).lineTo(Point(i, scaleY - 6))
			.moveTo(Point(i, height - scaleY)).lineTo(Point(i, height - scaleY + 6))
			.moveTo(Point(i, height * 0.5 - 4)).lineTo(Point(i, height * 0.5 + 4))
			.stroke;
		};
	};
	Pen.color_(Color.white);
	scaleX = width / sigSize;
	sig.do { |y, i|
		if(intermediateSig.notNil) {
			y = y + (intermediateSig[i] * interp);
		};
		y = height * (0.5 - (y * scale));
		i = i * scaleX;
		if(i == 0) { Pen.moveTo(Point(i, y)) } { Pen.lineTo(Point(i, y)) };
	};
	Pen.stroke;
	if(intermediateSig.notNil) {
		Pen.color_(Color.gray(0.7));
		intermediateSig.do { |y, i|
			y = blend(y, sig[i] + y, interp);
			y = height * (0.5 - (y * scale));
			i = i * scaleX;
			if(i == 0) { Pen.moveTo(Point(i, y)) } { Pen.lineTo(Point(i, y)) };
		};
		Pen.stroke;
	};
};
uv.refresh;

r = Task({
	var halfSize = y.rho.size div: 2, lo, hi;
	#lo, hi = rangeFunc.(indexRange);
	while { hi < halfSize } {
		hi = hi + 1;
		indexRange.hi = rangeSpec.unmap(hi);
		indexRNum.value = hi;
		sig = allCosines[lo .. hi].sum;
		uv.refresh;
		waitTime.wait;
	};
	runButton.value = 0;
}, AppClock);

w.onClose = { r.stop };
)
raw 5185 chars (focus & ctrl+a+c to copy)
reception
comments