@swiftbrowser/native 0.21.0 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/native",
3
- "version": "0.21.0",
3
+ "version": "0.25.0",
4
4
  "type": "module",
5
5
  "description": "Run a @swiftbrowser/react app natively: the runtime a JavaScriptCore-hosted app needs (timers, console, fetch and the other web globals over the SwiftBrowser Native bridge), the `run` entry that renders into the native SwiftUI renderer, and the bundler that builds app.js for the Xcode target.",
6
6
  "repository": {
@@ -63,8 +63,8 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "@babel/core": "^7.29.7",
66
- "@swiftbrowser/react": "0.21.0",
67
- "@swiftbrowser/web": "0.21.0",
66
+ "@swiftbrowser/react": "0.25.0",
67
+ "@swiftbrowser/web": "0.25.0",
68
68
  "esbuild": "^0.28.2",
69
69
  "react-refresh": "^0.17.0"
70
70
  },
@@ -99,6 +99,36 @@ final class ProgramNode: AudioEngineCustomNode {
99
99
  }
100
100
  return true
101
101
  }
102
+ if key.hasPrefix("table.") || key.hasPrefix("shape.") || key.hasPrefix("sample.") {
103
+ // Data replaced by name (DSPNode `setTable` …): made ready here, off the audio thread, then swapped in.
104
+ guard let json = value.string, let data = json.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) else {
105
+ throw ProgramNodeError("unsupported: \(key) crosses as JSON")
106
+ }
107
+ let name = String(key[key.index(after: key.firstIndex(of: ".")!)...])
108
+ func floats(_ value: Any?) -> [Float] { (value as? [NSNumber] ?? []).map(\.floatValue) }
109
+ if key.hasPrefix("table."), let index = program?.tables.firstIndex(where: { $0.name == name }) {
110
+ let table = object as? [String: Any] ?? [:]
111
+ let frames = (table["frames"] as? [Any] ?? []).map { floats($0) }
112
+ let cosines = (table["cosines"] as? [Any])?.map { floats($0) }
113
+ renderer.send(.table(Int32(index), DSPMachine.tableLevels(frames: frames, cosines: cosines), Int32(frames.count)))
114
+ } else if key.hasPrefix("shape."), let index = program?.shapes.firstIndex(where: { $0.name == name }) {
115
+ renderer.send(.shape(Int32(index), DSPBuffer(floats(object))))
116
+ } else if key.hasPrefix("sample."), let index = program?.samples.firstIndex(where: { $0.name == name }) {
117
+ let sample = object as? [String: Any] ?? [:]
118
+ let bytes = Data(base64Encoded: sample["data"] as? String ?? "") ?? Data()
119
+ var audio = [Float](repeating: 0, count: bytes.count / 4)
120
+ _ = audio.withUnsafeMutableBytes { bytes.copyBytes(to: $0) }
121
+ renderer.send(.sample(Int32(index), DSPBuffer(audio), (sample["rate"] as? NSNumber)?.doubleValue ?? 44100))
122
+ }
123
+ return true
124
+ }
125
+ if key.hasPrefix("routes.") {
126
+ // A route list replaced (DSPNode `setRoutes`): its registers, copied in by the machine.
127
+ guard let json = value.string, let data = json.data(using: .utf8), let list = (try? JSONSerialization.jsonObject(with: data)) as? [NSNumber],
128
+ let index = Int32(key.dropFirst(7)) else { throw ProgramNodeError("unsupported: \(key) crosses as JSON") }
129
+ renderer.send(.routes(index, DSPBuffer(list.map(\.floatValue))))
130
+ return true
131
+ }
102
132
  if key.hasPrefix("param.") {
103
133
  guard let number = value.double else { throw ProgramNodeError("unsupported: \(key) takes a number") }
104
134
  let name = String(key.dropFirst(6))
@@ -145,6 +175,12 @@ enum ProgramEvent: Sendable {
145
175
  case controller(Int32, Int32, Double?)
146
176
  case pitchBend(Int32, Double?)
147
177
  case parameter(Int32, Float)
178
+ /// Data replaced (a table's samples and frames, a shape's points, a sample's audio and rate), made off the audio
179
+ /// thread; once swapped in, the buffer holds what the machine let go of, freed back off the audio thread.
180
+ case table(Int32, DSPBuffer, Int32)
181
+ case shape(Int32, DSPBuffer)
182
+ case sample(Int32, DSPBuffer, Double)
183
+ case routes(Int32, DSPBuffer)
148
184
  case tempo(Double)
149
185
  case seek(Double)
150
186
  case reset
@@ -161,6 +197,8 @@ final class Renderer: @unchecked Sendable {
161
197
  var events: [ProgramEvent] = []
162
198
  var machine: DSPMachine?
163
199
  var retired: DSPMachine?
200
+ /// Buffers the audio thread swapped out, for the main actor to free.
201
+ var spent: [DSPBuffer] = []
164
202
  }
165
203
 
166
204
  private let lock = OSAllocatedUnfairLock(initialState: Shared())
@@ -169,10 +207,16 @@ final class Renderer: @unchecked Sendable {
169
207
  // The audio thread's own.
170
208
  private var machine: DSPMachine?
171
209
  private var inbox: [ProgramEvent] = []
210
+ /// Buffers swapped out since the lock was last free, waiting to go to `Shared.spent`.
211
+ private var spent: [DSPBuffer] = []
172
212
 
173
213
  init() {
174
214
  inbox.reserveCapacity(256)
175
- lock.withLockUnchecked { $0.events.reserveCapacity(256) }
215
+ spent.reserveCapacity(64)
216
+ lock.withLockUnchecked {
217
+ $0.events.reserveCapacity(256)
218
+ $0.spent.reserveCapacity(64)
219
+ }
176
220
  }
177
221
 
178
222
  func send(_ event: ProgramEvent) {
@@ -193,11 +237,14 @@ final class Renderer: @unchecked Sendable {
193
237
 
194
238
  /// Releases, on the caller's thread, a machine the audio thread let go of.
195
239
  func collect() {
196
- let retired = lock.withLockUnchecked { shared -> DSPMachine? in
197
- defer { shared.retired = nil }
198
- return shared.retired
240
+ let (retired, spent) = lock.withLockUnchecked { shared -> (DSPMachine?, [DSPBuffer]) in
241
+ defer {
242
+ shared.retired = nil
243
+ shared.spent.removeAll(keepingCapacity: true)
244
+ }
245
+ return (shared.retired, shared.spent)
199
246
  }
200
- _ = retired
247
+ _ = (retired, spent)
201
248
  }
202
249
 
203
250
  /// The audio thread: false when there is no program yet (silence). With `right`, the left side into `out`.
@@ -211,6 +258,8 @@ final class Renderer: @unchecked Sendable {
211
258
  shared.machine = nil
212
259
  }
213
260
  swap(&shared.events, &inbox)
261
+ // What was swapped out, for the main actor to free (when it has taken the last).
262
+ if shared.spent.isEmpty && !spent.isEmpty { swap(&shared.spent, &spent) }
214
263
  }
215
264
  guard let machine else {
216
265
  inbox.removeAll(keepingCapacity: true)
@@ -223,6 +272,15 @@ final class Renderer: @unchecked Sendable {
223
272
  case let .controller(number, value, beat): machine.controller(number, value: value, atBeat: beat)
224
273
  case let .pitchBend(value, beat): machine.pitchBend(value, atBeat: beat)
225
274
  case let .parameter(index, value): machine.setParameter(at: Int(index), value)
275
+ case let .table(index, levels, frames):
276
+ if machine.replaceTable(Int(index), levels: levels, frames: Int(frames)) { spent.append(levels) }
277
+ case let .shape(index, points):
278
+ if machine.replaceShape(Int(index), points: points) { spent.append(points) }
279
+ case let .sample(index, audio, rate):
280
+ if machine.replaceSample(Int(index), audio: audio, rate: rate) { spent.append(audio) }
281
+ case let .routes(index, list):
282
+ _ = machine.replaceRoutes(Int(index), registers: list)
283
+ spent.append(list)
226
284
  case let .tempo(bpm): machine.setTempo(bpm)
227
285
  case let .seek(beat): machine.seek(toBeat: beat)
228
286
  case .reset: machine.reset()