@swiftbrowser/native 0.18.3 → 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.
- package/README.md +19 -0
- package/bin/swiftbrowser-native.mjs +47 -0
- package/dist/bridge.d.ts +34 -7
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/encoding.d.ts +13 -0
- package/dist/encoding.d.ts.map +1 -1
- package/dist/encoding.js +24 -0
- package/dist/encoding.js.map +1 -1
- package/dist/fetch.d.ts +10 -4
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +19 -11
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +22 -6
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +65 -13
- package/dist/hostRequests.js.map +1 -1
- package/dist/ios.d.ts +79 -0
- package/dist/ios.d.ts.map +1 -0
- package/dist/ios.js +181 -0
- package/dist/ios.js.map +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +16 -7
- package/dist/runtime.js.map +1 -1
- package/dist/subtle.d.ts +1 -1
- package/dist/subtle.d.ts.map +1 -1
- package/dist/subtle.js +9 -5
- package/dist/subtle.js.map +1 -1
- package/dist/webSocket.d.ts +12 -5
- package/dist/webSocket.d.ts.map +1 -1
- package/dist/webSocket.js +24 -10
- package/dist/webSocket.js.map +1 -1
- package/package.json +7 -3
- package/src/bridge.ts +30 -7
- package/src/encoding.ts +22 -0
- package/src/fetch.ts +18 -13
- package/src/hostRequests.ts +72 -19
- package/src/ios.ts +253 -0
- package/src/runtime.ts +23 -8
- package/src/subtle.ts +10 -6
- package/src/webSocket.ts +27 -12
- package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +203 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +523 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +188 -0
- package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
- package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +3 -3
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +106 -28
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +43 -13
- package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
- package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +81 -20
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +100 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +4 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +26 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
- 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
|