midi player on frontend
Browse files- backend/main.py +45 -2
- backend/pipeline.py +298 -17
- backend/scripts/diagnose_makemeasures.py +265 -0
- backend/scripts/diagnose_musicxml.py +333 -0
- backend/tasks.py +39 -6
- frontend/package-lock.json +81 -811
- frontend/package.json +3 -2
- frontend/src/api/client.ts +26 -0
- frontend/src/components/NotationCanvas.tsx +59 -58
- frontend/src/components/ScoreEditor.tsx +48 -18
- frontend/src/store/notation.ts +44 -0
- frontend/src/utils/midi-parser.ts +291 -0
- frontend/src/utils/musicxml-parser.ts +24 -3
- frontend/src/utils/validate-musicxml.ts +289 -0
backend/main.py
CHANGED
|
@@ -376,6 +376,40 @@ async def download_midi(job_id: str):
|
|
| 376 |
)
|
| 377 |
|
| 378 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
# === WebSocket Endpoint ===
|
| 380 |
|
| 381 |
@app.websocket("/api/v1/jobs/{job_id}/stream")
|
|
@@ -401,10 +435,19 @@ async def websocket_endpoint(websocket: WebSocket, job_id: str):
|
|
| 401 |
update = json.loads(message['data'])
|
| 402 |
await websocket.send_json(update)
|
| 403 |
|
| 404 |
-
# Close connection if job completed
|
| 405 |
-
if update.get('type')
|
| 406 |
break
|
| 407 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
# Send initial status
|
| 409 |
job_data = redis_client.hgetall(f"job:{job_id}")
|
| 410 |
if job_data:
|
|
|
|
| 376 |
)
|
| 377 |
|
| 378 |
|
| 379 |
+
@app.get("/api/v1/scores/{job_id}/metadata")
|
| 380 |
+
async def get_metadata(job_id: str):
|
| 381 |
+
"""
|
| 382 |
+
Get detected metadata for a completed transcription.
|
| 383 |
+
|
| 384 |
+
Returns tempo, key signature, and time signature detected from audio.
|
| 385 |
+
|
| 386 |
+
Args:
|
| 387 |
+
job_id: Job identifier
|
| 388 |
+
|
| 389 |
+
Returns:
|
| 390 |
+
JSON with tempo, key_signature, time_signature
|
| 391 |
+
"""
|
| 392 |
+
job_data = redis_client.hgetall(f"job:{job_id}")
|
| 393 |
+
|
| 394 |
+
if not job_data or job_data.get('status') != 'completed':
|
| 395 |
+
raise HTTPException(status_code=404, detail="Metadata not available")
|
| 396 |
+
|
| 397 |
+
metadata_str = job_data.get('metadata')
|
| 398 |
+
if not metadata_str:
|
| 399 |
+
# Return defaults if metadata not stored (for older jobs)
|
| 400 |
+
return {
|
| 401 |
+
"tempo": 120.0,
|
| 402 |
+
"key_signature": "C",
|
| 403 |
+
"time_signature": {"numerator": 4, "denominator": 4},
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
try:
|
| 407 |
+
metadata = json.loads(metadata_str)
|
| 408 |
+
return metadata
|
| 409 |
+
except json.JSONDecodeError:
|
| 410 |
+
raise HTTPException(status_code=500, detail="Invalid metadata format")
|
| 411 |
+
|
| 412 |
+
|
| 413 |
# === WebSocket Endpoint ===
|
| 414 |
|
| 415 |
@app.websocket("/api/v1/jobs/{job_id}/stream")
|
|
|
|
| 435 |
update = json.loads(message['data'])
|
| 436 |
await websocket.send_json(update)
|
| 437 |
|
| 438 |
+
# Close connection if job completed
|
| 439 |
+
if update.get('type') == 'completed':
|
| 440 |
break
|
| 441 |
|
| 442 |
+
# Close connection if job failed with non-retryable error
|
| 443 |
+
if update.get('type') == 'error':
|
| 444 |
+
error_info = update.get('error', {})
|
| 445 |
+
is_retryable = error_info.get('retryable', False)
|
| 446 |
+
if not is_retryable:
|
| 447 |
+
# Only close if error is permanent
|
| 448 |
+
break
|
| 449 |
+
# If retryable, keep connection open for retry progress updates
|
| 450 |
+
|
| 451 |
# Send initial status
|
| 452 |
job_data = redis_client.hgetall(f"job:{job_id}")
|
| 453 |
if job_data:
|
backend/pipeline.py
CHANGED
|
@@ -54,6 +54,13 @@ class TranscriptionPipeline:
|
|
| 54 |
else:
|
| 55 |
self.config = config
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
def set_progress_callback(self, callback) -> None:
|
| 58 |
"""Set callback for progress updates: callback(percent, stage, message)"""
|
| 59 |
self.progress_callback = callback
|
|
@@ -335,6 +342,8 @@ class TranscriptionPipeline:
|
|
| 335 |
midi_path = transcriber.transcribe_audio(audio_path, output_dir)
|
| 336 |
|
| 337 |
print(f" ✓ YourMT3+ transcription complete")
|
|
|
|
|
|
|
| 338 |
return midi_path
|
| 339 |
|
| 340 |
except Exception as e:
|
|
@@ -1269,10 +1278,23 @@ class TranscriptionPipeline:
|
|
| 1269 |
print(f" Detected: {detected_tempo} BPM (confidence: {tempo_confidence:.2f})")
|
| 1270 |
print(f" Detected: {time_sig_num}/{time_sig_denom} time (confidence: {ts_confidence:.2f})")
|
| 1271 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1272 |
self.progress(93, "musicxml", "Parsing MIDI")
|
| 1273 |
|
| 1274 |
# Step 2: Parse MIDI
|
|
|
|
|
|
|
| 1275 |
score = converter.parse(midi_path)
|
|
|
|
| 1276 |
|
| 1277 |
self.progress(94, "musicxml", "Detecting key signature")
|
| 1278 |
|
|
@@ -1280,10 +1302,22 @@ class TranscriptionPipeline:
|
|
| 1280 |
detected_key, key_confidence = self.detect_key_ensemble(score, source_audio)
|
| 1281 |
print(f" Detected key: {detected_key} (confidence: {key_confidence:.2f})")
|
| 1282 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1283 |
self.progress(96, "musicxml", "Creating measures")
|
| 1284 |
|
| 1285 |
-
# Step 4: Create measures
|
| 1286 |
-
|
|
|
|
| 1287 |
|
| 1288 |
# Step 5: Grand staff split (optional)
|
| 1289 |
if self.config.enable_grand_staff:
|
|
@@ -1314,6 +1348,12 @@ class TranscriptionPipeline:
|
|
| 1314 |
# YourMT3+ output is clean, but music21 has limitations on complex durations
|
| 1315 |
score = self._remove_impossible_durations(score)
|
| 1316 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1317 |
self.progress(98, "musicxml", "Exporting MusicXML")
|
| 1318 |
|
| 1319 |
# Step 6: Export MusicXML
|
|
@@ -1322,12 +1362,15 @@ class TranscriptionPipeline:
|
|
| 1322 |
print(f" Writing MusicXML to {output_path}...")
|
| 1323 |
try:
|
| 1324 |
score.write('musicxml', fp=str(output_path), makeNotation=False)
|
|
|
|
| 1325 |
except Exception as e:
|
| 1326 |
-
# If export still fails
|
| 1327 |
-
#
|
| 1328 |
-
print(f"
|
| 1329 |
-
print(f"
|
|
|
|
| 1330 |
score.write('musicxml', fp=str(output_path), makeNotation=True)
|
|
|
|
| 1331 |
|
| 1332 |
print(f" ✓ MusicXML generation complete")
|
| 1333 |
return output_path
|
|
@@ -1721,28 +1764,244 @@ class TranscriptionPipeline:
|
|
| 1721 |
|
| 1722 |
return score
|
| 1723 |
|
| 1724 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1725 |
"""
|
| 1726 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1727 |
|
| 1728 |
music21's makeMeasures() can create rests with impossible durations (2048th notes)
|
| 1729 |
when filling gaps. This removes them to prevent MusicXML export errors.
|
| 1730 |
|
| 1731 |
Args:
|
| 1732 |
score: music21 Score with measures
|
|
|
|
| 1733 |
|
| 1734 |
Returns:
|
| 1735 |
Cleaned score
|
| 1736 |
"""
|
| 1737 |
from music21 import note, stream
|
| 1738 |
|
| 1739 |
-
# Remove notes shorter than
|
| 1740 |
-
#
|
| 1741 |
-
#
|
| 1742 |
-
|
| 1743 |
-
MIN_DURATION = 0.125 # 32nd note (1.0 / 8)
|
| 1744 |
|
| 1745 |
-
|
| 1746 |
for part in score.parts:
|
| 1747 |
for measure in part.getElementsByClass('Measure'):
|
| 1748 |
# Collect elements to remove
|
|
@@ -1750,15 +2009,37 @@ class TranscriptionPipeline:
|
|
| 1750 |
|
| 1751 |
for element in measure.notesAndRests:
|
| 1752 |
if element.quarterLength < MIN_DURATION:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1753 |
to_remove.append(element)
|
| 1754 |
-
removed_count += 1
|
| 1755 |
|
| 1756 |
# Remove impossible durations
|
| 1757 |
for element in to_remove:
|
| 1758 |
measure.remove(element)
|
| 1759 |
|
| 1760 |
-
if
|
| 1761 |
-
print(f" Removed {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1762 |
|
| 1763 |
return score
|
| 1764 |
|
|
|
|
| 54 |
else:
|
| 55 |
self.config = config
|
| 56 |
|
| 57 |
+
# Store detected metadata for API access
|
| 58 |
+
self.metadata = {
|
| 59 |
+
"tempo": 120.0,
|
| 60 |
+
"time_signature": {"numerator": 4, "denominator": 4},
|
| 61 |
+
"key_signature": "C",
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
def set_progress_callback(self, callback) -> None:
|
| 65 |
"""Set callback for progress updates: callback(percent, stage, message)"""
|
| 66 |
self.progress_callback = callback
|
|
|
|
| 342 |
midi_path = transcriber.transcribe_audio(audio_path, output_dir)
|
| 343 |
|
| 344 |
print(f" ✓ YourMT3+ transcription complete")
|
| 345 |
+
print(f" [DEBUG] MIDI path returned: {midi_path}")
|
| 346 |
+
print(f" [DEBUG] MIDI exists at returned path: {midi_path.exists()}")
|
| 347 |
return midi_path
|
| 348 |
|
| 349 |
except Exception as e:
|
|
|
|
| 1278 |
print(f" Detected: {detected_tempo} BPM (confidence: {tempo_confidence:.2f})")
|
| 1279 |
print(f" Detected: {time_sig_num}/{time_sig_denom} time (confidence: {ts_confidence:.2f})")
|
| 1280 |
|
| 1281 |
+
# Step 1.5: Validate and adjust metadata
|
| 1282 |
+
detected_tempo, time_sig_num, time_sig_denom = self._validate_and_adjust_metadata(
|
| 1283 |
+
midi_path,
|
| 1284 |
+
detected_tempo,
|
| 1285 |
+
tempo_confidence,
|
| 1286 |
+
time_sig_num,
|
| 1287 |
+
time_sig_denom,
|
| 1288 |
+
ts_confidence
|
| 1289 |
+
)
|
| 1290 |
+
|
| 1291 |
self.progress(93, "musicxml", "Parsing MIDI")
|
| 1292 |
|
| 1293 |
# Step 2: Parse MIDI
|
| 1294 |
+
print(f" [DEBUG] About to parse MIDI: {midi_path}")
|
| 1295 |
+
print(f" [DEBUG] MIDI exists before parsing: {midi_path.exists()}")
|
| 1296 |
score = converter.parse(midi_path)
|
| 1297 |
+
print(f" [DEBUG] MIDI exists after parsing: {midi_path.exists()}")
|
| 1298 |
|
| 1299 |
self.progress(94, "musicxml", "Detecting key signature")
|
| 1300 |
|
|
|
|
| 1302 |
detected_key, key_confidence = self.detect_key_ensemble(score, source_audio)
|
| 1303 |
print(f" Detected key: {detected_key} (confidence: {key_confidence:.2f})")
|
| 1304 |
|
| 1305 |
+
# Store metadata for API access
|
| 1306 |
+
self.metadata = {
|
| 1307 |
+
"tempo": float(detected_tempo),
|
| 1308 |
+
"time_signature": {"numerator": int(time_sig_num), "denominator": int(time_sig_denom)},
|
| 1309 |
+
"key_signature": str(detected_key),
|
| 1310 |
+
}
|
| 1311 |
+
|
| 1312 |
+
# Step 3.5: Snap durations to prevent makeMeasures from creating impossible tuplets
|
| 1313 |
+
print(f" Snapping note durations to MusicXML-safe values...")
|
| 1314 |
+
score = self._snap_all_durations_pre_measures(score)
|
| 1315 |
+
|
| 1316 |
self.progress(96, "musicxml", "Creating measures")
|
| 1317 |
|
| 1318 |
+
# Step 4: Create measures WITHOUT splitting notes
|
| 1319 |
+
# Using custom function to prevent makeMeasures from splitting notes across measure boundaries
|
| 1320 |
+
score = self._create_measures_without_splitting(score, time_sig_num, time_sig_denom)
|
| 1321 |
|
| 1322 |
# Step 5: Grand staff split (optional)
|
| 1323 |
if self.config.enable_grand_staff:
|
|
|
|
| 1348 |
# YourMT3+ output is clean, but music21 has limitations on complex durations
|
| 1349 |
score = self._remove_impossible_durations(score)
|
| 1350 |
|
| 1351 |
+
# Step 5.6: Split complex durations to make them exportable
|
| 1352 |
+
# This is critical - music21 won't export notes with "complex" durations
|
| 1353 |
+
# even if they're standard note values, if they don't fit cleanly in measures
|
| 1354 |
+
print(f" Splitting complex durations for MusicXML export...")
|
| 1355 |
+
score = self._split_complex_durations_for_export(score)
|
| 1356 |
+
|
| 1357 |
self.progress(98, "musicxml", "Exporting MusicXML")
|
| 1358 |
|
| 1359 |
# Step 6: Export MusicXML
|
|
|
|
| 1362 |
print(f" Writing MusicXML to {output_path}...")
|
| 1363 |
try:
|
| 1364 |
score.write('musicxml', fp=str(output_path), makeNotation=False)
|
| 1365 |
+
print(f" ✓ Successfully exported with makeNotation=False (preserving exact durations)")
|
| 1366 |
except Exception as e:
|
| 1367 |
+
# If export still fails, we have a fundamental problem
|
| 1368 |
+
# Log the error but DON'T use makeNotation=True as it corrupts the data
|
| 1369 |
+
print(f" ERROR: Export failed even after duration splitting: {e}")
|
| 1370 |
+
print(f" This indicates a music21 limitation. Attempting export anyway...")
|
| 1371 |
+
# Try one more time with makeNotation=True as last resort
|
| 1372 |
score.write('musicxml', fp=str(output_path), makeNotation=True)
|
| 1373 |
+
print(f" WARNING: Used makeNotation=True - timing may be corrupted")
|
| 1374 |
|
| 1375 |
print(f" ✓ MusicXML generation complete")
|
| 1376 |
return output_path
|
|
|
|
| 1764 |
|
| 1765 |
return score
|
| 1766 |
|
| 1767 |
+
def _validate_and_adjust_metadata(
|
| 1768 |
+
self,
|
| 1769 |
+
midi_path: Path,
|
| 1770 |
+
tempo: float,
|
| 1771 |
+
tempo_confidence: float,
|
| 1772 |
+
time_sig_num: int,
|
| 1773 |
+
time_sig_denom: int,
|
| 1774 |
+
ts_confidence: float,
|
| 1775 |
+
confidence_threshold: float = 0.5
|
| 1776 |
+
) -> tuple[float, int, int]:
|
| 1777 |
+
"""
|
| 1778 |
+
Validate detected metadata and adjust if confidence is low or values are unusual.
|
| 1779 |
+
|
| 1780 |
+
Wrong metadata (especially time signature) causes makeMeasures() to create
|
| 1781 |
+
incorrect measure boundaries, leading to note splitting and duration corruption.
|
| 1782 |
+
|
| 1783 |
+
Args:
|
| 1784 |
+
midi_path: Path to MIDI file (to check for embedded tempo)
|
| 1785 |
+
tempo: Detected tempo in BPM
|
| 1786 |
+
tempo_confidence: Confidence score (0.0-1.0)
|
| 1787 |
+
time_sig_num: Time signature numerator
|
| 1788 |
+
time_sig_denom: Time signature denominator
|
| 1789 |
+
ts_confidence: Time signature confidence score (0.0-1.0)
|
| 1790 |
+
confidence_threshold: Minimum confidence to trust detection
|
| 1791 |
+
|
| 1792 |
+
Returns:
|
| 1793 |
+
Tuple of (adjusted_tempo, adjusted_time_sig_num, adjusted_time_sig_denom)
|
| 1794 |
+
"""
|
| 1795 |
+
from music21 import converter, tempo as m21_tempo
|
| 1796 |
+
|
| 1797 |
+
adjusted_tempo = tempo
|
| 1798 |
+
adjusted_time_sig_num = time_sig_num
|
| 1799 |
+
adjusted_time_sig_denom = time_sig_denom
|
| 1800 |
+
|
| 1801 |
+
# Validate tempo
|
| 1802 |
+
if tempo_confidence < confidence_threshold:
|
| 1803 |
+
print(f" WARNING: Low tempo confidence ({tempo_confidence:.2f}), checking MIDI for embedded tempo...")
|
| 1804 |
+
|
| 1805 |
+
# Try to extract tempo from MIDI file
|
| 1806 |
+
try:
|
| 1807 |
+
score = converter.parse(midi_path)
|
| 1808 |
+
tempo_marks = score.flatten().getElementsByClass(m21_tempo.MetronomeMark)
|
| 1809 |
+
|
| 1810 |
+
if tempo_marks:
|
| 1811 |
+
midi_tempo = tempo_marks[0].number
|
| 1812 |
+
if midi_tempo and 40 <= midi_tempo <= 240: # Reasonable tempo range
|
| 1813 |
+
print(f" Using MIDI embedded tempo: {midi_tempo} BPM")
|
| 1814 |
+
adjusted_tempo = float(midi_tempo)
|
| 1815 |
+
else:
|
| 1816 |
+
print(f" MIDI tempo {midi_tempo} out of range, using detected tempo")
|
| 1817 |
+
else:
|
| 1818 |
+
print(f" No MIDI tempo found, using detected tempo")
|
| 1819 |
+
except Exception as e:
|
| 1820 |
+
print(f" Failed to extract MIDI tempo: {e}")
|
| 1821 |
+
|
| 1822 |
+
# Validate time signature
|
| 1823 |
+
VALID_TIME_SIGS = [(4, 4), (3, 4), (6, 8), (2, 4), (12, 8), (2, 2), (3, 8), (5, 4), (7, 8)]
|
| 1824 |
+
|
| 1825 |
+
if (time_sig_num, time_sig_denom) not in VALID_TIME_SIGS:
|
| 1826 |
+
print(f" WARNING: Unusual time signature {time_sig_num}/{time_sig_denom}, defaulting to 4/4")
|
| 1827 |
+
adjusted_time_sig_num, adjusted_time_sig_denom = 4, 4
|
| 1828 |
+
elif ts_confidence < confidence_threshold:
|
| 1829 |
+
print(f" WARNING: Low time signature confidence ({ts_confidence:.2f}), defaulting to 4/4")
|
| 1830 |
+
adjusted_time_sig_num, adjusted_time_sig_denom = 4, 4
|
| 1831 |
+
|
| 1832 |
+
# Final sanity checks
|
| 1833 |
+
if not (40 <= adjusted_tempo <= 240):
|
| 1834 |
+
print(f" WARNING: Tempo {adjusted_tempo} out of range [40-240], defaulting to 120 BPM")
|
| 1835 |
+
adjusted_tempo = 120.0
|
| 1836 |
+
|
| 1837 |
+
return adjusted_tempo, adjusted_time_sig_num, adjusted_time_sig_denom
|
| 1838 |
+
|
| 1839 |
+
def _create_measures_without_splitting(
|
| 1840 |
+
self,
|
| 1841 |
+
score,
|
| 1842 |
+
time_sig_num: int,
|
| 1843 |
+
time_sig_denom: int
|
| 1844 |
+
) -> stream.Score:
|
| 1845 |
+
"""
|
| 1846 |
+
Create measures manually without using makeMeasures().
|
| 1847 |
+
|
| 1848 |
+
music21's makeMeasures() corrupts YourMT3+ output by:
|
| 1849 |
+
1. Adding rests at the beginning to align with measure boundaries
|
| 1850 |
+
2. Splitting notes across measure boundaries
|
| 1851 |
+
3. Re-quantizing durations when exporting with makeNotation=True
|
| 1852 |
+
|
| 1853 |
+
This function manually creates measures by calculating boundaries
|
| 1854 |
+
from the time signature and inserting measure markers WITHOUT
|
| 1855 |
+
modifying note timing or durations.
|
| 1856 |
+
"""
|
| 1857 |
+
from music21 import stream, meter, bar
|
| 1858 |
+
|
| 1859 |
+
print(f" Creating measures manually to preserve note timing...")
|
| 1860 |
+
|
| 1861 |
+
# Calculate measure duration in quarter notes
|
| 1862 |
+
# time_sig_num / time_sig_denom gives fraction of whole note
|
| 1863 |
+
# Multiply by 4 to get quarter notes
|
| 1864 |
+
measure_duration = (time_sig_num / time_sig_denom) * 4.0
|
| 1865 |
+
|
| 1866 |
+
# Get all notes from the score
|
| 1867 |
+
all_notes = list(score.flatten().notesAndRests)
|
| 1868 |
+
if not all_notes:
|
| 1869 |
+
return score
|
| 1870 |
+
|
| 1871 |
+
# Find the end time of the last note
|
| 1872 |
+
last_note = max(all_notes, key=lambda n: n.offset + n.quarterLength)
|
| 1873 |
+
total_duration = last_note.offset + last_note.quarterLength
|
| 1874 |
+
|
| 1875 |
+
# Calculate number of measures needed
|
| 1876 |
+
num_measures = int(total_duration / measure_duration) + 1
|
| 1877 |
+
|
| 1878 |
+
print(f" Total duration: {total_duration:.2f} quarter notes")
|
| 1879 |
+
print(f" Measure duration: {measure_duration:.2f} quarter notes")
|
| 1880 |
+
print(f" Creating {num_measures} measures...")
|
| 1881 |
+
|
| 1882 |
+
# Create new score with measures
|
| 1883 |
+
new_score = stream.Score()
|
| 1884 |
+
ts = meter.TimeSignature(f'{time_sig_num}/{time_sig_denom}')
|
| 1885 |
+
|
| 1886 |
+
for part in score.parts:
|
| 1887 |
+
new_part = stream.Part()
|
| 1888 |
+
new_part.id = part.id
|
| 1889 |
+
new_part.partName = getattr(part, 'partName', 'Piano')
|
| 1890 |
+
|
| 1891 |
+
# Create measures and distribute notes
|
| 1892 |
+
for measure_num in range(num_measures):
|
| 1893 |
+
measure_start = measure_num * measure_duration
|
| 1894 |
+
measure_end = measure_start + measure_duration
|
| 1895 |
+
|
| 1896 |
+
new_measure = stream.Measure(number=measure_num + 1)
|
| 1897 |
+
|
| 1898 |
+
# Add time signature to first measure only
|
| 1899 |
+
if measure_num == 0:
|
| 1900 |
+
new_measure.timeSignature = ts
|
| 1901 |
+
|
| 1902 |
+
# Find notes that START in this measure
|
| 1903 |
+
# (Don't split notes that cross measure boundaries - just put them in the measure where they start)
|
| 1904 |
+
for note in part.flatten().notesAndRests:
|
| 1905 |
+
note_start = note.offset
|
| 1906 |
+
if measure_start <= note_start < measure_end:
|
| 1907 |
+
# Calculate offset within measure
|
| 1908 |
+
measure_offset = note_start - measure_start
|
| 1909 |
+
# Insert note at its original offset within the measure
|
| 1910 |
+
new_measure.insert(measure_offset, note)
|
| 1911 |
+
|
| 1912 |
+
new_part.append(new_measure)
|
| 1913 |
+
|
| 1914 |
+
new_score.append(new_part)
|
| 1915 |
+
|
| 1916 |
+
notes_after = sum(1 for _ in new_score.flatten().notesAndRests)
|
| 1917 |
+
print(f" ✓ Created {num_measures} measures preserving all {notes_after} notes")
|
| 1918 |
+
|
| 1919 |
+
return new_score
|
| 1920 |
+
|
| 1921 |
+
def _split_complex_durations_for_export(self, score) -> stream.Score:
|
| 1922 |
+
"""
|
| 1923 |
+
Skip duration splitting - it's causing note loss!
|
| 1924 |
+
|
| 1925 |
+
splitAtDurations() was removing notes instead of splitting them,
|
| 1926 |
+
reducing the note count from 2,347 to 1,785 (24% data loss).
|
| 1927 |
+
|
| 1928 |
+
Since we're now successfully exporting with makeNotation=False,
|
| 1929 |
+
we should preserve the exact durations from YourMT3+ without
|
| 1930 |
+
any additional processing.
|
| 1931 |
+
|
| 1932 |
+
Args:
|
| 1933 |
+
score: music21 Score with measures
|
| 1934 |
+
|
| 1935 |
+
Returns:
|
| 1936 |
+
Score unchanged
|
| 1937 |
+
"""
|
| 1938 |
+
print(f" Skipping duration splitting to preserve YourMT3+ note data...")
|
| 1939 |
+
return score
|
| 1940 |
+
|
| 1941 |
+
def _snap_all_durations_pre_measures(self, score) -> stream.Score:
|
| 1942 |
"""
|
| 1943 |
+
Snap all note durations to MusicXML-safe values BEFORE makeMeasures().
|
| 1944 |
+
|
| 1945 |
+
This prevents music21's makeMeasures() from creating complex tuplets or
|
| 1946 |
+
impossible durations when trying to fit notes into measure boundaries.
|
| 1947 |
+
|
| 1948 |
+
Args:
|
| 1949 |
+
score: music21 Score (flat stream, before makeMeasures)
|
| 1950 |
+
|
| 1951 |
+
Returns:
|
| 1952 |
+
Score with snapped durations
|
| 1953 |
+
"""
|
| 1954 |
+
from music21 import stream
|
| 1955 |
+
|
| 1956 |
+
# MusicXML-safe durations (in quarter notes)
|
| 1957 |
+
# whole, dotted half, half, dotted quarter, quarter, dotted eighth, eighth, dotted 16th, 16th, 32nd
|
| 1958 |
+
SAFE_DURATIONS = [4.0, 3.0, 2.0, 1.5, 1.0, 0.75, 0.5, 0.375, 0.25, 0.125]
|
| 1959 |
+
|
| 1960 |
+
snapped_count = 0
|
| 1961 |
+
total_diff = 0.0
|
| 1962 |
+
|
| 1963 |
+
for part in score.parts:
|
| 1964 |
+
for element in part.flatten().notesAndRests:
|
| 1965 |
+
current_dur = element.quarterLength
|
| 1966 |
+
|
| 1967 |
+
# Find nearest safe duration
|
| 1968 |
+
best_dur = min(SAFE_DURATIONS, key=lambda x: abs(x - current_dur))
|
| 1969 |
+
|
| 1970 |
+
# Only snap if different (tolerance: 0.01 quarter notes)
|
| 1971 |
+
if abs(best_dur - current_dur) > 0.01:
|
| 1972 |
+
diff = abs(best_dur - current_dur)
|
| 1973 |
+
total_diff += diff
|
| 1974 |
+
element.quarterLength = best_dur
|
| 1975 |
+
snapped_count += 1
|
| 1976 |
+
|
| 1977 |
+
if snapped_count > 0:
|
| 1978 |
+
avg_diff = total_diff / snapped_count
|
| 1979 |
+
print(f" Snapped {snapped_count} note durations (avg change: {avg_diff:.4f} quarter notes)")
|
| 1980 |
+
|
| 1981 |
+
return score
|
| 1982 |
+
|
| 1983 |
+
def _remove_impossible_durations(self, score, log_removed: bool = True) -> stream.Score:
|
| 1984 |
+
"""
|
| 1985 |
+
Remove notes/rests with durations too short for MusicXML export (<64th note).
|
| 1986 |
|
| 1987 |
music21's makeMeasures() can create rests with impossible durations (2048th notes)
|
| 1988 |
when filling gaps. This removes them to prevent MusicXML export errors.
|
| 1989 |
|
| 1990 |
Args:
|
| 1991 |
score: music21 Score with measures
|
| 1992 |
+
log_removed: If True, log details of first few removed notes
|
| 1993 |
|
| 1994 |
Returns:
|
| 1995 |
Cleaned score
|
| 1996 |
"""
|
| 1997 |
from music21 import note, stream
|
| 1998 |
|
| 1999 |
+
# Remove notes shorter than 64th note (lowered from 32nd)
|
| 2000 |
+
# YourMT3+ produces cleaner output, so we can use a lower threshold
|
| 2001 |
+
# 64th note = 0.0625 quarter notes (1.0 / 16)
|
| 2002 |
+
MIN_DURATION = 0.0625 # 64th note
|
|
|
|
| 2003 |
|
| 2004 |
+
removed_notes = []
|
| 2005 |
for part in score.parts:
|
| 2006 |
for measure in part.getElementsByClass('Measure'):
|
| 2007 |
# Collect elements to remove
|
|
|
|
| 2009 |
|
| 2010 |
for element in measure.notesAndRests:
|
| 2011 |
if element.quarterLength < MIN_DURATION:
|
| 2012 |
+
# Log note details before removing
|
| 2013 |
+
if log_removed and len(removed_notes) < 10:
|
| 2014 |
+
if hasattr(element, 'pitch'):
|
| 2015 |
+
pitch_name = element.pitch.nameWithOctave
|
| 2016 |
+
elif isinstance(element, note.Rest):
|
| 2017 |
+
pitch_name = 'Rest'
|
| 2018 |
+
else:
|
| 2019 |
+
pitch_name = 'Unknown'
|
| 2020 |
+
|
| 2021 |
+
removed_notes.append({
|
| 2022 |
+
'pitch': pitch_name,
|
| 2023 |
+
'duration': element.quarterLength,
|
| 2024 |
+
'offset': element.offset,
|
| 2025 |
+
'measure': measure.number if hasattr(measure, 'number') else 0
|
| 2026 |
+
})
|
| 2027 |
+
|
| 2028 |
to_remove.append(element)
|
|
|
|
| 2029 |
|
| 2030 |
# Remove impossible durations
|
| 2031 |
for element in to_remove:
|
| 2032 |
measure.remove(element)
|
| 2033 |
|
| 2034 |
+
if removed_notes:
|
| 2035 |
+
print(f" Removed {len(removed_notes)} notes/rests shorter than 64th note:")
|
| 2036 |
+
for note_data in removed_notes[:5]: # Show first 5
|
| 2037 |
+
# Convert Fraction to float for formatting
|
| 2038 |
+
duration_val = float(note_data['duration']) if hasattr(note_data['duration'], 'numerator') else note_data['duration']
|
| 2039 |
+
offset_val = float(note_data['offset']) if hasattr(note_data['offset'], 'numerator') else note_data['offset']
|
| 2040 |
+
print(f" - Measure {note_data['measure']}: {note_data['pitch']} @ {offset_val:.4f} (dur: {duration_val:.6f})")
|
| 2041 |
+
if len(removed_notes) > 5:
|
| 2042 |
+
print(f" ... and {len(removed_notes) - 5} more")
|
| 2043 |
|
| 2044 |
return score
|
| 2045 |
|
backend/scripts/diagnose_makemeasures.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Diagnostic script to analyze what music21's makeMeasures() does to notes.
|
| 4 |
+
|
| 5 |
+
Purpose: Understand how makeMeasures() transforms note durations and timing.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python diagnose_makemeasures.py <midi_file> [--time-sig 4/4]
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import List, Dict, Any
|
| 14 |
+
from music21 import converter, note, chord, meter
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def extract_note_data(element) -> List[Dict[str, Any]]:
|
| 18 |
+
"""Extract note data from a note or chord element."""
|
| 19 |
+
notes = []
|
| 20 |
+
|
| 21 |
+
if isinstance(element, note.Note):
|
| 22 |
+
notes.append({
|
| 23 |
+
'pitch': element.pitch.midi,
|
| 24 |
+
'pitch_name': element.pitch.nameWithOctave,
|
| 25 |
+
'offset': float(element.offset),
|
| 26 |
+
'duration': float(element.quarterLength),
|
| 27 |
+
'type': 'note'
|
| 28 |
+
})
|
| 29 |
+
elif isinstance(element, chord.Chord):
|
| 30 |
+
for pitch in element.pitches:
|
| 31 |
+
notes.append({
|
| 32 |
+
'pitch': pitch.midi,
|
| 33 |
+
'pitch_name': pitch.nameWithOctave,
|
| 34 |
+
'offset': float(element.offset),
|
| 35 |
+
'duration': float(element.quarterLength),
|
| 36 |
+
'type': 'chord'
|
| 37 |
+
})
|
| 38 |
+
elif isinstance(element, note.Rest):
|
| 39 |
+
notes.append({
|
| 40 |
+
'pitch': None,
|
| 41 |
+
'pitch_name': 'Rest',
|
| 42 |
+
'offset': float(element.offset),
|
| 43 |
+
'duration': float(element.quarterLength),
|
| 44 |
+
'type': 'rest'
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
return notes
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def dump_score_notes(score, label: str) -> List[Dict[str, Any]]:
|
| 51 |
+
"""Dump all notes from score with optional label."""
|
| 52 |
+
notes_list = []
|
| 53 |
+
|
| 54 |
+
for element in score.flatten().notesAndRests:
|
| 55 |
+
notes_list.extend(extract_note_data(element))
|
| 56 |
+
|
| 57 |
+
return sorted(notes_list, key=lambda x: (x['offset'], x['pitch'] if x['pitch'] is not None else -1))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def analyze_makemeasures_changes(before_notes: List[Dict[str, Any]],
|
| 61 |
+
after_notes: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 62 |
+
"""
|
| 63 |
+
Analyze what changed between before and after makeMeasures().
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
- note_count_change: Difference in note count
|
| 67 |
+
- duration_changes: Notes whose durations changed
|
| 68 |
+
- timing_changes: Notes whose offsets changed
|
| 69 |
+
- new_rests: Rests that were inserted
|
| 70 |
+
- split_notes: Notes that were split into multiple notes
|
| 71 |
+
"""
|
| 72 |
+
report = {
|
| 73 |
+
'before_count': len(before_notes),
|
| 74 |
+
'after_count': len(after_notes),
|
| 75 |
+
'note_count_change': len(after_notes) - len(before_notes),
|
| 76 |
+
'duration_changes': [],
|
| 77 |
+
'timing_changes': [],
|
| 78 |
+
'new_rests': [],
|
| 79 |
+
'split_notes': [],
|
| 80 |
+
'impossible_durations': []
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
# Count rests
|
| 84 |
+
before_rests = [n for n in before_notes if n['type'] == 'rest']
|
| 85 |
+
after_rests = [n for n in after_notes if n['type'] == 'rest']
|
| 86 |
+
|
| 87 |
+
report['new_rests'] = [r for r in after_rests if r not in before_rests]
|
| 88 |
+
|
| 89 |
+
# Find impossible durations (< 64th note = 0.0625 quarter notes)
|
| 90 |
+
MIN_DURATION = 0.0625
|
| 91 |
+
for note_data in after_notes:
|
| 92 |
+
if note_data['duration'] < MIN_DURATION:
|
| 93 |
+
report['impossible_durations'].append({
|
| 94 |
+
'note': note_data['pitch_name'],
|
| 95 |
+
'offset': note_data['offset'],
|
| 96 |
+
'duration': note_data['duration'],
|
| 97 |
+
'type': note_data['type']
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
# Try to match before/after notes
|
| 101 |
+
after_matched = [False] * len(after_notes)
|
| 102 |
+
|
| 103 |
+
for before_note in before_notes:
|
| 104 |
+
if before_note['type'] == 'rest':
|
| 105 |
+
continue # Skip rests for now
|
| 106 |
+
|
| 107 |
+
# Find matching note(s) in after_notes
|
| 108 |
+
matches = []
|
| 109 |
+
for i, after_note in enumerate(after_notes):
|
| 110 |
+
if after_matched[i]:
|
| 111 |
+
continue
|
| 112 |
+
if after_note['pitch'] == before_note['pitch']:
|
| 113 |
+
# Check if timing is close (within 0.1 quarter notes)
|
| 114 |
+
timing_diff = abs(after_note['offset'] - before_note['offset'])
|
| 115 |
+
if timing_diff < 0.1:
|
| 116 |
+
matches.append((i, after_note, timing_diff))
|
| 117 |
+
|
| 118 |
+
if len(matches) == 0:
|
| 119 |
+
# Note disappeared?
|
| 120 |
+
report['duration_changes'].append({
|
| 121 |
+
'before': before_note,
|
| 122 |
+
'after': None,
|
| 123 |
+
'change': 'disappeared'
|
| 124 |
+
})
|
| 125 |
+
elif len(matches) == 1:
|
| 126 |
+
# One-to-one match
|
| 127 |
+
idx, after_note, _ = matches[0]
|
| 128 |
+
after_matched[idx] = True
|
| 129 |
+
|
| 130 |
+
# Check for duration change
|
| 131 |
+
duration_diff = abs(after_note['duration'] - before_note['duration'])
|
| 132 |
+
if duration_diff > 0.001:
|
| 133 |
+
report['duration_changes'].append({
|
| 134 |
+
'note': before_note['pitch_name'],
|
| 135 |
+
'before_duration': before_note['duration'],
|
| 136 |
+
'after_duration': after_note['duration'],
|
| 137 |
+
'difference': after_note['duration'] - before_note['duration'],
|
| 138 |
+
'offset': before_note['offset']
|
| 139 |
+
})
|
| 140 |
+
|
| 141 |
+
# Check for timing change
|
| 142 |
+
timing_diff = abs(after_note['offset'] - before_note['offset'])
|
| 143 |
+
if timing_diff > 0.001:
|
| 144 |
+
report['timing_changes'].append({
|
| 145 |
+
'note': before_note['pitch_name'],
|
| 146 |
+
'before_offset': before_note['offset'],
|
| 147 |
+
'after_offset': after_note['offset'],
|
| 148 |
+
'difference': after_note['offset'] - before_note['offset']
|
| 149 |
+
})
|
| 150 |
+
else:
|
| 151 |
+
# Multiple matches - note was split
|
| 152 |
+
for idx, after_note, _ in matches:
|
| 153 |
+
after_matched[idx] = True
|
| 154 |
+
|
| 155 |
+
total_after_duration = sum(m[1]['duration'] for m in matches)
|
| 156 |
+
report['split_notes'].append({
|
| 157 |
+
'note': before_note['pitch_name'],
|
| 158 |
+
'before_duration': before_note['duration'],
|
| 159 |
+
'after_count': len(matches),
|
| 160 |
+
'after_total_duration': total_after_duration,
|
| 161 |
+
'after_notes': [m[1] for m in matches]
|
| 162 |
+
})
|
| 163 |
+
|
| 164 |
+
return report
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def print_forensics_report(report: Dict[str, Any]):
|
| 168 |
+
"""Print forensics report in human-readable format."""
|
| 169 |
+
print("\n" + "="*80)
|
| 170 |
+
print("music21 makeMeasures() FORENSICS REPORT")
|
| 171 |
+
print("="*80)
|
| 172 |
+
|
| 173 |
+
print(f"\n📊 NOTE COUNT:")
|
| 174 |
+
print(f" Before makeMeasures(): {report['before_count']}")
|
| 175 |
+
print(f" After makeMeasures(): {report['after_count']}")
|
| 176 |
+
print(f" Change: {report['note_count_change']:+d}")
|
| 177 |
+
|
| 178 |
+
if report['new_rests']:
|
| 179 |
+
print(f"\n🎵 NEW RESTS INSERTED: {len(report['new_rests'])}")
|
| 180 |
+
for i, rest in enumerate(report['new_rests'][:10]):
|
| 181 |
+
print(f" {i+1}. Rest at offset {rest['offset']:.4f}, duration {rest['duration']:.4f}")
|
| 182 |
+
|
| 183 |
+
if report['impossible_durations']:
|
| 184 |
+
print(f"\n⚠️ IMPOSSIBLE DURATIONS CREATED: {len(report['impossible_durations'])}")
|
| 185 |
+
print(f" (notes shorter than 64th note = 0.0625 quarter notes)")
|
| 186 |
+
for i, note_data in enumerate(report['impossible_durations'][:10]):
|
| 187 |
+
print(f" {i+1}. {note_data['note']} at {note_data['offset']:.4f}, duration {note_data['duration']:.6f}")
|
| 188 |
+
|
| 189 |
+
if report['duration_changes']:
|
| 190 |
+
print(f"\n⏱️ DURATION CHANGES: {len(report['duration_changes'])}")
|
| 191 |
+
for i, change in enumerate(report['duration_changes'][:10]):
|
| 192 |
+
if change.get('change') == 'disappeared':
|
| 193 |
+
print(f" {i+1}. {change['before']['pitch_name']} DISAPPEARED")
|
| 194 |
+
print(f" Before: offset {change['before']['offset']:.4f}, duration {change['before']['duration']:.4f}")
|
| 195 |
+
else:
|
| 196 |
+
print(f" {i+1}. {change['note']} at offset {change['offset']:.4f}")
|
| 197 |
+
print(f" Before: {change['before_duration']:.4f} → After: {change['after_duration']:.4f}")
|
| 198 |
+
print(f" Difference: {change['difference']:+.4f}")
|
| 199 |
+
|
| 200 |
+
if report['timing_changes']:
|
| 201 |
+
print(f"\n📍 TIMING CHANGES: {len(report['timing_changes'])}")
|
| 202 |
+
for i, change in enumerate(report['timing_changes'][:10]):
|
| 203 |
+
print(f" {i+1}. {change['note']}")
|
| 204 |
+
print(f" Before: {change['before_offset']:.4f} → After: {change['after_offset']:.4f}")
|
| 205 |
+
print(f" Difference: {change['difference']:+.4f}")
|
| 206 |
+
|
| 207 |
+
if report['split_notes']:
|
| 208 |
+
print(f"\n✂️ NOTES SPLIT: {len(report['split_notes'])}")
|
| 209 |
+
for i, split in enumerate(report['split_notes'][:5]):
|
| 210 |
+
print(f" {i+1}. {split['note']} (duration {split['before_duration']:.4f}) split into {split['after_count']} notes:")
|
| 211 |
+
for j, after_note in enumerate(split['after_notes']):
|
| 212 |
+
print(f" {j+1}. Offset {after_note['offset']:.4f}, duration {after_note['duration']:.4f}")
|
| 213 |
+
|
| 214 |
+
print("\n" + "="*80)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def main():
|
| 218 |
+
parser = argparse.ArgumentParser(
|
| 219 |
+
description='Analyze what music21.makeMeasures() does to notes'
|
| 220 |
+
)
|
| 221 |
+
parser.add_argument('midi_file', type=Path, help='Path to MIDI file')
|
| 222 |
+
parser.add_argument('--time-sig', '-t', type=str, default='4/4',
|
| 223 |
+
help='Time signature to use (default: 4/4)')
|
| 224 |
+
|
| 225 |
+
args = parser.parse_args()
|
| 226 |
+
|
| 227 |
+
if not args.midi_file.exists():
|
| 228 |
+
print(f"ERROR: MIDI file not found: {args.midi_file}")
|
| 229 |
+
return 1
|
| 230 |
+
|
| 231 |
+
print(f"🔬 Analyzing makeMeasures() behavior...")
|
| 232 |
+
print(f" MIDI file: {args.midi_file}")
|
| 233 |
+
print(f" Time signature: {args.time_sig}")
|
| 234 |
+
|
| 235 |
+
# Parse MIDI
|
| 236 |
+
print(f"\n📝 Loading MIDI file...")
|
| 237 |
+
score = converter.parse(args.midi_file)
|
| 238 |
+
|
| 239 |
+
# Dump notes BEFORE makeMeasures
|
| 240 |
+
print(f"📝 Extracting notes BEFORE makeMeasures()...")
|
| 241 |
+
before_notes = dump_score_notes(score, "BEFORE")
|
| 242 |
+
|
| 243 |
+
# Apply makeMeasures
|
| 244 |
+
print(f"🔧 Calling makeMeasures()...")
|
| 245 |
+
time_sig_parts = args.time_sig.split('/')
|
| 246 |
+
time_sig = meter.TimeSignature(args.time_sig)
|
| 247 |
+
|
| 248 |
+
score_with_measures = score.makeMeasures()
|
| 249 |
+
|
| 250 |
+
# Dump notes AFTER makeMeasures
|
| 251 |
+
print(f"📝 Extracting notes AFTER makeMeasures()...")
|
| 252 |
+
after_notes = dump_score_notes(score_with_measures, "AFTER")
|
| 253 |
+
|
| 254 |
+
# Analyze changes
|
| 255 |
+
print(f"🔬 Analyzing changes...")
|
| 256 |
+
report = analyze_makemeasures_changes(before_notes, after_notes)
|
| 257 |
+
|
| 258 |
+
# Print report
|
| 259 |
+
print_forensics_report(report)
|
| 260 |
+
|
| 261 |
+
return 0
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
if __name__ == '__main__':
|
| 265 |
+
exit(main())
|
backend/scripts/diagnose_musicxml.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Diagnostic script to compare MIDI with generated MusicXML.
|
| 4 |
+
|
| 5 |
+
Purpose: Identify where note data is lost or corrupted during MIDI → MusicXML conversion.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python diagnose_musicxml.py <midi_file> <musicxml_file> [--output report.json]
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import json
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import List, Dict, Any, Tuple
|
| 15 |
+
from music21 import converter, note, chord
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def extract_notes_from_midi(midi_path: Path) -> List[Dict[str, Any]]:
|
| 19 |
+
"""
|
| 20 |
+
Extract all notes from MIDI file.
|
| 21 |
+
|
| 22 |
+
Returns list of note dictionaries with:
|
| 23 |
+
- pitch: MIDI note number (0-127)
|
| 24 |
+
- start: Start time in quarter notes
|
| 25 |
+
- duration: Duration in quarter notes
|
| 26 |
+
- velocity: MIDI velocity (0-127)
|
| 27 |
+
"""
|
| 28 |
+
score = converter.parse(midi_path)
|
| 29 |
+
notes_list = []
|
| 30 |
+
|
| 31 |
+
for element in score.flatten().notesAndRests:
|
| 32 |
+
if isinstance(element, note.Note):
|
| 33 |
+
notes_list.append({
|
| 34 |
+
'pitch': element.pitch.midi,
|
| 35 |
+
'pitch_name': element.pitch.nameWithOctave,
|
| 36 |
+
'start': float(element.offset),
|
| 37 |
+
'duration': float(element.quarterLength),
|
| 38 |
+
'velocity': element.volume.velocity if element.volume.velocity else 64,
|
| 39 |
+
'is_rest': False
|
| 40 |
+
})
|
| 41 |
+
elif isinstance(element, chord.Chord):
|
| 42 |
+
# Expand chords into individual notes
|
| 43 |
+
for pitch in element.pitches:
|
| 44 |
+
notes_list.append({
|
| 45 |
+
'pitch': pitch.midi,
|
| 46 |
+
'pitch_name': pitch.nameWithOctave,
|
| 47 |
+
'start': float(element.offset),
|
| 48 |
+
'duration': float(element.quarterLength),
|
| 49 |
+
'velocity': element.volume.velocity if element.volume.velocity else 64,
|
| 50 |
+
'is_rest': False
|
| 51 |
+
})
|
| 52 |
+
elif isinstance(element, note.Rest):
|
| 53 |
+
notes_list.append({
|
| 54 |
+
'pitch': None,
|
| 55 |
+
'pitch_name': 'Rest',
|
| 56 |
+
'start': float(element.offset),
|
| 57 |
+
'duration': float(element.quarterLength),
|
| 58 |
+
'velocity': 0,
|
| 59 |
+
'is_rest': True
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
return sorted(notes_list, key=lambda x: (x['start'], x['pitch'] if x['pitch'] is not None else -1))
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def extract_notes_from_musicxml(musicxml_path: Path) -> List[Dict[str, Any]]:
|
| 66 |
+
"""
|
| 67 |
+
Extract all notes from MusicXML file.
|
| 68 |
+
|
| 69 |
+
Returns same format as extract_notes_from_midi().
|
| 70 |
+
"""
|
| 71 |
+
score = converter.parse(musicxml_path)
|
| 72 |
+
notes_list = []
|
| 73 |
+
|
| 74 |
+
for element in score.flatten().notesAndRests:
|
| 75 |
+
if isinstance(element, note.Note):
|
| 76 |
+
notes_list.append({
|
| 77 |
+
'pitch': element.pitch.midi,
|
| 78 |
+
'pitch_name': element.pitch.nameWithOctave,
|
| 79 |
+
'start': float(element.offset),
|
| 80 |
+
'duration': float(element.quarterLength),
|
| 81 |
+
'velocity': element.volume.velocity if element.volume.velocity else 64,
|
| 82 |
+
'is_rest': False
|
| 83 |
+
})
|
| 84 |
+
elif isinstance(element, chord.Chord):
|
| 85 |
+
# Expand chords into individual notes
|
| 86 |
+
for pitch in element.pitches:
|
| 87 |
+
notes_list.append({
|
| 88 |
+
'pitch': pitch.midi,
|
| 89 |
+
'pitch_name': pitch.nameWithOctave,
|
| 90 |
+
'start': float(element.offset),
|
| 91 |
+
'duration': float(element.quarterLength),
|
| 92 |
+
'velocity': element.volume.velocity if element.volume.velocity else 64,
|
| 93 |
+
'is_rest': False
|
| 94 |
+
})
|
| 95 |
+
elif isinstance(element, note.Rest):
|
| 96 |
+
notes_list.append({
|
| 97 |
+
'pitch': None,
|
| 98 |
+
'pitch_name': 'Rest',
|
| 99 |
+
'start': float(element.offset),
|
| 100 |
+
'duration': float(element.quarterLength),
|
| 101 |
+
'velocity': 0,
|
| 102 |
+
'is_rest': True
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
return sorted(notes_list, key=lambda x: (x['start'], x['pitch'] if x['pitch'] is not None else -1))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def find_note_match(target_note: Dict[str, Any], candidates: List[Dict[str, Any]],
|
| 109 |
+
tolerance: float = 0.05) -> Tuple[int, float]:
|
| 110 |
+
"""
|
| 111 |
+
Find best matching note in candidates list.
|
| 112 |
+
|
| 113 |
+
Returns: (index, match_score) where match_score = 0.0 (perfect) to 1.0 (no match)
|
| 114 |
+
"""
|
| 115 |
+
best_idx = -1
|
| 116 |
+
best_score = float('inf')
|
| 117 |
+
|
| 118 |
+
for idx, candidate in enumerate(candidates):
|
| 119 |
+
# Skip if rest/note mismatch
|
| 120 |
+
if target_note['is_rest'] != candidate['is_rest']:
|
| 121 |
+
continue
|
| 122 |
+
|
| 123 |
+
# For rests, only check timing
|
| 124 |
+
if target_note['is_rest']:
|
| 125 |
+
timing_diff = abs(target_note['start'] - candidate['start'])
|
| 126 |
+
duration_diff = abs(target_note['duration'] - candidate['duration'])
|
| 127 |
+
score = timing_diff + duration_diff
|
| 128 |
+
else:
|
| 129 |
+
# For notes, check pitch + timing + duration
|
| 130 |
+
if target_note['pitch'] != candidate['pitch']:
|
| 131 |
+
continue # Pitch must match exactly
|
| 132 |
+
|
| 133 |
+
timing_diff = abs(target_note['start'] - candidate['start'])
|
| 134 |
+
duration_diff = abs(target_note['duration'] - candidate['duration'])
|
| 135 |
+
score = timing_diff + duration_diff
|
| 136 |
+
|
| 137 |
+
if score < best_score:
|
| 138 |
+
best_score = score
|
| 139 |
+
best_idx = idx
|
| 140 |
+
|
| 141 |
+
return best_idx, best_score
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def compare_note_lists(midi_notes: List[Dict[str, Any]],
|
| 145 |
+
xml_notes: List[Dict[str, Any]],
|
| 146 |
+
tolerance: float = 0.05) -> Dict[str, Any]:
|
| 147 |
+
"""
|
| 148 |
+
Compare MIDI notes with MusicXML notes.
|
| 149 |
+
|
| 150 |
+
Returns diagnostic report with:
|
| 151 |
+
- total_midi_notes
|
| 152 |
+
- total_musicxml_notes
|
| 153 |
+
- missing_notes (in MIDI but not MusicXML)
|
| 154 |
+
- extra_notes (in MusicXML but not MIDI)
|
| 155 |
+
- duration_mismatches
|
| 156 |
+
- timing_mismatches
|
| 157 |
+
"""
|
| 158 |
+
report = {
|
| 159 |
+
'total_midi_notes': len(midi_notes),
|
| 160 |
+
'total_musicxml_notes': len(xml_notes),
|
| 161 |
+
'missing_notes': [],
|
| 162 |
+
'extra_notes': [],
|
| 163 |
+
'duration_mismatches': [],
|
| 164 |
+
'timing_mismatches': [],
|
| 165 |
+
'perfect_matches': 0,
|
| 166 |
+
'tolerance': tolerance
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
# Track which XML notes have been matched
|
| 170 |
+
xml_matched = [False] * len(xml_notes)
|
| 171 |
+
|
| 172 |
+
# For each MIDI note, find matching XML note
|
| 173 |
+
for midi_note in midi_notes:
|
| 174 |
+
# Only search unmatched XML notes
|
| 175 |
+
unmatched_xml = [xml_notes[i] for i, matched in enumerate(xml_matched) if not matched]
|
| 176 |
+
unmatched_indices = [i for i, matched in enumerate(xml_matched) if not matched]
|
| 177 |
+
|
| 178 |
+
if not unmatched_xml:
|
| 179 |
+
report['missing_notes'].append(midi_note)
|
| 180 |
+
continue
|
| 181 |
+
|
| 182 |
+
rel_idx, match_score = find_note_match(midi_note, unmatched_xml, tolerance)
|
| 183 |
+
|
| 184 |
+
if rel_idx == -1 or match_score > tolerance * 2:
|
| 185 |
+
# No good match found
|
| 186 |
+
report['missing_notes'].append(midi_note)
|
| 187 |
+
else:
|
| 188 |
+
# Found a match
|
| 189 |
+
actual_idx = unmatched_indices[rel_idx]
|
| 190 |
+
xml_matched[actual_idx] = True
|
| 191 |
+
xml_note = xml_notes[actual_idx]
|
| 192 |
+
|
| 193 |
+
if match_score < 0.001:
|
| 194 |
+
report['perfect_matches'] += 1
|
| 195 |
+
else:
|
| 196 |
+
# Check what's different
|
| 197 |
+
if not midi_note['is_rest']:
|
| 198 |
+
duration_diff = abs(midi_note['duration'] - xml_note['duration'])
|
| 199 |
+
if duration_diff > tolerance:
|
| 200 |
+
report['duration_mismatches'].append({
|
| 201 |
+
'note': midi_note['pitch_name'],
|
| 202 |
+
'midi_duration': midi_note['duration'],
|
| 203 |
+
'xml_duration': xml_note['duration'],
|
| 204 |
+
'difference': duration_diff,
|
| 205 |
+
'midi_start': midi_note['start'],
|
| 206 |
+
'xml_start': xml_note['start']
|
| 207 |
+
})
|
| 208 |
+
|
| 209 |
+
timing_diff = abs(midi_note['start'] - xml_note['start'])
|
| 210 |
+
if timing_diff > tolerance:
|
| 211 |
+
report['timing_mismatches'].append({
|
| 212 |
+
'note': midi_note['pitch_name'],
|
| 213 |
+
'midi_start': midi_note['start'],
|
| 214 |
+
'xml_start': xml_note['start'],
|
| 215 |
+
'difference': timing_diff
|
| 216 |
+
})
|
| 217 |
+
|
| 218 |
+
# Any unmatched XML notes are "extra"
|
| 219 |
+
for idx, matched in enumerate(xml_matched):
|
| 220 |
+
if not matched:
|
| 221 |
+
report['extra_notes'].append(xml_notes[idx])
|
| 222 |
+
|
| 223 |
+
return report
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def print_report(report: Dict[str, Any]):
|
| 227 |
+
"""Print diagnostic report in human-readable format."""
|
| 228 |
+
print("\n" + "="*80)
|
| 229 |
+
print("MIDI → MusicXML DIAGNOSTIC REPORT")
|
| 230 |
+
print("="*80)
|
| 231 |
+
|
| 232 |
+
print(f"\n📊 SUMMARY:")
|
| 233 |
+
print(f" Total MIDI notes: {report['total_midi_notes']}")
|
| 234 |
+
print(f" Total MusicXML notes: {report['total_musicxml_notes']}")
|
| 235 |
+
print(f" Perfect matches: {report['perfect_matches']}")
|
| 236 |
+
print(f" Missing notes: {len(report['missing_notes'])} (in MIDI but not MusicXML)")
|
| 237 |
+
print(f" Extra notes: {len(report['extra_notes'])} (in MusicXML but not MIDI)")
|
| 238 |
+
print(f" Duration mismatches: {len(report['duration_mismatches'])}")
|
| 239 |
+
print(f" Timing mismatches: {len(report['timing_mismatches'])}")
|
| 240 |
+
|
| 241 |
+
# Calculate accuracy
|
| 242 |
+
if report['total_midi_notes'] > 0:
|
| 243 |
+
accuracy = (report['perfect_matches'] / report['total_midi_notes']) * 100
|
| 244 |
+
print(f"\n ✅ Match accuracy: {accuracy:.1f}%")
|
| 245 |
+
|
| 246 |
+
# Show missing notes
|
| 247 |
+
if report['missing_notes']:
|
| 248 |
+
print(f"\n❌ MISSING NOTES (first 10):")
|
| 249 |
+
for i, note in enumerate(report['missing_notes'][:10]):
|
| 250 |
+
if note['is_rest']:
|
| 251 |
+
print(f" {i+1}. Rest at {note['start']:.2f}, duration {note['duration']:.4f}")
|
| 252 |
+
else:
|
| 253 |
+
print(f" {i+1}. {note['pitch_name']} (MIDI {note['pitch']}) at {note['start']:.2f}, duration {note['duration']:.4f}")
|
| 254 |
+
|
| 255 |
+
# Show extra notes
|
| 256 |
+
if report['extra_notes']:
|
| 257 |
+
print(f"\n➕ EXTRA NOTES (first 10):")
|
| 258 |
+
for i, note in enumerate(report['extra_notes'][:10]):
|
| 259 |
+
if note['is_rest']:
|
| 260 |
+
print(f" {i+1}. Rest at {note['start']:.2f}, duration {note['duration']:.4f}")
|
| 261 |
+
else:
|
| 262 |
+
print(f" {i+1}. {note['pitch_name']} (MIDI {note['pitch']}) at {note['start']:.2f}, duration {note['duration']:.4f}")
|
| 263 |
+
|
| 264 |
+
# Show duration mismatches
|
| 265 |
+
if report['duration_mismatches']:
|
| 266 |
+
print(f"\n⏱️ DURATION MISMATCHES (first 10):")
|
| 267 |
+
for i, mismatch in enumerate(report['duration_mismatches'][:10]):
|
| 268 |
+
print(f" {i+1}. {mismatch['note']} at {mismatch['midi_start']:.2f}:")
|
| 269 |
+
print(f" MIDI: {mismatch['midi_duration']:.4f} → MusicXML: {mismatch['xml_duration']:.4f}")
|
| 270 |
+
print(f" Difference: {mismatch['difference']:.4f} quarter notes")
|
| 271 |
+
|
| 272 |
+
# Show timing mismatches
|
| 273 |
+
if report['timing_mismatches']:
|
| 274 |
+
print(f"\n📍 TIMING MISMATCHES (first 10):")
|
| 275 |
+
for i, mismatch in enumerate(report['timing_mismatches'][:10]):
|
| 276 |
+
print(f" {i+1}. {mismatch['note']}:")
|
| 277 |
+
print(f" MIDI: {mismatch['midi_start']:.4f} → MusicXML: {mismatch['xml_start']:.4f}")
|
| 278 |
+
print(f" Difference: {mismatch['difference']:.4f} quarter notes")
|
| 279 |
+
|
| 280 |
+
print("\n" + "="*80)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def main():
|
| 284 |
+
parser = argparse.ArgumentParser(
|
| 285 |
+
description='Compare MIDI with MusicXML to diagnose conversion issues'
|
| 286 |
+
)
|
| 287 |
+
parser.add_argument('midi_file', type=Path, help='Path to MIDI file')
|
| 288 |
+
parser.add_argument('musicxml_file', type=Path, help='Path to MusicXML file')
|
| 289 |
+
parser.add_argument('--output', '-o', type=Path, help='Output JSON report file')
|
| 290 |
+
parser.add_argument('--tolerance', '-t', type=float, default=0.05,
|
| 291 |
+
help='Timing/duration tolerance in quarter notes (default: 0.05)')
|
| 292 |
+
|
| 293 |
+
args = parser.parse_args()
|
| 294 |
+
|
| 295 |
+
# Validate input files
|
| 296 |
+
if not args.midi_file.exists():
|
| 297 |
+
print(f"ERROR: MIDI file not found: {args.midi_file}")
|
| 298 |
+
return 1
|
| 299 |
+
|
| 300 |
+
if not args.musicxml_file.exists():
|
| 301 |
+
print(f"ERROR: MusicXML file not found: {args.musicxml_file}")
|
| 302 |
+
return 1
|
| 303 |
+
|
| 304 |
+
print(f"🔍 Analyzing MIDI → MusicXML conversion...")
|
| 305 |
+
print(f" MIDI: {args.midi_file}")
|
| 306 |
+
print(f" MusicXML: {args.musicxml_file}")
|
| 307 |
+
print(f" Tolerance: {args.tolerance} quarter notes")
|
| 308 |
+
|
| 309 |
+
# Extract notes
|
| 310 |
+
print("\n📝 Extracting notes from MIDI...")
|
| 311 |
+
midi_notes = extract_notes_from_midi(args.midi_file)
|
| 312 |
+
|
| 313 |
+
print(f"📝 Extracting notes from MusicXML...")
|
| 314 |
+
xml_notes = extract_notes_from_musicxml(args.musicxml_file)
|
| 315 |
+
|
| 316 |
+
# Compare
|
| 317 |
+
print(f"🔬 Comparing notes...")
|
| 318 |
+
report = compare_note_lists(midi_notes, xml_notes, args.tolerance)
|
| 319 |
+
|
| 320 |
+
# Print report
|
| 321 |
+
print_report(report)
|
| 322 |
+
|
| 323 |
+
# Save JSON if requested
|
| 324 |
+
if args.output:
|
| 325 |
+
with open(args.output, 'w') as f:
|
| 326 |
+
json.dump(report, f, indent=2)
|
| 327 |
+
print(f"\n💾 Full report saved to: {args.output}")
|
| 328 |
+
|
| 329 |
+
return 0
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
if __name__ == '__main__':
|
| 333 |
+
exit(main())
|
backend/tasks.py
CHANGED
|
@@ -99,10 +99,25 @@ def process_transcription_task(self, job_id: str):
|
|
| 99 |
# Copy the MusicXML file to outputs
|
| 100 |
shutil.copy(str(temp_output_path), str(output_path))
|
| 101 |
|
| 102 |
-
# Copy the
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
if temp_midi_path.exists():
|
|
|
|
| 105 |
shutil.copy(str(temp_midi_path), str(midi_path))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
# Cleanup temp files (pipeline has its own cleanup method)
|
| 108 |
pipeline.cleanup()
|
|
@@ -113,6 +128,7 @@ def process_transcription_task(self, job_id: str):
|
|
| 113 |
"progress": 100,
|
| 114 |
"output_path": str(output_path.absolute()),
|
| 115 |
"midi_path": str(midi_path.absolute()) if temp_midi_path.exists() else "",
|
|
|
|
| 116 |
"completed_at": datetime.utcnow().isoformat(),
|
| 117 |
})
|
| 118 |
|
|
@@ -128,12 +144,25 @@ def process_transcription_task(self, job_id: str):
|
|
| 128 |
return str(output_path)
|
| 129 |
|
| 130 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
# Mark job as failed
|
| 132 |
redis_client.hset(f"job:{job_id}", mapping={
|
| 133 |
"status": "failed",
|
| 134 |
"error": json.dumps({
|
| 135 |
"message": str(e),
|
| 136 |
-
"
|
|
|
|
|
|
|
| 137 |
}),
|
| 138 |
"failed_at": datetime.utcnow().isoformat(),
|
| 139 |
})
|
|
@@ -144,16 +173,20 @@ def process_transcription_task(self, job_id: str):
|
|
| 144 |
"job_id": job_id,
|
| 145 |
"error": {
|
| 146 |
"message": str(e),
|
| 147 |
-
"
|
|
|
|
| 148 |
},
|
| 149 |
"timestamp": datetime.utcnow().isoformat(),
|
| 150 |
}
|
| 151 |
redis_client.publish(f"job:{job_id}:updates", json.dumps(error_msg))
|
| 152 |
|
| 153 |
-
#
|
| 154 |
-
if
|
|
|
|
| 155 |
raise self.retry(exc=e, countdown=2 ** self.request.retries)
|
| 156 |
else:
|
|
|
|
|
|
|
| 157 |
raise
|
| 158 |
|
| 159 |
|
|
|
|
| 99 |
# Copy the MusicXML file to outputs
|
| 100 |
shutil.copy(str(temp_output_path), str(output_path))
|
| 101 |
|
| 102 |
+
# Copy the MIDI file to outputs (YourMT3+ or basic-pitch)
|
| 103 |
+
# transcribe_to_midi() renames all MIDI files to piano.mid for consistency
|
| 104 |
+
temp_midi_path = pipeline.temp_dir / "piano.mid"
|
| 105 |
+
print(f"[DEBUG] Checking for MIDI at: {temp_midi_path}")
|
| 106 |
+
print(f"[DEBUG] MIDI exists: {temp_midi_path.exists()}")
|
| 107 |
+
|
| 108 |
if temp_midi_path.exists():
|
| 109 |
+
print(f"[DEBUG] Copying MIDI from {temp_midi_path} to {midi_path}")
|
| 110 |
shutil.copy(str(temp_midi_path), str(midi_path))
|
| 111 |
+
print(f"[DEBUG] Copy complete, destination exists: {midi_path.exists()}")
|
| 112 |
+
else:
|
| 113 |
+
print(f"[DEBUG] WARNING: No MIDI file found at either location!")
|
| 114 |
+
|
| 115 |
+
# Store metadata for API access
|
| 116 |
+
metadata = getattr(pipeline, 'metadata', {
|
| 117 |
+
"tempo": 120.0,
|
| 118 |
+
"time_signature": {"numerator": 4, "denominator": 4},
|
| 119 |
+
"key_signature": "C",
|
| 120 |
+
})
|
| 121 |
|
| 122 |
# Cleanup temp files (pipeline has its own cleanup method)
|
| 123 |
pipeline.cleanup()
|
|
|
|
| 128 |
"progress": 100,
|
| 129 |
"output_path": str(output_path.absolute()),
|
| 130 |
"midi_path": str(midi_path.absolute()) if temp_midi_path.exists() else "",
|
| 131 |
+
"metadata": json.dumps(metadata),
|
| 132 |
"completed_at": datetime.utcnow().isoformat(),
|
| 133 |
})
|
| 134 |
|
|
|
|
| 144 |
return str(output_path)
|
| 145 |
|
| 146 |
except Exception as e:
|
| 147 |
+
import traceback
|
| 148 |
+
|
| 149 |
+
# Determine if error is retryable (only retry transient errors, not code bugs)
|
| 150 |
+
RETRYABLE_EXCEPTIONS = (
|
| 151 |
+
ConnectionError, # Network errors
|
| 152 |
+
TimeoutError, # Timeout errors
|
| 153 |
+
IOError, # I/O errors (file system, disk full, etc.)
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
is_retryable = isinstance(e, RETRYABLE_EXCEPTIONS) and self.request.retries < self.max_retries
|
| 157 |
+
|
| 158 |
# Mark job as failed
|
| 159 |
redis_client.hset(f"job:{job_id}", mapping={
|
| 160 |
"status": "failed",
|
| 161 |
"error": json.dumps({
|
| 162 |
"message": str(e),
|
| 163 |
+
"type": type(e).__name__,
|
| 164 |
+
"retryable": is_retryable,
|
| 165 |
+
"traceback": traceback.format_exc(),
|
| 166 |
}),
|
| 167 |
"failed_at": datetime.utcnow().isoformat(),
|
| 168 |
})
|
|
|
|
| 173 |
"job_id": job_id,
|
| 174 |
"error": {
|
| 175 |
"message": str(e),
|
| 176 |
+
"type": type(e).__name__,
|
| 177 |
+
"retryable": is_retryable,
|
| 178 |
},
|
| 179 |
"timestamp": datetime.utcnow().isoformat(),
|
| 180 |
}
|
| 181 |
redis_client.publish(f"job:{job_id}:updates", json.dumps(error_msg))
|
| 182 |
|
| 183 |
+
# Only retry if the error is transient (network, I/O, etc.)
|
| 184 |
+
if is_retryable:
|
| 185 |
+
print(f"[RETRY] Retrying job {job_id} (attempt {self.request.retries + 1}/{self.max_retries})")
|
| 186 |
raise self.retry(exc=e, countdown=2 ** self.request.retries)
|
| 187 |
else:
|
| 188 |
+
# Non-retryable error (code bug, validation error, etc.) - fail immediately
|
| 189 |
+
print(f"[ERROR] Non-retryable error for job {job_id}: {type(e).__name__}: {e}")
|
| 190 |
raise
|
| 191 |
|
| 192 |
|
frontend/package-lock.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
| 8 |
"name": "frontend",
|
| 9 |
"version": "0.0.0",
|
| 10 |
"dependencies": {
|
|
|
|
| 11 |
"@xmldom/xmldom": "^0.8.11",
|
| 12 |
"react": "^19.2.0",
|
| 13 |
"react-dom": "^19.2.0",
|
|
@@ -18,7 +19,7 @@
|
|
| 18 |
"devDependencies": {
|
| 19 |
"@eslint/js": "^9.39.1",
|
| 20 |
"@testing-library/jest-dom": "^6.1.5",
|
| 21 |
-
"@testing-library/react": "^
|
| 22 |
"@testing-library/user-event": "^14.5.1",
|
| 23 |
"@types/node": "^24.10.1",
|
| 24 |
"@types/react": "^19.2.5",
|
|
@@ -1596,33 +1597,33 @@
|
|
| 1596 |
"license": "MIT"
|
| 1597 |
},
|
| 1598 |
"node_modules/@testing-library/dom": {
|
| 1599 |
-
"version": "
|
| 1600 |
-
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-
|
| 1601 |
-
"integrity": "sha512-
|
| 1602 |
"dev": true,
|
| 1603 |
"license": "MIT",
|
| 1604 |
"dependencies": {
|
| 1605 |
"@babel/code-frame": "^7.10.4",
|
| 1606 |
"@babel/runtime": "^7.12.5",
|
| 1607 |
"@types/aria-query": "^5.0.1",
|
| 1608 |
-
"aria-query": "5.
|
| 1609 |
-
"chalk": "^4.1.0",
|
| 1610 |
"dom-accessibility-api": "^0.5.9",
|
| 1611 |
"lz-string": "^1.5.0",
|
|
|
|
| 1612 |
"pretty-format": "^27.0.2"
|
| 1613 |
},
|
| 1614 |
"engines": {
|
| 1615 |
-
"node": ">=
|
| 1616 |
}
|
| 1617 |
},
|
| 1618 |
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
| 1619 |
-
"version": "5.
|
| 1620 |
-
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.
|
| 1621 |
-
"integrity": "sha512-
|
| 1622 |
"dev": true,
|
| 1623 |
"license": "Apache-2.0",
|
| 1624 |
"dependencies": {
|
| 1625 |
-
"
|
| 1626 |
}
|
| 1627 |
},
|
| 1628 |
"node_modules/@testing-library/dom/node_modules/dom-accessibility-api": {
|
|
@@ -1653,22 +1654,28 @@
|
|
| 1653 |
}
|
| 1654 |
},
|
| 1655 |
"node_modules/@testing-library/react": {
|
| 1656 |
-
"version": "
|
| 1657 |
-
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-
|
| 1658 |
-
"integrity": "sha512-
|
| 1659 |
"dev": true,
|
| 1660 |
"license": "MIT",
|
| 1661 |
"dependencies": {
|
| 1662 |
"@babel/runtime": "^7.12.5",
|
| 1663 |
-
"@testing-library/dom": "^
|
| 1664 |
"@types/react-dom": "^18.0.0"
|
| 1665 |
},
|
| 1666 |
"engines": {
|
| 1667 |
-
"node": ">=
|
| 1668 |
},
|
| 1669 |
"peerDependencies": {
|
|
|
|
| 1670 |
"react": "^18.0.0",
|
| 1671 |
"react-dom": "^18.0.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1672 |
}
|
| 1673 |
},
|
| 1674 |
"node_modules/@testing-library/react/node_modules/@types/react-dom": {
|
|
@@ -1695,6 +1702,16 @@
|
|
| 1695 |
"@testing-library/dom": ">=7.21.4"
|
| 1696 |
}
|
| 1697 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1698 |
"node_modules/@types/aria-query": {
|
| 1699 |
"version": "5.0.4",
|
| 1700 |
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
|
@@ -2419,22 +2436,11 @@
|
|
| 2419 |
"node": ">= 0.4"
|
| 2420 |
}
|
| 2421 |
},
|
| 2422 |
-
"node_modules/array-
|
| 2423 |
-
"version": "
|
| 2424 |
-
"resolved": "https://registry.npmjs.org/array-
|
| 2425 |
-
"integrity": "sha512-
|
| 2426 |
-
"
|
| 2427 |
-
"license": "MIT",
|
| 2428 |
-
"dependencies": {
|
| 2429 |
-
"call-bound": "^1.0.3",
|
| 2430 |
-
"is-array-buffer": "^3.0.5"
|
| 2431 |
-
},
|
| 2432 |
-
"engines": {
|
| 2433 |
-
"node": ">= 0.4"
|
| 2434 |
-
},
|
| 2435 |
-
"funding": {
|
| 2436 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 2437 |
-
}
|
| 2438 |
},
|
| 2439 |
"node_modules/assertion-error": {
|
| 2440 |
"version": "1.1.0",
|
|
@@ -2466,22 +2472,6 @@
|
|
| 2466 |
"node": ">=18.2.0"
|
| 2467 |
}
|
| 2468 |
},
|
| 2469 |
-
"node_modules/available-typed-arrays": {
|
| 2470 |
-
"version": "1.0.7",
|
| 2471 |
-
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
| 2472 |
-
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
|
| 2473 |
-
"dev": true,
|
| 2474 |
-
"license": "MIT",
|
| 2475 |
-
"dependencies": {
|
| 2476 |
-
"possible-typed-array-names": "^1.0.0"
|
| 2477 |
-
},
|
| 2478 |
-
"engines": {
|
| 2479 |
-
"node": ">= 0.4"
|
| 2480 |
-
},
|
| 2481 |
-
"funding": {
|
| 2482 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 2483 |
-
}
|
| 2484 |
-
},
|
| 2485 |
"node_modules/balanced-match": {
|
| 2486 |
"version": "1.0.2",
|
| 2487 |
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
|
@@ -2577,25 +2567,6 @@
|
|
| 2577 |
"node": ">=8"
|
| 2578 |
}
|
| 2579 |
},
|
| 2580 |
-
"node_modules/call-bind": {
|
| 2581 |
-
"version": "1.0.8",
|
| 2582 |
-
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
| 2583 |
-
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
| 2584 |
-
"dev": true,
|
| 2585 |
-
"license": "MIT",
|
| 2586 |
-
"dependencies": {
|
| 2587 |
-
"call-bind-apply-helpers": "^1.0.0",
|
| 2588 |
-
"es-define-property": "^1.0.0",
|
| 2589 |
-
"get-intrinsic": "^1.2.4",
|
| 2590 |
-
"set-function-length": "^1.2.2"
|
| 2591 |
-
},
|
| 2592 |
-
"engines": {
|
| 2593 |
-
"node": ">= 0.4"
|
| 2594 |
-
},
|
| 2595 |
-
"funding": {
|
| 2596 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 2597 |
-
}
|
| 2598 |
-
},
|
| 2599 |
"node_modules/call-bind-apply-helpers": {
|
| 2600 |
"version": "1.0.2",
|
| 2601 |
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
|
@@ -2610,23 +2581,6 @@
|
|
| 2610 |
"node": ">= 0.4"
|
| 2611 |
}
|
| 2612 |
},
|
| 2613 |
-
"node_modules/call-bound": {
|
| 2614 |
-
"version": "1.0.4",
|
| 2615 |
-
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
| 2616 |
-
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
| 2617 |
-
"dev": true,
|
| 2618 |
-
"license": "MIT",
|
| 2619 |
-
"dependencies": {
|
| 2620 |
-
"call-bind-apply-helpers": "^1.0.2",
|
| 2621 |
-
"get-intrinsic": "^1.3.0"
|
| 2622 |
-
},
|
| 2623 |
-
"engines": {
|
| 2624 |
-
"node": ">= 0.4"
|
| 2625 |
-
},
|
| 2626 |
-
"funding": {
|
| 2627 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 2628 |
-
}
|
| 2629 |
-
},
|
| 2630 |
"node_modules/callsites": {
|
| 2631 |
"version": "3.1.0",
|
| 2632 |
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
|
@@ -2877,39 +2831,6 @@
|
|
| 2877 |
"node": ">=6"
|
| 2878 |
}
|
| 2879 |
},
|
| 2880 |
-
"node_modules/deep-equal": {
|
| 2881 |
-
"version": "2.2.3",
|
| 2882 |
-
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
|
| 2883 |
-
"integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
|
| 2884 |
-
"dev": true,
|
| 2885 |
-
"license": "MIT",
|
| 2886 |
-
"dependencies": {
|
| 2887 |
-
"array-buffer-byte-length": "^1.0.0",
|
| 2888 |
-
"call-bind": "^1.0.5",
|
| 2889 |
-
"es-get-iterator": "^1.1.3",
|
| 2890 |
-
"get-intrinsic": "^1.2.2",
|
| 2891 |
-
"is-arguments": "^1.1.1",
|
| 2892 |
-
"is-array-buffer": "^3.0.2",
|
| 2893 |
-
"is-date-object": "^1.0.5",
|
| 2894 |
-
"is-regex": "^1.1.4",
|
| 2895 |
-
"is-shared-array-buffer": "^1.0.2",
|
| 2896 |
-
"isarray": "^2.0.5",
|
| 2897 |
-
"object-is": "^1.1.5",
|
| 2898 |
-
"object-keys": "^1.1.1",
|
| 2899 |
-
"object.assign": "^4.1.4",
|
| 2900 |
-
"regexp.prototype.flags": "^1.5.1",
|
| 2901 |
-
"side-channel": "^1.0.4",
|
| 2902 |
-
"which-boxed-primitive": "^1.0.2",
|
| 2903 |
-
"which-collection": "^1.0.1",
|
| 2904 |
-
"which-typed-array": "^1.1.13"
|
| 2905 |
-
},
|
| 2906 |
-
"engines": {
|
| 2907 |
-
"node": ">= 0.4"
|
| 2908 |
-
},
|
| 2909 |
-
"funding": {
|
| 2910 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 2911 |
-
}
|
| 2912 |
-
},
|
| 2913 |
"node_modules/deep-is": {
|
| 2914 |
"version": "0.1.4",
|
| 2915 |
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
|
@@ -2917,42 +2838,6 @@
|
|
| 2917 |
"dev": true,
|
| 2918 |
"license": "MIT"
|
| 2919 |
},
|
| 2920 |
-
"node_modules/define-data-property": {
|
| 2921 |
-
"version": "1.1.4",
|
| 2922 |
-
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
| 2923 |
-
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
| 2924 |
-
"dev": true,
|
| 2925 |
-
"license": "MIT",
|
| 2926 |
-
"dependencies": {
|
| 2927 |
-
"es-define-property": "^1.0.0",
|
| 2928 |
-
"es-errors": "^1.3.0",
|
| 2929 |
-
"gopd": "^1.0.1"
|
| 2930 |
-
},
|
| 2931 |
-
"engines": {
|
| 2932 |
-
"node": ">= 0.4"
|
| 2933 |
-
},
|
| 2934 |
-
"funding": {
|
| 2935 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 2936 |
-
}
|
| 2937 |
-
},
|
| 2938 |
-
"node_modules/define-properties": {
|
| 2939 |
-
"version": "1.2.1",
|
| 2940 |
-
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
| 2941 |
-
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
| 2942 |
-
"dev": true,
|
| 2943 |
-
"license": "MIT",
|
| 2944 |
-
"dependencies": {
|
| 2945 |
-
"define-data-property": "^1.0.1",
|
| 2946 |
-
"has-property-descriptors": "^1.0.0",
|
| 2947 |
-
"object-keys": "^1.1.1"
|
| 2948 |
-
},
|
| 2949 |
-
"engines": {
|
| 2950 |
-
"node": ">= 0.4"
|
| 2951 |
-
},
|
| 2952 |
-
"funding": {
|
| 2953 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 2954 |
-
}
|
| 2955 |
-
},
|
| 2956 |
"node_modules/delayed-stream": {
|
| 2957 |
"version": "1.0.0",
|
| 2958 |
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
|
@@ -2963,6 +2848,16 @@
|
|
| 2963 |
"node": ">=0.4.0"
|
| 2964 |
}
|
| 2965 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2966 |
"node_modules/diff-sequences": {
|
| 2967 |
"version": "29.6.3",
|
| 2968 |
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
|
@@ -3035,27 +2930,6 @@
|
|
| 3035 |
"node": ">= 0.4"
|
| 3036 |
}
|
| 3037 |
},
|
| 3038 |
-
"node_modules/es-get-iterator": {
|
| 3039 |
-
"version": "1.1.3",
|
| 3040 |
-
"resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
|
| 3041 |
-
"integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
|
| 3042 |
-
"dev": true,
|
| 3043 |
-
"license": "MIT",
|
| 3044 |
-
"dependencies": {
|
| 3045 |
-
"call-bind": "^1.0.2",
|
| 3046 |
-
"get-intrinsic": "^1.1.3",
|
| 3047 |
-
"has-symbols": "^1.0.3",
|
| 3048 |
-
"is-arguments": "^1.1.1",
|
| 3049 |
-
"is-map": "^2.0.2",
|
| 3050 |
-
"is-set": "^2.0.2",
|
| 3051 |
-
"is-string": "^1.0.7",
|
| 3052 |
-
"isarray": "^2.0.5",
|
| 3053 |
-
"stop-iteration-iterator": "^1.0.0"
|
| 3054 |
-
},
|
| 3055 |
-
"funding": {
|
| 3056 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 3057 |
-
}
|
| 3058 |
-
},
|
| 3059 |
"node_modules/es-object-atoms": {
|
| 3060 |
"version": "1.1.1",
|
| 3061 |
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
|
@@ -3518,22 +3392,6 @@
|
|
| 3518 |
"dev": true,
|
| 3519 |
"license": "ISC"
|
| 3520 |
},
|
| 3521 |
-
"node_modules/for-each": {
|
| 3522 |
-
"version": "0.3.5",
|
| 3523 |
-
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
| 3524 |
-
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
|
| 3525 |
-
"dev": true,
|
| 3526 |
-
"license": "MIT",
|
| 3527 |
-
"dependencies": {
|
| 3528 |
-
"is-callable": "^1.2.7"
|
| 3529 |
-
},
|
| 3530 |
-
"engines": {
|
| 3531 |
-
"node": ">= 0.4"
|
| 3532 |
-
},
|
| 3533 |
-
"funding": {
|
| 3534 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 3535 |
-
}
|
| 3536 |
-
},
|
| 3537 |
"node_modules/form-data": {
|
| 3538 |
"version": "4.0.5",
|
| 3539 |
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
|
@@ -3583,16 +3441,6 @@
|
|
| 3583 |
"url": "https://github.com/sponsors/ljharb"
|
| 3584 |
}
|
| 3585 |
},
|
| 3586 |
-
"node_modules/functions-have-names": {
|
| 3587 |
-
"version": "1.2.3",
|
| 3588 |
-
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
|
| 3589 |
-
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
|
| 3590 |
-
"dev": true,
|
| 3591 |
-
"license": "MIT",
|
| 3592 |
-
"funding": {
|
| 3593 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 3594 |
-
}
|
| 3595 |
-
},
|
| 3596 |
"node_modules/gensync": {
|
| 3597 |
"version": "1.0.0-beta.2",
|
| 3598 |
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
|
@@ -3726,19 +3574,6 @@
|
|
| 3726 |
"url": "https://github.com/sponsors/ljharb"
|
| 3727 |
}
|
| 3728 |
},
|
| 3729 |
-
"node_modules/has-bigints": {
|
| 3730 |
-
"version": "1.1.0",
|
| 3731 |
-
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
| 3732 |
-
"integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
|
| 3733 |
-
"dev": true,
|
| 3734 |
-
"license": "MIT",
|
| 3735 |
-
"engines": {
|
| 3736 |
-
"node": ">= 0.4"
|
| 3737 |
-
},
|
| 3738 |
-
"funding": {
|
| 3739 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 3740 |
-
}
|
| 3741 |
-
},
|
| 3742 |
"node_modules/has-flag": {
|
| 3743 |
"version": "4.0.0",
|
| 3744 |
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
|
@@ -3749,19 +3584,6 @@
|
|
| 3749 |
"node": ">=8"
|
| 3750 |
}
|
| 3751 |
},
|
| 3752 |
-
"node_modules/has-property-descriptors": {
|
| 3753 |
-
"version": "1.0.2",
|
| 3754 |
-
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
| 3755 |
-
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
| 3756 |
-
"dev": true,
|
| 3757 |
-
"license": "MIT",
|
| 3758 |
-
"dependencies": {
|
| 3759 |
-
"es-define-property": "^1.0.0"
|
| 3760 |
-
},
|
| 3761 |
-
"funding": {
|
| 3762 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 3763 |
-
}
|
| 3764 |
-
},
|
| 3765 |
"node_modules/has-symbols": {
|
| 3766 |
"version": "1.1.0",
|
| 3767 |
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
|
@@ -3958,322 +3780,59 @@
|
|
| 3958 |
"dev": true,
|
| 3959 |
"license": "ISC"
|
| 3960 |
},
|
| 3961 |
-
"node_modules/
|
| 3962 |
-
"version": "
|
| 3963 |
-
"resolved": "https://registry.npmjs.org/
|
| 3964 |
-
"integrity": "sha512-
|
| 3965 |
-
"dev": true,
|
| 3966 |
-
"license": "MIT",
|
| 3967 |
-
"dependencies": {
|
| 3968 |
-
"es-errors": "^1.3.0",
|
| 3969 |
-
"hasown": "^2.0.2",
|
| 3970 |
-
"side-channel": "^1.1.0"
|
| 3971 |
-
},
|
| 3972 |
-
"engines": {
|
| 3973 |
-
"node": ">= 0.4"
|
| 3974 |
-
}
|
| 3975 |
-
},
|
| 3976 |
-
"node_modules/is-arguments": {
|
| 3977 |
-
"version": "1.2.0",
|
| 3978 |
-
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
| 3979 |
-
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
| 3980 |
-
"dev": true,
|
| 3981 |
-
"license": "MIT",
|
| 3982 |
-
"dependencies": {
|
| 3983 |
-
"call-bound": "^1.0.2",
|
| 3984 |
-
"has-tostringtag": "^1.0.2"
|
| 3985 |
-
},
|
| 3986 |
-
"engines": {
|
| 3987 |
-
"node": ">= 0.4"
|
| 3988 |
-
},
|
| 3989 |
-
"funding": {
|
| 3990 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 3991 |
-
}
|
| 3992 |
-
},
|
| 3993 |
-
"node_modules/is-array-buffer": {
|
| 3994 |
-
"version": "3.0.5",
|
| 3995 |
-
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
| 3996 |
-
"integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
|
| 3997 |
-
"dev": true,
|
| 3998 |
-
"license": "MIT",
|
| 3999 |
-
"dependencies": {
|
| 4000 |
-
"call-bind": "^1.0.8",
|
| 4001 |
-
"call-bound": "^1.0.3",
|
| 4002 |
-
"get-intrinsic": "^1.2.6"
|
| 4003 |
-
},
|
| 4004 |
-
"engines": {
|
| 4005 |
-
"node": ">= 0.4"
|
| 4006 |
-
},
|
| 4007 |
-
"funding": {
|
| 4008 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4009 |
-
}
|
| 4010 |
-
},
|
| 4011 |
-
"node_modules/is-bigint": {
|
| 4012 |
-
"version": "1.1.0",
|
| 4013 |
-
"resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
|
| 4014 |
-
"integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
|
| 4015 |
-
"dev": true,
|
| 4016 |
-
"license": "MIT",
|
| 4017 |
-
"dependencies": {
|
| 4018 |
-
"has-bigints": "^1.0.2"
|
| 4019 |
-
},
|
| 4020 |
-
"engines": {
|
| 4021 |
-
"node": ">= 0.4"
|
| 4022 |
-
},
|
| 4023 |
-
"funding": {
|
| 4024 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4025 |
-
}
|
| 4026 |
-
},
|
| 4027 |
-
"node_modules/is-boolean-object": {
|
| 4028 |
-
"version": "1.2.2",
|
| 4029 |
-
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
|
| 4030 |
-
"integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
|
| 4031 |
-
"dev": true,
|
| 4032 |
-
"license": "MIT",
|
| 4033 |
-
"dependencies": {
|
| 4034 |
-
"call-bound": "^1.0.3",
|
| 4035 |
-
"has-tostringtag": "^1.0.2"
|
| 4036 |
-
},
|
| 4037 |
-
"engines": {
|
| 4038 |
-
"node": ">= 0.4"
|
| 4039 |
-
},
|
| 4040 |
-
"funding": {
|
| 4041 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4042 |
-
}
|
| 4043 |
-
},
|
| 4044 |
-
"node_modules/is-callable": {
|
| 4045 |
-
"version": "1.2.7",
|
| 4046 |
-
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
| 4047 |
-
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
|
| 4048 |
-
"dev": true,
|
| 4049 |
-
"license": "MIT",
|
| 4050 |
-
"engines": {
|
| 4051 |
-
"node": ">= 0.4"
|
| 4052 |
-
},
|
| 4053 |
-
"funding": {
|
| 4054 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4055 |
-
}
|
| 4056 |
-
},
|
| 4057 |
-
"node_modules/is-date-object": {
|
| 4058 |
-
"version": "1.1.0",
|
| 4059 |
-
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
|
| 4060 |
-
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
|
| 4061 |
-
"dev": true,
|
| 4062 |
-
"license": "MIT",
|
| 4063 |
-
"dependencies": {
|
| 4064 |
-
"call-bound": "^1.0.2",
|
| 4065 |
-
"has-tostringtag": "^1.0.2"
|
| 4066 |
-
},
|
| 4067 |
-
"engines": {
|
| 4068 |
-
"node": ">= 0.4"
|
| 4069 |
-
},
|
| 4070 |
-
"funding": {
|
| 4071 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4072 |
-
}
|
| 4073 |
-
},
|
| 4074 |
-
"node_modules/is-extglob": {
|
| 4075 |
-
"version": "2.1.1",
|
| 4076 |
-
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
| 4077 |
-
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
| 4078 |
-
"dev": true,
|
| 4079 |
-
"license": "MIT",
|
| 4080 |
-
"engines": {
|
| 4081 |
-
"node": ">=0.10.0"
|
| 4082 |
-
}
|
| 4083 |
-
},
|
| 4084 |
-
"node_modules/is-glob": {
|
| 4085 |
-
"version": "4.0.3",
|
| 4086 |
-
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
| 4087 |
-
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
| 4088 |
-
"dev": true,
|
| 4089 |
-
"license": "MIT",
|
| 4090 |
-
"dependencies": {
|
| 4091 |
-
"is-extglob": "^2.1.1"
|
| 4092 |
-
},
|
| 4093 |
-
"engines": {
|
| 4094 |
-
"node": ">=0.10.0"
|
| 4095 |
-
}
|
| 4096 |
-
},
|
| 4097 |
-
"node_modules/is-map": {
|
| 4098 |
-
"version": "2.0.3",
|
| 4099 |
-
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
|
| 4100 |
-
"integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
|
| 4101 |
-
"dev": true,
|
| 4102 |
-
"license": "MIT",
|
| 4103 |
-
"engines": {
|
| 4104 |
-
"node": ">= 0.4"
|
| 4105 |
-
},
|
| 4106 |
-
"funding": {
|
| 4107 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4108 |
-
}
|
| 4109 |
-
},
|
| 4110 |
-
"node_modules/is-number": {
|
| 4111 |
-
"version": "7.0.0",
|
| 4112 |
-
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
| 4113 |
-
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
| 4114 |
-
"dev": true,
|
| 4115 |
-
"license": "MIT",
|
| 4116 |
-
"engines": {
|
| 4117 |
-
"node": ">=0.12.0"
|
| 4118 |
-
}
|
| 4119 |
-
},
|
| 4120 |
-
"node_modules/is-number-object": {
|
| 4121 |
-
"version": "1.1.1",
|
| 4122 |
-
"resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
|
| 4123 |
-
"integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
|
| 4124 |
-
"dev": true,
|
| 4125 |
-
"license": "MIT",
|
| 4126 |
-
"dependencies": {
|
| 4127 |
-
"call-bound": "^1.0.3",
|
| 4128 |
-
"has-tostringtag": "^1.0.2"
|
| 4129 |
-
},
|
| 4130 |
-
"engines": {
|
| 4131 |
-
"node": ">= 0.4"
|
| 4132 |
-
},
|
| 4133 |
-
"funding": {
|
| 4134 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4135 |
-
}
|
| 4136 |
-
},
|
| 4137 |
-
"node_modules/is-potential-custom-element-name": {
|
| 4138 |
-
"version": "1.0.1",
|
| 4139 |
-
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
| 4140 |
-
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
| 4141 |
-
"dev": true,
|
| 4142 |
-
"license": "MIT"
|
| 4143 |
-
},
|
| 4144 |
-
"node_modules/is-regex": {
|
| 4145 |
-
"version": "1.2.1",
|
| 4146 |
-
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
| 4147 |
-
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
| 4148 |
-
"dev": true,
|
| 4149 |
-
"license": "MIT",
|
| 4150 |
-
"dependencies": {
|
| 4151 |
-
"call-bound": "^1.0.2",
|
| 4152 |
-
"gopd": "^1.2.0",
|
| 4153 |
-
"has-tostringtag": "^1.0.2",
|
| 4154 |
-
"hasown": "^2.0.2"
|
| 4155 |
-
},
|
| 4156 |
-
"engines": {
|
| 4157 |
-
"node": ">= 0.4"
|
| 4158 |
-
},
|
| 4159 |
-
"funding": {
|
| 4160 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4161 |
-
}
|
| 4162 |
-
},
|
| 4163 |
-
"node_modules/is-set": {
|
| 4164 |
-
"version": "2.0.3",
|
| 4165 |
-
"resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
|
| 4166 |
-
"integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
|
| 4167 |
-
"dev": true,
|
| 4168 |
-
"license": "MIT",
|
| 4169 |
-
"engines": {
|
| 4170 |
-
"node": ">= 0.4"
|
| 4171 |
-
},
|
| 4172 |
-
"funding": {
|
| 4173 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4174 |
-
}
|
| 4175 |
-
},
|
| 4176 |
-
"node_modules/is-shared-array-buffer": {
|
| 4177 |
-
"version": "1.0.4",
|
| 4178 |
-
"resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
|
| 4179 |
-
"integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
|
| 4180 |
-
"dev": true,
|
| 4181 |
-
"license": "MIT",
|
| 4182 |
-
"dependencies": {
|
| 4183 |
-
"call-bound": "^1.0.3"
|
| 4184 |
-
},
|
| 4185 |
-
"engines": {
|
| 4186 |
-
"node": ">= 0.4"
|
| 4187 |
-
},
|
| 4188 |
-
"funding": {
|
| 4189 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4190 |
-
}
|
| 4191 |
-
},
|
| 4192 |
-
"node_modules/is-stream": {
|
| 4193 |
-
"version": "3.0.0",
|
| 4194 |
-
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
|
| 4195 |
-
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
|
| 4196 |
"dev": true,
|
| 4197 |
"license": "MIT",
|
| 4198 |
"engines": {
|
| 4199 |
-
"node": "
|
| 4200 |
-
},
|
| 4201 |
-
"funding": {
|
| 4202 |
-
"url": "https://github.com/sponsors/sindresorhus"
|
| 4203 |
}
|
| 4204 |
},
|
| 4205 |
-
"node_modules/is-
|
| 4206 |
-
"version": "
|
| 4207 |
-
"resolved": "https://registry.npmjs.org/is-
|
| 4208 |
-
"integrity": "sha512-
|
| 4209 |
"dev": true,
|
| 4210 |
"license": "MIT",
|
| 4211 |
"dependencies": {
|
| 4212 |
-
"
|
| 4213 |
-
"has-tostringtag": "^1.0.2"
|
| 4214 |
},
|
| 4215 |
"engines": {
|
| 4216 |
-
"node": ">=
|
| 4217 |
-
},
|
| 4218 |
-
"funding": {
|
| 4219 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4220 |
}
|
| 4221 |
},
|
| 4222 |
-
"node_modules/is-
|
| 4223 |
-
"version": "
|
| 4224 |
-
"resolved": "https://registry.npmjs.org/is-
|
| 4225 |
-
"integrity": "sha512-
|
| 4226 |
"dev": true,
|
| 4227 |
"license": "MIT",
|
| 4228 |
-
"dependencies": {
|
| 4229 |
-
"call-bound": "^1.0.2",
|
| 4230 |
-
"has-symbols": "^1.1.0",
|
| 4231 |
-
"safe-regex-test": "^1.1.0"
|
| 4232 |
-
},
|
| 4233 |
"engines": {
|
| 4234 |
-
"node": ">=
|
| 4235 |
-
},
|
| 4236 |
-
"funding": {
|
| 4237 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4238 |
}
|
| 4239 |
},
|
| 4240 |
-
"node_modules/is-
|
| 4241 |
-
"version": "
|
| 4242 |
-
"resolved": "https://registry.npmjs.org/is-
|
| 4243 |
-
"integrity": "sha512-
|
| 4244 |
"dev": true,
|
| 4245 |
-
"license": "MIT"
|
| 4246 |
-
"engines": {
|
| 4247 |
-
"node": ">= 0.4"
|
| 4248 |
-
},
|
| 4249 |
-
"funding": {
|
| 4250 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4251 |
-
}
|
| 4252 |
},
|
| 4253 |
-
"node_modules/is-
|
| 4254 |
-
"version": "
|
| 4255 |
-
"resolved": "https://registry.npmjs.org/is-
|
| 4256 |
-
"integrity": "sha512-
|
| 4257 |
"dev": true,
|
| 4258 |
"license": "MIT",
|
| 4259 |
-
"dependencies": {
|
| 4260 |
-
"call-bound": "^1.0.3",
|
| 4261 |
-
"get-intrinsic": "^1.2.6"
|
| 4262 |
-
},
|
| 4263 |
"engines": {
|
| 4264 |
-
"node": ">=
|
| 4265 |
},
|
| 4266 |
"funding": {
|
| 4267 |
-
"url": "https://github.com/sponsors/
|
| 4268 |
}
|
| 4269 |
},
|
| 4270 |
-
"node_modules/isarray": {
|
| 4271 |
-
"version": "2.0.5",
|
| 4272 |
-
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
| 4273 |
-
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
| 4274 |
-
"dev": true,
|
| 4275 |
-
"license": "MIT"
|
| 4276 |
-
},
|
| 4277 |
"node_modules/isexe": {
|
| 4278 |
"version": "2.0.0",
|
| 4279 |
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
|
@@ -4649,6 +4208,12 @@
|
|
| 4649 |
"url": "https://github.com/sponsors/jonschlinkert"
|
| 4650 |
}
|
| 4651 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4652 |
"node_modules/mime-db": {
|
| 4653 |
"version": "1.52.0",
|
| 4654 |
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
|
@@ -4807,67 +4372,6 @@
|
|
| 4807 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 4808 |
}
|
| 4809 |
},
|
| 4810 |
-
"node_modules/object-inspect": {
|
| 4811 |
-
"version": "1.13.4",
|
| 4812 |
-
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
| 4813 |
-
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
| 4814 |
-
"dev": true,
|
| 4815 |
-
"license": "MIT",
|
| 4816 |
-
"engines": {
|
| 4817 |
-
"node": ">= 0.4"
|
| 4818 |
-
},
|
| 4819 |
-
"funding": {
|
| 4820 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4821 |
-
}
|
| 4822 |
-
},
|
| 4823 |
-
"node_modules/object-is": {
|
| 4824 |
-
"version": "1.1.6",
|
| 4825 |
-
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
| 4826 |
-
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
| 4827 |
-
"dev": true,
|
| 4828 |
-
"license": "MIT",
|
| 4829 |
-
"dependencies": {
|
| 4830 |
-
"call-bind": "^1.0.7",
|
| 4831 |
-
"define-properties": "^1.2.1"
|
| 4832 |
-
},
|
| 4833 |
-
"engines": {
|
| 4834 |
-
"node": ">= 0.4"
|
| 4835 |
-
},
|
| 4836 |
-
"funding": {
|
| 4837 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4838 |
-
}
|
| 4839 |
-
},
|
| 4840 |
-
"node_modules/object-keys": {
|
| 4841 |
-
"version": "1.1.1",
|
| 4842 |
-
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
| 4843 |
-
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
| 4844 |
-
"dev": true,
|
| 4845 |
-
"license": "MIT",
|
| 4846 |
-
"engines": {
|
| 4847 |
-
"node": ">= 0.4"
|
| 4848 |
-
}
|
| 4849 |
-
},
|
| 4850 |
-
"node_modules/object.assign": {
|
| 4851 |
-
"version": "4.1.7",
|
| 4852 |
-
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
|
| 4853 |
-
"integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
|
| 4854 |
-
"dev": true,
|
| 4855 |
-
"license": "MIT",
|
| 4856 |
-
"dependencies": {
|
| 4857 |
-
"call-bind": "^1.0.8",
|
| 4858 |
-
"call-bound": "^1.0.3",
|
| 4859 |
-
"define-properties": "^1.2.1",
|
| 4860 |
-
"es-object-atoms": "^1.0.0",
|
| 4861 |
-
"has-symbols": "^1.1.0",
|
| 4862 |
-
"object-keys": "^1.1.1"
|
| 4863 |
-
},
|
| 4864 |
-
"engines": {
|
| 4865 |
-
"node": ">= 0.4"
|
| 4866 |
-
},
|
| 4867 |
-
"funding": {
|
| 4868 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 4869 |
-
}
|
| 4870 |
-
},
|
| 4871 |
"node_modules/once": {
|
| 4872 |
"version": "1.4.0",
|
| 4873 |
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
|
@@ -5056,16 +4560,6 @@
|
|
| 5056 |
"dev": true,
|
| 5057 |
"license": "MIT"
|
| 5058 |
},
|
| 5059 |
-
"node_modules/possible-typed-array-names": {
|
| 5060 |
-
"version": "1.1.0",
|
| 5061 |
-
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
| 5062 |
-
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
|
| 5063 |
-
"dev": true,
|
| 5064 |
-
"license": "MIT",
|
| 5065 |
-
"engines": {
|
| 5066 |
-
"node": ">= 0.4"
|
| 5067 |
-
}
|
| 5068 |
-
},
|
| 5069 |
"node_modules/postcss": {
|
| 5070 |
"version": "8.5.6",
|
| 5071 |
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
|
@@ -5236,27 +4730,6 @@
|
|
| 5236 |
"node": ">=8"
|
| 5237 |
}
|
| 5238 |
},
|
| 5239 |
-
"node_modules/regexp.prototype.flags": {
|
| 5240 |
-
"version": "1.5.4",
|
| 5241 |
-
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
| 5242 |
-
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
|
| 5243 |
-
"dev": true,
|
| 5244 |
-
"license": "MIT",
|
| 5245 |
-
"dependencies": {
|
| 5246 |
-
"call-bind": "^1.0.8",
|
| 5247 |
-
"define-properties": "^1.2.1",
|
| 5248 |
-
"es-errors": "^1.3.0",
|
| 5249 |
-
"get-proto": "^1.0.1",
|
| 5250 |
-
"gopd": "^1.2.0",
|
| 5251 |
-
"set-function-name": "^2.0.2"
|
| 5252 |
-
},
|
| 5253 |
-
"engines": {
|
| 5254 |
-
"node": ">= 0.4"
|
| 5255 |
-
},
|
| 5256 |
-
"funding": {
|
| 5257 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 5258 |
-
}
|
| 5259 |
-
},
|
| 5260 |
"node_modules/require-from-string": {
|
| 5261 |
"version": "2.0.2",
|
| 5262 |
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
|
@@ -5368,24 +4841,6 @@
|
|
| 5368 |
"queue-microtask": "^1.2.2"
|
| 5369 |
}
|
| 5370 |
},
|
| 5371 |
-
"node_modules/safe-regex-test": {
|
| 5372 |
-
"version": "1.1.0",
|
| 5373 |
-
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
| 5374 |
-
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
|
| 5375 |
-
"dev": true,
|
| 5376 |
-
"license": "MIT",
|
| 5377 |
-
"dependencies": {
|
| 5378 |
-
"call-bound": "^1.0.2",
|
| 5379 |
-
"es-errors": "^1.3.0",
|
| 5380 |
-
"is-regex": "^1.2.1"
|
| 5381 |
-
},
|
| 5382 |
-
"engines": {
|
| 5383 |
-
"node": ">= 0.4"
|
| 5384 |
-
},
|
| 5385 |
-
"funding": {
|
| 5386 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 5387 |
-
}
|
| 5388 |
-
},
|
| 5389 |
"node_modules/safer-buffer": {
|
| 5390 |
"version": "2.1.2",
|
| 5391 |
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
|
@@ -5422,40 +4877,6 @@
|
|
| 5422 |
"semver": "bin/semver.js"
|
| 5423 |
}
|
| 5424 |
},
|
| 5425 |
-
"node_modules/set-function-length": {
|
| 5426 |
-
"version": "1.2.2",
|
| 5427 |
-
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
| 5428 |
-
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
| 5429 |
-
"dev": true,
|
| 5430 |
-
"license": "MIT",
|
| 5431 |
-
"dependencies": {
|
| 5432 |
-
"define-data-property": "^1.1.4",
|
| 5433 |
-
"es-errors": "^1.3.0",
|
| 5434 |
-
"function-bind": "^1.1.2",
|
| 5435 |
-
"get-intrinsic": "^1.2.4",
|
| 5436 |
-
"gopd": "^1.0.1",
|
| 5437 |
-
"has-property-descriptors": "^1.0.2"
|
| 5438 |
-
},
|
| 5439 |
-
"engines": {
|
| 5440 |
-
"node": ">= 0.4"
|
| 5441 |
-
}
|
| 5442 |
-
},
|
| 5443 |
-
"node_modules/set-function-name": {
|
| 5444 |
-
"version": "2.0.2",
|
| 5445 |
-
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
|
| 5446 |
-
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
|
| 5447 |
-
"dev": true,
|
| 5448 |
-
"license": "MIT",
|
| 5449 |
-
"dependencies": {
|
| 5450 |
-
"define-data-property": "^1.1.4",
|
| 5451 |
-
"es-errors": "^1.3.0",
|
| 5452 |
-
"functions-have-names": "^1.2.3",
|
| 5453 |
-
"has-property-descriptors": "^1.0.2"
|
| 5454 |
-
},
|
| 5455 |
-
"engines": {
|
| 5456 |
-
"node": ">= 0.4"
|
| 5457 |
-
}
|
| 5458 |
-
},
|
| 5459 |
"node_modules/shebang-command": {
|
| 5460 |
"version": "2.0.0",
|
| 5461 |
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
|
@@ -5479,82 +4900,6 @@
|
|
| 5479 |
"node": ">=8"
|
| 5480 |
}
|
| 5481 |
},
|
| 5482 |
-
"node_modules/side-channel": {
|
| 5483 |
-
"version": "1.1.0",
|
| 5484 |
-
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
| 5485 |
-
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
| 5486 |
-
"dev": true,
|
| 5487 |
-
"license": "MIT",
|
| 5488 |
-
"dependencies": {
|
| 5489 |
-
"es-errors": "^1.3.0",
|
| 5490 |
-
"object-inspect": "^1.13.3",
|
| 5491 |
-
"side-channel-list": "^1.0.0",
|
| 5492 |
-
"side-channel-map": "^1.0.1",
|
| 5493 |
-
"side-channel-weakmap": "^1.0.2"
|
| 5494 |
-
},
|
| 5495 |
-
"engines": {
|
| 5496 |
-
"node": ">= 0.4"
|
| 5497 |
-
},
|
| 5498 |
-
"funding": {
|
| 5499 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 5500 |
-
}
|
| 5501 |
-
},
|
| 5502 |
-
"node_modules/side-channel-list": {
|
| 5503 |
-
"version": "1.0.0",
|
| 5504 |
-
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
| 5505 |
-
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
| 5506 |
-
"dev": true,
|
| 5507 |
-
"license": "MIT",
|
| 5508 |
-
"dependencies": {
|
| 5509 |
-
"es-errors": "^1.3.0",
|
| 5510 |
-
"object-inspect": "^1.13.3"
|
| 5511 |
-
},
|
| 5512 |
-
"engines": {
|
| 5513 |
-
"node": ">= 0.4"
|
| 5514 |
-
},
|
| 5515 |
-
"funding": {
|
| 5516 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 5517 |
-
}
|
| 5518 |
-
},
|
| 5519 |
-
"node_modules/side-channel-map": {
|
| 5520 |
-
"version": "1.0.1",
|
| 5521 |
-
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
| 5522 |
-
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
| 5523 |
-
"dev": true,
|
| 5524 |
-
"license": "MIT",
|
| 5525 |
-
"dependencies": {
|
| 5526 |
-
"call-bound": "^1.0.2",
|
| 5527 |
-
"es-errors": "^1.3.0",
|
| 5528 |
-
"get-intrinsic": "^1.2.5",
|
| 5529 |
-
"object-inspect": "^1.13.3"
|
| 5530 |
-
},
|
| 5531 |
-
"engines": {
|
| 5532 |
-
"node": ">= 0.4"
|
| 5533 |
-
},
|
| 5534 |
-
"funding": {
|
| 5535 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 5536 |
-
}
|
| 5537 |
-
},
|
| 5538 |
-
"node_modules/side-channel-weakmap": {
|
| 5539 |
-
"version": "1.0.2",
|
| 5540 |
-
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
| 5541 |
-
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
| 5542 |
-
"dev": true,
|
| 5543 |
-
"license": "MIT",
|
| 5544 |
-
"dependencies": {
|
| 5545 |
-
"call-bound": "^1.0.2",
|
| 5546 |
-
"es-errors": "^1.3.0",
|
| 5547 |
-
"get-intrinsic": "^1.2.5",
|
| 5548 |
-
"object-inspect": "^1.13.3",
|
| 5549 |
-
"side-channel-map": "^1.0.1"
|
| 5550 |
-
},
|
| 5551 |
-
"engines": {
|
| 5552 |
-
"node": ">= 0.4"
|
| 5553 |
-
},
|
| 5554 |
-
"funding": {
|
| 5555 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 5556 |
-
}
|
| 5557 |
-
},
|
| 5558 |
"node_modules/siginfo": {
|
| 5559 |
"version": "2.0.0",
|
| 5560 |
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
|
@@ -5625,20 +4970,6 @@
|
|
| 5625 |
"dev": true,
|
| 5626 |
"license": "MIT"
|
| 5627 |
},
|
| 5628 |
-
"node_modules/stop-iteration-iterator": {
|
| 5629 |
-
"version": "1.1.0",
|
| 5630 |
-
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
| 5631 |
-
"integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
|
| 5632 |
-
"dev": true,
|
| 5633 |
-
"license": "MIT",
|
| 5634 |
-
"dependencies": {
|
| 5635 |
-
"es-errors": "^1.3.0",
|
| 5636 |
-
"internal-slot": "^1.1.0"
|
| 5637 |
-
},
|
| 5638 |
-
"engines": {
|
| 5639 |
-
"node": ">= 0.4"
|
| 5640 |
-
}
|
| 5641 |
-
},
|
| 5642 |
"node_modules/strip-final-newline": {
|
| 5643 |
"version": "3.0.0",
|
| 5644 |
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
|
|
@@ -7221,67 +6552,6 @@
|
|
| 7221 |
"node": ">= 8"
|
| 7222 |
}
|
| 7223 |
},
|
| 7224 |
-
"node_modules/which-boxed-primitive": {
|
| 7225 |
-
"version": "1.1.1",
|
| 7226 |
-
"resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
|
| 7227 |
-
"integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
|
| 7228 |
-
"dev": true,
|
| 7229 |
-
"license": "MIT",
|
| 7230 |
-
"dependencies": {
|
| 7231 |
-
"is-bigint": "^1.1.0",
|
| 7232 |
-
"is-boolean-object": "^1.2.1",
|
| 7233 |
-
"is-number-object": "^1.1.1",
|
| 7234 |
-
"is-string": "^1.1.1",
|
| 7235 |
-
"is-symbol": "^1.1.1"
|
| 7236 |
-
},
|
| 7237 |
-
"engines": {
|
| 7238 |
-
"node": ">= 0.4"
|
| 7239 |
-
},
|
| 7240 |
-
"funding": {
|
| 7241 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 7242 |
-
}
|
| 7243 |
-
},
|
| 7244 |
-
"node_modules/which-collection": {
|
| 7245 |
-
"version": "1.0.2",
|
| 7246 |
-
"resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
|
| 7247 |
-
"integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
|
| 7248 |
-
"dev": true,
|
| 7249 |
-
"license": "MIT",
|
| 7250 |
-
"dependencies": {
|
| 7251 |
-
"is-map": "^2.0.3",
|
| 7252 |
-
"is-set": "^2.0.3",
|
| 7253 |
-
"is-weakmap": "^2.0.2",
|
| 7254 |
-
"is-weakset": "^2.0.3"
|
| 7255 |
-
},
|
| 7256 |
-
"engines": {
|
| 7257 |
-
"node": ">= 0.4"
|
| 7258 |
-
},
|
| 7259 |
-
"funding": {
|
| 7260 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 7261 |
-
}
|
| 7262 |
-
},
|
| 7263 |
-
"node_modules/which-typed-array": {
|
| 7264 |
-
"version": "1.1.19",
|
| 7265 |
-
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
| 7266 |
-
"integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
|
| 7267 |
-
"dev": true,
|
| 7268 |
-
"license": "MIT",
|
| 7269 |
-
"dependencies": {
|
| 7270 |
-
"available-typed-arrays": "^1.0.7",
|
| 7271 |
-
"call-bind": "^1.0.8",
|
| 7272 |
-
"call-bound": "^1.0.4",
|
| 7273 |
-
"for-each": "^0.3.5",
|
| 7274 |
-
"get-proto": "^1.0.1",
|
| 7275 |
-
"gopd": "^1.2.0",
|
| 7276 |
-
"has-tostringtag": "^1.0.2"
|
| 7277 |
-
},
|
| 7278 |
-
"engines": {
|
| 7279 |
-
"node": ">= 0.4"
|
| 7280 |
-
},
|
| 7281 |
-
"funding": {
|
| 7282 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 7283 |
-
}
|
| 7284 |
-
},
|
| 7285 |
"node_modules/why-is-node-running": {
|
| 7286 |
"version": "2.3.0",
|
| 7287 |
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
|
|
|
| 8 |
"name": "frontend",
|
| 9 |
"version": "0.0.0",
|
| 10 |
"dependencies": {
|
| 11 |
+
"@tonejs/midi": "^2.0.28",
|
| 12 |
"@xmldom/xmldom": "^0.8.11",
|
| 13 |
"react": "^19.2.0",
|
| 14 |
"react-dom": "^19.2.0",
|
|
|
|
| 19 |
"devDependencies": {
|
| 20 |
"@eslint/js": "^9.39.1",
|
| 21 |
"@testing-library/jest-dom": "^6.1.5",
|
| 22 |
+
"@testing-library/react": "^15.0.0",
|
| 23 |
"@testing-library/user-event": "^14.5.1",
|
| 24 |
"@types/node": "^24.10.1",
|
| 25 |
"@types/react": "^19.2.5",
|
|
|
|
| 1597 |
"license": "MIT"
|
| 1598 |
},
|
| 1599 |
"node_modules/@testing-library/dom": {
|
| 1600 |
+
"version": "10.4.1",
|
| 1601 |
+
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
| 1602 |
+
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
| 1603 |
"dev": true,
|
| 1604 |
"license": "MIT",
|
| 1605 |
"dependencies": {
|
| 1606 |
"@babel/code-frame": "^7.10.4",
|
| 1607 |
"@babel/runtime": "^7.12.5",
|
| 1608 |
"@types/aria-query": "^5.0.1",
|
| 1609 |
+
"aria-query": "5.3.0",
|
|
|
|
| 1610 |
"dom-accessibility-api": "^0.5.9",
|
| 1611 |
"lz-string": "^1.5.0",
|
| 1612 |
+
"picocolors": "1.1.1",
|
| 1613 |
"pretty-format": "^27.0.2"
|
| 1614 |
},
|
| 1615 |
"engines": {
|
| 1616 |
+
"node": ">=18"
|
| 1617 |
}
|
| 1618 |
},
|
| 1619 |
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
| 1620 |
+
"version": "5.3.0",
|
| 1621 |
+
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
| 1622 |
+
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
| 1623 |
"dev": true,
|
| 1624 |
"license": "Apache-2.0",
|
| 1625 |
"dependencies": {
|
| 1626 |
+
"dequal": "^2.0.3"
|
| 1627 |
}
|
| 1628 |
},
|
| 1629 |
"node_modules/@testing-library/dom/node_modules/dom-accessibility-api": {
|
|
|
|
| 1654 |
}
|
| 1655 |
},
|
| 1656 |
"node_modules/@testing-library/react": {
|
| 1657 |
+
"version": "15.0.7",
|
| 1658 |
+
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-15.0.7.tgz",
|
| 1659 |
+
"integrity": "sha512-cg0RvEdD1TIhhkm1IeYMQxrzy0MtUNfa3minv4MjbgcYzJAZ7yD0i0lwoPOTPr+INtiXFezt2o8xMSnyHhEn2Q==",
|
| 1660 |
"dev": true,
|
| 1661 |
"license": "MIT",
|
| 1662 |
"dependencies": {
|
| 1663 |
"@babel/runtime": "^7.12.5",
|
| 1664 |
+
"@testing-library/dom": "^10.0.0",
|
| 1665 |
"@types/react-dom": "^18.0.0"
|
| 1666 |
},
|
| 1667 |
"engines": {
|
| 1668 |
+
"node": ">=18"
|
| 1669 |
},
|
| 1670 |
"peerDependencies": {
|
| 1671 |
+
"@types/react": "^18.0.0",
|
| 1672 |
"react": "^18.0.0",
|
| 1673 |
"react-dom": "^18.0.0"
|
| 1674 |
+
},
|
| 1675 |
+
"peerDependenciesMeta": {
|
| 1676 |
+
"@types/react": {
|
| 1677 |
+
"optional": true
|
| 1678 |
+
}
|
| 1679 |
}
|
| 1680 |
},
|
| 1681 |
"node_modules/@testing-library/react/node_modules/@types/react-dom": {
|
|
|
|
| 1702 |
"@testing-library/dom": ">=7.21.4"
|
| 1703 |
}
|
| 1704 |
},
|
| 1705 |
+
"node_modules/@tonejs/midi": {
|
| 1706 |
+
"version": "2.0.28",
|
| 1707 |
+
"resolved": "https://registry.npmjs.org/@tonejs/midi/-/midi-2.0.28.tgz",
|
| 1708 |
+
"integrity": "sha512-RII6YpInPsOZ5t3Si/20QKpNqB1lZ2OCFJSOzJxz38YdY/3zqDr3uaml4JuCWkdixuPqP1/TBnXzhQ39csyoVg==",
|
| 1709 |
+
"license": "MIT",
|
| 1710 |
+
"dependencies": {
|
| 1711 |
+
"array-flatten": "^3.0.0",
|
| 1712 |
+
"midi-file": "^1.2.2"
|
| 1713 |
+
}
|
| 1714 |
+
},
|
| 1715 |
"node_modules/@types/aria-query": {
|
| 1716 |
"version": "5.0.4",
|
| 1717 |
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
|
|
|
| 2436 |
"node": ">= 0.4"
|
| 2437 |
}
|
| 2438 |
},
|
| 2439 |
+
"node_modules/array-flatten": {
|
| 2440 |
+
"version": "3.0.0",
|
| 2441 |
+
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz",
|
| 2442 |
+
"integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==",
|
| 2443 |
+
"license": "MIT"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2444 |
},
|
| 2445 |
"node_modules/assertion-error": {
|
| 2446 |
"version": "1.1.0",
|
|
|
|
| 2472 |
"node": ">=18.2.0"
|
| 2473 |
}
|
| 2474 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2475 |
"node_modules/balanced-match": {
|
| 2476 |
"version": "1.0.2",
|
| 2477 |
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
|
|
|
| 2567 |
"node": ">=8"
|
| 2568 |
}
|
| 2569 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2570 |
"node_modules/call-bind-apply-helpers": {
|
| 2571 |
"version": "1.0.2",
|
| 2572 |
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
|
|
|
| 2581 |
"node": ">= 0.4"
|
| 2582 |
}
|
| 2583 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2584 |
"node_modules/callsites": {
|
| 2585 |
"version": "3.1.0",
|
| 2586 |
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
|
|
|
| 2831 |
"node": ">=6"
|
| 2832 |
}
|
| 2833 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2834 |
"node_modules/deep-is": {
|
| 2835 |
"version": "0.1.4",
|
| 2836 |
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
|
|
|
| 2838 |
"dev": true,
|
| 2839 |
"license": "MIT"
|
| 2840 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2841 |
"node_modules/delayed-stream": {
|
| 2842 |
"version": "1.0.0",
|
| 2843 |
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
|
|
|
| 2848 |
"node": ">=0.4.0"
|
| 2849 |
}
|
| 2850 |
},
|
| 2851 |
+
"node_modules/dequal": {
|
| 2852 |
+
"version": "2.0.3",
|
| 2853 |
+
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
| 2854 |
+
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
| 2855 |
+
"dev": true,
|
| 2856 |
+
"license": "MIT",
|
| 2857 |
+
"engines": {
|
| 2858 |
+
"node": ">=6"
|
| 2859 |
+
}
|
| 2860 |
+
},
|
| 2861 |
"node_modules/diff-sequences": {
|
| 2862 |
"version": "29.6.3",
|
| 2863 |
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
|
|
|
| 2930 |
"node": ">= 0.4"
|
| 2931 |
}
|
| 2932 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2933 |
"node_modules/es-object-atoms": {
|
| 2934 |
"version": "1.1.1",
|
| 2935 |
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
|
|
|
| 3392 |
"dev": true,
|
| 3393 |
"license": "ISC"
|
| 3394 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3395 |
"node_modules/form-data": {
|
| 3396 |
"version": "4.0.5",
|
| 3397 |
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
|
|
|
| 3441 |
"url": "https://github.com/sponsors/ljharb"
|
| 3442 |
}
|
| 3443 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3444 |
"node_modules/gensync": {
|
| 3445 |
"version": "1.0.0-beta.2",
|
| 3446 |
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
|
|
|
| 3574 |
"url": "https://github.com/sponsors/ljharb"
|
| 3575 |
}
|
| 3576 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3577 |
"node_modules/has-flag": {
|
| 3578 |
"version": "4.0.0",
|
| 3579 |
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
|
|
|
| 3584 |
"node": ">=8"
|
| 3585 |
}
|
| 3586 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3587 |
"node_modules/has-symbols": {
|
| 3588 |
"version": "1.1.0",
|
| 3589 |
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
|
|
|
| 3780 |
"dev": true,
|
| 3781 |
"license": "ISC"
|
| 3782 |
},
|
| 3783 |
+
"node_modules/is-extglob": {
|
| 3784 |
+
"version": "2.1.1",
|
| 3785 |
+
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
| 3786 |
+
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3787 |
"dev": true,
|
| 3788 |
"license": "MIT",
|
| 3789 |
"engines": {
|
| 3790 |
+
"node": ">=0.10.0"
|
|
|
|
|
|
|
|
|
|
| 3791 |
}
|
| 3792 |
},
|
| 3793 |
+
"node_modules/is-glob": {
|
| 3794 |
+
"version": "4.0.3",
|
| 3795 |
+
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
| 3796 |
+
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
| 3797 |
"dev": true,
|
| 3798 |
"license": "MIT",
|
| 3799 |
"dependencies": {
|
| 3800 |
+
"is-extglob": "^2.1.1"
|
|
|
|
| 3801 |
},
|
| 3802 |
"engines": {
|
| 3803 |
+
"node": ">=0.10.0"
|
|
|
|
|
|
|
|
|
|
| 3804 |
}
|
| 3805 |
},
|
| 3806 |
+
"node_modules/is-number": {
|
| 3807 |
+
"version": "7.0.0",
|
| 3808 |
+
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
| 3809 |
+
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
| 3810 |
"dev": true,
|
| 3811 |
"license": "MIT",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3812 |
"engines": {
|
| 3813 |
+
"node": ">=0.12.0"
|
|
|
|
|
|
|
|
|
|
| 3814 |
}
|
| 3815 |
},
|
| 3816 |
+
"node_modules/is-potential-custom-element-name": {
|
| 3817 |
+
"version": "1.0.1",
|
| 3818 |
+
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
| 3819 |
+
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
| 3820 |
"dev": true,
|
| 3821 |
+
"license": "MIT"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3822 |
},
|
| 3823 |
+
"node_modules/is-stream": {
|
| 3824 |
+
"version": "3.0.0",
|
| 3825 |
+
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
|
| 3826 |
+
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
|
| 3827 |
"dev": true,
|
| 3828 |
"license": "MIT",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3829 |
"engines": {
|
| 3830 |
+
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
| 3831 |
},
|
| 3832 |
"funding": {
|
| 3833 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 3834 |
}
|
| 3835 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3836 |
"node_modules/isexe": {
|
| 3837 |
"version": "2.0.0",
|
| 3838 |
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
|
|
|
| 4208 |
"url": "https://github.com/sponsors/jonschlinkert"
|
| 4209 |
}
|
| 4210 |
},
|
| 4211 |
+
"node_modules/midi-file": {
|
| 4212 |
+
"version": "1.2.4",
|
| 4213 |
+
"resolved": "https://registry.npmjs.org/midi-file/-/midi-file-1.2.4.tgz",
|
| 4214 |
+
"integrity": "sha512-B5SnBC6i2bwJIXTY9MElIydJwAmnKx+r5eJ1jknTLetzLflEl0GWveuBB6ACrQpecSRkOB6fhTx1PwXk2BVxnA==",
|
| 4215 |
+
"license": "MIT"
|
| 4216 |
+
},
|
| 4217 |
"node_modules/mime-db": {
|
| 4218 |
"version": "1.52.0",
|
| 4219 |
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
|
|
|
| 4372 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 4373 |
}
|
| 4374 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4375 |
"node_modules/once": {
|
| 4376 |
"version": "1.4.0",
|
| 4377 |
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
|
|
|
| 4560 |
"dev": true,
|
| 4561 |
"license": "MIT"
|
| 4562 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4563 |
"node_modules/postcss": {
|
| 4564 |
"version": "8.5.6",
|
| 4565 |
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
|
|
|
| 4730 |
"node": ">=8"
|
| 4731 |
}
|
| 4732 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4733 |
"node_modules/require-from-string": {
|
| 4734 |
"version": "2.0.2",
|
| 4735 |
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
|
|
|
| 4841 |
"queue-microtask": "^1.2.2"
|
| 4842 |
}
|
| 4843 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4844 |
"node_modules/safer-buffer": {
|
| 4845 |
"version": "2.1.2",
|
| 4846 |
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
|
|
|
| 4877 |
"semver": "bin/semver.js"
|
| 4878 |
}
|
| 4879 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4880 |
"node_modules/shebang-command": {
|
| 4881 |
"version": "2.0.0",
|
| 4882 |
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
|
|
|
| 4900 |
"node": ">=8"
|
| 4901 |
}
|
| 4902 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4903 |
"node_modules/siginfo": {
|
| 4904 |
"version": "2.0.0",
|
| 4905 |
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
|
|
|
| 4970 |
"dev": true,
|
| 4971 |
"license": "MIT"
|
| 4972 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4973 |
"node_modules/strip-final-newline": {
|
| 4974 |
"version": "3.0.0",
|
| 4975 |
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
|
|
|
|
| 6552 |
"node": ">= 8"
|
| 6553 |
}
|
| 6554 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6555 |
"node_modules/why-is-node-running": {
|
| 6556 |
"version": "2.3.0",
|
| 6557 |
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
frontend/package.json
CHANGED
|
@@ -13,6 +13,7 @@
|
|
| 13 |
"test:coverage": "vitest --coverage"
|
| 14 |
},
|
| 15 |
"dependencies": {
|
|
|
|
| 16 |
"@xmldom/xmldom": "^0.8.11",
|
| 17 |
"react": "^19.2.0",
|
| 18 |
"react-dom": "^19.2.0",
|
|
@@ -29,6 +30,7 @@
|
|
| 29 |
"@types/react": "^19.2.5",
|
| 30 |
"@types/react-dom": "^19.2.3",
|
| 31 |
"@vitejs/plugin-react": "^5.1.1",
|
|
|
|
| 32 |
"@vitest/ui": "^1.1.0",
|
| 33 |
"eslint": "^9.39.1",
|
| 34 |
"eslint-plugin-react-hooks": "^7.0.1",
|
|
@@ -38,7 +40,6 @@
|
|
| 38 |
"typescript": "~5.9.3",
|
| 39 |
"typescript-eslint": "^8.46.4",
|
| 40 |
"vite": "^7.2.4",
|
| 41 |
-
"vitest": "^1.1.0"
|
| 42 |
-
"@vitest/coverage-v8": "^1.1.0"
|
| 43 |
}
|
| 44 |
}
|
|
|
|
| 13 |
"test:coverage": "vitest --coverage"
|
| 14 |
},
|
| 15 |
"dependencies": {
|
| 16 |
+
"@tonejs/midi": "^2.0.28",
|
| 17 |
"@xmldom/xmldom": "^0.8.11",
|
| 18 |
"react": "^19.2.0",
|
| 19 |
"react-dom": "^19.2.0",
|
|
|
|
| 30 |
"@types/react": "^19.2.5",
|
| 31 |
"@types/react-dom": "^19.2.3",
|
| 32 |
"@vitejs/plugin-react": "^5.1.1",
|
| 33 |
+
"@vitest/coverage-v8": "^1.1.0",
|
| 34 |
"@vitest/ui": "^1.1.0",
|
| 35 |
"eslint": "^9.39.1",
|
| 36 |
"eslint-plugin-react-hooks": "^7.0.1",
|
|
|
|
| 40 |
"typescript": "~5.9.3",
|
| 41 |
"typescript-eslint": "^8.46.4",
|
| 42 |
"vite": "^7.2.4",
|
| 43 |
+
"vitest": "^1.1.0"
|
|
|
|
| 44 |
}
|
| 45 |
}
|
frontend/src/api/client.ts
CHANGED
|
@@ -141,3 +141,29 @@ export async function getJobStatus(jobId: string) {
|
|
| 141 |
export async function downloadScore(jobId: string) {
|
| 142 |
return api.getScore(jobId);
|
| 143 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
export async function downloadScore(jobId: string) {
|
| 142 |
return api.getScore(jobId);
|
| 143 |
}
|
| 144 |
+
|
| 145 |
+
export async function getMidiFile(jobId: string): Promise<ArrayBuffer> {
|
| 146 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/scores/${jobId}/midi`);
|
| 147 |
+
|
| 148 |
+
if (!response.ok) {
|
| 149 |
+
throw new Error('Failed to fetch MIDI file');
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
return response.arrayBuffer();
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
export interface ScoreMetadata {
|
| 156 |
+
tempo: number;
|
| 157 |
+
key_signature: string;
|
| 158 |
+
time_signature: { numerator: number; denominator: number };
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
export async function getMetadata(jobId: string): Promise<ScoreMetadata> {
|
| 162 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/scores/${jobId}/metadata`);
|
| 163 |
+
|
| 164 |
+
if (!response.ok) {
|
| 165 |
+
throw new Error('Failed to fetch metadata');
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
return response.json();
|
| 169 |
+
}
|
frontend/src/components/NotationCanvas.tsx
CHANGED
|
@@ -10,7 +10,6 @@ import type { Note, Part } from '../store/notation';
|
|
| 10 |
import './NotationCanvas.css';
|
| 11 |
|
| 12 |
interface NotationCanvasProps {
|
| 13 |
-
musicXML: string;
|
| 14 |
showControls?: boolean;
|
| 15 |
interactive?: boolean;
|
| 16 |
onNoteSelect?: (id: string) => void;
|
|
@@ -20,7 +19,7 @@ interface NotationCanvasProps {
|
|
| 20 |
height?: number;
|
| 21 |
}
|
| 22 |
|
| 23 |
-
export function NotationCanvas({
|
| 24 |
const containerRef = useRef<HTMLDivElement>(null);
|
| 25 |
const score = useNotationStore((state) => state.score);
|
| 26 |
const playingNoteIds = useNotationStore((state) => state.playingNoteIds);
|
|
@@ -58,13 +57,6 @@ export function NotationCanvas({ musicXML, showControls, interactive, onNoteSele
|
|
| 58 |
containerRef.current.appendChild(label);
|
| 59 |
}
|
| 60 |
|
| 61 |
-
// Show simple error when XML is obviously invalid
|
| 62 |
-
if (musicXML && musicXML.includes('<invalid')) {
|
| 63 |
-
const err = document.createElement('div');
|
| 64 |
-
err.textContent = 'Invalid MusicXML';
|
| 65 |
-
containerRef.current.appendChild(err);
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
if (!score || !score.parts.length) {
|
| 69 |
return;
|
| 70 |
}
|
|
@@ -93,7 +85,7 @@ export function NotationCanvas({ musicXML, showControls, interactive, onNoteSele
|
|
| 93 |
// Single staff fallback
|
| 94 |
renderSingleStaff(context, score.parts[0], measuresPerRow, staveWidth, staveHeight, score);
|
| 95 |
}
|
| 96 |
-
}, [
|
| 97 |
|
| 98 |
// Highlight playing notes
|
| 99 |
useEffect(() => {
|
|
@@ -132,6 +124,16 @@ export function NotationCanvas({ musicXML, showControls, interactive, onNoteSele
|
|
| 132 |
|
| 133 |
export default NotationCanvas;
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
/**
|
| 136 |
* Render grand staff with treble and bass clefs connected by brace
|
| 137 |
*/
|
|
@@ -160,8 +162,9 @@ function renderGrandStaff(
|
|
| 160 |
if (col === 0) {
|
| 161 |
trebleStave.addClef('treble');
|
| 162 |
trebleStave.addTimeSignature(score.timeSignature);
|
| 163 |
-
|
| 164 |
-
|
|
|
|
| 165 |
}
|
| 166 |
}
|
| 167 |
trebleStave.setContext(context).draw();
|
|
@@ -171,8 +174,9 @@ function renderGrandStaff(
|
|
| 171 |
if (col === 0) {
|
| 172 |
bassStave.addClef('bass');
|
| 173 |
bassStave.addTimeSignature(score.timeSignature);
|
| 174 |
-
|
| 175 |
-
|
|
|
|
| 176 |
}
|
| 177 |
}
|
| 178 |
bassStave.setContext(context).draw();
|
|
@@ -222,8 +226,9 @@ function renderSingleStaff(
|
|
| 222 |
if (idx === 0) {
|
| 223 |
stave.addClef(part.clef);
|
| 224 |
stave.addTimeSignature(score.timeSignature);
|
| 225 |
-
|
| 226 |
-
|
|
|
|
| 227 |
}
|
| 228 |
}
|
| 229 |
|
|
@@ -245,58 +250,54 @@ function renderMeasureNotes(
|
|
| 245 |
timeSignature: string
|
| 246 |
): void {
|
| 247 |
try {
|
| 248 |
-
// Group notes by
|
| 249 |
-
//
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
const vexNotes: StaveNote[] = [];
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
while (i < notes.length) {
|
| 255 |
-
const currentNote = notes[i];
|
| 256 |
-
|
| 257 |
-
if (currentNote.isRest) {
|
| 258 |
-
// Rests are always single
|
| 259 |
-
try {
|
| 260 |
-
vexNotes.push(convertToVexNote(currentNote));
|
| 261 |
-
} catch (err) {
|
| 262 |
-
// Skip invalid rest
|
| 263 |
-
}
|
| 264 |
-
i++;
|
| 265 |
-
continue;
|
| 266 |
-
}
|
| 267 |
-
|
| 268 |
-
// Look ahead to find all notes with same duration (potential chord)
|
| 269 |
-
const chordNotes: Note[] = [currentNote];
|
| 270 |
-
let j = i + 1;
|
| 271 |
-
|
| 272 |
-
// Collect consecutive notes with same duration as a chord
|
| 273 |
-
while (j < notes.length &&
|
| 274 |
-
!notes[j].isRest &&
|
| 275 |
-
notes[j].duration === currentNote.duration &&
|
| 276 |
-
notes[j].dotted === currentNote.dotted) {
|
| 277 |
-
chordNotes.push(notes[j]);
|
| 278 |
-
j++;
|
| 279 |
-
|
| 280 |
-
// Limit chord size to reasonable amount (max 10 notes)
|
| 281 |
-
if (chordNotes.length >= 10) break;
|
| 282 |
-
}
|
| 283 |
-
|
| 284 |
-
// Create VexFlow note (single or chord)
|
| 285 |
try {
|
| 286 |
-
if (
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
} else {
|
| 289 |
-
|
|
|
|
| 290 |
}
|
| 291 |
} catch (err) {
|
| 292 |
// Skip invalid note/chord
|
|
|
|
| 293 |
}
|
| 294 |
-
|
| 295 |
-
i = j;
|
| 296 |
}
|
| 297 |
|
|
|
|
| 298 |
if (vexNotes.length === 0) {
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
}
|
| 301 |
|
| 302 |
// Create voice - use SOFT mode for now to handle incomplete measures gracefully
|
|
|
|
| 10 |
import './NotationCanvas.css';
|
| 11 |
|
| 12 |
interface NotationCanvasProps {
|
|
|
|
| 13 |
showControls?: boolean;
|
| 14 |
interactive?: boolean;
|
| 15 |
onNoteSelect?: (id: string) => void;
|
|
|
|
| 19 |
height?: number;
|
| 20 |
}
|
| 21 |
|
| 22 |
+
export function NotationCanvas({ showControls, interactive, onNoteSelect, selectedNotes, showMeasureNumbers, width, height }: NotationCanvasProps) {
|
| 23 |
const containerRef = useRef<HTMLDivElement>(null);
|
| 24 |
const score = useNotationStore((state) => state.score);
|
| 25 |
const playingNoteIds = useNotationStore((state) => state.playingNoteIds);
|
|
|
|
| 57 |
containerRef.current.appendChild(label);
|
| 58 |
}
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
if (!score || !score.parts.length) {
|
| 61 |
return;
|
| 62 |
}
|
|
|
|
| 85 |
// Single staff fallback
|
| 86 |
renderSingleStaff(context, score.parts[0], measuresPerRow, staveWidth, staveHeight, score);
|
| 87 |
}
|
| 88 |
+
}, [score]);
|
| 89 |
|
| 90 |
// Highlight playing notes
|
| 91 |
useEffect(() => {
|
|
|
|
| 124 |
|
| 125 |
export default NotationCanvas;
|
| 126 |
|
| 127 |
+
/**
|
| 128 |
+
* Convert key signature from music21 format (e.g., "A major", "F# minor")
|
| 129 |
+
* to VexFlow format (e.g., "A", "F#")
|
| 130 |
+
*/
|
| 131 |
+
function convertKeySignature(key: string): string {
|
| 132 |
+
if (!key) return 'C';
|
| 133 |
+
// Remove " major" or " minor" suffix
|
| 134 |
+
return key.replace(/ major| minor/gi, '').trim();
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
/**
|
| 138 |
* Render grand staff with treble and bass clefs connected by brace
|
| 139 |
*/
|
|
|
|
| 162 |
if (col === 0) {
|
| 163 |
trebleStave.addClef('treble');
|
| 164 |
trebleStave.addTimeSignature(score.timeSignature);
|
| 165 |
+
const vexflowKey = convertKeySignature(score.key);
|
| 166 |
+
if (vexflowKey && vexflowKey !== 'C') {
|
| 167 |
+
trebleStave.addKeySignature(vexflowKey);
|
| 168 |
}
|
| 169 |
}
|
| 170 |
trebleStave.setContext(context).draw();
|
|
|
|
| 174 |
if (col === 0) {
|
| 175 |
bassStave.addClef('bass');
|
| 176 |
bassStave.addTimeSignature(score.timeSignature);
|
| 177 |
+
const vexflowKey = convertKeySignature(score.key);
|
| 178 |
+
if (vexflowKey && vexflowKey !== 'C') {
|
| 179 |
+
bassStave.addKeySignature(vexflowKey);
|
| 180 |
}
|
| 181 |
}
|
| 182 |
bassStave.setContext(context).draw();
|
|
|
|
| 226 |
if (idx === 0) {
|
| 227 |
stave.addClef(part.clef);
|
| 228 |
stave.addTimeSignature(score.timeSignature);
|
| 229 |
+
const vexflowKey = convertKeySignature(score.key);
|
| 230 |
+
if (vexflowKey && vexflowKey !== 'C') {
|
| 231 |
+
stave.addKeySignature(vexflowKey);
|
| 232 |
}
|
| 233 |
}
|
| 234 |
|
|
|
|
| 250 |
timeSignature: string
|
| 251 |
): void {
|
| 252 |
try {
|
| 253 |
+
// Group notes by chordId (trust the parser's chord grouping from MusicXML <chord/> tags)
|
| 254 |
+
// This is more accurate than re-detecting chords by duration
|
| 255 |
+
const noteGroups: Record<string, Note[]> = notes.reduce((groups, note) => {
|
| 256 |
+
const groupId = note.chordId || `single-${note.id}`;
|
| 257 |
+
if (!groups[groupId]) groups[groupId] = [];
|
| 258 |
+
groups[groupId].push(note);
|
| 259 |
+
return groups;
|
| 260 |
+
}, {} as Record<string, Note[]>);
|
| 261 |
+
|
| 262 |
+
// Convert each group to VexFlow note
|
| 263 |
const vexNotes: StaveNote[] = [];
|
| 264 |
+
for (const group of Object.values(noteGroups)) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
try {
|
| 266 |
+
if (group[0].isRest) {
|
| 267 |
+
// Rests are always single
|
| 268 |
+
vexNotes.push(convertToVexNote(group[0]));
|
| 269 |
+
} else if (group.length === 1) {
|
| 270 |
+
// Single note
|
| 271 |
+
vexNotes.push(convertToVexNote(group[0]));
|
| 272 |
} else {
|
| 273 |
+
// Chord (multiple notes with same chordId)
|
| 274 |
+
vexNotes.push(convertToVexChord(group));
|
| 275 |
}
|
| 276 |
} catch (err) {
|
| 277 |
// Skip invalid note/chord
|
| 278 |
+
console.warn('Failed to convert note/chord:', err);
|
| 279 |
}
|
|
|
|
|
|
|
| 280 |
}
|
| 281 |
|
| 282 |
+
// Handle empty measures by rendering a whole rest
|
| 283 |
if (vexNotes.length === 0) {
|
| 284 |
+
const [beats, beatValue] = timeSignature.split('/').map(Number);
|
| 285 |
+
// Use whole rest for 4/4, half rest for 2/4, etc.
|
| 286 |
+
const restDuration = beats >= 4 ? 'w' : beats >= 2 ? 'h' : 'q';
|
| 287 |
+
|
| 288 |
+
const wholeRest = new StaveNote({
|
| 289 |
+
keys: ['b/4'],
|
| 290 |
+
duration: restDuration + 'r',
|
| 291 |
+
});
|
| 292 |
+
|
| 293 |
+
const voice = new Voice({ num_beats: beats, beat_value: beatValue });
|
| 294 |
+
voice.setMode(Voice.Mode.SOFT);
|
| 295 |
+
voice.addTickables([wholeRest]);
|
| 296 |
+
|
| 297 |
+
const formatter = new Formatter();
|
| 298 |
+
formatter.joinVoices([voice]).format([voice], stave.getWidth() - 20);
|
| 299 |
+
voice.draw(context, stave);
|
| 300 |
+
return;
|
| 301 |
}
|
| 302 |
|
| 303 |
// Create voice - use SOFT mode for now to handle incomplete measures gracefully
|
frontend/src/components/ScoreEditor.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
* Main score editor component integrating notation, playback, and export.
|
| 3 |
*/
|
| 4 |
import { useState, useEffect } from 'react';
|
| 5 |
-
import {
|
| 6 |
import { useNotationStore } from '../store/notation';
|
| 7 |
import { NotationCanvas } from './NotationCanvas';
|
| 8 |
import { PlaybackControls } from './PlaybackControls';
|
|
@@ -13,9 +13,9 @@ interface ScoreEditorProps {
|
|
| 13 |
}
|
| 14 |
|
| 15 |
export function ScoreEditor({ jobId }: ScoreEditorProps) {
|
| 16 |
-
const [musicXML, setMusicXML] = useState('');
|
| 17 |
const [loading, setLoading] = useState(true);
|
| 18 |
-
const
|
|
|
|
| 19 |
|
| 20 |
useEffect(() => {
|
| 21 |
loadScore();
|
|
@@ -23,36 +23,66 @@ export function ScoreEditor({ jobId }: ScoreEditorProps) {
|
|
| 23 |
|
| 24 |
const loadScore = async () => {
|
| 25 |
try {
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
setLoading(false);
|
| 30 |
} catch (err) {
|
| 31 |
console.error('Failed to load score:', err);
|
|
|
|
| 32 |
setLoading(false);
|
| 33 |
}
|
| 34 |
};
|
| 35 |
|
| 36 |
const handleExportMusicXML = () => {
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
});
|
| 40 |
-
const url = URL.createObjectURL(blob);
|
| 41 |
-
const a = document.createElement('a');
|
| 42 |
-
a.href = url;
|
| 43 |
-
a.download = `score_${jobId}.musicxml`;
|
| 44 |
-
a.click();
|
| 45 |
-
URL.revokeObjectURL(url);
|
| 46 |
};
|
| 47 |
|
| 48 |
-
const handleExportMIDI = () => {
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
};
|
| 51 |
|
| 52 |
if (loading) {
|
| 53 |
return <div className="score-editor loading">Loading score...</div>;
|
| 54 |
}
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
return (
|
| 57 |
<div className="score-editor">
|
| 58 |
<div className="editor-toolbar">
|
|
@@ -65,7 +95,7 @@ export function ScoreEditor({ jobId }: ScoreEditorProps) {
|
|
| 65 |
|
| 66 |
<PlaybackControls />
|
| 67 |
|
| 68 |
-
<NotationCanvas
|
| 69 |
|
| 70 |
<div className="editor-instructions">
|
| 71 |
<h3>Editing Instructions (MVP)</h3>
|
|
|
|
| 2 |
* Main score editor component integrating notation, playback, and export.
|
| 3 |
*/
|
| 4 |
import { useState, useEffect } from 'react';
|
| 5 |
+
import { getMidiFile, getMetadata } from '../api/client';
|
| 6 |
import { useNotationStore } from '../store/notation';
|
| 7 |
import { NotationCanvas } from './NotationCanvas';
|
| 8 |
import { PlaybackControls } from './PlaybackControls';
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
export function ScoreEditor({ jobId }: ScoreEditorProps) {
|
|
|
|
| 16 |
const [loading, setLoading] = useState(true);
|
| 17 |
+
const [error, setError] = useState<string | null>(null);
|
| 18 |
+
const loadFromMidi = useNotationStore((state) => state.loadFromMidi);
|
| 19 |
|
| 20 |
useEffect(() => {
|
| 21 |
loadScore();
|
|
|
|
| 23 |
|
| 24 |
const loadScore = async () => {
|
| 25 |
try {
|
| 26 |
+
setLoading(true);
|
| 27 |
+
setError(null);
|
| 28 |
+
|
| 29 |
+
// Fetch MIDI file and metadata in parallel
|
| 30 |
+
const [midiData, metadata] = await Promise.all([
|
| 31 |
+
getMidiFile(jobId),
|
| 32 |
+
getMetadata(jobId),
|
| 33 |
+
]);
|
| 34 |
+
|
| 35 |
+
// Load MIDI into notation store
|
| 36 |
+
await loadFromMidi(midiData, {
|
| 37 |
+
tempo: metadata.tempo,
|
| 38 |
+
keySignature: metadata.key_signature,
|
| 39 |
+
timeSignature: metadata.time_signature,
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
setLoading(false);
|
| 43 |
} catch (err) {
|
| 44 |
console.error('Failed to load score:', err);
|
| 45 |
+
setError(err instanceof Error ? err.message : 'Failed to load score');
|
| 46 |
setLoading(false);
|
| 47 |
}
|
| 48 |
};
|
| 49 |
|
| 50 |
const handleExportMusicXML = () => {
|
| 51 |
+
// TODO: Generate MusicXML from edited score state
|
| 52 |
+
alert('MusicXML export coming soon - will generate from your edited notation');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
};
|
| 54 |
|
| 55 |
+
const handleExportMIDI = async () => {
|
| 56 |
+
try {
|
| 57 |
+
// Download the original MIDI file
|
| 58 |
+
const midiData = await getMidiFile(jobId);
|
| 59 |
+
const blob = new Blob([midiData], { type: 'audio/midi' });
|
| 60 |
+
const url = URL.createObjectURL(blob);
|
| 61 |
+
const a = document.createElement('a');
|
| 62 |
+
a.href = url;
|
| 63 |
+
a.download = `score_${jobId}.mid`;
|
| 64 |
+
a.click();
|
| 65 |
+
URL.revokeObjectURL(url);
|
| 66 |
+
} catch (err) {
|
| 67 |
+
console.error('Failed to export MIDI:', err);
|
| 68 |
+
alert('Failed to export MIDI file');
|
| 69 |
+
}
|
| 70 |
};
|
| 71 |
|
| 72 |
if (loading) {
|
| 73 |
return <div className="score-editor loading">Loading score...</div>;
|
| 74 |
}
|
| 75 |
|
| 76 |
+
if (error) {
|
| 77 |
+
return (
|
| 78 |
+
<div className="score-editor error">
|
| 79 |
+
<h2>Error Loading Score</h2>
|
| 80 |
+
<p>{error}</p>
|
| 81 |
+
<button onClick={loadScore}>Retry</button>
|
| 82 |
+
</div>
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
return (
|
| 87 |
<div className="score-editor">
|
| 88 |
<div className="editor-toolbar">
|
|
|
|
| 95 |
|
| 96 |
<PlaybackControls />
|
| 97 |
|
| 98 |
+
<NotationCanvas />
|
| 99 |
|
| 100 |
<div className="editor-instructions">
|
| 101 |
<h3>Editing Instructions (MVP)</h3>
|
frontend/src/store/notation.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
*/
|
| 4 |
import { create } from 'zustand';
|
| 5 |
import { parseMusicXML } from '../utils/musicxml-parser';
|
|
|
|
| 6 |
|
| 7 |
export interface Note {
|
| 8 |
id: string;
|
|
@@ -13,6 +14,7 @@ export interface Note {
|
|
| 13 |
dotted: boolean;
|
| 14 |
accidental?: 'sharp' | 'flat' | 'natural';
|
| 15 |
isRest: boolean;
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
export interface Measure {
|
|
@@ -48,6 +50,14 @@ interface NotationState {
|
|
| 48 |
|
| 49 |
// Actions
|
| 50 |
loadFromMusicXML: (xml: string) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
exportToMusicXML: () => string;
|
| 52 |
addNote: (measureId: string, note: Note) => void;
|
| 53 |
deleteNote: (noteId: string) => void;
|
|
@@ -88,6 +98,40 @@ export const useNotationStore = create<NotationState>((set, _get) => ({
|
|
| 88 |
}
|
| 89 |
},
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
exportToMusicXML: () => {
|
| 92 |
// TODO: Implement MusicXML generation
|
| 93 |
return '<?xml version="1.0"?><score-partwise></score-partwise>';
|
|
|
|
| 3 |
*/
|
| 4 |
import { create } from 'zustand';
|
| 5 |
import { parseMusicXML } from '../utils/musicxml-parser';
|
| 6 |
+
import { parseMidiFile, assignChordIds } from '../utils/midi-parser';
|
| 7 |
|
| 8 |
export interface Note {
|
| 9 |
id: string;
|
|
|
|
| 14 |
dotted: boolean;
|
| 15 |
accidental?: 'sharp' | 'flat' | 'natural';
|
| 16 |
isRest: boolean;
|
| 17 |
+
chordId?: string; // Group chord notes together (notes with same chordId are rendered as single VexFlow chord)
|
| 18 |
}
|
| 19 |
|
| 20 |
export interface Measure {
|
|
|
|
| 50 |
|
| 51 |
// Actions
|
| 52 |
loadFromMusicXML: (xml: string) => void;
|
| 53 |
+
loadFromMidi: (
|
| 54 |
+
midiData: ArrayBuffer,
|
| 55 |
+
metadata?: {
|
| 56 |
+
tempo?: number;
|
| 57 |
+
keySignature?: string;
|
| 58 |
+
timeSignature?: { numerator: number; denominator: number };
|
| 59 |
+
}
|
| 60 |
+
) => Promise<void>;
|
| 61 |
exportToMusicXML: () => string;
|
| 62 |
addNote: (measureId: string, note: Note) => void;
|
| 63 |
deleteNote: (noteId: string) => void;
|
|
|
|
| 98 |
}
|
| 99 |
},
|
| 100 |
|
| 101 |
+
loadFromMidi: async (midiData, metadata) => {
|
| 102 |
+
try {
|
| 103 |
+
let score = await parseMidiFile(midiData, {
|
| 104 |
+
tempo: metadata?.tempo,
|
| 105 |
+
timeSignature: metadata?.timeSignature,
|
| 106 |
+
keySignature: metadata?.keySignature,
|
| 107 |
+
splitAtMiddleC: true,
|
| 108 |
+
middleCNote: 60,
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
// Assign chord IDs to simultaneous notes
|
| 112 |
+
score = assignChordIds(score);
|
| 113 |
+
|
| 114 |
+
set({ score });
|
| 115 |
+
} catch (error) {
|
| 116 |
+
console.error('Failed to parse MIDI:', error);
|
| 117 |
+
// Fallback to empty score
|
| 118 |
+
set({
|
| 119 |
+
score: {
|
| 120 |
+
id: 'score-1',
|
| 121 |
+
title: 'Transcribed Score',
|
| 122 |
+
composer: 'YourMT3+',
|
| 123 |
+
key: metadata?.keySignature || 'C',
|
| 124 |
+
timeSignature: metadata?.timeSignature
|
| 125 |
+
? `${metadata.timeSignature.numerator}/${metadata.timeSignature.denominator}`
|
| 126 |
+
: '4/4',
|
| 127 |
+
tempo: metadata?.tempo || 120,
|
| 128 |
+
parts: [],
|
| 129 |
+
measures: [],
|
| 130 |
+
},
|
| 131 |
+
});
|
| 132 |
+
}
|
| 133 |
+
},
|
| 134 |
+
|
| 135 |
exportToMusicXML: () => {
|
| 136 |
// TODO: Implement MusicXML generation
|
| 137 |
return '<?xml version="1.0"?><score-partwise></score-partwise>';
|
frontend/src/utils/midi-parser.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* MIDI Parser - Converts MIDI files to internal Score format
|
| 3 |
+
*
|
| 4 |
+
* This bypasses MusicXML entirely to preserve YourMT3+ transcription accuracy.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import { Midi } from '@tonejs/midi';
|
| 8 |
+
import type { Score, Part, Measure, Note } from '../store/notation';
|
| 9 |
+
|
| 10 |
+
export interface MidiParseOptions {
|
| 11 |
+
tempo?: number;
|
| 12 |
+
timeSignature?: { numerator: number; denominator: number };
|
| 13 |
+
keySignature?: string;
|
| 14 |
+
splitAtMiddleC?: boolean; // For grand staff (treble + bass)
|
| 15 |
+
middleCNote?: number; // MIDI note number for staff split (default: 60)
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Parse MIDI file into Score format
|
| 20 |
+
*/
|
| 21 |
+
export async function parseMidiFile(
|
| 22 |
+
midiData: ArrayBuffer,
|
| 23 |
+
options: MidiParseOptions = {}
|
| 24 |
+
): Promise<Score> {
|
| 25 |
+
const midi = new Midi(midiData);
|
| 26 |
+
|
| 27 |
+
// Extract metadata
|
| 28 |
+
const tempo = options.tempo || midi.header.tempos[0]?.bpm || 120;
|
| 29 |
+
const timeSignature = options.timeSignature || {
|
| 30 |
+
numerator: midi.header.timeSignatures[0]?.timeSignature[0] || 4,
|
| 31 |
+
denominator: midi.header.timeSignatures[0]?.timeSignature[1] || 4,
|
| 32 |
+
};
|
| 33 |
+
const keySignature = options.keySignature || 'C';
|
| 34 |
+
|
| 35 |
+
// Parse all tracks into single note list
|
| 36 |
+
const allNotes = extractNotesFromMidi(midi);
|
| 37 |
+
|
| 38 |
+
// Create measures from notes
|
| 39 |
+
const measureDuration = (timeSignature.numerator / timeSignature.denominator) * 4; // in quarter notes
|
| 40 |
+
const parts = createPartsFromNotes(
|
| 41 |
+
allNotes,
|
| 42 |
+
measureDuration,
|
| 43 |
+
options.splitAtMiddleC ?? true,
|
| 44 |
+
options.middleCNote ?? 60,
|
| 45 |
+
tempo
|
| 46 |
+
);
|
| 47 |
+
|
| 48 |
+
return {
|
| 49 |
+
id: 'score-1',
|
| 50 |
+
title: midi.name || 'Transcribed Score',
|
| 51 |
+
composer: 'YourMT3+',
|
| 52 |
+
key: keySignature,
|
| 53 |
+
timeSignature: `${timeSignature.numerator}/${timeSignature.denominator}`,
|
| 54 |
+
tempo,
|
| 55 |
+
parts,
|
| 56 |
+
measures: parts[0]?.measures || [], // Legacy compatibility
|
| 57 |
+
};
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
interface MidiNote {
|
| 61 |
+
midi: number;
|
| 62 |
+
time: number;
|
| 63 |
+
duration: number;
|
| 64 |
+
velocity: number;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/**
|
| 68 |
+
* Extract all notes from MIDI tracks
|
| 69 |
+
*/
|
| 70 |
+
function extractNotesFromMidi(midi: Midi): MidiNote[] {
|
| 71 |
+
const notes: MidiNote[] = [];
|
| 72 |
+
|
| 73 |
+
for (const track of midi.tracks) {
|
| 74 |
+
for (const note of track.notes) {
|
| 75 |
+
notes.push({
|
| 76 |
+
midi: note.midi,
|
| 77 |
+
time: note.time,
|
| 78 |
+
duration: note.duration,
|
| 79 |
+
velocity: note.velocity,
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Sort by time
|
| 85 |
+
notes.sort((a, b) => a.time - b.time);
|
| 86 |
+
|
| 87 |
+
return notes;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* Create grand staff parts (treble + bass) or single part
|
| 92 |
+
*/
|
| 93 |
+
function createPartsFromNotes(
|
| 94 |
+
notes: MidiNote[],
|
| 95 |
+
measureDuration: number,
|
| 96 |
+
splitStaff: boolean,
|
| 97 |
+
middleCNote: number,
|
| 98 |
+
tempo: number
|
| 99 |
+
): Part[] {
|
| 100 |
+
if (splitStaff) {
|
| 101 |
+
// Split into treble (>= middle C) and bass (< middle C)
|
| 102 |
+
const trebleNotes = notes.filter((n) => n.midi >= middleCNote);
|
| 103 |
+
const bassNotes = notes.filter((n) => n.midi < middleCNote);
|
| 104 |
+
|
| 105 |
+
return [
|
| 106 |
+
{
|
| 107 |
+
id: 'part-treble',
|
| 108 |
+
name: 'Piano Right Hand',
|
| 109 |
+
clef: 'treble',
|
| 110 |
+
measures: createMeasures(trebleNotes, measureDuration, tempo),
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
id: 'part-bass',
|
| 114 |
+
name: 'Piano Left Hand',
|
| 115 |
+
clef: 'bass',
|
| 116 |
+
measures: createMeasures(bassNotes, measureDuration, tempo),
|
| 117 |
+
},
|
| 118 |
+
];
|
| 119 |
+
} else {
|
| 120 |
+
// Single staff
|
| 121 |
+
return [
|
| 122 |
+
{
|
| 123 |
+
id: 'part-1',
|
| 124 |
+
name: 'Piano',
|
| 125 |
+
clef: 'treble',
|
| 126 |
+
measures: createMeasures(notes, measureDuration, tempo),
|
| 127 |
+
},
|
| 128 |
+
];
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* Create measures from notes
|
| 134 |
+
*/
|
| 135 |
+
function createMeasures(notes: MidiNote[], measureDuration: number, tempo: number = 120): Measure[] {
|
| 136 |
+
if (notes.length === 0) {
|
| 137 |
+
return [
|
| 138 |
+
{
|
| 139 |
+
id: 'measure-1',
|
| 140 |
+
number: 1,
|
| 141 |
+
notes: [],
|
| 142 |
+
},
|
| 143 |
+
];
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// Calculate total duration and number of measures
|
| 147 |
+
const maxTime = Math.max(...notes.map((n) => n.time + n.duration));
|
| 148 |
+
const numMeasures = Math.ceil(maxTime / measureDuration);
|
| 149 |
+
|
| 150 |
+
const measures: Measure[] = [];
|
| 151 |
+
|
| 152 |
+
for (let i = 0; i < numMeasures; i++) {
|
| 153 |
+
const measureStart = i * measureDuration;
|
| 154 |
+
const measureEnd = (i + 1) * measureDuration;
|
| 155 |
+
|
| 156 |
+
// Find notes that start in this measure
|
| 157 |
+
const measureNotes = notes
|
| 158 |
+
.filter((n) => n.time >= measureStart && n.time < measureEnd)
|
| 159 |
+
.map((midiNote, idx) => convertMidiNoteToNote(midiNote, `m${i + 1}-n${idx}`, measureStart, tempo));
|
| 160 |
+
|
| 161 |
+
measures.push({
|
| 162 |
+
id: `measure-${i + 1}`,
|
| 163 |
+
number: i + 1,
|
| 164 |
+
notes: measureNotes,
|
| 165 |
+
});
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
return measures;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
/**
|
| 172 |
+
* Convert MIDI note to internal Note format
|
| 173 |
+
*/
|
| 174 |
+
function convertMidiNoteToNote(midiNote: MidiNote, id: string, measureStart: number, tempo: number): Note {
|
| 175 |
+
const { pitch, octave, accidental } = midiNumberToPitch(midiNote.midi);
|
| 176 |
+
const { duration, dotted } = durationToNoteName(midiNote.duration, tempo);
|
| 177 |
+
|
| 178 |
+
return {
|
| 179 |
+
id,
|
| 180 |
+
pitch: `${pitch}${octave}`,
|
| 181 |
+
duration,
|
| 182 |
+
octave,
|
| 183 |
+
startTime: midiNote.time - measureStart, // Relative to measure start
|
| 184 |
+
dotted,
|
| 185 |
+
accidental,
|
| 186 |
+
isRest: false,
|
| 187 |
+
// chordId will be assigned by grouping simultaneous notes
|
| 188 |
+
};
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/**
|
| 192 |
+
* Convert MIDI note number to pitch name, octave, and accidental
|
| 193 |
+
*/
|
| 194 |
+
function midiNumberToPitch(midiNumber: number): {
|
| 195 |
+
pitch: string;
|
| 196 |
+
octave: number;
|
| 197 |
+
accidental?: 'sharp' | 'flat' | 'natural';
|
| 198 |
+
} {
|
| 199 |
+
const pitchClasses = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
| 200 |
+
const pitchClass = midiNumber % 12;
|
| 201 |
+
const octave = Math.floor(midiNumber / 12) - 1;
|
| 202 |
+
const pitchName = pitchClasses[pitchClass];
|
| 203 |
+
|
| 204 |
+
let accidental: 'sharp' | 'flat' | 'natural' | undefined;
|
| 205 |
+
if (pitchName.includes('#')) {
|
| 206 |
+
accidental = 'sharp';
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
return {
|
| 210 |
+
pitch: pitchName.replace('#', ''),
|
| 211 |
+
octave,
|
| 212 |
+
accidental,
|
| 213 |
+
};
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/**
|
| 217 |
+
* Convert duration (in seconds) to note name (whole, half, quarter, etc.)
|
| 218 |
+
*/
|
| 219 |
+
function durationToNoteName(duration: number, tempo: number): { duration: string; dotted: boolean } {
|
| 220 |
+
// Calculate quarter note duration based on actual tempo
|
| 221 |
+
// At tempo BPM: 1 quarter note = 60/BPM seconds
|
| 222 |
+
const quarterNoteDuration = 60 / tempo;
|
| 223 |
+
|
| 224 |
+
const durationInQuarters = duration / quarterNoteDuration;
|
| 225 |
+
|
| 226 |
+
// Find closest standard duration (including dotted notes)
|
| 227 |
+
const durations: [number, string, boolean][] = [
|
| 228 |
+
[4, 'whole', false],
|
| 229 |
+
[3, 'half', true], // dotted half
|
| 230 |
+
[2, 'half', false],
|
| 231 |
+
[1.5, 'quarter', true], // dotted quarter
|
| 232 |
+
[1, 'quarter', false],
|
| 233 |
+
[0.75, 'eighth', true], // dotted eighth
|
| 234 |
+
[0.5, 'eighth', false],
|
| 235 |
+
[0.375, '16th', true], // dotted 16th
|
| 236 |
+
[0.25, '16th', false],
|
| 237 |
+
[0.125, '32nd', false],
|
| 238 |
+
];
|
| 239 |
+
|
| 240 |
+
let closestDuration = durations[0];
|
| 241 |
+
let minDiff = Math.abs(durationInQuarters - durations[0][0]);
|
| 242 |
+
|
| 243 |
+
for (const [value, name, dotted] of durations) {
|
| 244 |
+
const diff = Math.abs(durationInQuarters - value);
|
| 245 |
+
if (diff < minDiff) {
|
| 246 |
+
minDiff = diff;
|
| 247 |
+
closestDuration = [value, name, dotted];
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
return {
|
| 252 |
+
duration: closestDuration[1],
|
| 253 |
+
dotted: closestDuration[2],
|
| 254 |
+
};
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
/**
|
| 258 |
+
* Group simultaneous notes into chords
|
| 259 |
+
*/
|
| 260 |
+
export function assignChordIds(score: Score): Score {
|
| 261 |
+
const CHORD_TOLERANCE = 0.05; // Notes within 50ms are considered simultaneous
|
| 262 |
+
|
| 263 |
+
for (const part of score.parts) {
|
| 264 |
+
for (const measure of part.measures) {
|
| 265 |
+
const notes = measure.notes;
|
| 266 |
+
|
| 267 |
+
// Group notes by start time
|
| 268 |
+
const groups: Record<string, Note[]> = {};
|
| 269 |
+
|
| 270 |
+
for (const note of notes) {
|
| 271 |
+
const timeKey = Math.round(note.startTime / CHORD_TOLERANCE).toString();
|
| 272 |
+
if (!groups[timeKey]) {
|
| 273 |
+
groups[timeKey] = [];
|
| 274 |
+
}
|
| 275 |
+
groups[timeKey].push(note);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
// Assign chordId to groups with multiple notes
|
| 279 |
+
for (const [timeKey, groupNotes] of Object.entries(groups)) {
|
| 280 |
+
if (groupNotes.length > 1) {
|
| 281 |
+
const chordId = `chord-${measure.id}-${timeKey}`;
|
| 282 |
+
groupNotes.forEach((note) => {
|
| 283 |
+
note.chordId = chordId;
|
| 284 |
+
});
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
return score;
|
| 291 |
+
}
|
frontend/src/utils/musicxml-parser.ts
CHANGED
|
@@ -72,14 +72,21 @@ export function parseMusicXML(xml: string): Score {
|
|
| 72 |
|
| 73 |
const noteElements = measureEl.querySelectorAll('note');
|
| 74 |
let currentChord: Note[] = [];
|
|
|
|
| 75 |
|
| 76 |
-
noteElements.forEach((noteEl) => {
|
| 77 |
const parsedNote = parseNoteElement(noteEl);
|
| 78 |
if (!parsedNote) return;
|
| 79 |
|
| 80 |
// Check if this note is part of a chord (simultaneous with previous note)
|
| 81 |
const isChordMember = noteEl.querySelector('chord') !== null;
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
if (parsedNote.isRest) {
|
| 84 |
// Flush any pending chord before adding rest
|
| 85 |
if (currentChord.length > 0) {
|
|
@@ -87,7 +94,7 @@ export function parseMusicXML(xml: string): Score {
|
|
| 87 |
currentChord = [];
|
| 88 |
}
|
| 89 |
|
| 90 |
-
// Include rests
|
| 91 |
notes.push({
|
| 92 |
id: `note-${measureNumber}-${notes.length}`,
|
| 93 |
pitch: '',
|
|
@@ -96,6 +103,7 @@ export function parseMusicXML(xml: string): Score {
|
|
| 96 |
startTime: 0,
|
| 97 |
dotted: parsedNote.dotted,
|
| 98 |
isRest: true,
|
|
|
|
| 99 |
});
|
| 100 |
} else {
|
| 101 |
// Build full pitch string for pitched notes
|
|
@@ -113,6 +121,7 @@ export function parseMusicXML(xml: string): Score {
|
|
| 113 |
dotted: parsedNote.dotted,
|
| 114 |
accidental: parsedNote.accidental as 'sharp' | 'flat' | 'natural' | undefined,
|
| 115 |
isRest: false,
|
|
|
|
| 116 |
};
|
| 117 |
|
| 118 |
if (isChordMember) {
|
|
@@ -205,12 +214,16 @@ function parseNoteElement(noteEl: Element): ParsedNote | null {
|
|
| 205 |
|
| 206 |
const step = pitchEl.querySelector('step')?.textContent;
|
| 207 |
const octave = pitchEl.querySelector('octave')?.textContent;
|
| 208 |
-
const alter = pitchEl.querySelector('alter')?.textContent;
|
|
|
|
| 209 |
const dotEl = noteEl.querySelector('dot');
|
| 210 |
|
| 211 |
if (!step || !octave) return null;
|
| 212 |
|
|
|
|
| 213 |
let accidental: string | undefined;
|
|
|
|
|
|
|
| 214 |
if (alter) {
|
| 215 |
const alterValue = parseInt(alter);
|
| 216 |
if (alterValue === 1) accidental = 'sharp';
|
|
@@ -218,6 +231,14 @@ function parseNoteElement(noteEl: Element): ParsedNote | null {
|
|
| 218 |
else if (alterValue === 0) accidental = 'natural';
|
| 219 |
}
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
return {
|
| 222 |
pitch: step,
|
| 223 |
octave: parseInt(octave),
|
|
|
|
| 72 |
|
| 73 |
const noteElements = measureEl.querySelectorAll('note');
|
| 74 |
let currentChord: Note[] = [];
|
| 75 |
+
let currentChordId: string | null = null;
|
| 76 |
|
| 77 |
+
noteElements.forEach((noteEl, noteIdx) => {
|
| 78 |
const parsedNote = parseNoteElement(noteEl);
|
| 79 |
if (!parsedNote) return;
|
| 80 |
|
| 81 |
// Check if this note is part of a chord (simultaneous with previous note)
|
| 82 |
const isChordMember = noteEl.querySelector('chord') !== null;
|
| 83 |
|
| 84 |
+
// Assign chord ID for chord grouping
|
| 85 |
+
if (!isChordMember) {
|
| 86 |
+
// Start new chord group (or single note)
|
| 87 |
+
currentChordId = `chord-${measureNumber}-${noteIdx}`;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
if (parsedNote.isRest) {
|
| 91 |
// Flush any pending chord before adding rest
|
| 92 |
if (currentChord.length > 0) {
|
|
|
|
| 94 |
currentChord = [];
|
| 95 |
}
|
| 96 |
|
| 97 |
+
// Include rests (rests don't have chordId)
|
| 98 |
notes.push({
|
| 99 |
id: `note-${measureNumber}-${notes.length}`,
|
| 100 |
pitch: '',
|
|
|
|
| 103 |
startTime: 0,
|
| 104 |
dotted: parsedNote.dotted,
|
| 105 |
isRest: true,
|
| 106 |
+
chordId: undefined, // Rests are never part of chords
|
| 107 |
});
|
| 108 |
} else {
|
| 109 |
// Build full pitch string for pitched notes
|
|
|
|
| 121 |
dotted: parsedNote.dotted,
|
| 122 |
accidental: parsedNote.accidental as 'sharp' | 'flat' | 'natural' | undefined,
|
| 123 |
isRest: false,
|
| 124 |
+
chordId: currentChordId || undefined, // Assign chord ID for grouping
|
| 125 |
};
|
| 126 |
|
| 127 |
if (isChordMember) {
|
|
|
|
| 214 |
|
| 215 |
const step = pitchEl.querySelector('step')?.textContent;
|
| 216 |
const octave = pitchEl.querySelector('octave')?.textContent;
|
| 217 |
+
const alter = pitchEl.querySelector('alter')?.textContent; // Semantic pitch alteration
|
| 218 |
+
const accidentalEl = noteEl.querySelector('accidental'); // Visual accidental display
|
| 219 |
const dotEl = noteEl.querySelector('dot');
|
| 220 |
|
| 221 |
if (!step || !octave) return null;
|
| 222 |
|
| 223 |
+
// Parse accidental from both <alter> (semantic) and <accidental> (visual) tags
|
| 224 |
let accidental: string | undefined;
|
| 225 |
+
|
| 226 |
+
// Priority 1: Use <alter> for pitch accuracy (indicates actual pitch)
|
| 227 |
if (alter) {
|
| 228 |
const alterValue = parseInt(alter);
|
| 229 |
if (alterValue === 1) accidental = 'sharp';
|
|
|
|
| 231 |
else if (alterValue === 0) accidental = 'natural';
|
| 232 |
}
|
| 233 |
|
| 234 |
+
// Priority 2: If no <alter>, check <accidental> tag (visual notation)
|
| 235 |
+
if (!accidental && accidentalEl) {
|
| 236 |
+
const accType = accidentalEl.textContent;
|
| 237 |
+
if (accType === 'sharp') accidental = 'sharp';
|
| 238 |
+
else if (accType === 'flat') accidental = 'flat';
|
| 239 |
+
else if (accType === 'natural') accidental = 'natural';
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
return {
|
| 243 |
pitch: step,
|
| 244 |
octave: parseInt(octave),
|
frontend/src/utils/validate-musicxml.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* MusicXML Parsing Validation Utility
|
| 3 |
+
*
|
| 4 |
+
* Purpose: Verify that the parser correctly extracts all MusicXML data
|
| 5 |
+
* by comparing raw XML elements with parsed Score object.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { parseMusicXML, Score } from './musicxml-parser';
|
| 9 |
+
|
| 10 |
+
export interface ValidationReport {
|
| 11 |
+
totalXMLNotes: number;
|
| 12 |
+
totalParsedNotes: number;
|
| 13 |
+
totalXMLMeasures: number;
|
| 14 |
+
totalParsedMeasures: number;
|
| 15 |
+
missingNotes: Array<{
|
| 16 |
+
measureNumber: number;
|
| 17 |
+
pitch?: string;
|
| 18 |
+
octave?: number;
|
| 19 |
+
duration?: string;
|
| 20 |
+
isRest: boolean;
|
| 21 |
+
}>;
|
| 22 |
+
parsingErrors: string[];
|
| 23 |
+
warnings: string[];
|
| 24 |
+
success: boolean;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
interface XMLNoteData {
|
| 28 |
+
measureNumber: number;
|
| 29 |
+
pitch?: string;
|
| 30 |
+
octave?: number;
|
| 31 |
+
alter?: number;
|
| 32 |
+
duration?: string;
|
| 33 |
+
isRest: boolean;
|
| 34 |
+
isChordMember: boolean;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Extract raw note data from MusicXML string
|
| 39 |
+
*/
|
| 40 |
+
function extractRawXMLNotes(xml: string): XMLNoteData[] {
|
| 41 |
+
const parser = new DOMParser();
|
| 42 |
+
const doc = parser.parseFromString(xml, 'text/xml');
|
| 43 |
+
|
| 44 |
+
const notes: XMLNoteData[] = [];
|
| 45 |
+
const measures = doc.querySelectorAll('measure');
|
| 46 |
+
|
| 47 |
+
measures.forEach((measureEl) => {
|
| 48 |
+
const measureNumber = parseInt(measureEl.getAttribute('number') || '0');
|
| 49 |
+
const noteElements = measureEl.querySelectorAll('note');
|
| 50 |
+
|
| 51 |
+
noteElements.forEach((noteEl) => {
|
| 52 |
+
const pitchEl = noteEl.querySelector('pitch');
|
| 53 |
+
const rest = noteEl.querySelector('rest');
|
| 54 |
+
const isChordMember = noteEl.querySelector('chord') !== null;
|
| 55 |
+
|
| 56 |
+
if (rest) {
|
| 57 |
+
// Rest
|
| 58 |
+
const durationEl = noteEl.querySelector('duration');
|
| 59 |
+
const typeEl = noteEl.querySelector('type');
|
| 60 |
+
|
| 61 |
+
notes.push({
|
| 62 |
+
measureNumber,
|
| 63 |
+
duration: typeEl?.textContent || undefined,
|
| 64 |
+
isRest: true,
|
| 65 |
+
isChordMember,
|
| 66 |
+
});
|
| 67 |
+
} else if (pitchEl) {
|
| 68 |
+
// Pitched note
|
| 69 |
+
const step = pitchEl.querySelector('step')?.textContent;
|
| 70 |
+
const octave = pitchEl.querySelector('octave')?.textContent;
|
| 71 |
+
const alter = pitchEl.querySelector('alter')?.textContent;
|
| 72 |
+
const typeEl = noteEl.querySelector('type');
|
| 73 |
+
|
| 74 |
+
notes.push({
|
| 75 |
+
measureNumber,
|
| 76 |
+
pitch: step || undefined,
|
| 77 |
+
octave: octave ? parseInt(octave) : undefined,
|
| 78 |
+
alter: alter ? parseInt(alter) : undefined,
|
| 79 |
+
duration: typeEl?.textContent || undefined,
|
| 80 |
+
isRest: false,
|
| 81 |
+
isChordMember,
|
| 82 |
+
});
|
| 83 |
+
}
|
| 84 |
+
});
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
return notes;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* Extract parsed note data from Score object
|
| 92 |
+
*/
|
| 93 |
+
function extractParsedNotes(score: Score): XMLNoteData[] {
|
| 94 |
+
const notes: XMLNoteData[] = [];
|
| 95 |
+
|
| 96 |
+
score.parts.forEach((part) => {
|
| 97 |
+
part.measures.forEach((measure) => {
|
| 98 |
+
measure.notes.forEach((note) => {
|
| 99 |
+
if (note.isRest) {
|
| 100 |
+
notes.push({
|
| 101 |
+
measureNumber: measure.number,
|
| 102 |
+
duration: note.duration,
|
| 103 |
+
isRest: true,
|
| 104 |
+
isChordMember: false, // Parsed notes don't preserve chord info yet
|
| 105 |
+
});
|
| 106 |
+
} else {
|
| 107 |
+
// Extract pitch from full pitch string (e.g., "C4" → "C", octave 4)
|
| 108 |
+
const pitchMatch = note.pitch.match(/([A-G][#b]?)(\d)/);
|
| 109 |
+
if (pitchMatch) {
|
| 110 |
+
const [, pitchName, octaveStr] = pitchMatch;
|
| 111 |
+
const step = pitchName[0]; // Just letter
|
| 112 |
+
const alter = pitchName.includes('#') ? 1 : pitchName.includes('b') ? -1 : 0;
|
| 113 |
+
|
| 114 |
+
notes.push({
|
| 115 |
+
measureNumber: measure.number,
|
| 116 |
+
pitch: step,
|
| 117 |
+
octave: parseInt(octaveStr),
|
| 118 |
+
alter: alter !== 0 ? alter : undefined,
|
| 119 |
+
duration: note.duration,
|
| 120 |
+
isRest: false,
|
| 121 |
+
isChordMember: false,
|
| 122 |
+
});
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
});
|
| 126 |
+
});
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
return notes;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* Compare two note data objects for equality
|
| 134 |
+
*/
|
| 135 |
+
function notesEqual(a: XMLNoteData, b: XMLNoteData): boolean {
|
| 136 |
+
if (a.isRest !== b.isRest) return false;
|
| 137 |
+
if (a.measureNumber !== b.measureNumber) return false;
|
| 138 |
+
|
| 139 |
+
if (a.isRest) {
|
| 140 |
+
// For rests, just check duration
|
| 141 |
+
return a.duration === b.duration;
|
| 142 |
+
} else {
|
| 143 |
+
// For pitched notes, check pitch, octave, and duration
|
| 144 |
+
return (
|
| 145 |
+
a.pitch === b.pitch &&
|
| 146 |
+
a.octave === b.octave &&
|
| 147 |
+
a.duration === b.duration
|
| 148 |
+
// Note: We don't check alter here because parser may handle it differently
|
| 149 |
+
);
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/**
|
| 154 |
+
* Validate MusicXML parsing
|
| 155 |
+
*/
|
| 156 |
+
export function validateMusicXMLParsing(xml: string): ValidationReport {
|
| 157 |
+
const report: ValidationReport = {
|
| 158 |
+
totalXMLNotes: 0,
|
| 159 |
+
totalParsedNotes: 0,
|
| 160 |
+
totalXMLMeasures: 0,
|
| 161 |
+
totalParsedMeasures: 0,
|
| 162 |
+
missingNotes: [],
|
| 163 |
+
parsingErrors: [],
|
| 164 |
+
warnings: [],
|
| 165 |
+
success: true,
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
try {
|
| 169 |
+
// Extract raw XML data
|
| 170 |
+
const rawNotes = extractRawXMLNotes(xml);
|
| 171 |
+
report.totalXMLNotes = rawNotes.length;
|
| 172 |
+
|
| 173 |
+
// Parse with our parser
|
| 174 |
+
const score = parseMusicXML(xml);
|
| 175 |
+
const parsedNotes = extractParsedNotes(score);
|
| 176 |
+
report.totalParsedNotes = parsedNotes.length;
|
| 177 |
+
|
| 178 |
+
// Count measures
|
| 179 |
+
const parser = new DOMParser();
|
| 180 |
+
const doc = parser.parseFromString(xml, 'text/xml');
|
| 181 |
+
report.totalXMLMeasures = doc.querySelectorAll('measure').length;
|
| 182 |
+
report.totalParsedMeasures = score.parts.reduce(
|
| 183 |
+
(sum, part) => sum + part.measures.length,
|
| 184 |
+
0
|
| 185 |
+
);
|
| 186 |
+
|
| 187 |
+
// Check note count
|
| 188 |
+
if (rawNotes.length !== parsedNotes.length) {
|
| 189 |
+
report.warnings.push(
|
| 190 |
+
`Note count mismatch: XML has ${rawNotes.length} notes, parsed ${parsedNotes.length}`
|
| 191 |
+
);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// Find missing notes
|
| 195 |
+
const parsedMatched = new Array(parsedNotes.length).fill(false);
|
| 196 |
+
|
| 197 |
+
for (const rawNote of rawNotes) {
|
| 198 |
+
// Find matching parsed note
|
| 199 |
+
const matchIdx = parsedNotes.findIndex(
|
| 200 |
+
(parsed, idx) => !parsedMatched[idx] && notesEqual(rawNote, parsed)
|
| 201 |
+
);
|
| 202 |
+
|
| 203 |
+
if (matchIdx === -1) {
|
| 204 |
+
// No match found
|
| 205 |
+
report.missingNotes.push(rawNote);
|
| 206 |
+
} else {
|
| 207 |
+
// Mark as matched
|
| 208 |
+
parsedMatched[matchIdx] = true;
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// Check for critical errors
|
| 213 |
+
if (report.totalXMLNotes === 0 && report.totalParsedNotes === 0) {
|
| 214 |
+
report.warnings.push('No notes found in MusicXML');
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
if (report.totalParsedNotes === 0 && report.totalXMLNotes > 0) {
|
| 218 |
+
report.parsingErrors.push('Parser failed to extract any notes from MusicXML');
|
| 219 |
+
report.success = false;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
if (report.missingNotes.length > report.totalXMLNotes * 0.1) {
|
| 223 |
+
// More than 10% notes missing
|
| 224 |
+
report.parsingErrors.push(
|
| 225 |
+
`Significant note loss: ${report.missingNotes.length} / ${report.totalXMLNotes} notes missing (${((report.missingNotes.length / report.totalXMLNotes) * 100).toFixed(1)}%)`
|
| 226 |
+
);
|
| 227 |
+
report.success = false;
|
| 228 |
+
}
|
| 229 |
+
} catch (error) {
|
| 230 |
+
report.parsingErrors.push(`Exception during parsing: ${error}`);
|
| 231 |
+
report.success = false;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
return report;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/**
|
| 238 |
+
* Print validation report to console
|
| 239 |
+
*/
|
| 240 |
+
export function printValidationReport(report: ValidationReport): void {
|
| 241 |
+
console.log('\n' + '='.repeat(80));
|
| 242 |
+
console.log('MUSICXML PARSING VALIDATION REPORT');
|
| 243 |
+
console.log('='.repeat(80));
|
| 244 |
+
|
| 245 |
+
console.log('\n📊 SUMMARY:');
|
| 246 |
+
console.log(` Total XML notes: ${report.totalXMLNotes}`);
|
| 247 |
+
console.log(` Total parsed notes: ${report.totalParsedNotes}`);
|
| 248 |
+
console.log(` Total XML measures: ${report.totalXMLMeasures}`);
|
| 249 |
+
console.log(` Total parsed measures: ${report.totalParsedMeasures}`);
|
| 250 |
+
console.log(` Missing notes: ${report.missingNotes.length}`);
|
| 251 |
+
|
| 252 |
+
if (report.totalXMLNotes > 0) {
|
| 253 |
+
const accuracy =
|
| 254 |
+
((report.totalXMLNotes - report.missingNotes.length) / report.totalXMLNotes) * 100;
|
| 255 |
+
console.log(`\n ${report.success ? '✅' : '❌'} Parsing accuracy: ${accuracy.toFixed(1)}%`);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
if (report.parsingErrors.length > 0) {
|
| 259 |
+
console.log('\n❌ PARSING ERRORS:');
|
| 260 |
+
report.parsingErrors.forEach((error, i) => {
|
| 261 |
+
console.log(` ${i + 1}. ${error}`);
|
| 262 |
+
});
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
if (report.warnings.length > 0) {
|
| 266 |
+
console.log('\n⚠️ WARNINGS:');
|
| 267 |
+
report.warnings.forEach((warning, i) => {
|
| 268 |
+
console.log(` ${i + 1}. ${warning}`);
|
| 269 |
+
});
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
if (report.missingNotes.length > 0) {
|
| 273 |
+
console.log(`\n❌ MISSING NOTES (first 10):`);
|
| 274 |
+
report.missingNotes.slice(0, 10).forEach((note, i) => {
|
| 275 |
+
if (note.isRest) {
|
| 276 |
+
console.log(
|
| 277 |
+
` ${i + 1}. Rest in measure ${note.measureNumber}, duration ${note.duration}`
|
| 278 |
+
);
|
| 279 |
+
} else {
|
| 280 |
+
const accidental = note.alter === 1 ? '#' : note.alter === -1 ? 'b' : '';
|
| 281 |
+
console.log(
|
| 282 |
+
` ${i + 1}. ${note.pitch}${accidental}${note.octave} in measure ${note.measureNumber}, duration ${note.duration}`
|
| 283 |
+
);
|
| 284 |
+
}
|
| 285 |
+
});
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
console.log('\n' + '='.repeat(80));
|
| 289 |
+
}
|