Oh wait, I see the problem with the triggering approach.
The assumption behind the triggering approach is that the phase should always wrap around to 0.
This assumption could be true, if the wavelength is an exact integer number of samples, although floating-point rounding error might make that ideal impossible to achieve.
But for the vast majority of frequencies, the wavelength is not an integer number of samples. In that case, the correct way for phase to wrap around is modulo, not a hard-sync to 0.
Phasor, left to its own devices, does this in fact. Let’s get the phase samples from a plot, and then split them into subarrays at the wraparound points (so that each subarray is a ramp up).
p = {
var phase = Phasor.ar(0, 1002 * SampleDur.ir, 0, 1);
[phase, SinOsc.ar(0, phase * 2pi)]
}.plot;
q = p.value[0];
r = q.separate { |a, b| b < a };
// these are all the reset points
r.collect(_.first);
-> [ 0.0, 0.022448940202594, 0.02217679284513, 0.021904645487666, 0.021632498130202, 0.021360350772738, 0.021088203415275, 0.020816056057811, 0.020543908700347, 0.020271761342883 ]
// and the differences between the reset points
r.collect(_.first).differentiate
-> [ 0.0, 0.022448940202594, -0.00027214735746384, -0.00027214735746384, -0.00027214735746384, -0.00027214735746384, -0.00027214735746384, -0.00027214735746384, -0.00027214735746384, -0.00027214735746384 ]
On every cycle, the Phasor ramp is shifting down slightly, by the same amount every time. This is correct for modulo with a constant increment. (At a different frequency, it may shift up slightly.) If we do the same thing with integers, let’s say, increment = 3, upper limit = 8 (8/3 isn’t an integer), then: 0, 3, 6 // 1, 4, 7 // 2, 5 // 0, 3, 6 etc. – each “cycle” is up-one (and another interesting observation – the 3+3+2 pattern is related to aliasing
). It would be wrong to do a +3 cycle over 8 as 0, 3, 6, 0, 3, 6, 0, 3, 6 – this involves a discontinuity every 3 samples.
By contrast, with Sweep and a trigger:
p = {
var phase = Sweep.ar(Impulse.ar(1002), 1002);
[phase, SinOsc.ar(0, phase * 2pi)]
}.plot;
q = p.value[0];
r = q.separate { |a, b| b < a };
r.collect(_.first);
-> [ 0.022721087560058, -0.0, -0.0, -0.0, -0.0, -0.0, -0.0, -0.0, -0.0, -0.0 ]
r.collect(_.last);
-> [ 1.022449016571, 0.97700679302216, 0.97700679302216, 0.97700679302216, 0.97700679302216, 0.97700679302216, 0.97700679302216, 0.97700679302216, 0.97700679302216, 0.97700679302216 ]
Except for a glitch in the first cycle (which could be a UGen init bug), the endpoints of each cycle are exactly the same… but if the wavelength is not an integer number of samples, then there is no way that this is correct. That means there is already a minor discontinuity in the sine wave – it might not be enough to notice in this basic case, but when you crank up the FM, that might make it noticeable.
Incidentally, the Phasor trigger is useful for implementing oscillator hard-sync! But you’re not looking for oscillator sync here.
I guess one other take away from this is about streamlining and simplifying code. Usually(?) Often(?) the best solution is the one that has removed as much unnecessary stuff as possible.
hjh