Skip to content

Commit b2c01c6

Browse files
committed
WIP AudioRingBuffer for use in TransportProtocol
1 parent 7cd52c7 commit b2c01c6

File tree

5 files changed

+237
-35
lines changed

5 files changed

+237
-35
lines changed

Package.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ let package = Package(
4747
.package(path: "Packages/AlloDataChannel"),
4848
.package(url: "https://github.com/DimaRU/PackageBuildInfo", branch: "master"),
4949
.package(url: "https://github.com/mxcl/Version.git", from: "2.0.0"),
50+
.package(url: "https://github.com/apple/swift-atomics.git", from: "1.2.0"),
5051
],
5152
targets: [
5253
.target(
@@ -58,6 +59,7 @@ let package = Package(
5859
"Version",
5960
.product(name: "kvSIMD", package: "kvSIMD.swift"),
6061
.product(name: "OpenCombineShim", package: "opencombine"),
62+
.product(name: "Atomics", package: "swift-atomics"),
6163
],
6264
plugins: [
6365
.plugin(name: "PackageBuildInfoPlugin", package: "PackageBuildInfo")

Sources/AlloReality/SpatialAudioPlayer.swift

Lines changed: 14 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public class SpatialAudioPlayer
7676

7777
assert(playState.controller == nil, "Playing the same stream twice?")
7878

79-
print("SpatialAudioPlayer setting up LiveMedia \(netent.id) <-- \(stream.mediaId)")
79+
print("SpatialAudioPlayer[\(playState.streamId)] setting up LiveMedia \(netent.id)")
8080

8181
// TODO: Pick these up as settings from an Alloverse component
8282
let spatial = SpatialAudioComponent(
@@ -88,48 +88,34 @@ public class SpatialAudioPlayer
8888
)
8989
guient.components.set(spatial)
9090

91-
let config = AudioGeneratorConfiguration(layoutTag: kAudioChannelLayoutTag_Mono)
91+
let ringBuffer = stream.render()
92+
ringBuffer.store(in: &playState.cancellables)
9293

94+
let config = AudioGeneratorConfiguration(layoutTag: kAudioChannelLayoutTag_Mono)
9395
let handler: Audio.GeneratorRenderHandler = { (isSilence, timestamp, frameCount, audioBufferList) -> OSStatus in
94-
// TODO: Pluck data from the ring buffer in stream.streamingAudio instead of generating tone
95-
isSilence.pointee = false
96-
97-
let freq: Double = 440
98-
let sampleRate: Double = 48000
99-
100-
// Phase from absolute sample time (keeps continuity across calls).
101-
var phase = freq * timestamp.pointee.mSampleTime * (1.0 / sampleRate)
102-
let phaseIncrement = freq / sampleRate
103-
104-
let abl = UnsafeMutableAudioBufferListPointer(audioBufferList)
105-
guard let buf0 = abl.first, let mData = buf0.mData else { return 0 }
106-
107-
let out = mData.bindMemory(to: Float32.self, capacity: Int(frameCount))
108-
109-
for i in 0..<Int(frameCount) {
110-
out[i] = Float32(sin(phase * 2.0 * .pi) * 0.5)
111-
phase += phaseIncrement
112-
}
113-
114-
return 0
96+
let requested = Int(frameCount)
97+
let ablPointer = UnsafeMutableAudioBufferListPointer(audioBufferList)
98+
print("SpatialAudioPlayer[\(playState.streamId)] rendering \(requested) rendering from \(ringBuffer)")
99+
ringBuffer.readOrSilence(into: ablPointer, frames: requested)
100+
return noErr
115101
}
116102
do {
117103
playState.controller = try guient.playAudio(handler)
118104
} catch {
119-
print("SpatialAudioPlayer !!! Failed to start audio generator for entity \(netent.id) stream \(playState.streamId): \(error)")
105+
print("SpatialAudioPlayer[\(playState.streamId)] !!! Failed to start audio generator for entity \(netent.id): \(error)")
120106
stop(streamId: playState.streamId)
121107
return
122108
}
123-
print("SpatialAudioPlayer Successfully set up audio renderer \(netent.id) <-- \(stream.mediaId)")
109+
print("SpatialAudioPlayer[\(playState.streamId)] Successfully set up audio renderer \(netent.id)")
124110
}
125111

126112
func stop(streamId: MediaStreamId)
127113
{
128-
print("SpatialAudioPlayer Tearing down LiveMedia renderer for stream \(streamId)")
114+
print("SpatialAudioPlayer[\(streamId)] Tearing down LiveMedia renderer")
129115
guard let playState = state[streamId] else { return }
130116
let guient = mapper.guiForEid(playState.eid)
131117

132-
print("SpatialAudioPlayer LiveMedia \(streamId) was attached to \(playState.eid), disabling it...")
118+
print("SpatialAudioPlayer[\(streamId)] was attached to \(playState.eid), disabling it...")
133119
playState.stop()
134120
state[streamId] = nil
135121

@@ -163,3 +149,4 @@ fileprivate class SpatialAudioPlaybackState
163149
self.eid = eid
164150
}
165151
}
152+

Sources/alloclient/UIWebRTCTransport.swift

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -541,15 +541,40 @@ private class ClientMediaStream: MediaStream
541541
var streamDirection: allonet2.MediaStreamDirection = .unknown
542542

543543
private let rtcStream: LKRTCMediaStream
544-
545-
let streamingAudio = AudioRingBuffer()
544+
545+
func render() -> AudioRingBuffer
546+
{
547+
// lessee... Caller owns ring buffer owns renderer.
548+
weak var track = rtcStream.audioTracks.first
549+
let renderer = AudioRingRenderer()
550+
// TODO: don't hardcode sample rate
551+
let ring = AudioRingBuffer(channels: 1, capacityFrames: 48000)
552+
{
553+
track?.remove(renderer)
554+
}
555+
renderer.ring = ring
556+
track!.add(renderer)
557+
return ring
558+
}
546559

547560
init(stream: LKRTCMediaStream)
548561
{
549562
self.rtcStream = stream
550563
}
551564
}
552565

