Semi-random thoughts and tales of tinkering
The DSP pipeline from Section 6 produces an array of 48 floats between 0 and 1, a peak frequency in Hz, and a note name. That data arrives on the main thread, ready for display. Now we need to actually display it.
This section is pure SwiftUI. We'll build a reusable SpectrumView component, then compose it into a dark-themed ContentView alongside the note readout, a VU meter strip, and a start/stop button. By the end, you'll have a fully working spectrum analyzer on screen.
Create a new Swift file: File → New → File → Swift File, name it SpectrumView.swift. Here's the complete view:
import SwiftUI
struct SpectrumView: View {
let bars: [Float]
var body: some View {
GeometryReader { geo in
let barWidth = geo.size.width / CGFloat(bars.count)
let spacing: CGFloat = 1
HStack(alignment: .bottom, spacing: spacing) {
ForEach(bars.indices, id: \.self) { i in
RoundedRectangle(cornerRadius: 2)
.fill(barColor(index: i, count: bars.count))
.frame(
width: barWidth - spacing,
height: max(2, geo.size.height * CGFloat(bars[i]))
)
.animation(.easeOut(duration: 0.08), value: bars[i])
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
}
}
func barColor(index: Int, count: Int) -> Color {
let t = Double(index) / Double(count)
return Color(hue: 0.35 - t * 0.35, saturation: 0.9, brightness: 0.9)
}
}
This is a compact view with a lot going on. Let's take it apart.
SpectrumView is a struct that conforms to View. It has a single input: bars: [Float]. It produces visual output. That's it. No internal state, no audio logic, no knowledge of where the data comes from. If you've used React, this is a functional component with props. If you've used WPF, it's a UserControl with a dependency property.
This separation is deliberate. SpectrumView can be previewed in isolation, tested with fake data, or reused in a completely different context. You could feed it data from a file, a network stream, or a synthesizer — it doesn't care. It draws bars.
GeometryReader gives us access to the size of the containing view. We used it back in Section 1 — here it's essential for calculating bar widths. geo.size.width is the available width in points, and dividing by bars.count gives us the width per bar. This means the view adapts automatically to any screen size: iPhone SE, iPhone 16 Pro Max, iPad, even a Mac via Catalyst. No hardcoded widths anywhere.
The bar height is geo.size.height * CGFloat(bars[i]), where bars[i] is our 0-to-1 normalized value from the DSP pipeline. A value of 1.0 fills the full height. A value of 0.0 would be invisible, which is why we use max(2, ...) to ensure even silent bars have a 2-point baseline. This visible baseline gives the user a sense of the frequency axis even when the room is quiet.
ForEach(bars.indices, id: \.self) { i in
SwiftUI's ForEach is not a plain loop — it's a view builder that needs to track which item is which across updates. The id: \.self parameter tells SwiftUI to use the index itself as the identity. Since our bar count is fixed at 48, the indices are stable: bar 0 is always the lowest frequency, bar 47 is always the highest. SwiftUI can then efficiently update just the bars whose values changed, rather than rebuilding the entire stack.
In WPF, you'd bind an ItemsControl to an ObservableCollection and use a DataTemplate to define the bar's appearance. SwiftUI's ForEach combines the collection binding and the template into a single construct. The id parameter serves the same role as the Key in React or the item identity in a CollectionView — it tells the framework how to diff updates efficiently.
func barColor(index: Int, count: Int) -> Color {
let t = Double(index) / Double(count)
return Color(hue: 0.35 - t * 0.35, saturation: 0.9, brightness: 0.9)
}
This creates a smooth gradient from green (low frequencies) to red (high frequencies) across the 48 bars. The hue parameter uses the HSB (Hue-Saturation-Brightness) color model, where hue is a value from 0 to 1 representing a position on the color wheel. Green is at ~0.33, yellow at ~0.17, and red at 0.0.
By setting hue to 0.35 - t * 0.35, we start slightly past green (hue 0.35) for the first bar and sweep down to red (hue 0.0) for the last. The saturation: 0.9 keeps colors vivid without being neon. brightness: 0.9 keeps them visible against our black background without being blinding.
This green-to-red gradient mirrors real hardware spectrum analyzers and audio meters. It's not just decorative — it gives the user instant visual feedback about which frequency range is active. A bass-heavy sound lights up the green bars on the left. Sibilance or cymbal hits light up the red bars on the right.
.animation(.easeOut(duration: 0.08), value: bars[i])
This is where the bars come to life. The .animation modifier tells SwiftUI to animate any change to the bar's height with an ease-out curve over 80 milliseconds. "Ease out" means the bar snaps up quickly and settles slowly — exactly the behavior you want for an audio meter. A sudden loud sound makes the bar jump instantly, then it glides back down smoothly when the sound fades.
The 80ms duration was chosen to feel responsive without being jittery. Faster (say, 30ms) and the bars vibrate nervously. Slower (say, 200ms) and the display feels sluggish, unable to keep up with the music. 80ms is the Goldilocks zone for real-time audio visualization.
The value: bars[i] parameter scopes the animation to changes in that specific bar's value. Without it, any state change anywhere in the view hierarchy could trigger the animation. With it, each bar animates independently — bar 12 can be rising while bar 30 is falling.
HStack(alignment: .bottom, spacing: spacing) { ... }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
Two things work together here. The HStack aligns its children to the bottom edge, so bars grow upward from a shared baseline. The .frame modifier expands the stack to fill all available space and pins it to the bottom of the GeometryReader. Without alignment: .bottom on the frame, the bars would float in the vertical center — which looks strange for a spectrum analyzer that should feel grounded.
Now let's compose the full screen. Open ContentView.swift and replace its contents:
import SwiftUI
struct ContentView: View {
@State private var audio = AudioEngine()
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 24) {
VStack(spacing: 4) {
Text(audio.peakNote)
.font(.system(size: 48, weight: .thin, design: .monospaced))
.foregroundStyle(.white)
Text(audio.peakHz > 30 ? String(format: "%.1f Hz", audio.peakHz) : "—")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.gray)
}
.frame(height: 80)
SpectrumView(bars: audio.spectrumBars)
.frame(height: 200)
.padding(.horizontal, 16)
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.white.opacity(0.1))
RoundedRectangle(cornerRadius: 4)
.fill(vuColor(level: audio.level))
.frame(width: geo.size.width * CGFloat(audio.level))
.animation(.easeOut(duration: 0.05), value: audio.level)
}
}
.frame(height: 12)
.padding(.horizontal, 16)
Button(audio.isRunning ? "Stop" : "Start") {
audio.isRunning ? audio.stop() : audio.start()
}
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.black)
.padding(.horizontal, 48)
.padding(.vertical, 12)
.background(audio.isRunning ? Color.red : Color.green)
.clipShape(Capsule())
}
.padding(.vertical, 40)
}
}
func vuColor(level: Float) -> Color {
switch level {
case 0..<0.6: return .green
case 0.6..<0.85: return .yellow
default: return .red
}
}
}
This is the entire screen — every pixel of the app's UI. Let's walk through the composition from top to bottom.
ZStack {
Color.black.ignoresSafeArea()
// ... content ...
}
A ZStack layers views on top of each other, back to front. The first layer is a solid black Color that extends into the safe area (behind the notch and home indicator). Everything else sits on top. This is the standard pattern for full-screen dark backgrounds in SwiftUI. Without .ignoresSafeArea(), you'd see the system background color peeking out at the top and bottom.
VStack(spacing: 4) {
Text(audio.peakNote)
.font(.system(size: 48, weight: .thin, design: .monospaced))
.foregroundStyle(.white)
Text(audio.peakHz > 30 ? String(format: "%.1f Hz", audio.peakHz) : "—")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.gray)
}
.frame(height: 80)
The top section shows the detected note name in large, thin, monospaced type — "A4", "C#3", or an em-dash when no clear pitch is detected. Below it, in smaller gray text, the raw frequency in Hz. The .frame(height: 80) reserves a fixed height so the layout doesn't jump around as the note name changes.
The font specification .system(size: 48, weight: .thin, design: .monospaced) demonstrates SwiftUI's typography API. You can specify size, weight, and design independently. The .thin weight gives the note name an elegant, instrument-like quality. .monospaced ensures character widths are uniform, preventing the text from shifting horizontally when the note changes from, say, "A4" to "G#3".
SwiftUI's font system supports Dynamic Type automatically. If the user has increased their system text size in Settings → Accessibility, fonts specified with semantic sizes like .caption will scale accordingly. Fixed-point sizes like size: 48 don't scale, which is intentional here — the note display has a fixed layout that shouldn't reflow. For text-heavy apps you'd want to use semantic sizes everywhere, but for a data display like ours, mixing fixed and dynamic sizes is fine.
SpectrumView(bars: audio.spectrumBars)
.frame(height: 200)
.padding(.horizontal, 16)
Our reusable SpectrumView component, given 200 points of height and 16 points of horizontal padding. That's it. The component handles everything else internally — bar widths, colors, animation, bottom alignment. This is the payoff of building it as a self-contained component: embedding it is one line.
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.white.opacity(0.1))
RoundedRectangle(cornerRadius: 4)
.fill(vuColor(level: audio.level))
.frame(width: geo.size.width * CGFloat(audio.level))
.animation(.easeOut(duration: 0.05), value: audio.level)
}
}
.frame(height: 12)
.padding(.horizontal, 16)
This is a horizontal VU meter — a thin strip that shows the overall audio level. Two rectangles are layered in a ZStack: the back one is a dim white track (10% opacity), and the front one is a colored fill whose width is proportional to the audio level. At 12 points tall, it's a subtle accent beneath the spectrum bars, not a competing visual element.
The vuColor function changes the fill color based on level: green below 60%, yellow from 60-85%, and red above 85%. This traffic-light pattern is universal in audio — every mixing console, every DAW, every hardware meter uses some variation of green/yellow/red to indicate signal health. Green means "good signal." Yellow means "getting loud." Red means "clipping is imminent."
The animation duration here is 50ms, faster than the spectrum bars' 80ms. The VU meter should feel snappy and immediate — it's tracking overall level, not frequency detail, and users expect level meters to react almost instantly.
Button(audio.isRunning ? "Stop" : "Start") {
audio.isRunning ? audio.stop() : audio.start()
}
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.black)
.padding(.horizontal, 48)
.padding(.vertical, 12)
.background(audio.isRunning ? Color.red : Color.green)
.clipShape(Capsule())
A pill-shaped button that toggles the audio engine. The label and background color both change based on state: green "Start" when stopped, red "Stop" when running. The .clipShape(Capsule()) modifier clips the rectangle to a capsule (a rectangle with fully rounded ends). Black text on a colored background ensures readability on both states.
The modifier chain here is worth studying. Each modifier wraps the previous view:
.font sets the text style..foregroundStyle sets the text color..padding adds internal spacing (this determines the button's tap target size)..background fills behind the padded content..clipShape clips the background to the capsule shape.Order matters. If you moved .background before .padding, the color would only fill the text area, not the padded region. The modifier chain reads like a sequence of wrapping operations, inside out.
If the modifier ordering feels confusing, try this mental model: imagine each modifier creates a new box around the previous one. Text("Start") is the innermost box. .padding(.horizontal, 48) adds a larger box with 48 points of space on the sides. .background(Color.green) paints the background of that larger box. .clipShape(Capsule()) trims the corners of the painted box. Visualize the nesting and the order becomes intuitive.
Step back and look at the full ContentView as a layout diagram:
The entire UI is four elements in a vertical stack, centered on a black background. No navigation controllers, no tab bars, no scroll views. Just a clean, focused, single-screen app. The 24-point spacing between elements gives everything room to breathe.
The .padding(.vertical, 40) on the VStack pushes the content away from the screen edges at the top and bottom, while the individual .padding(.horizontal, 16) modifiers on the spectrum and VU strip handle the side margins. The button doesn't need horizontal padding from the screen edge because .padding(.horizontal, 48) on the button itself creates generous internal padding that naturally keeps it away from the edges.
This view uses several different font configurations, so it's a good moment to catalog what's available. SwiftUI provides two ways to specify fonts:
Semantic sizes that adapt to the user's Dynamic Type setting:
.largeTitle, .title, .title2, .title3.headline, .subheadline.body (the default).callout, .caption, .caption2.footnoteExplicit sizes with full control via .system(size:weight:design:):
size — point size (does not scale with Dynamic Type)weight — .ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .blackdesign — .default (San Francisco), .monospaced (SF Mono), .rounded (SF Rounded), .serif (New York)Our app uses .monospaced extensively because numerical displays should never jitter. When a value changes from "440.0" to "1200.5", every digit occupies the same width in a monospaced font, so the text stays perfectly still and only the characters change. With a proportional font, the text would shift left and right with every update — visually distracting for a real-time display.
Hit Cmd+R. You should see a black screen with a green "Start" button. Tap it. The microphone activates (you may need to grant permission), and the spectrum bars start bouncing. The VU strip tracks the overall level. The note display shows whatever pitch is dominant.
Try these experiments:
Notice how the entire data flow is reactive. The audio tap fires, the analyzer processes the buffer, the results are published to @Observable properties, and SwiftUI automatically redraws only the views that depend on changed values. You never explicitly told SwiftUI "redraw bar 17." You just updated the data, and the framework figured out the rest. This is the same reactive pattern as WPF data binding, but with less boilerplate and more compiler safety.
Build and run. You now have a working spectrum analyzer with note detection. The screen should show 48 colored bars that bounce in response to sound, a note name and frequency readout at the top, a VU strip below the spectrum, and a Start/Stop button. Try singing a steady note — the peak note display should identify it. Play music and watch the spectrum dance. If all of that is working, congratulations — the hard part is done. Everything from here is polish.