Skip to content

Add Glow and Wave voice agent visualizations to StreamChatAI - #4338

Open
martinmitrevski wants to merge 4 commits into
developfrom
add/voice-agent-visualizations
Open

martinmitrevski wants to merge 4 commits into
developfrom
add/voice-agent-visualizations

Conversation

@martinmitrevski

@martinmitrevski martinmitrevski commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

🔗 Issue Links

Resolves https://linear.app/stream/issue/IOS-2189/add-glow-and-wave-voice-agent-visualizations-to-streamchatai.

🎯 Goal

Give voice agents a visual presence. The voice agent demo defines seven visualization styles and the design bar for them. This brings the first two to iOS as reusable SwiftUI components: Glow, the default, and Wave. Reference implementation: GetStream/stream-voice-agent-demo.

📝 Summary

  • New AIVoiceGlowView and AIVoiceWaveView in StreamChatAI, with the same parameters: state, colors, expressivity and audioLevel.
  • New AIVoiceAgentState: idle, connecting, listening, thinking, speaking. Changing it eases from one motion into the next.
  • New AIAppearance.Colors.voiceVisualization, the default palette of one to five colors. It defaults to the accent.
  • Tap to bounce and ripple, a hover ripple for pointers, live audio levels, light and dark mode, and Reduce Motion.
  • VoiceOver reads them as "Voice agent" with the state as the value.

🛠 Implementation

  • Shaders. The Glow and Wave GLSL is ported to Metal line for line. The port handles four places where GLSL and MSL differ: Metal's y-down fragment origin, flooring mod, pow of a negative base, and reversed smoothstep edges. The simplex noise was checked numerically against a float32 run of the GLSL, within 0.0012.
  • No Metal toolchain at build time. Xcode 26 and 27 don't include the Metal compiler by default, so a .metal file would break the build for SPM customers and CI. The Metal source lives in a Swift string instead (VoiceVisualizationShaders). It is compiled once, off the main thread, the first time a visualization appears. The view fades in over 0.3s when its first frame is ready.
  • Per-frame driver. VoiceVisualizationDriver ports the reference's per-frame easing, its three integrated clocks (time, flowSpin, orbSpin) and the expressivity scaling. The reference eases by a fixed share per frame, which settles twice as fast at 120 Hz. Here each share is converted to time, so motion is the same on any display. Long frames are clamped to 50 ms.
  • Interactions. The tap bounce uses the authored keyframes (0.93 at 30%, 1.03 at 62%) under CSS ease-out over 340 ms. Touches outside the globe or line pass through to views behind. Hover uses UIHoverGestureRecognizer, so it only ever comes from a pointer.
  • Rendering. MTKView keeps working on iOS 15, gives control over the render loop, and outputs premultiplied alpha as the web does. The loop pauses off screen and in the background. Glow renders at 2x like the web; Wave renders at screen scale to keep its line crisp.
  • Colors. Colors are kept as full HSV, so muted brand colors render as they are. They are resolved against the view's color scheme.
  • Sendable box. Metal objects cross threads in an UncheckedSendable box. Older SDKs, including CI's Xcode 16.1, may not mark Metal protocols Sendable, and Metal documents these objects as thread-safe.

🎨 Showcase

Glow, light and dark mode, in the Vision Agents Swift demo:

Light Dark
img img

🧪 Manual Testing Notes

AIVoiceGlowView(state: .speaking)
    .frame(width: 320, height: 320)

AIVoiceWaveView(state: .thinking, colors: [.purple, .pink, .mint])
    .frame(width: 320, height: 320)
  • Step through every state and check that each one eases into the next rather than cutting.
  • Tap the globe or the line: it bounces and ripples from the touch. Tapping the empty space around it does nothing.
  • Switch between light and dark mode, and turn on Reduce Motion: it should barely move and ignore taps.
  • Pass audioLevel (0 to 1) to drive the motion from live audio.

These were tested in the Vision Agents Swift demo's Voice tab against a local router, on the simulator.

