Persistent Latency Issues on Mac M1 with Focusrite 2i2 – Any Solution?

I’ve been struggling with latency issues on SuperCollider ever since I started using it on my Mac M1 (ARM) with a Focusrite Scarlett 2i2 audio interface. After a few minutes of playback, even simple patterns like the one below start generating late messages in the Post Window:

~b1[0] = Pbind(
	\instrument, \blip1,
	\scale,  Scale.yu,
	\degree, Pn(Pshuf([0,3,5,7,10],4),inf),
	\octave, [2],
	\dur, Pseq([0.25], inf),
	\amp, 0.2,
	\level, 1.5,
	\atk, 0,
	\sus, 0,
	\rel, 0.75,
	\curve, -6,
	\pan, 0
);

~b1.play;

After a while, the Post Window fills with messages like:

-> NodeProxy.audio(localhost, 2)
late 0.017677479
late 0.149598480
late 1.202926229
late 0.036259562
...

These values keep increasing and affect timing precision significantly.

I’ve tried:

  • Adjusting s.options.blockSize, hardwareBufferSize, latency, etc.
  • Reducing CPU load and memory allocation
  • Testing with and without the Focusrite (using internal audio)
  • Rewriting the setup to delay GUI creation and buffer loading
  • Running SC natively (not via Rosetta)
  • Syncing sample rate with Audio MIDI Setup (48kHz)

Still, the problem persists. I posted about this some time ago but didn’t get a concrete solution. I’m wondering if any other M1 + Focusrite users have experienced this issue and, more importantly, if anyone has found a fix or stable configuration.

Thanks in advance for any help or insights!

Best,

I have used m1 with 2i2 with no problems. Are you sure the problem isn’t with your SynthDef ? Can you post the blip1 code?

Hi, my blip1 SynthDef Code:

SynthDef("blip1", {arg out = 0, freq = 25, numharm = 10, att = 0.01, rel = 1, amp = 0.1, pan = 0;
	var snd, env;
	env = Env.perc(att, rel, amp).kr(doneAction: 2);
	snd = Blip.ar(
		freq: freq * [1, 1.01],
		numharm: numharm,
		mul: env
	);
	snd = LeakDC.ar(snd);
	snd = Mix.ar(snd);
	snd = Pan2.ar(snd, pan);
	Out.ar(out, snd);
},

metadata: (
	credit: "unknown",
	category: \pads,
	tags: [\pitched]
)
).add;

are you running sc with rosetta?

I still think these two posts are the most likely avenue for troubleshooting:

In the second of these, “It would be interesting to see both the OSC timestamps of the outgoing bundles on the client and the OSC timestamp for each audio callback on the server. Then we could see if there’s a problem with one particular clock. However, for that we would need to insert some printf statements.”

That is, insert some print statements into the C++ sources and rebuild for testing. Perhaps later I could suggest the print statements, but I’m not on Mac so I couldn’t build it for you.

hjh

