Skip to content

Commit 86f9a4a

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 69c2305 commit 86f9a4a

16 files changed

Lines changed: 807 additions & 49 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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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 updates a lock-
8+
/// protected level, which the UI samples at its own rate.
9+
final class ConversationAudioLevelMonitor: ConversationAudioObserver, @unchecked Sendable {
10+
private let lock = NSLock()
11+
private var storedLevel: Float = 0
12+
13+
var level: Float {
14+
lock.withLock { storedLevel }
15+
}
16+
17+
func didReceive(_ buffer: AVAudioPCMBuffer) {
18+
guard let level = Self.normalizedRMS(of: buffer) else { return }
19+
lock.withLock {
20+
// Fast attack, slow release: reacts instantly to speech, decays smoothly.
21+
storedLevel = max(level, storedLevel * 0.75)
22+
}
23+
}
24+
25+
func reset() {
26+
lock.withLock { storedLevel = 0 }
27+
}
28+
29+
/// RMS of the first channel, mapped from the top 60 dB of headroom onto 0...1.
30+
private static func normalizedRMS(of buffer: AVAudioPCMBuffer) -> Float? {
31+
let frames = Int(buffer.frameLength)
32+
guard frames > 0 else { return nil }
33+
let stride = buffer.format.isInterleaved ? vDSP_Stride(buffer.format.channelCount) : 1
34+
35+
var rms: Float = 0
36+
if let channel = buffer.floatChannelData?[0] {
37+
vDSP_rmsqv(channel, stride, &rms, vDSP_Length(frames))
38+
} else if let channel = buffer.int16ChannelData?[0] {
39+
var samples = [Float](repeating: 0, count: frames)
40+
vDSP_vflt16(channel, stride, &samples, 1, vDSP_Length(frames))
41+
var scale = 1 / Float(Int16.max)
42+
vDSP_vsmul(samples, 1, &scale, &samples, 1, vDSP_Length(frames))
43+
vDSP_rmsqv(samples, 1, &rms, vDSP_Length(frames))
44+
} else {
45+
return nil
46+
}
47+
48+
let decibels = 20 * log10(max(rms, 0.000_001))
49+
return min(max((decibels + 60) / 60, 0), 1)
50+
}
51+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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+
timer = Timer.scheduledTimer(withTimeInterval: Self.interval, repeats: true) { [weak self] _ in
31+
MainActor.assumeIsolated { self?.sample() }
32+
}
33+
}
34+
35+
private func stop() {
36+
timer?.invalidate()
37+
timer = nil
38+
micMonitor.reset()
39+
agentMonitor.reset()
40+
input = 0
41+
output = 0
42+
}
43+
44+
/// Only publishes on change, so a silent conversation doesn't redraw the orb.
45+
private func sample() {
46+
let mic = micMonitor.level
47+
let agent = agentMonitor.level
48+
if mic != input { input = mic }
49+
if agent != output { output = agent }
50+
}
51+
}

0 commit comments

Comments
 (0)