☑️ Contributor Checklist

  • I have signed the Stream CLA (required)
  • This change should be manually QAed
  • Changelog is updated with client-facing changes
  • Changelog is updated with new localization keys
  • New code is covered by unit tests
  • Documentation has been updated in the docs-content repo

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added two voice-activity visualizations: a glowing orb and a flowing wave. Both reflect the agent’s idle, connecting, listening, thinking, and speaking states.
    • Added customizable visualization colors, with the app’s accent color used by default.
    • Added optional audio-level reactivity and adjustable expressivity.
    • Added accessibility labels and state descriptions for the visualizations.

AIVoiceGlowView and AIVoiceWaveView show what a voice agent is doing
(idle, connecting, listening, thinking, speaking). They port the web
voice agent demo's shaders to Metal, compiled at runtime so building
the SDK needs no Metal toolchain, with its per-frame easing, tap
bounce and ripple, hover, live audio level and Reduce Motion support.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@martinmitrevski
martinmitrevski requested a review from a team as a code owner October 16, 2026 10:43
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c0cc8b1b-d71e-4980-aa5f-e3a73665e616
📥 Commits

Reviewing files that changed from the base of the PR and between f775f74 and 95f81e0.

📒 Files selected for processing: 1
  • Sources/StreamChatAI/UI/Voice/VoiceVisualizationRenderer.swift

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.

📝 Walkthrough

This pull request adds public glow and wave voice-agent views. It adds state-based animation, Metal rendering, configurable colors, audio-level input, accessibility labels, and localized state values.

Changes: Voice Agent Visualizations

