I notice the plan was written to the wrong location -- it was written into the project directory rather than the designated plan file path. The content is complete though. Let me present the full implementation plan here since the file was already written.
Here is the comprehensive implementation plan for the architecture refactoring.
Architecture Refactoring Plan: ProgressionPlayer
Summary of Exploration Findings
I read every source file in the project. Here are the key findings that shape the plan:
Current type relationships:
EnvelopeHandlePlayer (Performer.swift:24) -- monophonic Arrow voice, referenced only within Performer.swift (3 occurrences)
SamplerVoice (Performer.swift:130) -- monophonic sampler voice, referenced only within Performer.swift (3 occurrences)
PolyphonicVoiceGroup (Performer.swift:154) -- referenced in Performer.swift, SyntacticSynth.swift (3 occurrences), and Pattern.swift (3 occurrences) = 8 total
poolVoice -- accessed 61 times in SyntacticSynth.swift (the didSet handlers on ~30 @Observable properties) plus 2 in views
EngineAndVoicePool protocol -- used in SyntacticSynth.swift (definition + conformance) and Sequencer.swift (convenience init)
loadSamplerInstrument -- private to Preset.swift, called once internally
MusicEvent.play() creates PolyphonicVoiceGroup(presets:) inline (Pattern.swift:48, 67)
MusicPattern owns its own preset pool of size 20 with lease/return logic (Pattern.swift:345-442)
Consumer chain:
ProgressionPlayerApp creates SyntacticSynth and puts it in the SwiftUI environment
SongView creates Sequencer(synth: synth, numTracks: 2) and MusicPattern(...)
TheoryView creates Sequencer(synth: synth, numTracks: 2)
VisualizerView uses synth.noteHandler for keyboard playback
- Both views check
synth.poolVoice == nil to disable the Edit button
Step-by-Step Implementation Plan
The plan has 10 steps. Each step leaves the project compilable and runnable.
STEP 1: Rename EnvelopeHandlePlayer to PlayableArrow
Goal: Establish the new naming with zero behavioral change.
File: /Users/glangmead/proj/themusic/ProgressionPlayer/Sources/Tones/Performer.swift
- Line 24: Rename
class EnvelopeHandlePlayer to class PlayableArrow
- Line 169: Change type annotation
EnvelopeHandlePlayer? to PlayableArrow?
- Line 171: Change constructor call
EnvelopeHandlePlayer(arrow: sound) to PlayableArrow(arrow: sound)
No other file references EnvelopeHandlePlayer. This is a pure rename.
Verification: Build project. Only 3 occurrences existed, all in Performer.swift.
STEP 2: Create Sampler class (extract from Preset)
Goal: Create a thin wrapper around AVAudioUnitSampler that owns file loading logic.
New file: Sources/AppleAudio/Sampler.swift
The class Sampler should have:
- Properties:
let node: AVAudioUnitSampler, let fileNames: [String], let bank: UInt8, let program: UInt8
- Constructor creates the
AVAudioUnitSampler node
- Method
loadInstrument() -- the body is moved verbatim from Preset.loadSamplerInstrument (lines 309-338 of Preset.swift). It handles wav/aiff, exs, and sf2 loading.
File: /Users/glangmead/proj/themusic/ProgressionPlayer/Sources/AppleAudio/Preset.swift
- Add stored property
var sampler: Sampler? = nil
- In
init(samplerFilenames:samplerBank:samplerProgram:) (line 215): create a Sampler and store it, keep old fields temporarily for backward compat
- In
wrapInAppleNodes (line 264): change the sampler branch to use self.sampler:} else if let sampler = self.sampler {
self.samplerNode = sampler.node // backward compat
engine.attach([sampler.node])
sampler.loadInstrument()
initialNode = sampler.node
}
Verification: Build and run a sampler-based preset. Preset.samplerNode is still populated so all callers work unchanged.
STEP 3: Create PlayableSampler (replace SamplerVoice)
Goal: Replace SamplerVoice with PlayableSampler wrapping the new Sampler class.
File: /Users/glangmead/proj/themusic/ProgressionPlayer/Sources/Tones/Performer.swift
Add PlayableSampler class after PlayableArrow. It should:
- Conform to
NoteHandler
- Own a
let sampler: Sampler (not AVAudioUnitSampler directly)
- Have
weak var preset: Preset? (same pattern as SamplerVoice)
noteOn calls sampler.node.startNote(...), noteOff calls sampler.node.stopNote(...)
Update PolyphonicVoiceGroup's sampler branch (lines 180-190):
- Change
SamplerVoice? to PlayableSampler?
- Change
SamplerVoice(node: node) to PlayableSampler(sampler: preset.sampler!)
- Change the guard from
guard let node = preset.samplerNode to guard let sampler = preset.sampler
Delete SamplerVoice (lines 130-151) -- it is now fully replaced.
Verification: Build and run sampler presets. PlayableSampler does the same thing SamplerVoice did.
STEP 4: Create PolyphonicArrowPool, delete PolyphonicVoiceGroup
Goal: Split PolyphonicVoiceGroup into PolyphonicArrowPool (Arrow-only) and typealias PolyphonicSamplerPool = PlayableSampler.
File: /Users/glangmead/proj/themusic/ProgressionPlayer/Sources/Tones/Performer.swift
Add PolyphonicArrowPool class:
final class PolyphonicArrowPool: ArrowWithHandles, NoteHandler
- Constructor takes
[Preset], extracts PlayableArrow from each preset's .sound, creates VoiceLedger
super.init(ArrowSum(innerArrs: handles)) followed by withMergeDictsFromArrows(handles) -- same as the Arrow branch of PolyphonicVoiceGroup
noteOn/noteOff -- same ledger-based logic as PolyphonicVoiceGroup
Add: typealias PolyphonicSamplerPool = PlayableSampler
Delete PolyphonicVoiceGroup entirely.
File: /Users/glangmead/proj/themusic/ProgressionPlayer/Sources/Synths/SyntacticSynth.swift
- Line 32: Change
var poolVoice: PolyphonicVoiceGroup? = nil to var poolVoice: PolyphonicArrowPool? = nil
- Add:
var samplerHandler: PlayableSampler? = nil
- Line 31: Change
var noteHandler: NoteHandler? { poolVoice } to var noteHandler: NoteHandler? { poolVoice ?? samplerHandler }
- Line 246 (Arrow branch): Change
PolyphonicVoiceGroup(presets: presets) to PolyphonicArrowPool(presets: presets)
- Line 257 (Sampler branch): Replace
PolyphonicVoiceGroup(presets: presets) with creating a PlayableSampler(sampler: presets[0].sampler!) and assigning to samplerHandler
File: /Users/glangmead/proj/themusic/ProgressionPlayer/Sources/Generators/Pattern.swift
- Line 48: Change
PolyphonicVoiceGroup(presets: presets) to PolyphonicArrowPool(presets: presets)
- Line 67: Change
PolyphonicVoiceGroup(presets: presets) to PlayableSampler(sampler: presets[0].sampler!)
- Line 66: Change
presets[0].samplerNode to presets[0].sampler
Files: SongView.swift (line 55) and TheoryView.swift (line 111):
- Change
.disabled(synth.poolVoice == nil) to .disabled(synth.noteHandler == nil)
Verification: Build and run. All paths tested: Arrow presets work through PolyphonicArrowPool, sampler presets work through PlayableSampler. SyntacticSynth knobs still work because poolVoice is now PolyphonicArrowPool which extends ArrowWithHandles (same dictionaries). MusicEvent still works. Sequencer still works.
STEP 5: Clean up Preset
Goal: Remove redundant sampler fields from Preset now that Sampler owns them.
File: /Users/glangmead/proj/themusic/ProgressionPlayer/Sources/AppleAudio/Preset.swift
- Remove stored properties:
samplerFilenames, samplerProgram, samplerBank
- Convert
samplerNode from stored to computed: var samplerNode: AVAudioUnitSampler? { sampler?.node }
- Simplify
init(samplerFilenames:...) to just create the Sampler and call initEffects()
- Update
wrapInAppleNodes to use sampler directly (no more self.samplerNode = ... assignment)
- Update
detachAppleNodes to include sampler?.node instead of samplerNode
- Delete
private func loadSamplerInstrument(...) entirely
Verification: Build and run sampler presets. The computed samplerNode property returns the same AVAudioUnitSampler that callers expect.
STEP 6: Create SpatialPreset (additive, no existing code changes)
Goal: Introduce SpatialPreset -- a polyphonic pool of Presets with chord-level note management.
New file: Sources/AppleAudio/SpatialPreset.swift
The @Observable class SpatialPreset should have:
let presetSpec: PresetSyntax, let engine: SpatialAudioEngine, let numVoices: Int
private(set) var presets: [Preset] -- the pool
var arrowPool: PolyphonicArrowPool? and var samplerHandler: PlayableSampler?
var noteHandler: NoteHandler? { arrowPool ?? samplerHandler }
var handles: ArrowWithHandles? { arrowPool } -- for parameter editing
init(...) calls setup() which: compiles presets, wraps in Apple nodes, connects to engine, creates the appropriate pool/handler
cleanup() detaches all presets
reload(presetSpec:) calls cleanup then setup
noteOn/noteOff -- delegates to noteHandler
notesOn(_ notes:, independentSpatial:) / notesOff(_ notes:) -- chord-level API using noteOn/noteOff internally
forEachPreset(_ body:) -- for FX parameter changes
This step is purely additive. SpatialPreset is not used by any caller yet.
Verification: Build. SpatialPreset compiles but is unused.
STEP 7: Migrate SyntacticSynth to use SpatialPreset
Goal: SyntacticSynth delegates to SpatialPreset instead of directly managing presets and pools.
File: /Users/glangmead/proj/themusic/ProgressionPlayer/Sources/Synths/SyntacticSynth.swift
Replace internal state:
// Remove:
private var tones = [ArrowWithHandles]()
private var presets = [Preset]()
var poolVoice: PolyphonicArrowPool? = nil
var samplerHandler: PlayableSampler? = nil
// Add:
private(set) var spatialPreset: SpatialPreset? = nil
Add computed properties for backward compat:
private var presets: [Preset] { spatialPreset?.presets ?? [] }
var noteHandler: NoteHandler? { spatialPreset?.noteHandler }
var hasArrowPool: Bool { spatialPreset?.arrowPool != nil }
Critical bulk change: All ~30 didSet handlers that do poolVoice?.namedSomething[...] must change to spatialPreset?.handles?.namedSomething[...]. This is a mechanical find-and-replace of poolVoice? with spatialPreset?.handles?. For example:
// Before:
var ampAttack: CoreFloat = 0 { didSet {
poolVoice?.namedADSREnvelopes["ampEnv"]!.forEach { $0.env.attackTime = ampAttack } }
}
// After:
var ampAttack: CoreFloat = 0 { didSet {
spatialPreset?.handles?.namedADSREnvelopes["ampEnv"]!.forEach { $0.env.attackTime = ampAttack } }
}
Update setup(presetSpec:):
private func setup(presetSpec: PresetSyntax) {
spatialPreset = SpatialPreset(presetSpec: presetSpec, engine: engine, numVoices: numVoices)
// Read initial values using spatialPreset?.handles? instead of poolVoice?
// ... same structure, mechanical replacement
}
Update cleanup():
private func cleanup() {
spatialPreset?.cleanup()
spatialPreset = nil
}
Update views that checked synth.poolVoice == nil to use synth.noteHandler == nil (already done in Step 4).
Verification: Build and run. Test all: preset loading, knob editing, keyboard playing, MIDI file playback, pattern playback. All should work because SpatialPreset internally creates the same PolyphonicArrowPool/PlayableSampler that SyntacticSynth was creating directly.
STEP 8: Refactor Sequencer for multi-track support
Goal: Each AVMusicTrack can target a different NoteHandler.
File: /Users/glangmead/proj/themusic/ProgressionPlayer/Sources/AppleAudio/Sequencer.swift
The key insight: AVMusicTrack.destinationMIDIEndpoint is set per-track. Currently all tracks share one MIDICallbackInstrument. For multi-track routing, we need one MIDICallbackInstrument per distinct NoteHandler.
Add to Sequencer:
private var trackListenerMap: [Int: MIDICallbackInstrument] -- per-track listeners
private var defaultListener: MIDICallbackInstrument? -- fallback for tracks without specific assignment
func setHandler(_ handler: NoteHandler, forTrack trackIndex: Int) -- creates a listener and stores it
private func createListener(for handler: NoteHandler) -> MIDICallbackInstrument -- extracts the callback creation
Keep the existing convenience init init(synth: SyntacticSynth, numTracks:) for backward compat (all tracks go to one handler).
Add new init: init(engine: AVAudioEngine, numTracks: Int, handlers: [Int: NoteHandler], defaultHandler: NoteHandler).
In play(), assign each track's destinationMIDIEndpoint from trackListenerMap[index] or fall back to defaultListener.
Remove EngineAndVoicePool protocol from SyntacticSynth.swift (line 20-23). Update the Sequencer convenience init to take SyntacticSynth directly.
Verification: Build and run. Play a MIDI file -- all tracks route to the default handler as before. The new multi-track API is available but not yet exercised.
STEP 9: Refactor MusicPattern and MusicEvent
Goal: MusicEvent receives a NoteHandler rather than owning presets. MusicPattern uses SpatialPreset. Add MusicPatterns container.
File: /Users/glangmead/proj/themusic/ProgressionPlayer/Sources/Generators/Pattern.swift
MusicEvent changes:
- Remove
var presets: [Preset] field
- Add
let noteHandler: NoteHandler field (passed in by MusicPattern)
- Remove
private(set) var voice: NoteHandler? = nil (replaced by noteHandler)
- In
play(): remove the PolyphonicArrowPool/PlayableSampler creation logic. Use noteHandler directly. For modulation, cast noteHandler as? PolyphonicArrowPool to access namedConsts.
- In
cancel(): use noteHandler directly
- Remove
cleanup closure (no longer needed since MusicPattern doesn't lease/return presets)
MusicPattern changes:
- Replace
var presetSpec: PresetSyntax and var engine: SpatialAudioEngine with let spatialPreset: SpatialPreset
- Remove
presetPool, poolSize, leasePresets, returnPresets entirely
deinit no longer detaches presets (SpatialPreset handles its own lifecycle)
next() creates MusicEvent(noteHandler: spatialPreset.noteHandler!, ...)
play() -- same task group logic
Add MusicPatterns:
actor MusicPatterns {
private var patterns: [MusicPattern] = []
private var playbackTasks: [Task<Void, Error>] = []
func addPattern(_ pattern: MusicPattern)
func playAll() async
func stopAll()
}
File: /Users/glangmead/proj/themusic/ProgressionPlayer/Sources/SongView.swift
Update the "Play Pattern" button to create a SpatialPreset for the pattern and pass it to MusicPattern:
let patternSpatialPreset = SpatialPreset(
presetSpec: synth.presetSpec,
engine: synth.engine,
numVoices: 20
)
musicPattern = MusicPattern(
spatialPreset: patternSpatialPreset,
modulators: [...],
notes: ...,
sustains: ...,
gaps: ...
)
Store a reference to patternSpatialPreset so it can be cleaned up on stop.
Verification: Build and run. Test "Play Pattern" button. Test MIDI playback. Test keyboard. All three paths exercise different parts of the new architecture.
STEP 10: Final cleanup
- Search for any remaining references to deleted types (
PolyphonicVoiceGroup, SamplerVoice, EnvelopeHandlePlayer)
- Remove
EngineAndVoicePool protocol if still present
- Remove
tones array from SyntacticSynth if unused
- Verify
EventUsingArrow still works (its event: MusicEvent? field -- MusicEvent still has notes which is what the closure accesses)
- Run full test suite
- Test all app features end-to-end
Dependency Graph
Step 1 (Rename PlayableArrow) ----\
Step 2 (Create Sampler) -----+---> Step 4 (PolyphonicArrowPool)
Step 3 (PlayableSampler) [needs 2] ----/ |
Step 5 (Clean Preset) [needs 2] v
Step 6 (SpatialPreset) [needs 4]
|
Step 7 (Migrate SyntacticSynth) [needs 4,6]
/ \
Step 8 (Sequencer) Step 9 (MusicPattern) [needs 6,7]
\ /
Step 10 (Cleanup)
Steps 1 and 2 can be done in parallel. Step 5 can be done any time after Step 2.
Critical Integration Points
1. SyntacticSynth didSet handlers (Step 7): There are approximately 30 @Observable properties whose didSet blocks access poolVoice?.namedADSREnvelopes, poolVoice?.namedConsts, poolVoice?.namedBasicOscs, poolVoice?.namedChorusers. Each must be changed to spatialPreset?.handles?.... The force-unwrap (!) after the dictionary lookup is safe because if handles is non-nil, the dictionaries are populated the same way as before.
2. MusicEvent preset ownership removal (Step 9): Currently MusicEvent owns presets: [Preset] and creates a fresh PolyphonicVoiceGroup each time play() is called. The MusicPattern leases presets from a pool and returns them via a cleanup closure. After refactoring, MusicEvent just receives a NoteHandler reference -- the SpatialPreset it points to is long-lived and managed by MusicPattern. This eliminates the lease/return complexity entirely.
3. Sequencer multi-track routing (Step 8): Each AVMusicTrack needs its own MIDICallbackInstrument to route to a different NoteHandler. The MIDICallbackInstrument from AudioKit creates a Core MIDI endpoint, so each creates a distinct endpoint. This is the right approach since AVMusicTrack.destinationMIDIEndpoint takes a single endpoint.
4. Preset.samplerNode transition (Step 5): Changing samplerNode from stored to computed could break code that assigns to it. Inspection shows only wrapInAppleNodes ever assigns samplerNode = AVAudioUnitSampler() (line 265), which gets replaced with sampler.node usage. No external code assigns to it.
5. EventUsingArrow compatibility (Step 9): The EventUsingArrow class stores var event: MusicEvent? and its closure signature is (_ event: MusicEvent, _ t: CoreFloat) -> CoreFloat. In SongView.swift line 113, the closure accesses event.notes[0].note. Since notes remains on MusicEvent after refactoring, this continues to work. The presets field removal does not affect any existing lambda.
Critical Files for Implementation
/Users/glangmead/proj/themusic/ProgressionPlayer/Sources/Tones/Performer.swift - Core refactoring target: rename PlayableArrow, add PlayableSampler, create PolyphonicArrowPool, delete PolyphonicVoiceGroup and SamplerVoice
/Users/glangmead/proj/themusic/ProgressionPlayer/Sources/Synths/SyntacticSynth.swift - Most complex migration: ~30 @Observable property didSet handlers must change from poolVoice to spatialPreset.handles, setup/cleanup rewritten to delegate to SpatialPreset
/Users/glangmead/proj/themusic/ProgressionPlayer/Sources/AppleAudio/Preset.swift - Extract sampler loading to Sampler class, clean up redundant fields, convert samplerNode to computed property
/Users/glangmead/proj/themusic/ProgressionPlayer/Sources/Generators/Pattern.swift - MusicEvent loses preset ownership and gets NoteHandler instead, MusicPattern uses SpatialPreset, add MusicPatterns container
/Users/glangmead/proj/themusic/ProgressionPlayer/Sources/AppleAudio/Sequencer.swift - Multi-track NoteHandler routing with per-track MIDICallbackInstrument creation
agentId: ac380a1 (for resuming to continue this agent's work if needed)