@swiftbrowser/native 0.18.3 → 0.21.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 +53 -11
- package/bin/swiftbrowser-native.mjs +47 -0
- package/dist/blob.d.ts +85 -11
- package/dist/blob.d.ts.map +1 -1
- package/dist/blob.js +184 -34
- package/dist/blob.js.map +1 -1
- package/dist/bridge.d.ts +66 -8
- 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 +27 -13
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +117 -45
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +24 -6
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +108 -15
- package/dist/hostRequests.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.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 +37 -11
- package/dist/webSocket.js.map +1 -1
- package/package.json +7 -3
- package/src/blob.ts +215 -34
- package/src/bridge.ts +56 -8
- package/src/encoding.ts +22 -0
- package/src/fetch.ts +111 -48
- package/src/hostRequests.ts +129 -22
- package/src/index.ts +1 -2
- package/src/ios.ts +253 -0
- package/src/runtime.ts +23 -8
- package/src/subtle.ts +10 -6
- package/src/webSocket.ts +37 -13
- package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +306 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1561 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +483 -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/Packages/SwiftBrowserShader/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserShader/README.md +45 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
- package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +144 -10
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +151 -27
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +139 -40
- 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/Runtime/NativeFunctions.swift +1 -1
- package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +12 -7
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
- 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 +87 -21
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +103 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +146 -0
- package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +31 -2
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
- package/dist/native.d.ts +0 -19
- package/dist/native.d.ts.map +0 -1
- package/dist/native.js +0 -17
- package/dist/native.js.map +0 -1
- package/src/native.ts +0 -35
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +0 -53
package/src/webSocket.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* `binaryType` (`blob` by default, or `arraybuffer`).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { SimpleBlob } from './blob';
|
|
12
|
+
import { blobBytes, LazyFile, 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,28 @@ export class NativeWebSocket {
|
|
|
85
92
|
bridge?.wsSend(this.id, JSON.stringify({ text: data }));
|
|
86
93
|
return;
|
|
87
94
|
}
|
|
88
|
-
|
|
89
|
-
|
|
95
|
+
if (data instanceof LazyFile) {
|
|
96
|
+
// A picked file on disk: read it, then send (the frame goes out when the read lands; order among lazy sends is the reads').
|
|
97
|
+
void blobBytes(data).then((bytes) => {
|
|
98
|
+
if (this.readyState !== NativeWebSocket.OPEN) return;
|
|
99
|
+
if (carriesBytes) bridge?.wsSend(this.id, JSON.stringify({ binary: { $bytes: 0 } }), bytes);
|
|
100
|
+
else bridge?.wsSend(this.id, JSON.stringify({ binary: base64Encode(bytes) }));
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const bytes =
|
|
105
|
+
data instanceof SimpleBlob
|
|
106
|
+
? data.bytes()
|
|
107
|
+
: data instanceof ArrayBuffer
|
|
108
|
+
? new Uint8Array(data)
|
|
109
|
+
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
110
|
+
if (carriesBytes) bridge?.wsSend(this.id, JSON.stringify({ binary: { $bytes: 0 } }), bytes);
|
|
111
|
+
else bridge?.wsSend(this.id, JSON.stringify({ binary: base64Encode(bytes) }));
|
|
90
112
|
}
|
|
91
113
|
|
|
92
114
|
close(code?: number, reason?: string): void {
|
|
93
|
-
if (code !== undefined && code !== 1000 && !(code >= 3000 && code <= 4999))
|
|
115
|
+
if (code !== undefined && code !== 1000 && !(code >= 3000 && code <= 4999))
|
|
116
|
+
throw new DOMExceptionLike('InvalidAccessError', 'WebSocket: the close code must be 1000 or in 3000–4999');
|
|
94
117
|
if (this.readyState === NativeWebSocket.CLOSING || this.readyState === NativeWebSocket.CLOSED) return;
|
|
95
118
|
this.readyState = NativeWebSocket.CLOSING;
|
|
96
119
|
bridge?.wsClose(this.id, code ?? 1000, reason ?? '');
|
|
@@ -129,7 +152,7 @@ export class NativeWebSocket {
|
|
|
129
152
|
if (this.readyState !== NativeWebSocket.OPEN) return;
|
|
130
153
|
let data: unknown = event.text ?? '';
|
|
131
154
|
if (event.binary !== undefined) {
|
|
132
|
-
const bytes =
|
|
155
|
+
const bytes = bridgedBytes(typeof event.binary === 'string' ? event.binary : event.bytes?.[event.binary.$bytes]);
|
|
133
156
|
data = this.binaryType === 'arraybuffer' ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : new SimpleBlob([bytes]);
|
|
134
157
|
}
|
|
135
158
|
this.dispatchEvent({ type: 'message', target: this, data });
|
|
@@ -147,8 +170,8 @@ export class NativeWebSocket {
|
|
|
147
170
|
}
|
|
148
171
|
}
|
|
149
172
|
|
|
150
|
-
/** The bridge's `__sbWebSocketEvent(id, json)
|
|
151
|
-
static receive(id: number, json: string | NativeWebSocketEvent): void {
|
|
173
|
+
/** The bridge's `__sbWebSocketEvent(id, json, ...bytes)`: a binary message's bytes after the JSON that names them. */
|
|
174
|
+
static receive(id: number, json: string | NativeWebSocketEvent, ...bytes: ArrayBufferView[]): void {
|
|
152
175
|
const socket = sockets.get(id);
|
|
153
176
|
if (!socket) return;
|
|
154
177
|
let event: NativeWebSocketEvent;
|
|
@@ -157,12 +180,13 @@ export class NativeWebSocket {
|
|
|
157
180
|
} catch {
|
|
158
181
|
return;
|
|
159
182
|
}
|
|
160
|
-
socket.receive(event);
|
|
183
|
+
socket.receive(bytes.length ? { ...event, bytes } : event);
|
|
161
184
|
}
|
|
162
185
|
|
|
163
|
-
/** Points the sockets at the bridge (the runtime, once per install). */
|
|
164
|
-
static install(native: WebSocketBridge): void {
|
|
186
|
+
/** Points the sockets at the bridge (the runtime, once per install); `bytes`: the host carries typed arrays. */
|
|
187
|
+
static install(native: WebSocketBridge, options: { bytes?: boolean } = {}): void {
|
|
165
188
|
bridge = native;
|
|
189
|
+
carriesBytes = options.bytes === true;
|
|
166
190
|
}
|
|
167
191
|
}
|
|
168
192
|
|
|
@@ -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,306 @@
|
|
|
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, and `ScopeView`'s SwiftUI view; call at
|
|
22
|
+
/// launch, before the bundle loads.
|
|
23
|
+
public static func install() {
|
|
24
|
+
AudioEngineNodes.register("program") { context in ProgramNode(format: context.format) }
|
|
25
|
+
ViewRegistry.register("SwiftBrowserDSP.scope") { context in DSPScopeView(props: context.props) }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// A program as a node: an `AVAudioSourceNode` whose render block runs the
|
|
30
|
+
/// machine. What the app sends (a program, its parameters, notes) is handed
|
|
31
|
+
/// to the audio thread through `Renderer` without a lock the audio thread
|
|
32
|
+
/// waits on, and applies at its next render.
|
|
33
|
+
@MainActor
|
|
34
|
+
final class ProgramNode: AudioEngineCustomNode {
|
|
35
|
+
let node: AVAudioNode
|
|
36
|
+
private let renderer: Renderer
|
|
37
|
+
private let sampleRate: Double
|
|
38
|
+
/// The program the audio thread runs (or is about to), for parameter names.
|
|
39
|
+
private var program: DSPProgram?
|
|
40
|
+
/// Every parameter the app set, by name: a program loaded later takes the values it has names for.
|
|
41
|
+
private var values: [String: Float] = [:]
|
|
42
|
+
/// The transport's tempo as last set, for a program loaded later.
|
|
43
|
+
private var tempo: Double = 120
|
|
44
|
+
|
|
45
|
+
init(format: AVAudioFormat) {
|
|
46
|
+
sampleRate = format.sampleRate
|
|
47
|
+
renderer = Renderer()
|
|
48
|
+
node = Self.source(renderer, format: format)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Made off the main actor: the render block runs on the audio thread.
|
|
52
|
+
nonisolated private static func source(_ renderer: Renderer, format: AVAudioFormat) -> AVAudioSourceNode {
|
|
53
|
+
AVAudioSourceNode(format: format) { silence, _, frames, list in
|
|
54
|
+
let buffers = UnsafeMutableAudioBufferListPointer(list)
|
|
55
|
+
guard let first = buffers.first?.mData?.assumingMemoryBound(to: Float.self) else { return noErr }
|
|
56
|
+
let count = Int(frames)
|
|
57
|
+
// Two channels or more: the left side, then the right; one: their average.
|
|
58
|
+
let second = buffers.count > 1 ? buffers[1].mData?.assumingMemoryBound(to: Float.self) : nil
|
|
59
|
+
if !renderer.render(into: first, right: second, frames: count) {
|
|
60
|
+
silence.pointee = true
|
|
61
|
+
for buffer in buffers { buffer.mData?.initializeMemory(as: Float.self, repeating: 0, count: count) }
|
|
62
|
+
return noErr
|
|
63
|
+
}
|
|
64
|
+
for buffer in buffers.dropFirst(2) {
|
|
65
|
+
buffer.mData?.initializeMemory(as: Float.self, repeating: 0, count: count)
|
|
66
|
+
}
|
|
67
|
+
return noErr
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func set(_ key: String, _ value: JSONValue) throws -> Bool {
|
|
72
|
+
defer { renderer.collect() }
|
|
73
|
+
if key == "view" {
|
|
74
|
+
// What a view finds the node's frames by.
|
|
75
|
+
guard let token = value.string else { throw ProgramNodeError("unsupported: view takes a string") }
|
|
76
|
+
ScopeTaps.register(token, renderer.tap)
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
if key == "program" {
|
|
80
|
+
guard let json = value.string else { throw DSPProgramError("a program crosses as JSON") }
|
|
81
|
+
let program = try DSPProgram(json: json)
|
|
82
|
+
// Made and given its parameters here, then handed over whole.
|
|
83
|
+
let machine = DSPMachine(program: program, sampleRate: sampleRate)
|
|
84
|
+
for (name, value) in values {
|
|
85
|
+
if let index = machine.parameterIndex(name) { machine.setParameter(at: index, value) }
|
|
86
|
+
}
|
|
87
|
+
machine.setTempo(tempo)
|
|
88
|
+
self.program = program
|
|
89
|
+
renderer.replace(machine)
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
if key == "tempo" || key == "beat" {
|
|
93
|
+
guard let number = value.double else { throw ProgramNodeError("unsupported: \(key) takes a number") }
|
|
94
|
+
if key == "tempo" {
|
|
95
|
+
tempo = number
|
|
96
|
+
renderer.send(.tempo(number))
|
|
97
|
+
} else {
|
|
98
|
+
renderer.send(.seek(number))
|
|
99
|
+
}
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
if key.hasPrefix("param.") {
|
|
103
|
+
guard let number = value.double else { throw ProgramNodeError("unsupported: \(key) takes a number") }
|
|
104
|
+
let name = String(key.dropFirst(6))
|
|
105
|
+
values[name] = Float(number)
|
|
106
|
+
if let index = program?.parameters.firstIndex(where: { $0.name == name }) {
|
|
107
|
+
renderer.send(.parameter(Int32(index), Float(number)))
|
|
108
|
+
}
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func note(_ action: String, _ params: JSONObject) throws -> Bool {
|
|
115
|
+
defer { renderer.collect() }
|
|
116
|
+
let int = { (key: String) in Int32(truncatingIfNeeded: params.int(key) ?? 0) }
|
|
117
|
+
// When on the program's transport, for a command that says.
|
|
118
|
+
let beat = params.double("beat")
|
|
119
|
+
switch action {
|
|
120
|
+
case "startNote": renderer.send(.noteOn(int("note"), int("velocity"), beat))
|
|
121
|
+
case "stopNote": renderer.send(.noteOff(int("note"), beat))
|
|
122
|
+
case "controller": renderer.send(.controller(int("controller"), int("value"), beat))
|
|
123
|
+
case "pitchBend": renderer.send(.pitchBend(Int32(truncatingIfNeeded: params.int("value") ?? 8192), beat))
|
|
124
|
+
default: return false
|
|
125
|
+
}
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func halt() {
|
|
130
|
+
renderer.send(.reset)
|
|
131
|
+
renderer.collect()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// A failure the app reads as `kind: detail`.
|
|
136
|
+
struct ProgramNodeError: Error, CustomStringConvertible {
|
|
137
|
+
let description: String
|
|
138
|
+
init(_ description: String) { self.description = description }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// What the main actor sends the audio thread.
|
|
142
|
+
enum ProgramEvent: Sendable {
|
|
143
|
+
case noteOn(Int32, Int32, Double?)
|
|
144
|
+
case noteOff(Int32, Double?)
|
|
145
|
+
case controller(Int32, Int32, Double?)
|
|
146
|
+
case pitchBend(Int32, Double?)
|
|
147
|
+
case parameter(Int32, Float)
|
|
148
|
+
case tempo(Double)
|
|
149
|
+
case seek(Double)
|
|
150
|
+
case reset
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// The machine and what is sent to it, between the main actor and the audio
|
|
154
|
+
/// thread. The audio thread only ever tries the lock (`withLockIfAvailable`):
|
|
155
|
+
/// when the main actor holds it, this render goes on with the events it has
|
|
156
|
+
/// and takes the rest at the next. A machine the audio thread lets go of
|
|
157
|
+
/// waits in `retired` for the main actor to release, so nothing is freed on
|
|
158
|
+
/// the audio thread.
|
|
159
|
+
final class Renderer: @unchecked Sendable {
|
|
160
|
+
private struct Shared {
|
|
161
|
+
var events: [ProgramEvent] = []
|
|
162
|
+
var machine: DSPMachine?
|
|
163
|
+
var retired: DSPMachine?
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private let lock = OSAllocatedUnfairLock(initialState: Shared())
|
|
167
|
+
/// The frames rendered last, for views.
|
|
168
|
+
let tap = ScopeTap()
|
|
169
|
+
// The audio thread's own.
|
|
170
|
+
private var machine: DSPMachine?
|
|
171
|
+
private var inbox: [ProgramEvent] = []
|
|
172
|
+
|
|
173
|
+
init() {
|
|
174
|
+
inbox.reserveCapacity(256)
|
|
175
|
+
lock.withLockUnchecked { $0.events.reserveCapacity(256) }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func send(_ event: ProgramEvent) {
|
|
179
|
+
lock.withLockUnchecked { $0.events.append(event) }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func replace(_ machine: DSPMachine) {
|
|
183
|
+
// A machine that never reached the audio thread is released here, after the lock.
|
|
184
|
+
let superseded = lock.withLockUnchecked { shared -> DSPMachine? in
|
|
185
|
+
defer {
|
|
186
|
+
shared.machine = machine
|
|
187
|
+
shared.events.removeAll(keepingCapacity: true)
|
|
188
|
+
}
|
|
189
|
+
return shared.machine
|
|
190
|
+
}
|
|
191
|
+
_ = superseded
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Releases, on the caller's thread, a machine the audio thread let go of.
|
|
195
|
+
func collect() {
|
|
196
|
+
let retired = lock.withLockUnchecked { shared -> DSPMachine? in
|
|
197
|
+
defer { shared.retired = nil }
|
|
198
|
+
return shared.retired
|
|
199
|
+
}
|
|
200
|
+
_ = retired
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// The audio thread: false when there is no program yet (silence). With `right`, the left side into `out`.
|
|
204
|
+
func render(into out: UnsafeMutablePointer<Float>, right: UnsafeMutablePointer<Float>? = nil, frames: Int) -> Bool {
|
|
205
|
+
_ = lock.withLockIfAvailableUnchecked { shared in
|
|
206
|
+
if let next = shared.machine, shared.retired == nil {
|
|
207
|
+
// The transport carries on through a new program.
|
|
208
|
+
if let machine { next.seek(toBeat: machine.position) }
|
|
209
|
+
shared.retired = machine
|
|
210
|
+
machine = next
|
|
211
|
+
shared.machine = nil
|
|
212
|
+
}
|
|
213
|
+
swap(&shared.events, &inbox)
|
|
214
|
+
}
|
|
215
|
+
guard let machine else {
|
|
216
|
+
inbox.removeAll(keepingCapacity: true)
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
for event in inbox {
|
|
220
|
+
switch event {
|
|
221
|
+
case let .noteOn(note, velocity, beat): machine.noteOn(note, velocity: velocity, atBeat: beat)
|
|
222
|
+
case let .noteOff(note, beat): machine.noteOff(note, atBeat: beat)
|
|
223
|
+
case let .controller(number, value, beat): machine.controller(number, value: value, atBeat: beat)
|
|
224
|
+
case let .pitchBend(value, beat): machine.pitchBend(value, atBeat: beat)
|
|
225
|
+
case let .parameter(index, value): machine.setParameter(at: Int(index), value)
|
|
226
|
+
case let .tempo(bpm): machine.setTempo(bpm)
|
|
227
|
+
case let .seek(beat): machine.seek(toBeat: beat)
|
|
228
|
+
case .reset: machine.reset()
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
inbox.removeAll(keepingCapacity: true)
|
|
232
|
+
if let right {
|
|
233
|
+
machine.render(left: out, right: right, frames: frames)
|
|
234
|
+
} else {
|
|
235
|
+
machine.render(into: out, frames: frames)
|
|
236
|
+
}
|
|
237
|
+
tap.write(out, right, frames: frames)
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/// A node's last 2048 frames, its two sides mixed, for views (`DSPScopeView`): the audio thread writes them when the
|
|
243
|
+
/// lock is free (never waiting, and a frame a view misses is only a frame), a view copies the newest out.
|
|
244
|
+
final class ScopeTap: @unchecked Sendable {
|
|
245
|
+
static let capacity = 2048
|
|
246
|
+
/// Frames written so far; the samples are only touched under it.
|
|
247
|
+
private let lock = OSAllocatedUnfairLock(initialState: 0)
|
|
248
|
+
private let samples: UnsafeMutablePointer<Float>
|
|
249
|
+
|
|
250
|
+
init() {
|
|
251
|
+
samples = .allocate(capacity: Self.capacity)
|
|
252
|
+
samples.initialize(repeating: 0, count: Self.capacity)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
deinit {
|
|
256
|
+
samples.deallocate()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// The audio thread.
|
|
260
|
+
func write(_ left: UnsafePointer<Float>, _ right: UnsafePointer<Float>?, frames: Int) {
|
|
261
|
+
let samples = samples
|
|
262
|
+
_ = lock.withLockIfAvailableUnchecked { written in
|
|
263
|
+
let mask = Self.capacity - 1
|
|
264
|
+
if let right {
|
|
265
|
+
for i in 0..<frames { samples[(written + i) & mask] = (left[i] + right[i]) * 0.5 }
|
|
266
|
+
} else {
|
|
267
|
+
for i in 0..<frames { samples[(written + i) & mask] = left[i] }
|
|
268
|
+
}
|
|
269
|
+
written += frames
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// The newest `buffer.count` frames (at most 2048), oldest first.
|
|
274
|
+
func read(into buffer: inout [Float]) {
|
|
275
|
+
let count = Swift.min(buffer.count, Self.capacity)
|
|
276
|
+
let samples = samples
|
|
277
|
+
buffer.withUnsafeMutableBufferPointer { out in
|
|
278
|
+
lock.withLockUnchecked { written in
|
|
279
|
+
let mask = Self.capacity - 1
|
|
280
|
+
for i in 0..<count { out[i] = samples[(written - count + i) & mask] }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// The program nodes' taps by view token, for views made before or after the node.
|
|
287
|
+
enum ScopeTaps {
|
|
288
|
+
private struct Weak {
|
|
289
|
+
weak var tap: ScopeTap?
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private static let lock = OSAllocatedUnfairLock(initialState: [String: Weak]())
|
|
293
|
+
|
|
294
|
+
static func register(_ token: String, _ tap: ScopeTap) {
|
|
295
|
+
lock.withLockUnchecked { taps in
|
|
296
|
+
taps = taps.filter { $0.value.tap != nil }
|
|
297
|
+
taps[token] = Weak(tap: tap)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
static func tap(_ token: String) -> ScopeTap? {
|
|
302
|
+
lock.withLockUnchecked { $0[token]?.tap }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
#endif
|
|
306
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#if canImport(AVFoundation) && canImport(SwiftUI)
|
|
2
|
+
import SwiftBrowserProtocol
|
|
3
|
+
import SwiftUI
|
|
4
|
+
|
|
5
|
+
/// `ScopeView` (@swiftbrowser/dsp): a program node's output as a waveform,
|
|
6
|
+
/// drawn every display frame from what the audio thread keeps for it
|
|
7
|
+
/// (`ScopeTap`), its frames never crossing to the app's JavaScript. The
|
|
8
|
+
/// trace starts at a rising zero crossing, so a steady tone stands still;
|
|
9
|
+
/// it is the page's canvas (packages/dsp/src/scope.ts), step for step.
|
|
10
|
+
struct DSPScopeView: View {
|
|
11
|
+
let token: String
|
|
12
|
+
let color: Color
|
|
13
|
+
let gain: Float
|
|
14
|
+
@State private var reader = ScopeReader()
|
|
15
|
+
|
|
16
|
+
init(props: JSONValue) {
|
|
17
|
+
token = props["view"].string ?? ""
|
|
18
|
+
gain = Float(props["gain"].double ?? 1)
|
|
19
|
+
color = props["color"].string.flatMap(Self.color(hex:)) ?? .accentColor
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
var body: some View {
|
|
23
|
+
TimelineView(.animation) { timeline in
|
|
24
|
+
// The tick's date is the trace's own input: SwiftUI draws a view again only when its inputs change, and
|
|
25
|
+
// a canvas that read nothing from the timeline was drawn once and left.
|
|
26
|
+
ScopeTrace(date: timeline.date, token: token, color: color, gain: gain, reader: reader)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// The first rising zero crossing in the first half (scope.ts `traceStart`).
|
|
31
|
+
static func traceStart(_ samples: [Float], _ length: Int) -> Int {
|
|
32
|
+
let last = Swift.max(1, samples.count - length)
|
|
33
|
+
for i in 1..<last where samples[i - 1] < 0 && samples[i] >= 0 { return i }
|
|
34
|
+
return 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// `#rrggbb`.
|
|
38
|
+
static func color(hex: String) -> Color? {
|
|
39
|
+
let digits = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex
|
|
40
|
+
guard digits.count == 6, let value = UInt32(digits, radix: 16) else { return nil }
|
|
41
|
+
return Color(red: Double((value >> 16) & 0xFF) / 255, green: Double((value >> 8) & 0xFF) / 255, blue: Double(value & 0xFF) / 255)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// One display frame's trace: what the tap holds at `date`.
|
|
46
|
+
private struct ScopeTrace: View {
|
|
47
|
+
let date: Date
|
|
48
|
+
let token: String
|
|
49
|
+
let color: Color
|
|
50
|
+
let gain: Float
|
|
51
|
+
let reader: ScopeReader
|
|
52
|
+
|
|
53
|
+
var body: some View {
|
|
54
|
+
Canvas { context, size in
|
|
55
|
+
let samples = reader.read(ScopeTaps.tap(token))
|
|
56
|
+
let length = samples.count / 2
|
|
57
|
+
let start = DSPScopeView.traceStart(samples, length)
|
|
58
|
+
var path = Path()
|
|
59
|
+
for i in 0..<length {
|
|
60
|
+
let x = CGFloat(i) / CGFloat(length - 1) * size.width
|
|
61
|
+
let value = Swift.max(-1, Swift.min(1, samples[start + i] * gain))
|
|
62
|
+
let y = size.height / 2 - CGFloat(value) * size.height / 2
|
|
63
|
+
if i == 0 { path.move(to: CGPoint(x: x, y: y)) } else { path.addLine(to: CGPoint(x: x, y: y)) }
|
|
64
|
+
}
|
|
65
|
+
context.stroke(path, with: .color(color), style: StrokeStyle(lineWidth: 1.5, lineJoin: .round))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// The view's copy of the newest frames, made again each display frame.
|
|
71
|
+
final class ScopeReader {
|
|
72
|
+
private var samples = [Float](repeating: 0, count: 1024)
|
|
73
|
+
|
|
74
|
+
func read(_ tap: ScopeTap?) -> [Float] {
|
|
75
|
+
tap?.read(into: &samples)
|
|
76
|
+
return samples
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
#endif
|