Layer / File(s) Summary
Voice states, palette, and localization
Sources/StreamChatAI/UI/Voice/AIVoiceAgentState.swift, Sources/StreamChatAI/UI/Appearance/AIAppearance+Colors.swift, Sources/StreamChatAI/Resources/en.lproj/Localizable.strings, Sources/StreamChatAI/Utils/Localization.swift, Tests/StreamChatAITests/UI/Appearance/AIAppearance_Tests.swift, Tests/StreamChatAITests/Utils/Localization_Tests.swift
Adds five public agent states and their motion parameters. Adds a configurable visualization palette with an accent-color fallback, plus localized accessibility labels and state values. Tests check palette defaults and localization strings.
Visualization motion and color data
Sources/StreamChatAI/UI/Voice/VoiceVisualizationStyle.swift, Sources/StreamChatAI/UI/Voice/VoiceVisualizationUniforms.swift, Sources/StreamChatAI/UI/Voice/VoiceVisualizationPalette.swift, Sources/StreamChatAI/UI/Voice/VoiceVisualizationDriver.swift, Sources/StreamChatAI/UI/Voice/VoiceVisualizationBounce.swift, Tests/StreamChatAITests/UI/Voice/*
Adds style and uniform data, palette conversion, and time-based motion for state changes, audio levels, taps, and hover. Tests cover motion, palette conversion, bounce, and shape bounds.
Metal rendering and frame lifecycle
Sources/StreamChatAI/UI/Voice/VoiceVisualizationShaders.swift, Sources/StreamChatAI/UI/Voice/VoiceVisualizationRenderer.swift, Sources/StreamChatAI/UI/Voice/VoiceVisualizationMetalView.swift, Tests/StreamChatAITests/UI/Voice/VoiceVisualizationRenderer_Tests.swift
Adds glow and wave shaders, per-style render pipelines, and a Metal view that advances the driver and presents rendered frames. The view handles app and window lifecycle, taps, and hover.
SwiftUI views and project integration
Sources/StreamChatAI/UI/Voice/AIVoiceGlowView.swift, Sources/StreamChatAI/UI/Voice/AIVoiceWaveView.swift, Sources/StreamChatAI/UI/Voice/VoiceVisualizationView.swift, StreamChat.xcodeproj/project.pbxproj, CHANGELOG.md
Adds public SwiftUI views that pass visualization inputs to the Metal-backed representable and expose accessibility values. Updates Xcode target membership and the changelog.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant MetalView as VoiceVisualizationMetalView
  participant Driver as VoiceVisualizationDriver
  participant Renderer as VoiceVisualizationRenderer
  participant Shaders as VoiceVisualizationShaders
  MetalView->>Driver: Advance animation and build uniforms
  MetalView->>Renderer: Prepare style pipelines
  Renderer->>Shaders: Compile shader source
  MetalView->>Renderer: Draw with style and uniforms
  Renderer-->>MetalView: Return command buffer
  MetalView->>MetalView: Present drawable
Loading

Suggested reviewers: ipavlidakis

Merge Risk: ⚪ Minimal · up to 95f81

No actionable merge-blocking issue is established for this change; it is mergeable after normal checks.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 16.22% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 74 functions across 21 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the primary change: adding Glow and Wave voice agent visualizations to StreamChatAI.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
1 Warning
⚠️ Big PR

Generated by 🚫 Danger

@Stream-SDK-Bot

Copy link
Copy Markdown
Collaborator

SDK Performance

target metric benchmark branch performance status
MessageList Hitches total duration 10 ms 5.01 ms 49.9% 🔼 🟢
Duration 2.6 s 2.53 s 2.69% 🔼 🟢
Hitch time ratio 4 ms per s 1.98 ms per s 50.5% 🔼 🟢
Frame rate 75 fps 79.35 fps 5.8% 🔼 🟢
Number of hitches 1 0.6 40.0% 🔼 🟢

// Starts compiling the shaders, unless they are compiled or compiling.
func prepare() {
guard loading == nil else { return }
let device = UncheckedSendable(device)

@laevandus laevandus Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here MTLDevice is Sendable. Should be OK then. We should really avoid workarounds like UncheckedSendable because it loses the idea of what Swift 6 brings.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, we should teach our skills not to do this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f775f74: the UncheckedSendable box and the detached task are gone, and the Metal objects go through as they are.

One catch: the Xcode 16.1 job in CI builds StreamChatAI too, and the iOS 18 SDKs don't mark MTLDevice, MTLLibrary or MTLRenderPipelineState Sendable yet (no NS_SWIFT_SENDABLE in their headers; every iOS 26 SDK has it). So for compilers before 6.2 Metal is imported with @preconcurrency, the same way DatabaseContainer imports CoreData. On Xcode 26+ it's a plain import Metal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to the above: @preconcurrency wasn't enough. The Xcode 16.1 job failed because Swift 6.0 still rejects sending the main actor's MTLDevice to Metal's async APIs when the SDK doesn't mark it Sendable. In 95f81e0, Xcode 16 builds call the synchronous APIs on the main actor, so the device never leaves it, and Xcode 26+ keeps the async ones. There's no @preconcurrency or unchecked anything now. Only CI builds StreamChatAI with Xcode 16 (SPM needs Xcode 27 for it), and that job is green.

on device: any MTLDevice
) throws -> UncheckedSendable<[VoiceVisualizationStyle: any MTLRenderPipelineState]> {
let library = try device.makeLibrary(source: VoiceVisualizationShaders.source, options: nil)
var pipelines: [VoiceVisualizationStyle: any MTLRenderPipelineState] = [:]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MTLRenderPipelineState is also Sendable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the pipelines now come back as a plain [VoiceVisualizationStyle: any MTLRenderPipelineState] (f775f74).

Comment on lines +41 to +50
loading = Task {
do {
let compiled = try await Task.detached(priority: .userInitiated) {
try Self.compile(on: device.value)
}.value
pipelines = compiled.value
} catch {
log.error("Voice visualizations can't render", error: error)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to use the async version of the makeRenderPipelineState?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, done in f775f74. Both makeLibrary(source:options:) and makeRenderPipelineState(descriptor:) use the async variants now, so Metal compiles on its own threads and the main actor only awaits the result. That also removed the need for Task.detached.

Comment on lines +8 to +9
// Draws a voice visualization's shader over a whole render target. All visualizations share
// one. The shaders compile once, off the main thread, and nothing is drawn until they have.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not require a metal toolbox for compilation (it is just additional download)? Good for the performance of the SDK when it comes pre-compiled?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I measured it before deciding. On an iPhone 15 Pro, compiling both shaders took 78–167 ms the first time the device saw this source, and 0 ms after that, because Metal caches libraries compiled from source on disk across launches. The compile runs asynchronously and the view fades in on its first frame, so the cost is roughly 0.1 s once per install, and never on the main thread.

Requiring the toolchain would cost everyone who builds StreamChatAI from source, which with SPM means every customer. Xcode 26 and later don't include the Metal compiler, and a .metal file in the target fails the build with cannot execute tool 'metal' due to missing Metal Toolchain until xcodebuild -downloadComponent MetalToolchain is run. That applies to every dev machine and CI runner, ours included. It seemed like a lot of friction to save ~0.1 s once per install.

If we want precompiled shaders anyway, there's a middle ground: commit prebuilt .metallibs (one for device, one for simulator) as resources, load them with makeLibrary(URL:), and regenerate them with a script when the shaders change, like we do for L10n. Customers wouldn't need the toolchain, and we'd maintain a binary artifact. Happy to do that if you prefer it.

martinmitrevski and others added 2 commits October 16, 2026 14:04
Metal's devices, libraries and pipelines are Sendable in the Xcode 26
SDKs, so the shaders compile through the async makeLibrary and
makeRenderPipelineState on the main actor, without the unchecked
Sendable box or a detached task. Xcode 16's SDKs lack the annotations,
so they import Metal with @preconcurrency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Xcode 16's SDKs do not mark MTLDevice Sendable, and Swift 6.0 rejects
sending the main actor's device to Metal's async APIs even with a
@preconcurrency import. Builds with those SDKs now call the synchronous
APIs on the main actor; Xcode 26 and later keep the async ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Public Interface

+ public enum AIVoiceAgentState: String, CaseIterable, Sendable  
+ 
+   case idle
+   case connecting
+   case listening
+   case thinking
+   case speaking

+ public struct AIVoiceGlowView: View  
+ 
+   public var body: some View
+   
+ 
+   public init(state: AIVoiceAgentState,colors: [Color]? = nil,expressivity: Double = 1,audioLevel: Double? = nil)

+ public struct AIVoiceWaveView: View  
+ 
+   public var body: some View
+   
+ 
+   public init(state: AIVoiceAgentState,colors: [Color]? = nil,expressivity: Double = 1,audioLevel: Double? = nil)



 @MainActor public final class Colors  
-   
+   public lazy var voiceVisualization: [UIColor]
- 
+   
-   public nonisolated init(tokens: DesignSystemTokens = DesignSystemTokens())
+ 
+   public nonisolated init(tokens: DesignSystemTokens = DesignSystemTokens())

@Stream-SDK-Bot

Copy link
Copy Markdown
Collaborator

SDK Size

title develop branch diff status
StreamChat 7.08 MB 7.08 MB 0 KB 🟢
StreamChatUI 3.38 MB 3.38 MB 0 KB 🟢
StreamChatCommonUI 1.03 MB 1.03 MB 0 KB 🟢
StreamChatAI 2.36 MB 2.44 MB +76 KB 🟢

@Stream-SDK-Bot

Copy link
Copy Markdown
Collaborator

StreamChatAI XCSize

Object Diff (bytes)
VoiceVisualizationRenderer.o +20392
ChartSpec.o +18589
VoiceVisualizationMetalView.o +7391
VoiceVisualizationDriver.o +5560
VoiceVisualizationView.o +4396
Show 14 more objects
Object Diff (bytes)
AIVoiceAgentState.o +3352
AIVoiceGlowView.o +2618
AIClientToolRunner.o +1788
AIVoiceWaveView.o +1597
VoiceVisualizationStyle.o +1254
VoiceVisualizationPalette.o +1214
AIAppearance+Colors.o +1026
AIMessagePart.o +608
VoiceVisualizationBounce.o +575
PhotoLibraryService.o -547
AIComposerViewModel.o -432
SwiftUI.tbd +300
MarkdownUI.o -148
StreamCore.o -91

@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants