calebhan commited on
Commit
6293e69
·
1 Parent(s): 75d3906

midi player on frontend

Browse files
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 or failed
405
- if update.get('type') in ['completed', 'error']:
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
- score = score.makeMeasures()
 
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 due to complex durations, try with makeNotation=True
1327
- # This lets music21 handle the complex durations automatically
1328
- print(f" WARNING: Export failed with makeNotation=False: {e}")
1329
- print(f" Retrying with makeNotation=True (auto-notation)...")
 
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 _remove_impossible_durations(self, score) -> stream.Score:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1725
  """
1726
- Remove notes/rests with durations too short for MusicXML export (<128th note).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 32nd note to prevent impossible tuplets
1740
- # basic-pitch can create very short notes that cause music21 to generate
1741
- # complex tuplets with impossible durations (2048th notes)
1742
- # 32nd note is a reasonable minimum for piano music
1743
- MIN_DURATION = 0.125 # 32nd note (1.0 / 8)
1744
 
1745
- removed_count = 0
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 removed_count > 0:
1761
- print(f" Removed {removed_count} notes/rests shorter than 32nd note to prevent tuplet errors")
 
 
 
 
 
 
 
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 cleaned MIDI file to outputs
103
- temp_midi_path = pipeline.temp_dir / "piano_clean.mid"
 
 
 
 
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
- "retryable": self.request.retries < self.max_retries,
 
 
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
- "retryable": self.request.retries < self.max_retries,
 
148
  },
149
  "timestamp": datetime.utcnow().isoformat(),
150
  }
151
  redis_client.publish(f"job:{job_id}:updates", json.dumps(error_msg))
152
 
153
- # Retry if retryable
154
- if self.request.retries < self.max_retries:
 
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": "^14.1.2",
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": "9.3.4",
1600
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
1601
- "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==",
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.1.3",
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": ">=14"
1616
  }
1617
  },
1618
  "node_modules/@testing-library/dom/node_modules/aria-query": {
1619
- "version": "5.1.3",
1620
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
1621
- "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
1622
  "dev": true,
1623
  "license": "Apache-2.0",
1624
  "dependencies": {
1625
- "deep-equal": "^2.0.5"
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": "14.3.1",
1657
- "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz",
1658
- "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==",
1659
  "dev": true,
1660
  "license": "MIT",
1661
  "dependencies": {
1662
  "@babel/runtime": "^7.12.5",
1663
- "@testing-library/dom": "^9.0.0",
1664
  "@types/react-dom": "^18.0.0"
1665
  },
1666
  "engines": {
1667
- "node": ">=14"
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-buffer-byte-length": {
2423
- "version": "1.0.2",
2424
- "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
2425
- "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
2426
- "dev": true,
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/internal-slot": {
3962
- "version": "1.1.0",
3963
- "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
3964
- "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
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": "^12.20.0 || ^14.13.1 || >=16.0.0"
4200
- },
4201
- "funding": {
4202
- "url": "https://github.com/sponsors/sindresorhus"
4203
  }
4204
  },
4205
- "node_modules/is-string": {
4206
- "version": "1.1.1",
4207
- "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
4208
- "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
4209
  "dev": true,
4210
  "license": "MIT",
4211
  "dependencies": {
4212
- "call-bound": "^1.0.3",
4213
- "has-tostringtag": "^1.0.2"
4214
  },
4215
  "engines": {
4216
- "node": ">= 0.4"
4217
- },
4218
- "funding": {
4219
- "url": "https://github.com/sponsors/ljharb"
4220
  }
4221
  },
4222
- "node_modules/is-symbol": {
4223
- "version": "1.1.1",
4224
- "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
4225
- "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
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": ">= 0.4"
4235
- },
4236
- "funding": {
4237
- "url": "https://github.com/sponsors/ljharb"
4238
  }
4239
  },
4240
- "node_modules/is-weakmap": {
4241
- "version": "2.0.2",
4242
- "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
4243
- "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
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-weakset": {
4254
- "version": "2.0.4",
4255
- "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
4256
- "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
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": ">= 0.4"
4265
  },
4266
  "funding": {
4267
- "url": "https://github.com/sponsors/ljharb"
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({ musicXML, showControls, interactive, onNoteSelect, selectedNotes, showMeasureNumbers, width, height }: NotationCanvasProps) {
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
- }, [musicXML, score]);
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
- if (score.key && score.key !== 'C') {
164
- trebleStave.addKeySignature(score.key);
 
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
- if (score.key && score.key !== 'C') {
175
- bassStave.addKeySignature(score.key);
 
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
- if (score.key && score.key !== 'C') {
226
- stave.addKeySignature(score.key);
 
227
  }
228
  }
229
 
@@ -245,58 +250,54 @@ function renderMeasureNotes(
245
  timeSignature: string
246
  ): void {
247
  try {
248
- // Group notes by startTime to identify chords (notes with same duration/timing)
249
- // Since our parser now keeps chord notes sequential, we need to identify them
250
- // by checking if consecutive notes have the same duration and are pitched
 
 
 
 
 
 
 
251
  const vexNotes: StaveNote[] = [];
252
- let i = 0;
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 (chordNotes.length === 1) {
287
- vexNotes.push(convertToVexNote(chordNotes[0]));
 
 
 
 
288
  } else {
289
- vexNotes.push(convertToVexChord(chordNotes));
 
290
  }
291
  } catch (err) {
292
  // Skip invalid note/chord
 
293
  }
294
-
295
- i = j;
296
  }
297
 
 
298
  if (vexNotes.length === 0) {
299
- return; // No valid notes to render
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 { api } from '../api/client';
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 loadFromMusicXML = useNotationStore((state) => state.loadFromMusicXML);
 
19
 
20
  useEffect(() => {
21
  loadScore();
@@ -23,36 +23,66 @@ export function ScoreEditor({ jobId }: ScoreEditorProps) {
23
 
24
  const loadScore = async () => {
25
  try {
26
- const xml = await api.getScore(jobId);
27
- setMusicXML(xml);
28
- loadFromMusicXML(xml);
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  setLoading(false);
30
  } catch (err) {
31
  console.error('Failed to load score:', err);
 
32
  setLoading(false);
33
  }
34
  };
35
 
36
  const handleExportMusicXML = () => {
37
- const blob = new Blob([musicXML], {
38
- type: 'application/vnd.recordare.musicxml+xml',
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
- alert('MIDI export not yet implemented');
 
 
 
 
 
 
 
 
 
 
 
 
 
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 musicXML={musicXML} />
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
+ }