@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.
Files changed (66) hide show
  1. package/README.md +34 -11
  2. package/dist/blob.d.ts +85 -11
  3. package/dist/blob.d.ts.map +1 -1
  4. package/dist/blob.js +184 -34
  5. package/dist/blob.js.map +1 -1
  6. package/dist/bridge.d.ts +33 -2
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/fetch.d.ts +19 -11
  10. package/dist/fetch.d.ts.map +1 -1
  11. package/dist/fetch.js +105 -41
  12. package/dist/fetch.js.map +1 -1
  13. package/dist/hostRequests.d.ts +3 -1
  14. package/dist/hostRequests.d.ts.map +1 -1
  15. package/dist/hostRequests.js +47 -6
  16. package/dist/hostRequests.js.map +1 -1
  17. package/dist/index.d.ts +1 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/runtime.js +1 -1
  22. package/dist/runtime.js.map +1 -1
  23. package/dist/webSocket.d.ts.map +1 -1
  24. package/dist/webSocket.js +13 -1
  25. package/dist/webSocket.js.map +1 -1
  26. package/package.json +3 -3
  27. package/src/blob.ts +215 -34
  28. package/src/bridge.ts +27 -2
  29. package/src/fetch.ts +99 -41
  30. package/src/hostRequests.ts +62 -8
  31. package/src/index.ts +1 -2
  32. package/src/runtime.ts +1 -1
  33. package/src/webSocket.ts +10 -1
  34. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +185 -24
  35. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
  36. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +2075 -213
  37. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +407 -16
  38. package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
  39. package/swift/Packages/SwiftBrowserShader/README.md +45 -0
  40. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
  41. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
  42. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
  43. package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
  44. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +141 -7
  45. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +47 -1
  46. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +98 -29
  47. package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
  48. package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
  49. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +11 -7
  50. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +28 -6
  51. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
  52. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +15 -10
  53. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +31 -28
  54. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +142 -0
  55. package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
  56. package/swift/Sources/SwiftBrowserProtocol/Props.swift +5 -2
  57. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
  58. package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
  59. package/swift/Sources/SwiftBrowserProtocol/TextChange.swift +17 -0
  60. package/wasm/SwiftBrowserNative.wasm +2 -2
  61. package/dist/native.d.ts +0 -19
  62. package/dist/native.d.ts.map +0 -1
  63. package/dist/native.js +0 -17
  64. package/dist/native.js.map +0 -1
  65. package/src/native.ts +0 -35
  66. 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; call at launch, before the bundle loads.
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
- if !renderer.render(into: first, frames: count) {
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?.assumingMemoryBound(to: Float.self).update(from: first, count: count)
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
- lock.withLockUnchecked { $0.events.reserveCapacity(256) }
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? in
168
- defer { shared.retired = nil }
169
- 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)
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
- machine.render(into: out, frames: frames)
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