@swiftbrowser/native 0.18.2 → 0.19.0

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 (73) hide show
  1. package/README.md +19 -0
  2. package/bin/swiftbrowser-native.mjs +47 -0
  3. package/dist/bridge.d.ts +34 -7
  4. package/dist/bridge.d.ts.map +1 -1
  5. package/dist/bridge.js.map +1 -1
  6. package/dist/encoding.d.ts +13 -0
  7. package/dist/encoding.d.ts.map +1 -1
  8. package/dist/encoding.js +24 -0
  9. package/dist/encoding.js.map +1 -1
  10. package/dist/fetch.d.ts +10 -4
  11. package/dist/fetch.d.ts.map +1 -1
  12. package/dist/fetch.js +19 -11
  13. package/dist/fetch.js.map +1 -1
  14. package/dist/hostRequests.d.ts +22 -6
  15. package/dist/hostRequests.d.ts.map +1 -1
  16. package/dist/hostRequests.js +65 -13
  17. package/dist/hostRequests.js.map +1 -1
  18. package/dist/ios.d.ts +79 -0
  19. package/dist/ios.d.ts.map +1 -0
  20. package/dist/ios.js +181 -0
  21. package/dist/ios.js.map +1 -0
  22. package/dist/runtime.d.ts.map +1 -1
  23. package/dist/runtime.js +16 -7
  24. package/dist/runtime.js.map +1 -1
  25. package/dist/subtle.d.ts +1 -1
  26. package/dist/subtle.d.ts.map +1 -1
  27. package/dist/subtle.js +9 -5
  28. package/dist/subtle.js.map +1 -1
  29. package/dist/webSocket.d.ts +12 -5
  30. package/dist/webSocket.d.ts.map +1 -1
  31. package/dist/webSocket.js +24 -10
  32. package/dist/webSocket.js.map +1 -1
  33. package/package.json +7 -3
  34. package/src/bridge.ts +30 -7
  35. package/src/encoding.ts +22 -0
  36. package/src/fetch.ts +18 -13
  37. package/src/hostRequests.ts +72 -19
  38. package/src/ios.ts +253 -0
  39. package/src/runtime.ts +23 -8
  40. package/src/subtle.ts +10 -6
  41. package/src/webSocket.ts +27 -12
  42. package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
  43. package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
  44. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +203 -0
  45. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +523 -0
  46. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +188 -0
  47. package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
  48. package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
  49. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
  50. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
  51. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
  52. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
  53. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +3 -3
  54. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +106 -28
  55. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +43 -13
  56. package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
  57. package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
  58. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
  59. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
  60. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
  61. package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
  62. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
  63. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
  64. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
  65. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
  66. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +81 -20
  67. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
  68. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +100 -0
  69. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
  70. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +4 -0
  71. package/swift/Sources/SwiftBrowserProtocol/Props.swift +26 -0
  72. package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
  73. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -0,0 +1,921 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ #if !canImport(FoundationBridge)