OK, here’s the best I can come up with – @Spacechild1 can correct me if I’m proposing something wrong.

  1. In lang/LangPrimSource/OSCData.cpp, add these scprintf calls – non-scprintf lines are the original ones in the function; scprintf is the addition.

    • makeSynthMsgWithTags() near the top:
          if (GetTag(slots) == tagSym && slotRawSymbol(slots)->name[0] != '/') {
              scprintf("makeSynthMsgWithTags name = %s\n", slotRawSymbol(slots)->name);
              packet->adds_slpre(slotRawSymbol(slots)->name);
          } else {
      
    • makeSynthBundle() near the bottom:
          packet->CloseBundle();
          scprintf("makeSynthBundle for OSC timestamp %lx\n", oscTime);
          return errNone;
      
      • addMsgSlot() near the top (again, only scprintf lines are new):
      static int addMsgSlot(big_scpacket* packet, PyrSlot* slot) {
          switch (GetTag(slot)) {
          case tagInt:
            scprintf("addMsgSlot int = %d\n", slotRawInt(slot));
              packet->addi(slotRawInt(slot));
              break;
          case tagSym:
            scprintf("addMsgSlot sym = %s\n", slotRawSymbol(slot)->name);
              packet->adds(slotRawSymbol(slot)->name);
              break;
          case tagObj:
              if (isKindOf(slotRawObject(slot), class_string)) {
                  PyrString* stringObj = slotRawString(slot);
                  scprintf("addMsgSlot str = %s\n", stringObj->s);
                  packet->adds(stringObj->s, stringObj->size);
      
  2. In server/scsynth/SC_CoreAudio.cpp:

    • ProcessOSCPacket() at the top:
      bool ProcessOSCPacket(World* inWorld, OSC_Packet* inPacket) {
          // scprintf("ProcessOSCPacket %d, '%s'\n", inPacket->mSize, inPacket->mData);
          if (!inPacket)
              return false;
        scprintf("ProcessOSCPacket at OSC timestamp %lx, '%s'\n", oscTimeNow(), inPacket->mData);
      

Then you would need to rebuild SC from sources. On my Ubuntu Studio machine, I don’t think I can build for macOS M1, so, you’d have to figure out how to do that yourself, or get someone to do it for you.

The OSCData.cpp changes should identify the command path and outgoing timestamp of each message going out. The SC_CoreAudio.cpp changes should identify bundled messages as #bundle, and give the OSC timestamp at the moment of receipt.

For example:

(
s.waitForBoot {
	2.0.wait;
	s.makeBundle(0.2, { Bus(\control, 0, 1, s).get });
	1.0.wait;
	s.quit;
}
)

There’s a lot of junk from status messages, but in the middle, there will be something like this:

ProcessOSCPacket at OSC timestamp ec11fa5b3c55ccc3, '/status'
ProcessOSCPacket at OSC timestamp ec11fa5b8f808377, '/status'
addMsgSlot str = /status
ProcessOSCPacket at OSC timestamp ec11fa5b90f76969, '/status'
makeSynthMsgWithTags name = c_get
makeSynthBundle for OSC timestamp 1
makeSynthMsgWithTags name = c_get
makeSynthBundle for OSC timestamp ec11fa5bd3c84e36
ProcessOSCPacket at OSC timestamp ec11fa5ba0baa369, '#bundle'
ProcessOSCPacket at OSC timestamp ec11fa5be2b0d8ee, '/status'
Bus control index: 0 value: 0.0.
ProcessOSCPacket at OSC timestamp ec11fa5c35e75d20, '/status'

The important bit:

makeSynthBundle for OSC timestamp ec11fa5bd3c84e36
ProcessOSCPacket at OSC timestamp ec11fa5ba0baa369, '#bundle'

So we know that the c_get message was sent with a timestamp to be executed at ec11fa5bd3c84e36, and it was received at timestamp ec11fa5ba0baa369.

Needs a little love to make it intelligible to humans:

(
// EDIT: remove reference to an extension method `hexToInt`
~hexToInt = { |hexStr|
	var result = 0, i, digit;
	hexStr.do { |char|
		digit = char.digit;
		if(digit.notNil) {
			result = (result << 4) | digit;
		} {
			"Invalid hex digit found at % in %".format(char, hexStr).warn;
			result = (result << 4);
		};
	};
	result
};	

~intAsUnsignedFloat = { |int|
	int.asFloat.wrap(0.0, 2.0 ** 32)
};

~splitOSCTimestamp = { |uint64HexString|
	var out;
	uint64HexString = uint64HexString.toUpper;
	if(uint64HexString.size < 16) {
		uint64HexString = String.fill(16 - uint64HexString.size, $0) ++ uint64HexString;
	};
	out = (
		secondsSince1900: ~intAsUnsignedFloat.(~hexToInt.(uint64HexString[0..7])),
		fractionRawInt: ~intAsUnsignedFloat.(~hexToInt.(uint64HexString[8..15]))
	);
	out.put(\fraction, out[\fractionRawInt] * (2 ** -32))
};
)

// outgoing timestamp (desired execution time)
~splitOSCTimestamp.("ec11fa5bd3c84e36");
-> ('fractionRawInt': 3553119798.0, 'fraction': 0.82727516954765, 'secondsSince1900': 3960601179.0)

// timestamp at the moment of receipt in scsynth
~splitOSCTimestamp.("ec11fa5ba0baa369");
-> ('fractionRawInt': 2696586089.0, 'fraction': 0.62784787476994, 'secondsSince1900': 3960601179.0)

From this, we can read that the message was received almost exactly 200 ms before the desired execution time – which we would expect, since the s.makeBundle in the test code specified 0.2 sec latency.

A “late” message should happen if the receipt timestamp is later than the desired execution time (or maybe if it’s within hw-buffer-size before exec time).

What we are looking for, then, is inconsistency in either the outgoing or ingoing timestamps. If sclang’s clock is behaving inconsistently, then the “makeSynthBundle” timestamps will increase erratically. If it’s scsynth’s clock that is the problem, then the ProcessOSCPacket timestamps will be unpredictable. (ProcessOSCPacket would be affected by network jitter as well, but, on the same machine, UDP packet transmission should take less than 1 ms, so if the problem is on the receiving side, I’d assume first that it’s the clock.)

Now… is this a lot of effort to troubleshoot? Yes… but, it really seems to be something very weird about timing / timestamp internals. I don’t recall seeing this before. To look at internals that are not otherwise exposed, we have to do some weird sh—tuff.

IMO the issue does not have to do with blockSize, hardwareBufferSize, memory allocation or CPU load. I don’t think any progress can be made on this issue without identifying which side of the timing equation is misbehaving, and that means getting hands dirty… nobody likes this, but I don’t see another way. At least we have to rule it out.

hjh

Is this the same if you run off of the internal soundcard or only when using Focusrite? You probably already tried this, but what about setting hardwarebuffersize = nil which should result in SC picking up the hardwarebuffersize from the interface? Which OSX version are you on?

Hi jamshark70,

Thank you so much for your detailed advice on diagnosing the
SuperCollider latency! I’ve followed your instructions to the letter,
and the scprintf patches have been incredibly helpful in narrowing
down the problem.

Here’s what I did and what I found:

  1. Patching and Recompilation:
    I successfully applied your scprintf calls to
    lang/LangPrimSource/OSCData.cpp (in makeSynthMsgWithTags(),
    makeSynthBundle(), and addMsgSlot()) and
    server/scsynth/SC_CoreAudio.cpp (in ProcessOSCPacket()).

The compilation process had a couple of hiccups, but I managed to
resolve them:

  • Git Submodules: cmake initially complained about uninitialized git
    submodules. Running git submodule update --init --recursive fixed
    this.
  • Qt Path: Then, cmake couldn’t find macdeployqt. I explicitly set
    CMAKE_PREFIX_PATH to my Qt5 installation
    (-DCMAKE_PREFIX_PATH=/opt/local/libexec/qt5), and that allowed cmake
    to configure successfully.

After that, the build completed without issues, and I now have a
custom-compiled SuperCollider.app.

  1. OSC Timestamp Analysis (as per your diagnostic method):
    I ran my SuperCollider code (including some parts that put a higher
    load on the CPU) and captured the scprintf output. I then analyzed
    the makeSynthBundle (outgoing timestamp) and ProcessOSCPacket
    (incoming timestamp) pairs, as you suggested.

Here are the statistics from the analysis of 108 such pairs:

  • Calculated Latency (Outgoing Timestamp - Incoming Timestamp):
    • Minimum: 197.2 ms
    • Maximum: 201.1 ms
    • Average: 198.6 ms
    • Standard Deviation: 0.65 ms
  1. Interpretation of Results (and why your method was key!):
    As you predicted, the scprintf output allowed me to see the exact
    timestamps. The results are quite clear: the OSC communication between
    sclang and scsynth appears to be remarkably stable and consistent.
    The very low standard deviation (0.65 ms) strongly suggests that the
    perceived latency is not originating from inconsistencies in the OSC
    messaging or the internal timing synchronization at this level. Your
    diagnostic method successfully helped me rule out this as the primary
    cause.

  2. Next Steps (based on your guidance):
    Given that the OSC timing is stable, it seems the problem likely lies
    elsewhere in the audio chain, as you hinted. I’m now looking into the
    server’s audio configuration.

Here’s the output of s.options.dump from my SuperCollider setup:


    1 Instance of ServerOptions {    (0x14061ca68, gc=CC, fmt
      =00, flg=00, set=06)
    2   instance variables [41]
    3     numAudioBusChannels : Integer 1024
    4     numControlBusChannels : Integer 16384
    5     numInputBusChannels : Integer 2
    6     numOutputBusChannels : Integer 2
    7     numBuffers : Integer 16384
    8     maxNodes : Integer 1024
    9     maxSynthDefs : Integer 1024
   10     protocol : Symbol 'udp'
   11     blockSize : Integer 64
   12     hardwareBufferSize : nil
   13     memSize : Integer 1048576
   14     numRGens : Integer 64
   15     numWireBufs : Integer 64
   16     sampleRate : nil
   17     loadDefs : true
   18     inputStreamsEnabled : nil
   19     outputStreamsEnabled : nil
   20     inDevice : nil
   21     outDevice : nil
   22     verbosity : Integer 0
   23     zeroConf : false
   24     restrictedPath : nil
   25     ugenPluginsPath : nil
   26     initialNodeID : Integer 1000
   27     remoteControlVolume : false
   28     memoryLocking : false
   29     threads : nil
   30     threadPinning : nil
   31     useSystemClock : true
   32     numPrivateAudioBusChannels : Integer 1020
   33     reservedNumAudioBusChannels : Integer 0
   34     reservedNumControlBusChannels : Integer 0
   35     reservedNumBuffers : Integer 0
   36     pingsBeforeConsideredDead : Integer 5
   37     maxLogins : Integer 1
   38     recHeaderFormat : "wav"
   39     recSampleFormat : "float"
   40     recChannels : Integer 2
   41     recBufSize : nil
   42     bindAddress : "127.0.0.1"
   43     safetyClipThreshold : Float 1.260000   C28F5C29
      3FF428F5
   44 }
   45 -> a ServerOptions

The hardwareBufferSize : nil caught my eye, as it means SuperCollider
is relying on the system’s default. I’m going to try explicitly
setting this to a lower value (e.g., 128 or 64) and also check my
macOS Audio MIDI Setup to ensure consistency.

Thanks again for pointing me in the right direction. Your help has
been invaluable!


Useful test result!

On a second look at the source code, I think there’s one more thing to print – SC_CoreAudio.cpp, function PerformOSCPacket():

    } else {
        // in real time engine, schedule the packet
        int64 time = OSCtime(packet->mData + 8);
        if (time == 0 || time == 1) {
            PerformOSCBundle(world, packet);
            return PacketPerformed;
        } else {
	  scprintf("PerformOSCPacket driver's osctime = %lx\n", driver->mOSCbuftime);
            if ((time < driver->mOSCbuftime) && (world->mVerbosity >= 0)) {

Here’s where the actual “late” check happens.

In my test, I see that driver->mOSCbuftime is a bit earlier than the moment of receipt (to be expected, as messages arrive mid-block). driver->mOSCbuftime is also different in ProcessOSCPacket vs PerformOSCPacket (since the “perform” is deferred to the next hardware block AFAIK), by about one hardware buffer duration (which makes sense).

The driver’s OSC time in PerformOSCPacket might be inconsistent, which I think would show up as a large-standard-deviation difference between receipt time (ProcessOSCPacket) and execution time (PerformOSCPacket). You’ve ruled out network lags for sure, but I hadn’t found the actual “late” check yesterday, so I think it’s worth checking that too.

hjh