@geastack/apple 0.2.8

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.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +54 -0
  3. package/dist/index.d.ts +127 -0
  4. package/dist/index.js +3076 -0
  5. package/generated/AVFoundation.d.ts +112 -0
  6. package/generated/ActivityKit.d.ts +7 -0
  7. package/generated/AppKit.d.ts +268 -0
  8. package/generated/CoreGraphics.d.ts +18 -0
  9. package/generated/CoreLocation.d.ts +28 -0
  10. package/generated/CoreMedia.d.ts +8 -0
  11. package/generated/Dispatch.d.ts +2 -0
  12. package/generated/Foundation.d.ts +13 -0
  13. package/generated/MapKit.d.ts +28 -0
  14. package/generated/Metal.d.ts +91 -0
  15. package/generated/MetalKit.d.ts +22 -0
  16. package/generated/Photos.d.ts +5 -0
  17. package/generated/QuartzCore.d.ts +12 -0
  18. package/generated/SwiftData.d.ts +11 -0
  19. package/generated/UIKit.d.ts +187 -0
  20. package/package.json +123 -0
  21. package/runtime/AVFoundation.js +136 -0
  22. package/runtime/ActivityKit.js +18 -0
  23. package/runtime/AppKit.js +313 -0
  24. package/runtime/CoreGraphics.js +15 -0
  25. package/runtime/CoreLocation.js +30 -0
  26. package/runtime/CoreMedia.js +7 -0
  27. package/runtime/Dispatch.js +11 -0
  28. package/runtime/Foundation.js +21 -0
  29. package/runtime/MapKit.js +22 -0
  30. package/runtime/Metal.js +126 -0
  31. package/runtime/MetalKit.js +15 -0
  32. package/runtime/Photos.js +9 -0
  33. package/runtime/QuartzCore.js +9 -0
  34. package/runtime/SwiftData.js +29 -0
  35. package/runtime/UIKit.js +242 -0
  36. package/runtime/empty.js +1 -0
  37. package/targets/ios/Info.plist.in +25 -0
  38. package/targets/ios/README.md +46 -0
  39. package/targets/ios/build-ios.sh +395 -0
  40. package/targets/ios/detect-development-team.mjs +109 -0
  41. package/targets/ios/generate-resident-entry.mjs +67 -0
  42. package/targets/ios/generate-resident-registry.mjs +162 -0
  43. package/targets/ios/generate-xcode-project.mjs +520 -0
  44. package/targets/ios/main/canvas_view.h +7 -0
  45. package/targets/ios/main/canvas_view.mm +94 -0
  46. package/targets/ios/main/font_registry.h +17 -0
  47. package/targets/ios/main/font_registry.mm +129 -0
  48. package/targets/ios/main/image_bridge.h +13 -0
  49. package/targets/ios/main/image_bridge.mm +53 -0
  50. package/targets/ios/main/ios_apps.c +28 -0
  51. package/targets/ios/main/ios_camera.mm +446 -0
  52. package/targets/ios/main/ios_display.mm +344 -0
  53. package/targets/ios/main/ios_main.mm +811 -0
  54. package/targets/ios/main/ios_memory.cpp +55 -0
  55. package/targets/ios/main/ios_renderer.h +20 -0
  56. package/targets/ios/main/ios_renderer.mm +27 -0
  57. package/targets/ios/main/ios_root_background.cpp +20 -0
  58. package/targets/ios/main/ios_root_background.h +11 -0
  59. package/targets/ios/main/ios_sensors.mm +244 -0
  60. package/targets/ios/main/ios_timers.mm +53 -0
  61. package/targets/ios/main/renderer/ios_renderer_internal.h +65 -0
  62. package/targets/ios/main/renderer/ios_renderer_support.mm +111 -0
  63. package/targets/ios/main/renderer/native_button.mm +179 -0
  64. package/targets/ios/main/renderer/native_label.mm +161 -0
  65. package/targets/ios/main/renderer/native_scroll_container.mm +279 -0
  66. package/targets/ios/main/renderer/view_reconciler.mm +274 -0
  67. package/targets/macos/Info.plist.in +26 -0
  68. package/targets/macos/README.md +131 -0
  69. package/targets/macos/build-child-lifecycle.sh +119 -0
  70. package/targets/macos/build-macos.sh +2156 -0
  71. package/targets/macos/check-module-graph-freshness.mjs +82 -0
  72. package/targets/macos/compiler-input-fingerprint.mjs +32 -0
  73. package/targets/macos/finalize-apple-native-output.mjs +77 -0
  74. package/targets/macos/generate-resident-entry.mjs +80 -0
  75. package/targets/macos/generate-resident-registry.mjs +141 -0
  76. package/targets/macos/heavy-build-lock.sh +176 -0
  77. package/targets/macos/main/canvas_view.h +9 -0
  78. package/targets/macos/main/canvas_view.mm +176 -0
  79. package/targets/macos/main/color_convert.h +18 -0
  80. package/targets/macos/main/color_convert.mm +39 -0
  81. package/targets/macos/main/font_registry.h +35 -0
  82. package/targets/macos/main/font_registry.mm +350 -0
  83. package/targets/macos/main/image_bridge.h +16 -0
  84. package/targets/macos/main/image_bridge.mm +52 -0
  85. package/targets/macos/main/macos_app_platform.mm +95 -0
  86. package/targets/macos/main/macos_apps.c +31 -0
  87. package/targets/macos/main/macos_device_control.mm +56 -0
  88. package/targets/macos/main/macos_display.mm +484 -0
  89. package/targets/macos/main/macos_host_device_control.mm +95 -0
  90. package/targets/macos/main/macos_main.mm +839 -0
  91. package/targets/macos/main/macos_memory.cpp +31 -0
  92. package/targets/macos/main/macos_native_shell.h +31 -0
  93. package/targets/macos/main/macos_native_shell.mm +396 -0
  94. package/targets/macos/main/macos_network.mm +253 -0
  95. package/targets/macos/main/macos_renderer.h +39 -0
  96. package/targets/macos/main/macos_renderer.mm +1969 -0
  97. package/targets/macos/main/macos_sensors.cpp +148 -0
  98. package/targets/macos/main/macos_smoke_app.cpp +65 -0
  99. package/targets/macos/main/macos_timers.mm +52 -0
  100. package/targets/macos/main/press_bridge.h +21 -0
  101. package/targets/macos/main/press_bridge.mm +72 -0
  102. package/targets/macos/main/thermalright_hid_display.h +14 -0
  103. package/targets/macos/main/thermalright_hid_display.mm +995 -0
  104. package/targets/macos/native-artifact-provenance.mjs +421 -0
  105. package/targets/macos/order-compile-edges.mjs +64 -0
  106. package/targets/macos/preflight-hard-muted-artifact.mjs +93 -0
  107. package/targets/macos/prune-generated-modules.mjs +147 -0
  108. package/targets/macos/sync-resources.mjs +153 -0
  109. package/targets/shared/analyze-audio-capture.mjs +131 -0
  110. package/targets/shared/apple_audio.mm +1194 -0
  111. package/targets/shared/apple_audio_selftest.mm +602 -0
  112. package/targets/shared/apple_audio_testing.h +34 -0
  113. package/targets/shared/build-audio-selftest.sh +50 -0
