Imperfection of language-based timing

This goes back to an observation of my colleague Gerhard Eckel. I posted this a while ago on the mailing list, but can’t find it anymore in the archives. As I think it’s an interesting topic I’m reposting it here again for future reference.

Timing in general is a crucial topic and practically users are mainly confronted with asynchronicity between server and language, latency, quantization, the Out/OffsetOut differences etc. Sample-accurate timing isn’t relevant in all contexts, nevertheless in certain situations it’s absolutely necessary.

Starting point: you can have sample-accurate timing with synths, but not with language based sequencing in RT. The reason for this is a kind of calibration that different clocks, responsible for SC timing and RT output must perform. Frankly I am not aware of the details, there has been a lengthy discussion and there was no consensus if this inaccuracy could be circumvented at all. Practically a workaround is the usage of NRT synthesis, therewith you can get sample-accuracy also with patterns, this is shown in the second part of the example.

The following results come from SC 3.9.3 on OSX with built-in audio and standard driver blocksize 512 (samplerate 44100), other settings might even produce more deviations.

Note that SynthDef ‘dirac’ already uses OffsetOut, so this inaccuracy is independant from the rough one you get with Out alone – see the OffsetOut helpfile for the reason for this (so for lang-based timing with short durations always use OffsetOut).

(
s = Server.local;
Server.default = s;
s.boot;
)


(
// use store for later NRT usage,
// define with dummy channel here for use with pattern recording,
// play reference pulse train of synth to channel 0, "real" Pbind pulse train to channel 1
// we need an out arg anyway for aPattern.record

SynthDef(\diracs, { |out = 0, duration = 60|
    OffsetOut.ar(out, [Impulse.ar(10), DC.ar(0)] * EnvGen.ar(Env([1, 1, 0], [duration + 0.01, 0]), doneAction: 2))
}).store;

SynthDef(\dirac, { |out = 0|
    OffsetOut.ar(out, [DC.ar(0), FreeSelf.kr(Impulse.ar(1))])
}).store;

// Synth as Pbind for pattern recording, needs legato = 1 to get equal duration
p = Pbind(\instrument, \diracs, \duration, 60);
q = Pbind(\instrument, \dirac, \dur, 0.1);
)


(
// record 60 sec of both pulse trains in realtime to compare,
// wait until finishing confirmed in post window
// mute to avoid playing hard clicks to speakers

c = TempoClock.();
t = 60;
s.volume.mute;

~date = Date.getDate.stamp;
~fileName = "diracsRT_synthL_patR" ++ "_" ++ ~date ++ ".aiff";

// Reference pulse train (Pbind with one synth) needs to know overall duration
r = Ppar([p <> Pbind(\dur, Pn(t, 1)), q]);

r.record(~fileName, clock: c, dur: t + 0.05);

// need CmdPeriod here as record doesn't stop (broken)
c.sched(t + 3, { CmdPeriod.run; s.volume.unmute });
)


(
// helper function for index search
~indicesOfEqualWithPrecision = { |seq, item, prec = 0.0001|
    var indices;
    seq.do { |val, i|
        if (item.equalWithPrecision(val, prec)) { indices = indices.add(i) }
    };
    indices
};

// analysing function

~analyse = { |fileName|
    ~soundFile = SoundFile.openRead(fileName);
    ~data = FloatArray.fill(~soundFile.numFrames * ~soundFile.numChannels, 0);
    ~soundFile.readData(~data);
    ~soundFile.close;
    ~stereo = ~data.clump(2).flop;
    ~dataL = ~stereo[0];
    ~dataR = ~stereo[1];


    // get indices of diracs
    ~leftIndices = ~indicesOfEqualWithPrecision.(~dataL, 1);
    ~rightIndices = ~indicesOfEqualWithPrecision.(~dataR, 1);

    // look at deltas
    ~leftDeltas = ~leftIndices.differentiate.drop(1);
    ~rightDeltas = ~rightIndices.differentiate.drop(1);

    // count occurences of (possibly) different deltas

    ~leftDeltaSet = ~leftDeltas.asSet.collect { |x| [x, ~leftDeltas.occurrencesOf(x)] };
    ~rightDeltaSet = ~rightDeltas.asSet.collect { |x| [x, ~rightDeltas.occurrencesOf(x)] };

    "".postln;
    "occurences of sample deltas with single synth: ".postln;
    ~leftDeltaSet.postln;"".postln;

    "occurences of sample deltas with Pbind: ".postln;
    ~rightDeltaSet.postln;"".postln;
};
)


