@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.
Files changed (102) hide show
  1. package/README.md +53 -11
  2. package/bin/swiftbrowser-native.mjs +47 -0
  3. package/dist/blob.d.ts +85 -11
  4. package/dist/blob.d.ts.map +1 -1
  5. package/dist/blob.js +184 -34
  6. package/dist/blob.js.map +1 -1
  7. package/dist/bridge.d.ts +66 -8
  8. package/dist/bridge.d.ts.map +1 -1
  9. package/dist/bridge.js.map +1 -1
  10. package/dist/encoding.d.ts +13 -0
  11. package/dist/encoding.d.ts.map +1 -1
  12. package/dist/encoding.js +24 -0
  13. package/dist/encoding.js.map +1 -1
  14. package/dist/fetch.d.ts +27 -13
  15. package/dist/fetch.d.ts.map +1 -1
  16. package/dist/fetch.js +117 -45
  17. package/dist/fetch.js.map +1 -1
  18. package/dist/hostRequests.d.ts +24 -6
  19. package/dist/hostRequests.d.ts.map +1 -1
  20. package/dist/hostRequests.js +108 -15
  21. package/dist/hostRequests.js.map +1 -1
  22. package/dist/index.d.ts +1 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/ios.d.ts +79 -0
  27. package/dist/ios.d.ts.map +1 -0
  28. package/dist/ios.js +181 -0
  29. package/dist/ios.js.map +1 -0
  30. package/dist/runtime.d.ts.map +1 -1
  31. package/dist/runtime.js +16 -7
  32. package/dist/runtime.js.map +1 -1
  33. package/dist/subtle.d.ts +1 -1
  34. package/dist/subtle.d.ts.map +1 -1
  35. package/dist/subtle.js +9 -5
  36. package/dist/subtle.js.map +1 -1
  37. package/dist/webSocket.d.ts +12 -5
  38. package/dist/webSocket.d.ts.map +1 -1
  39. package/dist/webSocket.js +37 -11
  40. package/dist/webSocket.js.map +1 -1
  41. package/package.json +7 -3
  42. package/src/blob.ts +215 -34
  43. package/src/bridge.ts +56 -8
  44. package/src/encoding.ts +22 -0
  45. package/src/fetch.ts +111 -48
  46. package/src/hostRequests.ts +129 -22
  47. package/src/index.ts +1 -2
  48. package/src/ios.ts +253 -0
  49. package/src/runtime.ts +23 -8
  50. package/src/subtle.ts +10 -6
  51. package/src/webSocket.ts +37 -13
  52. package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
  53. package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
  54. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +306 -0
  55. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
  56. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1561 -0
  57. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +483 -0
  58. package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
  59. package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
  60. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
  61. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
  62. package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
  63. package/swift/Packages/SwiftBrowserShader/README.md +45 -0
  64. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
  65. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
  66. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
  67. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
  68. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
  69. package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
  70. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +144 -10
  71. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +151 -27
  72. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +139 -40
  73. package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
  74. package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
  75. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
  76. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
  77. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
  78. package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
  79. package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
  80. package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
  81. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +12 -7
  82. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
  83. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
  84. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
  85. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
  86. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +87 -21
  87. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
  88. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +103 -0
  89. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
  90. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +146 -0
  91. package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
  92. package/swift/Sources/SwiftBrowserProtocol/Props.swift +31 -2
  93. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
  94. package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
  95. package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
  96. package/wasm/SwiftBrowserNative.wasm +2 -2
  97. package/dist/native.d.ts +0 -19
  98. package/dist/native.d.ts.map +0 -1
  99. package/dist/native.js +0 -17
  100. package/dist/native.js.map +0 -1
  101. package/src/native.ts +0 -35
  102. 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 { base64Decode, base64Encode } from './encoding';
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
- binary?: string;
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
- bridge.wsOpen(this.id, href, typeof protocols === 'string' ? [protocols] : [...protocols]);
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
- const bytes = data instanceof SimpleBlob ? data.bytes() : data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
89
- bridge?.wsSend(this.id, JSON.stringify({ binary: base64Encode(bytes) }));
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)) throw new DOMExceptionLike('InvalidAccessError', 'WebSocket: the close code must be 1000 or in 3000–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 = base64Decode(event.binary);
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