5
+ import AVFoundation
6
+
7
+ /// The `audioEngine` host request (packages/native/src/hostRequests.ts): the
8
+ /// app's `AVAudioEngine` (@swiftbrowser/react, audioEngine.ts) answered by
9
+ /// `AVAudioEngine` itself. The commands and their answers are the page's
10
+ /// (Web/src/audioEngine.ts; docs/ops-protocol.md, "An audio engine"): the
11
+ /// app names every engine, node, file and buffer by a number, and each
12
+ /// command acts on them in the order sent. Commands are handled as they
13
+ /// arrive, not on a later turn of the main actor (`HostRequestBridge`
14
+ /// passes them straight here), so a note reaches the sampler at once and a
15
+ /// `connect` never overtakes its `attach`. What finishes later answers
16
+ /// later: `start` once the microphone is allowed, `openFile` once a remote
17
+ /// file is downloaded, a scheduled file or buffer once it has played.
18
+ ///
19
+ /// AVAudioEngine raises Objective-C exceptions (which end the app) for
20
+ /// misuse a page shrugs off, so each command checks first: a player plays
21
+ /// only connected and with its engine running, a buffer is converted to its
22
+ /// player's format, the input is connected only with a format it has.
23
+ ///
24
+ /// A sampler's `loadZones` loads the files with `loadAudioFiles(at:)`, so
25
+ /// the instrument is the sampler's own default one (velocity, the pedal,
26
+ /// the pitch wheel), then rewrites each zone's root and key range, and the
27
+ /// envelope's attack and release, in the unit's state (`fullState`, the
28
+ /// dictionary an `.aupreset` holds).
29
+ ///
30
+ /// A kind a package added (`AudioEngineNodes`: SwiftBrowserDSP's `program`)
31
+ /// is made by the package, which takes its properties and notes.
32
+ @MainActor
33
+ final class AudioEngineBridge {
34
+ typealias Answer = @MainActor @Sendable (Result<JSONValue, any Error>) -> Void
35
+
36
+ private final class Engine {
37
+ let engine = AVAudioEngine()
38
+ var nodes: [Int: AVAudioNode] = [:]
39
+ let mixer: Int
40
+ let output: Int
41
+ let input: Int
42
+ /// Players paused with the engine, to play again at `start`.
43
+ var paused: [AVAudioPlayerNode] = []
44
+ var configurationObserver: NSObjectProtocol?
45
+
46
+ init(mixer: Int, output: Int, input: Int) {
47
+ self.mixer = mixer
48
+ self.output = output
49
+ self.input = input
50
+ }
51
+ }
52
+
53
+ /// A tap's latest level, written on the audio thread and read on the main actor.
54
+ private final class Meter: @unchecked Sendable {
55
+ private let lock = NSLock()
56
+ private var value: Float = 0
57
+
58
+ var rms: Float {
59
+ get { lock.withLock { value } }
60
+ set { lock.withLock { value = newValue } }
61
+ }
62
+ }
63
+
64
+ private var engines: [Int: Engine] = [:]
65
+ /// Which engine each node is in.
66
+ private var owners: [Int: Int] = [:]
67
+ private var files: [Int: AVAudioFile] = [:]
68
+ private var buffers: [Int: AVAudioPCMBuffer] = [:]
69
+ private var meters: [Int: Meter] = [:]
70
+ /// Nodes of the kinds packages added, by id.
71
+ private var custom: [Int: any AudioEngineCustomNode] = [:]
72
+ /// Offline rendering in this format instead of the speakers (tests).
73
+ private let manualRendering: AVAudioFormat?
74
+
75
+ init(manualRendering: AVAudioFormat? = nil) {
76
+ self.manualRendering = manualRendering
77
+ }
78
+
79
+ /// Every engine stopped and let go (the app is going away).
80
+ func stop() {
81
+ for id in Array(engines.keys) { destroy(id) }
82
+ }
83
+
84
+ private static let noteActions: Set<String> = ["startNote", "stopNote", "controller", "pitchBend"]
85
+
86
+ /// Performs one command; `answer` is called once, now or when it finishes.
87
+ /// `bytes` are the typed arrays sent beside the command (a buffer's samples).
88
+ func perform(_ params: JSONObject, bytes: [HostBytes] = [], answer: @escaping Answer) {
89
+ do {
90
+ if let value = try handle(params, bytes: bytes, answer: answer) { answer(.success(value)) }
91
+ } catch {
92
+ answer(.failure(error))
93
+ }
94
+ }
95
+
96
+ /// The command's value, or nil when it answers later itself.
97
+ private func handle(_ params: JSONObject, bytes: [HostBytes], answer: @escaping Answer) throws -> JSONValue? {
98
+ let ok = JSONValue.object([:])
99
+ let action = params.string("action") ?? ""
100
+ // A note for a package's node goes to the package.
101
+ if Self.noteActions.contains(action), let id = params.int("node"), let made = custom[id] {
102
+ guard try made.note(action, params) else { throw HostRequestFailure("unsupported: node \(id) takes no notes") }
103
+ return ok
104
+ }
105
+ switch action {
106
+ case "create":
107
+ try create(params)
108
+ case "destroy":
109
+ destroy(params.int("engine") ?? -1)
110
+ case "attach":
111
+ try attach(params)
112
+ case "detach":
113
+ try detach(params)
114
+ case "connect":
115
+ try connect(params)
116
+ case "disconnectOutput":
117
+ let (engine, node) = try engineNode(params)
118
+ engine.engine.disconnectNodeOutput(node)
119
+ case "disconnectInput":
120
+ let (engine, node) = try engineNode(params)
121
+ engine.engine.disconnectNodeInput(node)
122
+ case "prepare":
123
+ try findEngine(params).engine.prepare()
124
+ case "start":
125
+ try start(findEngine(params), answer: answer)
126
+ return nil
127
+ case "pause":
128
+ pause(try findEngine(params))
129
+ case "stop":
130
+ let engine = try findEngine(params)
131
+ engine.paused = []
132
+ engine.engine.stop()
133
+ for node in engine.nodes.values { (node as? AVAudioPlayerNode)?.stop() }
134
+ for id in engine.nodes.keys { custom[id]?.halt() }
135
+ case "reset":
136
+ let engine = try findEngine(params)
137
+ engine.paused = []
138
+ for node in engine.nodes.values {
139
+ (node as? AVAudioPlayerNode)?.stop()
140
+ (node as? AVAudioUnitSampler)?.reset()
141
+ }
142
+ for id in engine.nodes.keys { custom[id]?.halt() }
143
+ engine.engine.reset()
144
+ case "set":
145
+ let id = params.int("node")
146
+ let node = try findNode(id)
147
+ for (key, value) in params.object("values") ?? [:] { try set(node, id: id, key, value) }
148
+ case "preset":
149
+ try preset(findNode(params.int("node")), params.string("preset") ?? "")
150
+ case "openFile":
151
+ try openFile(params, answer: answer)
152
+ return nil
153
+ case "readFile":
154
+ return try readFile(params)
155
+ case "writeBuffer":
156
+ try writeBuffer(params, bytes: bytes)
157
+ case "release":
158
+ let id = params.int("id") ?? -1
159
+ files[id] = nil
160
+ buffers[id] = nil
161
+ case "scheduleFile":
162
+ try scheduleFile(params, answer: answer)
163
+ return nil
164
+ case "scheduleBuffer":
165
+ try scheduleBuffer(params, answer: answer)
166
+ return nil
167
+ case "play":
168
+ try play(params)
169
+ case "pausePlayer":
170
+ try findPlayer(params.int("node")).pause()
171
+ case "stopPlayer":
172
+ try findPlayer(params.int("node")).stop()
173
+ case "playerTime":
174
+ let player = try findPlayer(params.int("node"))
175
+ guard let now = player.lastRenderTime, let time = player.playerTime(forNodeTime: now) else { return .null }
176
+ return .object(["sampleTime": .number(Double(time.sampleTime)), "sampleRate": .number(time.sampleRate)])
177
+ case "loadZones":
178
+ try loadZones(params)
179
+ case "loadInstrument":
180
+ try loadInstrument(params)
181
+ case "startNote":
182
+ let sampler = try findSampler(params.int("node"))
183
+ let velocity = midi(params.int("velocity"))
184
+ if velocity == 0 {
185
+ sampler.stopNote(midi(params.int("note")), onChannel: channel(params))
186
+ } else {
187
+ sampler.startNote(midi(params.int("note")), withVelocity: velocity, onChannel: channel(params))
188
+ }
189
+ case "stopNote":
190
+ try findSampler(params.int("node")).stopNote(midi(params.int("note")), onChannel: channel(params))
191
+ case "controller":
192
+ try findSampler(params.int("node")).sendController(midi(params.int("controller")), withValue: midi(params.int("value")), onChannel: channel(params))
193
+ case "pitchBend":
194
+ let value = UInt16(max(0, min(16383, params.int("value") ?? 8192)))
195
+ try findSampler(params.int("node")).sendPitchBend(value, onChannel: channel(params))
196
+ case "meter":
197
+ return .object(["level": .number(level(try findNode(params.int("node"))))])
198
+ default:
199
+ throw HostRequestFailure("unsupported: audioEngine \(params.string("action") ?? "without an action")")
200
+ }
201
+ return ok
202
+ }
203
+
204
+ // MARK: Engines and nodes
205
+
206
+ private func create(_ params: JSONObject) throws {
207
+ guard let id = params.int("engine"), let mixer = params.int("mixer"), let output = params.int("output"), let input = params.int("input") else {
208
+ throw HostRequestFailure("audioEngine: create expects an engine, mixer, output and input")
209
+ }
210
+ if engines[id] != nil { return }
211
+ let engine = Engine(mixer: mixer, output: output, input: input)
212
+ if let format = manualRendering {
213
+ try engine.engine.enableManualRenderingMode(.offline, format: format, maximumFrameCount: 4096)
214
+ }
215
+ engine.nodes[mixer] = engine.engine.mainMixerNode
216
+ engine.nodes[output] = engine.engine.outputNode
217
+ engines[id] = engine
218
+ for node in [mixer, output, input] { owners[node] = id }
219
+ // A route change (headphones in or out) stops the engine: it starts again if it was running.
220
+ engine.configurationObserver = NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange, object: engine.engine, queue: .main) { [weak self] _ in
221
+ MainActor.assumeIsolated {
222
+ guard let engine = self?.engines[id], !engine.engine.isRunning, self?.manualRendering == nil else { return }
223
+ try? engine.engine.start()
224
+ }
225
+ }
226
+ }
227
+
228
+ private func destroy(_ id: Int) {
229
+ guard let engine = engines.removeValue(forKey: id) else { return }
230
+ if let observer = engine.configurationObserver { NotificationCenter.default.removeObserver(observer) }
231
+ engine.engine.stop()
232
+ for (nodeID, node) in engine.nodes {
233
+ if meters.removeValue(forKey: nodeID) != nil { node.removeTap(onBus: 0) }
234
+ owners[nodeID] = nil
235
+ custom.removeValue(forKey: nodeID)?.halt()
236
+ }
237
+ for node in [engine.mixer, engine.output, engine.input] { owners[node] = nil }
238
+ engine.nodes = [:]
239
+ }
240
+
241
+ private func findEngine(_ params: JSONObject) throws -> Engine {
242
+ guard let id = params.int("engine"), let engine = engines[id] else { throw HostRequestFailure("notFound: no engine \(params.int("engine") ?? -1)") }
243
+ return engine
244
+ }
245
+
246
+ /// The node a command names, with its engine; the input node is made the first time it is named.
247
+ private func engineNode(_ params: JSONObject) throws -> (Engine, AVAudioNode) {
248
+ let engine = try findEngine(params)
249
+ return (engine, try findNode(params.int("node"), in: engine))
250
+ }
251
+
252
+ private func findNode(_ id: Int?, in given: Engine? = nil) throws -> AVAudioNode {
253
+ guard let id, let engine = given ?? owners[id].flatMap({ engines[$0] }) else { throw HostRequestFailure("notFound: no node \(id ?? -1) is attached") }
254
+ if let node = engine.nodes[id] { return node }
255
+ if id == engine.input {
256
+ let input = engine.engine.inputNode
257
+ engine.nodes[id] = input
258
+ return input
259
+ }
260
+ throw HostRequestFailure("notFound: no node \(id) is attached")
261
+ }
262
+
263
+ private func findPlayer(_ id: Int?) throws -> AVAudioPlayerNode {
264
+ guard let player = try findNode(id) as? AVAudioPlayerNode else { throw HostRequestFailure("unsupported: node \(id ?? -1) is not a player") }
265
+ return player
266
+ }
267
+
268
+ private func findSampler(_ id: Int?) throws -> AVAudioUnitSampler {
269
+ guard let sampler = try findNode(id) as? AVAudioUnitSampler else { throw HostRequestFailure("unsupported: node \(id ?? -1) is not a sampler") }
270
+ return sampler
271
+ }
272
+
273
+ private func attach(_ params: JSONObject) throws {
274
+ let engine = try findEngine(params)
275
+ guard let id = params.int("node") else { throw HostRequestFailure("audioEngine: attach expects a node") }
276
+ if engine.nodes[id] != nil || owners[id] != nil { throw HostRequestFailure("exists: node \(id) is attached already") }
277
+ let node: AVAudioNode
278
+ switch params.string("kind") ?? "" {
279
+ case "player": node = AVAudioPlayerNode()
280
+ case "sampler": node = AVAudioUnitSampler()
281
+ case "mixer": node = AVAudioMixerNode()
282
+ case "reverb":
283
+ let reverb = AVAudioUnitReverb()
284
+ reverb.loadFactoryPreset(.mediumHall)
285
+ node = reverb
286
+ case "delay": node = AVAudioUnitDelay()
287
+ case "distortion": node = AVAudioUnitDistortion()
288
+ case "eq": node = AVAudioUnitEQ(numberOfBands: max(1, min(32, params.int("bands") ?? 1)))
289
+ case "timePitch": node = AVAudioUnitTimePitch()
290
+ case let kind:
291
+ let context = AudioEngineNodeContext(format: manualRendering ?? playerFormat(engine))
292
+ guard let made = try AudioEngineNodes.make(kind, context) else {
293
+ throw HostRequestFailure("unsupported: no node kind \(kind)\(kind == "program" ? " (the app installs SwiftBrowserDSP for it)" : "")")
294
+ }
295
+ node = made.node
296
+ custom[id] = made
297
+ }
298
+ engine.engine.attach(node)
299
+ engine.nodes[id] = node
300
+ owners[id] = params.int("engine")
301
+ do {
302
+ for (key, value) in params.object("values") ?? [:] {
303
+ if key == "preset" { try preset(node, value.string ?? "") } else { try set(node, id: id, key, value) }
304
+ }
305
+ } catch {
306
+ // A package's node that cannot take its values (a program that does not load) is not left attached.
307
+ if let made = custom.removeValue(forKey: id) {
308
+ made.halt()
309
+ engine.engine.detach(node)
310
+ engine.nodes[id] = nil
311
+ owners[id] = nil
312
+ }
313
+ throw error
314
+ }
315
+ }
316
+
317
+ private func detach(_ params: JSONObject) throws {
318
+ let engine = try findEngine(params)
319
+ guard let id = params.int("node"), let node = engine.nodes[id], id != engine.mixer, id != engine.output, id != engine.input else { return }
320
+ if meters.removeValue(forKey: id) != nil { node.removeTap(onBus: 0) }
321
+ (node as? AVAudioPlayerNode)?.stop()
322
+ custom.removeValue(forKey: id)?.halt()
323
+ engine.engine.detach(node)
324
+ engine.nodes[id] = nil
325
+ owners[id] = nil
326
+ }
327
+
328
+ /// The format a player sends in: stereo at the engine's rate, so a buffer is converted to it once.
329
+ private func playerFormat(_ engine: Engine) -> AVAudioFormat {
330
+ let rate = engine.engine.outputNode.outputFormat(forBus: 0).sampleRate
331
+ return AVAudioFormat(standardFormatWithSampleRate: rate > 0 ? rate : 48_000, channels: 2)!
332
+ }
333
+
334
+ /// Connects `from` to `to` (several: a fan-out), replacing where it went
335
+ /// before; a destination that is not a mixer takes one input, so what
336
+ /// was connected into it is let go first.
337
+ private func connect(_ params: JSONObject) throws {
338
+ let engine = try findEngine(params)
339
+ let from = try findNode(params.int("from"), in: engine)
340
+ let targets = try (params.array("to") ?? []).map { try findNode($0.int, in: engine) }
341
+ guard !targets.isEmpty else { throw HostRequestFailure("audioEngine: connect expects a destination") }
342
+ if targets.contains(where: { $0 is AVAudioPlayerNode || $0 is AVAudioUnitSampler || $0 is AVAudioInputNode || $0 is AVAudioSourceNode }) {
343
+ throw HostRequestFailure("unsupported: a player, a sampler, a source or the input takes no input")
344
+ }
345
+ if from is AVAudioOutputNode || targets.contains(where: { $0 === from }) {
346
+ throw HostRequestFailure("unsupported: the output sends nowhere, and a node cannot feed itself")
347
+ }
348
+ var format: AVAudioFormat?
349
+ if let input = from as? AVAudioInputNode {
350
+ let inputFormat = input.outputFormat(forBus: 0)
351
+ guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else { throw HostRequestFailure("unavailable: this device has no microphone input") }
352
+ format = inputFormat
353
+ } else if from is AVAudioPlayerNode {
354
+ format = playerFormat(engine)
355
+ }
356
+ engine.engine.disconnectNodeOutput(from)
357
+ var points: [AVAudioConnectionPoint] = []
358
+ for target in targets {
359
+ if let mixer = target as? AVAudioMixerNode {
360
+ points.append(AVAudioConnectionPoint(node: mixer, bus: mixer.nextAvailableInputBus))
361
+ } else {
362
+ engine.engine.disconnectNodeInput(target)
363
+ points.append(AVAudioConnectionPoint(node: target, bus: 0))
364
+ }
365
+ }
366
+ engine.engine.connect(from, to: points, fromBus: 0, format: format)
367
+ }
368
+
369
+ private func start(_ engine: Engine, answer: @escaping Answer) throws {
370
+ if engine.engine.isRunning {
371
+ answer(.success(.object([:])))
372
+ return
373
+ }
374
+ let usesInput = engine.nodes[engine.input].map { !engine.engine.outputConnectionPoints(for: $0, outputBus: 0).isEmpty } ?? false
375
+ guard usesInput, manualRendering == nil else {
376
+ try run(engine, recording: false)
377
+ answer(.success(.object([:])))
378
+ return
379
+ }
380
+ // Asking for the microphone without the usage string in the app's
381
+ // Info.plist ends the app (iOS's privacy check): an error instead.
382
+ guard Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") != nil else {
383
+ throw HostRequestFailure("permission: the app's Info.plist has no NSMicrophoneUsageDescription")
384
+ }
385
+ Task { @MainActor in
386
+ #if os(iOS)
387
+ guard await AVAudioApplication.requestRecordPermission() else {
388
+ answer(.failure(HostRequestFailure("permission: the microphone was not allowed")))
389
+ return
390
+ }
391
+ #endif
392
+ do {
393
+ try self.run(engine, recording: true)
394
+ answer(.success(.object([:])))
395
+ } catch {
396
+ answer(.failure(error))
397
+ }
398
+ }
399
+ }
400
+
401
+ /// Sets up the session (iOS) and starts the engine; the players it paused play on.
402
+ private func run(_ engine: Engine, recording: Bool) throws {
403
+ #if os(iOS)
404
+ if manualRendering == nil {
405
+ let session = AVAudioSession.sharedInstance()
406
+ // Music plays with the ringer switch off, as a music app's does; recording plays through the speaker.
407
+ if recording {
408
+ try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP])
409
+ } else if session.category != .playAndRecord {
410
+ try session.setCategory(.playback, mode: .default)
411
+ }
412
+ try session.setActive(true)
413
+ }
414
+ #endif
415
+ engine.engine.prepare()
416
+ do {
417
+ try engine.engine.start()
418
+ } catch {
419
+ throw HostRequestFailure("unavailable: the engine did not start (\(error.localizedDescription))")
420
+ }
421
+ for player in engine.paused { player.play() }
422
+ engine.paused = []
423
+ }
424
+
425
+ private func pause(_ engine: Engine) {
426
+ guard engine.engine.isRunning else { return }
427
+ engine.paused = engine.nodes.values.compactMap { $0 as? AVAudioPlayerNode }.filter(\.isPlaying)
428
+ for player in engine.paused { player.pause() }
429
+ engine.engine.pause()
430
+ }
431
+
432
+ // MARK: Properties
433
+
434
+ private func set(_ node: AVAudioNode, id: Int?, _ key: String, _ value: JSONValue) throws {
435
+ let number = Float(value.double ?? 0)
436
+ let flag = value.bool ?? false
437
+ if key == "volume" || key == "pan", let mixing = node as? AVAudioMixing {
438
+ if key == "volume" { mixing.volume = max(0, number) } else { mixing.pan = max(-1, min(1, number)) }
439
+ return
440
+ }
441
+ if let made = id.flatMap({ custom[$0] }) {
442
+ guard try made.set(key, value) else { throw HostRequestFailure("unsupported: this node has no \(key)") }
443
+ return
444
+ }
445
+ if key == "bypass" {
446
+ if let effect = node as? AVAudioUnitEffect { effect.bypass = flag }
447
+ if let effect = node as? AVAudioUnitTimeEffect { effect.bypass = flag }
448
+ return
449
+ }
450
+ switch node {
451
+ case let mixer as AVAudioMixerNode where key == "outputVolume":
452
+ mixer.outputVolume = max(0, number)
453
+ case let sampler as AVAudioUnitSampler:
454
+ switch key {
455
+ case "overallGain": sampler.overallGain = max(-90, min(12, number))
456
+ case "globalTuning": sampler.globalTuning = max(-2400, min(2400, number))
457
+ case "stereoPan": sampler.stereoPan = max(-100, min(100, number))
458
+ default: break
459
+ }
460
+ case let reverb as AVAudioUnitReverb where key == "wetDryMix":
461
+ reverb.wetDryMix = max(0, min(100, number))
462
+ case let delay as AVAudioUnitDelay:
463
+ switch key {
464
+ case "delayTime": delay.delayTime = TimeInterval(max(0, min(2, number)))
465
+ case "feedback": delay.feedback = max(-100, min(100, number))
466
+ case "lowPassCutoff": delay.lowPassCutoff = max(10, number)
467
+ case "wetDryMix": delay.wetDryMix = max(0, min(100, number))
468
+ default: break
469
+ }
470
+ case let distortion as AVAudioUnitDistortion:
471
+ switch key {
472
+ case "preGain": distortion.preGain = max(-80, min(20, number))
473
+ case "wetDryMix": distortion.wetDryMix = max(0, min(100, number))
474
+ default: break
475
+ }
476
+ case let eq as AVAudioUnitEQ:
477
+ try setEQ(eq, key, value)
478
+ case let timePitch as AVAudioUnitTimePitch:
479
+ switch key {
480
+ case "pitch": timePitch.pitch = max(-2400, min(2400, number))
481
+ case "rate": timePitch.rate = max(1.0 / 32, min(32, number))
482
+ case "overlap": timePitch.overlap = max(3, min(32, number))
483
+ default: break
484
+ }
485
+ default:
486
+ break
487
+ }
488
+ }
489
+
490
+ private static let filterTypes: [String: AVAudioUnitEQFilterType] = [
491
+ "parametric": .parametric, "lowPass": .lowPass, "highPass": .highPass, "resonantLowPass": .resonantLowPass,
492
+ "resonantHighPass": .resonantHighPass, "bandPass": .bandPass, "bandStop": .bandStop, "lowShelf": .lowShelf,
493
+ "highShelf": .highShelf, "resonantLowShelf": .resonantLowShelf, "resonantHighShelf": .resonantHighShelf,
494
+ ]
495
+
496
+ private func setEQ(_ eq: AVAudioUnitEQ, _ key: String, _ value: JSONValue) throws {
497
+ if key == "globalGain" {
498
+ eq.globalGain = max(-96, min(24, Float(value.double ?? 0)))
499
+ return
500
+ }
501
+ let parts = key.split(separator: ".")
502
+ guard parts.count == 3, parts[0] == "bands", let index = Int(parts[1]), eq.bands.indices.contains(index) else { return }
503
+ let band = eq.bands[index]
504
+ let number = Float(value.double ?? 0)
505
+ switch parts[2] {
506
+ case "filterType": if let type = Self.filterTypes[value.string ?? ""] { band.filterType = type }
507
+ case "frequency": band.frequency = max(10, number)
508
+ case "bandwidth": band.bandwidth = max(0.05, min(5, number))
509
+ case "gain": band.gain = max(-96, min(24, number))
510
+ case "bypass": band.bypass = value.bool ?? true
511
+ default: break
512
+ }
513
+ }
514
+
515
+ private static let reverbPresets: [String: AVAudioUnitReverbPreset] = [
516
+ "smallRoom": .smallRoom, "mediumRoom": .mediumRoom, "largeRoom": .largeRoom, "mediumHall": .mediumHall,
517
+ "largeHall": .largeHall, "plate": .plate, "mediumChamber": .mediumChamber, "largeChamber": .largeChamber,
518
+ "cathedral": .cathedral, "largeRoom2": .largeRoom2, "mediumHall2": .mediumHall2, "mediumHall3": .mediumHall3,
519
+ "largeHall2": .largeHall2,
520
+ ]
521
+
522
+ private static let distortionPresets: [String: AVAudioUnitDistortionPreset] = [
523
+ "drumsBitBrush": .drumsBitBrush, "drumsBufferBeats": .drumsBufferBeats, "drumsLoFi": .drumsLoFi,
524
+ "multiBrokenSpeaker": .multiBrokenSpeaker, "multiCellphoneConcert": .multiCellphoneConcert,
525
+ "multiDecimated1": .multiDecimated1, "multiDecimated2": .multiDecimated2, "multiDecimated3": .multiDecimated3,
526
+ "multiDecimated4": .multiDecimated4, "multiDistortedFunk": .multiDistortedFunk, "multiDistortedCubed": .multiDistortedCubed,
527
+ "multiDistortedSquared": .multiDistortedSquared, "multiEcho1": .multiEcho1, "multiEcho2": .multiEcho2,
528
+ "multiEchoTight1": .multiEchoTight1, "multiEchoTight2": .multiEchoTight2, "multiEverythingIsBroken": .multiEverythingIsBroken,
529
+ "speechAlienChatter": .speechAlienChatter, "speechCosmicInterference": .speechCosmicInterference,
530
+ "speechGoldenPi": .speechGoldenPi, "speechRadioTower": .speechRadioTower, "speechWaves": .speechWaves,
531
+ ]
532
+
533
+ private func preset(_ node: AVAudioNode, _ name: String) throws {
534
+ if let reverb = node as? AVAudioUnitReverb, let preset = Self.reverbPresets[name] {
535
+ reverb.loadFactoryPreset(preset)
536
+ } else if let distortion = node as? AVAudioUnitDistortion, let preset = Self.distortionPresets[name] {
537
+ distortion.loadFactoryPreset(preset)
538
+ } else {
539
+ throw HostRequestFailure("unsupported: no preset \(name) for this node")
540
+ }
541
+ }
542
+
543
+ // MARK: Files and buffers
544
+
545
+ /// Opens a file to read: the app's own (`file:`), a page's (`https:`, downloaded first) or a `data:` URL (written to a temporary file).
546
+ private func openFile(_ params: JSONObject, answer: @escaping Answer) throws {
547
+ guard let id = params.int("file"), let string = params.string("url"), let url = URL(string: string) else {
548
+ throw HostRequestFailure("audioEngine: openFile expects a file and a url")
549
+ }
550
+ let opened: @MainActor (URL) throws -> JSONValue = { [weak self] local in
551
+ let file: AVAudioFile
552
+ do {
553
+ file = try AVAudioFile(forReading: local)
554
+ } catch {
555
+ throw HostRequestFailure("decode: \(string.prefix(80)) is not audio the app can read (\(error.localizedDescription))")
556
+ }
557
+ self?.files[id] = file
558
+ return .object([
559
+ "length": .number(Double(file.length)),
560
+ "sampleRate": .number(file.processingFormat.sampleRate),
561
+ "channelCount": .number(Double(file.processingFormat.channelCount)),
562
+ ])
563
+ }
564
+ switch url.scheme?.lowercased() {
565
+ case "file":
566
+ let local = AppFiles.current(url)
567
+ guard FileManager.default.fileExists(atPath: local.path) else { throw HostRequestFailure("notFound: no file at \(local.path)") }
568
+ answer(.success(try opened(local)))
569
+ case "data":
570
+ answer(.success(try opened(try Self.temporaryFile(dataURL: string))))
571
+ case "http", "https":
572
+ Task { @MainActor in
573
+ do {
574
+ let (download, response) = try await URLSession.shared.download(from: url)
575
+ let ext = url.pathExtension.isEmpty ? Self.fileExtension(mime: response.mimeType ?? "") : url.pathExtension
576
+ let local = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).\(ext)")
577
+ try FileManager.default.moveItem(at: download, to: local)
578
+ answer(.success(try opened(local)))
579
+ } catch let failure as HostRequestFailure {
580
+ answer(.failure(failure))
581
+ } catch {
582
+ answer(.failure(HostRequestFailure("notFound: \(string) could not be downloaded (\(error.localizedDescription))")))
583
+ }
584
+ }
585
+ default:
586
+ throw HostRequestFailure("unsupported: the app cannot open \(url.scheme ?? "this") URLs as audio")
587
+ }
588
+ }
589
+
590
+ private static func fileExtension(mime: String) -> String {
591
+ switch mime.lowercased() {
592
+ case "audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave": return "wav"
593
+ case "audio/mpeg", "audio/mp3": return "mp3"
594
+ case "audio/aiff", "audio/x-aiff": return "aif"
595
+ case "audio/flac", "audio/x-flac": return "flac"
596
+ case "audio/caf", "audio/x-caf": return "caf"
597
+ default: return "m4a"
598
+ }
599
+ }
600
+
601
+ /// A `data:` URL's bytes in a temporary file named for its type (AVAudioFile reads the type from the name).
602
+ static func temporaryFile(dataURL: String) throws -> URL {
603
+ guard let comma = dataURL.firstIndex(of: ",") else { throw HostRequestFailure("decode: a data URL without data") }
604
+ let header = dataURL[dataURL.index(dataURL.startIndex, offsetBy: 5)..<comma]
605
+ let mime = String(header.split(separator: ";").first ?? "")
606
+ let payload = String(dataURL[dataURL.index(after: comma)...])
607
+ let data: Data?
608
+ if header.hasSuffix(";base64") {
609
+ data = Data(base64Encoded: payload)
610
+ } else {
611
+ data = payload.removingPercentEncoding.map { Data($0.utf8) }
612
+ }
613
+ guard let data else { throw HostRequestFailure("decode: the data URL's data could not be read") }
614
+ let file = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).\(fileExtension(mime: mime))")
615
+ try data.write(to: file)
616
+ return file
617
+ }
618
+
619
+ private func readFile(_ params: JSONObject) throws -> JSONValue {
620
+ guard let fileID = params.int("file"), let file = files[fileID] else { throw HostRequestFailure("notFound: no file \(params.int("file") ?? -1) is open") }
621
+ guard let bufferID = params.int("buffer") else { throw HostRequestFailure("audioEngine: readFile expects a buffer") }
622
+ let start = max(0, min(file.length, AVAudioFramePosition(params.int("start") ?? 0)))
623
+ let frames = AVAudioFrameCount(max(0, min(Int64(params.int("frames") ?? 0), file.length - start)))
624
+ guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: max(1, frames)) else {
625
+ throw HostRequestFailure("unavailable: no buffer of \(frames) frames")
626
+ }
627
+ file.framePosition = start
628
+ if frames > 0 { try file.read(into: buffer, frameCount: frames) }
629
+ buffers[bufferID] = buffer
630
+ return .object([
631
+ "frameLength": .number(Double(buffer.frameLength)),
632
+ "sampleRate": .number(buffer.format.sampleRate),
633
+ "channelCount": .number(Double(buffer.format.channelCount)),
634
+ ])
635
+ }
636
+
637
+ /// The app's own samples (float32, base64, a channel each) as a buffer.
638
+ /// Each channel's samples: a `Float32Array` sent beside the command (`{ "$bytes": n }`), or base64 of float32 little-endian.
639
+ private func writeBuffer(_ params: JSONObject, bytes: [HostBytes]) throws {
640
+ guard let id = params.int("buffer") else { throw HostRequestFailure("audioEngine: writeBuffer expects a buffer") }
641
+ let channels = try (params.array("channels") ?? []).map { value -> [Float] in
642
+ guard let data = value.bytes(from: bytes) else { throw HostRequestFailure("audioEngine: a writeBuffer channel is neither samples nor base64") }
643
+ return Self.floats(data)
644
+ }
645
+ let rate = max(3000, min(768_000, params.double("sampleRate") ?? 48_000))
646
+ let frames = AVAudioFrameCount(max(1, channels.map(\.count).max() ?? 1))
647
+ guard !channels.isEmpty,
648
+ let format = AVAudioFormat(standardFormatWithSampleRate: rate, channels: AVAudioChannelCount(channels.count)),
649
+ let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames),
650
+ let data = buffer.floatChannelData else {
651
+ throw HostRequestFailure("unavailable: no buffer for those samples")
652
+ }
653
+ buffer.frameLength = frames
654
+ for (channel, samples) in channels.enumerated() {
655
+ let target = data[channel]
656
+ for i in 0..<Int(frames) { target[i] = i < samples.count ? samples[i] : 0 }
657
+ }
658
+ buffers[id] = buffer
659
+ }
660
+
661
+ /// Float32 samples from their bytes, whatever the data's alignment.
662
+ private static func floats(_ data: Data) -> [Float] {
663
+ let count = data.count / MemoryLayout<Float>.size
664
+ return [Float](unsafeUninitializedCapacity: count) { buffer, initialized in
665
+ initialized = data.copyBytes(to: UnsafeMutableRawBufferPointer(buffer)) / MemoryLayout<Float>.size
666
+ }
667
+ }
668
+
669
+ /// `buffer` in `format`, converted (sample rate, channels) where it is not.
670
+ private func converted(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> AVAudioPCMBuffer {
671
+ if buffer.format.sampleRate == format.sampleRate, buffer.format.channelCount == format.channelCount, buffer.format.commonFormat == format.commonFormat, buffer.format.isInterleaved == format.isInterleaved {
672
+ return buffer
673
+ }
674
+ guard let converter = AVAudioConverter(from: buffer.format, to: format) else { throw HostRequestFailure("unsupported: the buffer's format cannot be converted") }
675
+ // A mono sample plays in both ears, not the left one only.
676
+ if buffer.format.channelCount == 1, format.channelCount == 2 { converter.channelMap = [0, 0] }
677
+ let capacity = AVAudioFrameCount(ceil(Double(buffer.frameLength) * format.sampleRate / buffer.format.sampleRate)) + 64
678
+ guard let output = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: max(1, capacity)) else { throw HostRequestFailure("unavailable: no buffer to convert into") }
679
+ if let error = Self.convert(buffer, with: converter, into: output) {
680
+ throw HostRequestFailure("decode: the buffer could not be converted (\(error.localizedDescription))")
681
+ }
682
+ return output
683
+ }
684
+
685
+ /// Runs the converter over the whole buffer at once (its input block hands the buffer over once).
686
+ private nonisolated static func convert(_ buffer: AVAudioPCMBuffer, with converter: AVAudioConverter, into output: AVAudioPCMBuffer) -> NSError? {
687
+ /// The input, handed over once; the block runs synchronously inside `convert`.
688
+ final class Feed: @unchecked Sendable {
689
+ var buffer: AVAudioPCMBuffer?
690
+ init(_ buffer: AVAudioPCMBuffer) { self.buffer = buffer }
691
+ }
692
+ let feed = Feed(buffer)
693
+ var error: NSError?
694
+ converter.convert(to: output, error: &error) { _, status in
695
+ guard let next = feed.buffer else {
696
+ status.pointee = .endOfStream
697
+ return nil
698
+ }
699
+ feed.buffer = nil
700
+ status.pointee = .haveData
701
+ return next
702
+ }
703
+ return error
704
+ }
705
+
706
+ // MARK: Players
707
+
708
+ private func time(_ spec: JSONObject?) -> AVAudioTime? {
709
+ guard let spec else { return nil }
710
+ if let sampleTime = spec.double("sampleTime"), let rate = spec.double("sampleRate"), rate > 0 {
711
+ return AVAudioTime(sampleTime: AVAudioFramePosition(sampleTime), atRate: rate)
712
+ }
713
+ if let delay = spec.double("delay") {
714
+ return AVAudioTime(hostTime: mach_absolute_time() + AVAudioTime.hostTime(forSeconds: max(0, delay)))
715
+ }
716
+ return nil
717
+ }
718
+
719
+ /// The player a schedule names, connected (a player sends nothing unconnected, and AVAudioEngine traps on its buffers).
720
+ private func connectedPlayer(_ params: JSONObject) throws -> (AVAudioPlayerNode, Engine) {
721
+ let player = try findPlayer(params.int("node"))
722
+ guard let engine = owners[params.int("node") ?? -1].flatMap({ engines[$0] }) else { throw HostRequestFailure("notFound: no engine for the player") }
723
+ guard !engine.engine.outputConnectionPoints(for: player, outputBus: 0).isEmpty else {
724
+ throw HostRequestFailure("notConnected: connect the player before scheduling or playing")
725
+ }
726
+ return (player, engine)
727
+ }
728
+
729
+ /// When a scheduled buffer counts as played: once heard, or rendering
730
+ /// offline (where nothing is heard), once rendered.
731
+ private var playedBack: AVAudioPlayerNodeCompletionCallbackType {
732
+ manualRendering == nil ? .dataPlayedBack : .dataRendered
733
+ }
734
+
735
+ /// A completion handler (called on the audio thread) answering on the main actor.
736
+ private nonisolated static func played(_ answer: @escaping Answer) -> AVAudioPlayerNodeCompletionHandler {
737
+ { _ in Task { @MainActor in answer(.success(.object([:]))) } }
738
+ }
739
+
740
+ private func scheduleFile(_ params: JSONObject, answer: @escaping Answer) throws {
741
+ let (player, engine) = try connectedPlayer(params)
742
+ guard let file = files[params.int("file") ?? -1] else { throw HostRequestFailure("notFound: no file \(params.int("file") ?? -1) is open") }
743
+ let start = max(0, min(file.length, AVAudioFramePosition(params.int("start") ?? 0)))
744
+ let frames = AVAudioFrameCount(max(0, min(Int64(params.int("frames") ?? Int(file.length - start)), file.length - start)))
745
+ let at = time(params.object("at"))
746
+ let format = playerFormat(engine)
747
+ guard frames > 0 else {
748
+ answer(.success(.object([:])))
749
+ return
750
+ }
751
+ if file.processingFormat.sampleRate == format.sampleRate, file.processingFormat.channelCount == format.channelCount {
752
+ player.scheduleSegment(file, startingFrame: start, frameCount: frames, at: at, completionCallbackType: playedBack, completionHandler: Self.played(answer))
753
+ return
754
+ }
755
+ // A file in another format is read and converted, then played as a buffer.
756
+ guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frames) else { throw HostRequestFailure("unavailable: no buffer of \(frames) frames") }
757
+ file.framePosition = start
758
+ try file.read(into: buffer, frameCount: frames)
759
+ player.scheduleBuffer(try converted(buffer, to: format), at: at, options: [], completionCallbackType: playedBack, completionHandler: Self.played(answer))
760
+ }
761
+
762
+ private func scheduleBuffer(_ params: JSONObject, answer: @escaping Answer) throws {
763
+ let (player, engine) = try connectedPlayer(params)
764
+ guard let buffer = buffers[params.int("buffer") ?? -1] else { throw HostRequestFailure("notFound: no buffer \(params.int("buffer") ?? -1)") }
765
+ var options: AVAudioPlayerNodeBufferOptions = []
766
+ if params.bool("loops") == true { options.insert(.loops) }
767
+ if params.bool("interrupts") == true { options.insert(.interrupts) }
768
+ if params.bool("interruptsAtLoop") == true { options.insert(.interruptsAtLoop) }
769
+ let playable = try converted(buffer, to: playerFormat(engine))
770
+ player.scheduleBuffer(playable, at: time(params.object("at")), options: options, completionCallbackType: playedBack, completionHandler: Self.played(answer))
771
+ }
772
+
773
+ private func play(_ params: JSONObject) throws {
774
+ let (player, engine) = try connectedPlayer(params)
775
+ guard engine.engine.isRunning else { throw HostRequestFailure("stopped: a player plays only while its engine runs") }
776
+ player.play(at: time(params.object("at")))
777
+ }
778
+
779
+ // MARK: Samplers
780
+
781
+ private func loadZones(_ params: JSONObject) throws {
782
+ let sampler = try findSampler(params.int("node"))
783
+ let zones = try (params.array("zones") ?? []).map { value -> (file: AVAudioFile, root: Int, low: Int, high: Int) in
784
+ let zone = value.object ?? [:]
785
+ guard let file = files[zone.int("file") ?? -1] else { throw HostRequestFailure("notFound: no file \(zone.int("file") ?? -1) is open") }
786
+ return (file, zone.int("root") ?? 60, zone.int("low") ?? 0, zone.int("high") ?? 127)
787
+ }
788
+ guard !zones.isEmpty else { throw HostRequestFailure("audioEngine: loadZones expects zones") }
789
+ do {
790
+ try sampler.loadAudioFiles(at: zones.map(\.file.url))
791
+ } catch {
792
+ throw HostRequestFailure("decode: the sampler could not load the files (\(error.localizedDescription))")
793
+ }
794
+ let placed = zones.map { (path: $0.file.url.resolvingSymlinksInPath().path, root: $0.root, low: $0.low, high: $0.high) }
795
+ Self.place(sampler, zones: placed, attack: params.double("attack") ?? 0.002, release: params.double("release") ?? 0.15)
796
+ }
797
+
798
+ /// Rewrites the loaded instrument's zones (root, key range) and its envelope (attack, release) in the unit's state.
799
+ static func place(_ sampler: AVAudioUnitSampler, zones: [(path: String, root: Int, low: Int, high: Int)], attack: Double, release: Double) {
800
+ guard var state = sampler.auAudioUnit.fullState,
801
+ var instrument = state["Instrument"] as? [String: Any],
802
+ var layers = instrument["Layers"] as? [[String: Any]], !layers.isEmpty else { return }
803
+ let references = state["file-references"] as? [String: Any] ?? [:]
804
+ var layer = layers[0]
805
+ if var placedZones = layer["Zones"] as? [[String: Any]] {
806
+ for (index, zone) in placedZones.enumerated() {
807
+ guard let waveform = (zone["waveform"] as? NSNumber)?.intValue,
808
+ let path = references["Sample:\(waveform)"] as? String,
809
+ let match = zones.first(where: { URL(fileURLWithPath: path).resolvingSymlinksInPath().path == $0.path || URL(fileURLWithPath: path).lastPathComponent == URL(fileURLWithPath: $0.path).lastPathComponent }) else { continue }
810
+ var updated = zone
811
+ updated["root key"] = match.root
812
+ updated["min key"] = match.low
813
+ updated["max key"] = match.high
814
+ placedZones[index] = updated
815
+ }
816
+ layer["Zones"] = placedZones
817
+ }
818
+ if var envelopes = layer["Envelopes"] as? [[String: Any]], var envelope = envelopes.first, var stages = envelope["Stages"] as? [[String: Any]] {
819
+ for (index, stage) in stages.enumerated() {
820
+ var updated = stage
821
+ switch (stage["stage"] as? NSNumber)?.intValue {
822
+ case 1: updated["time"] = max(0, attack)
823
+ case 5: updated["time"] = max(0, release)
824
+ default: continue
825
+ }
826
+ stages[index] = updated
827
+ }
828
+ envelope["Stages"] = stages
829
+ envelopes[0] = envelope
830
+ layer["Envelopes"] = envelopes
831
+ }
832
+ layers[0] = layer
833
+ instrument["Layers"] = layers
834
+ state["Instrument"] = instrument
835
+ sampler.auAudioUnit.fullState = state
836
+ }
837
+
838
+ private func loadInstrument(_ params: JSONObject) throws {
839
+ let sampler = try findSampler(params.int("node"))
840
+ guard let string = params.string("url"), let url = URL(string: string), url.isFileURL else {
841
+ throw HostRequestFailure("unsupported: a sound bank or preset loads from the app's files")
842
+ }
843
+ let local = AppFiles.current(url)
844
+ do {
845
+ if let program = params.int("program") {
846
+ try sampler.loadSoundBankInstrument(at: local, program: midi(program), bankMSB: midi(params.int("bankMSB") ?? 0x79), bankLSB: midi(params.int("bankLSB") ?? 0))
847
+ } else {
848
+ try sampler.loadInstrument(at: local)
849
+ }
850
+ } catch {
851
+ throw HostRequestFailure("decode: the sampler could not load \(local.lastPathComponent) (\(error.localizedDescription))")
852
+ }
853
+ }
854
+
855
+ private func midi(_ value: Int?) -> UInt8 {
856
+ UInt8(max(0, min(127, value ?? 0)))
857
+ }
858
+
859
+ private func channel(_ params: JSONObject) -> UInt8 {
860
+ UInt8(max(0, min(15, params.int("channel") ?? 0)))
861
+ }
862
+
863
+ // MARK: Meters
864
+
865
+ /// A node's output level on the meters' scale (0 at -50 dB, 1 at 0 dB), from a tap made the first time it is asked.
866
+ private func level(_ node: AVAudioNode) -> Double {
867
+ guard let id = nodeID(node) else { return 0 }
868
+ let meter: Meter
869
+ if let existing = meters[id] {
870
+ meter = existing
871
+ } else {
872
+ meter = Meter()
873
+ meters[id] = meter
874
+ node.installTap(onBus: 0, bufferSize: 1024, format: nil, block: Self.tap(meter))
875
+ }
876
+ return Self.meterLevel(Double(meter.rms))
877
+ }
878
+
879
+ /// The tap's block, made outside the main actor: it runs on the audio thread.
880
+ private nonisolated static func tap(_ meter: Meter) -> AVAudioNodeTapBlock {
881
+ { buffer, _ in
882
+ guard let data = buffer.floatChannelData, buffer.frameLength > 0 else { return }
883
+ var sum: Float = 0
884
+ let samples = data[0]
885
+ for i in 0..<Int(buffer.frameLength) { sum += samples[i] * samples[i] }
886
+ meter.rms = (sum / Float(buffer.frameLength)).squareRoot()
887
+ }
888
+ }
889
+
890
+ private func nodeID(_ node: AVAudioNode) -> Int? {
891
+ for engine in engines.values {
892
+ if let id = engine.nodes.first(where: { $0.value === node })?.key { return id }
893
+ }
894
+ return nil
895
+ }
896
+
897
+ static func meterLevel(_ rms: Double) -> Double {
898
+ guard rms > 0 else { return 0 }
899
+ return max(0, min(1, (20 * log10(rms) + 50) / 50))
900
+ }
901
+
902
+ // MARK: Tests
903
+
904
+ /// Renders `frames` offline (an engine made in manual rendering mode) and answers its first channel.
905
+ func render(engine id: Int, frames: Int) throws -> [Float] {
906
+ guard let engine = engines[id], let format = manualRendering else { throw HostRequestFailure("unsupported: not rendering offline") }
907
+ var samples: [Float] = []
908
+ var left = frames
909
+ while left > 0 {
910
+ let count = AVAudioFrameCount(min(4096, left))
911
+ guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: count) else { break }
912
+ let status = try engine.engine.renderOffline(count, to: buffer)
913
+ guard status == .success, let data = buffer.floatChannelData else { break }
914
+ samples.append(contentsOf: UnsafeBufferPointer(start: data[0], count: Int(buffer.frameLength)))
915
+ left -= Int(buffer.frameLength)
916
+ }
917
+ return samples
918
+ }
919
+ }
920
+ #endif
921
+ #endif