// analyse, takes some seconds

~analyse.(~fileName)

->

occurences of sample deltas with single synth: 
Set[ [ 4410, 600 ] ]

occurences of sample deltas with Pbind: 
Set[ [ 4446, 3 ], [ 4374, 3 ], [ 4410, 594 ] ]


// verify equal start

~leftIndices[0]

~rightIndices[0]


/////

(
// render 60 secs of the same stereo audio generation in NRT mode

c = TempoClock.();
t = 60;
s.volume.mute;

~date = Date.getDate.stamp;
~fileName = "diracsRT_synthL_patR" ++ "_" ++ ~date ++ ".aiff";

// Reference pulse train (Pbind with one synth) needs to know overall duration
r = Ppar([p <> Pbind(\dur, Pn(t + 0.05, 1)), q]);

// to be passed to render stereo
o = ServerOptions.new.numOutputBusChannels = 2;
r.render(~fileName, t + 0.05, options: o);
)

~analyse.(~fileName)

// perfect NRT timing
->

occurences of sample deltas with single synth: 
Set[ [ 4410, 600 ] ]

occurences of sample deltas with Pbind: 
Set[ [ 4410, 600 ] ]

Daniel

3 Likes

Here are the results of running this experiment on my laptop, sc 3.10-beta on arch linux 64bit, kernel 4.18.5-arch1-1-ARCH, with jack using 1024 samples buffer size and 48000 Hz sampling rate.

On my system instead, I get:

occurences of sample deltas with single synth:
Set[ [ 4800, 600 ] ]

occurences of sample deltas with Pbind:
Set[ [ 4800, 600 ] ]

Which looks quite ok to me.

Gives:
→ 7001
→ 7001

1 Like

Thanks for the check, that’s very interesting !
So, if I got it right, you are reporting sample-accuracy with language based RT-timing ?
I’m very surprised that this is the case.

On my OSX tests the calibration happened every 10-20 secs or so, it might be that there are larger calibration intervals on linux. So you could check with e.g. t = 200 or 300 instead of 60.

BTW with 48000 Hz on OSX and built-in audio standard driver blocksize 512 I get similar results as with 44100 Hz

occurences of sample deltas with single synth: 
Set[ [ 4800, 600 ] ]

occurences of sample deltas with Pbind: 
Set[ [ 4834, 1 ], [ 4800, 592 ], [ 4833, 2 ], [ 4799, 2 ], [ 4767, 3 ] ]

With t=300, things don’t look as rosy anymore…

occurences of sample deltas with single synth:
Set[ [ 4800, 600 ] ]

