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 +4 -3
- package/ios/ExpoJuce.podspec +1 -0
- package/ios/ExpoJuceAudioEngine.mm +44 -32
- package/ios/GraphPlayer.h +4 -0
- package/ios/JucePadView.swift +3 -0
- package/ios/NoteEventQueue.h +143 -0
- package/ios/PolySynthProcessor.h +15 -33
- package/ios/PolySynthProcessor.mm +26 -91
- package/ios/Voice.h +6 -0
- package/ios/Voice.mm +3 -0
- package/ios/VoiceAllocator.h +1 -1
- package/ios/VoiceAllocator.mm +2 -1
- package/ios/juce_modules/juce_audio_processors/processors/juce_AudioProcessorGraph.cpp +2 -2
- package/package.json +1 -1
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
|
-
├─
|
|
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
|
|
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();
|
package/ios/ExpoJuce.podspec
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
724
|
+
sNoteQueue.pushAllNotesOff();
|
|
705
725
|
[self teardownEngineLocked];
|
|
706
726
|
ok = [self setupEngineLocked];
|
|
707
|
-
if (ok)
|
|
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
|
-
//
|
|
881
|
-
//
|
|
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
|
-
|
|
885
|
-
|
|
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
|
-
|
|
897
|
-
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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;
|
package/ios/JucePadView.swift
CHANGED
|
@@ -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
|
+
};
|
package/ios/PolySynthProcessor.h
CHANGED
|
@@ -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 (
|
|
19
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
//
|
|
122
|
-
void PolySynthProcessor::
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
package/ios/VoiceAllocator.h
CHANGED
|
@@ -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);
|
package/ios/VoiceAllocator.mm
CHANGED
|
@@ -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();
|