expo-juce 0.2.25 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,25 +17,26 @@ Pod::Spec.new do |s|
17
17
 
18
18
  s.dependency 'ExpoModulesCore'
19
19
 
20
- # Our source files: Swift module, ObjC bridge, C++ tone generator
20
+ # Our source files: Swift module, ObjC bridge, C++ tone generator, JUCE compilation unit
21
21
  s.source_files = "*.{h,m,mm,swift}"
22
- s.exclude_files = ["JuceConfig.h", "JuceModules.mm"]
23
22
  s.requires_arc = true
24
23
 
25
- # JUCE sources preserved on disk for future use but NOT compiled —
26
- # current engine uses pure C++ + AVFoundation, no JUCE APIs.
27
- # Compiling JUCE modules (especially juce_events) causes launch crashes
28
- # due to static initializers conflicting with React Native's app lifecycle.
24
+ # JUCE modules compiled: juce_core, juce_audio_basics, juce_audio_formats, juce_dsp
25
+ # NOT compiled: juce_events, juce_data_structures (static initializer conflicts with RN)
26
+ # JuceModules.mm is a single translation unit that #includes the JUCE .cpp files.
29
27
  s.preserve_paths = ["juce_modules/**"]
30
28
 
31
29
  s.frameworks = 'AVFoundation', 'AudioToolbox', 'CoreAudio',
32
- 'Foundation', 'Accelerate'
30
+ 'Foundation', 'Accelerate', 'CoreMIDI'
33
31
  s.libraries = 'c++'
34
32
 
35
33
  s.pod_target_xcconfig = {
36
34
  'DEFINES_MODULE' => 'YES',
37
35
  'SWIFT_COMPILATION_MODE' => 'wholemodule',
38
36
  'CLANG_CXX_LANGUAGE_STANDARD' => 'c++17',
39
- 'HEADER_SEARCH_PATHS' => '"$(PODS_TARGET_SRCROOT)"',
37
+ 'HEADER_SEARCH_PATHS' => '"$(PODS_TARGET_SRCROOT)" "$(PODS_TARGET_SRCROOT)/juce_modules"',
38
+ # Suppress JUCE deprecation warnings and implicit conversion warnings in vendored code
39
+ 'GCC_WARN_ABOUT_DEPRECATED_DECLARATIONS' => 'NO',
40
+ 'OTHER_CPLUSPLUSFLAGS' => '-Wno-shorten-64-to-32 -Wno-comma -Wno-deprecated-declarations',
40
41
  }
41
42
  end
