livepilot 1.9.13 → 1.9.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +51 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +32 -8
- package/installer/install.js +21 -2
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
- package/livepilot/skills/livepilot-core/SKILL.md +81 -6
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
- package/livepilot/skills/livepilot-core/references/overview.md +3 -3
- package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
- package/livepilot/skills/livepilot-release/SKILL.md +13 -13
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +6 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/curves.py +11 -3
- package/mcp_server/evaluation/__init__.py +1 -0
- package/mcp_server/evaluation/fabric.py +575 -0
- package/mcp_server/evaluation/feature_extractors.py +84 -0
- package/mcp_server/evaluation/policy.py +67 -0
- package/mcp_server/evaluation/tools.py +53 -0
- package/mcp_server/memory/__init__.py +11 -2
- package/mcp_server/memory/anti_memory.py +78 -0
- package/mcp_server/memory/promotion.py +94 -0
- package/mcp_server/memory/session_memory.py +108 -0
- package/mcp_server/memory/taste_memory.py +158 -0
- package/mcp_server/memory/technique_store.py +2 -1
- package/mcp_server/memory/tools.py +112 -0
- package/mcp_server/mix_engine/__init__.py +1 -0
- package/mcp_server/mix_engine/critics.py +299 -0
- package/mcp_server/mix_engine/models.py +152 -0
- package/mcp_server/mix_engine/planner.py +103 -0
- package/mcp_server/mix_engine/state_builder.py +316 -0
- package/mcp_server/mix_engine/tools.py +214 -0
- package/mcp_server/performance_engine/__init__.py +1 -0
- package/mcp_server/performance_engine/models.py +148 -0
- package/mcp_server/performance_engine/planner.py +267 -0
- package/mcp_server/performance_engine/safety.py +162 -0
- package/mcp_server/performance_engine/tools.py +183 -0
- package/mcp_server/project_brain/__init__.py +6 -0
- package/mcp_server/project_brain/arrangement_graph.py +64 -0
- package/mcp_server/project_brain/automation_graph.py +72 -0
- package/mcp_server/project_brain/builder.py +123 -0
- package/mcp_server/project_brain/capability_graph.py +64 -0
- package/mcp_server/project_brain/models.py +282 -0
- package/mcp_server/project_brain/refresh.py +80 -0
- package/mcp_server/project_brain/role_graph.py +103 -0
- package/mcp_server/project_brain/session_graph.py +51 -0
- package/mcp_server/project_brain/tools.py +144 -0
- package/mcp_server/reference_engine/__init__.py +1 -0
- package/mcp_server/reference_engine/gap_analyzer.py +239 -0
- package/mcp_server/reference_engine/models.py +105 -0
- package/mcp_server/reference_engine/profile_builder.py +149 -0
- package/mcp_server/reference_engine/tactic_router.py +117 -0
- package/mcp_server/reference_engine/tools.py +235 -0
- package/mcp_server/runtime/__init__.py +1 -0
- package/mcp_server/runtime/action_ledger.py +117 -0
- package/mcp_server/runtime/action_ledger_models.py +84 -0
- package/mcp_server/runtime/action_tools.py +57 -0
- package/mcp_server/runtime/capability_state.py +218 -0
- package/mcp_server/runtime/safety_kernel.py +339 -0
- package/mcp_server/runtime/safety_tools.py +42 -0
- package/mcp_server/runtime/tools.py +64 -0
- package/mcp_server/server.py +23 -1
- package/mcp_server/sound_design/__init__.py +1 -0
- package/mcp_server/sound_design/critics.py +297 -0
- package/mcp_server/sound_design/models.py +147 -0
- package/mcp_server/sound_design/planner.py +104 -0
- package/mcp_server/sound_design/tools.py +297 -0
- package/mcp_server/tools/_agent_os_engine.py +947 -0
- package/mcp_server/tools/_composition_engine.py +1530 -0
- package/mcp_server/tools/_conductor.py +199 -0
- package/mcp_server/tools/_conductor_budgets.py +222 -0
- package/mcp_server/tools/_evaluation_contracts.py +91 -0
- package/mcp_server/tools/_form_engine.py +416 -0
- package/mcp_server/tools/_motif_engine.py +351 -0
- package/mcp_server/tools/_planner_engine.py +516 -0
- package/mcp_server/tools/_research_engine.py +542 -0
- package/mcp_server/tools/_research_provider.py +185 -0
- package/mcp_server/tools/_snapshot_normalizer.py +49 -0
- package/mcp_server/tools/agent_os.py +440 -0
- package/mcp_server/tools/analyzer.py +18 -0
- package/mcp_server/tools/automation.py +25 -10
- package/mcp_server/tools/composition.py +563 -0
- package/mcp_server/tools/motif.py +104 -0
- package/mcp_server/tools/planner.py +144 -0
- package/mcp_server/tools/research.py +223 -0
- package/mcp_server/tools/tracks.py +18 -3
- package/mcp_server/tools/transport.py +10 -2
- package/mcp_server/transition_engine/__init__.py +6 -0
- package/mcp_server/transition_engine/archetypes.py +167 -0
- package/mcp_server/transition_engine/critics.py +340 -0
- package/mcp_server/transition_engine/models.py +90 -0
- package/mcp_server/transition_engine/tools.py +291 -0
- package/mcp_server/translation_engine/__init__.py +5 -0
- package/mcp_server/translation_engine/critics.py +297 -0
- package/mcp_server/translation_engine/models.py +27 -0
- package/mcp_server/translation_engine/tools.py +74 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/requirements.txt +1 -1
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Motif Engine — pattern detection and transformation for musical motifs.
|
|
2
|
+
|
|
3
|
+
Detects recurring melodic and rhythmic patterns across clips, scores them
|
|
4
|
+
for salience and fatigue risk, and provides transformation operations
|
|
5
|
+
(inversion, augmentation, register shift, fragmentation).
|
|
6
|
+
|
|
7
|
+
Zero external dependencies beyond stdlib.
|
|
8
|
+
Design: spec at docs/COMPOSITION_ENGINE_V1.md, sections 7.5, 10.4, 11.3.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ── Motif Data Structures ─────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class MotifUnit:
|
|
22
|
+
"""A recurring musical pattern detected across clips."""
|
|
23
|
+
motif_id: str
|
|
24
|
+
kind: str # "melodic", "rhythmic", "intervallic"
|
|
25
|
+
intervals: list[int] # relative intervals (semitones between consecutive notes)
|
|
26
|
+
rhythm: list[float] # relative durations (ratios)
|
|
27
|
+
representative_pitches: list[int] # first occurrence's actual pitches
|
|
28
|
+
occurrences: list[dict] = field(default_factory=list) # [{track, clip, start_bar}]
|
|
29
|
+
salience: float = 0.0 # 0-1, how distinctive/memorable
|
|
30
|
+
fatigue_risk: float = 0.0 # 0-1, risk of overuse
|
|
31
|
+
suggested_developments: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict:
|
|
34
|
+
return asdict(self)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── Pattern Extraction ────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def _extract_intervals(notes: list[dict]) -> list[int]:
|
|
40
|
+
"""Extract pitch intervals between consecutive notes (sorted by start_time)."""
|
|
41
|
+
if len(notes) < 2:
|
|
42
|
+
return []
|
|
43
|
+
sorted_notes = sorted(notes, key=lambda n: n.get("start_time", 0))
|
|
44
|
+
return [
|
|
45
|
+
sorted_notes[i + 1].get("pitch", 0) - sorted_notes[i].get("pitch", 0)
|
|
46
|
+
for i in range(len(sorted_notes) - 1)
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _extract_rhythm(notes: list[dict]) -> list[float]:
|
|
51
|
+
"""Extract rhythm pattern as duration ratios (normalized to first note)."""
|
|
52
|
+
if not notes:
|
|
53
|
+
return []
|
|
54
|
+
sorted_notes = sorted(notes, key=lambda n: n.get("start_time", 0))
|
|
55
|
+
durations = [n.get("duration", 0.5) for n in sorted_notes]
|
|
56
|
+
base = durations[0] if durations[0] > 0 else 0.5
|
|
57
|
+
return [round(d / base, 2) for d in durations]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _find_recurring_subsequences(
|
|
61
|
+
intervals: list[int],
|
|
62
|
+
min_length: int = 3,
|
|
63
|
+
max_length: int = 8,
|
|
64
|
+
) -> list[tuple[tuple[int, ...], list[int]]]:
|
|
65
|
+
"""Find recurring interval subsequences and their start positions.
|
|
66
|
+
|
|
67
|
+
Returns list of (pattern_tuple, [start_indices]).
|
|
68
|
+
"""
|
|
69
|
+
if len(intervals) < min_length:
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
pattern_positions: dict[tuple[int, ...], list[int]] = {}
|
|
73
|
+
|
|
74
|
+
for length in range(min_length, min(max_length + 1, len(intervals) + 1)):
|
|
75
|
+
for start in range(len(intervals) - length + 1):
|
|
76
|
+
pattern = tuple(intervals[start:start + length])
|
|
77
|
+
pattern_positions.setdefault(pattern, []).append(start)
|
|
78
|
+
|
|
79
|
+
# Filter to patterns that occur at least twice
|
|
80
|
+
return [
|
|
81
|
+
(pattern, positions)
|
|
82
|
+
for pattern, positions in pattern_positions.items()
|
|
83
|
+
if len(positions) >= 2
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _score_salience(pattern: tuple[int, ...], occurrence_count: int, total_notes: int) -> float:
|
|
88
|
+
"""Score how memorable/distinctive a pattern is.
|
|
89
|
+
|
|
90
|
+
Higher salience for: longer patterns, more variety, moderate occurrence count.
|
|
91
|
+
"""
|
|
92
|
+
length_score = min(1.0, len(pattern) / 8.0)
|
|
93
|
+
|
|
94
|
+
# Interval variety (unique intervals / total intervals)
|
|
95
|
+
unique_intervals = len(set(pattern))
|
|
96
|
+
variety_score = unique_intervals / max(len(pattern), 1)
|
|
97
|
+
|
|
98
|
+
# Occurrence: too few = obscure, too many = boring
|
|
99
|
+
occurrence_ratio = occurrence_count / max(total_notes / len(pattern), 1)
|
|
100
|
+
occurrence_score = min(1.0, occurrence_ratio * 2) * (1.0 - min(1.0, occurrence_ratio * 0.5))
|
|
101
|
+
|
|
102
|
+
return round(min(1.0, (length_score * 0.3 + variety_score * 0.4 + occurrence_score * 0.3)), 3)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _score_fatigue(occurrence_count: int, total_bars: int) -> float:
|
|
106
|
+
"""Score risk of listener fatigue from repetition.
|
|
107
|
+
|
|
108
|
+
Higher fatigue for: high occurrence count relative to total length.
|
|
109
|
+
"""
|
|
110
|
+
if total_bars <= 0:
|
|
111
|
+
return 0.0
|
|
112
|
+
density = occurrence_count / total_bars
|
|
113
|
+
return round(min(1.0, density * 2), 3)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _suggest_developments(motif: MotifUnit) -> list[str]:
|
|
117
|
+
"""Suggest musical transformations based on motif properties."""
|
|
118
|
+
suggestions = []
|
|
119
|
+
|
|
120
|
+
if motif.fatigue_risk > 0.5:
|
|
121
|
+
suggestions.append("rhythmic_variation")
|
|
122
|
+
suggestions.append("register_shift")
|
|
123
|
+
|
|
124
|
+
if len(motif.intervals) >= 4:
|
|
125
|
+
suggestions.append("fragmentation")
|
|
126
|
+
suggestions.append("inversion")
|
|
127
|
+
|
|
128
|
+
if all(abs(i) <= 2 for i in motif.intervals):
|
|
129
|
+
suggestions.append("register_shift_up") # Stepwise motion → try a leap
|
|
130
|
+
|
|
131
|
+
if any(abs(i) >= 5 for i in motif.intervals):
|
|
132
|
+
suggestions.append("augmentation") # Has leaps → try slowing down
|
|
133
|
+
|
|
134
|
+
if motif.salience > 0.6:
|
|
135
|
+
suggestions.append("answer_phrase")
|
|
136
|
+
suggestions.append("orchestral_reassignment")
|
|
137
|
+
|
|
138
|
+
if not suggestions:
|
|
139
|
+
suggestions.append("register_shift")
|
|
140
|
+
|
|
141
|
+
return suggestions
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── Motif Detection ───────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
def detect_motifs(
|
|
147
|
+
notes_by_track: dict[int, list[dict]],
|
|
148
|
+
total_bars: int = 32,
|
|
149
|
+
min_pattern_length: int = 3,
|
|
150
|
+
max_pattern_length: int = 8,
|
|
151
|
+
) -> list[MotifUnit]:
|
|
152
|
+
"""Detect recurring musical patterns across all tracks.
|
|
153
|
+
|
|
154
|
+
notes_by_track: {track_index: [note dicts with pitch, start_time, duration]}
|
|
155
|
+
total_bars: approximate total length for fatigue scoring
|
|
156
|
+
Returns: list of MotifUnit sorted by salience (most memorable first)
|
|
157
|
+
"""
|
|
158
|
+
# Collect all intervals per track
|
|
159
|
+
all_patterns: dict[tuple[int, ...], list[dict]] = {}
|
|
160
|
+
total_note_count = 0
|
|
161
|
+
|
|
162
|
+
for track_idx, notes in notes_by_track.items():
|
|
163
|
+
if not notes:
|
|
164
|
+
continue
|
|
165
|
+
total_note_count += len(notes)
|
|
166
|
+
intervals = _extract_intervals(notes)
|
|
167
|
+
|
|
168
|
+
# Find recurring subsequences in this track
|
|
169
|
+
recurring = _find_recurring_subsequences(
|
|
170
|
+
intervals, min_pattern_length, max_pattern_length,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
for pattern, positions in recurring:
|
|
174
|
+
if pattern not in all_patterns:
|
|
175
|
+
all_patterns[pattern] = []
|
|
176
|
+
sorted_notes = sorted(notes, key=lambda n: n.get("start_time", 0))
|
|
177
|
+
for pos in positions:
|
|
178
|
+
start_time = sorted_notes[pos].get("start_time", 0) if pos < len(sorted_notes) else 0
|
|
179
|
+
all_patterns[pattern].append({
|
|
180
|
+
"track": track_idx,
|
|
181
|
+
"start_position": pos,
|
|
182
|
+
"start_time": start_time,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
# Also check for cross-track patterns
|
|
186
|
+
all_intervals_flat: list[tuple[int, list[int], int]] = []
|
|
187
|
+
for track_idx, notes in notes_by_track.items():
|
|
188
|
+
intervals = _extract_intervals(notes)
|
|
189
|
+
if intervals:
|
|
190
|
+
all_intervals_flat.append((track_idx, intervals, len(notes)))
|
|
191
|
+
|
|
192
|
+
# Build motif objects
|
|
193
|
+
motifs = []
|
|
194
|
+
seen_patterns: set[tuple[int, ...]] = set()
|
|
195
|
+
|
|
196
|
+
for pattern, occurrences in sorted(all_patterns.items(), key=lambda x: -len(x[1])):
|
|
197
|
+
# Skip sub-patterns of already-found patterns
|
|
198
|
+
if any(pattern != seen and _is_subsequence(pattern, seen) for seen in seen_patterns):
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
salience = _score_salience(pattern, len(occurrences), total_note_count)
|
|
202
|
+
fatigue = _score_fatigue(len(occurrences), total_bars)
|
|
203
|
+
|
|
204
|
+
# Get representative pitches from first occurrence
|
|
205
|
+
first_occ = occurrences[0] if occurrences else {}
|
|
206
|
+
first_track = first_occ.get("track", 0)
|
|
207
|
+
first_pos = first_occ.get("start_position", 0)
|
|
208
|
+
rep_pitches = []
|
|
209
|
+
if first_track in notes_by_track:
|
|
210
|
+
sorted_notes = sorted(notes_by_track[first_track],
|
|
211
|
+
key=lambda n: n.get("start_time", 0))
|
|
212
|
+
rep_pitches = [
|
|
213
|
+
sorted_notes[first_pos + j].get("pitch", 60)
|
|
214
|
+
for j in range(min(len(pattern) + 1, len(sorted_notes) - first_pos))
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
motif = MotifUnit(
|
|
218
|
+
motif_id=f"motif_{len(motifs):03d}",
|
|
219
|
+
kind="melodic" if any(abs(i) > 0 for i in pattern) else "rhythmic",
|
|
220
|
+
intervals=list(pattern),
|
|
221
|
+
rhythm=[], # TODO: rhythm detection in Phase 3
|
|
222
|
+
representative_pitches=rep_pitches,
|
|
223
|
+
occurrences=occurrences,
|
|
224
|
+
salience=salience,
|
|
225
|
+
fatigue_risk=fatigue,
|
|
226
|
+
)
|
|
227
|
+
motif.suggested_developments = _suggest_developments(motif)
|
|
228
|
+
motifs.append(motif)
|
|
229
|
+
seen_patterns.add(pattern)
|
|
230
|
+
|
|
231
|
+
if len(motifs) >= 10:
|
|
232
|
+
break # Cap at 10 most significant motifs
|
|
233
|
+
|
|
234
|
+
# Sort by salience
|
|
235
|
+
motifs.sort(key=lambda m: -m.salience)
|
|
236
|
+
return motifs
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _is_subsequence(short: tuple, long: tuple) -> bool:
|
|
240
|
+
"""Check if short is a contiguous subsequence of long."""
|
|
241
|
+
if len(short) >= len(long):
|
|
242
|
+
return False
|
|
243
|
+
for i in range(len(long) - len(short) + 1):
|
|
244
|
+
if long[i:i + len(short)] == short:
|
|
245
|
+
return True
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ── Motif Transformations ─────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
def transform_motif(
|
|
252
|
+
motif: MotifUnit,
|
|
253
|
+
transformation: str,
|
|
254
|
+
reference_pitch: int = 60,
|
|
255
|
+
) -> list[dict]:
|
|
256
|
+
"""Apply a musical transformation to a motif, returning new notes.
|
|
257
|
+
|
|
258
|
+
transformation: "inversion", "augmentation", "diminution",
|
|
259
|
+
"fragmentation", "register_shift_up", "register_shift_down",
|
|
260
|
+
"retrograde"
|
|
261
|
+
reference_pitch: base pitch for the output (default: C4=60)
|
|
262
|
+
Returns: list of note dicts ready for add_notes
|
|
263
|
+
"""
|
|
264
|
+
if not motif.representative_pitches:
|
|
265
|
+
return []
|
|
266
|
+
|
|
267
|
+
pitches = motif.representative_pitches
|
|
268
|
+
intervals = motif.intervals
|
|
269
|
+
|
|
270
|
+
if transformation == "inversion":
|
|
271
|
+
# Flip intervals: up becomes down
|
|
272
|
+
new_intervals = [-i for i in intervals]
|
|
273
|
+
return _intervals_to_notes(new_intervals, reference_pitch)
|
|
274
|
+
|
|
275
|
+
elif transformation == "retrograde":
|
|
276
|
+
# Reverse the interval sequence
|
|
277
|
+
new_intervals = list(reversed(intervals))
|
|
278
|
+
return _intervals_to_notes(new_intervals, reference_pitch)
|
|
279
|
+
|
|
280
|
+
elif transformation == "augmentation":
|
|
281
|
+
# Double the duration of each note
|
|
282
|
+
return _intervals_to_notes(intervals, reference_pitch, duration_multiplier=2.0)
|
|
283
|
+
|
|
284
|
+
elif transformation == "diminution":
|
|
285
|
+
# Halve the duration
|
|
286
|
+
return _intervals_to_notes(intervals, reference_pitch, duration_multiplier=0.5)
|
|
287
|
+
|
|
288
|
+
elif transformation == "fragmentation":
|
|
289
|
+
# Take only the first half of the motif
|
|
290
|
+
half = max(1, len(intervals) // 2)
|
|
291
|
+
return _intervals_to_notes(intervals[:half], reference_pitch)
|
|
292
|
+
|
|
293
|
+
elif transformation == "register_shift_up":
|
|
294
|
+
# Transpose up an octave
|
|
295
|
+
return _intervals_to_notes(intervals, reference_pitch + 12)
|
|
296
|
+
|
|
297
|
+
elif transformation == "register_shift_down":
|
|
298
|
+
# Transpose down an octave
|
|
299
|
+
return _intervals_to_notes(intervals, reference_pitch - 12)
|
|
300
|
+
|
|
301
|
+
elif transformation == "orchestral_reassignment":
|
|
302
|
+
# Redistribute across a wider register — odd notes up, even notes down
|
|
303
|
+
# Creates an interleaved texture from a single-voice motif
|
|
304
|
+
notes = _intervals_to_notes(intervals, reference_pitch)
|
|
305
|
+
for i, note in enumerate(notes):
|
|
306
|
+
if i % 2 == 0:
|
|
307
|
+
note["pitch"] = max(0, min(127, note["pitch"] + 7)) # Up a fifth
|
|
308
|
+
else:
|
|
309
|
+
note["pitch"] = max(0, min(127, note["pitch"] - 5)) # Down a fourth
|
|
310
|
+
note["velocity"] = max(40, min(127, note["velocity"] + (10 if i % 2 == 0 else -10)))
|
|
311
|
+
return notes
|
|
312
|
+
|
|
313
|
+
else:
|
|
314
|
+
raise ValueError(
|
|
315
|
+
f"Unknown transformation '{transformation}'. Valid: "
|
|
316
|
+
"inversion, retrograde, augmentation, diminution, fragmentation, "
|
|
317
|
+
"register_shift_up, register_shift_down, orchestral_reassignment"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _intervals_to_notes(
|
|
322
|
+
intervals: list[int],
|
|
323
|
+
start_pitch: int = 60,
|
|
324
|
+
duration_multiplier: float = 1.0,
|
|
325
|
+
base_duration: float = 0.5,
|
|
326
|
+
base_velocity: int = 80,
|
|
327
|
+
) -> list[dict]:
|
|
328
|
+
"""Convert interval sequence to note dicts."""
|
|
329
|
+
notes = []
|
|
330
|
+
current_pitch = start_pitch
|
|
331
|
+
current_time = 0.0
|
|
332
|
+
duration = base_duration * duration_multiplier
|
|
333
|
+
|
|
334
|
+
notes.append({
|
|
335
|
+
"pitch": current_pitch,
|
|
336
|
+
"start_time": current_time,
|
|
337
|
+
"duration": duration,
|
|
338
|
+
"velocity": base_velocity,
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
for interval in intervals:
|
|
342
|
+
current_pitch += interval
|
|
343
|
+
current_time += duration
|
|
344
|
+
notes.append({
|
|
345
|
+
"pitch": max(0, min(127, current_pitch)),
|
|
346
|
+
"start_time": round(current_time, 4),
|
|
347
|
+
"duration": duration,
|
|
348
|
+
"velocity": base_velocity,
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
return notes
|