Coverage for src/seqrule/rulesets/music.py: 29%
139 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-27 10:39 -0600
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-27 10:39 -0600
1"""
2Musical sequence rules.
4This module implements sequence rules for musical sequences, with support for:
5- Melodic patterns and phrases
6- Rhythmic patterns and time signatures
7- Note types (melody, harmony, rest)
8- Duration constraints
9- Basic harmony rules
10- Common musical forms
12Common use cases:
13- Validating melodic patterns
14- Checking rhythmic consistency
15- Ensuring proper phrase structure
16- Managing voice leading
17- Creating musical variations
18"""
20from enum import Enum
21from fractions import Fraction
22from typing import Callable, Dict, List, Optional, Union
24from ..core import AbstractObject, Sequence
25from ..dsl import DSLRule, if_then_rule
28class NoteType(Enum):
29 """Types of musical notes."""
31 MELODY = "melody" # Main melodic line
32 HARMONY = "harmony" # Harmonic accompaniment
33 REST = "rest" # Musical rest
34 BASS = "bass" # Bass line
35 PERCUSSION = "percussion" # Rhythmic percussion
38class TimeSignature:
39 """Musical time signature (e.g., 4/4, 3/4, 6/8)."""
41 def __init__(self, beats: int, beat_unit: int):
42 """
43 Initialize a time signature.
45 Args:
46 beats: Number of beats per measure
47 beat_unit: Note value of one beat (4 = quarter, 8 = eighth, etc.)
48 """
49 self.beats = beats
50 self.beat_unit = beat_unit
51 # Convert to quarter notes: multiply by 4/beat_unit to get quarter note duration
52 self.measure_duration = Fraction(
53 beats * 4, beat_unit
54 ) # Duration in quarter notes
56 def __repr__(self) -> str:
57 return f"{self.beats}/{self.beat_unit}"
60class Note(AbstractObject):
61 """
62 A musical note with properties.
64 Properties:
65 pitch: Note pitch (e.g., "C4", "F#5", "rest")
66 duration: Duration in quarter notes (1.0 = quarter, 0.5 = eighth)
67 note_type: Type of note (melody, harmony, rest, etc.)
68 velocity: Note velocity/volume (0-127, MIDI standard)
69 measure: Measure number in the sequence
70 beat: Beat position within the measure
71 """
73 def __init__(
74 self,
75 pitch: str,
76 duration: float,
77 note_type: Union[str, NoteType],
78 velocity: int = 64,
79 measure: Optional[int] = None,
80 beat: Optional[float] = None,
81 ):
82 """Initialize a note with its properties."""
83 if isinstance(note_type, str):
84 note_type = NoteType(note_type)
86 if not (0 <= velocity <= 127):
87 raise ValueError("Velocity must be between 0 and 127")
89 if duration <= 0:
90 raise ValueError("Duration must be positive")
92 super().__init__(
93 pitch=pitch,
94 duration=float(duration),
95 note_type=note_type.value,
96 velocity=velocity,
97 measure=measure,
98 beat=float(beat) if beat is not None else None,
99 )
101 def __repr__(self) -> str:
102 return (
103 f"Note(pitch={self['pitch']}, "
104 f"duration={self['duration']}, "
105 f"type={self['note_type']})"
106 )
109def note_type_is(note_type: Union[str, NoteType]) -> Callable[[AbstractObject], bool]:
110 """Creates a predicate that checks if a note has a specific type."""
111 if isinstance(note_type, str):
112 note_type = NoteType(note_type)
113 return lambda obj: obj["note_type"] == note_type.value
116def note_pitch_is(pitch: str) -> Callable[[AbstractObject], bool]:
117 """Creates a predicate that checks if a note has a specific pitch."""
118 return lambda obj: obj["pitch"] == pitch
121def note_duration_is(duration: float) -> Callable[[AbstractObject], bool]:
122 """Creates a predicate that checks if a note has a specific duration."""
123 return lambda obj: obj["duration"] == duration
126# Basic rules
127rest_followed_by_melody = if_then_rule(
128 note_type_is(NoteType.REST), note_type_is(NoteType.MELODY)
129)
132def create_rhythm_pattern_rule(
133 durations: List[float], allow_consolidation: bool = False
134) -> DSLRule:
135 """
136 Creates a rule requiring notes to follow a specific rhythm pattern.
138 Args:
139 durations: List of note durations (in quarter notes)
140 allow_consolidation: Whether to allow combining consecutive notes
142 Example:
143 waltz = create_rhythm_pattern_rule([1.0, 0.5, 0.5]) # Basic waltz pattern
144 """
146 def check_rhythm(seq: Sequence) -> bool:
147 if not seq:
148 return True
150 # Extract durations from sequence
151 seq_durations = [note["duration"] for note in seq]
153 if not allow_consolidation:
154 if len(seq_durations) != len(durations):
155 return False
156 return all(sd == d for sd, d in zip(seq_durations, durations))
158 # Check if sequence can be consolidated to match pattern
159 total = 0
160 pattern_idx = 0
161 for duration in seq_durations:
162 total += duration
163 if total == durations[pattern_idx]:
164 total = 0
165 pattern_idx += 1
166 if pattern_idx >= len(durations):
167 pattern_idx = 0
168 elif total > durations[pattern_idx]:
169 return False
170 return total == 0 # All durations must be fully consumed
172 return DSLRule(check_rhythm, f"matches rhythm pattern {durations}")
175def create_melody_pattern_rule(pitches: List[str], transpose: bool = False) -> DSLRule:
176 """
177 Creates a rule requiring melody notes to follow a specific pitch pattern.
179 Args:
180 pitches: List of note pitches
181 transpose: Whether to allow transposed versions of the pattern
183 Example:
184 motif = create_melody_pattern_rule(["C4", "E4", "G4"]) # C major arpeggio
185 """
187 def get_intervals(notes: List[str]) -> List[int]:
188 """Convert pitch sequence to intervals."""
189 if not notes or len(notes) < 2:
190 return []
191 base = get_semitones(notes[0])
192 return [get_semitones(n) - base for n in notes[1:]]
194 def get_semitones(note: str) -> int:
195 """Convert note to semitone number (C4 = 60, etc.)."""
196 if note == "rest":
197 return -1
199 # Extract pitch class and octave
200 pitch_class = note[0].upper()
202 # Handle accidentals properly
203 accidental = 0
204 if "#" in note:
205 # Count number of sharps
206 accidental = note.count("#")
207 elif "b" in note:
208 # Count number of flats (negative)
209 accidental = -note.count("b")
211 # Extract octave (last character)
212 octave = int(note[-1])
214 # Base semitones for each pitch class
215 base = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
216 return base[pitch_class] + accidental + (octave + 1) * 12
218 # Pre-calculate pattern intervals for transposition check
219 pattern_melody = [p for p in pitches if p != "rest"]
220 pattern_intervals = get_intervals(pattern_melody)
222 def check_melody(seq: Sequence) -> bool:
223 # Extract all notes in sequence
224 all_notes = []
225 for note in seq:
226 if note["note_type"] == NoteType.MELODY.value:
227 all_notes.append(note["pitch"])
228 elif note["note_type"] == NoteType.REST.value:
229 all_notes.append("rest")
231 if len(all_notes) != len(pitches):
232 return False
234 if not transpose:
235 return all(n == p for n, p in zip(all_notes, pitches))
237 # Check if intervals match when transposed (ignoring rests)
238 melody_notes = [n for n in all_notes if n != "rest"]
239 if (
240 not melody_notes
241 or len(melody_notes) < 2
242 or not pattern_melody
243 or len(pattern_melody) < 2
244 ):
245 # If not enough notes to form intervals, check direct equality
246 return all(n == p for n, p in zip(all_notes, pitches))
248 # Calculate intervals for the sequence
249 seq_intervals = get_intervals(melody_notes)
251 # For transposition, we only care about the interval pattern, not the absolute pitches
252 return seq_intervals == pattern_intervals
254 return DSLRule(check_melody, f"melody matches pitch pattern {pitches}")
257def create_measure_rule(time_sig: TimeSignature) -> DSLRule:
258 """
259 Creates a rule ensuring notes fit properly in measures.
261 Example:
262 common_time = create_measure_rule(TimeSignature(4, 4))
263 """
265 def check_measures(seq: Sequence) -> bool:
266 if not seq:
267 return True
269 # Group notes by measure
270 measures: Dict[int, List[AbstractObject]] = {}
271 for note in seq:
272 measure = note["measure"]
273 if measure is None:
274 return False
275 if measure not in measures:
276 measures[measure] = []
277 measures[measure].append(note)
279 # Check each measure's duration
280 for measure_notes in measures.values():
281 total = sum(note["duration"] for note in measure_notes)
282 if total != time_sig.measure_duration:
283 return False
284 return True
286 return DSLRule(check_measures, f"notes fit in {time_sig} measures")
289def create_total_duration_rule(target: float, tolerance: float = 0.001) -> DSLRule:
290 """
291 Creates a rule requiring the total duration to match a target value.
293 Example:
294 eight_bars = create_total_duration_rule(32.0) # 8 measures in 4/4
295 """
297 def check_total_duration(seq: Sequence) -> bool:
298 total = sum(note["duration"] for note in seq)
299 return abs(total - target) <= tolerance
301 return DSLRule(check_total_duration, f"total duration = {target} ± {tolerance}")
304def create_max_consecutive_rule(
305 note_type: Union[str, NoteType], max_count: int
306) -> DSLRule:
307 """
308 Creates a rule limiting the number of consecutive notes of a specific type.
310 Example:
311 max_rests = create_max_consecutive_rule(NoteType.REST, 2)
312 """
313 if isinstance(note_type, str):
314 note_type = NoteType(note_type)
316 def check_consecutive(seq: Sequence) -> bool:
317 count = 0
318 for note in seq:
319 if note["note_type"] == note_type.value:
320 count += 1
321 if count > max_count:
322 return False
323 else:
324 count = 0
325 return True
327 return DSLRule(
328 check_consecutive, f"at most {max_count} consecutive {note_type.value} notes"
329 )
332# Common musical patterns
333basic_waltz = create_rhythm_pattern_rule([1.0, 0.5, 0.5]) # ONE-two-three
334basic_march = create_rhythm_pattern_rule([1.0, 1.0]) # LEFT-right
335swing_rhythm = create_rhythm_pattern_rule([0.66, 0.33]) # Long-short swing
337# Example sequences
338waltz_pattern = [
339 Note("C4", 1.0, NoteType.MELODY, measure=1, beat=1), # ONE
340 Note("G3", 0.5, NoteType.HARMONY, measure=1, beat=2), # two
341 Note("E3", 0.5, NoteType.HARMONY, measure=1, beat=2.5), # three
342]
344melody_with_rest = [
345 Note("C4", 1.0, NoteType.MELODY),
346 Note("rest", 0.5, NoteType.REST),
347 Note("E4", 0.5, NoteType.MELODY),
348]
350c_major_scale = [
351 Note(pitch, 0.25, NoteType.MELODY)
352 for pitch in ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5"]
353]