Files
luma/Luma/PatternSoundView.swift
Ole André Vadla Ravnås f3e36dbeba ui: Visualize and navigate decoded patterns
Show detached visualizers in popovers and inline ones in place. Move
through the tree with the keyboard, and scroll the hex view and the
tree separately.
2026-10-01 20:49:11 +02:00

161 lines
5.9 KiB
Swift

import AVFoundation
import LumaCore
import SwiftUI
struct PatternSoundView: View {
let sound: PatternVisualization.Sound
@State private var playback = SoundPlayback()
private let waveform: [[ClosedRange<Float>]]
private static let width: CGFloat = 600
private static let height: CGFloat = 150
init(sound: PatternVisualization.Sound) {
self.sound = sound
waveform = Self.envelope(of: sound, columns: Int(Self.width))
}
private var frameCount: Int { sound.samples.count / sound.channels }
private var duration: Double { Double(frameCount) / Double(sound.sampleRate) }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
TimelineView(.animation(paused: playback.startedAt == nil)) { context in
Canvas { canvas, size in
drawWaveform(in: &canvas, size: size)
if let startedAt = playback.startedAt {
let progress = min(context.date.timeIntervalSince(startedAt) / duration, 1)
let x = size.width * progress
canvas.stroke(
Path { $0.addLines([CGPoint(x: x, y: 0), CGPoint(x: x, y: size.height)]) }, with: .color(.primary), lineWidth: 1
)
}
}
}
.frame(width: Self.width, height: Self.height)
HStack(spacing: 10) {
Button {
if playback.startedAt == nil {
playback.play(sound)
} else {
playback.stop()
}
} label: {
Image(systemName: playback.startedAt == nil ? "play.fill" : "stop.fill")
}
.disabled(frameCount == 0)
Text("\(sound.channels) ch · \(sound.sampleRate) Hz · \(duration.formatted(.number.precision(.fractionLength(2)))) s")
.foregroundStyle(.secondary)
if let problem = playback.problem {
Text(problem)
.foregroundStyle(.red)
}
}
}
.onDisappear { playback.stop() }
}
private func drawWaveform(in canvas: inout GraphicsContext, size: CGSize) {
let laneHeight = size.height / CGFloat(max(waveform.count, 1))
for (channel, columns) in waveform.enumerated() {
let middle = laneHeight * (CGFloat(channel) + 0.5)
var path = Path()
let columnWidth = size.width / CGFloat(max(columns.count, 1))
for (column, range) in columns.enumerated() {
let x = (CGFloat(column) + 0.5) * columnWidth
path.move(to: CGPoint(x: x, y: middle - CGFloat(range.upperBound) * laneHeight / 2))
path.addLine(to: CGPoint(x: x, y: middle - CGFloat(range.lowerBound) * laneHeight / 2 + 0.5))
}
canvas.stroke(path, with: .color(.fridaBrand), lineWidth: 1)
}
}
private static func envelope(of sound: PatternVisualization.Sound, columns: Int) -> [[ClosedRange<Float>]] {
let frames = sound.samples.count / sound.channels
guard frames > 0 else { return [] }
return (0..<sound.channels).map { channel in
func sample(_ frame: Int) -> Float {
Float(sound.samples[frame * sound.channels + channel]) / 32768
}
var previous = sample(0)
return (0..<min(columns, frames)).map { column in
let first = column * frames / columns
let end = max(first + 1, (column + 1) * frames / columns)
var low = previous
var high = previous
for frame in first..<end {
previous = sample(frame)
low = min(low, previous)
high = max(high, previous)
}
return low...high
}
}
}
}
@MainActor
@Observable
private final class SoundPlayback {
private(set) var startedAt: Date?
private(set) var problem: String?
private var engine: AVAudioEngine?
private var player: AVAudioPlayerNode?
private var generation = 0
func play(_ sound: PatternVisualization.Sound) {
stop()
let frames = sound.samples.count / sound.channels
guard
let format = AVAudioFormat(
standardFormatWithSampleRate: Double(sound.sampleRate), channels: AVAudioChannelCount(sound.channels)),
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frames))
else {
problem = "Cannot play \(sound.channels) channels at \(sound.sampleRate) Hz."
return
}
buffer.frameLength = AVAudioFrameCount(frames)
let channels = buffer.floatChannelData!
for frame in 0..<frames {
for channel in 0..<sound.channels {
channels[channel][frame] = Float(sound.samples[frame * sound.channels + channel]) / 32768
}
}
let engine = AVAudioEngine()
let player = AVAudioPlayerNode()
engine.attach(player)
engine.connect(player, to: engine.mainMixerNode, format: format)
do {
try engine.start()
} catch {
problem = error.localizedDescription
return
}
generation += 1
let playing = generation
player.scheduleBuffer(buffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in
Task { @MainActor in
guard let self, self.generation == playing else { return }
self.stop()
}
}
player.play()
self.engine = engine
self.player = player
startedAt = .now
problem = nil
}
func stop() {
generation += 1
player?.stop()
engine?.stop()
player = nil
engine = nil
startedAt = nil
}
}