diff --git a/.gitignore b/.gitignore
index 860f657..26860cc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -85,7 +85,7 @@ ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
-# .python-version
+.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
@@ -194,3 +194,8 @@ score_audio_Bach-fugue_bwv_858.wav
score_audio_mozart_k265_var1.wav
performance_audio*/
score_audio*/
+notebook/
+
+.claude/
+src/
+scripts/
diff --git a/matchmaker/matchmaker.py b/matchmaker/matchmaker.py
index ecc0aef..a9ef050 100644
--- a/matchmaker/matchmaker.py
+++ b/matchmaker/matchmaker.py
@@ -1,6 +1,4 @@
-import csv
import os
-from pathlib import Path
from typing import Optional, Union
import numpy as np
@@ -22,7 +20,6 @@
from matchmaker.io.audio import AudioStream
from matchmaker.io.midi import MidiStream
from matchmaker.prob.hmm import (
- CosineExpGaussianAudioPitchTempoObservationModel,
GaussianAudioPitchHMM,
GaussianAudioPitchTempoHMM,
PitchIOIHMM,
@@ -37,6 +34,7 @@
from matchmaker.utils.misc import (
adjust_tempo_for_performance_audio,
generate_score_audio,
+ get_tempo_from_score,
is_audio_file,
is_midi_file,
save_debug_results,
@@ -79,6 +77,12 @@ class Matchmaker(object):
device_name_or_index : Union[str, int]
Name or index of the audio device to be used.
Ignored if `file_path` is given.
+ tempo : float, optional
+ Tempo in BPM. If None, reads from score; if score has no tempo marking,
+ defaults to 120 BPM.
+ adjust_tempo : bool (default: False)
+ If True and performance_file is provided, adjusts tempo based on
+ performance audio analysis. Applies to all methods.
"""
@@ -94,6 +98,8 @@ def __init__(
device_name_or_index: Union[str, int] = None,
sample_rate: int = SAMPLE_RATE,
frame_rate: int = FRAME_RATE,
+ tempo: Optional[float] = None,
+ adjust_tempo: bool = False,
):
self.score_file = str(score_file)
self.performance_file = (
@@ -109,9 +115,9 @@ def __init__(
self.stream = None
self.score_follower = None
self.reference_features = None
- self.tempo = DEFAULT_TEMPO # bpm for quarter note
self._has_run = False
self.method = method
+ self.adjust_tempo = adjust_tempo
# setup score file
if score_file is None:
@@ -123,6 +129,16 @@ def __init__(
except Exception as e:
raise ValueError(f"Invalid score file: {e}")
+ # Set tempo: user-provided > score marking > default (120 BPM)
+ # _user_specified_tempo: if True, use uniform tempo; if False, use score tempo map
+ if tempo is not None:
+ self.tempo = float(tempo)
+ self._user_specified_tempo = True
+ else:
+ self._user_specified_tempo = False
+ score_tempo = get_tempo_from_score(self.score_part, self.score_file)
+ self.tempo = score_tempo if score_tempo is not None else DEFAULT_TEMPO
+
# setup feature processor
if self.feature_type is None:
self.feature_type = "chroma" if input_type == "audio" else "pitchclass"
@@ -238,8 +254,8 @@ def __init__(
def preprocess_score(self):
if self.input_type == "audio":
- if self.performance_file is not None:
- # tempo is slightly adjusted to reflect the tempo of the performance audio
+ # Adjust tempo based on performance audio if requested
+ if self.adjust_tempo and self.performance_file is not None:
self.tempo = adjust_tempo_for_performance_audio(
self.score_part, self.performance_file, self.tempo
)
diff --git a/matchmaker/utils/misc.py b/matchmaker/utils/misc.py
index 2265ead..99e1961 100644
--- a/matchmaker/utils/misc.py
+++ b/matchmaker/utils/misc.py
@@ -7,24 +7,147 @@
import csv
import numbers
import os
+import re
+import xml.etree.ElementTree as ET
from pathlib import Path
from queue import Empty, Queue
-from typing import Any, Dict, Iterable, List, Union
+from typing import Any, Dict, Iterable, List, Optional, Union
import librosa
-import mido
import numpy as np
import partitura
import scipy
import soundfile as sf
from matplotlib import pyplot as plt
from numpy.typing import NDArray
-from partitura.io.exportmidi import get_ppq
from partitura.score import ScoreLike
-from partitura.utils.music import performance_notearray_from_score_notearray
from matchmaker.features.audio import SAMPLE_RATE
+# Tempo marking to BPM mapping
+# Reference: https://en.wikipedia.org/wiki/Tempo#Basic_tempo_markings
+TEMPO_MARKING_TO_BPM = {
+ # Very slow (24-40 BPM)
+ "larghissimo": 24,
+ "grave": 30,
+ # Slow (40-66 BPM)
+ "largo": 40,
+ "larghetto": 50,
+ "lento": 60,
+ # Slow-moderate (44-80 BPM)
+ "adagio": 60,
+ "adagietto": 70,
+ # Walking pace (56-108 BPM)
+ "andante": 80,
+ "andantino": 90,
+ # Moderate (86-126 BPM)
+ "moderato": 110,
+ "allegretto": 120,
+ # Fast (100-156 BPM)
+ "allegro": 130,
+ # Very fast (136-200+ BPM)
+ "vivace": 150,
+ "vivacissimo": 170,
+ "presto": 180,
+ "prestissimo": 200,
+}
+
+
+def extract_tempo_marking_from_musicxml(
+ score_file: Union[str, Path],
+) -> Optional[float]:
+ """
+ Extract tempo from text tempo marking (e.g., "Allegro", "Andante") in MusicXML.
+
+ Parses ... elements and
+ matches against known tempo markings. The tempo is adjusted based on the
+ time signature to return quarter-note BPM.
+
+ Handles both simple and compound meters:
+ - Simple meters (2/2, 4/4, etc.): beat is the note indicated by denominator
+ - Compound meters (6/8, 9/8, 12/8): beat is a dotted note (3 subdivisions)
+
+ For example:
+ - 2/2 "Allegro" (130 half notes/min) → 260 quarter-note BPM
+ - 6/8 "Presto" (180 dotted-quarters/min) → 270 quarter-note BPM
+
+ Parameters
+ ----------
+ score_file : str or Path
+ Path to the MusicXML file
+
+ Returns
+ -------
+ float or None
+ Quarter-note BPM based on tempo marking, or None if not found
+ """
+ try:
+ tree = ET.parse(str(score_file))
+ root = tree.getroot()
+
+ # Track current time signature
+ # Default to 4/4 (simple meter, quarter note beat)
+ current_beats = 4
+ current_beat_type = 4
+
+ # Process measures in order to track time signature changes
+ for part in root.iter("part"):
+ for measure in part.iter("measure"):
+ # Check for time signature change in this measure
+ for attributes in measure.iter("attributes"):
+ for time_elem in attributes.iter("time"):
+ beats_elem = time_elem.find("beats")
+ beat_type_elem = time_elem.find("beat-type")
+ if beats_elem is not None and beats_elem.text:
+ current_beats = int(beats_elem.text)
+ if beat_type_elem is not None and beat_type_elem.text:
+ current_beat_type = int(beat_type_elem.text)
+
+ # Look for tempo marking in this measure
+ for direction in measure.iter("direction"):
+ for direction_type in direction.iter("direction-type"):
+ for words in direction_type.iter("words"):
+ if words.text:
+ text = words.text.lower().strip()
+ # Check each tempo marking
+ for marking, bpm in TEMPO_MARKING_TO_BPM.items():
+ # Match if marking appears at the start of the text
+ if re.match(rf"^{marking}\b", text):
+ # Check if compound meter (6/8, 9/8, 12/8, etc.)
+ # Compound meter: numerator divisible by 3 and >= 6
+ is_compound = (
+ current_beats >= 6
+ and current_beats % 3 == 0
+ )
+
+ if is_compound:
+ # Compound meter: beat is dotted note
+ # e.g., 6/8: dotted quarter = 3 eighth notes
+ # To maintain the same "feel" as simple meter:
+ # - In 4/4 "Andante=80": quarter = 0.75s
+ # - In 6/8 we want dotted quarter as beat
+ # - dotted quarter = 1.5 × quarter
+ # - So dotted quarter BPM = quarter BPM / 1.5
+ quarter_note_bpm = bpm / 1.5
+ else:
+ # Simple meter: beat-type indicates beat note
+ # Convert to quarter-note BPM
+ # beat-type 2 (half note): multiply by 2
+ # beat-type 4 (quarter note): no change
+ # beat-type 8 (eighth note): divide by 2
+ quarter_note_bpm = bpm * (
+ 4.0 / current_beat_type
+ )
+
+ return float(quarter_note_bpm)
+
+ # Only process first part to avoid duplicates
+ break
+ except Exception:
+ pass
+
+ return None
+
class MatchmakerInvalidParameterTypeError(Exception):
"""
@@ -191,6 +314,114 @@ def interleave_with_constant(
return interleaved_array
+def get_tempo_from_score(
+ score_part: ScoreLike,
+ score_file: Optional[Union[str, Path]] = None,
+) -> Optional[float]:
+ """
+ Extract first tempo marking from score if available.
+
+ Tries multiple sources in order:
+ 1. Partitura Tempo objects (explicit BPM)
+ 2. MusicXML element (if score_file provided)
+
+ Parameters
+ ----------
+ score_part : ScoreLike
+ Partitura score part
+ score_file : str or Path, optional
+ Path to the score file. Used as fallback to parse MusicXML directly
+ when partitura doesn't extract tempo.
+
+ Returns
+ -------
+ float or None
+ Tempo in BPM if found in score, None otherwise.
+ """
+ # Try partitura Tempo objects first
+ if score_part is not None:
+ try:
+ for tempo_obj in score_part.iter_all(partitura.score.Tempo):
+ if hasattr(tempo_obj, "bpm") and tempo_obj.bpm is not None:
+ return float(tempo_obj.bpm)
+ except Exception:
+ pass
+
+ # Fallback: parse MusicXML directly for
+ if score_file is not None:
+ try:
+ import xml.etree.ElementTree as ET
+
+ tree = ET.parse(str(score_file))
+ root = tree.getroot()
+
+ for sound_elem in root.iter("sound"):
+ tempo_attr = sound_elem.get("tempo")
+ if tempo_attr is not None:
+ return float(tempo_attr)
+ except Exception:
+ pass
+
+ return None
+
+
+def get_tempo_at_beat(
+ score_part: ScoreLike,
+ beat: float,
+ default_tempo: float = 120.0,
+) -> float:
+ """
+ Get tempo (BPM) at a specific beat position in the score.
+
+ Uses score tempo markings if available. Falls back to default_tempo otherwise.
+
+ Parameters
+ ----------
+ score_part : ScoreLike
+ Partitura score part
+ beat : float
+ Beat position in the score
+ default_tempo : float
+ Default tempo to use if no tempo markings found
+
+ Returns
+ -------
+ float
+ Tempo in BPM at the given beat position
+ """
+ if score_part is None:
+ return default_tempo
+
+ # Collect all tempo markings with their positions
+ tempo_changes = []
+ try:
+ for tempo_obj in score_part.iter_all(partitura.score.Tempo):
+ if hasattr(tempo_obj, "bpm") and tempo_obj.bpm is not None:
+ # Get beat position of tempo marking
+ start_time = getattr(tempo_obj, "start", None)
+ if start_time is not None:
+ tempo_beat = score_part.beat_map(start_time.t)
+ tempo_changes.append((tempo_beat, float(tempo_obj.bpm)))
+ except Exception:
+ pass
+
+ if not tempo_changes:
+ return default_tempo
+
+ # Sort by beat position
+ tempo_changes.sort(key=lambda x: x[0])
+
+ # Find the tempo at the given beat (last tempo marking before or at beat)
+ current_tempo = default_tempo
+ for tempo_beat, bpm in tempo_changes:
+ if tempo_beat <= beat:
+ current_tempo = bpm
+ else:
+ break
+
+ return current_tempo
+
+
def adjust_tempo_for_performance_audio(
score: ScoreLike, performance_audio: Path, default_tempo: int = 120
):
diff --git a/pyproject.toml b/pyproject.toml
index 014e5b4..c8f20bb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,7 @@ dependencies = [
"numpy>=1.26.3,<2.0",
"scipy>=1.11.4,<1.15",
"librosa>=0.10.1",
+ "pandas>=2.0.0",
"partitura>=1.7.0",
"progressbar2>=4.2.0",
"python-hiddenmarkov>=0.1.3",
diff --git a/tests/resources/Bach-fugue_bwv_858.musicxml b/tests/resources/Bach-fugue_bwv_858.musicxml
index 460546c..b177666 100644
--- a/tests/resources/Bach-fugue_bwv_858.musicxml
+++ b/tests/resources/Bach-fugue_bwv_858.musicxml
@@ -97,6 +97,16 @@
4
+
+
+
+ quarter
+ 70
+
+
+ 1
+
+
A