Skip to content

Commit a0c3aa8

Browse files
committed
Decouple Song from MIDI protocol with new MidiCompiler, Song is now a pure data container for musical intent, legacy API preserved with deprecation warnings for backward compatibility
1 parent f932140 commit a0c3aa8

17 files changed

+2321
-920
lines changed

midigen/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@
1010
from .instruments import INSTRUMENT_MAP
1111
from .time_utils import TimeConverter
1212
from .melody import Melody
13+
from .channel_pool import ChannelPool, ChannelExhaustedError
14+
from .compiler import MidiCompiler

midigen/channel_pool.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""
2+
MIDI Channel Pool Manager.
3+
4+
MIDI has 16 channels (0-15). Channel 9 is reserved for percussion
5+
per General MIDI specification. This module manages channel allocation
6+
to prevent channel overflow and ensure correct drum channel usage.
7+
"""
8+
9+
from typing import Set, Dict, Optional
10+
11+
12+
class ChannelExhaustedError(Exception):
13+
"""Raised when no MIDI channels are available for allocation."""
14+
pass
15+
16+
17+
class ChannelPool:
18+
"""
19+
Manages MIDI channel allocation for instruments.
20+
21+
MIDI has 16 channels (0-15). Channel 9 is reserved for percussion
22+
per General MIDI specification. This pool manages melodic channel
23+
allocation and provides the drum channel separately.
24+
25+
Example:
26+
>>> pool = ChannelPool()
27+
>>> piano_ch = pool.allocate("Piano") # Returns 0
28+
>>> bass_ch = pool.allocate("Bass") # Returns 1
29+
>>> drums_ch = pool.allocate_drums() # Returns 9
30+
>>> pool.release("Piano") # Channel 0 available again
31+
"""
32+
33+
DRUM_CHANNEL = 9
34+
MAX_CHANNELS = 16
35+
MELODIC_CHANNELS = set(range(MAX_CHANNELS)) - {DRUM_CHANNEL}
36+
37+
def __init__(self):
38+
"""Initialize the channel pool with all melodic channels available."""
39+
self._available: Set[int] = self.MELODIC_CHANNELS.copy()
40+
self._allocated: Dict[str, int] = {} # instrument_name -> channel
41+
self._drums_allocated: bool = False
42+
43+
def allocate(self, instrument_name: str) -> int:
44+
"""
45+
Allocate a channel for an instrument.
46+
47+
If the instrument has already been allocated a channel, returns
48+
the same channel (idempotent).
49+
50+
Args:
51+
instrument_name: Unique name/identifier for the instrument.
52+
53+
Returns:
54+
Channel number (0-15, excluding 9).
55+
56+
Raises:
57+
ChannelExhaustedError: If all 15 melodic channels are in use.
58+
59+
Example:
60+
>>> pool = ChannelPool()
61+
>>> pool.allocate("Acoustic Grand Piano")
62+
0
63+
>>> pool.allocate("Electric Bass")
64+
1
65+
"""
66+
# Return existing allocation if instrument already has a channel
67+
if instrument_name in self._allocated:
68+
return self._allocated[instrument_name]
69+
70+
if not self._available:
71+
raise ChannelExhaustedError(
72+
f"All {len(self.MELODIC_CHANNELS)} melodic channels exhausted. "
73+
f"MIDI supports max 15 melodic instruments + 1 drum channel. "
74+
f"Currently allocated: {list(self._allocated.keys())}"
75+
)
76+
77+
# Prefer lower channels for predictability
78+
channel = min(self._available)
79+
self._available.remove(channel)
80+
self._allocated[instrument_name] = channel
81+
return channel
82+
83+
def allocate_drums(self) -> int:
84+
"""
85+
Return the drum channel (always 9).
86+
87+
The drum channel is separate from the melodic pool and can be
88+
allocated independently.
89+
90+
Returns:
91+
The drum channel (9).
92+
93+
Example:
94+
>>> pool = ChannelPool()
95+
>>> pool.allocate_drums()
96+
9
97+
"""
98+
self._drums_allocated = True
99+
return self.DRUM_CHANNEL
100+
101+
def release(self, instrument_name: str) -> None:
102+
"""
103+
Release a channel back to the pool.
104+
105+
Args:
106+
instrument_name: The instrument to release.
107+
108+
Note:
109+
Releasing an unallocated instrument is a no-op.
110+
"""
111+
if instrument_name in self._allocated:
112+
channel = self._allocated.pop(instrument_name)
113+
self._available.add(channel)
114+
115+
def release_drums(self) -> None:
116+
"""Release the drum channel."""
117+
self._drums_allocated = False
118+
119+
def get_channel(self, instrument_name: str) -> Optional[int]:
120+
"""
121+
Get the channel allocated to an instrument without allocating.
122+
123+
Args:
124+
instrument_name: The instrument to look up.
125+
126+
Returns:
127+
The channel number, or None if not allocated.
128+
"""
129+
return self._allocated.get(instrument_name)
130+
131+
def is_allocated(self, instrument_name: str) -> bool:
132+
"""Check if an instrument has been allocated a channel."""
133+
return instrument_name in self._allocated
134+
135+
def is_drums_allocated(self) -> bool:
136+
"""Check if the drum channel has been allocated."""
137+
return self._drums_allocated
138+
139+
@property
140+
def available_count(self) -> int:
141+
"""Number of melodic channels still available."""
142+
return len(self._available)
143+
144+
@property
145+
def allocated_count(self) -> int:
146+
"""Number of melodic channels currently allocated."""
147+
return len(self._allocated)
148+
149+
@property
150+
def allocated_instruments(self) -> Dict[str, int]:
151+
"""Copy of the instrument -> channel mapping."""
152+
return self._allocated.copy()
153+
154+
def reset(self) -> None:
155+
"""Reset the pool to initial state (all channels available)."""
156+
self._available = self.MELODIC_CHANNELS.copy()
157+
self._allocated.clear()
158+
self._drums_allocated = False
159+
160+
def __repr__(self) -> str:
161+
return (
162+
f"ChannelPool(available={self.available_count}, "
163+
f"allocated={self.allocated_count}, "
164+
f"drums={'allocated' if self._drums_allocated else 'free'})"
165+
)

midigen/chord.py

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from typing import List
22
from midigen.note import Note
33
from midigen.key import KEY_MAP, Key
4+
from midigen.roman import get_chord_pitches
45
from enum import Enum
5-
import music21
66

77

88
class Chord:
@@ -307,29 +307,35 @@ def from_roman_numerals(
307307
duration: int = 480,
308308
time_per_chord: int = 0
309309
):
310-
m21_key = music21.key.Key(key.name, key.mode)
310+
"""
311+
Create a chord progression from Roman numeral notation.
312+
313+
Args:
314+
key: The key for the progression (e.g., Key("C", "major"))
315+
progression_string: Dash-separated Roman numerals (e.g., "I-V-vi-IV")
316+
octave: Base octave for the chords (default 4)
317+
duration: Duration of each note in ticks (default 480)
318+
time_per_chord: Time between chord starts in ticks (default 0)
319+
320+
Returns:
321+
ChordProgression containing the parsed chords.
322+
"""
311323
roman_numerals = progression_string.split('-')
312324
chords = []
313325
current_time = 0
326+
314327
for rn_str in roman_numerals:
315-
rn = music21.roman.RomanNumeral(rn_str, m21_key)
316-
pitches = rn.pitches
328+
# Use native parser to get MIDI pitches
329+
pitches = get_chord_pitches(key.name, key.mode, rn_str, octave=octave)
330+
317331
notes = []
318-
for i, pitch in enumerate(pitches):
319-
note_name = f"{pitch.nameWithOctave}"
320-
# A simple way to handle octave, might need refinement
321-
note_name_without_octave = ''.join(filter(str.isalpha, pitch.name))
322-
full_note_name = f"{note_name_without_octave}{octave}"
323-
midi_pitch = KEY_MAP.get(full_note_name)
324-
325-
# If the note is not in the current octave, try the next one
326-
if midi_pitch is None:
327-
full_note_name = f"{note_name_without_octave}{octave + 1}"
328-
midi_pitch = KEY_MAP.get(full_note_name)
329-
330-
if midi_pitch:
331-
# All notes in the chord start at the same time (current_time)
332-
notes.append(Note(pitch=midi_pitch, velocity=64, duration=duration, time=current_time))
332+
for midi_pitch in pitches:
333+
notes.append(Note(
334+
pitch=midi_pitch,
335+
velocity=64,
336+
duration=duration,
337+
time=current_time
338+
))
333339

334340
if notes:
335341
chords.append(Chord(notes))

midigen/compiler/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""
2+
Compiler package for transforming musical intent into MIDI protocol.
3+
4+
This package bridges the gap between high-level musical concepts
5+
(songs, sections, progressions) and low-level MIDI implementation.
6+
"""
7+
8+
from .midi_compiler import MidiCompiler
9+
10+
__all__ = ["MidiCompiler"]

0 commit comments

Comments
 (0)