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
Detailed steps on how to reproduce the bug
MultiVoiceOscillator::start()andOscillator::start()(
modules/tracktion_engine/utilities/tracktion_Oscillators.cpp:204and:61) take eachnote's start phase from a function-local
static juce::Random:That generator is shared by every oscillator instance in the process, and it is entered from
an audio thread:
FourOscVoiceholdsMultiVoiceOscillator oscillators[4](
tracktion_FourOscPlugin.cpp:741) and callso.start()on each of them fromnoteStarted()(:341), which runs insiderenderNextBlock— on a new note, and again onretrigger (
: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 itconcurrently, 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 therepository calls it — 4OSC always arrives at
Oscillator::start (float)with a phaseMultiVoiceOscillatorhas 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
developat0d55ef0c.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::Randomrather than thestd::default_random_enginethatOscillatoralready 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 whatstart()effectively does today. Cost is 16 bytes per oscillator; if that is unwelcome forOscillator(a 4OSC voice holds 64 of them), theOscillatorhalf can be dropped and onlyMultiVoiceOscillatorfixed, since nothing in the engine callsOscillator::start().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
developbranchThe bug is present on the
developbranch (0d55ef0c).Code of Conduct