@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.
Files changed (73) hide show
  1. package/README.md +19 -0
  2. package/bin/swiftbrowser-native.mjs +47 -0
  3. package/dist/bridge.d.ts +34 -7
  4. package/dist/bridge.d.ts.map +1 -1
  5. package/dist/bridge.js.map +1 -1
  6. package/dist/encoding.d.ts +13 -0
  7. package/dist/encoding.d.ts.map +1 -1
  8. package/dist/encoding.js +24 -0
  9. package/dist/encoding.js.map +1 -1
  10. package/dist/fetch.d.ts +10 -4
  11. package/dist/fetch.d.ts.map +1 -1
  12. package/dist/fetch.js +19 -11
  13. package/dist/fetch.js.map +1 -1
  14. package/dist/hostRequests.d.ts +22 -6
  15. package/dist/hostRequests.d.ts.map +1 -1
  16. package/dist/hostRequests.js +65 -13
  17. package/dist/hostRequests.js.map +1 -1
  18. package/dist/ios.d.ts +79 -0
  19. package/dist/ios.d.ts.map +1 -0
  20. package/dist/ios.js +181 -0
  21. package/dist/ios.js.map +1 -0
  22. package/dist/runtime.d.ts.map +1 -1
  23. package/dist/runtime.js +16 -7
  24. package/dist/runtime.js.map +1 -1
  25. package/dist/subtle.d.ts +1 -1
  26. package/dist/subtle.d.ts.map +1 -1
  27. package/dist/subtle.js +9 -5
  28. package/dist/subtle.js.map +1 -1
  29. package/dist/webSocket.d.ts +12 -5
  30. package/dist/webSocket.d.ts.map +1 -1
  31. package/dist/webSocket.js +24 -10
  32. package/dist/webSocket.js.map +1 -1
  33. package/package.json +7 -3
  34. package/src/bridge.ts +30 -7
  35. package/src/encoding.ts +22 -0
  36. package/src/fetch.ts +18 -13
  37. package/src/hostRequests.ts +72 -19
  38. package/src/ios.ts +253 -0
  39. package/src/runtime.ts +23 -8
  40. package/src/subtle.ts +10 -6
  41. package/src/webSocket.ts +27 -12
  42. package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
  43. package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
  44. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +203 -0
  45. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +523 -0
  46. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +188 -0
  47. package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
  48. package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
  49. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
  50. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
  51. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
  52. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
  53. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +3 -3
  54. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +106 -28
  55. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +43 -13
  56. package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
  57. package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
  58. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
  59. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
  60. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
  61. package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
  62. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
  63. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
  64. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
  65. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
  66. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +81 -20
  67. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
  68. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +100 -0
  69. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
  70. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +4 -0
  71. package/swift/Sources/SwiftBrowserProtocol/Props.swift +26 -0
  72. package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
  73. package/wasm/SwiftBrowserNative.wasm +2 -2
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 { 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,19 @@ 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
+ 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)) throw new DOMExceptionLike('InvalidAccessError', 'WebSocket: the close code must be 1000 or in 3000–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 = base64Decode(event.binary);
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