@@ -6,9 +6,6 @@ public class ExpoJuceModule: Module {
6
6
  public func definition() -> ModuleDefinition {
7
7
  Name("ExpoJuce")
8
8
 
9
- // Audio engine is initialized lazily on first use, not at module load.
10
- // Initializing AVAudioEngine in OnCreate can crash before JS is ready.
11
-
12
9
  OnDestroy {
13
10
  ExpoJuceBridge.stopBeatTimer()
14
11
  ExpoJuceBridge.shutdown()
@@ -20,8 +17,6 @@ public class ExpoJuceModule: Module {
20
17
 
21
18
  Events("onChange", "onBeat")
22
19
 
23
- // Beat timer only runs while JS has active listeners.
24
- // This prevents sendEvent from firing before listeners are attached (crash).
25
20
  OnStartObserving {
26
21
  self.hasListeners = true
27
22
  ExpoJuceBridge.startBeatTimer()
@@ -37,8 +32,13 @@ public class ExpoJuceModule: Module {
37
32
  }
38
33
 
39
34
  // ── Synth ───────────────────────────────────────────────────────
35
+ // All audio methods use AsyncFunction dispatched on main queue.
36
+ // AVAudioEngine and CoreAudio must be accessed from the main thread.
37
+ // Expo's synchronous Function() runs on the JS/Hermes thread,
38
+ // which is NOT the main thread in production builds — this causes
39
+ // native crashes when calling CoreAudio APIs.
40
40
 
41
- Function("setup") { [weak self] in
41
+ AsyncFunction("setup") { [weak self] in
42
42
  ExpoJuceBridge.setup()
43
43
  ExpoJuceBridge.setBeatCallback { [weak self] beat, bpm in
44
44
  guard let self = self, self.hasListeners else { return }
@@ -47,61 +47,61 @@ public class ExpoJuceModule: Module {
47
47
  "bpm": bpm,
48
48
  ])
49
49
  }
50
- }
50
+ }.runOnQueue(.main)
51
51
 
52
- Function("playTone") { (frequency: Double, duration: Double) -> String in
52
+ AsyncFunction("playTone") { (frequency: Double, duration: Double) -> String in
53
53
  ExpoJuceBridge.playTone(withFrequency: frequency, duration: duration)
54
54
  return "Playing tone at \(frequency)Hz for \(duration)ms"
55
- }
55
+ }.runOnQueue(.main)
56
56
 
57
- Function("setFrequency") { (frequency: Double) in
57
+ AsyncFunction("setFrequency") { (frequency: Double) in
58
58
  ExpoJuceBridge.setFrequency(frequency)
59
- }
59
+ }.runOnQueue(.main)
60
60
 
61
- Function("setLevel") { (level: Double) in
61
+ AsyncFunction("setLevel") { (level: Double) in
62
62
  ExpoJuceBridge.setLevel(level)
63
- }
63
+ }.runOnQueue(.main)
64
64
 
65
- Function("setWaveform") { (waveform: String) in
65
+ AsyncFunction("setWaveform") { (waveform: String) in
66
66
  ExpoJuceBridge.setWaveform(waveform)
67
- }
67
+ }.runOnQueue(.main)
68
68
 
69
- Function("setHarmonicLevels") { (levels: [Double]) in
69
+ AsyncFunction("setHarmonicLevels") { (levels: [Double]) in
70
70
  let nsLevels = levels.map { NSNumber(value: $0) }
71
71
  ExpoJuceBridge.setHarmonicLevels(nsLevels)
72
- }
72
+ }.runOnQueue(.main)
73
73
 
74
- Function("noteOn") {
74
+ AsyncFunction("noteOn") {
75
75
  ExpoJuceBridge.noteOn()
76
- }
76
+ }.runOnQueue(.main)
77
77
 
78
- Function("noteOff") {
78
+ AsyncFunction("noteOff") {
79
79
  ExpoJuceBridge.noteOff()
80
- }
80
+ }.runOnQueue(.main)
81
81
 
82
82
  // ── DSP Params ──────────────────────────────────────────────────
83
83
 
84
- Function("setAttack") { (ms: Double) in
84
+ AsyncFunction("setAttack") { (ms: Double) in
85
85
  ExpoJuceBridge.setAttack(ms)
86
- }
86
+ }.runOnQueue(.main)
87
87
 
88
- Function("setRelease") { (ms: Double) in
88
+ AsyncFunction("setRelease") { (ms: Double) in
89
89
  ExpoJuceBridge.setRelease(ms)
90
- }
90
+ }.runOnQueue(.main)
91
91
 
92
- Function("setFilterCutoff") { (hz: Double) in
92
+ AsyncFunction("setFilterCutoff") { (hz: Double) in
93
93
  ExpoJuceBridge.setFilterCutoff(hz)
94
- }
94
+ }.runOnQueue(.main)
95
95
 
96
- Function("setFilterResonance") { (q: Double) in
96
+ AsyncFunction("setFilterResonance") { (q: Double) in
97
97
  ExpoJuceBridge.setFilterResonance(q)
98
- }
98
+ }.runOnQueue(.main)
99
99
 
100
- Function("setDetune") { (cents: Double) in
100
+ AsyncFunction("setDetune") { (cents: Double) in
101
101
  ExpoJuceBridge.setDetune(cents)
102
- }
102
+ }.runOnQueue(.main)
103
103
 
104
- Function("setDSPValue") { (param: String, value: Double) in
104
+ AsyncFunction("setDSPValue") { (param: String, value: Double) in
105
105
  switch param {
106
106
  case "frequency":
107
107
  ExpoJuceBridge.setFrequency(value)
@@ -120,37 +120,37 @@ public class ExpoJuceModule: Module {
120
120
  default:
121
121
  break
122
122
  }
123
- }
123
+ }.runOnQueue(.main)
124
124
 
125
125
  // ── Transport ───────────────────────────────────────────────────
126
126
 
127
- Function("startTransport") {
127
+ AsyncFunction("startTransport") {
128
128
  ExpoJuceBridge.startTransport()
129
- }
129
+ }.runOnQueue(.main)
130
130
 
131
- Function("stopTransport") {
131
+ AsyncFunction("stopTransport") {
132
132
  ExpoJuceBridge.stopTransport()
133
- }
133
+ }.runOnQueue(.main)
134
134
 
135
- Function("setTempo") { (bpm: Double) in
135
+ AsyncFunction("setTempo") { (bpm: Double) in
136
136
  ExpoJuceBridge.setTempo(bpm)
137
- }
137
+ }.runOnQueue(.main)
138
138
 
139
- Function("setTransportPosition") { (beat: Double) in
139
+ AsyncFunction("setTransportPosition") { (beat: Double) in
140
140
  ExpoJuceBridge.setTransportPosition(beat)
141
- }
141
+ }.runOnQueue(.main)
142
142
 
143
- Function("getTransportPosition") { () -> Double in
143
+ AsyncFunction("getTransportPosition") { () -> Double in
144
144
  return ExpoJuceBridge.getTransportPosition()
145
- }
145
+ }.runOnQueue(.main)
146
146
 
147
- Function("getTempo") { () -> Double in
147
+ AsyncFunction("getTempo") { () -> Double in
148
148
  return ExpoJuceBridge.getTempo()
149
- }
149
+ }.runOnQueue(.main)
150
150
 
151
- Function("isTransportPlaying") { () -> Bool in
151
+ AsyncFunction("isTransportPlaying") { () -> Bool in
152
152
  return ExpoJuceBridge.isTransportPlaying()
153
- }
153
+ }.runOnQueue(.main)
154
154
 
155
155
  // ── Async / View ────────────────────────────────────────────────
156
156
 
package/ios/JuceConfig.h CHANGED
@@ -16,14 +16,19 @@
16
16
  #define JUCE_USE_DARK_SPLASH_SCREEN 0
17
17
  #define JUCE_STRICT_REFCOUNTEDPOINTER 1
18
18
 
19
- // Module availability flags
19
+ // Module availability flags — only safe modules (no juce_events)
20
20
  #define JUCE_MODULE_AVAILABLE_juce_core 1
21
- #define JUCE_MODULE_AVAILABLE_juce_events 1
22
- #define JUCE_MODULE_AVAILABLE_juce_data_structures 1
23
21
  #define JUCE_MODULE_AVAILABLE_juce_audio_basics 1
24
22
  #define JUCE_MODULE_AVAILABLE_juce_audio_formats 1
25
23
  #define JUCE_MODULE_AVAILABLE_juce_dsp 1
26
24
 
25
+ // Explicitly mark dangerous modules as unavailable
26
+ #define JUCE_MODULE_AVAILABLE_juce_events 0
27
+ #define JUCE_MODULE_AVAILABLE_juce_data_structures 0
28
+ #define JUCE_MODULE_AVAILABLE_juce_gui_basics 0
29
+ #define JUCE_MODULE_AVAILABLE_juce_audio_processors 0
30
+ #define JUCE_MODULE_AVAILABLE_juce_audio_devices 0
31
+
27
32
  // Not a standalone JUCE app — embedded in React Native
28
33
  #define JUCE_STANDALONE_APPLICATION 0
29
34
 
@@ -37,3 +42,4 @@
37
42
  #define JUCE_USE_MP3AUDIOFORMAT 0
38
43
  #define JUCE_USE_LAME_AUDIO_FORMAT 0
39
44
  #define JUCE_USE_WINDOWS_MEDIA_FORMAT 0
45
+ #define JUCE_USE_FLAC 0
@@ -1,11 +1,14 @@
1
- // JuceModules.mm — Compiles all vendored JUCE modules as a single ObjC++ translation unit.
1
+ // JuceModules.mm — Compiles vendored JUCE modules as a single ObjC++ translation unit.
2
2
  // The .mm extension ensures Objective-C++ mode so JUCE's Apple platform code compiles correctly.
3
+ //
4
+ // Only safe modules are compiled here. juce_events is EXCLUDED because its
5
+ // static MessageManager initializer conflicts with React Native's app lifecycle.
6
+ // The dependency chain is: juce_dsp -> juce_audio_formats -> juce_audio_basics -> juce_core
7
+ // None of these modules depend on juce_events.
3
8
 
4
9
  #include "JuceConfig.h"
5
10
 
6
11
  #include "juce_modules/juce_core/juce_core.cpp"
7
- #include "juce_modules/juce_events/juce_events.cpp"
8
- #include "juce_modules/juce_data_structures/juce_data_structures.cpp"
9
12
  #include "juce_modules/juce_audio_basics/juce_audio_basics.cpp"
10
13
  #include "juce_modules/juce_audio_formats/juce_audio_formats.cpp"
11
14
  #include "juce_modules/juce_dsp/juce_dsp.cpp"
@@ -4,227 +4,244 @@
4
4
  #include <cmath>
5
5
  #include <algorithm>
6
6
 
7
+ #include "JuceConfig.h"
8
+ #include <juce_core/juce_core.h>
9
+ #include <juce_audio_basics/juce_audio_basics.h>
10
+ #include <juce_dsp/juce_dsp.h>
11
+
7
12
  // ── Waveform Types ────────────────────────────────────────────────
8
13
  enum class Waveform { Sine, Square, Saw, Triangle };
9
14
 
10
15
  static const int MAX_HARMONICS = 8;
11
16
 
12
- // ── Simple one-pole low-pass filter ───────────────────────────────
13
- class OnePoleLPF {
17
+ // ── Synth Engine (JUCE DSP) ──────────────────────────────────────
18
+ //
19
+ // Thread safety contract:
20
+ // - Main thread: calls set*() methods (atomic parameter updates)
21
+ // - Audio thread: calls getNextSample() (reads atomics, mutates DSP state)
22
+ // - DSP objects (oscillator, filter, adsr) are ONLY touched by the audio thread
23
+ // - prepare() is called once from main thread BEFORE audio thread starts
24
+ //
25
+ class SynthEngine {
14
26
  public:
15
- OnePoleLPF() : y1(0.0), cutoff(20000.0), resonance(0.0), pendingReset(false) {}
16
-
17
- void setCutoff(double hz) { cutoff.store(hz); }
18
- void setResonance(double q) { resonance.store(q); }
19
-
20
- float process(float input, double sampleRate) {
21
- // Handle reset on audio thread to avoid data race
22
- if (pendingReset.exchange(false)) {
23
- y1 = 0.0;
27
+ SynthEngine()
28
+ : frequency(440.0), level(0.5),
29
+ waveform(Waveform::Sine), detuneCents(0.0),
30
+ pendingNoteOn(false), pendingNoteOff(false),
31
+ filterCutoff(20000.0), filterResonance(0.0),
32
+ attackMs(10.0), releaseMs(200.0),
33
+ pendingParamUpdate(true)
34
+ {
35
+ for (int i = 0; i < MAX_HARMONICS; i++) {
36
+ harmonics[i] = (i == 0) ? 1.0 : 0.0;
24
37
  }
25
-
26
- double fc = cutoff.load();
27
-
28
- // Clamp cutoff to Nyquist
29
- if (fc > sampleRate * 0.49) fc = sampleRate * 0.49;
30
- if (fc < 20.0) fc = 20.0;
31
-
32
- // Compute coefficient (simple RC low-pass)
33
- double w = 2.0 * M_PI * fc / sampleRate;
34
- double g = w / (1.0 + w);
35
-
36
- // One-pole filter
37
- y1 = y1 + g * (input - y1);
38
-
39
- return (float)y1;
40
38
  }
41
39
 
42
- // Signal reset from main thread applied on next audio render
43
- void reset() { pendingReset.store(true); }
40
+ // Called once from main thread before audio engine starts
41
+ void prepare(double sampleRate, int blockSize) {
42
+ juce::dsp::ProcessSpec spec;
43
+ spec.sampleRate = sampleRate;
44
+ spec.maximumBlockSize = (juce::uint32)blockSize;
45
+ spec.numChannels = 1;
44
46
 
45
- private:
46
- double y1; // Only touched by audio thread
47
- std::atomic<double> cutoff;
48
- std::atomic<double> resonance;
49
- std::atomic<bool> pendingReset;
50
- };
51
-
52
- // ── ADSR Envelope ─────────────────────────────────────────────────
53
- class ADSREnvelope {
54
- public:
55
- enum class Stage { Idle, Attack, Sustain, Release };
47
+ // Pre-initialize all waveform oscillators with lookup tables (no allocation on audio thread)
48
+ sineOsc.initialise([](float x) { return std::sin(x); }, 2048);
49
+ sineOsc.prepare(spec);
56
50
 
57
- ADSREnvelope()
58
- : attackMs(10.0), releaseMs(200.0),
59
- stage(Stage::Idle), gain(0.0) {}
51
+ squareOsc.initialise([](float x) -> float { return x < 0.0f ? -1.0f : 1.0f; }, 2048);
52
+ squareOsc.prepare(spec);
60
53
 
61
- void setAttack(double ms) { attackMs.store(std::max(1.0, ms)); }
62
- void setRelease(double ms) { releaseMs.store(std::max(1.0, ms)); }
54
+ sawOsc.initialise([](float x) -> float { return x / juce::MathConstants<float>::pi; }, 2048);
55
+ sawOsc.prepare(spec);
63
56
 
64
- void noteOn() { stage.store(Stage::Attack); }
65
- void noteOff() { stage.store(Stage::Release); }
66
- bool isActive() { return stage.load() != Stage::Idle; }
57
+ triOsc.initialise([](float x) -> float {
58
+ return 2.0f * std::abs(x) / juce::MathConstants<float>::pi - 1.0f;
59
+ }, 2048);
60
+ triOsc.prepare(spec);
67
61
 
68
- float process(double sampleRate) {
69
- Stage s = stage.load();
62
+ filter.prepare(spec);
63
+ filter.setType(juce::dsp::StateVariableTPTFilter<float>::Type::lowpass);
70
64
 
71
- switch (s) {
72
- case Stage::Idle:
73
- gain = 0.0;
74
- break;
65
+ adsr.setSampleRate(sampleRate);
66
+ juce::ADSR::Parameters adsrParams { 0.01f, 0.0f, 1.0f, 0.2f };
67
+ adsr.setParameters(adsrParams);
75
68
 
76
- case Stage::Attack: {
77
- double rate = 1.0 / (attackMs.load() * 0.001 * sampleRate);
78
- gain += rate;
79
- if (gain >= 1.0) {
80
- gain = 1.0;
81
- stage.store(Stage::Sustain);
82
- }
83
- break;
84
- }
85
-
86
- case Stage::Sustain:
87
- gain = 1.0;
88
- break;
89
-
90
- case Stage::Release: {
91
- double rate = 1.0 / (releaseMs.load() * 0.001 * sampleRate);
92
- gain -= rate;
93
- if (gain <= 0.0) {
94
- gain = 0.0;
95
- stage.store(Stage::Idle);
96
- }
97
- break;
98
- }
99
- }
100
-
101
- return (float)gain;
102
- }
103
-
104
- void reset() {
105
- gain = 0.0;
106
- stage.store(Stage::Idle);
69
+ cachedSampleRate = sampleRate;
70
+ prepared = true;
107
71
  }
108
72
 
109
- private:
110
- std::atomic<double> attackMs;
111
- std::atomic<double> releaseMs;
112
- std::atomic<Stage> stage;
113
- double gain;
114
- };
115
-
116
- // ── Synth Engine ──────────────────────────────────────────────────
117
- class SynthEngine {
118
- public:
119
- SynthEngine()
120
- : frequency(440.0), level(0.5), phase(0.0),
121
- waveform(Waveform::Sine), detuneCents(0.0)
122
- {
123
- for (int i = 0; i < MAX_HARMONICS; i++) {
124
- harmonics[i] = (i == 0) ? 1.0 : 0.0;
125
- }
126
- }
73
+ // ── Main-thread setters (all atomic or lock-free) ─────────────
127
74
 
128
- void setFrequency(double freq) { frequency = freq; }
129
- void setLevel(double lev) { level = lev; }
75
+ void setFrequency(double freq) { frequency.store(freq); }
76
+ void setLevel(double lev) { level.store(lev); }
130
77
  void setWaveform(Waveform wf) { waveform.store(wf); }
131
78
  void setDetune(double cents) { detuneCents.store(cents); }
132
79
 
133
80
  void setHarmonics(const double *levels, int count) {
134
81
  for (int i = 0; i < MAX_HARMONICS; i++) {
135
- harmonics[i] = (i < count) ? levels[i] : 0.0;
82
+ harmonics[i].store((i < count) ? levels[i] : 0.0);
136
83
  }
137
84
  }
138
85
 
139
86
  void noteOn() {
140
87
  pendingNoteOn.store(true);
141
- filter.reset();
142
- envelope.noteOn();
143
88
  }
144
89
 
145
90
  void noteOff() {
146
- envelope.noteOff();
91
+ pendingNoteOff.store(true);
147
92
  }
148
93
 
149
- void setAttack(double ms) { envelope.setAttack(ms); }
150
- void setRelease(double ms) { envelope.setRelease(ms); }
151
- void setFilterCutoff(double hz) { filter.setCutoff(hz); }
152
- void setFilterResonance(double q) { filter.setResonance(q); }
94
+ void setAttack(double ms) {
95
+ attackMs.store(std::max(1.0, ms));
96
+ pendingParamUpdate.store(true);
97
+ }
98
+
99
+ void setRelease(double ms) {
100
+ releaseMs.store(std::max(1.0, ms));
101
+ pendingParamUpdate.store(true);
102
+ }
103
+
104
+ void setFilterCutoff(double hz) {
105
+ filterCutoff.store(std::max(20.0, std::min(20000.0, hz)));
106
+ }
107
+
108
+ void setFilterResonance(double q) {
109
+ filterResonance.store(std::max(0.0, std::min(1.0, q)));
110
+ }
111
+
112
+ // ── Audio-thread render ───────────────────────────────────────
153
113
 
154
114
  float getNextSample(double sampleRate) {
155
- // Handle phase reset on audio thread to avoid data race
115
+ if (!prepared) return 0.0f;
116
+
117
+ // Apply pending note events (main thread -> audio thread)
156
118
  if (pendingNoteOn.exchange(false)) {
157
- phase = 0.0;
119
+ sineOsc.reset();
120
+ squareOsc.reset();
121
+ sawOsc.reset();
122
+ triOsc.reset();
123
+ filter.reset();
124
+ adsr.noteOn();
125
+ }
126
+ if (pendingNoteOff.exchange(false)) {
127
+ adsr.noteOff();
128
+ }
129
+
130
+ // Apply pending ADSR parameter changes
131
+ if (pendingParamUpdate.exchange(false)) {
132
+ float atkSec = (float)(attackMs.load() * 0.001);
133
+ float relSec = (float)(releaseMs.load() * 0.001);
134
+ juce::ADSR::Parameters p { atkSec, 0.0f, 1.0f, relSec };
135
+ adsr.setParameters(p);
158
136
  }
159
137
 
160
- float envGain = envelope.process(sampleRate);
161
- if (envGain < 0.00001f && !envelope.isActive()) {
138
+ // Get envelope value
139
+ float envGain = adsr.getNextSample();
140
+ if (envGain < 0.00001f && !adsr.isActive()) {
162
141
  return 0.0f;
163
142
  }
164
143
 
144
+ // Read atomic parameters
165
145
  double freq = frequency.load();
166
- double lev = level.load();
146
+ float lev = (float)level.load();
167
147
  Waveform wf = waveform.load();
168
148
  double detune = detuneCents.load();
169
149
 
170
- // Apply detune (cents to frequency ratio)
150
+ // Apply detune
171
151
  if (std::fabs(detune) > 0.01) {
172
152
  freq *= std::pow(2.0, detune / 1200.0);
173
153
  }
174
154
 
175
- double sample = 0.0;
155
+ // Set frequency on all oscillators (they track phase independently)
156
+ float freqF = (float)freq;
157
+ sineOsc.setFrequency(freqF, true);
158
+ squareOsc.setFrequency(freqF, true);
159
+ sawOsc.setFrequency(freqF, true);
160
+ triOsc.setFrequency(freqF, true);
161
+
162
+ // Update filter parameters (safe — JUCE TPT filter handles per-sample modulation)
163
+ float cutoff = (float)filterCutoff.load();
164
+ float resonance = (float)filterResonance.load();
165
+ float mappedQ = 0.707f + resonance * 9.293f;
166
+ filter.setCutoffFrequency(cutoff);
167
+ filter.setResonance(mappedQ);
168
+
169
+ // Generate sample from the active waveform's pre-initialized oscillator
170
+ float sample = 0.0f;
176
171
 
177
172
  switch (wf) {
178
- case Waveform::Sine:
179
- for (int h = 0; h < MAX_HARMONICS; h++) {
180
- double hLevel = harmonics[h].load();
181
- if (hLevel > 0.0001) {
182
- sample += std::sin(phase * (h + 1) * 2.0 * M_PI) * hLevel;
173
+ case Waveform::Sine: {
174
+ float baseSample = sineOsc.processSample(0.0f);
175
+ float h0 = (float)harmonics[0].load();
176
+ sample = baseSample * h0;
177
+
178
+ // Additive harmonics (if any higher harmonics are set)
179
+ for (int h = 1; h < MAX_HARMONICS; h++) {
180
+ float hLevel = (float)harmonics[h].load();
181
+ if (hLevel > 0.0001f) {
182
+ // Approximate higher harmonics from base phase
183
+ // This is safe: no allocation, just math
184
+ float harmSample = std::sin(std::asin(baseSample) * (float)(h + 1));
185
+ sample += harmSample * hLevel;
183
186
  }
184
187
  }
185
188
  break;
189
+ }
186
190
 
187
- case Waveform::Square: {
188
- double p = std::fmod(phase, 1.0);
189
- sample = (p < 0.5) ? 0.7 : -0.7;
191
+ case Waveform::Square:
192
+ sample = squareOsc.processSample(0.0f) * 0.7f;
190
193
  break;
191
- }
192
194
 
193
- case Waveform::Saw: {
194
- double p = std::fmod(phase, 1.0);
195
- sample = (2.0 * p - 1.0) * 0.6;
195
+ case Waveform::Saw:
196
+ sample = sawOsc.processSample(0.0f) * 0.6f;
196
197
  break;
197
- }
198
198
 
199
- case Waveform::Triangle: {
200
- double p = std::fmod(phase, 1.0);
201
- sample = (4.0 * std::fabs(p - 0.5) - 1.0) * 0.8;
199
+ case Waveform::Triangle:
200
+ sample = triOsc.processSample(0.0f) * 0.8f;
202
201
  break;
203
- }
204
202
  }
205
203
 
206
- phase += freq / sampleRate;
207
- if (phase >= 1.0) phase -= 1.0;
204
+ // Advance inactive oscillators to keep phase tracking coherent
205
+ // (prevents phase discontinuity on waveform switch)
206
+ if (wf != Waveform::Sine) sineOsc.processSample(0.0f);
207
+ if (wf != Waveform::Square) squareOsc.processSample(0.0f);
208
+ if (wf != Waveform::Saw) sawOsc.processSample(0.0f);
209
+ if (wf != Waveform::Triangle) triOsc.processSample(0.0f);
208
210
 
209
211
  // Apply filter
210
- float filtered = filter.process((float)sample, sampleRate);
212
+ float filtered = filter.processSample(0, sample);
211
213
 
212
- return filtered * (float)lev * envGain;
214
+ return filtered * lev * envGain;
213
215
  }
214
216
 
215
217
  private:
218
+ // Atomic parameters (written by main thread, read by audio thread)
216
219
  std::atomic<double> frequency;
217
220
  std::atomic<double> level;
218
- double phase; // Only touched by audio thread
219
- std::atomic<bool> pendingNoteOn{false};
220
221
  std::atomic<Waveform> waveform;
221
222
  std::atomic<double> detuneCents;
222
223
  std::atomic<double> harmonics[MAX_HARMONICS];
223
- ADSREnvelope envelope;
224
- OnePoleLPF filter;
224
+ std::atomic<bool> pendingNoteOn;
225
+ std::atomic<bool> pendingNoteOff;
226
+ std::atomic<double> filterCutoff;
227
+ std::atomic<double> filterResonance;
228
+ std::atomic<double> attackMs;
229
+ std::atomic<double> releaseMs;
230
+ std::atomic<bool> pendingParamUpdate;
231
+
232
+ // DSP objects (ONLY touched by audio thread after prepare())
233
+ juce::dsp::Oscillator<float> sineOsc;
234
+ juce::dsp::Oscillator<float> squareOsc;
235
+ juce::dsp::Oscillator<float> sawOsc;
236
+ juce::dsp::Oscillator<float> triOsc;
237
+ juce::dsp::StateVariableTPTFilter<float> filter;
238
+ juce::ADSR adsr;
239
+ double cachedSampleRate = 44100.0;
240
+ bool prepared = false;
225
241
  };
226
242
 
227
243
  // ── Transport Engine ──────────────────────────────────────────────
244
+ // All-atomic transport — safe for cross-thread access.
228
245
  class TransportEngine {
229
246
  public:
230
247
  TransportEngine()
@@ -241,7 +258,7 @@ public:
241
258
  void stop() { playing.store(false); }
242
259
  bool isPlaying() { return playing.load(); }
243
260
 
244
- // Returns true if we crossed a new beat boundary
261
+ // Returns true if we crossed a new beat boundary (audio thread only)
245
262
  bool advance(double sampleRate) {
246
263
  if (!playing.load()) return false;
247
264
 
@@ -266,14 +283,15 @@ private:
266
283
  std::atomic<int> lastBeatInt;
267
284
  };
268
285
 
269
- // ── Objective-C Wrapper ───────────────────────────────────────────
270
-
286
+ // ── Beat State (lock-free audio->main thread communication) ──────
271
287
  struct BeatState {
272
288
  std::atomic<bool> crossed{false};
273
289
  std::atomic<double> beat{0.0};
274
290
  std::atomic<double> bpm{120.0};
275
291
  };
276
292
 
293
+ // ── Objective-C Wrapper ───────────────────────────────────────────
294
+
277
295
  @interface JuceToneGenerator ()
278
296
  @property (nonatomic, strong) AVAudioEngine *audioEngine;
279
297
  @property (nonatomic, strong) AVAudioSourceNode *sourceNode;
@@ -300,7 +318,6 @@ struct BeatState {
300
318
  if (self) {
301
319
  _engine = new SynthEngine();
302
320
  _transport = new TransportEngine();
303
- // Audio engine initialized lazily via [self initialize] called from setup
304
321
  }
305
322
  return self;
306
323
  }
@@ -358,6 +375,9 @@ struct BeatState {
358
375
  AVAudioFormat *format = [[AVAudioFormat alloc] initStandardFormatWithSampleRate:sampleRate channels:2];
359
376
  NSLog(@"[ExpoJuce] Using sample rate: %.0f", sampleRate);
360
377
 
378
+ // Prepare JUCE DSP objects on main thread BEFORE audio thread starts
379
+ self.engine->prepare(sampleRate, 512);
380
+
361
381
  SynthEngine *eng = self.engine;
362
382
  TransportEngine *trans = self.transport;
363
383
 
@@ -366,19 +386,27 @@ struct BeatState {
366
386
  }
367
387
  BeatState *bs = self.beatState;
368
388
 
369
- self.sourceNode = [[AVAudioSourceNode alloc] initWithRenderBlock:^OSStatus(BOOL *isSilence, const AudioTimeStamp *timestamp, AVAudioFrameCount frameCount, AudioBufferList *outputData) {
370
- double sr = sampleRate;
389
+ self.sourceNode = [[AVAudioSourceNode alloc] initWithRenderBlock:^OSStatus(
390
+ BOOL *isSilence,
391
+ const AudioTimeStamp *timestamp,
392
+ AVAudioFrameCount frameCount,
393
+ AudioBufferList *outputData
394
+ ) {
371
395
  float *leftChannel = (float *)outputData->mBuffers[0].mData;
372
- float *rightChannel = (outputData->mNumberBuffers > 1) ? (float *)outputData->mBuffers[1].mData : leftChannel;
396
+ float *rightChannel = (outputData->mNumberBuffers > 1)
397
+ ? (float *)outputData->mBuffers[1].mData
398
+ : leftChannel;
373
399
 
374
400
  for (AVAudioFrameCount i = 0; i < frameCount; i++) {
375
- if (trans->advance(sr)) {
401
+ // Advance transport
402
+ if (trans->advance(sampleRate)) {
376
403
  bs->crossed.store(true);
377
404
  bs->beat.store(trans->getPosition());
378
405
  bs->bpm.store(trans->getTempo());
379
406
  }
380
407
 
381
- float sample = eng->getNextSample(sr);
408
+ // Render synth sample
409
+ float sample = eng->getNextSample(sampleRate);
382
410
  leftChannel[i] = sample;
383
411
  rightChannel[i] = sample;
384
412
  }
@@ -398,7 +426,7 @@ struct BeatState {
398
426
  continue;
399
427
  }
400
428
 
401
- NSLog(@"[ExpoJuce] Audio engine started successfully");
429
+ NSLog(@"[ExpoJuce] Audio engine started successfully (JUCE DSP active)");
402
430
  return;
403
431
 
404
432
  } @catch (NSException *exception) {
@@ -476,9 +504,7 @@ struct BeatState {
476
504
  // ── Synth Methods ─────────────────────────────────────────────────
477
505
 
478
506
  - (void)playToneWithFrequency:(double)frequency duration:(double)duration {
479
- NSLog(@"[ExpoJuce] playTone freq=%.1f dur=%.0f", frequency, duration);
480
507
  if (!self.engine) return;
481
-
482
508
  self.engine->setFrequency(frequency);
483
509
  self.engine->noteOn();
484
510
 
@@ -553,12 +579,10 @@ struct BeatState {
553
579
  // ── Transport ─────────────────────────────────────────────────────
554
580
 
555
581
  - (void)startTransport {
556
- NSLog(@"[ExpoJuce] Transport start");
557
582
  if (self.transport) self.transport->start();
558
583
  }
559
584
 
560
585
  - (void)stopTransport {
561
- NSLog(@"[ExpoJuce] Transport stop");
562
586
  if (self.transport) self.transport->stop();
563
587
  }
564
588
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-juce",
3
- "version": "0.2.25",
3
+ "version": "0.3.1",
4
4
  "description": "Realtime DSP w/C++ & JUCE",
5
5
  "type": "module",
6
6
  "main": "build/index.js",