566+
fileprivate class AudioRingRenderer : NSObject, LKRTCAudioRenderer
567+
{
568+
weak var ring: AudioRingBuffer? = nil
569+
func render(pcmBuffer pcm: AVAudioPCMBuffer)
570+
{
571+
print("Writing \(pcm.frameLength) frames to \(ring)")
572+
_ = ring?.write(pcm)
573+
}
574+
}
575+
576+
577+
553578
extension LKRTCMediaStream {
554579
static var wrapperKey: Void = ()
555580
fileprivate var wrapper: ClientMediaStream {
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
//
2+
// AudioRingBuffer.swift
3+
// allonet2
4+
//
5+
// Created by Nevyn Bengtsson on 2025-09-16.
6+
// ... mostly written by ChatGPT though.
7+
8+
import Foundation
9+
import AVFoundation
10+
import Atomics
11+
import AudioToolbox
12+
import OpenCombineShim
13+
14+
/// Lock-free SPSC ring buffer for deinterleaved Float32 audio.
15+
///
16+
/// - One writer thread (producer), one reader thread (consumer), no blocking.
17+
/// - Format: Float32, non-interleaved; `channels` in [1, 8].
18+
/// - Capacity is in frames; per-channel storage has that many samples per channel.
19+
public final class AudioRingBuffer: Cancellable, CustomStringConvertible
20+
{
21+
public let channels: Int
22+
public let capacityFrames: Int
23+
24+
// Per-channel storage (contiguous) to simplify wrap logic.
25+
private var channelPtrs: [UnsafeMutablePointer<Float32>]
26+
private let deallocator: () -> Void
27+
28+
// Lock-free indices (SPSC).
29+
// `writeIndex` is advanced by producer; `readIndex` by consumer.
30+
private let writeIndex: ManagedAtomic<Int>
31+
private let readIndex: ManagedAtomic<Int>
32+
33+
public init(channels: Int, capacityFrames: Int, canceller: @escaping () -> ()) {
34+
precondition(channels > 0 && channels <= 8, "1...8 channels supported")
35+
precondition(capacityFrames > 0)
36+
37+
self.channels = channels
38+
// Use power-of-two capacity for cheap modulo if you like; we keep general case.
39+
self.capacityFrames = capacityFrames
40+
41+
let bytesPerChannel = capacityFrames * MemoryLayout<Float32>.stride
42+
43+
var pointers: [UnsafeMutablePointer<Float32>] = []
44+
pointers.reserveCapacity(channels)
45+
46+
// Allocate one contiguous block for all channels to be cache-friendly.
47+
let totalBytes = bytesPerChannel * channels
48+
let base = UnsafeMutableRawPointer.allocate(byteCount: totalBytes, alignment: MemoryLayout<Float32>.alignment) as! UnsafeMutableRawPointer
49+
50+
// Zero out once.
51+
base.initializeMemory(as: UInt8.self, repeating: 0, count: totalBytes)
52+
53+
for ch in 0..<channels {
54+
let ptr = base.advanced(by: ch * bytesPerChannel).bindMemory(to: Float32.self, capacity: capacityFrames)
55+
pointers.append(ptr)
56+
}
57+
58+
self.channelPtrs = pointers
59+
self.deallocator = {
60+
base.deallocate()
61+
}
62+
63+
self.writeIndex = ManagedAtomic(0)
64+
self.readIndex = ManagedAtomic(0)
65+
self.canceller = canceller
66+
}
67+
68+
deinit {
69+
deallocator()
70+
}
71+
72+
public var description: String {
73+
"<AudioRingBuffer@{\(Unmanaged.passUnretained(self).toOpaque())} buffered frames: \(availableToRead()), write capacity \(availableToWrite())>"
74+
}
75+
76+
/// Frames available to read.
77+
@inline(__always)
78+
public func availableToRead() -> Int {
79+
let w = writeIndex.load(ordering: .acquiring)
80+
let r = readIndex.load(ordering: .acquiring)
81+
let diff = w - r
82+
return diff >= 0 ? diff : diff + capacityFrames
83+
}
84+
85+
/// Free space for writing.
86+
@inline(__always)
87+
public func availableToWrite() -> Int {
88+
// We leave one frame empty to disambiguate full vs empty.
89+
return capacityFrames - 1 - availableToRead()
90+
}
91+
92+
/// Write up to `pcm.frameLength` frames from a non-interleaved Float32 AVAudioPCMBuffer.
93+
/// Returns frames accepted (may be less than requested if full).
94+
@discardableResult
95+
public func write(_ pcm: AVAudioPCMBuffer) -> Int {
96+
guard let src = pcm.floatChannelData else { return 0 }
97+
let frames = Int(pcm.frameLength)
98+
let ch = Int(pcm.format.channelCount)
99+
guard ch == channels else { return 0 }
100+
return writeDeinterleaved(source: UnsafePointer(src), frames: frames)
101+
}
102+
103+
/// Write from deinterleaved channel pointers.
104+
/// `source` is an array-like pointer set (Float32* per channel).
105+
@discardableResult
106+
public func writeDeinterleaved(source: UnsafePointer<UnsafeMutablePointer<Float32>>, frames: Int) -> Int {
107+
if frames == 0 { return 0 }
108+
let writable = availableToWrite()
109+
if writable == 0 { return 0 }
110+
111+
let toWrite = min(frames, writable)
112+
var w = writeIndex.load(ordering: .relaxed)
113+
114+
// First segment: up to ring end.
115+
let first = min(toWrite, capacityFrames - w)
116+
let second = toWrite - first
117+
118+
for c in 0..<channels {
119+
let srcCh = source[c]
120+
let dstCh = channelPtrs[c]
121+
122+
// segment 1
123+
dstCh.advanced(by: w).assign(from: srcCh, count: first)
124+
// segment 2 (wrap)
125+
if second > 0 {
126+
dstCh.assign(from: srcCh.advanced(by: first), count: second)
127+
}
128+
}
129+
130+
// Publish new write index with release ordering.
131+
w = (w + toWrite) % capacityFrames
132+
writeIndex.store(w, ordering: .releasing)
133+
return toWrite
134+
}
135+
136+
/// Read up to `frames` frames into an AudioBufferList (expects non-interleaved Float32).
137+
/// Returns frames actually read (<= requested and <= available).
138+
@discardableResult
139+
public func read(into abl: UnsafeMutableAudioBufferListPointer, frames: Int) -> Int {
140+
if frames == 0 { return 0 }
141+
let readable = availableToRead()
142+
if readable == 0 { return 0 }
143+
144+
let toRead = min(frames, readable)
145+
var r = readIndex.load(ordering: .relaxed)
146+
147+
let first = min(toRead, capacityFrames - r)
148+
let second = toRead - first
149+
150+
// Validate abl matches our channel count and format.
151+
guard abl.count >= channels else { return 0 }
152+
for c in 0..<channels {
153+
let dst = abl[c]
154+
guard dst.mNumberChannels == 1 else { return 0 } // non-interleaved
155+
guard dst.mDataByteSize >= UInt32(toRead * MemoryLayout<Float32>.stride) else { return 0 }
156+
}
157+
158+
for c in 0..<channels {
159+
let srcCh = channelPtrs[c]
160+
let dstBuf = abl[c]
161+
guard let dstPtr = dstBuf.mData?.assumingMemoryBound(to: Float32.self) else { continue }
162+
163+
// segment 1
164+
dstPtr.assign(from: srcCh.advanced(by: r), count: first)
165+
// segment 2 (wrap)
166+
if second > 0 {
167+
dstPtr.advanced(by: first).assign(from: srcCh, count: second)
168+
}
169+
}
170+
171+
// Publish new read index.
172+
r = (r + toRead) % capacityFrames
173+
readIndex.store(r, ordering: .releasing)
174+
return toRead
175+
}
176+
177+
/// Convenience: zero-fill ABL for frames where ring underflowed.
178+
public func readOrSilence(into abl: UnsafeMutableAudioBufferListPointer, frames: Int) {
179+
let got = read(into: abl, frames: frames)
180+
if got < frames {
181+
let deficit = frames - got
182+
for c in 0..<channels {
183+
let dst = abl[c]
184+
if let ptr = dst.mData?.assumingMemoryBound(to: Float32.self) {
185+
ptr.advanced(by: got).initialize(repeating: 0, count: deficit)
186+
}
187+
}
188+
}
189+
}
190+
191+
var canceller: () -> ()
192+
public func cancel() { canceller() }
193+
}

Sources/allonet2/TransportProtocol.swift

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,7 @@ public protocol MediaStream
125125
var streamDirection: MediaStreamDirection { get }
126126

127127
// XXX: Move to AudioTrack and add an array of audiotracks here
128-
var streamingAudio: AudioRingBuffer { get }
129-
}
130-
131-
public struct AudioRingBuffer // TODO
132-
{
133-
public init() {}
128+
func render() -> AudioRingBuffer
134129
}
135130

136131
public protocol AudioTrack

0 commit comments

Comments
 (0)