@swiftbrowser/native 0.19.0 → 0.23.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 +34 -11
- 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 +33 -2
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/fetch.d.ts +19 -11
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +105 -41
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +3 -1
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +47 -6
- 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/runtime.js +1 -1
- package/dist/runtime.js.map +1 -1
- package/dist/webSocket.d.ts.map +1 -1
- package/dist/webSocket.js +13 -1
- package/dist/webSocket.js.map +1 -1
- package/package.json +3 -3
- package/src/blob.ts +215 -34
- package/src/bridge.ts +27 -2
- package/src/fetch.ts +99 -41
- package/src/hostRequests.ts +62 -8
- package/src/index.ts +1 -2
- package/src/runtime.ts +1 -1
- package/src/webSocket.ts +10 -1
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +185 -24
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +2075 -213
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +407 -16
- 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/BodyStream.swift +166 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +141 -7
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +47 -1
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +98 -29
- 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 +11 -7
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +28 -6
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +15 -10
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +31 -28
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +142 -0
- package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +5 -2
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
- package/swift/Sources/SwiftBrowserProtocol/TextChange.swift +17 -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
|
@@ -18,9 +18,11 @@ import SwiftBrowserProtocol
|
|
|
18
18
|
/// ```
|
|
19
19
|
@MainActor
|
|
20
20
|
public enum SwiftBrowserDSP {
|
|
21
|
-
/// Registers the `program` kind with SwiftBrowserNative's audio engine
|
|
21
|
+
/// Registers the `program` kind with SwiftBrowserNative's audio engine, and `ScopeView`'s SwiftUI view; call at
|
|
22
|
+
/// launch, before the bundle loads.
|
|
22
23
|
public static func install() {
|
|
23
24
|
AudioEngineNodes.register("program") { context in ProgramNode(format: context.format) }
|
|
25
|
+
ViewRegistry.register("SwiftBrowserDSP.scope") { context in DSPScopeView(props: context.props) }
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -37,6 +39,8 @@ final class ProgramNode: AudioEngineCustomNode {
|
|
|
37
39
|
private var program: DSPProgram?
|
|
38
40
|
/// Every parameter the app set, by name: a program loaded later takes the values it has names for.
|
|
39
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
|
|
40
44
|
|
|
41
45
|
init(format: AVAudioFormat) {
|
|
42
46
|
sampleRate = format.sampleRate
|
|
@@ -50,13 +54,15 @@ final class ProgramNode: AudioEngineCustomNode {
|
|
|
50
54
|
let buffers = UnsafeMutableAudioBufferListPointer(list)
|
|
51
55
|
guard let first = buffers.first?.mData?.assumingMemoryBound(to: Float.self) else { return noErr }
|
|
52
56
|
let count = Int(frames)
|
|
53
|
-
|
|
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) {
|
|
54
60
|
silence.pointee = true
|
|
55
61
|
for buffer in buffers { buffer.mData?.initializeMemory(as: Float.self, repeating: 0, count: count) }
|
|
56
62
|
return noErr
|
|
57
63
|
}
|
|
58
|
-
for buffer in buffers.dropFirst() {
|
|
59
|
-
buffer.mData?.
|
|
64
|
+
for buffer in buffers.dropFirst(2) {
|
|
65
|
+
buffer.mData?.initializeMemory(as: Float.self, repeating: 0, count: count)
|
|
60
66
|
}
|
|
61
67
|
return noErr
|
|
62
68
|
}
|
|
@@ -64,6 +70,12 @@ final class ProgramNode: AudioEngineCustomNode {
|
|
|
64
70
|
|
|
65
71
|
func set(_ key: String, _ value: JSONValue) throws -> Bool {
|
|
66
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
|
+
}
|
|
67
79
|
if key == "program" {
|
|
68
80
|
guard let json = value.string else { throw DSPProgramError("a program crosses as JSON") }
|
|
69
81
|
let program = try DSPProgram(json: json)
|
|
@@ -72,10 +84,51 @@ final class ProgramNode: AudioEngineCustomNode {
|
|
|
72
84
|
for (name, value) in values {
|
|
73
85
|
if let index = machine.parameterIndex(name) { machine.setParameter(at: index, value) }
|
|
74
86
|
}
|
|
87
|
+
machine.setTempo(tempo)
|
|
75
88
|
self.program = program
|
|
76
89
|
renderer.replace(machine)
|
|
77
90
|
return true
|
|
78
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("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
|
+
}
|
|
79
132
|
if key.hasPrefix("param.") {
|
|
80
133
|
guard let number = value.double else { throw ProgramNodeError("unsupported: \(key) takes a number") }
|
|
81
134
|
let name = String(key.dropFirst(6))
|
|
@@ -91,11 +144,13 @@ final class ProgramNode: AudioEngineCustomNode {
|
|
|
91
144
|
func note(_ action: String, _ params: JSONObject) throws -> Bool {
|
|
92
145
|
defer { renderer.collect() }
|
|
93
146
|
let int = { (key: String) in Int32(truncatingIfNeeded: params.int(key) ?? 0) }
|
|
147
|
+
// When on the program's transport, for a command that says.
|
|
148
|
+
let beat = params.double("beat")
|
|
94
149
|
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)))
|
|
150
|
+
case "startNote": renderer.send(.noteOn(int("note"), int("velocity"), beat))
|
|
151
|
+
case "stopNote": renderer.send(.noteOff(int("note"), beat))
|
|
152
|
+
case "controller": renderer.send(.controller(int("controller"), int("value"), beat))
|
|
153
|
+
case "pitchBend": renderer.send(.pitchBend(Int32(truncatingIfNeeded: params.int("value") ?? 8192), beat))
|
|
99
154
|
default: return false
|
|
100
155
|
}
|
|
101
156
|
return true
|
|
@@ -115,11 +170,19 @@ struct ProgramNodeError: Error, CustomStringConvertible {
|
|
|
115
170
|
|
|
116
171
|
/// What the main actor sends the audio thread.
|
|
117
172
|
enum ProgramEvent: Sendable {
|
|
118
|
-
case noteOn(Int32, Int32)
|
|
119
|
-
case noteOff(Int32)
|
|
120
|
-
case controller(Int32, Int32)
|
|
121
|
-
case pitchBend(Int32)
|
|
173
|
+
case noteOn(Int32, Int32, Double?)
|
|
174
|
+
case noteOff(Int32, Double?)
|
|
175
|
+
case controller(Int32, Int32, Double?)
|
|
176
|
+
case pitchBend(Int32, Double?)
|
|
122
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)
|
|
184
|
+
case tempo(Double)
|
|
185
|
+
case seek(Double)
|
|
123
186
|
case reset
|
|
124
187
|
}
|
|
125
188
|
|
|
@@ -134,16 +197,26 @@ final class Renderer: @unchecked Sendable {
|
|
|
134
197
|
var events: [ProgramEvent] = []
|
|
135
198
|
var machine: DSPMachine?
|
|
136
199
|
var retired: DSPMachine?
|
|
200
|
+
/// Buffers the audio thread swapped out, for the main actor to free.
|
|
201
|
+
var spent: [DSPBuffer] = []
|
|
137
202
|
}
|
|
138
203
|
|
|
139
204
|
private let lock = OSAllocatedUnfairLock(initialState: Shared())
|
|
205
|
+
/// The frames rendered last, for views.
|
|
206
|
+
let tap = ScopeTap()
|
|
140
207
|
// The audio thread's own.
|
|
141
208
|
private var machine: DSPMachine?
|
|
142
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] = []
|
|
143
212
|
|
|
144
213
|
init() {
|
|
145
214
|
inbox.reserveCapacity(256)
|
|
146
|
-
|
|
215
|
+
spent.reserveCapacity(64)
|
|
216
|
+
lock.withLockUnchecked {
|
|
217
|
+
$0.events.reserveCapacity(256)
|
|
218
|
+
$0.spent.reserveCapacity(64)
|
|
219
|
+
}
|
|
147
220
|
}
|
|
148
221
|
|
|
149
222
|
func send(_ event: ProgramEvent) {
|
|
@@ -164,22 +237,29 @@ final class Renderer: @unchecked Sendable {
|
|
|
164
237
|
|
|
165
238
|
/// Releases, on the caller's thread, a machine the audio thread let go of.
|
|
166
239
|
func collect() {
|
|
167
|
-
let retired = lock.withLockUnchecked { shared -> DSPMachine
|
|
168
|
-
defer {
|
|
169
|
-
|
|
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)
|
|
170
246
|
}
|
|
171
|
-
_ = retired
|
|
247
|
+
_ = (retired, spent)
|
|
172
248
|
}
|
|
173
249
|
|
|
174
|
-
/// The audio thread: false when there is no program yet (silence).
|
|
175
|
-
func render(into out: UnsafeMutablePointer<Float>, frames: Int) -> Bool {
|
|
250
|
+
/// The audio thread: false when there is no program yet (silence). With `right`, the left side into `out`.
|
|
251
|
+
func render(into out: UnsafeMutablePointer<Float>, right: UnsafeMutablePointer<Float>? = nil, frames: Int) -> Bool {
|
|
176
252
|
_ = lock.withLockIfAvailableUnchecked { shared in
|
|
177
253
|
if let next = shared.machine, shared.retired == nil {
|
|
254
|
+
// The transport carries on through a new program.
|
|
255
|
+
if let machine { next.seek(toBeat: machine.position) }
|
|
178
256
|
shared.retired = machine
|
|
179
257
|
machine = next
|
|
180
258
|
shared.machine = nil
|
|
181
259
|
}
|
|
182
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) }
|
|
183
263
|
}
|
|
184
264
|
guard let machine else {
|
|
185
265
|
inbox.removeAll(keepingCapacity: true)
|
|
@@ -187,17 +267,98 @@ final class Renderer: @unchecked Sendable {
|
|
|
187
267
|
}
|
|
188
268
|
for event in inbox {
|
|
189
269
|
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)
|
|
270
|
+
case let .noteOn(note, velocity, beat): machine.noteOn(note, velocity: velocity, atBeat: beat)
|
|
271
|
+
case let .noteOff(note, beat): machine.noteOff(note, atBeat: beat)
|
|
272
|
+
case let .controller(number, value, beat): machine.controller(number, value: value, atBeat: beat)
|
|
273
|
+
case let .pitchBend(value, beat): machine.pitchBend(value, atBeat: beat)
|
|
194
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)
|
|
284
|
+
case let .tempo(bpm): machine.setTempo(bpm)
|
|
285
|
+
case let .seek(beat): machine.seek(toBeat: beat)
|
|
195
286
|
case .reset: machine.reset()
|
|
196
287
|
}
|
|
197
288
|
}
|
|
198
289
|
inbox.removeAll(keepingCapacity: true)
|
|
199
|
-
|
|
290
|
+
if let right {
|
|
291
|
+
machine.render(left: out, right: right, frames: frames)
|
|
292
|
+
} else {
|
|
293
|
+
machine.render(into: out, frames: frames)
|
|
294
|
+
}
|
|
295
|
+
tap.write(out, right, frames: frames)
|
|
200
296
|
return true
|
|
201
297
|
}
|
|
202
298
|
}
|
|
299
|
+
|
|
300
|
+
/// A node's last 2048 frames, its two sides mixed, for views (`DSPScopeView`): the audio thread writes them when the
|
|
301
|
+
/// lock is free (never waiting, and a frame a view misses is only a frame), a view copies the newest out.
|
|
302
|
+
final class ScopeTap: @unchecked Sendable {
|
|
303
|
+
static let capacity = 2048
|
|
304
|
+
/// Frames written so far; the samples are only touched under it.
|
|
305
|
+
private let lock = OSAllocatedUnfairLock(initialState: 0)
|
|
306
|
+
private let samples: UnsafeMutablePointer<Float>
|
|
307
|
+
|
|
308
|
+
init() {
|
|
309
|
+
samples = .allocate(capacity: Self.capacity)
|
|
310
|
+
samples.initialize(repeating: 0, count: Self.capacity)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
deinit {
|
|
314
|
+
samples.deallocate()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/// The audio thread.
|
|
318
|
+
func write(_ left: UnsafePointer<Float>, _ right: UnsafePointer<Float>?, frames: Int) {
|
|
319
|
+
let samples = samples
|
|
320
|
+
_ = lock.withLockIfAvailableUnchecked { written in
|
|
321
|
+
let mask = Self.capacity - 1
|
|
322
|
+
if let right {
|
|
323
|
+
for i in 0..<frames { samples[(written + i) & mask] = (left[i] + right[i]) * 0.5 }
|
|
324
|
+
} else {
|
|
325
|
+
for i in 0..<frames { samples[(written + i) & mask] = left[i] }
|
|
326
|
+
}
|
|
327
|
+
written += frames
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/// The newest `buffer.count` frames (at most 2048), oldest first.
|
|
332
|
+
func read(into buffer: inout [Float]) {
|
|
333
|
+
let count = Swift.min(buffer.count, Self.capacity)
|
|
334
|
+
let samples = samples
|
|
335
|
+
buffer.withUnsafeMutableBufferPointer { out in
|
|
336
|
+
lock.withLockUnchecked { written in
|
|
337
|
+
let mask = Self.capacity - 1
|
|
338
|
+
for i in 0..<count { out[i] = samples[(written - count + i) & mask] }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/// The program nodes' taps by view token, for views made before or after the node.
|
|
345
|
+
enum ScopeTaps {
|
|
346
|
+
private struct Weak {
|
|
347
|
+
weak var tap: ScopeTap?
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private static let lock = OSAllocatedUnfairLock(initialState: [String: Weak]())
|
|
351
|
+
|
|
352
|
+
static func register(_ token: String, _ tap: ScopeTap) {
|
|
353
|
+
lock.withLockUnchecked { taps in
|
|
354
|
+
taps = taps.filter { $0.value.tap != nil }
|
|
355
|
+
taps[token] = Weak(tap: tap)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
static func tap(_ token: String) -> ScopeTap? {
|
|
360
|
+
lock.withLockUnchecked { $0[token]?.tap }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
203
363
|
#endif
|
|
364
|
+
|
|
@@ -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
|