expo-juce 0.5.2-rc.1 → 0.5.2

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.
package/README.md CHANGED
@@ -24,7 +24,7 @@ iOS only for now; the Android and web modules are scaffolds.
24
24
  juce::AudioDeviceManager ─► GraphPlayer (AudioIODeviceCallback: graph, peaks, NaNs, test tone)
25
25
  │
26
26
  juce::AudioProcessorGraph PolySynthProcessor ─► audio output node
27
- ├─ SPSC note queue (256) drained at block start
27
+ ├─ NoteEventQueue: note-on ring (256) drained at block start; releases as marks
28
28
  ├─ 8 × Voice: dsp::Oscillator → StateVariableTPTFilter (LP) → ADSR
29
29
  └─ TransportEngine (tempo / position / beat crossings)
30
30
  ```
@@ -39,7 +39,7 @@ expo-juce is being built toward full audio workstation capabilities. The work is
39
39
  Replaced the mono SynthEngine + AVAudioEngine with a JUCE-owned audio stack built on `AudioProcessorGraph`. 8-voice polyphonic synth with per-voice oscillator, SVF filter, and full ADSR envelope. MIDI-style API (`noteOn(note, velocity)` / `noteOff(note)`). JUCE owns device management and output for cross-platform support (iOS, macOS, Windows, Linux).
40
40
 
41
41
  ### Diagnostics, recovery and latency
42
- **Status:** Diagnostics done (0.5.2-diag.x); recovery and latency margin in 0.5.2 — see the [latency guide](docs/latency.md)
42
+ **Status:** Diagnostics done (0.5.2-diag.x); recovery and latency margin in 0.5.2 — see the [latency guide](docs/latency.md). TODO: latency measurement and interruption / route-change device checks ([list](docs/latency.md#todo-device-checks-pending-for-052))
43
43
 
44
44
  `setup()` rejects with the failing step, `getDiagnostics()` exposes every layer from device callbacks to synth peak, `playTestTone()` bypasses the graph, an offline harness reproduces the device callback on Linux. Root causes of the first on-device silence (fixed 2026-10-22): a stale Metro bundle running the app on stubs, infinite recursion in `-setBeatCallback:`, `setup()` always resolving, and expo-av switching the shared session to Ambient. 0.5.2 adds: 128-sample buffer, no allocation on the audio thread, native `JucePadView` touch path, 2 ms default attack, session re-assert and interruption / media-reset recovery (keeping synth settings and transport state across rebuilds).
45
45
 
@@ -92,7 +92,8 @@ await ExpoJuce.setup();
92
92
  // 0.5.2+: optional device request (defaults 128 samples / 48 kHz; actual values may differ)
93
93
  // await ExpoJuce.setup({ bufferSize: 128, sampleRate: 48000 });
94
94
 
95
- // Notes — synchronous; pushed onto a lock-free single-producer queue read by the audio thread
95
+ // Notes — synchronous, never wait on a lock; the audio thread picks them up at the next block
96
+ // (note-offs and allNotesOff are never dropped, even mid-recovery)
96
97
  ExpoJuce.noteOn(60, 127); // note 0–127, velocity 0–127
97
98
  ExpoJuce.noteOff(60);
98
99
  ExpoJuce.allNotesOff();
@@ -42,6 +42,7 @@ Pod::Spec.new do |s|
42
42
  "PolySynthProcessor.h",
43
43
  "TransportEngine.h",
44
44
  "GraphPlayer.h",
45
+ "NoteEventQueue.h",
45
46
  ]
46
47
 
47
48
  # JUCE modules: each compiled as its own translation unit (separate .mm file)
@@ -72,7 +72,8 @@ static TransportEngine* sTransport = nullptr;
72
72
  // ── Engine lifecycle lock ──
73
73
  // Guards the pointers above (and isReady) between the lifecycle code, which runs only on the
74
74
  // main thread and takes the lock blocking (setup / teardown / rebuild / device restart), and the
75
- // users that dereference them (JS thread, and JucePadView on the main thread).
75
+ // users that dereference them (JS thread, and JucePadView on the main thread). Note input
76
+ // (noteOn / noteOff / allNotesOff) does not use it: it goes through sNoteQueue below.
76
77
  // Rules that keep it deadlock-free:
77
78
  // - Lifecycle sections take it at exactly one level (setupWithBufferSize, shutdown, dead-device reopen,
78
79
  // rebuild, device restart); everything they call under it is a *Locked helper that never
@@ -83,13 +84,23 @@ static TransportEngine* sTransport = nullptr;
83
84
  // section at the same time, so the only other holder is a short user section). Users on
84
85
  // other threads use try_lock: if the lifecycle holds it, the call is dropped (safe default
85
86
  // returned) and counted in lockBusyDrops. A short user section on another thread is waited
86
- // out with a bounded retry so a note-off is not dropped for a microsecond of contention.
87
+ // out with a bounded retry so a parameter change is not dropped for a microsecond of contention.
87
88
  // The audio thread never takes this lock.
88
89
  static std::mutex sEngineLock;
89
90
  static std::atomic<bool> sLifecycleBusy{false}; // true while a lifecycle section holds sEngineLock
90
91
  static std::atomic<uint64_t> sLockBusyDrops{0};
91
92
  static std::atomic<uint64_t> sDeadDeviceRestarts{0};
92
93
 
94
+ // ── Note input ──
95
+ // One queue for the life of the process (never deleted, unlike the synth), so producers need
96
+ // no lock: note-ons go into its ring, releases are marks the synth applies every block (see
97
+ // NoteEventQueue.h). A release can therefore not be dropped by lock contention, a full ring, or
98
+ // a device reopen (the synth applies it on the first block after the device restarts).
99
+ // Note-ons are accepted only while the engine is ready (sAcceptingNotes); setup discards
100
+ // note-ons left in the ring before the new synth is attached, so nothing stale plays.
101
+ static NoteEventQueue sNoteQueue;
102
+ static std::atomic<bool> sAcceptingNotes{false};
103
+
93
104
  // RAII: holds sEngineLock for a lifecycle section (main thread, blocking) and flags it busy.
94
105
  struct LifecycleLock {
95
106
  std::lock_guard<std::mutex> guard;
@@ -185,7 +196,11 @@ static void withTransport(ExpoJuceAudioEngine *engine, void (^body)(TransportEng
185
196
  LifecycleLock lifecycle;
186
197
  ok = [self setupEngineLocked];
187
198
  // Setup after a failed rebuild (retry pending or given up) continues that recovery.
188
- if (ok) [self restoreRecoveryStateLocked];
199
+ // Note-ons are accepted only once the restored settings are in place.
200
+ if (ok) {
201
+ [self restoreRecoveryStateLocked];
202
+ sAcceptingNotes.store(true, std::memory_order_release);
203
+ }
189
204
  }
190
205
  if (ok) {
191
206
  [self registerSessionObservers];
@@ -320,6 +335,10 @@ static void withTransport(ExpoJuceAudioEngine *engine, void (^body)(TransportEng
320
335
  auto synthProcessor = std::make_unique<PolySynthProcessor>();
321
336
  sSynth = synthProcessor.get();
322
337
  sSynth->setTransport(sTransport);
338
+ // Nothing drains sNoteQueue until the player is attached (step 11), so this thread may act
339
+ // as its consumer here: note-ons left from before (a failed or replaced engine) are dropped.
340
+ sNoteQueue.discardPending();
341
+ sSynth->setNoteQueue(&sNoteQueue);
323
342
 
324
343
  self.setupStep = 7;
325
344
  NSLog(@"[ExpoJuceAudioEngine] Step 7: addNode (synth)");
@@ -402,6 +421,7 @@ static void withTransport(ExpoJuceAudioEngine *engine, void (^body)(TransportEng
402
421
  // Main thread, caller holds sEngineLock. Deletes everything doSetup built (any subset).
403
422
  - (void)teardownEngineLocked {
404
423
  NSLog(@"[ExpoJuceAudioEngine] Tearing down engine...");
424
+ sAcceptingNotes.store(false, std::memory_order_release);
405
425
  self.isReady = false;
406
426
  if (sDeviceManager && sPlayer) sDeviceManager->removeAudioCallback(sPlayer);
407
427
  delete sPlayer; sPlayer = nullptr;
@@ -444,7 +464,7 @@ static void withTransport(ExpoJuceAudioEngine *engine, void (^body)(TransportEng
444
464
  [self stopBeatTimer];
445
465
  {
446
466
  LifecycleLock lifecycle;
447
- if (sSynth) sSynth->allNotesOff();
467
+ sNoteQueue.pushAllNotesOff();
448
468
  [self teardownEngineLocked];
449
469
  }
450
470
  NSLog(@"[ExpoJuceAudioEngine] Shutdown complete");
@@ -701,10 +721,13 @@ static void withTransport(ExpoJuceAudioEngine *engine, void (^body)(TransportEng
701
721
  BOOL ok = NO;
702
722
  {
703
723
  LifecycleLock lifecycle;
704
- if (sSynth) sSynth->allNotesOff();
724
+ sNoteQueue.pushAllNotesOff();
705
725
  [self teardownEngineLocked];
706
726
  ok = [self setupEngineLocked];
707
- if (ok) [self restoreRecoveryStateLocked];
727
+ if (ok) {
728
+ [self restoreRecoveryStateLocked];
729
+ sAcceptingNotes.store(true, std::memory_order_release);
730
+ }
708
731
  }
709
732
 
710
733
  if (ok) {
@@ -794,15 +817,16 @@ static void withTransport(ExpoJuceAudioEngine *engine, void (^body)(TransportEng
794
817
  if (synth) {
795
818
  synthProcessCount = (double)synth->diag.processCount.load(relaxed);
796
819
  synthPeak = (double)synth->diag.peak.load(relaxed);
797
- notesQueued = (double)synth->diag.notesQueued.load(relaxed);
798
- notesConsumed = (double)synth->diag.notesConsumed.load(relaxed);
799
- notesDropped = (double)synth->diag.notesDropped.load(relaxed);
800
820
  activeVoices = synth->diag.activeVoices.load(relaxed);
801
821
  lastNoteToRenderMs = synth->diag.lastNoteToRenderMs.load(relaxed);
802
822
  lastTouchToRenderMs = synth->diag.lastTouchToRenderMs.load(relaxed);
803
823
  }
804
824
  }
805
825
  }
826
+ // Note counters: cumulative since launch, read from the process-lifetime queue (no lock).
827
+ notesQueued = (double)sNoteQueue.notesQueued.load(std::memory_order_relaxed);
828
+ notesConsumed = (double)sNoteQueue.notesConsumed.load(std::memory_order_relaxed);
829
+ notesDropped = (double)sNoteQueue.notesDropped.load(std::memory_order_relaxed);
806
830
 
807
831
  d[@"isReady"] = @(ready);
808
832
  d[@"setupStep"] = @(self.setupStep);
@@ -877,14 +901,13 @@ static void withTransport(ExpoJuceAudioEngine *engine, void (^body)(TransportEng
877
901
  }
878
902
 
879
903
  // ── Synth forwarding ──
880
- // Each call holds sEngineLock (acquireUserLock) while it dereferences sSynth / sTransport; if
881
- // the lifecycle holds it, the call is dropped and the safe default returned.
904
+ // Notes go through sNoteQueue without the lock (see "Note input"). Every other call holds
905
+ // sEngineLock (acquireUserLock) while it dereferences sSynth / sTransport; if the lifecycle
906
+ // holds it, the call is dropped and the safe default returned.
882
907
 
883
908
  - (void)noteOn:(int)note velocity:(int)velocity {
884
- std::unique_lock<std::mutex> lock(sEngineLock, std::defer_lock);
885
- if (!acquireUserLock(lock)) return;
886
- if (!self.isReady || !sSynth) return;
887
- sSynth->noteOn(note, velocity);
909
+ if (!sAcceptingNotes.load(std::memory_order_acquire)) return;
910
+ sNoteQueue.pushNoteOn(note, velocity);
888
911
  }
889
912
 
890
913
  - (void)noteOn:(int)note velocity:(int)velocity touchTimestamp:(double)touchTimestamp {
@@ -893,25 +916,14 @@ static void withTransport(ExpoJuceAudioEngine *engine, void (^body)(TransportEng
893
916
  const juce::int64 touchTicks = (touchTimestamp > 0.0)
894
917
  ? juce::Time::secondsToHighResolutionTicks(touchTimestamp)
895
918
  : (juce::int64)0;
896
- std::unique_lock<std::mutex> lock(sEngineLock, std::defer_lock);
897
- if (!acquireUserLock(lock)) return;
898
- if (!self.isReady || !sSynth) return;
899
- sSynth->noteOn(note, velocity, touchTicks);
919
+ if (!sAcceptingNotes.load(std::memory_order_acquire)) return;
920
+ sNoteQueue.pushNoteOn(note, velocity, touchTicks);
900
921
  }
901
922
 
902
- - (void)noteOff:(int)note {
903
- std::unique_lock<std::mutex> lock(sEngineLock, std::defer_lock);
904
- if (!acquireUserLock(lock)) return;
905
- if (!self.isReady || !sSynth) return;
906
- sSynth->noteOff(note);
907
- }
908
-
909
- - (void)allNotesOff {
910
- std::unique_lock<std::mutex> lock(sEngineLock, std::defer_lock);
911
- if (!acquireUserLock(lock)) return;
912
- if (!self.isReady || !sSynth) return;
913
- sSynth->allNotesOff();
914
- }
923
+ // Releases are always recorded, ready or not: a release must reach a voice that is still
924
+ // sounding whatever the engine is doing (a mark with nothing to release is harmless).
925
+ - (void)noteOff:(int)note { sNoteQueue.pushNoteOff(note); }
926
+ - (void)allNotesOff { sNoteQueue.pushAllNotesOff(); }
915
927
 
916
928
  - (void)setWaveform:(NSString *)waveform {
917
929
  Waveform wf = Waveform::Sine;
package/ios/GraphPlayer.h CHANGED
@@ -17,6 +17,10 @@
17
17
  // No allocation on the audio thread: the render buffer is sized in
18
18
  // audioDeviceAboutToStart (>= 2x the device buffer, >= 1024 samples). A device block
19
19
  // larger than that is rendered in chunks of at most the preallocated capacity.
20
+ // The graph's own render is allocation-free only with the vendored JUCE patch
21
+ // graph-output-buffer-no-realloc (src/scripts/juce-patches.cjs): unpatched,
22
+ // AudioProcessorGraph reallocates its output buffer whenever the block length changes.
23
+ // The harness counts allocations inside this callback and fails on any.
20
24
  class GraphPlayer : public juce::AudioIODeviceCallback {
21
25
  public:
22
26
  static constexpr int minCapacitySamples = 1024;
@@ -11,8 +11,11 @@ class JucePadView: ExpoView {
11
11
 
12
12
  var note: Int = 60
13
13
  var velocity: Int = 100
14
+ // Disabled pads leave hit-testing entirely, so touches reach the views underneath (forwarding
15
+ // to super would only walk the responder chain, which does not include siblings).
14
16
  var enabled: Bool = true {
15
17
  didSet {
18
+ isUserInteractionEnabled = enabled
16
19
  if !enabled {
17
20
  releaseAllTouches()
18
21
  }
@@ -0,0 +1,143 @@
1
+ #pragma once
2
+
3
+ // Pure C++ (no Objective-C): used by PolySynthProcessor, ExpoJuceAudioEngine.mm and the
4
+ // offline harness (test/harness).
5
+
6
+ #include "JuceConfig.h"
7
+ #include <juce_core/juce_core.h>
8
+ #include <algorithm>
9
+ #include <atomic>
10
+ #include <cstdint>
11
+ #include <thread>
12
+
13
+ // A note-on waiting for the audio thread.
14
+ struct NoteEvent {
15
+ int note; // 0–127
16
+ int velocity;
17
+ uint64_t seq; // NoteEventQueue sequence number (1, 2, …), compared with the release marks
18
+ juce::int64 stampTicks; // juce::Time::getHighResolutionTicks() at enqueue
19
+ juce::int64 touchTicks; // originating UITouch.timestamp in high-res ticks (same clock); 0 if not a touch
20
+ };
21
+
22
+ // Note input for the synth: note-ons travel in a ring, releases are marks.
23
+ //
24
+ // Note-ons: multi-producer (main thread + JS thread, serialised by a spinlock), single consumer
25
+ // (the audio thread, lock-free). A full ring drops the note-on (counted in notesDropped).
26
+ //
27
+ // Releases are level-triggered marks, not queued events, so nothing can drop them: noteOff(n)
28
+ // raises releasedUpTo[n] to the sequence number of the latest note-on of n, allNotesOff raises
29
+ // allReleasedUpTo to the latest note-on of any note. Every block the synth stops each held voice
30
+ // whose note-on is at or below its mark (PolySynthProcessor::processBlock). A note-on pushed after
31
+ // a release has a higher sequence number, so a release never cuts a later note. Producers only
32
+ // store atomics: no lock to wait for, no slot to run out of.
33
+ //
34
+ // The engine keeps one queue for the life of the process, outside the objects a rebuild replaces,
35
+ // so note input never needs the engine lock, and a release sent while the device is stopped or
36
+ // being reopened is applied on the first block after it.
37
+ class NoteEventQueue {
38
+ public:
39
+ static constexpr int kSize = 256;
40
+
41
+ // ── Producers (any thread) ──
42
+
43
+ // Returns false if the ring is full (counted in notesDropped).
44
+ bool pushNoteOn(int note, int velocity, juce::int64 touchTicks = 0) {
45
+ note = std::clamp(note, 0, 127);
46
+ SpinGuard guard(producerLock);
47
+ const int wp = writePos.load(std::memory_order_relaxed);
48
+ const int next = (wp + 1) % kSize;
49
+ if (next == readPos.load(std::memory_order_acquire)) {
50
+ notesDropped.fetch_add(1, std::memory_order_relaxed);
51
+ return false;
52
+ }
53
+ const uint64_t seq = lastSeq.load(std::memory_order_relaxed) + 1;
54
+ ring[wp] = { note, velocity, seq, juce::Time::getHighResolutionTicks(), touchTicks };
55
+ lastOnSeq[note].store(seq, std::memory_order_release);
56
+ lastSeq.store(seq, std::memory_order_release);
57
+ // Count before publishing: the release-store below orders this increment ahead of the
58
+ // consumer's notesConsumed increment for the same event (consumed never exceeds queued).
59
+ notesQueued.fetch_add(1, std::memory_order_relaxed);
60
+ writePos.store(next, std::memory_order_release);
61
+ return true;
62
+ }
63
+
64
+ // Releases every note-on of `note` pushed so far.
65
+ void pushNoteOff(int note) {
66
+ if (note < 0 || note > 127) return;
67
+ raise(releasedUpTo[note], lastOnSeq[note].load(std::memory_order_acquire));
68
+ }
69
+
70
+ // Releases every note-on pushed so far.
71
+ void pushAllNotesOff() {
72
+ raise(allReleasedUpTo, lastSeq.load(std::memory_order_acquire));
73
+ }
74
+
75
+ // ── Consumer (the audio thread) ──
76
+
77
+ template <typename Handler>
78
+ void drainNoteOns(Handler&& onNoteOn) {
79
+ int rp = readPos.load(std::memory_order_relaxed);
80
+ const int wp = writePos.load(std::memory_order_acquire);
81
+ while (rp != wp) {
82
+ onNoteOn(ring[rp]);
83
+ notesConsumed.fetch_add(1, std::memory_order_relaxed);
84
+ rp = (rp + 1) % kSize;
85
+ }
86
+ readPos.store(rp, std::memory_order_release);
87
+ }
88
+
89
+ // True if the note-on `seq` of `note` has been released (seq 0 = not from this queue).
90
+ bool isReleased(int note, uint64_t seq) const {
91
+ if (seq == 0) return false;
92
+ uint64_t mark = allReleasedUpTo.load(std::memory_order_acquire);
93
+ if (note >= 0 && note <= 127)
94
+ mark = std::max(mark, releasedUpTo[note].load(std::memory_order_acquire));
95
+ return seq <= mark;
96
+ }
97
+
98
+ // Drops queued note-ons (counted in notesDiscarded). Consumer side: call only while no audio
99
+ // thread drains the queue (engine setup, before the device callback is attached).
100
+ void discardPending() {
101
+ const int rp = readPos.load(std::memory_order_relaxed);
102
+ const int wp = writePos.load(std::memory_order_acquire);
103
+ notesDiscarded.fetch_add((uint32_t)((wp - rp + kSize) % kSize), std::memory_order_relaxed);
104
+ readPos.store(wp, std::memory_order_release);
105
+ }
106
+
107
+ // Cumulative counters (relaxed). notesQueued == notesConsumed + notesDiscarded + still queued;
108
+ // attempted note-ons == notesQueued + notesDropped.
109
+ std::atomic<uint32_t> notesQueued{0};
110
+ std::atomic<uint32_t> notesConsumed{0};
111
+ std::atomic<uint32_t> notesDropped{0}; // note-on rejected: ring full
112
+ std::atomic<uint32_t> notesDiscarded{0}; // dropped by discardPending (engine setup)
113
+
114
+ private:
115
+ // Spinlock guard for the producer side (test_and_set acquire / clear release). Critical
116
+ // sections are a few stores; yield in case the holder was preempted.
117
+ struct SpinGuard {
118
+ std::atomic_flag& flag;
119
+ explicit SpinGuard(std::atomic_flag& f) : flag(f) {
120
+ while (flag.test_and_set(std::memory_order_acquire)) std::this_thread::yield();
121
+ }
122
+ ~SpinGuard() { flag.clear(std::memory_order_release); }
123
+ SpinGuard(const SpinGuard&) = delete;
124
+ SpinGuard& operator=(const SpinGuard&) = delete;
125
+ };
126
+
127
+ static void raise(std::atomic<uint64_t>& mark, uint64_t value) {
128
+ uint64_t current = mark.load(std::memory_order_relaxed);
129
+ while (current < value
130
+ && !mark.compare_exchange_weak(current, value, std::memory_order_release,
131
+ std::memory_order_relaxed)) {}
132
+ }
133
+
134
+ NoteEvent ring[kSize];
135
+ std::atomic<int> writePos{0};
136
+ std::atomic<int> readPos{0};
137
+ std::atomic_flag producerLock = ATOMIC_FLAG_INIT;
138
+
139
+ std::atomic<uint64_t> lastSeq{0}; // latest note-on sequence number (any note)
140
+ std::atomic<uint64_t> lastOnSeq[128] = {}; // latest note-on sequence number per note
141
+ std::atomic<uint64_t> releasedUpTo[128] = {};
142
+ std::atomic<uint64_t> allReleasedUpTo{0};
143
+ };
@@ -5,6 +5,7 @@
5
5
  #include "Voice.h"
6
6
  #include "VoiceAllocator.h"
7
7
  #include "TransportEngine.h"
8
+ #include "NoteEventQueue.h"
8
9
  #include <atomic>
9
10
  #include <cmath>
10
11
  #include <cstdint>
@@ -15,17 +16,11 @@ struct BeatState {
15
16
  std::atomic<double> bpm{120.0};
16
17
  };
17
18
 
18
- // Diagnostics counters. Written on the audio thread (except notesQueued and
19
- // notesDropped, which the noteOn producers write), read from any thread. Relaxed
20
- // loads only; notesQueued is incremented before the release-store that publishes
21
- // the event, so a reader never sees notesConsumed > notesQueued. notesQueued counts
22
- // accepted NoteOns only: attempted NoteOns == notesQueued + notesDropped.
19
+ // Diagnostics counters. Written on the audio thread, read from any thread (relaxed loads
20
+ // only). The note counters live in the NoteEventQueue.
23
21
  struct SynthDiagnostics {
24
22
  std::atomic<uint64_t> processCount{0};
25
23
  std::atomic<float> peak{0.0f}; // decaying hold of block max |x|
26
- std::atomic<uint32_t> notesQueued{0};
27
- std::atomic<uint32_t> notesConsumed{0};
28
- std::atomic<uint32_t> notesDropped{0}; // noteOn rejected: event queue full
29
24
  std::atomic<int> activeVoices{0};
30
25
  std::atomic<double> lastNoteToRenderMs{-1.0}; // -1 = no NoteOn consumed yet
31
26
  std::atomic<double> lastTouchToRenderMs{-1.0}; // -1 = no touch-stamped NoteOn consumed yet
@@ -38,17 +33,6 @@ inline float diagPeakDecay(int numSamples, double sampleRate) {
38
33
  return (float)std::exp(-(double)numSamples / (0.43 * sampleRate));
39
34
  }
40
35
 
41
- // Note event queue: multi-producer (main thread + JS thread, serialised by a spinlock),
42
- // single consumer (audio thread, lock-free).
43
- struct NoteEvent {
44
- enum Type : uint8_t { NoteOn, NoteOff, AllNotesOff };
45
- Type type;
46
- int note;
47
- int velocity;
48
- juce::int64 stampTicks; // juce::Time::getHighResolutionTicks() at enqueue (0 if unused)
49
- juce::int64 touchTicks; // originating UITouch.timestamp in high-res ticks (same clock); 0 if not a touch
50
- };
51
-
52
36
  // Every user-settable synth parameter, as stored (after clamping). getSettings / applySettings
53
37
  // carry the configuration across an engine rebuild, which replaces the processor.
54
38
  struct SynthSettings {
@@ -82,13 +66,17 @@ public:
82
66
  void getStateInformation(juce::MemoryBlock&) override {}
83
67
  void setStateInformation(const void*, int) override {}
84
68
 
85
- // Thread-safe: callable from any number of threads (producers are serialised by
86
- // producerLock), queued for the audio thread.
69
+ // Thread-safe, any number of threads: forwarded to the note queue (see NoteEventQueue).
87
70
  // touchTicks: originating UITouch timestamp in juce::Time high-resolution ticks
88
71
  // (mach_absolute_time on Apple), or 0 when the note did not come from a touch.
89
- void noteOn(int note, int velocity, juce::int64 touchTicks = 0);
90
- void noteOff(int note);
91
- void allNotesOff();
72
+ void noteOn(int note, int velocity, juce::int64 touchTicks = 0) { queue->pushNoteOn(note, velocity, touchTicks); }
73
+ void noteOff(int note) { queue->pushNoteOff(note); }
74
+ void allNotesOff() { queue->pushAllNotesOff(); }
75
+
76
+ // The queue this synth reads its notes from: its own by default; the engine passes its
77
+ // process-lifetime queue (before the synth is attached to a device). nullptr = own queue.
78
+ void setNoteQueue(NoteEventQueue* q) { queue = q ? q : &ownQueue; }
79
+ NoteEventQueue& noteQueue() { return *queue; }
92
80
 
93
81
  void setWaveform(Waveform wf);
94
82
  void setFilterCutoff(double hz);
@@ -109,7 +97,7 @@ public:
109
97
  SynthDiagnostics diag;
110
98
 
111
99
  private:
112
- void processNoteEvent(const NoteEvent& event);
100
+ void startQueuedNote(const NoteEvent& event);
113
101
 
114
102
  Voice voices[MAX_VOICES];
115
103
  VoiceAllocator allocator;
@@ -121,12 +109,6 @@ private:
121
109
  std::atomic<float> detuneCents{0.0f};
122
110
  std::atomic<float> masterLevel{0.5f};
123
111
 
124
- // Ring buffer for note events (producers → audio thread). Producers (noteOn / noteOff /
125
- // allNotesOff, from the main thread and the JS thread) hold producerLock while they
126
- // write; the consumer (processBlock) never touches it.
127
- static constexpr int EVENT_QUEUE_SIZE = 256;
128
- NoteEvent eventQueue[EVENT_QUEUE_SIZE];
129
- std::atomic<int> eventWritePos{0};
130
- std::atomic<int> eventReadPos{0};
131
- std::atomic_flag producerLock = ATOMIC_FLAG_INIT;
112
+ NoteEventQueue ownQueue;
113
+ NoteEventQueue* queue = &ownQueue;
132
114
  };
@@ -1,21 +1,6 @@
1
1
  #include "PolySynthProcessor.h"
2
2
  #include <cmath>
3
3
  #include <algorithm>
4
- #include <thread>
5
-
6
- namespace {
7
- // Spinlock guard for the note queue's producer side (test_and_set acquire / clear release).
8
- // Critical sections are a few stores; yield in case the holder was preempted.
9
- struct ProducerSpinGuard {
10
- std::atomic_flag& flag;
11
- explicit ProducerSpinGuard(std::atomic_flag& f) : flag(f) {
12
- while (flag.test_and_set(std::memory_order_acquire)) std::this_thread::yield();
13
- }
14
- ~ProducerSpinGuard() { flag.clear(std::memory_order_release); }
15
- ProducerSpinGuard(const ProducerSpinGuard&) = delete;
16
- ProducerSpinGuard& operator=(const ProducerSpinGuard&) = delete;
17
- };
18
- }
19
4
 
20
5
  PolySynthProcessor::PolySynthProcessor()
21
6
  : AudioProcessor(BusesProperties()
@@ -34,29 +19,14 @@ void PolySynthProcessor::processBlock(juce::AudioBuffer<float>& buffer,
34
19
  juce::MidiBuffer& /*midiMessages*/) {
35
20
  buffer.clear();
36
21
 
37
- // Drain note event FIFO (audio thread side)
38
- int rp = eventReadPos.load(std::memory_order_relaxed);
39
- int wp = eventWritePos.load(std::memory_order_acquire);
40
- while (rp != wp) {
41
- const NoteEvent& event = eventQueue[rp];
42
- processNoteEvent(event);
43
- if (event.type == NoteEvent::NoteOn) {
44
- diag.notesConsumed.fetch_add(1, std::memory_order_relaxed);
45
- const juce::int64 nowTicks = juce::Time::getHighResolutionTicks();
46
- if (event.stampTicks != 0) {
47
- const juce::int64 elapsed = nowTicks - event.stampTicks;
48
- diag.lastNoteToRenderMs.store(juce::Time::highResolutionTicksToSeconds(elapsed) * 1000.0,
49
- std::memory_order_relaxed);
50
- }
51
- if (event.touchTicks != 0 && nowTicks >= event.touchTicks) {
52
- const juce::int64 elapsed = nowTicks - event.touchTicks;
53
- diag.lastTouchToRenderMs.store(juce::Time::highResolutionTicksToSeconds(elapsed) * 1000.0,
54
- std::memory_order_relaxed);
55
- }
56
- }
57
- rp = (rp + 1) % EVENT_QUEUE_SIZE;
22
+ // Note-ons waiting in the queue, then releases: every held voice whose note-on is at or
23
+ // below its release mark stops (see NoteEventQueue). Applied every block, so a release sent
24
+ // while the device was stopped or reopening takes effect on the first block after it.
25
+ queue->drainNoteOns([this](const NoteEvent& event) { startQueuedNote(event); });
26
+ for (int v = 0; v < MAX_VOICES; v++) {
27
+ if (voices[v].isHeld() && queue->isReleased(voices[v].getNoteNumber(), voices[v].startSeq))
28
+ voices[v].stopNote();
58
29
  }
59
- eventReadPos.store(rp, std::memory_order_release);
60
30
 
61
31
  int numSamples = buffer.getNumSamples();
62
32
  double sr = getSampleRate();
@@ -118,62 +88,27 @@ void PolySynthProcessor::processBlock(juce::AudioBuffer<float>& buffer,
118
88
  }
119
89
  }
120
90
 
121
- // Called on audio thread from processBlock
122
- void PolySynthProcessor::processNoteEvent(const NoteEvent& event) {
123
- switch (event.type) {
124
- case NoteEvent::NoteOn: {
125
- int clampedNote = std::clamp(event.note, 0, 127);
126
- int clampedVel = std::clamp(event.velocity, 0, 127);
127
- allocator.noteOn(clampedNote, (float)clampedVel, voices,
128
- static_cast<Waveform>(waveform.load()),
129
- (double)detuneCents.load(),
130
- filterCutoff.load(), filterResonance.load(),
131
- masterLevel.load());
132
- break;
133
- }
134
- case NoteEvent::NoteOff: {
135
- int clampedNote = std::clamp(event.note, 0, 127);
136
- allocator.noteOff(clampedNote, voices);
137
- break;
138
- }
139
- case NoteEvent::AllNotesOff:
140
- allocator.allNotesOff(voices);
141
- break;
91
+ // Audio thread (processBlock): start a queued note-on and record its latency.
92
+ void PolySynthProcessor::startQueuedNote(const NoteEvent& event) {
93
+ const int clampedNote = std::clamp(event.note, 0, 127);
94
+ const int clampedVel = std::clamp(event.velocity, 0, 127);
95
+ allocator.noteOn(clampedNote, (float)clampedVel, voices,
96
+ static_cast<Waveform>(waveform.load()),
97
+ (double)detuneCents.load(),
98
+ filterCutoff.load(), filterResonance.load(),
99
+ masterLevel.load(), event.seq);
100
+
101
+ const juce::int64 nowTicks = juce::Time::getHighResolutionTicks();
102
+ if (event.stampTicks != 0) {
103
+ const juce::int64 elapsed = nowTicks - event.stampTicks;
104
+ diag.lastNoteToRenderMs.store(juce::Time::highResolutionTicksToSeconds(elapsed) * 1000.0,
105
+ std::memory_order_relaxed);
142
106
  }
143
- }
144
-
145
- // Producers (main thread, JS thread): push an event to the FIFO under producerLock.
146
- void PolySynthProcessor::noteOn(int note, int velocity, juce::int64 touchTicks) {
147
- ProducerSpinGuard guard(producerLock);
148
- int wp = eventWritePos.load(std::memory_order_relaxed);
149
- int nextWp = (wp + 1) % EVENT_QUEUE_SIZE;
150
- if (nextWp == eventReadPos.load(std::memory_order_acquire)) { // full, drop event
151
- diag.notesDropped.fetch_add(1, std::memory_order_relaxed);
152
- return;
107
+ if (event.touchTicks != 0 && nowTicks >= event.touchTicks) {
108
+ const juce::int64 elapsed = nowTicks - event.touchTicks;
109
+ diag.lastTouchToRenderMs.store(juce::Time::highResolutionTicksToSeconds(elapsed) * 1000.0,
110
+ std::memory_order_relaxed);
153
111
  }
154
- eventQueue[wp] = { NoteEvent::NoteOn, note, velocity, juce::Time::getHighResolutionTicks(), touchTicks };
155
- // Count before publishing: the release-store below orders this increment
156
- // ahead of the consumer's notesConsumed increment for the same event.
157
- diag.notesQueued.fetch_add(1, std::memory_order_relaxed);
158
- eventWritePos.store(nextWp, std::memory_order_release);
159
- }
160
-
161
- void PolySynthProcessor::noteOff(int note) {
162
- ProducerSpinGuard guard(producerLock);
163
- int wp = eventWritePos.load(std::memory_order_relaxed);
164
- int nextWp = (wp + 1) % EVENT_QUEUE_SIZE;
165
- if (nextWp == eventReadPos.load(std::memory_order_acquire)) return;
166
- eventQueue[wp] = { NoteEvent::NoteOff, note, 0, 0, 0 };
167
- eventWritePos.store(nextWp, std::memory_order_release);
168
- }
169
-
170
- void PolySynthProcessor::allNotesOff() {
171
- ProducerSpinGuard guard(producerLock);
172
- int wp = eventWritePos.load(std::memory_order_relaxed);
173
- int nextWp = (wp + 1) % EVENT_QUEUE_SIZE;
174
- if (nextWp == eventReadPos.load(std::memory_order_acquire)) return;
175
- eventQueue[wp] = { NoteEvent::AllNotesOff, 0, 0, 0, 0 };
176
- eventWritePos.store(nextWp, std::memory_order_release);
177
112
  }
178
113
 
179
114
  void PolySynthProcessor::setWaveform(Waveform wf) { waveform.store(static_cast<int>(wf)); }
package/ios/Voice.h CHANGED
@@ -22,6 +22,11 @@ public:
22
22
  void startNote(int note, float velocity);
23
23
  void stopNote();
24
24
 
25
+ // Held: between startNote and stopNote (a voice in its release stage is not held).
26
+ bool isHeld() const { return held && noteNumber >= 0; }
27
+ // NoteEventQueue sequence number of the note-on that started this voice (0 = none).
28
+ uint64_t startSeq = 0;
29
+
25
30
  void captureAndRelease(Waveform waveform, double frequency,
26
31
  float filterCutoff, float filterResonance,
27
32
  float masterLevel);
@@ -52,6 +57,7 @@ private:
52
57
  juce::ADSR adsr;
53
58
 
54
59
  int noteNumber = -1;
60
+ bool held = false;
55
61
  float velocityGain = 0.0f;
56
62
  float currentLevel = 0.0f;
57
63
  double cachedSampleRate = 44100.0;
package/ios/Voice.mm CHANGED
@@ -52,10 +52,12 @@ void Voice::startNote(int note, float velocity) {
52
52
  filter.reset();
53
53
 
54
54
  updateADSR();
55
+ held = true;
55
56
  adsr.noteOn();
56
57
  }
57
58
 
58
59
  void Voice::stopNote() {
60
+ held = false;
59
61
  adsr.noteOff();
60
62
  }
61
63
 
@@ -88,6 +90,7 @@ void Voice::captureAndRelease(Waveform waveform, double frequency,
88
90
  }
89
91
 
90
92
  noteNumber = -1;
93
+ held = false;
91
94
  currentLevel = 0.0f;
92
95
  }
93
96
 
@@ -11,7 +11,7 @@ public:
11
11
  int noteOn(int note, float velocity, Voice* voices,
12
12
  Waveform waveform, double detuneCents,
13
13
  float filterCutoff, float filterResonance,
14
- float masterLevel);
14
+ float masterLevel, uint64_t seq = 0);
15
15
 
16
16
  void noteOff(int note, Voice* voices);
17
17
  void allNotesOff(Voice* voices);
@@ -28,7 +28,7 @@ int VoiceAllocator::findVoice(Voice* voices) {
28
28
  int VoiceAllocator::noteOn(int note, float velocity, Voice* voices,
29
29
  Waveform waveform, double detuneCents,
30
30
  float filterCutoff, float filterResonance,
31
- float masterLevel) {
31
+ float masterLevel, uint64_t seq) {
32
32
  if (note >= 0 && note < 128 && noteMap[note] >= 0) {
33
33
  int existing = noteMap[note];
34
34
  if (existing < MAX_VOICES && voices[existing].getNoteNumber() == note) {
@@ -58,6 +58,7 @@ int VoiceAllocator::noteOn(int note, float velocity, Voice* voices,
58
58
  }
59
59
 
60
60
  voice.startNote(note, velocity);
61
+ voice.startSeq = seq;
61
62
 
62
63
  if (note >= 0 && note < 128) {
63
64
  noteMap[note] = voiceIdx;
@@ -551,7 +551,7 @@ struct GraphRenderSequence
551
551
  return;
552
552
  }
553
553
 
554
- currentAudioOutputBuffer.setSize (jmax (1, buffer.getNumChannels()), numSamples);
554
+ currentAudioOutputBuffer.setSize (jmax (1, buffer.getNumChannels()), numSamples, false, false, true); // expo-juce patch: graph-output-buffer-no-realloc
555
555
  currentAudioOutputBuffer.clear();
556
556
  currentMidiOutputBuffer.clear();
557
557
 
@@ -798,7 +798,7 @@ struct GraphRenderSequence
798
798
  {
799
799
  renderingBuffer.setSize (numBuffersNeeded + 1, blockSize);
800
800
  renderingBuffer.clear();
801
- currentAudioOutputBuffer.setSize (numBuffersNeeded + 1, blockSize);
801
+ currentAudioOutputBuffer.setSize (jmax (numBuffersNeeded + 1, 8), blockSize); // expo-juce patch: graph-output-buffer-no-realloc
802
802
  currentAudioOutputBuffer.clear();
803
803
 
804
804
  currentMidiOutputBuffer.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-juce",
3
- "version": "0.5.2-rc.1",
3
+ "version": "0.5.2",
4
4
  "description": "Realtime DSP w/C++ & JUCE",
5
5
  "type": "module",
6
6
  "main": "build/index.js",