@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
package/src/webSocket.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { SimpleBlob } from './blob';
|
|
13
13
|
import type { NativeBridge } from './bridge';
|
|
14
|
-
import {
|
|
14
|
+
import { base64Encode, bridgedBytes } from './encoding';
|
|
15
15
|
|
|
16
16
|
export interface WebSocketEventLike {
|
|
17
17
|
type: string;
|
|
@@ -31,7 +31,10 @@ export interface NativeWebSocketEvent {
|
|
|
31
31
|
protocol?: string;
|
|
32
32
|
text?: string;
|
|
33
33
|
/** Base64. */
|
|
34
|
-
|
|
34
|
+
/** Base64, or `{ $bytes: 0 }` naming the `Uint8Array` after the event's JSON. */
|
|
35
|
+
binary?: string | { $bytes: number };
|
|
36
|
+
/** The views that came after the JSON. */
|
|
37
|
+
bytes?: ArrayBufferView[];
|
|
35
38
|
message?: string;
|
|
36
39
|
code?: number;
|
|
37
40
|
reason?: string;
|
|
@@ -43,6 +46,8 @@ export type WebSocketBridge = Required<Pick<NativeBridge, 'wsOpen' | 'wsSend' |
|
|
|
43
46
|
const sockets = new Map<number, NativeWebSocket>();
|
|
44
47
|
let nextId = 1;
|
|
45
48
|
let bridge: WebSocketBridge | null = null;
|
|
49
|
+
/** Whether binary crosses as views beside the JSON (a host that carries bytes) rather than base64. */
|
|
50
|
+
let carriesBytes = false;
|
|
46
51
|
|
|
47
52
|
export class NativeWebSocket {
|
|
48
53
|
static readonly CONNECTING = 0;
|
|
@@ -75,7 +80,9 @@ export class NativeWebSocket {
|
|
|
75
80
|
this.url = href;
|
|
76
81
|
this.id = nextId++;
|
|
77
82
|
sockets.set(this.id, this);
|
|
78
|
-
|
|
83
|
+
const list = typeof protocols === 'string' ? [protocols] : [...protocols];
|
|
84
|
+
if (carriesBytes) bridge.wsOpen(this.id, href, list, true);
|
|
85
|
+
else bridge.wsOpen(this.id, href, list);
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
send(data: string | ArrayBuffer | ArrayBufferView | SimpleBlob): void {
|
|
@@ -85,12 +92,19 @@ export class NativeWebSocket {
|
|
|
85
92
|
bridge?.wsSend(this.id, JSON.stringify({ text: data }));
|
|
86
93
|
return;
|
|
87
94
|
}
|
|
88
|
-
const bytes =
|
|
89
|
-
|
|
95
|
+
const bytes =
|
|
96
|
+
data instanceof SimpleBlob
|
|
97
|
+
? data.bytes()
|
|
98
|
+
: data instanceof ArrayBuffer
|
|
99
|
+
? new Uint8Array(data)
|
|
100
|
+
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
101
|
+
if (carriesBytes) bridge?.wsSend(this.id, JSON.stringify({ binary: { $bytes: 0 } }), bytes);
|
|
102
|
+
else bridge?.wsSend(this.id, JSON.stringify({ binary: base64Encode(bytes) }));
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
close(code?: number, reason?: string): void {
|
|
93
|
-
if (code !== undefined && code !== 1000 && !(code >= 3000 && code <= 4999))
|
|
106
|
+
if (code !== undefined && code !== 1000 && !(code >= 3000 && code <= 4999))
|
|
107
|
+
throw new DOMExceptionLike('InvalidAccessError', 'WebSocket: the close code must be 1000 or in 3000–4999');
|
|
94
108
|
if (this.readyState === NativeWebSocket.CLOSING || this.readyState === NativeWebSocket.CLOSED) return;
|
|
95
109
|
this.readyState = NativeWebSocket.CLOSING;
|
|
96
110
|
bridge?.wsClose(this.id, code ?? 1000, reason ?? '');
|
|
@@ -129,7 +143,7 @@ export class NativeWebSocket {
|
|
|
129
143
|
if (this.readyState !== NativeWebSocket.OPEN) return;
|
|
130
144
|
let data: unknown = event.text ?? '';
|
|
131
145
|
if (event.binary !== undefined) {
|
|
132
|
-
const bytes =
|
|
146
|
+
const bytes = bridgedBytes(typeof event.binary === 'string' ? event.binary : event.bytes?.[event.binary.$bytes]);
|
|
133
147
|
data = this.binaryType === 'arraybuffer' ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : new SimpleBlob([bytes]);
|
|
134
148
|
}
|
|
135
149
|
this.dispatchEvent({ type: 'message', target: this, data });
|
|
@@ -147,8 +161,8 @@ export class NativeWebSocket {
|
|
|
147
161
|
}
|
|
148
162
|
}
|
|
149
163
|
|
|
150
|
-
/** The bridge's `__sbWebSocketEvent(id, json)
|
|
151
|
-
static receive(id: number, json: string | NativeWebSocketEvent): void {
|
|
164
|
+
/** The bridge's `__sbWebSocketEvent(id, json, ...bytes)`: a binary message's bytes after the JSON that names them. */
|
|
165
|
+
static receive(id: number, json: string | NativeWebSocketEvent, ...bytes: ArrayBufferView[]): void {
|
|
152
166
|
const socket = sockets.get(id);
|
|
153
167
|
if (!socket) return;
|
|
154
168
|
let event: NativeWebSocketEvent;
|
|
@@ -157,12 +171,13 @@ export class NativeWebSocket {
|
|
|
157
171
|
} catch {
|
|
158
172
|
return;
|
|
159
173
|
}
|
|
160
|
-
socket.receive(event);
|
|
174
|
+
socket.receive(bytes.length ? { ...event, bytes } : event);
|
|
161
175
|
}
|
|
162
176
|
|
|
163
|
-
/** Points the sockets at the bridge (the runtime, once per install). */
|
|
164
|
-
static install(native: WebSocketBridge): void {
|
|
177
|
+
/** Points the sockets at the bridge (the runtime, once per install); `bytes`: the host carries typed arrays. */
|
|
178
|
+
static install(native: WebSocketBridge, options: { bytes?: boolean } = {}): void {
|
|
165
179
|
bridge = native;
|
|
180
|
+
carriesBytes = options.bytes === true;
|
|
166
181
|
}
|
|
167
182
|
}
|
|
168
183
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// swift-tools-version: 6.0
|
|
2
|
+
import PackageDescription
|
|
3
|
+
|
|
4
|
+
// SwiftBrowserDSP: the native side of @swiftbrowser/dsp. A program written
|
|
5
|
+
// in the DSP language and compiled to bytecode by the package's TypeScript
|
|
6
|
+
// compiler runs here, on the audio thread, in an `AVAudioSourceNode` of the
|
|
7
|
+
// app's `AVAudioEngine`; JavaScript attaches it as a `DSPNode` and plays
|
|
8
|
+
// notes on it. A package of its own, so only an app that synthesizes sound
|
|
9
|
+
// links it; it installs itself as the `program` node kind
|
|
10
|
+
// (`SwiftBrowserDSP.install()`, SwiftBrowserNative's AudioEngineNodes.swift).
|
|
11
|
+
//
|
|
12
|
+
// SwiftBrowserDSPCore is the machine alone (the bytecode, its checks, the
|
|
13
|
+
// interpreter), with no Apple framework, so it builds and tests on Linux too.
|
|
14
|
+
let package = Package(
|
|
15
|
+
name: "SwiftBrowserDSP",
|
|
16
|
+
platforms: [.iOS(.v17), .macOS(.v14)],
|
|
17
|
+
products: [
|
|
18
|
+
.library(name: "SwiftBrowserDSP", targets: ["SwiftBrowserDSP"]),
|
|
19
|
+
.library(name: "SwiftBrowserDSPCore", targets: ["SwiftBrowserDSPCore"]),
|
|
20
|
+
],
|
|
21
|
+
dependencies: [
|
|
22
|
+
.package(path: "../.."),
|
|
23
|
+
],
|
|
24
|
+
targets: [
|
|
25
|
+
.target(name: "SwiftBrowserDSPCore"),
|
|
26
|
+
.target(
|
|
27
|
+
name: "SwiftBrowserDSP",
|
|
28
|
+
dependencies: ["SwiftBrowserDSPCore", .product(name: "SwiftBrowserNative", package: "swift")]
|
|
29
|
+
),
|
|
30
|
+
]
|
|
31
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# SwiftBrowserDSP
|
|
2
|
+
|
|
3
|
+
The native side of [@swiftbrowser/dsp](../../../packages/dsp): a program in
|
|
4
|
+
the DSP language, compiled to bytecode by the package's TypeScript compiler,
|
|
5
|
+
rendered on the audio thread by `DSPMachine` in an `AVAudioSourceNode` of
|
|
6
|
+
the app's `AVAudioEngine`. The JavaScript attaches it as a `DSPNode` and
|
|
7
|
+
plays notes on it.
|
|
8
|
+
|
|
9
|
+
It is a package of its own, as SwiftBrowserLocalModel is, so only an app
|
|
10
|
+
that synthesizes sound links it. @swiftbrowser/native ships it beside the
|
|
11
|
+
core (`node_modules/@swiftbrowser/native/swift/Packages/SwiftBrowserDSP`; it
|
|
12
|
+
depends on `SwiftBrowserNative` at `../..`, here and there). Link it into the
|
|
13
|
+
app's Xcode project with `npx swiftbrowser native add dsp` (or `native init
|
|
14
|
+
--with dsp`) and install it at launch:
|
|
15
|
+
|
|
16
|
+
```swift
|
|
17
|
+
import SwiftBrowserDSP
|
|
18
|
+
|
|
19
|
+
init() {
|
|
20
|
+
SwiftBrowserDSP.install()
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
That registers the `program` node kind with SwiftBrowserNative's audio
|
|
25
|
+
engine (`AudioEngineNodes`). Without it, attaching a `DSPNode` fails
|
|
26
|
+
`unsupported: no node kind program (the app installs SwiftBrowserDSP for it)`.
|
|
27
|
+
|
|
28
|
+
- **SwiftBrowserDSPCore** is the machine alone: the program's checks
|
|
29
|
+
(`DSPProgram`, every register, operand and instance in range), and the
|
|
30
|
+
interpreter, 16 lanes a register, a lane per voice, in blocks of 128
|
|
31
|
+
frames. No Apple framework, so it builds and tests on Linux.
|
|
32
|
+
- **SwiftBrowserDSP** is the node: the program, parameters and notes the
|
|
33
|
+
app sends are handed to the audio thread through a lock the audio thread
|
|
34
|
+
only ever tries (`withLockIfAvailable`), applied at its next render; a
|
|
35
|
+
replaced machine is released on the main thread. Nothing on the audio
|
|
36
|
+
thread allocates, locks or calls Objective-C.
|
|
37
|
+
|
|
38
|
+
The tests (`swift test`) render @swiftbrowser/dsp's parity fixtures and
|
|
39
|
+
compare them with the JavaScript machine's samples, check the program
|
|
40
|
+
validation, and on macOS play programs through a real `AVAudioEngine`
|
|
41
|
+
rendering offline. `swift test -c release -Xswiftc -enable-testing --filter
|
|
42
|
+
sixteenVoices` prints the machine's speed.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#if canImport(AVFoundation) && canImport(SwiftUI)
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import os
|
|
4
|
+
@_exported import SwiftBrowserDSPCore
|
|
5
|
+
import SwiftBrowserNative
|
|
6
|
+
import SwiftBrowserProtocol
|
|
7
|
+
|
|
8
|
+
/// The native side of @swiftbrowser/dsp: installs the `program` node kind
|
|
9
|
+
/// for the app's `AVAudioEngine`, so a `DSPNode` the JavaScript attaches is
|
|
10
|
+
/// rendered here, by `DSPMachine`, on the audio thread.
|
|
11
|
+
///
|
|
12
|
+
/// ```swift
|
|
13
|
+
/// import SwiftBrowserDSP
|
|
14
|
+
///
|
|
15
|
+
/// init() {
|
|
16
|
+
/// SwiftBrowserDSP.install()
|
|
17
|
+
/// }
|
|
18
|
+
/// ```
|
|
19
|
+
@MainActor
|
|
20
|
+
public enum SwiftBrowserDSP {
|
|
21
|
+
/// Registers the `program` kind with SwiftBrowserNative's audio engine; call at launch, before the bundle loads.
|
|
22
|
+
public static func install() {
|
|
23
|
+
AudioEngineNodes.register("program") { context in ProgramNode(format: context.format) }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// A program as a node: an `AVAudioSourceNode` whose render block runs the
|
|
28
|
+
/// machine. What the app sends (a program, its parameters, notes) is handed
|
|
29
|
+
/// to the audio thread through `Renderer` without a lock the audio thread
|
|
30
|
+
/// waits on, and applies at its next render.
|
|
31
|
+
@MainActor
|
|
32
|
+
final class ProgramNode: AudioEngineCustomNode {
|
|
33
|
+
let node: AVAudioNode
|
|
34
|
+
private let renderer: Renderer
|
|
35
|
+
private let sampleRate: Double
|
|
36
|
+
/// The program the audio thread runs (or is about to), for parameter names.
|
|
37
|
+
private var program: DSPProgram?
|
|
38
|
+
/// Every parameter the app set, by name: a program loaded later takes the values it has names for.
|
|
39
|
+
private var values: [String: Float] = [:]
|
|
40
|
+
|
|
41
|
+
init(format: AVAudioFormat) {
|
|
42
|
+
sampleRate = format.sampleRate
|
|
43
|
+
renderer = Renderer()
|
|
44
|
+
node = Self.source(renderer, format: format)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Made off the main actor: the render block runs on the audio thread.
|
|
48
|
+
nonisolated private static func source(_ renderer: Renderer, format: AVAudioFormat) -> AVAudioSourceNode {
|
|
49
|
+
AVAudioSourceNode(format: format) { silence, _, frames, list in
|
|
50
|
+
let buffers = UnsafeMutableAudioBufferListPointer(list)
|
|
51
|
+
guard let first = buffers.first?.mData?.assumingMemoryBound(to: Float.self) else { return noErr }
|
|
52
|
+
let count = Int(frames)
|
|
53
|
+
if !renderer.render(into: first, frames: count) {
|
|
54
|
+
silence.pointee = true
|
|
55
|
+
for buffer in buffers { buffer.mData?.initializeMemory(as: Float.self, repeating: 0, count: count) }
|
|
56
|
+
return noErr
|
|
57
|
+
}
|
|
58
|
+
for buffer in buffers.dropFirst() {
|
|
59
|
+
buffer.mData?.assumingMemoryBound(to: Float.self).update(from: first, count: count)
|
|
60
|
+
}
|
|
61
|
+
return noErr
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func set(_ key: String, _ value: JSONValue) throws -> Bool {
|
|
66
|
+
defer { renderer.collect() }
|
|
67
|
+
if key == "program" {
|
|
68
|
+
guard let json = value.string else { throw DSPProgramError("a program crosses as JSON") }
|
|
69
|
+
let program = try DSPProgram(json: json)
|
|
70
|
+
// Made and given its parameters here, then handed over whole.
|
|
71
|
+
let machine = DSPMachine(program: program, sampleRate: sampleRate)
|
|
72
|
+
for (name, value) in values {
|
|
73
|
+
if let index = machine.parameterIndex(name) { machine.setParameter(at: index, value) }
|
|
74
|
+
}
|
|
75
|
+
self.program = program
|
|
76
|
+
renderer.replace(machine)
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
if key.hasPrefix("param.") {
|
|
80
|
+
guard let number = value.double else { throw ProgramNodeError("unsupported: \(key) takes a number") }
|
|
81
|
+
let name = String(key.dropFirst(6))
|
|
82
|
+
values[name] = Float(number)
|
|
83
|
+
if let index = program?.parameters.firstIndex(where: { $0.name == name }) {
|
|
84
|
+
renderer.send(.parameter(Int32(index), Float(number)))
|
|
85
|
+
}
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func note(_ action: String, _ params: JSONObject) throws -> Bool {
|
|
92
|
+
defer { renderer.collect() }
|
|
93
|
+
let int = { (key: String) in Int32(truncatingIfNeeded: params.int(key) ?? 0) }
|
|
94
|
+
switch action {
|
|
95
|
+
case "startNote": renderer.send(.noteOn(int("note"), int("velocity")))
|
|
96
|
+
case "stopNote": renderer.send(.noteOff(int("note")))
|
|
97
|
+
case "controller": renderer.send(.controller(int("controller"), int("value")))
|
|
98
|
+
case "pitchBend": renderer.send(.pitchBend(Int32(truncatingIfNeeded: params.int("value") ?? 8192)))
|
|
99
|
+
default: return false
|
|
100
|
+
}
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func halt() {
|
|
105
|
+
renderer.send(.reset)
|
|
106
|
+
renderer.collect()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// A failure the app reads as `kind: detail`.
|
|
111
|
+
struct ProgramNodeError: Error, CustomStringConvertible {
|
|
112
|
+
let description: String
|
|
113
|
+
init(_ description: String) { self.description = description }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// What the main actor sends the audio thread.
|
|
117
|
+
enum ProgramEvent: Sendable {
|
|
118
|
+
case noteOn(Int32, Int32)
|
|
119
|
+
case noteOff(Int32)
|
|
120
|
+
case controller(Int32, Int32)
|
|
121
|
+
case pitchBend(Int32)
|
|
122
|
+
case parameter(Int32, Float)
|
|
123
|
+
case reset
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// The machine and what is sent to it, between the main actor and the audio
|
|
127
|
+
/// thread. The audio thread only ever tries the lock (`withLockIfAvailable`):
|
|
128
|
+
/// when the main actor holds it, this render goes on with the events it has
|
|
129
|
+
/// and takes the rest at the next. A machine the audio thread lets go of
|
|
130
|
+
/// waits in `retired` for the main actor to release, so nothing is freed on
|
|
131
|
+
/// the audio thread.
|
|
132
|
+
final class Renderer: @unchecked Sendable {
|
|
133
|
+
private struct Shared {
|
|
134
|
+
var events: [ProgramEvent] = []
|
|
135
|
+
var machine: DSPMachine?
|
|
136
|
+
var retired: DSPMachine?
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private let lock = OSAllocatedUnfairLock(initialState: Shared())
|
|
140
|
+
// The audio thread's own.
|
|
141
|
+
private var machine: DSPMachine?
|
|
142
|
+
private var inbox: [ProgramEvent] = []
|
|
143
|
+
|
|
144
|
+
init() {
|
|
145
|
+
inbox.reserveCapacity(256)
|
|
146
|
+
lock.withLockUnchecked { $0.events.reserveCapacity(256) }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func send(_ event: ProgramEvent) {
|
|
150
|
+
lock.withLockUnchecked { $0.events.append(event) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func replace(_ machine: DSPMachine) {
|
|
154
|
+
// A machine that never reached the audio thread is released here, after the lock.
|
|
155
|
+
let superseded = lock.withLockUnchecked { shared -> DSPMachine? in
|
|
156
|
+
defer {
|
|
157
|
+
shared.machine = machine
|
|
158
|
+
shared.events.removeAll(keepingCapacity: true)
|
|
159
|
+
}
|
|
160
|
+
return shared.machine
|
|
161
|
+
}
|
|
162
|
+
_ = superseded
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Releases, on the caller's thread, a machine the audio thread let go of.
|
|
166
|
+
func collect() {
|
|
167
|
+
let retired = lock.withLockUnchecked { shared -> DSPMachine? in
|
|
168
|
+
defer { shared.retired = nil }
|
|
169
|
+
return shared.retired
|
|
170
|
+
}
|
|
171
|
+
_ = retired
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// The audio thread: false when there is no program yet (silence).
|
|
175
|
+
func render(into out: UnsafeMutablePointer<Float>, frames: Int) -> Bool {
|
|
176
|
+
_ = lock.withLockIfAvailableUnchecked { shared in
|
|
177
|
+
if let next = shared.machine, shared.retired == nil {
|
|
178
|
+
shared.retired = machine
|
|
179
|
+
machine = next
|
|
180
|
+
shared.machine = nil
|
|
181
|
+
}
|
|
182
|
+
swap(&shared.events, &inbox)
|
|
183
|
+
}
|
|
184
|
+
guard let machine else {
|
|
185
|
+
inbox.removeAll(keepingCapacity: true)
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
for event in inbox {
|
|
189
|
+
switch event {
|
|
190
|
+
case let .noteOn(note, velocity): machine.noteOn(note, velocity: velocity)
|
|
191
|
+
case let .noteOff(note): machine.noteOff(note)
|
|
192
|
+
case let .controller(number, value): machine.controller(number, value: value)
|
|
193
|
+
case let .pitchBend(value): machine.pitchBend(value)
|
|
194
|
+
case let .parameter(index, value): machine.setParameter(at: Int(index), value)
|
|
195
|
+
case .reset: machine.reset()
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
inbox.removeAll(keepingCapacity: true)
|
|
199
|
+
machine.render(into: out, frames: frames)
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
#endif
|