Skip to content

[Bug]: shared static juce::Random in MultiVoiceOscillator::start() is a data race between audio threads #400

Description

@EugineKh

Detailed steps on how to reproduce the bug

MultiVoiceOscillator::start() and Oscillator::start()
(modules/tracktion_engine/utilities/tracktion_Oscillators.cpp:204 and :61) take each
note's start phase from a function-local static juce::Random:

void MultiVoiceOscillator::start()
{
    static juce::Random r;

    for (int i = 0; i < oscillators.size(); i += 2)
    {
        float phase = r.nextFloat();
        oscillators[i + 0]->start (phase);
        oscillators[i + 1]->start (phase);
    }
}

That generator is shared by every oscillator instance in the process, and it is entered from
an audio thread: FourOscVoice holds MultiVoiceOscillator oscillators[4]
(tracktion_FourOscPlugin.cpp:741) and calls o.start() on each of them from
noteStarted() (:341), which runs inside renderNextBlock — on a new note, and again on
retrigger (:464). With more than one audio worker thread —
EngineBehaviour::getNumberOfCPUsToUseForAudio() defaults to the CPU count
(tracktion_EngineBehaviour.h:136) — two 4OSC instances on different tracks can enter it
concurrently, so this races on juce::Random's seed, which is documented as not thread safe.

Oscillator::start() (the no-argument overload, :61) is built the same way. Nothing in the
repository calls it — 4OSC always arrives at Oscillator::start (float) with a phase
MultiVoiceOscillator has already drawn — but it is public API, so the patch covers it too.

Steps: an Edit with two audio tracks, a 4OSC on each, notes starting at the same beat on
both, played back or rendered with the default thread count.

To be clear about what this report is and isn't: the race is by construction, found by
reading the code. We have not run the engine under ThreadSanitizer, and without a sanitizer
the race is silent — which is why it seemed worth reporting from the code rather than waiting
for a symptom.

Read on develop at 0d55ef0c.

What is the expected behaviour?

Starting a note on one oscillator does not touch state shared with oscillators owned by
other plugins, so concurrent voices on different audio threads are safe.

Unit test to reproduce the error?

A data race cannot be shown reliably by a unit test — it needs ThreadSanitizer. Skipping
this section deliberately rather than filling it with a test that passes by luck.

Suggested fix

Behaviour is unchanged (still a random start phase per note); only the sharing goes away.

A per-instance juce::Random rather than the std::default_random_engine that Oscillator
already owns for noise: that one is default-seeded, so every instance would draw the same
phase sequence, whereas juce::Random's default constructor seeds itself — which is what
start() effectively does today. Cost is 16 bytes per oscillator; if that is unwelcome for
Oscillator (a 4OSC voice holds 64 of them), the Oscillator half can be dropped and only
MultiVoiceOscillator fixed, since nothing in the engine calls Oscillator::start().

diff --git a/modules/tracktion_engine/utilities/tracktion_Oscillators.cpp b/modules/tracktion_engine/utilities/tracktion_Oscillators.cpp
index 6f3db69c..965ea9fc 100644
--- a/modules/tracktion_engine/utilities/tracktion_Oscillators.cpp
+++ b/modules/tracktion_engine/utilities/tracktion_Oscillators.cpp
@@ -60,8 +60,7 @@ static float sawDown (float phase, float freq, double sampleRate)
 //==============================================================================
 void Oscillator::start()
 {
-    static juce::Random r;
-    phase = r.nextFloat();
+    phase = random.nextFloat();
 }
 
 void Oscillator::setSampleRate (double sr)
@@ -203,11 +202,9 @@ MultiVoiceOscillator::MultiVoiceOscillator (int maxVoices)
 
 void MultiVoiceOscillator::start()
 {
-    static juce::Random r;
-
     for (int i = 0; i < oscillators.size(); i += 2)
     {
-        float phase = r.nextFloat();
+        const float phase = random.nextFloat();
         oscillators[i + 0]->start (phase);
         oscillators[i + 1]->start (phase);
     }
diff --git a/modules/tracktion_engine/utilities/tracktion_Oscillators.h b/modules/tracktion_engine/utilities/tracktion_Oscillators.h
index 20713a1d..4315524c 100644
--- a/modules/tracktion_engine/utilities/tracktion_Oscillators.h
+++ b/modules/tracktion_engine/utilities/tracktion_Oscillators.h
@@ -77,6 +77,7 @@ private:
 
     BandlimitedWaveLookupTables::Ptr lookupTables;
 
+    juce::Random random;
     std::default_random_engine generator;
     std::normal_distribution<float> normalDistribution {0.0f, 0.1f};
 };
@@ -103,6 +104,7 @@ public:
 
 private:
     juce::OwnedArray<Oscillator> oscillators;
+    juce::Random random;
 
     int voices = 1;
     float detune = 0, spread = 0, gain = 1.0f, note = 69.0f, pan = 0.0f;

Related

Whether an offline render of a 4OSC Edit should be reproducible is a separate design
question — the random start phase is deliberate, so that is a feature request rather than a
bug, and we have not filed one. Happy to write it up if it would be useful.

Operating systems

Linux

What versions of the operating systems?

Debian 13 (trixie), kernel 6.16, GCC 14.2

Architectures

Intel/AMD 64-bit

Testing on the develop branch

The bug is present on the develop branch (0d55ef0c).

Code of Conduct

  • I agree to follow the Code of Conduct

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    claude-dispatchedA local Claude session has been dispatched for this issueclaude-localOpt this PR in for the local Claude PR-concierge session

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions