

# Architectural Analysis & Refactoring Instructions for `accompy`

## Current State Assessment

### What's Good ✓

1. **Pattern dataclasses** (`DrumPattern`, `BassPattern`, `CompingPattern`) — clean, immutable data structures
2. **`Score` as a domain model** — provides a unified representation of chord progressions
3. **`ensure_score()`** — good coercion/normalization pattern for flexible input
4. **Dual backend support** (MMA/builtin) — shows awareness of extensibility
5. **Dependency checking with UX guidance** — `print_setup_instructions()` is user-friendly

### What Needs Refactoring ✗

| Issue | Impact |
|-------|--------|
| **Monolithic `accompy.py`** (~1400 lines) | Hard to test, navigate, extend |
| **Tight coupling** — pattern selection, MIDI generation, audio rendering all interwoven | Can't swap components |
| **No protocols/interfaces** | No contracts for extensibility |
| **Pattern registry is a dict constant** | Can't register custom patterns at runtime |
| **Chord-to-notes is hard-coded** | Can't experiment with alternative voicings |
| **File-only output** | Blocks real-time use |
| **SoundFont path discovery is imperative** | Can't inject alternative synth backends |

---

## Refactoring Instructions for Your AI Coding Agent

### Phase 1: Module Decomposition

**Goal**: Split the monolithic `accompy.py` into focused modules.

```
accompy/
├── __init__.py          # Public API: generate_accompaniment, Score, ensure_score
├── base.py              # Core domain models: Score, ChordEvent, AccompanimentConfig
├── patterns/
│   ├── __init__.py      # Pattern registry, get_patterns()
│   ├── dataclasses.py   # DrumPattern, BassPattern, CompingPattern, DrumHit, NoteEvent
│   └── builtin.py       # All SWING_*, BOSSA_*, etc. constants
├── chord_resolution.py  # ChordResolver protocol, tonal implementation
├── renderers/
│   ├── __init__.py      # Renderer protocol, get_renderer()
│   ├── midi.py          # MIDI event generation (no file I/O)
│   └── mma.py           # MMA backend
├── synthesis/
│   ├── __init__.py      # Synthesizer protocol
│   ├── fluidsynth.py    # FluidSynth rendering
│   └── pyo_synth.py     # (Future) Real-time synthesis via Pyo
├── util.py              # Chord normalization, time parsing helpers
├── setup_utils.py       # Dependency checking, setup wizard
└── cli.py               # CLI entry point
```

**Instructions for agent**:
1. Move `DrumHit`, `NoteEvent`, `DrumPattern`, `BassPattern`, `CompingPattern` to `patterns/dataclasses.py`
2. Move all `*_DRUMS`, `*_BASS`, `*_COMP` constants to `patterns/builtin.py`
3. Move `Score`, `ChordEvent`, `AccompanimentConfig` to `base.py`
4. Move `_normalize_chord_symbol`, `_parse_chord_string`, `_parse_time_sig` to `util.py`
5. Move `check_dependencies`, `print_setup_instructions` to `setup_utils.py`
6. Keep `generate_accompaniment` in `__init__.py` as the main facade

---

### Phase 2: Define Protocols (Interfaces)

**Goal**: Create explicit contracts for extensibility using `typing.Protocol`.

Create `accompy/protocols.py`:

```python
"""Protocols defining extensibility contracts."""
from typing import Protocol, Iterator, runtime_checkable
from .base import Score, AccompanimentConfig

@runtime_checkable
class ChordResolver(Protocol):
    """Convert chord symbols to MIDI notes."""
    def __call__(self, symbol: str) -> list[int]: ...

@runtime_checkable  
class PatternSource(Protocol):
    """Provides patterns for a given style."""
    def get_patterns(self, style: str) -> dict[str, list]: ...
    def available_styles(self) -> list[str]: ...

@runtime_checkable
class MidiEventGenerator(Protocol):
    """Generates MIDI events from score + patterns."""
    def generate(
        self, 
        score: Score, 
        config: AccompanimentConfig,
        *,
        chord_resolver: ChordResolver,
        pattern_source: PatternSource,
    ) -> Iterator[MidiEvent]: ...

@runtime_checkable
class AudioRenderer(Protocol):
    """Renders MIDI events to audio."""
    def render(
        self, 
        events: Iterator[MidiEvent], 
        config: AccompanimentConfig,
    ) -> bytes: ...  # PCM audio data
    
    def render_to_file(
        self,
        events: Iterator[MidiEvent],
        config: AccompanimentConfig,
        output_path: Path,
    ) -> Path: ...
```

**Instructions for agent**:
1. Create `protocols.py` with the protocols above
2. Add `MidiEvent` dataclass to `base.py` (time, channel, note, velocity, duration)
3. Make existing implementations conform to these protocols

---

### Phase 3: Pattern Registry (Mapping Interface)

**Goal**: Replace the static `DRUM_PATTERNS` dict with a `MutableMapping` that supports runtime registration.

Create `accompy/patterns/__init__.py`:

```python
"""Pattern registry with runtime registration."""
from collections.abc import MutableMapping
from typing import Iterator

class PatternRegistry(MutableMapping[str, dict]):
    """
    Registry of accompaniment patterns by style.
    
    Implements MutableMapping for intuitive access:
        registry['bossa']  # Get patterns
        registry['my_style'] = {...}  # Register custom
        del registry['my_style']  # Remove
        
    Example:
        >>> registry = PatternRegistry()
        >>> registry.register_builtin_patterns()
        >>> 'swing' in registry
        True
        >>> registry['swing']['drums'][0].name
        'swing_basic'
    """
    
    def __init__(self):
        self._patterns: dict[str, dict] = {}
        
    def __getitem__(self, style: str) -> dict:
        if style not in self._patterns:
            raise KeyError(f"Unknown style: {style}. Available: {list(self._patterns)}")
        return self._patterns[style]
    
    def __setitem__(self, style: str, patterns: dict) -> None:
        # Validate structure
        required_keys = {'drums', 'bass', 'comp'}
        if not required_keys.issubset(patterns.keys()):
            raise ValueError(f"Pattern dict must have keys: {required_keys}")
        self._patterns[style] = patterns
        
    def __delitem__(self, style: str) -> None:
        del self._patterns[style]
        
    def __iter__(self) -> Iterator[str]:
        return iter(self._patterns)
        
    def __len__(self) -> int:
        return len(self._patterns)
    
    def register_builtin_patterns(self) -> None:
        """Load all built-in patterns."""
        from .builtin import DRUM_PATTERNS, BASS_PATTERNS, COMP_PATTERNS
        for style in DRUM_PATTERNS:
            self._patterns[style] = {
                'drums': DRUM_PATTERNS.get(style, []),
                'bass': BASS_PATTERNS.get(style, []),
                'comp': COMP_PATTERNS.get(style, []),
            }

# Global registry (lazy-loaded)
_registry: PatternRegistry | None = None

def get_pattern_registry() -> PatternRegistry:
    """Get the global pattern registry, initializing if needed."""
    global _registry
    if _registry is None:
        _registry = PatternRegistry()
        _registry.register_builtin_patterns()
    return _registry

def get_patterns(style: str) -> dict:
    """Convenience function for getting patterns."""
    return get_pattern_registry()[style]
```

**Instructions for agent**:
1. Replace `get_patterns()` in `accompy/patterns/__init__.py` with the registry pattern above
2. Prefer a clean public API over backward compatibility
3. Add `register_style()` convenience function for users

---

### Phase 4: Chord Resolution with Dependency Injection

**Goal**: Make chord-to-notes pluggable.

Create `accompy/chord_resolution.py`:

```python
"""Chord symbol to MIDI notes resolution."""
from typing import Callable

# Type alias for chord resolvers
ChordResolver = Callable[[str], list[int]]

def tonal_resolver(symbol: str, *, transpose: int = -12) -> list[int]:
    """Resolve using tonal package (default)."""
    from tonal.chords import chord_to_notes
    notes = list(chord_to_notes(symbol))
    return [n + transpose for n in notes] if transpose else notes

def music21_resolver(symbol: str, *, transpose: int = -12) -> list[int]:
    """Resolve using music21 (alternative)."""
    from music21 import harmony
    ch = harmony.ChordSymbol(symbol)
    notes = [p.midi for p in ch.pitches]
    return [n + transpose for n in notes] if transpose else notes

# Default resolver (configurable)
_default_resolver: ChordResolver = tonal_resolver

def get_chord_resolver() -> ChordResolver:
    """Get the current default chord resolver."""
    return _default_resolver

def set_chord_resolver(resolver: ChordResolver) -> None:
    """Set the default chord resolver."""
    global _default_resolver
    _default_resolver = resolver

def chord_to_notes(symbol: str) -> list[int]:
    """Convert chord symbol to MIDI notes using current resolver."""
    return _default_resolver(symbol)
```

**Instructions for agent**:
1. Create this module
2. Update `_chord_to_notes()` in the main code to use `chord_to_notes()` from this module
3. Add `chord_resolver` parameter to `generate_accompaniment()` for per-call override:
   ```python
   def generate_accompaniment(
       chords: Any,
       *,
       chord_resolver: ChordResolver | None = None,
       ...
   )
   ```

---

### Phase 5: Event-Based MIDI Generation (Real-Time Ready)

**Goal**: Separate event generation from file writing to enable streaming.

Create `accompy/renderers/midi.py`:

```python
"""MIDI event generation (stateless, iterator-based)."""
from dataclasses import dataclass
from typing import Iterator, Sequence
from ..base import Score, AccompanimentConfig
from ..patterns.dataclasses import DrumPattern, BassPattern, CompingPattern

@dataclass(frozen=True)
class MidiEvent:
    """A single MIDI event."""
    time: float        # In beats
    channel: int
    note: int
    velocity: int
    duration: float
    
def generate_midi_events(
    score: Score,
    config: AccompanimentConfig,
    *,
    pattern_source: 'PatternSource',
    chord_resolver: 'ChordResolver',
) -> Iterator[MidiEvent]:
    """
    Generate MIDI events from score.
    
    Yields events in time order. Does NOT write files.
    This enables real-time streaming or batch file creation.
    
    Example:
        >>> events = generate_midi_events(score, config, ...)
        >>> for event in events:
        ...     send_to_synth(event)  # Real-time
    """
    patterns = pattern_source.get_patterns(config.style)
    beats_per_bar = score.time_signature[0]
    
    for repeat in range(config.repeats):
        current_beat = repeat * len(score.measures) * beats_per_bar
        
        for measure in score.measures:
            for chord_idx, chord_symbol in enumerate(measure):
                if chord_symbol in ('', 'n', 'N.C.', 'NC'):
                    continue
                    
                chord_start = current_beat + (chord_idx * (beats_per_bar // len(measure)))
                notes = chord_resolver(chord_symbol)
                
                # Yield drum events
                yield from _drum_events(patterns['drums'], chord_start, config)
                
                # Yield bass events  
                yield from _bass_events(patterns['bass'], chord_start, notes, config)
                
                # Yield comp events
                yield from _comp_events(patterns['comp'], chord_start, notes, config)
                
            current_beat += beats_per_bar

def events_to_midi_file(events: Sequence[MidiEvent], path: Path, tempo: int) -> Path:
    """Write events to a MIDI file."""
    from midiutil import MIDIFile
    # ... implementation
```

**Instructions for agent**:
1. Refactor `_generate_builtin()` to use this event-based approach
2. Split into `generate_midi_events()` (pure, yields events) and `events_to_midi_file()` (side effect)
3. Keep existing file-based API working by composing these

---

### Phase 6: Synthesis Backend Abstraction

**Goal**: Abstract audio synthesis to support multiple backends.

Create `accompy/synthesis/__init__.py`:

```python
"""Audio synthesis backends."""
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Sequence
from ..renderers.midi import MidiEvent

class SynthesizerBackend(ABC):
    """Abstract base for audio synthesis."""
    
    @abstractmethod
    def render_to_file(
        self, 
        events: Sequence[MidiEvent], 
        output_path: Path,
        *,
        tempo: int,
        sample_rate: int = 44100,
    ) -> Path:
        """Render MIDI events to an audio file."""
        ...
    
    @abstractmethod
    def render_to_bytes(
        self,
        events: Sequence[MidiEvent],
        *,
        tempo: int,
        sample_rate: int = 44100,
    ) -> bytes:
        """Render MIDI events to PCM bytes (for streaming)."""
        ...
    
    @classmethod
    @abstractmethod
    def is_available(cls) -> bool:
        """Check if this backend's dependencies are installed."""
        ...

class FluidSynthBackend(SynthesizerBackend):
    """FluidSynth-based synthesis."""
    
    def __init__(self, soundfont_path: Path | None = None):
        self.soundfont_path = soundfont_path or _find_default_soundfont()
        
    # ... implementation

class PyoBackend(SynthesizerBackend):
    """Pyo-based real-time synthesis (integrates with hum package)."""
    
    def __init__(self, synth_func=None):
        # Could use hum.Synth here!
        ...

def get_default_backend() -> SynthesizerBackend:
    """Get the best available synthesis backend."""
    if FluidSynthBackend.is_available():
        return FluidSynthBackend()
    raise RuntimeError("No synthesis backend available")
```

**Instructions for agent**:
1. Create this module structure
2. Move FluidSynth logic from `_render_midi_to_audio()` into `FluidSynthBackend`
3. Add `synthesis_backend` parameter to `generate_accompaniment()`:
   ```python
   def generate_accompaniment(
       ...,
       synthesis_backend: SynthesizerBackend | None = None,
   )
   ```

---

### Phase 7: Configuration as First-Class Citizen

**Goal**: Make `AccompanimentConfig` the single source of truth with sensible defaults.

Update `accompy/base.py`:

```python
@dataclass
class AccompanimentConfig:
    """Configuration for accompaniment generation."""
    
    # Core settings
    style: str = "swing"
    tempo: int = 120
    repeats: int = 2
    time_signature: tuple[int, int] = (4, 4)
    
    # Instrument selection (use frozenset for immutability)
    enabled_instruments: frozenset[str] = field(
        default_factory=lambda: frozenset({'drums', 'bass', 'piano'})
    )
    
    # Per-instrument volumes (0.0-1.0)
    volumes: Mapping[str, float] = field(
        default_factory=lambda: MappingProxyType({
            'drums': 0.8, 'bass': 0.9, 'piano': 0.7
        })
    )
    
    # Synthesis settings
    soundfont: Path | None = None
    sample_rate: int = 44100
    output_format: Literal['wav', 'mp3', 'flac', 'midi'] = 'wav'
    
    # Extensibility hooks (dependency injection)
    chord_resolver: 'ChordResolver | None' = None
    pattern_source: 'PatternSource | None' = None
    synthesis_backend: 'SynthesizerBackend | None' = None
    
    def with_overrides(self, **kwargs) -> 'AccompanimentConfig':
        """Create a new config with specified overrides."""
        from dataclasses import replace
        return replace(self, **kwargs)
```

**Instructions for agent**:
1. Refactor `AccompanimentConfig` to include DI hooks
2. Add `with_overrides()` method for immutable updates
3. Update `generate_accompaniment()` to use config's DI hooks as defaults

---

### Phase 8: Real-Time Foundation

**Goal**: Add streaming support for future real-time use.

Create `accompy/realtime.py`:

```python
"""Real-time accompaniment support (foundation)."""
from typing import Iterator, Callable
from .base import Score, AccompanimentConfig
from .renderers.midi import MidiEvent, generate_midi_events

class RealtimeAccompaniment:
    """
    Real-time accompaniment player (foundation for future work).
    
    This class separates event scheduling from synthesis,
    enabling integration with real-time audio systems.
    
    Example (future usage):
        >>> player = RealtimeAccompaniment(config)
        >>> player.set_chords([('Dm7', 4), ('G7', 4), ('Cmaj7', 8)])
        >>> for event in player.events():
        ...     synth.play(event.note, event.velocity)
    """
    
    def __init__(
        self, 
        config: AccompanimentConfig | None = None,
        *,
        on_event: Callable[[MidiEvent], None] | None = None,
    ):
        self.config = config or AccompanimentConfig()
        self._on_event = on_event
        self._score: Score | None = None
        
    def set_chords(self, chords) -> None:
        """Update the chord progression."""
        from .base import ensure_score
        self._score = ensure_score(chords)
        
    def events(self) -> Iterator[MidiEvent]:
        """Generate events for current chord progression."""
        if self._score is None:
            return
        yield from generate_midi_events(
            self._score, 
            self.config,
            pattern_source=self.config.pattern_source or get_pattern_registry(),
            chord_resolver=self.config.chord_resolver or get_chord_resolver(),
        )
```

**Instructions for agent**:
1. Create this foundation module
2. Keep it minimal — this is scaffolding for future `hum`/`pyo` integration
3. Add example in docstring showing intended usage

---

### Phase 9: API Stabilization

**Goal**: Keep the public API small and consistent.

Update `accompy/__init__.py`:

```python
"""
accompy - Generate accompaniment audio from chord charts.

Example:
    >>> from accompy import generate_accompaniment
    >>> path = generate_accompaniment("| C | Am | F | G |", style="bossa")
"""

# Public API
from .base import Score, AccompanimentConfig, ensure_score
from .main import generate_accompaniment, play_audio

# Pattern access
from .patterns import get_patterns, get_pattern_registry

# Advanced/extensibility API
from .chord_resolution import chord_to_notes, get_chord_resolver, set_chord_resolver
from .protocols import ChordResolver, PatternSource, AudioRenderer

# Setup utilities
from .setup_utils import check_dependencies, print_setup_instructions

__all__ = [
    # Main API
    'generate_accompaniment',
    'play_audio',
    'Score',
    'AccompanimentConfig', 
    'ensure_score',
    # Patterns
    'get_patterns',
    'get_pattern_registry',
    # Chord resolution
    'chord_to_notes',
    # Setup
    'check_dependencies',
    'print_setup_instructions',
]
```

---

## Summary: Key Architectural Changes

| Before | After |
|--------|-------|
| Monolithic 1400-line file | ~10 focused modules |
| Static pattern dicts | `PatternRegistry(MutableMapping)` |
| Hard-coded chord resolver | Pluggable `ChordResolver` protocol |
| File-only output | Event iterator + optional file I/O |
| Single FluidSynth path | `SynthesizerBackend` abstraction |
| Config scattered across args | `AccompanimentConfig` with DI hooks |

## Extensibility Examples After Refactoring

```python
# Register custom style
registry = get_pattern_registry()
registry['my_funk'] = {
    'drums': [my_custom_drum_pattern],
    'bass': [my_custom_bass_pattern],
    'comp': [],
}

# Use alternative chord voicing
from accompy import generate_accompaniment, set_chord_resolver
set_chord_resolver(my_jazz_voicing_resolver)
generate_accompaniment("| Dm7 | G7 |", style="swing")

# Stream events for real-time (future)
from accompy.realtime import RealtimeAccompaniment
player = RealtimeAccompaniment()
player.set_chords([('Cmaj7', 4), ('Fmaj7', 4)])
for event in player.events():
    my_synth.send_note(event)
```

---

The key principles:
1. **Protocol-first** — define interfaces before implementations
2. **Iterator-based events** — enables both batch and real-time
3. **MutableMapping for registries** — Pythonic, extensible
4. **Dependency injection via config** — swap components without changing call sites
5. **Simplicity** — prefer fewer public entry points


Remember to:
* Read misc/docs/packages_summaries.md, and make sure you use the packages mentioned therein when appropriate -- or mention them as comments in your code (telling the user to check a refactor opportunity out, etc.). These should be pip installable, or you can check the code out in `https://github.com/thorwhalen/PKG_NAME`
* Check out misc/docs/extra_resources.md and misc/docs/accompaniment systems - research.md, where I've gathered some research and resources on the subject.
* Test what can be tested (some dependencies might be hard to install so either test with simpler alternatives or mocks, or do not test at all if it looks too complex to set up)
* once you are done, read the md files (README.md, plus what's in misc/docs/) and make sure you update them given the new code (delete what is obsolete and add what is missing)
* update misc/accompy_demo.ipynb accordingly too