occurences of sample deltas with Pbind:
Set[ [ 4810, 42 ], [ 4762, 4 ], [ 4803, 93 ], [ 4794, 77 ], [ 4756, 1 ], [ 4815, 11 ], [ 4792, 48 ], [ 4822, 36 ], [ 4827, 13 ], [ 4771, 3 ], [ 4770, 7 ], [ 4817, 13 ], [ 4808, 75 ], [ 4764, 7 ], [ 4834, 3 ], [ 4755, 3 ], [ 4765, 7 ], [ 4837, 4 ], [ 4818, 15 ], [ 4841, 3 ], [ 4790, 26 ], [ 4820, 26 ], [ 4782, 47 ], [ 4833, 8 ], [ 4838, 3 ], [ 4786, 29 ], [ 4816, 18 ], [ 4780, 40 ], [ 4775, 9 ], [ 4813, 28 ], [ 4789, 30 ], [ 4821, 19 ], [ 4832, 8 ], [ 4781, 40 ], [ 4814, 14 ], [ 4828, 19 ], [ 4757, 2 ], [ 47…etc…

Thanks for rechecking, that looks rather normal (unfortunately)

Ah, yes, for the single synth, duration would have to be replaced too, but the result would be exact anyway (probably)

The best we can do in RT is checking different combinations of samplerate, hardware (driver) and hardware driver buffer size to minimize deviations. IIRC with my setup a buffer size of 512 gave the best results.

You should write SynthDef(...).add, otherwise the parameters like duration are not sent from the pattern.

With .add and t=300 the image is not very different

→ a Function

occurences of sample deltas with single synth:
Set[ [ 4800, 600 ] ]

occurences of sample deltas with Pbind:
Set[ [ 4832, 10 ], [ 4762, 8 ], [ 4801, 139 ], [ 4753, 2 ], [ 4824, 38 ], [ 4834, 6 ], [ 4813, 38 ], [ 4785, 39 ], [ 4759, 2 ], [ 4774, 15 ], [ 4817, 20 ], [ 4755, 1 ], [ 4841, 2 ], [ 4831, 7 ], [ 4770, 9 ], [ 4788, 38 ], [ 4758, 1 ], [ 4805, 89 ], [ 4792, 46 ], [ 4833, 4 ], [ 4793, 50 ], [ 4823, 25 ], [ 4816, 33 ], [ 4795, 73 ], [ 4806, 84 ], [ 4775, 11 ], [ 4830, 14 ], [ 4803, 95 ], [ 4781, 39 ], [ 4778, 25 ], [ 4780, 26 ], [ 4827, 19 ], [ 4768, 6 ], [ 4765, 8 ], [ 4783, 54 ], [ 4842, 1 ], [ 4822, 24 ], […etc…

aha, in this case it probably makes no difference.

‘store’ is needed for the NRT part of the example and is generally ok with patterns, not only in this case,
from the help: “Write the defFile and store it in the SynthDescLib specified by libname”.

So duration as extra arg is passed correctly:

SynthDef(\diracs, { |out = 0, duration = 60, amp = 0.1|
    OffsetOut.ar(out, [Impulse.ar(10), DC.ar(0)] * 
         EnvGen.ar(Env([amp, amp, 0], [duration + 0.01, 0]), doneAction: 2))
}).store;

p = Pbind(\instrument, \diracs, \duration, 0.5, \dur, Pn(1, 1), \amp, 0.3).play

Ultimately, a comment of James in a thread on the mailing list has shed more light into this: the vast amount of jitter seems to be produced by system action (NTP)! After a switch to OS 10.15 the large jumps in the realtime variants analyzed in the first post of this thread – and confirmed by @shiihs on Linux – have gone away! Besides, if you want to check the realtime tests, take the variants below which contain an additional path definition necessary for newer SC versions.

https://www.listarc.bham.ac.uk/lists/sc-users/msg69793.html

On older OS systems disabling ”Set date and time automatically” in system preferences should do the trick, I’m not aware of the necessary terminal commands on Linux but some googling (NTP) should help.

Besides, tests on supernova show sample-accurate timing with the original examples whereas scsynth gives maximum deviations of just one sample.

I’m very happy about this finding because it enables nice synthesis options with patterns being unreliable so far.

E.g., a pulsar stream like that didn’t sound smoothly. With new settings and max deviations of one sample this is perfectly ok now (at least for my ears). There might be cases where the supernova variant could turn out to be better, I’d have to do further tests.

// ATTENTION: with NTP you get nasty and sometimes loud bumps every some seconds

(
SynthDef(\sinePerc, { |out, freq = 400, att = 0.005, rel = 0.05, amp = 0.1|
	OffsetOut.ar(out, EnvGen.ar(Env.perc(att, rel), doneAction: 2) * SinOsc.ar(freq, 0, amp) ! 2)
}).add;
)

(
x = Pbind(
	\instrument, \sinePerc,
	\freq, 500,
	\dur, 0.005,
	\att, 0.002,
	\rel, 0.002
).play
)

x.stop

It could also be interesting for Windows users to check those issues, I have no machine available at the moment.

Results on OS 10.15.7 and SC 3.11.2 (and on OS 10.10.5 and SC 3.9.3 with disabled automatic date and time setting)

The latency-based timing variant goes back to a suggestion of Christof Ressi to take only one logical time, it doesn’t make a difference in this case, though.

scsynth language-based timing (test 1): max deviation of 1 sample
scsynth latency-based timing (test 2): max deviation of 1 sample

supernova language-based timing (test 1): sample-accurate
supernova latency-based timing (test 2): sample-accurate

There were no principle differences between 44.1 and 48 kHz. Further testing should check longer run times than one minute and other interfaces (checked with built-in out now).

// for tests with scsynth
(
Server.scsynth;
s.reboot;
)

// for tests with supernova
(
Server.supernova;
s.reboot;
)

// prepare for test 1 - language-scheduled timing
(
// use store for later NRT usage,
// define with dummy channel here for use with pattern recording,
// play reference pulse train of synth to channel 0, "real" Pbind pulse train to channel 1
// we need an out arg anyway for aPattern.record

SynthDef(\diracs, { |out = 0, duration = 60|
    OffsetOut.ar(out, [Impulse.ar(10), DC.ar(0)] * EnvGen.ar(Env([1, 1, 0], [duration + 0.01, 0]), doneAction: 2))
}).store;

SynthDef(\dirac, { |out = 0|
    OffsetOut.ar(out, [DC.ar(0), FreeSelf.kr(Impulse.ar(1))])
}).store;

// Synth as Pbind for pattern recording, needs legato = 1 to get equal duration
p = Pbind(\instrument, \diracs, \duration, 60);
q = Pbind(\instrument, \dirac, \dur, 0.1);
)


// prepare for test 2 - latency-based timing
(
// use store for later NRT usage,
// define with dummy channel here for use with pattern recording,
// play reference pulse train of synth to channel 0, "real" Pbind pulse train to channel 1
// we need an out arg anyway for aPattern.record

SynthDef(\diracs, { |out = 0, duration = 60|
    OffsetOut.ar(out, [Impulse.ar(10), DC.ar(0)] * EnvGen.ar(Env([1, 1, 0], [duration + 0.01, 0]), doneAction: 2))
}).store;

SynthDef(\dirac, { |out = 0|
    OffsetOut.ar(out, [DC.ar(0), FreeSelf.kr(Impulse.ar(1))])
}).store;

// Synth as Pbind for pattern recording
p = Pbind(\instrument, \diracs, \duration, 60);

~latency = s.latency;

// we need a finite Pattern !
q = Pbind(
	\instrument, \dirac,
	\dur, 0,
	// dur sequencing to be inserted here
	\realDur, Pn(0.1, 601),
	\latency, Pfunc { |ev|
		var latency = ~latency;
		~latency = ~latency + ev[\realDur];
		latency
	}
);
)


// run test 1 or 2

(
// record 60 sec of both pulse trains in realtime to compare,
// wait until finishing confirmed in post window
// mute to avoid playing hard clicks to speakers

c = TempoClock.();
t = 60;
s.volume.mute;

~date = Date.getDate.stamp;
~fileName = Platform.recordingsDir +/+ "diracsRT_synthL_patR" ++ "_" ++ ~date ++ ".aiff";

// Reference pulse train (Pbind with one synth) needs to know overall duration
r = Ppar([p <> Pbind(\dur, Pn(t, 1)), q]);

r.record(~fileName, clock: c, dur: t + 0.05);

// need CmdPeriod here as record doesn't stop (broken)
c.sched(t + 3, { CmdPeriod.run; s.volume.unmute });
)


(
// helper function for index search
~indicesOfEqualWithPrecision = { |seq, item, prec = 0.0001|
    var indices;
    seq.do { |val, i|
        if (item.equalWithPrecision(val, prec)) { indices = indices.add(i) }
    };
    indices
};

// analysing function

~analyse = { |fileName|
    ~soundFile = SoundFile.openRead(fileName);
	~data = FloatArray.fill(~soundFile.numFrames * ~soundFile.numChannels, 0);
    ~soundFile.readData(~data);
    ~soundFile.close;
    ~stereo = ~data.clump(2).flop;
    ~dataL = ~stereo[0];
    ~dataR = ~stereo[1];


    // get indices of diracs
    ~leftIndices = ~indicesOfEqualWithPrecision.(~dataL, 1);
    ~rightIndices = ~indicesOfEqualWithPrecision.(~dataR, 1);

    // look at deltas
    ~leftDeltas = ~leftIndices.differentiate.drop(1);
    ~rightDeltas = ~rightIndices.differentiate.drop(1);

    // count occurences of (possibly) different deltas

    ~leftDeltaSet = ~leftDeltas.asSet.collect { |x| [x, ~leftDeltas.occurrencesOf(x)] };
    ~rightDeltaSet = ~rightDeltas.asSet.collect { |x| [x, ~rightDeltas.occurrencesOf(x)] };

    "".postln;
    "occurences of sample deltas with single synth: ".postln;
    ~leftDeltaSet.postln;"".postln;

    "occurences of sample deltas with Pbind: ".postln;
    ~rightDeltaSet.postln;"".postln;
};
)


// analyse, takes some seconds

~analyse.(~fileName)


// results of test 1 and 2 with supernova (44100 Hz):
->

occurences of sample deltas with single synth:
Set[ [ 4410, 600 ] ]

occurences of sample deltas with Pbind:
Set[ [ 4410, 600 ] ]


// results of test 1 and 2 with scsynth (of course second set can vary)
->

occurences of sample deltas with single synth:
Set[ [ 4410, 600 ] ]

occurences of sample deltas with Pbind:
Set[ [ 4409, 8 ], [ 4411, 52 ], [ 4410, 540 ] ]
2 Likes