Building an audio sequencer to arrange and play clips
At a glance
| Item |
Summary |
| Purpose |
Imports clips, detects tempo, and launches synchronized scenes through a multitrack audio graph. |
| App architecture |
SwiftUI MVVM over a controller, actor-isolated loader, and main-actor engine. |
| Main patterns |
MVVM, orchestration layer, AsyncStream events, actor boundary, extension-based responsibility split. |
| Project style |
Feature folders for Views, ViewModel, Controller, Models, and Audio; engine behavior is split across extensions. |
Project structure
ClipLauncher/ClipLauncher/
├── ClipLauncherApp.swift
├── Views/
│ ├── ContentView.swift
│ ├── Grid/
│ └── Controls/
├── ViewModel/ClipLauncherViewModel.swift
├── Controller/ClipLauncherController.swift
├── Models/{AudioClip,ClipTrack,ClipScene}.swift
└── Audio/
├── ClipEngine.swift
├── ClipEngine+Playback.swift
├── ClipEngine+Transport.swift
├── ClipEngine+Synchronization.swift
├── ClipEngine+Metering.swift
└── ClipLoader.swift
Structure observations
- Folder names describe architectural layers; small UI controls are further grouped by role.
ClipEngine stores shared engine state while same-type extensions separate playback, transport, synchronization, and metering.
Overall architecture
flowchart LR
Views["SwiftUI views"] --> VM["ClipLauncherViewModel"]
VM --> Controller["ClipLauncherController"]
Controller --> Loader["ClipLoader actor"]
Controller --> Engine["ClipEngine @MainActor"]
Loader --> Model["AudioClip Sendable"]
Model --> Engine
Engine --> Graph["Player → Varispeed → Track mixer → Main mixer"]
Controller -. AsyncStream events .-> VM
Reference code
ClipLauncher/ClipLauncher/Controller/ClipLauncherController.swift:125 — the controller crosses the loader boundary, updates the engine, then emits a UI event.
func loadClip(from url: URL, into trackID: ClipEngine.TrackID, sceneID: UUID) async {
do {
let result = try await loader.load(from: url)
engine.ensureTrack(trackID)
engine.loadClip(result.clip, into: trackID)
emit(.clipLoaded(trackID: trackID, sceneID: sceneID, clip: result.clip))
} catch {
emit(.clipLoadFailed(trackID: trackID, sceneID: sceneID, error: error.localizedDescription))
}
}
Interpretation
The view model owns presentation state and exposes user actions. The controller coordinates cross-component operations. File I/O and analysis are isolated in an actor; graph mutation remains on the main actor.
Ownership and state
classDiagram
ContentView *-- ClipLauncherViewModel : State owns
ClipLauncherViewModel *-- ClipLauncherController : owns
ClipLauncherController *-- ClipLoader : owns
ClipLauncherController *-- ClipEngine : owns
ClipEngine *-- AVAudioEngine : owns
ClipEngine *-- LoadedClip : retains per track
ClipLauncherViewModel *-- ClipTrack : UI state
Ownership evidence
ClipLauncher/ClipLauncher/ViewModel/ClipLauncherViewModel.swift:52 — the view model owns UI state while hiding its controller and tasks.
private(set) var tracks: [ClipTrack] = []
private(set) var mainTempo: Double = Constants.BPM.defaultTempo
@ObservationIgnored private let controller = ClipLauncherController()
@ObservationIgnored private var eventTask: Task<Void, Never>?
| Owner |
Object or state |
Relationship |
Mutation authority |
ContentView |
ClipLauncherViewModel |
Creates with @State |
View model |
ClipLauncherViewModel |
Tracks, scene flags, tempo, volume |
Authoritative UI state |
View model action/event handlers |
ClipLauncherController |
Engine, loader, event continuation, track mix state |
Creates and coordinates |
Controller |
ClipEngine |
AVAudioEngine graph and loaded-node maps |
Creates and retains |
Main-actor engine methods/extensions |
ClipLoader |
Temporary file/analysis work |
Actor-owned operation state |
Actor |
Class and protocol design
| Type |
Responsibility |
Depends on |
ClipLauncherViewModel |
Presentable state, action wrappers, event reduction |
ClipLauncherController |
ClipLauncherController |
Orchestrate loading, engine commands, and domain events |
ClipLoader, ClipEngine |
ClipLoader |
Read buffers, detect rhythm, extract waveform data |
AVFAudio, MusicUnderstanding |
ClipEngine |
Build and mutate the multitrack graph |
AVAudioEngine nodes |
AudioClip / ClipTrack / ClipScene |
Transfer audio and represent UI/domain state |
Value types and stable IDs |
The main abstraction boundary is actor isolation and the controller API; the sample does not introduce an app-defined service protocol.
Access control
| Symbol |
Access |
Verified effect |
Likely rationale |
tracks, mainTempo |
private(set) |
Views observe values but cannot replace canonical state. |
Preserve event-reduction invariants. |
| View-model controller/tasks |
private plus @ObservationIgnored |
Views cannot issue low-level commands; observation skips infrastructure. |
Keep the observable surface meaningful. |
| Controller engine/loader/continuation |
private |
All cross-service coordination enters through controller methods. |
Enforce the orchestration boundary. |
ClipEngine stored properties |
implicit internal |
Same-type extensions in separate files can share them. |
Swift private would block these cross-file extensions. |
| App types |
implicit internal |
No external module API. |
Application-only architecture. |
Reference code
ClipLauncher/ClipLauncher/Audio/ClipEngine.swift:38 — engine internals deliberately remain module-visible for cross-file extensions.
// MARK: - Internal Implementation
let engine = AVAudioEngine()
let mixer: AVAudioMixerNode
var trackMixers: [TrackID: AVAudioMixerNode] = [:]
var clips: [TrackID: [UUID: LoadedClip]] = [:]
var launchQueue: [TrackID: AudioClip] = [:]
Logic ownership and placement
| Logic |
Owning type or file |
Placement rationale |
| View rendering and bindings |
Views/ |
Presentation-only layer. |
| UI state reduction and action mapping |
ClipLauncherViewModel |
Single observable authority. |
| Cross-service workflows |
ClipLauncherController |
Keeps the view model thin and engine focused. |
| File I/O and tempo analysis |
ClipLoader |
Actor isolates expensive asynchronous work. |
| Graph construction |
ClipEngine.swift |
Core node ownership. |
| Playback/transport/metering |
ClipEngine+*.swift |
Responsibility-focused extensions over shared engine state. |
Design patterns
| Pattern |
Source evidence |
Purpose or tradeoff |
| MVVM |
ClipLauncher/ClipLauncher/Views/ContentView.swift:11 and ClipLauncher/ClipLauncher/ViewModel/ClipLauncherViewModel.swift:39 |
Separates SwiftUI rendering from state and actions. |
| Controller/service layer |
ClipLauncher/ClipLauncher/Controller/ClipLauncherController.swift:43 |
Coordinates loader and engine without pushing workflow into the view model. |
| Async event stream |
ClipLauncher/ClipLauncher/Controller/ClipLauncherController.swift:69 |
Sends engine/load results back through a typed, buffered channel. |
| Actor-isolated loader |
ClipLauncher/ClipLauncher/Audio/ClipLoader.swift:21 |
Makes file and analysis work safe across concurrency boundaries. |
| Extension-based decomposition |
ClipLauncher/ClipLauncher/Audio/ClipEngine.swift:38 |
Splits a cohesive engine by behavior while sharing state. |
Main application flow
sequenceDiagram
actor User
participant VM as ViewModel
participant C as Controller
participant E as ClipEngine
User->>VM: Launch scene
loop each populated track
VM->>C: queueClip(track, clipID)
C->>E: queue clip
end
VM->>C: flushLaunchQueue()
C->>E: schedule common quantized start
E-->>C: launch callbacks
C-->>VM: AsyncStream clipStarted events
Reference code
ClipLauncher/ClipLauncher/ViewModel/ClipLauncherViewModel.swift:228 — scene launch batches commands before one synchronized flush.
for tIndex in tracks.indices {
guard let clip = tracks[tIndex].scenes[sceneIndex].clip else { continue }
try? controller.queueClip(track: tracks[tIndex].id, clipID: clip.id)
}
controller.flushLaunchQueue()
Naming conventions
- Layer roles appear as suffixes:
View, ViewModel, Controller, Engine, Loader.
- Commands use direct verbs:
importClip, queueClip, flushLaunchQueue, stopAll.
- State names use domain language:
ClipTrack, ClipScene, launchQueue, beatCounter.
- Engine extension filenames name their behavior:
+Playback, +Transport, +Synchronization, +Metering.
Architecture takeaways
- Give observable UI state, orchestration, I/O, and graph mutation distinct owners.
- Use a typed event stream when lower layers must report asynchronous domain events upward.
- Keep a single actor in charge of AVAudioEngine graph state while moving file analysis to another actor.
- Cross-file extensions can justify internal engine state; document that boundary rather than marking everything private.
- Batch scene commands, then commit once, when synchronization is the business invariant.
Source map
| Source file |
Relevant symbols |
ClipLauncher/ClipLauncher/Views/ContentView.swift |
Root view and view-model ownership |
ClipLauncher/ClipLauncher/ViewModel/ClipLauncherViewModel.swift |
UI state, actions, event consumption |
ClipLauncher/ClipLauncher/Controller/ClipLauncherController.swift |
Orchestration and Event stream |
ClipLauncher/ClipLauncher/Audio/ClipEngine.swift |
Engine state and graph construction |
ClipLauncher/ClipLauncher/Audio/ClipLoader.swift |
Actor-isolated loading and analysis |
ClipLauncher/ClipLauncher/Models/AudioClip.swift |
Sendable clip model |