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

1""" 

2Musical sequence rules. 

3 

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 

11 

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""" 

19 

20from enum import Enum 

21from fractions import Fraction 

22from typing import Callable, Dict, List, Optional, Union 

23 

24from ..core import AbstractObject, Sequence 

25from ..dsl import DSLRule, if_then_rule 

26 

27 

28class NoteType(Enum): 

29 """Types of musical notes.""" 

30 

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 

36 

37 

38class TimeSignature: 

39 """Musical time signature (e.g., 4/4, 3/4, 6/8).""" 

40 

41 def __init__(self, beats: int, beat_unit: int): 

42 """ 

43 Initialize a time signature. 

44 

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 

55 

56 def __repr__(self) -> str: 

57 return f"{self.beats}/{self.beat_unit}" 

58 

59 

60class Note(AbstractObject): 

61 """ 

62 A musical note with properties. 

63 

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 """ 

72 

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) 

85 

86 if not (0 <= velocity <= 127): 

87 raise ValueError("Velocity must be between 0 and 127") 

88 

89 if duration <= 0: 

90 raise ValueError("Duration must be positive") 

91 

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 ) 

100 

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 ) 

107 

108 

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 

114 

115 

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 

119 

120 

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 

124 

125 

126# Basic rules 

127rest_followed_by_melody = if_then_rule( 

128 note_type_is(NoteType.REST), note_type_is(NoteType.MELODY) 

129) 

130 

131 

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. 

137 

138 Args: 

139 durations: List of note durations (in quarter notes) 

140 allow_consolidation: Whether to allow combining consecutive notes 

141 

142 Example: 

143 waltz = create_rhythm_pattern_rule([1.0, 0.5, 0.5]) # Basic waltz pattern 

144 """ 

145 

146 def check_rhythm(seq: Sequence) -> bool: 

147 if not seq: 

148 return True 

149 

150 # Extract durations from sequence 

151 seq_durations = [note["duration"] for note in seq] 

152 

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)) 

157 

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 

171 

172 return DSLRule(check_rhythm, f"matches rhythm pattern {durations}") 

173 

174 

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. 

178 

179 Args: 

180 pitches: List of note pitches 

181 transpose: Whether to allow transposed versions of the pattern 

182 

183 Example: 

184 motif = create_melody_pattern_rule(["C4", "E4", "G4"]) # C major arpeggio 

185 """ 

186 

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:]] 

193 

194 def get_semitones(note: str) -> int: 

195 """Convert note to semitone number (C4 = 60, etc.).""" 

196 if note == "rest": 

197 return -1 

198 

199 # Extract pitch class and octave 

200 pitch_class = note[0].upper() 

201 

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") 

210 

211 # Extract octave (last character) 

212 octave = int(note[-1]) 

213 

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 

217 

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) 

221 

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") 

230 

231 if len(all_notes) != len(pitches): 

232 return False 

233 

234 if not transpose: 

235 return all(n == p for n, p in zip(all_notes, pitches)) 

236 

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)) 

247 

248 # Calculate intervals for the sequence 

249 seq_intervals = get_intervals(melody_notes) 

250 

251 # For transposition, we only care about the interval pattern, not the absolute pitches 

252 return seq_intervals == pattern_intervals 

253 

254 return DSLRule(check_melody, f"melody matches pitch pattern {pitches}") 

255 

256 

257def create_measure_rule(time_sig: TimeSignature) -> DSLRule: 

258 """ 

259 Creates a rule ensuring notes fit properly in measures. 

260 

261 Example: 

262 common_time = create_measure_rule(TimeSignature(4, 4)) 

263 """ 

264 

265 def check_measures(seq: Sequence) -> bool: 

266 if not seq: 

267 return True 

268 

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) 

278 

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 

285 

286 return DSLRule(check_measures, f"notes fit in {time_sig} measures") 

287 

288 

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. 

292 

293 Example: 

294 eight_bars = create_total_duration_rule(32.0) # 8 measures in 4/4 

295 """ 

296 

297 def check_total_duration(seq: Sequence) -> bool: 

298 total = sum(note["duration"] for note in seq) 

299 return abs(total - target) <= tolerance 

300 

301 return DSLRule(check_total_duration, f"total duration = {target} ± {tolerance}") 

302 

303 

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. 

309 

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) 

315 

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 

326 

327 return DSLRule( 

328 check_consecutive, f"at most {max_count} consecutive {note_type.value} notes" 

329 ) 

330 

331 

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 

336 

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] 

343 

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] 

349 

350c_major_scale = [ 

351 Note(pitch, 0.25, NoteType.MELODY) 

352 for pitch in ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5"] 

353]