@@ -0,0 +1,1194 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // Real Apple audio backend for gea::platform::audio — shared by the macOS and
4
+ // iOS targets (AVFoundation is on both; the only platform-conditional code is
5
+ // the iOS AVAudioSession activation).
6
+ //
7
+ // Shape
8
+ // -----
9
+ // AVAudioEngine
10
+ // ├─ AVAudioSourceNode ── the synth: every OscillatorNode the app creates
11
+ // │ is rendered here, sample-accurately scheduled and
12
+ // │ mixed against every other live note.
13
+ // ├─ AVAudioPlayerNode ── AudioSystem::playFile
14
+ // └─ AVAudioPlayerNode ── AudioSystem::playPcm
15
+ // └─ mainMixerNode ─ outputNode
16
+ //
17
+ // Timebase
18
+ // --------
19
+ // AudioContext::currentTime() is the engine's own running time: frames rendered
20
+ // by the source node divided by the graph's sample rate (Web Audio semantics —
21
+ // it advances a render quantum at a time and sits slightly ahead of what the DAC
22
+ // has emitted). start(when)/stop(when) are converted to absolute frame numbers
23
+ // in that same timebase, so a note scheduled 150 ms out really starts 150 ms
24
+ // out. Before the engine is running (or if it never starts, e.g. no output
25
+ // device) currentTime() falls back to a steady wall clock so an app whose
26
+ // scheduler is driven off it keeps making progress instead of wedging; the
27
+ // frame offset is seeded from that clock when the engine does start, so the
28
+ // value is monotonic across the handover.
29
+ //
30
+ // Thread safety
31
+ // -------------
32
+ // The render block runs on the CoreAudio realtime thread. It allocates nothing,
33
+ // takes no lock, sends no Objective-C message and logs nothing: it drains a
34
+ // bounded lock-free command ring (producers serialise among themselves on a
35
+ // mutex the render thread never touches) into a fixed voice array it alone
36
+ // owns. Everything the app thread mutates — oscillator records, the AVAudioEngine
37
+ // graph — lives behind app-side mutexes the render thread never takes.
38
+ //
39
+ // Clicks
40
+ // ------
41
+ // Every note gets a ~2.5 ms attack ramp and a ~6 ms release ramp that starts at
42
+ // its scheduled stop time, so a burst of 24 ms notes (button-tetris) neither
43
+ // clicks on attack nor thuds on release.
44
+ //
45
+ // Verification hooks
46
+ // ------------------
47
+ // GEA_AUDIO_OFFLINE=1 build the graph in manual-rendering mode
48
+ // (no output device is touched).
49
+ // GEA_AUDIO_CAPTURE_RAW=<path> as above, plus a thread that pulls the graph
50
+ // at real-time pace and appends raw interleaved
51
+ // float32 stereo @48 kHz to <path>. That is how
52
+ // a real app run is measured end-to-end —
53
+ // analyze it with analyze-audio-capture.mjs.
54
+ // GEA_AUDIO_DISABLED=1 build no graph at all: the same state as
55
+ // "no output device", everything still callable.
56
+ // GEA_AUDIO_DEBUG=1 one stderr line at setup: mode, rate, whether
57
+ // the engine started, and why not.
58
+ // See apple_audio_testing.h for the in-process offline API, and
59
+ // build-audio-selftest.sh for the measurement harness.
60
+
61
+ #import <AVFoundation/AVFoundation.h>
62
+
63
+ #include <TargetConditionals.h>
64
+
65
+ #include "audio.h"
66
+
67
+ #include "apple_audio_testing.h"
68
+
69
+ #include <algorithm>
70
+ #include <atomic>
71
+ #include <chrono>
72
+ #include <cmath>
73
+ #include <cstdint>
74
+ #include <cstdio>
75
+ #include <cstdlib>
76
+ #include <cstring>
77
+ #include <limits>
78
+ #include <mutex>
79
+ #include <string>
80
+ #include <thread>
81
+ #include <vector>
82
+
83
+ namespace gea::platform::audio {
84
+
85
+ namespace {
86
+
87
+ constexpr int kMaxOscillators = 512; // app-side handle pool (round-robin)
88
+ constexpr int kMaxVoices = 64; // notes rendering simultaneously
89
+ constexpr int kCommandCapacity = 2048; // app thread -> render thread ring
90
+ constexpr double kTwoPi = 6.28318530717958647692;
91
+ constexpr double kDefaultFrequency = 440.0;
92
+ constexpr double kMinFrequency = 8.0;
93
+ constexpr double kMaxFrequency = 20000.0;
94
+ constexpr double kAttackSeconds = 0.0025;
95
+ constexpr double kReleaseSeconds = 0.006;
96
+ constexpr double kVoiceGain = 0.25; // headroom: four notes at once still fit
97
+ constexpr double kMaxVoiceSeconds = 30.0; // a never-stopped note cannot run forever
98
+ constexpr double kFallbackSampleRate = 48000.0;
99
+ constexpr double kCaptureSampleRate = 48000.0;
100
+ constexpr AVAudioFrameCount kOfflineMaxFrames = 4096;
101
+ constexpr AVAudioFrameCount kCaptureBlockFrames = 512;
102
+
103
+ double steadySeconds()
104
+ {
105
+ static const std::chrono::steady_clock::time_point origin = std::chrono::steady_clock::now();
106
+ return std::chrono::duration<double>(std::chrono::steady_clock::now() - origin).count();
107
+ }
108
+
109
+ OscillatorType normalizeType(OscillatorType type)
110
+ {
111
+ switch (type) {
112
+ case OscillatorType::Sine:
113
+ case OscillatorType::Square:
114
+ case OscillatorType::Sawtooth:
115
+ case OscillatorType::Triangle: return type;
116
+ }
117
+ return OscillatorType::Sine;
118
+ }
119
+
120
+ double normalizeFrequency(double frequency)
121
+ {
122
+ if (!std::isfinite(frequency)) return kDefaultFrequency;
123
+ if (frequency < kMinFrequency) return kMinFrequency;
124
+ if (frequency > kMaxFrequency) return kMaxFrequency;
125
+ return frequency;
126
+ }
127
+
128
+ // phase is [0, 1)
129
+ double waveform(OscillatorType type, double phase)
130
+ {
131
+ switch (type) {
132
+ case OscillatorType::Square: return phase < 0.5 ? 1.0 : -1.0;
133
+ case OscillatorType::Sawtooth: return 2.0 * phase - 1.0;
134
+ case OscillatorType::Triangle:
135
+ if (phase < 0.25) return 4.0 * phase;
136
+ if (phase < 0.75) return 2.0 - 4.0 * phase;
137
+ return 4.0 * phase - 4.0;
138
+ case OscillatorType::Sine: break;
139
+ }
140
+ return std::sin(phase * kTwoPi);
141
+ }
142
+
143
+ bool envFlagSet(const char *name)
144
+ {
145
+ const char *value = std::getenv(name);
146
+ return value != nullptr && value[0] != '\0' && std::strcmp(value, "0") != 0;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // app thread -> render thread commands
151
+ // ---------------------------------------------------------------------------
152
+ enum class CommandKind : std::uint8_t { StartVoice, StopVoice, SetFrequency, SetType, ScheduleFrequency };
153
+
154
+ struct Command {
155
+ CommandKind kind = CommandKind::StartVoice;
156
+ OscillatorType type = OscillatorType::Sine;
157
+ std::uint64_t voice = 0;
158
+ double frequency = kDefaultFrequency;
159
+ std::int64_t frame = 0;
160
+ };
161
+
162
+ // Multi-producer (serialised by a mutex the consumer never takes), single
163
+ // consumer, wait-free on the consumer side.
164
+ class CommandQueue {
165
+ public:
166
+ bool push(const Command &command)
167
+ {
168
+ std::lock_guard<std::mutex> guard(producers_);
169
+ const std::uint32_t tail = tail_.load(std::memory_order_relaxed);
170
+ const std::uint32_t next = (tail + 1u) % kCommandCapacity;
171
+ if (next == head_.load(std::memory_order_acquire)) {
172
+ dropped_.fetch_add(1, std::memory_order_relaxed);
173
+ return false;
174
+ }
175
+ items_[tail] = command;
176
+ tail_.store(next, std::memory_order_release);
177
+ return true;
178
+ }
179
+
180
+ bool pop(Command &out)
181
+ {
182
+ const std::uint32_t head = head_.load(std::memory_order_relaxed);
183
+ if (head == tail_.load(std::memory_order_acquire)) return false;
184
+ out = items_[head];
185
+ head_.store((head + 1u) % kCommandCapacity, std::memory_order_release);
186
+ return true;
187
+ }
188
+
189
+ std::uint64_t dropped() const { return dropped_.load(std::memory_order_relaxed); }
190
+
191
+ private:
192
+ Command items_[kCommandCapacity]{};
193
+ std::atomic<std::uint32_t> head_{0};
194
+ std::atomic<std::uint32_t> tail_{0};
195
+ std::atomic<std::uint64_t> dropped_{0};
196
+ std::mutex producers_;
197
+ };
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // render-thread voice state (touched by the render thread only)
201
+ // ---------------------------------------------------------------------------
202
+ constexpr int kMaxVoiceEvents = 8; // setValueAtTime events queued per note
203
+
204
+ struct VoiceEvent {
205
+ std::int64_t frame = 0;
206
+ double phaseIncrement = 0.0;
207
+ };
208
+
209
+ struct Voice {
210
+ bool active = false;
211
+ std::uint64_t id = 0;
212
+ OscillatorType type = OscillatorType::Sine;
213
+ double phase = 0.0;
214
+ double phaseIncrement = 0.0;
215
+ std::int64_t startFrame = 0;
216
+ std::int64_t stopFrame = 0;
217
+ std::int64_t endFrame = 0; // stopFrame + release, or the runaway cap
218
+ bool stopScheduled = false;
219
+ VoiceEvent events[kMaxVoiceEvents]{};
220
+ int eventHead = 0;
221
+ int eventCount = 0;
222
+ };
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // app-thread oscillator records
226
+ // ---------------------------------------------------------------------------
227
+ struct ScheduledValue {
228
+ double time = 0.0;
229
+ double value = kDefaultFrequency;
230
+ };
231
+
232
+ struct OscillatorRecord {
233
+ std::uint64_t handle = 0; // 0 == free slot
234
+ OscillatorType type = OscillatorType::Sine;
235
+ double frequency = kDefaultFrequency;
236
+ bool connected = false;
237
+ bool started = false;
238
+ bool stopped = false;
239
+ bool voiceLaunched = false;
240
+ double startTime = 0.0;
241
+ double stopTime = 0.0;
242
+ // setValueAtTime() calls made before the note is handed to the render thread
243
+ // are replayed as scheduled events the moment it is.
244
+ ScheduledValue pending[kMaxVoiceEvents]{};
245
+ int pendingCount = 0;
246
+ };
247
+
248
+ class Engine {
249
+ public:
250
+ static Engine &instance()
251
+ {
252
+ // Deliberately leaked: a static destructor racing the CoreAudio render
253
+ // thread at process exit is a crash nobody can debug, and the process is
254
+ // going away anyway. The capture file (if any) is flushed per block.
255
+ static Engine *engine = new Engine();
256
+ return *engine;
257
+ }
258
+
259
+ // ---- timebase -----------------------------------------------------------
260
+ double currentTime()
261
+ {
262
+ ensureSetup();
263
+ if (!running_.load(std::memory_order_relaxed)) return steadySeconds();
264
+ const double rate = sampleRate_.load(std::memory_order_relaxed);
265
+ const std::int64_t frames =
266
+ frameOffset_.load(std::memory_order_relaxed) + renderedFrames_.load(std::memory_order_relaxed);
267
+ return static_cast<double>(frames) / rate;
268
+ }
269
+
270
+ // ---- oscillator lifecycle ----------------------------------------------
271
+ std::uint64_t createOscillator()
272
+ {
273
+ ensureSetup();
274
+ std::lock_guard<std::mutex> guard(recordsMutex_);
275
+ const int slot = nextSlot_;
276
+ nextSlot_ = (nextSlot_ + 1) % kMaxOscillators;
277
+ const std::uint64_t handle = (++generation_ << 10) | static_cast<std::uint64_t>(slot + 1);
278
+ records_[slot] = OscillatorRecord{};
279
+ records_[slot].handle = handle;
280
+ return handle;
281
+ }
282
+
283
+ double frequency(std::uint64_t handle)
284
+ {
285
+ std::lock_guard<std::mutex> guard(recordsMutex_);
286
+ const OscillatorRecord *record = find(handle);
287
+ return record ? record->frequency : 0.0;
288
+ }
289
+
290
+ void setFrequency(std::uint64_t handle, double value)
291
+ {
292
+ Command command;
293
+ bool live = false;
294
+ {
295
+ std::lock_guard<std::mutex> guard(recordsMutex_);
296
+ OscillatorRecord *record = find(handle);
297
+ if (record == nullptr || !std::isfinite(value)) return;
298
+ record->frequency = value;
299
+ live = record->voiceLaunched && !record->stopped;
300
+ command = Command{CommandKind::SetFrequency, record->type, handle, normalizeFrequency(value), 0};
301
+ }
302
+ if (live) commands_.push(command);
303
+ }
304
+
305
+ OscillatorType type(std::uint64_t handle)
306
+ {
307
+ std::lock_guard<std::mutex> guard(recordsMutex_);
308
+ const OscillatorRecord *record = find(handle);
309
+ return record ? record->type : OscillatorType::Sine;
310
+ }
311
+
312
+ void setType(std::uint64_t handle, OscillatorType type)
313
+ {
314
+ Command command;
315
+ bool live = false;
316
+ {
317
+ std::lock_guard<std::mutex> guard(recordsMutex_);
318
+ OscillatorRecord *record = find(handle);
319
+ if (record == nullptr) return;
320
+ record->type = normalizeType(type);
321
+ live = record->voiceLaunched && !record->stopped;
322
+ command = Command{CommandKind::SetType, record->type, handle, record->frequency, 0};
323
+ }
324
+ if (live) commands_.push(command);
325
+ }
326
+
327
+ // A frequency change requested for a future time on a running note.
328
+ void scheduleFrequency(std::uint64_t handle, double value, double time)
329
+ {
330
+ ensureSetup();
331
+ Command command;
332
+ bool emit = false;
333
+ {
334
+ std::lock_guard<std::mutex> guard(recordsMutex_);
335
+ OscillatorRecord *record = find(handle);
336
+ if (record == nullptr || !std::isfinite(value)) return;
337
+ if (record->voiceLaunched && !record->stopped) {
338
+ emit = true;
339
+ command = Command{CommandKind::ScheduleFrequency, record->type, handle,
340
+ normalizeFrequency(value), frameFor(time)};
341
+ } else if (record->pendingCount < kMaxVoiceEvents) {
342
+ record->pending[record->pendingCount++] = ScheduledValue{time, value};
343
+ }
344
+ // Web Audio: a value scheduled for the future does not change
345
+ // frequency.value now — only setValue()/`= x` does that.
346
+ }
347
+ if (emit) commands_.push(command);
348
+ }
349
+
350
+ void connect(std::uint64_t handle)
351
+ {
352
+ Command commands[kMaxVoiceEvents + 2];
353
+ int count = 0;
354
+ {
355
+ std::lock_guard<std::mutex> guard(recordsMutex_);
356
+ OscillatorRecord *record = find(handle);
357
+ if (record == nullptr || record->connected) return;
358
+ record->connected = true;
359
+ // start() before connect() is legal; the note becomes audible here.
360
+ if (record->started && !record->voiceLaunched) {
361
+ record->voiceLaunched = true;
362
+ count = launchCommands(*record, handle, commands);
363
+ }
364
+ }
365
+ for (int index = 0; index < count; ++index) commands_.push(commands[index]);
366
+ }
367
+
368
+ void start(std::uint64_t handle, double when)
369
+ {
370
+ ensureSetup();
371
+ Command commands[kMaxVoiceEvents + 2];
372
+ int count = 0;
373
+ {
374
+ std::lock_guard<std::mutex> guard(recordsMutex_);
375
+ OscillatorRecord *record = find(handle);
376
+ if (record == nullptr || record->started) return;
377
+ record->started = true;
378
+ const double now = currentTimeLocked();
379
+ double at = (std::isfinite(when) && when > 0.0) ? when : now;
380
+ if (at < now) at = now;
381
+ record->startTime = at;
382
+ if (record->connected) {
383
+ record->voiceLaunched = true;
384
+ count = launchCommands(*record, handle, commands);
385
+ }
386
+ }
387
+ for (int index = 0; index < count; ++index) commands_.push(commands[index]);
388
+ }
389
+
390
+ void stop(std::uint64_t handle, double when)
391
+ {
392
+ ensureSetup();
393
+ Command command;
394
+ bool emit = false;
395
+ {
396
+ std::lock_guard<std::mutex> guard(recordsMutex_);
397
+ OscillatorRecord *record = find(handle);
398
+ if (record == nullptr || !record->started || record->stopped) return;
399
+ record->stopped = true;
400
+ const double now = currentTimeLocked();
401
+ double at = (std::isfinite(when) && when > 0.0) ? when : now;
402
+ if (at < record->startTime) at = record->startTime;
403
+ record->stopTime = at;
404
+ // stop() before connect() is legal too: the stop rides along with the
405
+ // launch commands when connect() finally makes the note audible.
406
+ emit = record->voiceLaunched;
407
+ command = Command{CommandKind::StopVoice, record->type, handle, record->frequency, frameFor(at)};
408
+ }
409
+ if (emit) commands_.push(command);
410
+ }
411
+
412
+ // ---- volume -------------------------------------------------------------
413
+ int volume() const { return volume_.load(std::memory_order_relaxed); }
414
+
415
+ void setVolume(int percent)
416
+ {
417
+ if (percent < 0) percent = 0;
418
+ if (percent > 100) percent = 100;
419
+ volume_.store(percent, std::memory_order_relaxed);
420
+ const float linear = static_cast<float>(percent) / 100.0f;
421
+ std::lock_guard<std::mutex> guard(graphMutex_);
422
+ @try {
423
+ if (filePlayer_ != nil) filePlayer_.volume = linear;
424
+ if (pcmPlayer_ != nil) pcmPlayer_.volume = linear;
425
+ } @catch (NSException *exception) {
426
+ (void)exception;
427
+ }
428
+ }
429
+
430
+ // ---- sample playback ----------------------------------------------------
431
+ bool playFile(const std::string &path)
432
+ {
433
+ ensureSetup();
434
+ if (path.empty()) return false;
435
+ NSURL *url = resolveURL(path);
436
+ if (url == nil) return false;
437
+
438
+ std::lock_guard<std::mutex> guard(graphMutex_);
439
+ if (engine_ == nil) return false;
440
+ @try {
441
+ NSError *error = nil;
442
+ AVAudioFile *file = [[AVAudioFile alloc] initForReading:url error:&error];
443
+ if (file == nil) return false;
444
+ if (filePlayer_ != nil) [filePlayer_ stop];
445
+ if (!ensurePlayerLocked(filePlayer_, filePlayerFormat_, file.processingFormat)) return false;
446
+ [filePlayer_ scheduleFile:file atTime:nil completionHandler:nil];
447
+ [filePlayer_ play];
448
+ return true;
449
+ } @catch (NSException *exception) {
450
+ (void)exception;
451
+ return false;
452
+ }
453
+ }
454
+
455
+ bool playPcm(const std::int16_t *samples, std::size_t sampleCount, int sampleRate, int channels)
456
+ {
457
+ ensureSetup();
458
+ if (samples == nullptr || sampleCount == 0) return false;
459
+ if (channels < 1) channels = 1;
460
+ if (channels > 2) channels = 2;
461
+ if (sampleRate <= 0) sampleRate = 16000;
462
+ const std::size_t frames = sampleCount / static_cast<std::size_t>(channels);
463
+ if (frames == 0) return false;
464
+
465
+ std::lock_guard<std::mutex> guard(graphMutex_);
466
+ if (engine_ == nil) return false;
467
+ @try {
468
+ AVAudioFormat *format = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32
469
+ sampleRate:static_cast<double>(sampleRate)
470
+ channels:static_cast<AVAudioChannelCount>(channels)
471
+ interleaved:NO];
472
+ if (format == nil) return false;
473
+ AVAudioPCMBuffer *buffer =
474
+ [[AVAudioPCMBuffer alloc] initWithPCMFormat:format
475
+ frameCapacity:static_cast<AVAudioFrameCount>(frames)];
476
+ if (buffer == nil) return false;
477
+ buffer.frameLength = static_cast<AVAudioFrameCount>(frames);
478
+ float *const *destination = buffer.floatChannelData;
479
+ if (destination == nullptr) return false;
480
+ for (std::size_t frame = 0; frame < frames; ++frame) {
481
+ for (int channel = 0; channel < channels; ++channel) {
482
+ const std::int16_t sample = samples[frame * static_cast<std::size_t>(channels) + channel];
483
+ destination[channel][frame] = static_cast<float>(sample) / 32768.0f;
484
+ }
485
+ }
486
+ if (pcmPlayer_ != nil) [pcmPlayer_ stop];
487
+ if (!ensurePlayerLocked(pcmPlayer_, pcmPlayerFormat_, format)) return false;
488
+ [pcmPlayer_ scheduleBuffer:buffer atTime:nil options:0 completionHandler:nil];
489
+ [pcmPlayer_ play];
490
+ return true;
491
+ } @catch (NSException *exception) {
492
+ (void)exception;
493
+ return false;
494
+ }
495
+ }
496
+
497
+ void stopPlayback()
498
+ {
499
+ std::lock_guard<std::mutex> guard(graphMutex_);
500
+ @try {
501
+ if (filePlayer_ != nil) [filePlayer_ stop];
502
+ if (pcmPlayer_ != nil) [pcmPlayer_ stop];
503
+ } @catch (NSException *exception) {
504
+ (void)exception;
505
+ }
506
+ }
507
+
508
+ // ---- offline rendering (see apple_audio_testing.h) -----------------------
509
+ bool beginOffline(double sampleRate)
510
+ {
511
+ std::lock_guard<std::mutex> guard(graphMutex_);
512
+ if (setupDone_) return false;
513
+ offlineRequested_ = true;
514
+ if (sampleRate > 0.0) offlineSampleRate_ = sampleRate;
515
+ return true;
516
+ }
517
+
518
+ std::size_t renderOffline(float *interleavedStereo, std::size_t frames)
519
+ {
520
+ ensureSetup();
521
+ std::lock_guard<std::mutex> guard(graphMutex_);
522
+ return renderOfflineLocked(interleavedStereo, frames);
523
+ }
524
+
525
+ double offlineSampleRate() const { return sampleRate_.load(std::memory_order_relaxed); }
526
+
527
+ void endOffline()
528
+ {
529
+ std::lock_guard<std::mutex> guard(graphMutex_);
530
+ if (engine_ == nil) return;
531
+ @try {
532
+ [engine_ stop];
533
+ if (engine_.isInManualRenderingMode) [engine_ disableManualRenderingMode];
534
+ } @catch (NSException *exception) {
535
+ (void)exception;
536
+ }
537
+ running_.store(false, std::memory_order_relaxed);
538
+ }
539
+
540
+ // ---- realtime render callback ------------------------------------------
541
+ // Realtime thread. No allocation, no lock, no Objective-C, no logging.
542
+ void renderAudio(AVAudioFrameCount frameCount, AudioBufferList *output, BOOL *isSilence)
543
+ {
544
+ Command command;
545
+ while (commands_.pop(command)) applyCommand(command);
546
+
547
+ const std::int64_t base =
548
+ frameOffset_.load(std::memory_order_relaxed) + renderedFrames_.load(std::memory_order_relaxed);
549
+ const double gain =
550
+ static_cast<double>(volume_.load(std::memory_order_relaxed)) / 100.0 * kVoiceGain;
551
+ const double attack = attackFrames_;
552
+ const double release = releaseFrames_;
553
+
554
+ bool sounded = false;
555
+ const std::uint32_t bufferCount = output != nullptr ? output->mNumberBuffers : 0;
556
+ float *channel0 = bufferCount > 0 ? static_cast<float *>(output->mBuffers[0].mData) : nullptr;
557
+ float *channel1 = bufferCount > 1 ? static_cast<float *>(output->mBuffers[1].mData) : nullptr;
558
+
559
+ for (AVAudioFrameCount index = 0; index < frameCount; ++index) {
560
+ const std::int64_t frame = base + static_cast<std::int64_t>(index);
561
+ double mixed = 0.0;
562
+ for (Voice &voice : voices_) {
563
+ if (!voice.active) continue;
564
+ if (frame >= voice.endFrame) {
565
+ voice.active = false;
566
+ continue;
567
+ }
568
+ if (frame < voice.startFrame) continue;
569
+
570
+ while (voice.eventCount > 0 && frame >= voice.events[voice.eventHead].frame) {
571
+ voice.phaseIncrement = voice.events[voice.eventHead].phaseIncrement;
572
+ voice.eventHead = (voice.eventHead + 1) % kMaxVoiceEvents;
573
+ --voice.eventCount;
574
+ }
575
+
576
+ double level = 1.0;
577
+ const double sinceStart = static_cast<double>(frame - voice.startFrame);
578
+ if (sinceStart < attack) level = sinceStart / attack;
579
+ if (voice.stopScheduled && frame >= voice.stopFrame) {
580
+ const double sinceStop = static_cast<double>(frame - voice.stopFrame);
581
+ const double decay = 1.0 - sinceStop / release;
582
+ level *= decay > 0.0 ? decay : 0.0;
583
+ }
584
+
585
+ mixed += waveform(voice.type, voice.phase) * level;
586
+ voice.phase += voice.phaseIncrement;
587
+ if (voice.phase >= 1.0) voice.phase -= 1.0;
588
+ sounded = true;
589
+ }
590
+
591
+ double sample = mixed * gain;
592
+ if (sample > 1.0) sample = 1.0;
593
+ if (sample < -1.0) sample = -1.0;
594
+ const float value = static_cast<float>(sample);
595
+ if (channel0 != nullptr) channel0[index] = value;
596
+ if (channel1 != nullptr) channel1[index] = value;
597
+ }
598
+
599
+ for (std::uint32_t buffer = 2; buffer < bufferCount; ++buffer) {
600
+ float *extra = static_cast<float *>(output->mBuffers[buffer].mData);
601
+ if (extra == nullptr) continue;
602
+ for (AVAudioFrameCount index = 0; index < frameCount; ++index) extra[index] = 0.0f;
603
+ }
604
+
605
+ renderedFrames_.fetch_add(static_cast<std::int64_t>(frameCount), std::memory_order_relaxed);
606
+ if (isSilence != nullptr) *isSilence = sounded ? NO : YES;
607
+ }
608
+
609
+ private:
610
+ Engine() = default;
611
+
612
+ // ---- records ------------------------------------------------------------
613
+ OscillatorRecord *find(std::uint64_t handle)
614
+ {
615
+ if (handle == 0) return nullptr;
616
+ const std::uint64_t slot = (handle & 0x3ffull);
617
+ if (slot == 0 || slot > static_cast<std::uint64_t>(kMaxOscillators)) return nullptr;
618
+ OscillatorRecord &record = records_[slot - 1];
619
+ return record.handle == handle ? &record : nullptr;
620
+ }
621
+
622
+ const OscillatorRecord *find(std::uint64_t handle) const
623
+ {
624
+ return const_cast<Engine *>(this)->find(handle);
625
+ }
626
+
627
+ // The StartVoice command plus any setValueAtTime events queued before launch.
628
+ int launchCommands(const OscillatorRecord &record, std::uint64_t handle, Command *out) const
629
+ {
630
+ double initial = record.frequency;
631
+ int count = 0;
632
+ // An event at or before the note's start time is just the starting value.
633
+ for (int index = 0; index < record.pendingCount; ++index) {
634
+ if (record.pending[index].time <= record.startTime) initial = record.pending[index].value;
635
+ }
636
+ out[count++] = Command{CommandKind::StartVoice, record.type, handle, normalizeFrequency(initial),
637
+ frameFor(record.startTime)};
638
+ for (int index = 0; index < record.pendingCount; ++index) {
639
+ const ScheduledValue &event = record.pending[index];
640
+ if (event.time <= record.startTime) continue;
641
+ out[count++] = Command{CommandKind::ScheduleFrequency, record.type, handle,
642
+ normalizeFrequency(event.value), frameFor(event.time)};
643
+ }
644
+ if (record.stopped) {
645
+ out[count++] = Command{CommandKind::StopVoice, record.type, handle,
646
+ normalizeFrequency(record.frequency), frameFor(record.stopTime)};
647
+ }
648
+ return count;
649
+ }
650
+
651
+ // currentTime() without ensureSetup() — callable while recordsMutex_ is held.
652
+ double currentTimeLocked() const
653
+ {
654
+ if (!running_.load(std::memory_order_relaxed)) return steadySeconds();
655
+ const double rate = sampleRate_.load(std::memory_order_relaxed);
656
+ const std::int64_t frames =
657
+ frameOffset_.load(std::memory_order_relaxed) + renderedFrames_.load(std::memory_order_relaxed);
658
+ return static_cast<double>(frames) / rate;
659
+ }
660
+
661
+ std::int64_t frameFor(double seconds) const
662
+ {
663
+ const double rate = sampleRate_.load(std::memory_order_relaxed);
664
+ const double frame = seconds * rate;
665
+ if (!std::isfinite(frame)) return 0;
666
+ if (frame > 9.0e15) return static_cast<std::int64_t>(9.0e15);
667
+ if (frame < 0.0) return 0;
668
+ return static_cast<std::int64_t>(std::llround(frame));
669
+ }
670
+
671
+ // ---- render-thread command application ----------------------------------
672
+ void applyCommand(const Command &command)
673
+ {
674
+ switch (command.kind) {
675
+ case CommandKind::StartVoice: {
676
+ Voice *voice = allocateVoice();
677
+ if (voice == nullptr) return;
678
+ const double rate = sampleRate_.load(std::memory_order_relaxed);
679
+ voice->active = true;
680
+ voice->id = command.voice;
681
+ voice->type = command.type;
682
+ voice->phase = 0.0;
683
+ voice->phaseIncrement = command.frequency / rate;
684
+ voice->startFrame = command.frame;
685
+ voice->stopFrame = 0;
686
+ voice->stopScheduled = false;
687
+ voice->endFrame = command.frame + static_cast<std::int64_t>(kMaxVoiceSeconds * rate);
688
+ voice->eventHead = 0;
689
+ voice->eventCount = 0;
690
+ return;
691
+ }
692
+ case CommandKind::StopVoice: {
693
+ Voice *voice = findVoice(command.voice);
694
+ if (voice == nullptr) return;
695
+ std::int64_t at = command.frame;
696
+ if (at < voice->startFrame) at = voice->startFrame;
697
+ voice->stopFrame = at;
698
+ voice->stopScheduled = true;
699
+ voice->endFrame = at + static_cast<std::int64_t>(releaseFrames_) + 1;
700
+ return;
701
+ }
702
+ case CommandKind::SetFrequency: {
703
+ Voice *voice = findVoice(command.voice);
704
+ if (voice == nullptr) return;
705
+ voice->phaseIncrement = command.frequency / sampleRate_.load(std::memory_order_relaxed);
706
+ return;
707
+ }
708
+ case CommandKind::SetType: {
709
+ Voice *voice = findVoice(command.voice);
710
+ if (voice == nullptr) return;
711
+ voice->type = command.type;
712
+ return;
713
+ }
714
+ case CommandKind::ScheduleFrequency: {
715
+ Voice *voice = findVoice(command.voice);
716
+ if (voice == nullptr || voice->eventCount >= kMaxVoiceEvents) return;
717
+ const int slot = (voice->eventHead + voice->eventCount) % kMaxVoiceEvents;
718
+ voice->events[slot] =
719
+ VoiceEvent{command.frame, command.frequency / sampleRate_.load(std::memory_order_relaxed)};
720
+ ++voice->eventCount;
721
+ return;
722
+ }
723
+ }
724
+ }
725
+
726
+ Voice *findVoice(std::uint64_t id)
727
+ {
728
+ for (Voice &voice : voices_)
729
+ if (voice.active && voice.id == id) return &voice;
730
+ return nullptr;
731
+ }
732
+
733
+ Voice *allocateVoice()
734
+ {
735
+ Voice *oldest = nullptr;
736
+ for (Voice &voice : voices_) {
737
+ if (!voice.active) return &voice;
738
+ if (oldest == nullptr || voice.endFrame < oldest->endFrame) oldest = &voice;
739
+ }
740
+ return oldest; // steal the note closest to finishing
741
+ }
742
+
743
+ // ---- engine setup -------------------------------------------------------
744
+ void ensureSetup()
745
+ {
746
+ if (setupDone_.load(std::memory_order_acquire)) return;
747
+ std::lock_guard<std::mutex> guard(graphMutex_);
748
+ if (setupDone_.load(std::memory_order_relaxed)) return;
749
+ setupLocked();
750
+ setupDone_.store(true, std::memory_order_release);
751
+ }
752
+
753
+ void setupLocked()
754
+ {
755
+ const bool offline = offlineRequested_ || envFlagSet("GEA_AUDIO_OFFLINE") ||
756
+ (std::getenv("GEA_AUDIO_CAPTURE_RAW") != nullptr);
757
+ const char *capturePath = std::getenv("GEA_AUDIO_CAPTURE_RAW");
758
+
759
+ // GEA_AUDIO_DISABLED=1 builds no graph at all. That is the same state the
760
+ // backend lands in when there is no output device, so it is also how the
761
+ // no-device path is exercised: everything stays callable, currentTime()
762
+ // runs off the wall clock and nothing sounds.
763
+ if (envFlagSet("GEA_AUDIO_DISABLED")) {
764
+ running_.store(false, std::memory_order_relaxed);
765
+ report(offline, sampleRate_.load(std::memory_order_relaxed), false, nil);
766
+ return;
767
+ }
768
+
769
+ @try {
770
+ #if TARGET_OS_IPHONE
771
+ if (!offline) activateAudioSession();
772
+ #endif
773
+ engine_ = [[AVAudioEngine alloc] init];
774
+ if (engine_ == nil) return;
775
+
776
+ double rate = kFallbackSampleRate;
777
+ if (offline) {
778
+ rate = capturePath != nullptr ? kCaptureSampleRate : offlineSampleRate_;
779
+ } else {
780
+ const double deviceRate = [[engine_ outputNode] outputFormatForBus:0].sampleRate;
781
+ if (deviceRate > 0.0) rate = deviceRate;
782
+ }
783
+ if (!(rate > 0.0)) rate = kFallbackSampleRate;
784
+ sampleRate_.store(rate, std::memory_order_relaxed);
785
+ attackFrames_ = std::max(1.0, kAttackSeconds * rate);
786
+ releaseFrames_ = std::max(1.0, kReleaseSeconds * rate);
787
+
788
+ format_ = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32
789
+ sampleRate:rate
790
+ channels:2
791
+ interleaved:NO];
792
+ if (format_ == nil) return;
793
+
794
+ Engine *self = this;
795
+ sourceNode_ = [[AVAudioSourceNode alloc]
796
+ initWithFormat:format_
797
+ renderBlock:^OSStatus(BOOL *isSilence, const AudioTimeStamp *timestamp,
798
+ AVAudioFrameCount frameCount, AudioBufferList *outputData) {
799
+ (void)timestamp;
800
+ self->renderAudio(frameCount, outputData, isSilence);
801
+ return noErr;
802
+ }];
803
+ if (sourceNode_ == nil) return;
804
+
805
+ [engine_ attachNode:sourceNode_];
806
+ [engine_ connect:sourceNode_ to:[engine_ mainMixerNode] format:format_];
807
+
808
+ if (offline) {
809
+ NSError *error = nil;
810
+ if (![engine_ enableManualRenderingMode:AVAudioEngineManualRenderingModeOffline
811
+ format:format_
812
+ maximumFrameCount:kOfflineMaxFrames
813
+ error:&error]) {
814
+ return;
815
+ }
816
+ manualBuffer_ = [[AVAudioPCMBuffer alloc] initWithPCMFormat:engine_.manualRenderingFormat
817
+ frameCapacity:kOfflineMaxFrames];
818
+ if (manualBuffer_ == nil) return;
819
+ }
820
+
821
+ const double rateNow = sampleRate_.load(std::memory_order_relaxed);
822
+ renderedFrames_.store(0, std::memory_order_relaxed);
823
+ frameOffset_.store(static_cast<std::int64_t>(std::llround(steadySeconds() * rateNow)),
824
+ std::memory_order_relaxed);
825
+
826
+ NSError *startError = nil;
827
+ if (![engine_ startAndReturnError:&startError]) {
828
+ running_.store(false, std::memory_order_relaxed);
829
+ // No output device (or the device refused): stay silent, keep the
830
+ // wall-clock timebase, and retry when the configuration changes.
831
+ report(offline, rateNow, false, startError);
832
+ observeConfigurationChanges();
833
+ return;
834
+ }
835
+ running_.store(true, std::memory_order_relaxed);
836
+ report(offline, rateNow, true, nil);
837
+ offlineMode_ = offline;
838
+ const float linear = static_cast<float>(volume_.load(std::memory_order_relaxed)) / 100.0f;
839
+ if (filePlayer_ != nil) filePlayer_.volume = linear;
840
+ if (pcmPlayer_ != nil) pcmPlayer_.volume = linear;
841
+ if (!offline) observeConfigurationChanges();
842
+ if (capturePath != nullptr) startCaptureLocked(capturePath);
843
+ } @catch (NSException *exception) {
844
+ (void)exception;
845
+ running_.store(false, std::memory_order_relaxed);
846
+ }
847
+ }
848
+
849
+ // GEA_AUDIO_DEBUG=1 prints one line at setup. Never called from the render
850
+ // thread, so stdio here is safe.
851
+ void report(bool offline, double rate, bool started, NSError *error) const
852
+ {
853
+ if (!envFlagSet("GEA_AUDIO_DEBUG")) return;
854
+ const char *reason = error != nil ? error.localizedDescription.UTF8String : "";
855
+ std::fprintf(stderr, "[gea-audio] mode=%s rate=%.0f started=%s%s%s\n",
856
+ offline ? "offline" : "device", rate, started ? "yes" : "no",
857
+ started ? "" : " error=", reason != nullptr ? reason : "");
858
+ std::fflush(stderr);
859
+ }
860
+
861
+ #if TARGET_OS_IPHONE
862
+ void activateAudioSession()
863
+ {
864
+ @try {
865
+ AVAudioSession *session = [AVAudioSession sharedInstance];
866
+ NSError *error = nil;
867
+ // Playback (not Ambient) so a game's sound is audible with the ring
868
+ // switch silenced; MixWithOthers so it does not stop the user's music.
869
+ [session setCategory:AVAudioSessionCategoryPlayback
870
+ withOptions:AVAudioSessionCategoryOptionMixWithOthers
871
+ error:&error];
872
+ [session setActive:YES error:&error];
873
+ } @catch (NSException *exception) {
874
+ (void)exception;
875
+ }
876
+ observeInterruptions();
877
+ }
878
+
879
+ // A call or Siri deactivates the session and stops the engine; nothing
880
+ // restarts it on its own. Reactivate and restart when the interruption ends.
881
+ void observeInterruptions()
882
+ {
883
+ if (interruptionObserver_ != nil) return;
884
+ Engine *self = this;
885
+ interruptionObserver_ = [[NSNotificationCenter defaultCenter]
886
+ addObserverForName:AVAudioSessionInterruptionNotification
887
+ object:[AVAudioSession sharedInstance]
888
+ queue:nil
889
+ usingBlock:^(NSNotification *note) {
890
+ NSNumber *type = note.userInfo[AVAudioSessionInterruptionTypeKey];
891
+ if (type == nil ||
892
+ type.unsignedIntegerValue != AVAudioSessionInterruptionTypeEnded) {
893
+ self->markStopped();
894
+ return;
895
+ }
896
+ self->activateAudioSession();
897
+ self->handleConfigurationChange();
898
+ }];
899
+ }
900
+ #endif
901
+
902
+ void markStopped() { running_.store(false, std::memory_order_relaxed); }
903
+
904
+ // A device disappearing (unplugged headphones, a display with speakers going
905
+ // away) stops the engine; AVAudioEngine posts a configuration change and the
906
+ // graph must be restarted. This also recovers a launch with no output device.
907
+ void observeConfigurationChanges()
908
+ {
909
+ if (configurationObserver_ != nil) return;
910
+ Engine *self = this;
911
+ configurationObserver_ = [[NSNotificationCenter defaultCenter]
912
+ addObserverForName:AVAudioEngineConfigurationChangeNotification
913
+ object:engine_
914
+ queue:nil
915
+ usingBlock:^(NSNotification *note) {
916
+ (void)note;
917
+ self->handleConfigurationChange();
918
+ }];
919
+ }
920
+
921
+ void handleConfigurationChange()
922
+ {
923
+ std::lock_guard<std::mutex> guard(graphMutex_);
924
+ if (engine_ == nil || offlineMode_) return;
925
+ @try {
926
+ if (engine_.isRunning) return;
927
+ const double rate = sampleRate_.load(std::memory_order_relaxed);
928
+ renderedFrames_.store(0, std::memory_order_relaxed);
929
+ frameOffset_.store(static_cast<std::int64_t>(std::llround(steadySeconds() * rate)),
930
+ std::memory_order_relaxed);
931
+ NSError *error = nil;
932
+ running_.store([engine_ startAndReturnError:&error] ? true : false, std::memory_order_relaxed);
933
+ } @catch (NSException *exception) {
934
+ (void)exception;
935
+ running_.store(false, std::memory_order_relaxed);
936
+ }
937
+ }
938
+
939
+ bool ensurePlayerLocked(AVAudioPlayerNode *__strong &player, AVAudioFormat *__strong &current,
940
+ AVAudioFormat *wanted)
941
+ {
942
+ if (engine_ == nil || wanted == nil) return false;
943
+ if (player == nil) {
944
+ player = [[AVAudioPlayerNode alloc] init];
945
+ if (player == nil) return false;
946
+ [engine_ attachNode:player];
947
+ player.volume = static_cast<float>(volume_.load(std::memory_order_relaxed)) / 100.0f;
948
+ }
949
+ if (current == nil || ![current isEqual:wanted]) {
950
+ [engine_ disconnectNodeOutput:player];
951
+ [engine_ connect:player to:[engine_ mainMixerNode] format:wanted];
952
+ current = wanted;
953
+ }
954
+ if (!engine_.isRunning) {
955
+ NSError *error = nil;
956
+ if (![engine_ startAndReturnError:&error]) return false;
957
+ running_.store(true, std::memory_order_relaxed);
958
+ }
959
+ return true;
960
+ }
961
+
962
+ NSURL *resolveURL(const std::string &path) const
963
+ {
964
+ NSString *text = [NSString stringWithUTF8String:path.c_str()];
965
+ if (text == nil || text.length == 0) return nil;
966
+ if ([text hasPrefix:@"file://"] || [text hasPrefix:@"http://"] || [text hasPrefix:@"https://"]) {
967
+ return [NSURL URLWithString:text];
968
+ }
969
+ if ([text hasPrefix:@"/"]) return [NSURL fileURLWithPath:text];
970
+
971
+ NSString *stripped = [text hasPrefix:@"./"] ? [text substringFromIndex:2] : text;
972
+ NSBundle *bundle = [NSBundle mainBundle];
973
+ if (bundle != nil) {
974
+ NSString *name = [stripped stringByDeletingPathExtension];
975
+ NSString *extension = [stripped pathExtension];
976
+ NSURL *resource = [bundle URLForResource:name withExtension:extension];
977
+ if (resource != nil) return resource;
978
+ NSString *inResources = [[bundle resourcePath] stringByAppendingPathComponent:stripped];
979
+ if (inResources != nil && [[NSFileManager defaultManager] fileExistsAtPath:inResources]) {
980
+ return [NSURL fileURLWithPath:inResources];
981
+ }
982
+ }
983
+ return [NSURL fileURLWithPath:stripped];
984
+ }
985
+
986
+ std::size_t renderOfflineLocked(float *interleavedStereo, std::size_t frames)
987
+ {
988
+ if (engine_ == nil || manualBuffer_ == nil || !engine_.isInManualRenderingMode) return 0;
989
+ std::size_t done = 0;
990
+ @try {
991
+ while (done < frames) {
992
+ const AVAudioFrameCount want = static_cast<AVAudioFrameCount>(
993
+ std::min<std::size_t>(frames - done, kOfflineMaxFrames));
994
+ NSError *error = nil;
995
+ const AVAudioEngineManualRenderingStatus status =
996
+ [engine_ renderOffline:want toBuffer:manualBuffer_ error:&error];
997
+ if (status != AVAudioEngineManualRenderingStatusSuccess) break;
998
+ const AVAudioFrameCount produced = manualBuffer_.frameLength;
999
+ if (produced == 0) break;
1000
+ float *const *channels = manualBuffer_.floatChannelData;
1001
+ const AVAudioChannelCount channelCount = manualBuffer_.format.channelCount;
1002
+ if (interleavedStereo != nullptr && channels != nullptr) {
1003
+ for (AVAudioFrameCount frame = 0; frame < produced; ++frame) {
1004
+ const float left = channels[0][frame];
1005
+ const float right = channelCount > 1 ? channels[1][frame] : left;
1006
+ interleavedStereo[(done + frame) * 2 + 0] = left;
1007
+ interleavedStereo[(done + frame) * 2 + 1] = right;
1008
+ }
1009
+ }
1010
+ done += produced;
1011
+ if (produced < want) break;
1012
+ }
1013
+ } @catch (NSException *exception) {
1014
+ (void)exception;
1015
+ }
1016
+ return done;
1017
+ }
1018
+
1019
+ // Real-time-paced offline capture: renders the graph exactly as the device
1020
+ // would consume it and appends raw interleaved float32 stereo to a file.
1021
+ void startCaptureLocked(const char *path)
1022
+ {
1023
+ captureFile_ = std::fopen(path, "wb");
1024
+ if (captureFile_ == nullptr) return;
1025
+ captureRunning_.store(true, std::memory_order_relaxed);
1026
+ Engine *self = this;
1027
+ std::thread([self] { self->captureLoop(); }).detach();
1028
+ }
1029
+
1030
+ void captureLoop()
1031
+ {
1032
+ std::vector<float> block(static_cast<std::size_t>(kCaptureBlockFrames) * 2, 0.0f);
1033
+ const double rate = sampleRate_.load(std::memory_order_relaxed);
1034
+ const double blockSeconds = static_cast<double>(kCaptureBlockFrames) / rate;
1035
+ auto deadline = std::chrono::steady_clock::now();
1036
+ while (captureRunning_.load(std::memory_order_relaxed)) {
1037
+ std::size_t produced = 0;
1038
+ {
1039
+ std::lock_guard<std::mutex> guard(graphMutex_);
1040
+ produced = renderOfflineLocked(block.data(), kCaptureBlockFrames);
1041
+ }
1042
+ if (produced > 0 && captureFile_ != nullptr) {
1043
+ std::fwrite(block.data(), sizeof(float), produced * 2, captureFile_);
1044
+ std::fflush(captureFile_);
1045
+ }
1046
+ deadline += std::chrono::duration_cast<std::chrono::steady_clock::duration>(
1047
+ std::chrono::duration<double>(blockSeconds));
1048
+ std::this_thread::sleep_until(deadline);
1049
+ }
1050
+ }
1051
+
1052
+ // ---- state --------------------------------------------------------------
1053
+ std::mutex graphMutex_;
1054
+ std::mutex recordsMutex_;
1055
+ CommandQueue commands_;
1056
+
1057
+ OscillatorRecord records_[kMaxOscillators]{};
1058
+ int nextSlot_ = 0;
1059
+ std::uint64_t generation_ = 0;
1060
+
1061
+ Voice voices_[kMaxVoices]{};
1062
+
1063
+ std::atomic<bool> setupDone_{false};
1064
+ std::atomic<bool> running_{false};
1065
+ std::atomic<double> sampleRate_{kFallbackSampleRate};
1066
+ std::atomic<std::int64_t> renderedFrames_{0};
1067
+ std::atomic<std::int64_t> frameOffset_{0};
1068
+ std::atomic<int> volume_{80};
1069
+ double attackFrames_ = kAttackSeconds * kFallbackSampleRate;
1070
+ double releaseFrames_ = kReleaseSeconds * kFallbackSampleRate;
1071
+
1072
+ bool offlineRequested_ = false;
1073
+ bool offlineMode_ = false;
1074
+ double offlineSampleRate_ = kFallbackSampleRate;
1075
+
1076
+ std::atomic<bool> captureRunning_{false};
1077
+ std::FILE *captureFile_ = nullptr;
1078
+
1079
+ AVAudioEngine *engine_ = nil;
1080
+ AVAudioSourceNode *sourceNode_ = nil;
1081
+ AVAudioFormat *format_ = nil;
1082
+ AVAudioPCMBuffer *manualBuffer_ = nil;
1083
+ AVAudioPlayerNode *filePlayer_ = nil;
1084
+ AVAudioFormat *filePlayerFormat_ = nil;
1085
+ AVAudioPlayerNode *pcmPlayer_ = nil;
1086
+ AVAudioFormat *pcmPlayerFormat_ = nil;
1087
+ id configurationObserver_ = nil;
1088
+ id interruptionObserver_ = nil;
1089
+ };
1090
+
1091
+ } // namespace
1092
+
1093
+ // ---------------------------------------------------------------------------
1094
+ // gea::platform::audio public API
1095
+ // ---------------------------------------------------------------------------
1096
+
1097
+ AudioParam::AudioParam(NativeAudioHandle oscillator) : oscillator_(oscillator) {}
1098
+
1099
+ double AudioParam::value() const
1100
+ {
1101
+ return Engine::instance().frequency(static_cast<std::uint64_t>(oscillator_));
1102
+ }
1103
+
1104
+ void AudioParam::setValue(double value)
1105
+ {
1106
+ Engine::instance().setFrequency(static_cast<std::uint64_t>(oscillator_), value);
1107
+ }
1108
+
1109
+ void AudioParam::setValueAtTime(double value, double start_time)
1110
+ {
1111
+ // The engine schedules a note's parameters at start; a change requested for a
1112
+ // future time on an already-running note is applied when that time arrives.
1113
+ Engine &engine = Engine::instance();
1114
+ const auto handle = static_cast<std::uint64_t>(oscillator_);
1115
+ if (!std::isfinite(start_time) || start_time <= engine.currentTime()) {
1116
+ engine.setFrequency(handle, value);
1117
+ return;
1118
+ }
1119
+ engine.scheduleFrequency(handle, value, start_time);
1120
+ }
1121
+
1122
+ AudioNode::AudioNode(NativeAudioHandle native) : native_(native) {}
1123
+
1124
+ NativeAudioHandle AudioNode::nativeId() const { return native_; }
1125
+
1126
+ AudioDestinationNode::AudioDestinationNode(NativeAudioHandle native) : AudioNode(native) {}
1127
+
1128
+ OscillatorNode::OscillatorNode(NativeAudioHandle native) : AudioNode(native), frequency(native) {}
1129
+
1130
+ OscillatorType OscillatorNode::type() const
1131
+ {
1132
+ return Engine::instance().type(static_cast<std::uint64_t>(nativeId()));
1133
+ }
1134
+
1135
+ void OscillatorNode::setType(OscillatorType type)
1136
+ {
1137
+ Engine::instance().setType(static_cast<std::uint64_t>(nativeId()), type);
1138
+ }
1139
+
1140
+ void OscillatorNode::connect(const AudioDestinationNode &)
1141
+ {
1142
+ Engine::instance().connect(static_cast<std::uint64_t>(nativeId()));
1143
+ }
1144
+
1145
+ void OscillatorNode::start(double when)
1146
+ {
1147
+ Engine::instance().start(static_cast<std::uint64_t>(nativeId()), when);
1148
+ }
1149
+
1150
+ void OscillatorNode::stop(double when)
1151
+ {
1152
+ Engine::instance().stop(static_cast<std::uint64_t>(nativeId()), when);
1153
+ }
1154
+
1155
+ double AudioContext::currentTime() const { return Engine::instance().currentTime(); }
1156
+
1157
+ AudioDestinationNode AudioContext::destination() const { return AudioDestinationNode(1); }
1158
+
1159
+ OscillatorNode AudioContext::createOscillator() const
1160
+ {
1161
+ return OscillatorNode(static_cast<NativeAudioHandle>(Engine::instance().createOscillator()));
1162
+ }
1163
+
1164
+ AudioContext AudioSystem::sharedContext() { return AudioContext{}; }
1165
+
1166
+ int AudioSystem::volume() { return Engine::instance().volume(); }
1167
+
1168
+ void AudioSystem::setVolume(int volume_percent) { Engine::instance().setVolume(volume_percent); }
1169
+
1170
+ bool AudioSystem::playFile(const std::string &path) { return Engine::instance().playFile(path); }
1171
+
1172
+ bool AudioSystem::playPcm(const std::int16_t *samples, std::size_t sample_count, int sample_rate, int channels)
1173
+ {
1174
+ return Engine::instance().playPcm(samples, sample_count, sample_rate, channels);
1175
+ }
1176
+
1177
+ void AudioSystem::stopPlayback() { Engine::instance().stopPlayback(); }
1178
+
1179
+ namespace testing {
1180
+
1181
+ bool beginOfflineRendering(double sample_rate) { return Engine::instance().beginOffline(sample_rate); }
1182
+
1183
+ std::size_t renderOfflineFrames(float *interleaved_stereo, std::size_t frames)
1184
+ {
1185
+ return Engine::instance().renderOffline(interleaved_stereo, frames);
1186
+ }
1187
+
1188
+ double offlineSampleRate() { return Engine::instance().offlineSampleRate(); }
1189
+
1190
+ void endOfflineRendering() { Engine::instance().endOffline(); }
1191
+
1192
+ } // namespace testing
1193
+
1194
+ } // namespace gea::platform::audio