Skip to content

Commit bdddbcd

Browse files
renal128cursoragent
andcommitted
feat: audio-reactive orb in the widget
Port the procedural Metal orb from ElevenLabs components-swift (Apache-2.0), trimmed to the volume-driven view, and drive it from the SDK's mic and agent audio observers: a lock-protected RMS monitor per stream, sampled at 30 Hz so PCM-rate audio never re-renders SwiftUI. The mic button reuses the same level. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c6af73b commit bdddbcd

16 files changed

Lines changed: 827 additions & 50 deletions

Package.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ let package = Package(
4545
name: "ElevenLabsWidget",
4646
dependencies: [
4747
"ElevenLabs"
48+
],
49+
resources: [
50+
.process("Resources/OrbShader.metal")
51+
]
52+
),
53+
.testTarget(
54+
name: "ElevenLabsWidgetTests",
55+
dependencies: [
56+
"ElevenLabsWidget"
4857
]
4958
),
5059
.testTarget(

Package@swift-6.0.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ let package = Package(
4545
name: "ElevenLabsWidget",
4646
dependencies: [
4747
"ElevenLabs"
48+
],
49+
resources: [
50+
.process("Resources/OrbShader.metal")
51+
]
52+
),
53+
.testTarget(
54+
name: "ElevenLabsWidgetTests",
55+
dependencies: [
56+
"ElevenLabsWidget"
4857
]
4958
),
5059
.testTarget(

Package@swift-6.2.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ let package = Package(
4545
name: "ElevenLabsWidget",
4646
dependencies: [
4747
"ElevenLabs"
48+
],
49+
resources: [
50+
.process("Resources/OrbShader.metal")
51+
]
52+
),
53+
.testTarget(
54+
name: "ElevenLabsWidgetTests",
55+
dependencies: [
56+
"ElevenLabsWidget"
4857
]
4958
),
5059
.testTarget(
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import Accelerate
2+
@preconcurrency import AVFoundation
3+
import ElevenLabs
4+
5+
/// Tracks a normalized loudness level for one conversation audio stream.
6+
///
7+
/// Pull-based: `didReceive` runs on the audio thread and only holds the loudest
8+
/// level seen. Reading drains it, which is also what paces the decay, so the
9+
/// level keeps falling once a stream goes quiet and stops delivering buffers.
10+
final class ConversationAudioLevelMonitor: ConversationAudioObserver, @unchecked Sendable {
11+
/// Fraction of the level kept per read: fast attack, slow release.
12+
private static let release: Float = 0.75
13+
14+
private let lock = NSLock()
15+
private var storedLevel: Float = 0
16+
/// Only touched from the audio thread, which delivers buffers serially.
17+
private var scratch: [Float] = []
18+
19+
/// The loudest level since the last read, leaving one release step behind.
20+
func sample() -> Float {
21+
lock.withLock {
22+
defer { storedLevel *= Self.release }
23+
return storedLevel
24+
}
25+
}
26+
27+
func didReceive(_ buffer: AVAudioPCMBuffer) {
28+
guard let level = normalizedRMS(of: buffer) else { return }
29+
lock.withLock { storedLevel = max(level, storedLevel) }
30+
}
31+
32+
func reset() {
33+
lock.withLock { storedLevel = 0 }
34+
}
35+
36+
/// RMS of the first channel, mapped from the top 60 dB of headroom onto 0...1.
37+
private func normalizedRMS(of buffer: AVAudioPCMBuffer) -> Float? {
38+
let frames = Int(buffer.frameLength)
39+
guard frames > 0 else { return nil }
40+
let stride = buffer.format.isInterleaved ? vDSP_Stride(buffer.format.channelCount) : 1
41+
42+
var rms: Float = 0
43+
if let channel = buffer.floatChannelData?[0] {
44+
vDSP_rmsqv(channel, stride, &rms, vDSP_Length(frames))
45+
} else if let channel = buffer.int16ChannelData?[0] {
46+
// Grown, never reallocated per buffer: this is the audio thread.
47+
if scratch.count < frames { scratch = [Float](repeating: 0, count: frames) }
48+
scratch.withUnsafeMutableBufferPointer { floats in
49+
guard let samples = floats.baseAddress else { return }
50+
vDSP_vflt16(channel, stride, samples, 1, vDSP_Length(frames))
51+
var scale = 1 / Float(Int16.max)
52+
vDSP_vsmul(samples, 1, &scale, samples, 1, vDSP_Length(frames))
53+
vDSP_rmsqv(samples, 1, &rms, vDSP_Length(frames))
54+
}
55+
} else {
56+
return nil
57+
}
58+
59+
let decibels = 20 * log10(max(rms, 0.000_001))
60+
return min(max((decibels + 60) / 60, 0), 1)
61+
}
62+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import Combine
2+
import Foundation
3+
4+
/// Samples the mic and agent level monitors at a display-friendly rate.
5+
///
6+
/// The monitors are updated at PCM rate on the audio thread; publishing from
7+
/// there would re-render SwiftUI thousands of times a second, so the levels are
8+
/// polled on a timer while a conversation is live instead.
9+
@MainActor
10+
final class OrbAudioLevels: ObservableObject {
11+
@Published private(set) var input: Float = 0
12+
@Published private(set) var output: Float = 0
13+
14+
let micMonitor = ConversationAudioLevelMonitor()
15+
let agentMonitor = ConversationAudioLevelMonitor()
16+
17+
private static let interval: TimeInterval = 1.0 / 30
18+
private var timer: Timer?
19+
20+
var isActive: Bool = false {
21+
didSet {
22+
guard isActive != oldValue else { return }
23+
isActive ? start() : stop()
24+
}
25+
}
26+
27+
deinit { timer?.invalidate() }
28+
29+
private func start() {
30+
// Clears anything the audio thread wrote after the last stop, which would
31+
// otherwise surface as leftover loudness from the previous call.
32+
micMonitor.reset()
33+
agentMonitor.reset()
34+
let timer = Timer(timeInterval: Self.interval, repeats: true) { [weak self] _ in
35+
MainActor.assumeIsolated { self?.sample() }
36+
}
37+
// Common mode, so the levels keep updating while the transcript scrolls.
38+
RunLoop.main.add(timer, forMode: .common)
39+
self.timer = timer
40+
}
41+
42+
private func stop() {
43+
timer?.invalidate()
44+
timer = nil
45+
micMonitor.reset()
46+
agentMonitor.reset()
47+
input = 0
48+
output = 0
49+
}
50+
51+
/// Only publishes on change, so a silent conversation doesn't redraw the orb.
52+
private func sample() {
53+
let mic = micMonitor.sample()
54+
let agent = agentMonitor.sample()
55+
if mic != input { input = mic }
56+
if agent != output { output = agent }
57+
}
58+
}

0 commit comments

Comments
 (0)