From 7a6f873e3a5c462686045413860c84df61b8767c Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Fri, 29 May 2026 20:53:24 +0100 Subject: [PATCH 01/22] code and a simple test for basic MIDI notes comparison --- config.json | 2 +- evaluation_function/evaluation.py | 53 +++++++++++++++++++++++++- evaluation_function/evaluation_test.py | 17 +++++---- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/config.json b/config.json index d13fa81..717b48f 100644 --- a/config.json +++ b/config.json @@ -1,3 +1,3 @@ { - "EvaluationFunctionName": "" + "EvaluationFunctionName": "evaluation_function" } diff --git a/evaluation_function/evaluation.py b/evaluation_function/evaluation.py index 61ecaa3..3e91406 100755 --- a/evaluation_function/evaluation.py +++ b/evaluation_function/evaluation.py @@ -1,6 +1,55 @@ from typing import Any from lf_toolkit.evaluation import Result, Params + +def basic_comparison(refMIDI, + learnerMIDI, + timing_tolerance = 0.1, + duration_tolerance = 0.1): + """ + Compares learner's MIDI notes with reference MIDI notes, + based on pitch, timing, and duration with specified tolerances. + Args: + refMIDI: The reference MIDI note. + learnerMIDI: The learner's MIDI note to evaluate. + timing_tolerance: consider as correct if start is within this tolerance. + duration_tolerance: consider as correct if duration is within this tolerance. + Returns: + bool: True if the notes match within the specified tolerances, False otherwise. + """ + ref_notes = refMIDI["notes"] + learner_notes = learnerMIDI["notes"] + + total_notes = len(ref_notes) + feedbacks = [] + all_correct = True + + for i in range(total_notes): + ref_note = ref_notes[i] + learner_note = learner_notes[i] + + # Check pitch, timing, and duration + pitch_match = ref_note["pitch"] == learner_note["pitch"] + timing_match = abs(ref_note["start"] - learner_note["start"]) <= timing_tolerance + duration_match = abs(ref_note["duration"] - learner_note["duration"]) <= duration_tolerance + + problems = [] + if not pitch_match: + problems.append(f"Pitch {learner_note['pitch']} is incorrect, should be {ref_note['pitch']}.") + if not timing_match: + problems.append(f"Difference in start time: {abs(ref_note['start'] - learner_note['start']):.2f}s.") + if not duration_match: + problems.append(f"Difference in duration: {abs(ref_note['duration'] - learner_note['duration']):.2f}s.") + + if len(problems) > 0: + feedbacks.append(" ".join(problems)) + all_correct = False + else: + feedbacks.append("All correct! Perfect practice!") + + return all_correct,feedbacks + + def evaluation_function( response: Any, answer: Any, @@ -28,7 +77,9 @@ def evaluation_function( return types and that evaluation_function() is the main function used to output the evaluation response. """ + all_correct, feedbacks = basic_comparison(response, answer) return Result( - is_correct=response == answer + is_correct=all_correct, + feedback=feedbacks ) \ No newline at end of file diff --git a/evaluation_function/evaluation_test.py b/evaluation_function/evaluation_test.py index 7a5c5bd..3994bdd 100755 --- a/evaluation_function/evaluation_test.py +++ b/evaluation_function/evaluation_test.py @@ -1,6 +1,13 @@ import unittest - from .evaluation import Params, evaluation_function +import json + +with open("./data/referenceMIDI.json") as f: + reference = json.load(f) + +with open("./data/learnerMIDI.json") as f: + learner = json.load(f) + class TestEvaluationFunction(unittest.TestCase): """ @@ -22,9 +29,5 @@ class TestEvaluationFunction(unittest.TestCase): """ def test_evaluation(self): - response, answer, params = "Hello, World", "Hello, World", Params() - - result = evaluation_function(response, answer, params).to_dict() - - self.assertEqual(result.get("is_correct"), True) - self.assertFalse(result.get("feedback", False)) + result = evaluation_function(learner, reference, Params()).to_dict() + self.assertFalse(result["is_correct"]) From e484d176d98c2ef95cab230e854aae41ec67697e Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Mon, 1 Jun 2026 22:01:37 +0100 Subject: [PATCH 02/22] add pitch alignment to basic comparison --- evaluation_function/evaluation.py | 82 +++++++++++++++++--------- evaluation_function/evaluation_test.py | 1 + 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/evaluation_function/evaluation.py b/evaluation_function/evaluation.py index 3e91406..839e731 100755 --- a/evaluation_function/evaluation.py +++ b/evaluation_function/evaluation.py @@ -1,9 +1,10 @@ from typing import Any from lf_toolkit.evaluation import Result, Params +import difflib -def basic_comparison(refMIDI, - learnerMIDI, +def basic_comparison(learnerMIDI, + refMIDI, timing_tolerance = 0.1, duration_tolerance = 0.1): """ @@ -20,35 +21,60 @@ def basic_comparison(refMIDI, ref_notes = refMIDI["notes"] learner_notes = learnerMIDI["notes"] - total_notes = len(ref_notes) feedbacks = [] all_correct = True + + # match the pitches to find if the learner play extra or missing notes during practice + ref_pitches = [note["pitch"] for note in ref_notes] + learner_pitches = [note["pitch"] for note in learner_notes] + pitch_similarity = difflib.SequenceMatcher(None, ref_pitches, learner_pitches) - for i in range(total_notes): - ref_note = ref_notes[i] - learner_note = learner_notes[i] - - # Check pitch, timing, and duration - pitch_match = ref_note["pitch"] == learner_note["pitch"] - timing_match = abs(ref_note["start"] - learner_note["start"]) <= timing_tolerance - duration_match = abs(ref_note["duration"] - learner_note["duration"]) <= duration_tolerance - - problems = [] - if not pitch_match: - problems.append(f"Pitch {learner_note['pitch']} is incorrect, should be {ref_note['pitch']}.") - if not timing_match: - problems.append(f"Difference in start time: {abs(ref_note['start'] - learner_note['start']):.2f}s.") - if not duration_match: - problems.append(f"Difference in duration: {abs(ref_note['duration'] - learner_note['duration']):.2f}s.") - - if len(problems) > 0: - feedbacks.append(" ".join(problems)) + for op, ref_start, ref_end, learner_start, learner_end in pitch_similarity.get_opcodes(): + + # if the pitches are the same, then check the timing and duration + if op == 'equal': + for i in range(ref_end - ref_start): + ref_note = ref_notes[ref_start + i] + learner_note = learner_notes[learner_start + i] + + timing_difference = abs(ref_note["start"] - learner_note["start"]) + duration_difference = abs(ref_note["duration"] - learner_note["duration"]) + timing_match = timing_difference <= timing_tolerance + duration_match = duration_difference <= duration_tolerance + + if timing_match and duration_match: + feedbacks.append( + f"Note {ref_start+i+1} with pitch {ref_note['pitch']} is correct.") + else: + all_correct = False + if not timing_match: + feedbacks.append(f"Note {ref_start+i+1}: difference in start time: {timing_difference:.2f}s.") + if not duration_match: + feedbacks.append(f"Note {ref_start+i+1}: difference in duration: {duration_difference:.2f}s.") + + # if the pitches are different, then check which pitch is wrong and give feedback + elif op == 'replace': + all_correct = False + for i in range(ref_end - ref_start): + ref_note = ref_notes[ref_start + i] + learner_note = learner_notes[learner_start + i] + feedbacks.append(f"Note {ref_start+i+1} is wrong: expected {ref_note['pitch']}, but played {learner_note['pitch']}.") + + # if some notes are missing, then give feedback about which notes are missing + elif op == 'delete': + all_correct = False + for i in range(ref_end - ref_start): + ref_note = ref_notes[ref_start + i] + feedbacks.append(f"Note {ref_start+i+1} with pitch {ref_note['pitch']} is missing in your performance.") + + # if some extra notes are played, then give feedback about which extra notes are played + elif op == 'insert': all_correct = False - else: - feedbacks.append("All correct! Perfect practice!") - - return all_correct,feedbacks + for i in range(learner_end - learner_start): + learner_note = learner_notes[learner_start + i] + feedbacks.append(f"You played an extra note {learner_start+i+1} with pitch {learner_note['pitch']}.") + return all_correct, feedbacks def evaluation_function( response: Any, @@ -80,6 +106,6 @@ def evaluation_function( all_correct, feedbacks = basic_comparison(response, answer) return Result( - is_correct=all_correct, - feedback=feedbacks + is_correct=all_correct, + feedback_items=[("feedback", "\n".join(feedbacks))] ) \ No newline at end of file diff --git a/evaluation_function/evaluation_test.py b/evaluation_function/evaluation_test.py index 3994bdd..1af48ca 100755 --- a/evaluation_function/evaluation_test.py +++ b/evaluation_function/evaluation_test.py @@ -31,3 +31,4 @@ class TestEvaluationFunction(unittest.TestCase): def test_evaluation(self): result = evaluation_function(learner, reference, Params()).to_dict() self.assertFalse(result["is_correct"]) + \ No newline at end of file From 3c73917040d341b48c456982a9a9316eadd0b8e6 Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Mon, 1 Jun 2026 22:20:50 +0100 Subject: [PATCH 03/22] add tests for each case separately --- evaluation_function/evaluation_test.py | 45 ++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/evaluation_function/evaluation_test.py b/evaluation_function/evaluation_test.py index 1af48ca..018dd33 100755 --- a/evaluation_function/evaluation_test.py +++ b/evaluation_function/evaluation_test.py @@ -8,6 +8,8 @@ with open("./data/learnerMIDI.json") as f: learner = json.load(f) +def make_midi(notes): + return {"notes": [{"pitch": p, "start": s, "duration": d} for p, s, d in notes]} class TestEvaluationFunction(unittest.TestCase): """ @@ -28,7 +30,46 @@ class TestEvaluationFunction(unittest.TestCase): as it should. """ - def test_evaluation(self): + def test_incorrect_performance(self): result = evaluation_function(learner, reference, Params()).to_dict() self.assertFalse(result["is_correct"]) - \ No newline at end of file + self.assertIn("feedback", result) + + def test_correct_notes(self): + midi = make_midi([(60, 0.0, 0.5), (62, 0.6, 0.5)]) + result = evaluation_function(midi, midi, Params()).to_dict() + self.assertTrue(result["is_correct"]) + + def test_wrong_pitch(self): + ref = make_midi([(60, 0.0, 0.5)]) + learner = make_midi([(61, 0.0, 0.5)]) + result = evaluation_function(learner, ref, Params()).to_dict() + self.assertFalse(result["is_correct"]) + self.assertIn("wrong", result["feedback"]) + + def test_timing_out_of_tolerance(self): + ref = make_midi([(60, 0.0, 0.5)]) + learner = make_midi([(60, 0.5, 0.5)]) # difference of 0.5s, out of tolerance + result = evaluation_function(learner, ref, Params()).to_dict() + self.assertFalse(result["is_correct"]) + self.assertIn("start time", result["feedback"]) + + def test_timing_within_tolerance(self): + ref = make_midi([(60, 0.0, 0.5)]) + learner = make_midi([(60, 0.05, 0.5)]) # difference of 0.05s, within tolerance + result = evaluation_function(learner, ref, Params()).to_dict() + self.assertTrue(result["is_correct"]) + + def test_missing_note(self): + ref = make_midi([(60, 0.0, 0.5), (62, 0.6, 0.5)]) + learner = make_midi([(60, 0.0, 0.5)]) + result = evaluation_function(learner, ref, Params()).to_dict() + self.assertFalse(result["is_correct"]) + self.assertIn("missing", result["feedback"]) + + def test_extra_note(self): + ref = make_midi([(60, 0.0, 0.5)]) + learner = make_midi([(60, 0.0, 0.5), (64, 0.6, 0.5)]) + result = evaluation_function(learner, ref, Params()).to_dict() + self.assertFalse(result["is_correct"]) + self.assertIn("extra", result["feedback"]) \ No newline at end of file From 540818cad0f96d527df82cb0bc25b356fa54573e Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Fri, 12 Jun 2026 21:06:12 +0100 Subject: [PATCH 04/22] change the vairables name to match the typical terminology --- evaluation_function/evaluation.py | 48 +++++++++++++------------- evaluation_function/evaluation_test.py | 26 +++++++------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/evaluation_function/evaluation.py b/evaluation_function/evaluation.py index 839e731..48b513d 100755 --- a/evaluation_function/evaluation.py +++ b/evaluation_function/evaluation.py @@ -3,78 +3,78 @@ import difflib -def basic_comparison(learnerMIDI, +def basic_comparison(responseMIDI, refMIDI, timing_tolerance = 0.1, duration_tolerance = 0.1): """ - Compares learner's MIDI notes with reference MIDI notes, + Compares student's response MIDI notes with reference MIDI notes, based on pitch, timing, and duration with specified tolerances. Args: refMIDI: The reference MIDI note. - learnerMIDI: The learner's MIDI note to evaluate. + responseMIDI: The student's response MIDI note to evaluate. timing_tolerance: consider as correct if start is within this tolerance. duration_tolerance: consider as correct if duration is within this tolerance. Returns: bool: True if the notes match within the specified tolerances, False otherwise. """ ref_notes = refMIDI["notes"] - learner_notes = learnerMIDI["notes"] + response_notes = responseMIDI["notes"] - feedbacks = [] + feedback = [] all_correct = True - # match the pitches to find if the learner play extra or missing notes during practice + # match the pitches to find if the student play extra or missing notes during practice ref_pitches = [note["pitch"] for note in ref_notes] - learner_pitches = [note["pitch"] for note in learner_notes] - pitch_similarity = difflib.SequenceMatcher(None, ref_pitches, learner_pitches) + response_pitches = [note["pitch"] for note in response_notes] + pitch_similarity = difflib.SequenceMatcher(None, ref_pitches, response_pitches) - for op, ref_start, ref_end, learner_start, learner_end in pitch_similarity.get_opcodes(): + for op, ref_start, ref_end, response_start, response_end in pitch_similarity.get_opcodes(): # if the pitches are the same, then check the timing and duration if op == 'equal': for i in range(ref_end - ref_start): ref_note = ref_notes[ref_start + i] - learner_note = learner_notes[learner_start + i] + response_note = response_notes[response_start + i] - timing_difference = abs(ref_note["start"] - learner_note["start"]) - duration_difference = abs(ref_note["duration"] - learner_note["duration"]) + timing_difference = abs(ref_note["start"] - response_note["start"]) + duration_difference = abs(ref_note["duration"] - response_note["duration"]) timing_match = timing_difference <= timing_tolerance duration_match = duration_difference <= duration_tolerance if timing_match and duration_match: - feedbacks.append( + feedback.append( f"Note {ref_start+i+1} with pitch {ref_note['pitch']} is correct.") else: all_correct = False if not timing_match: - feedbacks.append(f"Note {ref_start+i+1}: difference in start time: {timing_difference:.2f}s.") + feedback.append(f"Note {ref_start+i+1}: difference in start time: {timing_difference:.2f}s.") if not duration_match: - feedbacks.append(f"Note {ref_start+i+1}: difference in duration: {duration_difference:.2f}s.") + feedback.append(f"Note {ref_start+i+1}: difference in duration: {duration_difference:.2f}s.") # if the pitches are different, then check which pitch is wrong and give feedback elif op == 'replace': all_correct = False for i in range(ref_end - ref_start): ref_note = ref_notes[ref_start + i] - learner_note = learner_notes[learner_start + i] - feedbacks.append(f"Note {ref_start+i+1} is wrong: expected {ref_note['pitch']}, but played {learner_note['pitch']}.") + response_note = response_notes[response_start + i] + feedback.append(f"Note {ref_start+i+1} is wrong: expected {ref_note['pitch']}, but played {response_note['pitch']}.") # if some notes are missing, then give feedback about which notes are missing elif op == 'delete': all_correct = False for i in range(ref_end - ref_start): ref_note = ref_notes[ref_start + i] - feedbacks.append(f"Note {ref_start+i+1} with pitch {ref_note['pitch']} is missing in your performance.") + feedback.append(f"Note {ref_start+i+1} with pitch {ref_note['pitch']} is missing in your performance.") # if some extra notes are played, then give feedback about which extra notes are played elif op == 'insert': all_correct = False - for i in range(learner_end - learner_start): - learner_note = learner_notes[learner_start + i] - feedbacks.append(f"You played an extra note {learner_start+i+1} with pitch {learner_note['pitch']}.") + for i in range(response_end - response_start): + response_note = response_notes[response_start + i] + feedback.append(f"You played an extra note {response_start+i+1} with pitch {response_note['pitch']}.") - return all_correct, feedbacks + return all_correct, feedback def evaluation_function( response: Any, @@ -103,9 +103,9 @@ def evaluation_function( return types and that evaluation_function() is the main function used to output the evaluation response. """ - all_correct, feedbacks = basic_comparison(response, answer) + all_correct, feedback = basic_comparison(response, answer) return Result( is_correct=all_correct, - feedback_items=[("feedback", "\n".join(feedbacks))] + feedback_items=[("feedback", "\n".join(feedback))] ) \ No newline at end of file diff --git a/evaluation_function/evaluation_test.py b/evaluation_function/evaluation_test.py index 018dd33..e715e1f 100755 --- a/evaluation_function/evaluation_test.py +++ b/evaluation_function/evaluation_test.py @@ -5,8 +5,8 @@ with open("./data/referenceMIDI.json") as f: reference = json.load(f) -with open("./data/learnerMIDI.json") as f: - learner = json.load(f) +with open("./data/responseMIDI.json") as f: + response = json.load(f) def make_midi(notes): return {"notes": [{"pitch": p, "start": s, "duration": d} for p, s, d in notes]} @@ -31,7 +31,7 @@ class TestEvaluationFunction(unittest.TestCase): """ def test_incorrect_performance(self): - result = evaluation_function(learner, reference, Params()).to_dict() + result = evaluation_function(response, reference, Params()).to_dict() self.assertFalse(result["is_correct"]) self.assertIn("feedback", result) @@ -42,34 +42,34 @@ def test_correct_notes(self): def test_wrong_pitch(self): ref = make_midi([(60, 0.0, 0.5)]) - learner = make_midi([(61, 0.0, 0.5)]) - result = evaluation_function(learner, ref, Params()).to_dict() + response = make_midi([(61, 0.0, 0.5)]) + result = evaluation_function(response, ref, Params()).to_dict() self.assertFalse(result["is_correct"]) self.assertIn("wrong", result["feedback"]) def test_timing_out_of_tolerance(self): ref = make_midi([(60, 0.0, 0.5)]) - learner = make_midi([(60, 0.5, 0.5)]) # difference of 0.5s, out of tolerance - result = evaluation_function(learner, ref, Params()).to_dict() + response = make_midi([(60, 0.5, 0.5)]) # difference of 0.5s, out of tolerance + result = evaluation_function(response, ref, Params()).to_dict() self.assertFalse(result["is_correct"]) self.assertIn("start time", result["feedback"]) def test_timing_within_tolerance(self): ref = make_midi([(60, 0.0, 0.5)]) - learner = make_midi([(60, 0.05, 0.5)]) # difference of 0.05s, within tolerance - result = evaluation_function(learner, ref, Params()).to_dict() + response = make_midi([(60, 0.05, 0.5)]) # difference of 0.05s, within tolerance + result = evaluation_function(response, ref, Params()).to_dict() self.assertTrue(result["is_correct"]) def test_missing_note(self): ref = make_midi([(60, 0.0, 0.5), (62, 0.6, 0.5)]) - learner = make_midi([(60, 0.0, 0.5)]) - result = evaluation_function(learner, ref, Params()).to_dict() + response = make_midi([(60, 0.0, 0.5)]) + result = evaluation_function(response, ref, Params()).to_dict() self.assertFalse(result["is_correct"]) self.assertIn("missing", result["feedback"]) def test_extra_note(self): ref = make_midi([(60, 0.0, 0.5)]) - learner = make_midi([(60, 0.0, 0.5), (64, 0.6, 0.5)]) - result = evaluation_function(learner, ref, Params()).to_dict() + response = make_midi([(60, 0.0, 0.5), (64, 0.6, 0.5)]) + result = evaluation_function(response, ref, Params()).to_dict() self.assertFalse(result["is_correct"]) self.assertIn("extra", result["feedback"]) \ No newline at end of file From 8e652b676fe6595789aad56a642ebbe8c9758c84 Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Fri, 12 Jun 2026 23:38:03 +0100 Subject: [PATCH 05/22] basic code structure for DTW --- evaluation_function/evaluation.py | 74 +++++++++---------------------- 1 file changed, 21 insertions(+), 53 deletions(-) diff --git a/evaluation_function/evaluation.py b/evaluation_function/evaluation.py index 48b513d..721722e 100755 --- a/evaluation_function/evaluation.py +++ b/evaluation_function/evaluation.py @@ -1,9 +1,22 @@ from typing import Any from lf_toolkit.evaluation import Result, Params -import difflib -def basic_comparison(responseMIDI, +def compute_cost(note1, note2): + """ + Computes the cost used for Dynamic Time Warping. + Lower cost means the two notes are more similar. + """ + pass + +def note_alignment_DTW(responseNotes, refNotes): + """ + Use DTW to find the optimal alignment between response and reference MIDI notes. + """ + pass + + +def compare_notes(responseMIDI, refMIDI, timing_tolerance = 0.1, duration_tolerance = 0.1): @@ -21,59 +34,14 @@ def basic_comparison(responseMIDI, ref_notes = refMIDI["notes"] response_notes = responseMIDI["notes"] + aligned_notes = note_alignment_DTW(response_notes, ref_notes) + feedback = [] all_correct = True - - # match the pitches to find if the student play extra or missing notes during practice - ref_pitches = [note["pitch"] for note in ref_notes] - response_pitches = [note["pitch"] for note in response_notes] - pitch_similarity = difflib.SequenceMatcher(None, ref_pitches, response_pitches) - - for op, ref_start, ref_end, response_start, response_end in pitch_similarity.get_opcodes(): - - # if the pitches are the same, then check the timing and duration - if op == 'equal': - for i in range(ref_end - ref_start): - ref_note = ref_notes[ref_start + i] - response_note = response_notes[response_start + i] - - timing_difference = abs(ref_note["start"] - response_note["start"]) - duration_difference = abs(ref_note["duration"] - response_note["duration"]) - timing_match = timing_difference <= timing_tolerance - duration_match = duration_difference <= duration_tolerance - - if timing_match and duration_match: - feedback.append( - f"Note {ref_start+i+1} with pitch {ref_note['pitch']} is correct.") - else: - all_correct = False - if not timing_match: - feedback.append(f"Note {ref_start+i+1}: difference in start time: {timing_difference:.2f}s.") - if not duration_match: - feedback.append(f"Note {ref_start+i+1}: difference in duration: {duration_difference:.2f}s.") - - # if the pitches are different, then check which pitch is wrong and give feedback - elif op == 'replace': - all_correct = False - for i in range(ref_end - ref_start): - ref_note = ref_notes[ref_start + i] - response_note = response_notes[response_start + i] - feedback.append(f"Note {ref_start+i+1} is wrong: expected {ref_note['pitch']}, but played {response_note['pitch']}.") - - # if some notes are missing, then give feedback about which notes are missing - elif op == 'delete': - all_correct = False - for i in range(ref_end - ref_start): - ref_note = ref_notes[ref_start + i] - feedback.append(f"Note {ref_start+i+1} with pitch {ref_note['pitch']} is missing in your performance.") - - # if some extra notes are played, then give feedback about which extra notes are played - elif op == 'insert': - all_correct = False - for i in range(response_end - response_start): - response_note = response_notes[response_start + i] - feedback.append(f"You played an extra note {response_start+i+1} with pitch {response_note['pitch']}.") + # loop over each note pair + + return all_correct, feedback def evaluation_function( @@ -103,7 +71,7 @@ def evaluation_function( return types and that evaluation_function() is the main function used to output the evaluation response. """ - all_correct, feedback = basic_comparison(response, answer) + all_correct, feedback = compare_notes(response, answer) return Result( is_correct=all_correct, From f105a1eff858eec67ace35335f87132af3942fc1 Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Sat, 13 Jun 2026 18:20:38 +0100 Subject: [PATCH 06/22] working on the backtrack warping path --- notebooks/DTW.ipynb | 196 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 notebooks/DTW.ipynb diff --git a/notebooks/DTW.ipynb b/notebooks/DTW.ipynb new file mode 100644 index 0000000..0af2fbe --- /dev/null +++ b/notebooks/DTW.ipynb @@ -0,0 +1,196 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 16, + "id": "a6822f11", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import json\n", + "\n", + "cwd = os.getcwd()\n", + "dir = os.path.dirname(cwd)\n", + "reference_path = os.path.join(dir, \"data\", \"referenceMIDI.json\")\n", + "response_path = os.path.join(dir, \"data\", \"responseMIDI.json\")\n", + "\n", + "with open(reference_path) as f1:\n", + " reference = json.load(f1)\n", + "\n", + "with open(response_path) as f2:\n", + " response = json.load(f2)" + ] + }, + { + "cell_type": "markdown", + "id": "8d3bba8e", + "metadata": {}, + "source": [ + "# Dynamic Time Warping" + ] + }, + { + "cell_type": "markdown", + "id": "a900434b", + "metadata": {}, + "source": [ + "The goal of DTW is to find an optimal possibly nonlinear alignment between response MIDI sequence to reference MIDI sequence.\n", + "\n", + "Basic approach:\n", + "- Evaluating the local cost measure for each pair of elements in the response(X) and reference(Y) sequences. \n", + "- Dynamic programming to find an alignment path between X and Y having minimal overall cost, i.e. DTW distance. The algorithm computes a cumulative distance path, the timestamps of the target MIDI are warped so they perfectly align with the anchor points of the reference MIDI." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9c568285", + "metadata": {}, + "outputs": [], + "source": [ + "def compute_cost(note1, note2):\n", + " \"\"\"\n", + " Compute the local cost measure for each pair of notes.\n", + " \n", + " Only pitch is involved in the cost calculation, since the purpose \n", + " is to check if the user played missing or extra notes.\n", + " \n", + " Args:\n", + " note1: dict with keys \"pitch\" (int), \"start\" (float), \"duration\" (float)\n", + " note2: dict with keys \"pitch\" (int), \"start\" (float), \"duration\" (float)\n", + " \n", + " Returns:\n", + " int: cost value >= 0 (lower means more similar pitch)\n", + " \"\"\"\n", + " return int(abs(note1[\"pitch\"] - note2[\"pitch\"]))\n", + "\n", + "\n", + "import numpy as np\n", + "def compute_cost_matrix(response_notes, ref_notes):\n", + " \"\"\"\n", + " Build the local cost matrix C of size (N x M).\n", + " C[i, j] = note_cost(ref_notes[i], response_notes[j])\n", + " \n", + " Args:\n", + " response_notes: list of dicts, each with keys \"pitch\", \"start\", \"duration\"\n", + " ref_notes: list of dicts, each with keys \"pitch\", \"start\", \"duration\"\n", + " \n", + " Returns:\n", + " numpy array of shape (N, M) - cost matrix where C[i,j] is the cost of \n", + " aligning response_notes[i] with ref_notes[j]\n", + " \"\"\"\n", + " N = len(response_notes)\n", + " M = len(ref_notes)\n", + " C= np.zeros((N, M))\n", + " \n", + " for i in range(N):\n", + " for j in range(M):\n", + " C[i, j] = compute_cost(response_notes[i], ref_notes[j])\n", + " return C\n", + "\n", + "\n", + "def accumulate_cost_matrix(C):\n", + " \"\"\"\n", + " Build the accumulated cost matrix D of size (N+1 x M+1) \n", + " using small trick for simplifying the initialization:\n", + " set:\n", + " D[0, 0] = 0\n", + " D[n, 0] = inf for n >= 1\n", + " D[0, m] = inf for m >= 1\n", + " for all n in [1..N] and m in [1..M]:\n", + " D[i, j] = C[i, j] + min(D[i-1, j], D[i, j-1], D[i-1, j-1])\n", + " \n", + " Args:\n", + " numpy array of shape (N, M) — the local cost matrix\n", + " \n", + " Returns:\n", + " numpy array of shape (N+1, M+1) — the accumulated cost matrix\n", + " \"\"\"\n", + " N, M = C.shape\n", + " D = np.full((N + 1, M + 1), np.inf)\n", + " D[0, 0] = 0.0\n", + " \n", + " for i in range(1, N + 1):\n", + " for j in range(1, M + 1):\n", + " D[i, j] = C[i - 1, j - 1] + min(\n", + " D[i - 1, j], # vertical step\n", + " D[i, j - 1], # horizontal step\n", + " D[i - 1, j - 1]) # diagonal step\n", + " \n", + " return D\n", + "\n", + "def backtrack_warping_path(D):\n", + " \"\"\"\n", + " Backtrack through the D to find the optimal warping path P.\n", + " \n", + " Args:\n", + " D: numpy array of shape (N+1, M+1) — the accumulated cost matrix\n", + " \n", + " Returns:\n", + " list of tuples — the optimal warping path as a list of (i, j) indices\n", + " ordered from the start (0, 0) to the end (N-1, M-1)\n", + " \"\"\"\n", + " N = D.shape[0] - 1\n", + " M = D.shape[1] - 1\n", + "\n", + " path = []\n", + " \n", + " while N > 0 and M > 0:\n", + " path.append((N-1, M-1))\n", + " # Find the minimum cost step\n", + " min_step = min(D[N - 1, M], D[N, M - 1], D[N - 1, M - 1])\n", + " if min_step == D[N - 1, M]: # vertical step\n", + " N -= 1\n", + " elif min_step == D[N, M - 1]: # horizontal step\n", + " M -= 1\n", + " else: # diagonal step\n", + " N -= 1\n", + " M -= 1\n", + " \n", + " # Reverse to get the path from start to end\n", + " path.reverse() \n", + " return path\n", + "\n", + "\n", + "def note_alignment_DTW(responseNotes, refNotes):\n", + " \"\"\"\n", + " DTW algorithm based on dynamic programming.\n", + " To find the optimal alignment between response and reference MIDI notes.\n", + " \"\"\"\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "id": "62037762", + "metadata": {}, + "source": [ + "Reference:\n", + "\n", + "M. Müller, Fundamentals of Music Processing (Chpater 3.2 Dynamic Time Warping). Cham: Springer International Publishing, 2021, ISBN: 9783030698072. DOI:https://doi.org/10.1007/978-3-030-69808-9.\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "compareMusic", + "language": "python", + "name": "comparemusic" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From c4afe5456f675618edf4170df0ec119283276e5e Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Sat, 13 Jun 2026 22:53:06 +0100 Subject: [PATCH 07/22] evaluation after DTW, but there is an unexpected output message. --- data/referenceMIDI.json | 10 + data/responseMIDI.json | 9 + notebooks/DTW.ipynb | 231 ++++- poetry.lock | 1982 ++++++++++++++++++++++++++++++++++++++- pyproject.toml | 3 +- 5 files changed, 2175 insertions(+), 60 deletions(-) create mode 100644 data/referenceMIDI.json create mode 100644 data/responseMIDI.json diff --git a/data/referenceMIDI.json b/data/referenceMIDI.json new file mode 100644 index 0000000..40c6794 --- /dev/null +++ b/data/referenceMIDI.json @@ -0,0 +1,10 @@ +{ + "performance_type": "reference", + "notes": [ + {"pitch": 60, "start": 0.00, "duration": 0.50}, + {"pitch": 62, "start": 0.60, "duration": 0.50}, + {"pitch": 64, "start": 1.20, "duration": 0.50}, + {"pitch": 65, "start": 1.80, "duration": 0.50}, + {"pitch": 67, "start": 2.50, "duration": 0.50} + ] +} \ No newline at end of file diff --git a/data/responseMIDI.json b/data/responseMIDI.json new file mode 100644 index 0000000..24d09df --- /dev/null +++ b/data/responseMIDI.json @@ -0,0 +1,9 @@ +{ + "performance_type": "response", + "notes": [ + {"pitch": 60, "start": 0.00, "duration": 0.50}, + {"pitch": 63, "start": 0.60, "duration": 0.50}, + {"pitch": 64, "start": 1.35, "duration": 0.50}, + {"pitch": 65, "start": 1.80, "duration": 0.70} + ] +} \ No newline at end of file diff --git a/notebooks/DTW.ipynb b/notebooks/DTW.ipynb index 0af2fbe..14153c4 100644 --- a/notebooks/DTW.ipynb +++ b/notebooks/DTW.ipynb @@ -2,7 +2,19 @@ "cells": [ { "cell_type": "code", - "execution_count": 16, + "execution_count": 5, + "id": "96c2775c", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from typing import Any\n", + "from lf_toolkit.evaluation import Result, Params" + ] + }, + { + "cell_type": "code", + "execution_count": 6, "id": "a6822f11", "metadata": {}, "outputs": [], @@ -37,14 +49,22 @@ "source": [ "The goal of DTW is to find an optimal possibly nonlinear alignment between response MIDI sequence to reference MIDI sequence.\n", "\n", + "The algorithm can be found in this book (Chpater 3.2 Dynamic Time Warping):\n", + "M. Müller, Fundamentals of Music Processing. Cham: Springer International Publishing, 2021, ISBN: 9783030698072. DOI:https://doi.org/10.1007/978-3-030-69808-9.\n", + "\n", + "\n", "Basic approach:\n", "- Evaluating the local cost measure for each pair of elements in the response(X) and reference(Y) sequences. \n", - "- Dynamic programming to find an alignment path between X and Y having minimal overall cost, i.e. DTW distance. The algorithm computes a cumulative distance path, the timestamps of the target MIDI are warped so they perfectly align with the anchor points of the reference MIDI." + "- Dynamic programming to find an alignment path between X and Y having minimal overall cost, i.e. DTW distance. The algorithm computes a cumulative distance path, the timestamps of the target MIDI are warped so they perfectly align with the anchor points of the reference MIDI.\n", + "\n", + "However, this basic approach will not correctly handle the missing note case as expected, because it allows a note to match with multiple notes. Let's say, there is a note missing in the response, this algorithm tends to match a response note with two reference note, instead of reporting the missing problem.\n", + "\n", + "! need to modify this algorithm to make it handle the missing/extra problem correctly." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "9c568285", "metadata": {}, "outputs": [], @@ -66,8 +86,7 @@ " return int(abs(note1[\"pitch\"] - note2[\"pitch\"]))\n", "\n", "\n", - "import numpy as np\n", - "def compute_cost_matrix(response_notes, ref_notes):\n", + "def cost_matrix(response_notes, ref_notes):\n", " \"\"\"\n", " Build the local cost matrix C of size (N x M).\n", " C[i, j] = note_cost(ref_notes[i], response_notes[j])\n", @@ -135,14 +154,17 @@ " M = D.shape[1] - 1\n", "\n", " path = []\n", - " \n", + " \n", " while N > 0 and M > 0:\n", " path.append((N-1, M-1))\n", " # Find the minimum cost step\n", - " min_step = min(D[N - 1, M], D[N, M - 1], D[N - 1, M - 1])\n", - " if min_step == D[N - 1, M]: # vertical step\n", + " diag = D[N-1, M-1]\n", + " vertical = D[N-1, M ]\n", + " horizontal = D[N, M-1]\n", + " min_step = min(diag, vertical, horizontal)\n", + " if min_step == vertical:\n", " N -= 1\n", - " elif min_step == D[N, M - 1]: # horizontal step\n", + " elif min_step == horizontal:\n", " M -= 1\n", " else: # diagonal step\n", " N -= 1\n", @@ -153,23 +175,200 @@ " return path\n", "\n", "\n", - "def note_alignment_DTW(responseNotes, refNotes):\n", + "def note_alignment_DTW(response_notes, ref_notes):\n", " \"\"\"\n", - " DTW algorithm based on dynamic programming.\n", - " To find the optimal alignment between response and reference MIDI notes.\n", + " DTW pipeline: build cost matrix -> build accumulated cost matrix -> backtrack\n", + " \n", + " Args:\n", + " response_notes: The student's response MIDI notes to evaluate\n", + " ref_notes: The reference MIDI note\n", + " \n", + " Returns:\n", + " path: list of (response_idx, ref_idx) pairs — the optimal alignment\n", + " C: local cost matrix (useful for visualisation)\n", + " D: accumulated cost matrix (useful for visualisation)\n", " \"\"\"\n", - " pass" + " C = cost_matrix(response_notes, ref_notes)\n", + " D = accumulate_cost_matrix(C)\n", + " path = backtrack_warping_path(D)\n", + " return path, C, D" ] }, { "cell_type": "markdown", - "id": "62037762", + "id": "095ae680", + "metadata": {}, + "source": [ + "Evaluation" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "f2cc4262", + "metadata": {}, + "outputs": [], + "source": [ + "def evaluate_note_pair(response_note, ref_note, ref_idx,\n", + " timing_tolerance=0.1, duration_tolerance=0.1):\n", + " \"\"\"\n", + " Evaluate a single aligned note pair and return feedback.\n", + " \n", + " Args:\n", + " response_note: student's note dict\n", + " ref_note: reference note dict\n", + " ref_idx: 1-based display index (based on ref position)\n", + " timing_tolerance: consider as correct if start is within this tolerance\n", + " duration_tolerance: consider as correct if duration is within this tolerance\n", + " \n", + " Returns:\n", + " is_correct (bool), messages (list of str)\n", + " \"\"\"\n", + " messages = []\n", + " is_correct = True\n", + " \n", + " # Pitch check\n", + " if response_note[\"pitch\"] != ref_note[\"pitch\"]:\n", + " is_correct = False\n", + " messages.append(\n", + " f\"Note {ref_idx}: wrong pitch — expected {ref_note['pitch']}, \"\n", + " f\"played {response_note['pitch']}.\"\n", + " )\n", + " \n", + " # Timing check\n", + " timing_diff = abs(response_note[\"start\"] - ref_note[\"start\"])\n", + " if timing_diff > timing_tolerance:\n", + " is_correct = False\n", + " messages.append(f\"Note {ref_idx}: difference in start time: {timing_diff:.2f}s.\")\n", + " \n", + " # Duration check\n", + " duration_diff = abs(response_note[\"duration\"] - ref_note[\"duration\"])\n", + " if duration_diff > duration_tolerance:\n", + " is_correct = False\n", + " messages.append(f\"Note {ref_idx}: difference in duration: {duration_diff:.2f}s.\")\n", + " \n", + " if is_correct:\n", + " messages.append(f\"Note {ref_idx} (with pitch {ref_note['pitch']}) is correct.\")\n", + " \n", + " return is_correct, messages" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "99c84405", + "metadata": {}, + "outputs": [], + "source": [ + "def comparison(response, ref,\n", + " timing_tolerance=0.1, duration_tolerance=0.1):\n", + " \"\"\"\n", + " Compare student MIDI against reference MIDI after DTW-based alignment.\n", + " \n", + " Args:\n", + " response: The student's response MIDI\n", + " ref: The reference MIDI\n", + " timing_tolerance: seconds\n", + " duration_tolerance: seconds\n", + " \n", + " Returns:\n", + " all_correct (bool), feedback (list of str)\n", + " \"\"\"\n", + " response_notes = response[\"notes\"]\n", + " ref_notes = ref[\"notes\"]\n", + " \n", + " # Align using DTW — response first, ref second\n", + " path, C, D = note_alignment_DTW(response_notes, ref_notes)\n", + " \n", + " feedback = []\n", + " all_correct = True\n", + " \n", + " for response_idx, ref_idx in path:\n", + " is_correct, messages = evaluate_note_pair(\n", + " response_notes[response_idx], ref_notes[ref_idx],\n", + " ref_idx=ref_idx + 1,\n", + " timing_tolerance=timing_tolerance,\n", + " duration_tolerance=duration_tolerance,\n", + " )\n", + " if not is_correct:\n", + " all_correct = False\n", + " feedback.extend(messages)\n", + " \n", + " return all_correct, feedback" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "a203ee2d", + "metadata": {}, + "outputs": [], + "source": [ + "def evaluation_function(response: Any, answer: Any, params: Params) -> Result:\n", + " \"\"\"\n", + " Entry point for Lambda Feedback.\n", + " \n", + " Args:\n", + " response: student MIDI dict\n", + " answer: reference MIDI dict\n", + " params: optional extra parameters\n", + " \n", + " Returns:\n", + " Result with is_correct and feedback string\n", + " \"\"\"\n", + " all_correct, feedback = comparison(response, answer)\n", + " return Result(\n", + " is_correct=all_correct,\n", + " feedback_items=[(\"feedback\", \"\\n\".join(feedback))]\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "cd3befd9", "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "False\n", + "Note 1 (with pitch 60) is correct.\n", + "Note 2: wrong pitch — expected 62, played 63.\n", + "Note 3: difference in start time: 0.15s.\n", + "Note 4: difference in duration: 0.20s.\n", + "Note 5: wrong pitch — expected 67, played 65.\n", + "Note 5: difference in start time: 0.70s.\n", + "Note 5: difference in duration: 0.20s.\n" + ] + } + ], "source": [ - "Reference:\n", + "is_correct, feedbacks = comparison(\n", + " response,\n", + " reference\n", + ")\n", + "\n", + "print(is_correct)\n", "\n", - "M. Müller, Fundamentals of Music Processing (Chpater 3.2 Dynamic Time Warping). Cham: Springer International Publishing, 2021, ISBN: 9783030698072. DOI:https://doi.org/10.1007/978-3-030-69808-9.\n" + "for feedback in feedbacks:\n", + " print(feedback)" ] + }, + { + "cell_type": "markdown", + "id": "25bc065e", + "metadata": {}, + "source": [ + "note5 should be a missing pitch!! Need to check the DTW algorithm!" + ] + }, + { + "cell_type": "markdown", + "id": "01bcd63e", + "metadata": {}, + "source": [] } ], "metadata": { diff --git a/poetry.lock b/poetry.lock index 9f71f49..f62ad4e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,26 +1,37 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] [[package]] name = "anyio" -version = "4.4.0" +version = "4.6.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, - {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, + {file = "anyio-4.6.0-py3-none-any.whl", hash = "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a"}, + {file = "anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb"}, ] [package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] -doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] -trio = ["trio (>=0.23)"] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.21.0b1) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] +trio = ["trio (>=0.26.1)"] [[package]] name = "attrs" @@ -28,18 +39,385 @@ version = "24.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +description = "Backport of CPython tarfile module" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version == \"3.11\"" +files = [ + {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, + {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"] + +[[package]] +name = "boto3" +version = "1.43.29" +description = "The AWS SDK for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "boto3-1.43.29-py3-none-any.whl", hash = "sha256:77c27ada27cdbf619a3bbc41fa9e991caef818d3a2988cf92ea722e107d90108"}, + {file = "boto3-1.43.29.tar.gz", hash = "sha256:354006c512cdb87ef8214a095f2ade961c8145734475cd7a7e6b39260ff5494a"}, +] + +[package.dependencies] +botocore = ">=1.43.29,<1.44.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.18.0,<0.19.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.43.29" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "botocore-1.43.29-py3-none-any.whl", hash = "sha256:5d62f2a03ed279a50207ca2824e009313df15f082b6bb591a095a4f04c7faef3"}, + {file = "botocore-1.43.29.tar.gz", hash = "sha256:dce39d33b707aa162aa3820975f99d7f8f746d46576169fb42ce4f2b3b56b261"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = ">=1.25.4,<2.2.0 || >2.2.0,<3" + +[package.extras] +crt = ["awscrt (==0.32.2)"] + +[[package]] +name = "build" +version = "1.5.0" +description = "A simple, correct Python build frontend" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f"}, + {file = "build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "os_name == \"nt\""} +packaging = ">=24.0" +pyproject_hooks = "*" + +[package.extras] +keyring = ["keyring"] +uv = ["uv (>=0.1.18)"] +virtualenv = ["virtualenv (>=20.17) ; python_version >= \"3.10\" and python_version < \"3.14\"", "virtualenv (>=20.31) ; python_version >= \"3.14\""] + +[[package]] +name = "cachecontrol" +version = "0.14.4" +description = "httplib2 caching for requests" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b"}, + {file = "cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1"}, +] + +[package.dependencies] +filelock = {version = ">=3.8.0", optional = true, markers = "extra == \"filecache\""} +msgpack = ">=0.5.2,<2.0.0" +requests = ">=2.16.0" + +[package.extras] +dev = ["cachecontrol[filecache,redis]", "cheroot (>=11.1.2)", "cherrypy", "codespell", "furo", "mypy", "pytest", "pytest-cov", "ruff", "sphinx", "sphinx-copybutton", "types-redis", "types-requests"] +filecache = ["filelock (>=3.8.0)"] +redis = ["redis (>=2.10.5)"] + +[[package]] +name = "certifi" +version = "2026.5.20" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, + {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, +] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, + {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, + {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, +] + +[[package]] +name = "cleo" +version = "2.1.0" +description = "Cleo allows you to create beautiful and testable command-line interfaces." +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "cleo-2.1.0-py3-none-any.whl", hash = "sha256:4a31bd4dd45695a64ee3c4758f583f134267c2bc518d8ae9a29cf237d009b07e"}, + {file = "cleo-2.1.0.tar.gz", hash = "sha256:0b2c880b5d13660a7ea651001fb4acb527696c01f15c9ee650f377aa543fd523"}, +] + +[package.dependencies] +crashtest = ">=0.4.1,<0.5.0" +rapidfuzz = ">=3.0.0,<4.0.0" [[package]] name = "colorama" @@ -47,24 +425,222 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "os_name == \"nt\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} [[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" +name = "crashtest" +version = "0.4.1" +description = "Manage Python errors with ease" optional = false -python-versions = ">=3.7" +python-versions = ">=3.7,<4.0" +groups = ["main"] files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, + {file = "crashtest-0.4.1-py3-none-any.whl", hash = "sha256:8d23eac5fa660409f57472e3851dab7ac18aba459a8d19cbbba86d3d5aecd2a5"}, + {file = "crashtest-0.4.1.tar.gz", hash = "sha256:80d7b1f316ebfbd429f648076d6275c877ba30ba48979de4191714a75266f0ce"}, ] +[[package]] +name = "cryptography" +version = "49.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["main"] +markers = "sys_platform == \"linux\"" +files = [ + {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"}, + {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"}, + {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"}, + {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"}, + {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"}, + {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"}, + {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] + +[[package]] +name = "distlib" +version = "0.4.3" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b"}, + {file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"}, +] + +[[package]] +name = "dotenv" +version = "0.9.9" +description = "Deprecated package" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9"}, +] + +[package.dependencies] +python-dotenv = "*" + +[[package]] +name = "dulwich" +version = "0.22.8" +description = "Python Git Library" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "dulwich-0.22.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:546176d18b8cc0a492b0f23f07411e38686024cffa7e9d097ae20512a2e57127"}, + {file = "dulwich-0.22.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d2434dd72b2ae09b653c9cfe6764a03c25cfbd99fbbb7c426f0478f6fb1100f"}, + {file = "dulwich-0.22.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe8318bc0921d42e3e69f03716f983a301b5ee4c8dc23c7f2c5bbb28581257a9"}, + {file = "dulwich-0.22.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7a0f96a2a87f3b4f7feae79d2ac6b94107d6b7d827ac08f2f331b88c8f597a1"}, + {file = "dulwich-0.22.8-cp310-cp310-win32.whl", hash = "sha256:432a37b25733202897b8d67cdd641688444d980167c356ef4e4dd15a17a39a24"}, + {file = "dulwich-0.22.8-cp310-cp310-win_amd64.whl", hash = "sha256:f3a15e58dac8b8a76073ddca34e014f66f3672a5540a99d49ef6a9c09ab21285"}, + {file = "dulwich-0.22.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0852edc51cff4f4f62976bdaa1d82f6ef248356c681c764c0feb699bc17d5782"}, + {file = "dulwich-0.22.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:826aae8b64ac1a12321d6b272fc13934d8f62804fda2bc6ae46f93f4380798eb"}, + {file = "dulwich-0.22.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7ae726f923057d36cdbb9f4fb7da0d0903751435934648b13f1b851f0e38ea1"}, + {file = "dulwich-0.22.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6987d753227f55cf75ba29a8dab69d1d83308ce483d7a8c6d223086f7a42e125"}, + {file = "dulwich-0.22.8-cp311-cp311-win32.whl", hash = "sha256:7757b4a2aad64c6f1920082fc1fccf4da25c3923a0ae7b242c08d06861dae6e1"}, + {file = "dulwich-0.22.8-cp311-cp311-win_amd64.whl", hash = "sha256:12b243b7e912011c7225dc67480c313ac8d2990744789b876016fb593f6f3e19"}, + {file = "dulwich-0.22.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d81697f74f50f008bb221ab5045595f8a3b87c0de2c86aa55be42ba97421f3cd"}, + {file = "dulwich-0.22.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bff1da8e2e6a607c3cb45f5c2e652739589fe891245e1d5b770330cdecbde41"}, + {file = "dulwich-0.22.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9969099e15b939d3936f8bee8459eaef7ef5a86cd6173393a17fe28ca3d38aff"}, + {file = "dulwich-0.22.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:017152c51b9a613f0698db28c67cf3e0a89392d28050dbf4f4ac3f657ea4c0dc"}, + {file = "dulwich-0.22.8-cp312-cp312-win32.whl", hash = "sha256:ee70e8bb8798b503f81b53f7a103cb869c8e89141db9005909f79ab1506e26e9"}, + {file = "dulwich-0.22.8-cp312-cp312-win_amd64.whl", hash = "sha256:dc89c6f14dcdcbfee200b0557c59ae243835e42720be143526d834d0e53ed3af"}, + {file = "dulwich-0.22.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbade3342376be1cd2409539fe1b901d2d57a531106bbae204da921ef4456a74"}, + {file = "dulwich-0.22.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71420ffb6deebc59b2ce875e63d814509f9c1dc89c76db962d547aebf15670c7"}, + {file = "dulwich-0.22.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a626adbfac44646a125618266a24133763bdc992bf8bd0702910d67e6b994443"}, + {file = "dulwich-0.22.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f1476c9c4e4ede95714d06c4831883a26680e37b040b8b6230f506e5ba39f51"}, + {file = "dulwich-0.22.8-cp313-cp313-win32.whl", hash = "sha256:b2b31913932bb5bd41658dd398b33b1a2d4d34825123ad54e40912cfdfe60003"}, + {file = "dulwich-0.22.8-cp313-cp313-win_amd64.whl", hash = "sha256:7a44e5a61a7989aca1e301d39cfb62ad2f8853368682f524d6e878b4115d823d"}, + {file = "dulwich-0.22.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9cd0c67fb44a38358b9fcabee948bf11044ef6ce7a129e50962f54c176d084e"}, + {file = "dulwich-0.22.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b79b94726c3f4a9e5a830c649376fd0963236e73142a4290bac6bc9fc9cb120"}, + {file = "dulwich-0.22.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16bbe483d663944972e22d64e1f191201123c3b5580fbdaac6a4f66bfaa4fc11"}, + {file = "dulwich-0.22.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e02d403af23d93dc1f96eb2408e25efd50046e38590a88c86fa4002adc9849b0"}, + {file = "dulwich-0.22.8-cp39-cp39-win32.whl", hash = "sha256:8bdd9543a77fb01be704377f5e634b71f955fec64caa4a493dc3bfb98e3a986e"}, + {file = "dulwich-0.22.8-cp39-cp39-win_amd64.whl", hash = "sha256:3b6757c6b3ba98212b854a766a4157b9cb79a06f4e1b06b46dec4bd834945b8e"}, + {file = "dulwich-0.22.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7bb18fa09daa1586c1040b3e2777d38d4212a5cdbe47d384ba66a1ac336fcc4c"}, + {file = "dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2fda8e87907ed304d4a5962aea0338366144df0df60f950b8f7f125871707f"}, + {file = "dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1748cd573a0aee4d530bc223a23ccb8bb5b319645931a37bd1cfb68933b720c1"}, + {file = "dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a631b2309feb9a9631eabd896612ba36532e3ffedccace57f183bb868d7afc06"}, + {file = "dulwich-0.22.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:00e7d9a3d324f9e0a1b27880eec0e8e276ff76519621b66c1a429ca9eb3f5a8d"}, + {file = "dulwich-0.22.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f8aa3de93201f9e3e40198725389aa9554a4ee3318a865f96a8e9bc9080f0b25"}, + {file = "dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e8da9dd8135884975f5be0563ede02179240250e11f11942801ae31ac293f37"}, + {file = "dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc5ce2435fb3abdf76f1acabe48f2e4b3f7428232cadaef9daaf50ea7fa30ee"}, + {file = "dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:982b21cc3100d959232cadb3da0a478bd549814dd937104ea50f43694ec27153"}, + {file = "dulwich-0.22.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6bde2b13a05cc0ec2ecd4597a99896663544c40af1466121f4d046119b874ce3"}, + {file = "dulwich-0.22.8-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6d446cb7d272a151934ad4b48ba691f32486d5267cf2de04ee3b5e05fc865326"}, + {file = "dulwich-0.22.8-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f6338e6cf95cd76a0191b3637dc3caed1f988ae84d8e75f876d5cd75a8dd81a"}, + {file = "dulwich-0.22.8-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e004fc532ea262f2d5f375068101ca4792becb9d4aa663b050f5ac31fda0bb5c"}, + {file = "dulwich-0.22.8-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6bfdbc6fa477dee00d04e22d43a51571cd820cfaaaa886f0f155b8e29b3e3d45"}, + {file = "dulwich-0.22.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ae900c8e573f79d714c1d22b02cdadd50b64286dd7203028f0200f82089e4950"}, + {file = "dulwich-0.22.8-py3-none-any.whl", hash = "sha256:ffc7a02e62b72884de58baaa3b898b7f6427893e79b1289ffa075092efe59181"}, + {file = "dulwich-0.22.8.tar.gz", hash = "sha256:701547310415de300269331abe29cb5717aa1ea377af826bf513d0adfb1c209b"}, +] + +[package.dependencies] +urllib3 = ">=1.25" + [package.extras] -test = ["pytest (>=6)"] +dev = ["mypy (==1.15.0)", "ruff (==0.9.7)"] +fastimport = ["fastimport"] +https = ["urllib3 (>=1.24.1)"] +paramiko = ["paramiko"] +pgp = ["gpg"] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +description = "Fastest Python implementation of JSON schema" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463"}, + {file = "fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de"}, +] + +[package.extras] +devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] + +[[package]] +name = "filelock" +version = "3.29.4" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767"}, + {file = "filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a"}, +] + +[[package]] +name = "findpython" +version = "0.6.3" +description = "A utility to find python versions on your system" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "findpython-0.6.3-py3-none-any.whl", hash = "sha256:a85bb589b559cdf1b87227cc233736eb7cad894b9e68021ee498850611939ebc"}, + {file = "findpython-0.6.3.tar.gz", hash = "sha256:5863ea55556d8aadc693481a14ac4f3624952719efc1c5591abb0b4a9e965c94"}, +] + +[package.dependencies] +packaging = ">=20" [[package]] name = "flake8" @@ -72,6 +648,7 @@ version = "7.1.1" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.8.1" +groups = ["dev"] files = [ {file = "flake8-7.1.1-py2.py3-none-any.whl", hash = "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213"}, {file = "flake8-7.1.1.tar.gz", hash = "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38"}, @@ -82,12 +659,72 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.12.0,<2.13.0" pyflakes = ">=3.2.0,<3.3.0" +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "idna" version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -96,23 +733,156 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "importlib-metadata" +version = "9.0.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version == \"3.11\"" +files = [ + {file = "importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7"}, + {file = "importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +perf = ["ipython"] +test = ["packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + [[package]] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "installer" +version = "0.7.0" +description = "A library for installing Python wheels." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "installer-0.7.0-py3-none-any.whl", hash = "sha256:05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53"}, + {file = "installer-0.7.0.tar.gz", hash = "sha256:a26d3e3116289bb08216e0d0f7d925fcef0b0194eedfa0c944bcaaa106c4b631"}, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +description = "Utility functions for Python class constructs" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"}, + {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"}, +] + +[package.dependencies] +more-itertools = "*" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +description = "Useful decorators and context managers" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535"}, + {file = "jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3"}, +] + +[package.dependencies] +"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.test (>=5.6.0)", "portend", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + +[[package]] +name = "jaraco-functools" +version = "4.5.0" +description = "Functools like those found in stdlib" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4"}, + {file = "jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03"}, +] + +[package.dependencies] +more_itertools = "*" + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + +[[package]] +name = "jeepney" +version = "0.9.0" +description = "Low-level, pure Python DBus protocol wrapper." +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "sys_platform == \"linux\"" +files = [ + {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"}, + {file = "jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"}, +] + +[package.extras] +test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] +trio = ["trio"] + +[[package]] +name = "jmespath" +version = "1.1.0" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"}, + {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, +] + [[package]] name = "jsonrpcserver" version = "5.0.9" description = "Process JSON-RPC requests" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "jsonrpcserver-5.0.9.tar.gz", hash = "sha256:a71fb2cfa18541c80935f60987f92755d94d74141248c7438847b96eee5c4482"}, ] @@ -130,6 +900,7 @@ version = "4.23.0" description = "An implementation of JSON Schema validation for Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, @@ -137,7 +908,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -151,6 +922,7 @@ version = "2023.12.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, @@ -159,32 +931,70 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "keyring" +version = "25.7.0" +description = "Store and access your passwords safely." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f"}, + {file = "keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b"}, +] + +[package.dependencies] +importlib_metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} +"jaraco.classes" = "*" +"jaraco.context" = "*" +"jaraco.functools" = "*" +jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +completion = ["shtab (>=1.1.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["pyfakefs", "pytest (>=6,!=8.1.*)"] +type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] + [[package]] name = "lf-toolkit" version = "0.0.1" description = "" optional = false -python-versions = "^3.9" +python-versions = "^3.11" +groups = ["main"] files = [] develop = false [package.dependencies] -anyio = "4.4.0" -jsonrpcserver = "5.0.9" +anyio = "4.6.0" +boto3 = "^1.42.36" +dotenv = "^0.9.9" +jsonrpcserver = ">=5.0.9" +pillow = "^12.1.0" +poetry-plugin-export = "^1.9.0" +pydantic = "^2.0" +pytest-asyncio = "^1.2.0" pywin32 = {version = "^306", optional = true, markers = "sys_platform == \"win32\""} -sympy = "1.12" +requests = "^2.32.5" +sympy = ">=1.12,<2.0" ujson = "5.10.0" [package.extras] -http = ["fastapi (>=0.111.0,<0.112.0)"] -ipc = ["pywin32 (>=306,<307)"] -parsing = ["antlr4-python3-runtime (==4.13.1)", "lark (==1.1.9)", "latex2sympy @ git+https://github.com/purdue-tlt/latex2sympy.git@1.11.2"] +http = ["fastapi (>=0.115.0,<0.116.0)"] +ipc = ["pywin32 (>=306,<307) ; sys_platform == \"win32\""] +parsing = ["antlr4-python3-runtime (==4.13.2)", "lark (==1.2.2)", "latex2sympy @ git+https://github.com/purdue-tlt/latex2sympy.git@1.12.0"] [package.source] type = "git" url = "https://github.com/lambda-feedback/toolkit-python.git" reference = "main" -resolved_reference = "19704127e57f68fcad22cd9757c8239ca7d88ca2" +resolved_reference = "713f13fec11a1d81668fefc3199942f604ba1505" [[package]] name = "mccabe" @@ -192,17 +1002,31 @@ version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192"}, + {file = "more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d"}, +] + [[package]] name = "mpmath" version = "1.3.0" description = "Python library for arbitrary-precision floating-point arithmetic" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"}, {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"}, @@ -211,15 +1035,174 @@ files = [ [package.extras] develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] docs = ["sphinx"] -gmpy = ["gmpy2 (>=2.1.0a4)"] +gmpy = ["gmpy2 (>=2.1.0a4) ; platform_python_implementation != \"PyPy\""] tests = ["pytest (>=4.6)"] +[[package]] +name = "msgpack" +version = "1.2.0" +description = "MessagePack serializer" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "msgpack-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ed8c9495a0f12d17a2b4b69e23f895b88f26aabe40911c86594d3fbddecfff08"}, + {file = "msgpack-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d7384859c90b45a28a4b31aa50b49cca84504c9f27df459cea6e072627650dcb"}, + {file = "msgpack-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63b35e8e65f04ff7ad5c9c70885da587c74f51e4b4eb3db624eac6d250e8cf59"}, + {file = "msgpack-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004c5a02acd3eca4e15e1ae7b461c32e3711105a28b1ad78be2f6facff4c523"}, + {file = "msgpack-1.2.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e2032dacb0a973fcbf7bd088415a369dae31c5af40e199d234806be22e86765"}, + {file = "msgpack-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c1feb100651fbe4b39826207cb20af065dfbfbfa43b1bafd7eaa2252abf7acfd"}, + {file = "msgpack-1.2.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:82487709d4c597d252311a65370220675fb1cc859e7da9269a3060c03ac02cf6"}, + {file = "msgpack-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0268c67a74f5f913f545a0fdbbfaa3f6ebcf23b4c3209bb99704a2ea87e13f90"}, + {file = "msgpack-1.2.0-cp310-cp310-win32.whl", hash = "sha256:7df87173b0e13ddd134919731f13525dbbf75204145597decf1cb86887ebb492"}, + {file = "msgpack-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:6371edb47788fbfd8a22016f9a97b5616dd9849bc50abcbb8e82d38f71efa096"}, + {file = "msgpack-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ec35cd3f127f50806aa10c3f74bf27b749f13ddf1d2217964ada8f38042d1653"}, + {file = "msgpack-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:317eb298297121bfad9173d748124a04a36af27b6ac39c2bbc1db1ce57608dcf"}, + {file = "msgpack-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50fe6434de89073273026dd032a62e8b63f8857a261d7a2df5b07c9e72f3a8f7"}, + {file = "msgpack-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106c6d333ff3d4eda075b7d4b9695d1752c5bcc635e40d0dbaf4e276c9ed80e1"}, + {file = "msgpack-1.2.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:67055a611e871cb1bd0acb732f2e9f64ca8155ca0bba1d0a5bb362e7209e5541"}, + {file = "msgpack-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ceec7f8e633d5a4b4a32b0416bef90ee3cd1017ea36247f705e523072e576119"}, + {file = "msgpack-1.2.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7ec5851160a3c2c0f77d68ddec620318cd8e7d88d94f9c058190e8ce0dfa1d31"}, + {file = "msgpack-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd7140f7b09dbe1984a0dff3189375d840247e3e4cf4ac45c5a499b3b599c8d2"}, + {file = "msgpack-1.2.0-cp311-cp311-win32.whl", hash = "sha256:cbfd54018d386da0951c7a2be13de0f58559d251313e613b2155e52ed1cbd8f1"}, + {file = "msgpack-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:653373c4614c31463ba486a67776e4bb396af289921bd5353e209534b71467fa"}, + {file = "msgpack-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:7a260aea1e5e7d6c7f1d9284c7360d29021627b61dc4dd7df144b81210810537"}, + {file = "msgpack-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2d6047ccd11a12c96a69f2bfe026471abef67334c3d0494a93e5310e45140a2"}, + {file = "msgpack-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0347e3ac0dfee99086d3b68fe959da3f5f657c0019ddbaeaaa259a85f8603422"}, + {file = "msgpack-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25552ff1f2ff3dc8333e27eabb94f702da5929ed0e07969688194a3e9f12e151"}, + {file = "msgpack-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0d94420d9d52c56568159a69200af7e45eadb29615fa9d09fada140de1c38c7"}, + {file = "msgpack-1.2.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d16e1f2db4a9eebc07b7cc91898d71e710f2eed8358711a605fee802caff8923"}, + {file = "msgpack-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9cb2e700e85f1e27bbb5c9de6cc1c9a4bc5ac64d5404bdcbcb37a0dc7a947a3"}, + {file = "msgpack-1.2.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:717d0b166dd176a5f786aeafff081f6439680acf5af193eb63e6266c12b04d3d"}, + {file = "msgpack-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e87c7a21654d18111eb1a89bd5c42baba42e61887365d9e89585e112b4203f9e"}, + {file = "msgpack-1.2.0-cp312-cp312-win32.whl", hash = "sha256:967e0c891f5f23ab65762f2e5dc95922759c79f1ef99ef4c7e1fdd863e0d0af9"}, + {file = "msgpack-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:6c23e33cee28dcffa112ae205661da4636fd7b06bd9ad1559a890623b92d060b"}, + {file = "msgpack-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:6eeb771571f63f68045433b1a35c0256b946f31ed62f006997e40b8ad8b735af"}, + {file = "msgpack-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3a1d30df1f302f2b7a7404afbac2ab76d510036c34cf34dffb01f704a7288e45"}, + {file = "msgpack-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:581e317112260d8ca488d490cad9290a5682276f309c41c7de237a85ed8799c8"}, + {file = "msgpack-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6827d12eacc16873eba62408a1b7bbe8ecfb4a8f7ed78a631ae9bae6ad43cf2"}, + {file = "msgpack-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a186027e4279efa4c8bf06ce30605498d7d0d3af0fba0b9799dce85a3fd4a93c"}, + {file = "msgpack-1.2.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a96142c14a11cf1a509e8b9aaf72858a3b742b7613e095ce646913e88ce7bd99"}, + {file = "msgpack-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50c220579b68a6085b95408b2eaa486b259520f55d8e363ddc9b5d7ba5a6ac6d"}, + {file = "msgpack-1.2.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4dcb9d12ab100ecacdfaaf37a3d72fe8392eacc7054afc1916b12d1b747c8446"}, + {file = "msgpack-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a804727188ab0ebb237fadb303b743f04925a69d8c3247292d1e33e679767c15"}, + {file = "msgpack-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1a1ac6ae1fe23298f79380e7b144c8a454e5d05616b0096584f353ba2d750114"}, + {file = "msgpack-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:1c3c80949d79578f9dc85fd9fb91edfe6694e8a729cd5744634d59d8455fdde3"}, + {file = "msgpack-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fcf8f76fa587c2395fd0057c7232dbf071241f9ad280b235adb7ab585289989e"}, + {file = "msgpack-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f854fa1a8b55d75d82ef9a905d9cdbeffdf7897c088f6020bd221867da5e56a5"}, + {file = "msgpack-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e90df581f80f53b372d5d9d9349078d729851a3a0d0bd74f53ccb598d01e45b8"}, + {file = "msgpack-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b276ed50d8ac75d1f134a433ae79af8557d0fa25ee5b4737da533dfc2ce382e8"}, + {file = "msgpack-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:544d972459c92aa32e63b800d07c2d9cf2734a3be29cee3a0b478a622850e9f5"}, + {file = "msgpack-1.2.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a070147cc2cf6b8a891734e0f5c8fe8f70ed8739ab30ba140b058005a6e86af4"}, + {file = "msgpack-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7685e23b0f51745a751629c31713fbefdef8896b31b2bb38299dfa4ae6c0740c"}, + {file = "msgpack-1.2.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b9204daeee8d91a7ae5acf2d2a8e3983be9a3025f38aa21bfaefbd7eea84a7dc"}, + {file = "msgpack-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bfc057248609742ebbabf6bcd27fea4fd99c4980584e613c168c9b002318298f"}, + {file = "msgpack-1.2.0-cp314-cp314-win32.whl", hash = "sha256:a3faa7edf2388337ae849239878e92f0298b4dab4488e4f1834062f9d0c410c9"}, + {file = "msgpack-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:1a3effc392a57744e4681e55d05f97d5ee7b598747d718340a9b4b8a970c40e1"}, + {file = "msgpack-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:56a318f7df6bec7b40928d6b0519961f20a510d8baabf6baa393a70444588f0a"}, + {file = "msgpack-1.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:afa4a65ab2097795e771a74a3a81ea49534aaeba874eaf426a3332268e045ae6"}, + {file = "msgpack-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:409550770632bb28daa70a11d0ed5763f7db38f40b06f7db9f11dd2794d01102"}, + {file = "msgpack-1.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf47e3cd11ce044965a9736a322afdd390b31ed602d1c1b10211d1a841f1d587"}, + {file = "msgpack-1.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:204bc9f5d6e59c1718c0a4a84fc8ff71b5b4562faac257c1a68bca611ecf9b72"}, + {file = "msgpack-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:610154307b27267266368bc1d1c7bb8aeb71da7be9356d403cb2442d9e6399f5"}, + {file = "msgpack-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6799f157bb63e79f11e2e590cfdb28423fc18dd60c270c3914b5b4586ae36f7e"}, + {file = "msgpack-1.2.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:72bd844902cf0a5ac3af2ef742f253cd0b1e5bcd184f49b4fb9a6a1f7bf305e8"}, + {file = "msgpack-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3c0bd450f78d0d81722c80da6cdbf674a856967870a9db2f6c4debc4d8b3c67c"}, + {file = "msgpack-1.2.0-cp314-cp314t-win32.whl", hash = "sha256:378caf74c4c718dfc17590ce68a6d710ed398ff6fcf08237de23b77755730b55"}, + {file = "msgpack-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:553b42598165c4dd3235994fd6e4b0dfb1ce5f3fd33d94ba9609442643015f38"}, + {file = "msgpack-1.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2825bb1da548d214ab8a810906b7dd69a10f3838b615a2cc46e5172d3cb44f6e"}, + {file = "msgpack-1.2.0.tar.gz", hash = "sha256:8e17af38197bf58e7e819041678f6178f4491493f5b8c8580414f40f7c2c3c41"}, +] + +[[package]] +name = "numpy" +version = "2.4.6" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538"}, + {file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47"}, + {file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93"}, + {file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8"}, + {file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6"}, + {file = "numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8"}, + {file = "numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147"}, + {file = "numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698"}, + {file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f"}, + {file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853"}, + {file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a"}, + {file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2"}, + {file = "numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45"}, + {file = "numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751"}, + {file = "numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3"}, + {file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b"}, + {file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089"}, + {file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a"}, + {file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605"}, + {file = "numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91"}, + {file = "numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359"}, + {file = "numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997"}, + {file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20"}, + {file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d"}, + {file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67"}, + {file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd"}, + {file = "numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab"}, + {file = "numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75"}, + {file = "numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096"}, + {file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b"}, + {file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8"}, + {file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402"}, + {file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb"}, + {file = "numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1"}, + {file = "numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261"}, + {file = "numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e"}, + {file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43"}, + {file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e"}, + {file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895"}, + {file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4"}, + {file = "numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063"}, + {file = "numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627"}, + {file = "numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73"}, + {file = "numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda"}, +] + [[package]] name = "oslash" version = "0.6.3" description = "OSlash (Ø) for Python 3.8+" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "OSlash-0.6.3-py3-none-any.whl", hash = "sha256:89b978443b7db3ac2666106bdc3680add3c886a6d8fcdd02fd062af86d29494f"}, {file = "OSlash-0.6.3.tar.gz", hash = "sha256:868aeb58a656f2ed3b73d9dd6abe387b20b74fc9413d3e8653b615b15bf728f3"}, @@ -234,17 +1217,176 @@ version = "24.1" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "pbs-installer" +version = "2025.12.17" +description = "Installer for Python Build Standalone" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pbs_installer-2025.12.17-py3-none-any.whl", hash = "sha256:1a899ac5af9ca4c59a7a7944ec3fcf7ad7e40d5684b12eadcfbeee7c59d44123"}, + {file = "pbs_installer-2025.12.17.tar.gz", hash = "sha256:cf32043fadd168c17a1b18c1c3f801090281bd5c9ce101e2deb7e0e51c8279dd"}, +] + +[package.dependencies] +httpx = {version = ">=0.27.0,<1", optional = true, markers = "extra == \"download\""} +zstandard = {version = ">=0.21.0", optional = true, markers = "extra == \"install\""} + +[package.extras] +all = ["pbs-installer[download,install]"] +download = ["httpx (>=0.27.0,<1)"] +install = ["zstandard (>=0.21.0)"] + +[[package]] +name = "pillow" +version = "12.2.0" +description = "Python Imaging Library (fork)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"}, + {file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"}, + {file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"}, + {file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"}, + {file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"}, + {file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"}, + {file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"}, + {file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"}, + {file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"}, + {file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"}, + {file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"}, + {file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"}, + {file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"}, + {file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"}, + {file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"}, + {file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"}, + {file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"}, + {file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"}, + {file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"}, + {file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"}, + {file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"}, + {file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"}, + {file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"}, + {file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +xmp = ["defusedxml"] + +[[package]] +name = "pkginfo" +version = "1.12.1.2" +description = "Query metadata from sdists / bdists / installed packages." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343"}, + {file = "pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b"}, +] + +[package.extras] +testing = ["pytest", "pytest-cov", "wheel"] + +[[package]] +name = "platformdirs" +version = "4.10.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"}, + {file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"}, +] + [[package]] name = "pluggy" version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -254,34 +1396,260 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "poetry" +version = "2.1.4" +description = "Python dependency management and packaging made easy." +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "poetry-2.1.4-py3-none-any.whl", hash = "sha256:0019b64d33fed9184a332f7fad60ca47aace4d6a0e9c635cdea21b76e96f32ce"}, + {file = "poetry-2.1.4.tar.gz", hash = "sha256:bed4af5fc87fb145258ac5b1dae77de2cd7082ec494e3b2f66bca0f477cbfc5c"}, +] + +[package.dependencies] +build = ">=1.2.1,<2.0.0" +cachecontrol = {version = ">=0.14.0,<0.15.0", extras = ["filecache"]} +cleo = ">=2.1.0,<3.0.0" +dulwich = ">=0.22.6,<0.23.0" +fastjsonschema = ">=2.18.0,<3.0.0" +findpython = ">=0.6.2,<0.7.0" +installer = ">=0.7.0,<0.8.0" +keyring = ">=25.1.0,<26.0.0" +packaging = ">=24.0" +pbs-installer = {version = ">=2025.1.6,<2026.0.0", extras = ["download", "install"]} +pkginfo = ">=1.12,<2.0" +platformdirs = ">=3.0.0,<5" +poetry-core = "2.1.3" +pyproject-hooks = ">=1.0.0,<2.0.0" +requests = ">=2.26,<3.0" +requests-toolbelt = ">=1.0.0,<2.0.0" +shellingham = ">=1.5,<2.0" +tomlkit = ">=0.11.4,<1.0.0" +trove-classifiers = ">=2022.5.19" +virtualenv = ">=20.26.6,<20.33.0" +xattr = {version = ">=1.0.0,<2.0.0", markers = "sys_platform == \"darwin\""} + +[[package]] +name = "poetry-core" +version = "2.1.3" +description = "Poetry PEP 517 Build Backend" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "poetry_core-2.1.3-py3-none-any.whl", hash = "sha256:2c704f05016698a54ca1d327f46ce2426d72eaca6ff614132c8477c292266771"}, + {file = "poetry_core-2.1.3.tar.gz", hash = "sha256:0522a015477ed622c89aad56a477a57813cace0c8e7ff2a2906b7ef4a2e296a4"}, +] + +[[package]] +name = "poetry-plugin-export" +version = "1.10.0" +description = "Poetry plugin to export the dependencies to various formats" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "poetry_plugin_export-1.10.0-py3-none-any.whl", hash = "sha256:fb9b61332718fb91c8d9399edb00fd73cf99de37506adf617fbdf55079bab223"}, + {file = "poetry_plugin_export-1.10.0.tar.gz", hash = "sha256:26ef9df924cd874a825d92d6bc01a5a869a4a28d2f2ebba61d3b5b19c60120f0"}, +] + +[package.dependencies] +poetry = ">=2.1.0,<3.0.0" +poetry-core = ">=2.1.0,<3.0.0" +tomlkit = ">=0.11.4,<1.0.0" + [[package]] name = "pycodestyle" version = "2.12.1" description = "Python style guide checker" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3"}, {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"}, ] +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "(sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\") and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + +[[package]] +name = "pydantic" +version = "2.11.10" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a"}, + {file = "pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.33.2" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pyflakes" version = "3.2.0" description = "passive checker of Python programs" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, ] +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +description = "Wrappers to call pyproject.toml-based build backend hooks." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913"}, + {file = "pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8"}, +] + [[package]] name = "pytest" version = "8.3.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, @@ -289,21 +1657,71 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=1.5,<2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, + {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, +] + +[package.dependencies] +pytest = ">=8.2,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "1.2.2" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, + {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + [[package]] name = "pywin32" version = "306" description = "Python for Window Extensions" optional = false python-versions = "*" +groups = ["main"] +markers = "sys_platform == \"win32\"" files = [ {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, @@ -321,12 +1739,122 @@ files = [ {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +optional = false +python-versions = ">=3.6" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, + {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.5" +description = "rapid fuzzy string matching" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "rapidfuzz-3.14.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:071d96b957a33b9296b9284b6350a0fb6d030b154a04efd7c15e56b98b79a517"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667f40fe9c81ad129b198d236881b00dd9e8314d9cc72d03c3e16bdfe5879051"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9fff308486bbd2c8c24f25e8e152c7594d3fe8db265a2d6a1ce24d58671127f"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dfa552338f51aec280f17b02d28bace1e162d1a84ccd80e3339a57f98aedb56b"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:068b3e965ca9d9ee4debe40001ae7c3938ba646308afd33cf0c66618147db65c"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88b7d31ff1cc5e9bc0e4406e6b1fa00b6d37163d50bb58091e9b976ff1129faa"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eacb434410b8d9ca99a8d42352ef085cf423e3c76c1f0b86be2fcba3bff2952c"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:649712823f3abcdc48427147a5384fac15623ba435d0013959b52e6462521397"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-win32.whl", hash = "sha256:13cb79c23ef5516e4c4e3830877be8b19aa75203636be1163d690d37803f6504"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-win_amd64.whl", hash = "sha256:f2073495a7f9b75e57e600747ac09510d67683fd64d3228e009740b7ef88f9fe"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-win_arm64.whl", hash = "sha256:8166efddea49fdbc61185559f47593239e4794fd7c9044dd5a789d1a90af852d"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf"}, + {file = "rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e"}, +] + +[package.extras] +all = ["numpy"] + [[package]] name = "referencing" version = "0.35.1" description = "JSON Referencing + Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, @@ -336,12 +1864,50 @@ files = [ attrs = ">=22.2.0" rpds-py = ">=0.7.0" +[[package]] +name = "requests" +version = "2.34.2" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + [[package]] name = "rpds-py" version = "0.20.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "rpds_py-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2"}, {file = "rpds_py-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f"}, @@ -448,12 +2014,72 @@ files = [ {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"}, ] +[[package]] +name = "s3transfer" +version = "0.18.0" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6"}, + {file = "s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd"}, +] + +[package.dependencies] +botocore = ">=1.37.4,<2.0a0" + +[package.extras] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] + +[[package]] +name = "secretstorage" +version = "3.5.0" +description = "Python bindings to FreeDesktop.org Secret Service API" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "sys_platform == \"linux\"" +files = [ + {file = "secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137"}, + {file = "secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be"}, +] + +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sniffio" version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -465,6 +2091,7 @@ version = "1.12" description = "Computer algebra system (CAS) in Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "sympy-1.12-py3-none-any.whl", hash = "sha256:c3588cd4295d0c0f603d0f2ae780587e64e2efeedb3521e46b9bb1d08d184fa5"}, {file = "sympy-1.12.tar.gz", hash = "sha256:ebf595c8dac3e0fdc4152c51878b498396ec7f30e7a914d6071e674d49420fb8"}, @@ -474,14 +2101,27 @@ files = [ mpmath = ">=0.19" [[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" +name = "tomlkit" +version = "0.15.0" +description = "Style preserving TOML library" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738"}, + {file = "tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3"}, +] + +[[package]] +name = "trove-classifiers" +version = "2026.6.1.19" +description = "Canonical source for classifiers on PyPI (pypi.org)." +optional = false +python-versions = "*" +groups = ["main"] files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, + {file = "trove_classifiers-2026.6.1.19-py3-none-any.whl", hash = "sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3"}, + {file = "trove_classifiers-2026.6.1.19.tar.gz", hash = "sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745"}, ] [[package]] @@ -490,17 +2130,34 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + [[package]] name = "ujson" version = "5.10.0" description = "Ultra fast JSON encoder and decoder for Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "ujson-5.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2601aa9ecdbee1118a1c2065323bda35e2c5a2cf0797ef4522d485f9d3ef65bd"}, {file = "ujson-5.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:348898dd702fc1c4f1051bc3aacbf894caa0927fe2c53e68679c073375f732cf"}, @@ -582,7 +2239,246 @@ files = [ {file = "ujson-5.10.0.tar.gz", hash = "sha256:b3cd8f3c5d8c7738257f1018880444f7b7d9b66232c64649f562d7ba86ad4bc1"}, ] +[[package]] +name = "urllib3" +version = "2.7.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "virtualenv" +version = "20.32.0" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56"}, + {file = "virtualenv-20.32.0.tar.gz", hash = "sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] + +[[package]] +name = "xattr" +version = "1.3.0" +description = "Python wrapper for extended filesystem attributes" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "sys_platform == \"darwin\"" +files = [ + {file = "xattr-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a80c4617e08670cdc3ba71f1dbb275c1627744c5c3641280879cb3bc95a07237"}, + {file = "xattr-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51cdaa359f5cd2861178ae01ea3647b56dbdfd98e724a8aa3c04f77123b78217"}, + {file = "xattr-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2fea070768d7d2d25797817bea93bf0a6fda6449e88cfee8bb3d75de9ed11c7b"}, + {file = "xattr-1.3.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69bca34be2d7a928389aff4e32f27857e1c62d04c91ec7c1519b1636870bd58f"}, + {file = "xattr-1.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05f8e068409742d246babba60cff8310b2c577745491f498b08bf068e0c867a3"}, + {file = "xattr-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bbd06987102bc11f5cbd08b15d1029832b862cf5bc61780573fc0828812f01ca"}, + {file = "xattr-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b8589744116d2c37928b771c50383cb281675cd6dcfd740abfab6883e3d4af85"}, + {file = "xattr-1.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:331a51bf8f20c27822f44054b0d760588462d3ed472d5e52ba135cf0bea510e8"}, + {file = "xattr-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:196360f068b74fa0132a8c6001ce1333f095364b8f43b6fd8cdaf2f18741ef89"}, + {file = "xattr-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:405d2e4911d37f2b9400fa501acd920fe0c97fe2b2ec252cb23df4b59c000811"}, + {file = "xattr-1.3.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ae3a66ae1effd40994f64defeeaa97da369406485e60bfb421f2d781be3b75d"}, + {file = "xattr-1.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:69cd3bfe779f7ba87abe6473fdfa428460cf9e78aeb7e390cfd737b784edf1b5"}, + {file = "xattr-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c5742ca61761a99ae0c522f90a39d5fb8139280f27b254e3128482296d1df2db"}, + {file = "xattr-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a04ada131e9bdfd32db3ab1efa9f852646f4f7c9d6fde0596c3825c67161be3"}, + {file = "xattr-1.3.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dd4e63614722d183e81842cb237fd1cc978d43384166f9fe22368bfcb187ebe5"}, + {file = "xattr-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:995843ef374af73e3370b0c107319611f3cdcdb6d151d629449efecad36be4c4"}, + {file = "xattr-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa23a25220e29d956cedf75746e3df6cc824cc1553326d6516479967c540e386"}, + {file = "xattr-1.3.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b4345387087fffcd28f709eb45aae113d911e1a1f4f0f70d46b43ba81e69ccdd"}, + {file = "xattr-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe92bb05eb849ab468fe13e942be0f8d7123f15d074f3aba5223fad0c4b484de"}, + {file = "xattr-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c42ef5bdac3febbe28d3db14d3a8a159d84ba5daca2b13deae6f9f1fc0d4092"}, + {file = "xattr-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2aaa5d66af6523332189108f34e966ca120ff816dfa077ca34b31e6263f8a236"}, + {file = "xattr-1.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:937d8c91f6f372788aff8cc0984c4be3f0928584839aaa15ff1c95d64562071c"}, + {file = "xattr-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e470b3f15e9c3e263662506ff26e73b3027e1c9beac2cbe9ab89cad9c70c0495"}, + {file = "xattr-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f2238b2a973fcbf5fefa1137db97c296d27f4721f7b7243a1fac51514565e9ec"}, + {file = "xattr-1.3.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f32bb00395371f4a3bed87080ae315b19171ba114e8a5aa403a2c8508998ce78"}, + {file = "xattr-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:78df56bfe3dd4912548561ed880225437d6d49ef082fe6ccd45670810fa53cfe"}, + {file = "xattr-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:864c34c14728f21c3ef89a9f276d75ae5e31dd34f48064e0d37e4bf0f671fc6e"}, + {file = "xattr-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1fd185b3f01121bd172c98b943f9341ca3b9ea6c6d3eb7fe7074723614d959ff"}, + {file = "xattr-1.3.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:630c85020282bd0bcb72c3d031491c4e91d7f29bb4c094ebdfb9db51375c5b07"}, + {file = "xattr-1.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:95f1e14a4d9ca160b4b78c527bf2bac6addbeb0fd9882c405fc0b5e3073a8752"}, + {file = "xattr-1.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:88557c0769f64b1d014aada916c9630cfefa38b0be6c247eae20740d2d8f7b47"}, + {file = "xattr-1.3.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6992eb5da32c0a1375a9eeacfab15c66eebc8bd34be63ebd1eae80cc2f8bf03"}, + {file = "xattr-1.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:da5954424099ca9d402933eaf6112c29ddde26e6da59b32f0bf5a4e35eec0b28"}, + {file = "xattr-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:726b4d0b66724759132cacdcd84a5b19e00b0cdf704f4c2cf96d0c08dc5eaeb5"}, + {file = "xattr-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:928c49ceb0c70fc04732e46fa236d7c8281bfc3db1b40875e5f548bb14d2668c"}, + {file = "xattr-1.3.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f3bef26fd2d5d7b17488f4cc4424a69894c5a8ed71dd5f657fbbf69f77f68a51"}, + {file = "xattr-1.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:64f1fb511f8463851e0d97294eb0e0fde54b059150da90582327fb43baa1bb92"}, + {file = "xattr-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1e6c216927b16fd4b72df655d5124b69b2a406cb3132b5231179021182f0f0d1"}, + {file = "xattr-1.3.0-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c0d9ab346cdd20539afddf2f9e123efee0fe8d54254d9fc580b4e2b4e6d77351"}, + {file = "xattr-1.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2c5e7ba0e893042deef4e8638db7a497680f587ac7bd6d68925f29af633dfa6b"}, + {file = "xattr-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e0dabb39596d8d7b83d6f9f7fa30be68cf15bfb135cb633e2aad9887d308a32"}, + {file = "xattr-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5eeaa944516b7507ec51456751334b4880e421de169bbd067c4f32242670d606"}, + {file = "xattr-1.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:03712f84e056dcd23c36db03a1f45417a26eef2c73d47c2c7d425bf932601587"}, + {file = "xattr-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45f85233a51c71659969ce364abe6bd0c9048a302b7fcdbea675dc63071e47ff"}, + {file = "xattr-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fefcf20d040e79ec3bf6e7dc0fdcfd972f70f740d5a69ed67b20c699bb9cea"}, + {file = "xattr-1.3.0-cp39-cp39-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9e68a02adde8a5f8675be5e8edc837eb6fdbe214a6ee089956fae11d633c0e51"}, + {file = "xattr-1.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:50c12d92f5214b0416cf4b4fafcd02dca5434166657553b74b8ba6abc66cb4b4"}, + {file = "xattr-1.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c69999ed70411ac2859f1f8c918eb48a6fd2a71ef41dc03ee846f69e2200bb2"}, + {file = "xattr-1.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b3cf29da6840eb94b881eab692ae83b1421c9c15a0cd92ffb97a0696ceac8cac"}, + {file = "xattr-1.3.0.tar.gz", hash = "sha256:30439fabd7de0787b27e9a6e1d569c5959854cb322f64ce7380fedbfa5035036"}, +] + +[package.dependencies] +cffi = ">=1.16.0" + +[package.extras] +test = ["pytest"] + +[[package]] +name = "zipp" +version = "4.1.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version == \"3.11\"" +files = [ + {file = "zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f"}, + {file = "zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + +[[package]] +name = "zstandard" +version = "0.25.0" +description = "Zstandard bindings for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd"}, + {file = "zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7"}, + {file = "zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550"}, + {file = "zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d"}, + {file = "zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b"}, + {file = "zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0"}, + {file = "zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74"}, + {file = "zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa"}, + {file = "zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e"}, + {file = "zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c"}, + {file = "zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f"}, + {file = "zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431"}, + {file = "zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a"}, + {file = "zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc"}, + {file = "zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6"}, + {file = "zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7"}, + {file = "zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4"}, + {file = "zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2"}, + {file = "zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137"}, + {file = "zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b"}, + {file = "zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00"}, + {file = "zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64"}, + {file = "zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea"}, + {file = "zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb"}, + {file = "zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a"}, + {file = "zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa"}, + {file = "zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd"}, + {file = "zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01"}, + {file = "zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9"}, + {file = "zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94"}, + {file = "zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1"}, + {file = "zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f"}, + {file = "zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea"}, + {file = "zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e"}, + {file = "zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551"}, + {file = "zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf"}, + {file = "zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09"}, + {file = "zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5"}, + {file = "zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049"}, + {file = "zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3"}, + {file = "zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f"}, + {file = "zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c"}, + {file = "zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439"}, + {file = "zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043"}, + {file = "zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859"}, + {file = "zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0"}, + {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7"}, + {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2"}, + {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344"}, + {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c"}, + {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088"}, + {file = "zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12"}, + {file = "zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2"}, + {file = "zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d"}, + {file = "zstandard-0.25.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b9af1fe743828123e12b41dd8091eca1074d0c1569cc42e6e1eee98027f2bbd0"}, + {file = "zstandard-0.25.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b14abacf83dfb5c25eb4e4a79520de9e7e205f72c9ee7702f91233ae57d33a2"}, + {file = "zstandard-0.25.0-cp39-cp39-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:a51ff14f8017338e2f2e5dab738ce1ec3b5a851f23b18c1ae1359b1eecbee6df"}, + {file = "zstandard-0.25.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3b870ce5a02d4b22286cf4944c628e0f0881b11b3f14667c1d62185a99e04f53"}, + {file = "zstandard-0.25.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:05353cef599a7b0b98baca9b068dd36810c3ef0f42bf282583f438caf6ddcee3"}, + {file = "zstandard-0.25.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19796b39075201d51d5f5f790bf849221e58b48a39a5fc74837675d8bafc7362"}, + {file = "zstandard-0.25.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53e08b2445a6bc241261fea89d065536f00a581f02535f8122eba42db9375530"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1f3689581a72eaba9131b1d9bdbfe520ccd169999219b41000ede2fca5c1bfdb"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d8c56bb4e6c795fc77d74d8e8b80846e1fb8292fc0b5060cd8131d522974b751"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:53f94448fe5b10ee75d246497168e5825135d54325458c4bfffbaafabcc0a577"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c2ba942c94e0691467ab901fc51b6f2085ff48f2eea77b1a48240f011e8247c7"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:07b527a69c1e1c8b5ab1ab14e2afe0675614a09182213f21a0717b62027b5936"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:51526324f1b23229001eb3735bc8c94f9c578b1bd9e867a0a646a3b17109f388"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89c4b48479a43f820b749df49cd7ba2dbc2b1b78560ecb5ab52985574fd40b27"}, + {file = "zstandard-0.25.0-cp39-cp39-win32.whl", hash = "sha256:1cd5da4d8e8ee0e88be976c294db744773459d51bb32f707a0f166e5ad5c8649"}, + {file = "zstandard-0.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:37daddd452c0ffb65da00620afb8e17abd4adaae6ce6310702841760c2c26860"}, + {file = "zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b"}, +] + +[package.extras] +cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b0) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""] + [metadata] -lock-version = "2.0" -python-versions = "^3.9" -content-hash = "c5477d016b68fbb728e7211abafd67e0f8d2a0a8c4e5d81c7c39f68105682c25" +lock-version = "2.1" +python-versions = "^3.11" +content-hash = "2ca77bf0f8acc4673958514a8043a5d543bd6029389a4130956cb33a2b21fc63" diff --git a/pyproject.toml b/pyproject.toml index 17de985..91c9adc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,11 +11,12 @@ evaluation_function = "evaluation_function.main:main" evaluation_function_dev = "evaluation_function.dev:dev" [tool.poetry.dependencies] -python = "^3.9" +python = "^3.11" typing_extensions = "^4.12.2" lf_toolkit = { git = "https://github.com/lambda-feedback/toolkit-python.git", branch = "main", extras = [ "ipc", ] } +numpy = "^2.4.6" [tool.poetry.group.dev.dependencies] pytest = "^8.2.2" From 2a23814b1117eb56b292a5f2186a4e9719982b4e Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Mon, 15 Jun 2026 22:20:15 +0100 Subject: [PATCH 08/22] edit distance algorithm --- notebooks/Note_alignment.ipynb | 496 +++++++++++++++++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 notebooks/Note_alignment.ipynb diff --git a/notebooks/Note_alignment.ipynb b/notebooks/Note_alignment.ipynb new file mode 100644 index 0000000..686bf5b --- /dev/null +++ b/notebooks/Note_alignment.ipynb @@ -0,0 +1,496 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 5, + "id": "96c2775c", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from typing import Any\n", + "from lf_toolkit.evaluation import Result, Params" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "a6822f11", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import json\n", + "\n", + "cwd = os.getcwd()\n", + "dir = os.path.dirname(cwd)\n", + "reference_path = os.path.join(dir, \"data\", \"referenceMIDI.json\")\n", + "response_path = os.path.join(dir, \"data\", \"responseMIDI.json\")\n", + "\n", + "with open(reference_path) as f1:\n", + " reference = json.load(f1)\n", + "\n", + "with open(response_path) as f2:\n", + " response = json.load(f2)" + ] + }, + { + "cell_type": "markdown", + "id": "8d3bba8e", + "metadata": {}, + "source": [ + "# Note Alignment techniques" + ] + }, + { + "cell_type": "markdown", + "id": "519ed38f", + "metadata": {}, + "source": [ + "## DTW" + ] + }, + { + "cell_type": "markdown", + "id": "a900434b", + "metadata": {}, + "source": [ + "The goal of the note alignment is to find if the student played any missing or extra notes, and which note is missing/extra.\n", + "\n", + "\n", + "Based on the findings during the project plan phase, DTW is commonly used for alignment. The algorithm can be found in this book (Chpater 3.2 Dynamic Time Warping):\n", + "M. Müller, Fundamentals of Music Processing. Cham: Springer International Publishing, 2021, ISBN: 9783030698072. DOI:https://doi.org/10.1007/978-3-030-69808-9.\n", + "\n", + "In general, this DTW algorithm finds an optimal possibly nonlinear alignment between response MIDI sequence to reference MIDI sequence.\n", + "\n", + "Basic approach:\n", + "- Evaluating the local cost measure for each pair of elements in the response(X) and reference(Y) sequences. \n", + "- Dynamic programming to find an alignment path between X and Y having minimal overall cost, i.e. DTW distance. The algorithm computes a cumulative distance path, the timestamps of the target MIDI are warped so they perfectly align with the anchor points of the reference MIDI.\n", + "\n", + "\n", + "However, this basic approach will not correctly handle the missing note case as expected, because it allows a note to match with multiple notes. Let's say, there is a note missing in the response, this algorithm tends to match a response note with two reference note, instead of reporting the missing problem.\n", + "\n", + "! need to modify this algorithm to make it handle the missing/extra problem correctly. Cosider analysing each entry of the warping path." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0f6ac5e5", + "metadata": {}, + "outputs": [], + "source": [ + "def compute_cost(note1, note2):\n", + " \"\"\"\n", + " Compute the local cost measure for each pair of notes.\n", + " \n", + " Only pitch is involved in the cost calculation, since the purpose \n", + " is to pair up notes with similar pitches.\n", + " \n", + " Args:\n", + " note1: dict with keys \"pitch\" (int), \"start\" (float), \"duration\" (float)\n", + " note2: dict with keys \"pitch\" (int), \"start\" (float), \"duration\" (float)\n", + " \n", + " Returns:\n", + " int: cost value >= 0 (lower means more similar pitch)\n", + " \"\"\"\n", + " return int(abs(note1[\"pitch\"] - note2[\"pitch\"]))\n", + "\n", + "\n", + "def note_alignment_DTW(response_notes, ref_notes):\n", + " \"\"\"\n", + " DTW pipeline: build cost matrix C -> build accumulated cost matrix D \n", + " -> backtrack to find the optimal warping path\n", + " - the Rows of C and D correspond to response notes\n", + " - the Columns of C and D correspond to reference notes\n", + " \n", + " Args:\n", + " response_notes: The student's response MIDI notes to evaluate\n", + " ref_notes: The reference MIDI note\n", + " \n", + " Returns:\n", + " path: list of (response_idx, ref_idx) pairs — the optimal alignment\n", + " C: local cost matrix\n", + " D: accumulated cost matrix\n", + " \"\"\"\n", + "\n", + " N = len(response_notes)\n", + " M = len(ref_notes)\n", + "\n", + " # step1: Build the local cost matrix C of size (N x M).\n", + " # C[i, j] = note_cost(ref_notes[i], response_notes[j])\n", + " C= np.zeros((N, M))\n", + " for i in range(N):\n", + " for j in range(M):\n", + " C[i, j] = compute_cost(response_notes[i], ref_notes[j])\n", + "\n", + " # step2: Build the accumulated cost matrix D of size (N+1 x M+1)\n", + " # using small trick for simplifying the initialization\n", + " # D[n, 0] = inf for n >= 1\n", + " # D[0, m] = inf for m >= 1\n", + " D = np.full((N + 1, M + 1), np.inf)\n", + " D[0, 0] = 0\n", + " # for all n in [1..N] and m in [1..M]:\n", + " # D[i, j] = C[i, j] + min(D[i-1, j], D[i, j-1], D[i-1, j-1])\n", + " for i in range(1, N + 1):\n", + " for j in range(1, M + 1):\n", + " D[i, j] = C[i - 1, j - 1] + min(\n", + " D[i - 1, j], # vertical step, multiple response notes are mapped to the same ref note\n", + " D[i, j - 1], # horizontal step, same response note is reused for multiple ref notes\n", + " D[i - 1, j - 1]) # diagonal step\n", + "\n", + " # step3: Backtrack through the D to find the optimal warping path P\n", + " path = []\n", + " n, m = N, M\n", + " while n > 0 and m > 0:\n", + " path.append((n-1, m-1))\n", + " # Find the minimum cost step\n", + " diag = D[n-1, m-1]\n", + " vertical = D[n-1, m]\n", + " horizontal = D[n, m-1]\n", + " min_step = min(diag, vertical, horizontal)\n", + " if min_step == vertical:\n", + " n -= 1\n", + " elif min_step == horizontal:\n", + " m -= 1\n", + " else: # diagonal step\n", + " n -= 1\n", + " m -= 1\n", + " # Reverse to get the path from start to end\n", + " path.reverse() \n", + "\n", + " return path, C, D\n", + "\n", + "\n", + "def path_classification(path, C):\n", + " \"\"\"\n", + " Classify the each entry of the alignment path into:\n", + " - correct: response pitch matches reference pitch (cost = 0)\n", + " - wrong: response pitch differs from reference pitch, but all the parings are one-to-one (cost > 0)\n", + " - missing: same response pitch is mapped to multiple reference pitches \n", + " - extra: multiple response pitches are mapped to the same reference pitch\n", + " \n", + " Args: \n", + " path: list of (response_idx, reference_idx) pairs from the backtrack warping path\n", + " C: local cost matrix (N x M)\n", + "\n", + " Returns:\n", + " list of event dicts, each one of:\n", + " {'type': 'match' or 'replacement' or 'missing' or 'extra', \n", + " 'response_idx': int, 'ref_idx': int, 'cost': int}\n", + " \"\"\"" + ] + }, + { + "cell_type": "markdown", + "id": "095ae680", + "metadata": {}, + "source": [ + "Evaluation" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f2cc4262", + "metadata": {}, + "outputs": [], + "source": [ + "def evaluate_note_pair(response_note, ref_note, ref_idx,\n", + " timing_tolerance=0.1, duration_tolerance=0.1):\n", + " \"\"\"\n", + " Evaluate a single aligned note pair and return feedback.\n", + " \n", + " Args:\n", + " response_note: student's note dict\n", + " ref_note: reference note dict\n", + " ref_idx: 1-based display index (based on ref position)\n", + " timing_tolerance: consider as correct if start is within this tolerance\n", + " duration_tolerance: consider as correct if duration is within this tolerance\n", + " \n", + " Returns:\n", + " is_correct (bool), feedback (list of str)\n", + " \"\"\"\n", + " feedback = []\n", + " is_correct = True\n", + " \n", + " # Pitch check\n", + " if response_note[\"pitch\"] != ref_note[\"pitch\"]:\n", + " is_correct = False\n", + " feedback.append(\n", + " f\"Note {ref_idx}: wrong pitch — expected {ref_note['pitch']}, \"\n", + " f\"played {response_note['pitch']}.\"\n", + " )\n", + " \n", + " # Timing check\n", + " timing_diff = abs(response_note[\"start\"] - ref_note[\"start\"])\n", + " if timing_diff > timing_tolerance:\n", + " is_correct = False\n", + " feedback.append(f\"Note {ref_idx}: difference in start time: {timing_diff:.2f}s.\")\n", + " \n", + " # Duration check\n", + " duration_diff = abs(response_note[\"duration\"] - ref_note[\"duration\"])\n", + " if duration_diff > duration_tolerance:\n", + " is_correct = False\n", + " feedback.append(f\"Note {ref_idx}: difference in duration: {duration_diff:.2f}s.\")\n", + " \n", + " if is_correct:\n", + " feedback.append(f\"Note {ref_idx} (with pitch {ref_note['pitch']}) is correct.\")\n", + " \n", + " return is_correct, feedback" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "99c84405", + "metadata": {}, + "outputs": [], + "source": [ + "def comparison(response, ref,\n", + " timing_tolerance=0.1, duration_tolerance=0.1):\n", + " \"\"\"\n", + " Compare student MIDI against reference MIDI after DTW-based alignment.\n", + " \n", + " Args:\n", + " response: The student's response MIDI\n", + " ref: The reference MIDI\n", + " timing_tolerance: seconds\n", + " duration_tolerance: seconds\n", + " \n", + " Returns:\n", + " all_correct (bool), feedback (list of str)\n", + " \"\"\"\n", + " response_notes = response[\"notes\"]\n", + " ref_notes = ref[\"notes\"]\n", + " \n", + " # Align using DTW — response first, ref second\n", + " path, C, D = note_alignment_DTW(response_notes, ref_notes)\n", + " \n", + " feedback = []\n", + " all_correct = True\n", + " \n", + " for response_idx, ref_idx in path:\n", + " is_correct, feedback = evaluate_note_pair(\n", + " response_notes[response_idx], ref_notes[ref_idx],\n", + " ref_idx=ref_idx + 1,\n", + " timing_tolerance=timing_tolerance,\n", + " duration_tolerance=duration_tolerance,\n", + " )\n", + " if not is_correct:\n", + " all_correct = False\n", + " feedback.extend(feedback)\n", + " \n", + " return all_correct, feedback" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a203ee2d", + "metadata": {}, + "outputs": [], + "source": [ + "def evaluation_function(response: Any, answer: Any, params: Params) -> Result:\n", + " \"\"\"\n", + " Entry point for Lambda Feedback.\n", + " \n", + " Args:\n", + " response: student MIDI dict\n", + " answer: reference MIDI dict\n", + " params: optional extra parameters\n", + " \n", + " Returns:\n", + " Result with is_correct and feedback string\n", + " \"\"\"\n", + " all_correct, feedback = comparison(response, answer)\n", + " return Result(\n", + " is_correct=all_correct,\n", + " feedback_items=[(\"feedback\", \"\\n\".join(feedback))]\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cd3befd9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "False\n", + "Note 1 (with pitch 60) is correct.\n", + "Note 2: wrong pitch — expected 62, played 63.\n", + "Note 3: difference in start time: 0.15s.\n", + "Note 4: difference in duration: 0.20s.\n", + "Note 5: wrong pitch — expected 67, played 65.\n", + "Note 5: difference in start time: 0.70s.\n", + "Note 5: difference in duration: 0.20s.\n" + ] + } + ], + "source": [ + "is_correct, feedbacks = comparison(\n", + " response,\n", + " reference\n", + ")\n", + "\n", + "print(is_correct)\n", + "\n", + "for feedback in feedbacks:\n", + " print(feedback)" + ] + }, + { + "cell_type": "markdown", + "id": "25bc065e", + "metadata": {}, + "source": [ + "note5 should be a missing pitch!! Need to check the DTW algorithm!" + ] + }, + { + "cell_type": "markdown", + "id": "ded66276", + "metadata": {}, + "source": [ + "## Edit Distance" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "81666295", + "metadata": {}, + "outputs": [], + "source": [ + "def compute_cost(note1, note2):\n", + " \"\"\"\n", + " Cost of aligning (replacing) one note with another, based on pitch.\n", + " \n", + " cost = 0: pitches are identical (a 'match'). \n", + " cost > 0: different pitches (a 'replacement')\n", + " \n", + " Args:\n", + " note1: dict with keys \"pitch\" (int), \"start\" (float), \"duration\" (float)\n", + " note2: dict with keys \"pitch\" (int), \"start\" (float), \"duration\" (float)\n", + " \n", + " Returns:\n", + " int: cost value >= 0 (lower means more similar pitch)\n", + " \"\"\"\n", + " return int(abs(note1[\"pitch\"] - note2[\"pitch\"]))\n", + "\n", + "\n", + "def note_alignment_ED(response_notes, ref_notes, gap_penalty=6):\n", + " \"\"\"\n", + " Align notes using edit distance (ED). \n", + " The ED allows for insertions and deletions, which can be useful for \n", + " evaluating musical practice containing missing/extra notes.\n", + " \n", + " Args:\n", + " response_notes: The student's response MIDI notes to evaluate\n", + " ref_notes: The reference MIDI note\n", + " gap_penalty: cost of leaving a note unaligned (insertion/deletion)\n", + " \n", + " Returns:\n", + " operations: list of transformation ops dicts, in order from first note to last:\n", + " {'type': 'match' or 'replacement' or 'missing' or 'extra', \n", + " 'response_idx': int or None, \n", + " 'ref_idx': int or None, \n", + " 'cost': int}\n", + " D: accumulated cost matrix, shape (N+1, M+1)\n", + " \"\"\"\n", + " # the rows of D correspond to response notes\n", + " N = len(response_notes)\n", + " # the columns of D correspond to reference notes\n", + " M = len(ref_notes)\n", + "\n", + " # Build the accumulated cost matrix D of size (N+1 x M+1)\n", + " D = np.zeros((N + 1, M + 1), dtype=int)\n", + " # Boundary conditions: aligning against an empty sequence means every note\n", + " # is unaligned, so the cost is n (or m) times the gap penalty.\n", + " for n in range(1, N + 1):\n", + " D[n, 0] = n * gap_penalty # n extra response notes\n", + " for m in range(1, M + 1):\n", + " D[0, m] = m * gap_penalty # m missing ref notes\n", + " # Recursion (accumulated cost / score matrix D):\n", + " for n in range(1, N + 1):\n", + " for m in range(1, M + 1):\n", + " replace_cost = compute_cost(response_notes[n-1], ref_notes[m-1])\n", + " D[n, m] = min(\n", + " D[n-1, m-1] + replace_cost, # diagonal: match or replacement\n", + " D[n-1, m] + gap_penalty, # vertical: extra note response[n-1]\n", + " D[n, m-1] + gap_penalty, # horizontal: missing response for ref[m-1]\n", + " )\n", + "\n", + " # Backtrack, classify each transformation ops based on movement direction in D\n", + " operations = []\n", + " n, m = N, M\n", + " while n > 0 or m > 0:\n", + " # boundary conditions: if we are at the most top row or left column, we can only move in one direction\n", + " # at the top row, only horizontal moves possible\n", + " if n == 0: \n", + " # missing response for ref[m-1] (deletion)\n", + " operations.append({\"type\": \"missing\", \"response_idx\": None,\n", + " \"ref_idx\": m - 1, \"cost\": gap_penalty})\n", + " m -= 1\n", + " # at the most left column, only vertical moves possible\n", + " elif m == 0: \n", + " # extra note response[n-1] (insertion)\n", + " operations.append({\"type\": \"extra\", \"response_idx\": n - 1,\n", + " \"ref_idx\": None, \"cost\": gap_penalty})\n", + " n -= 1\n", + " # for all other cases, we can move in any direction (diagonal, vertical, horizontal)\n", + " else:\n", + " replace_cost = compute_cost(response_notes[n-1], ref_notes[m-1])\n", + " diag = D[n-1, m-1] + replace_cost # diagonal: match or replacement\n", + " up = D[n-1, m] + gap_penalty # vertical: extra note response[n-1]\n", + " left = D[n, m-1] + gap_penalty # horizontal: missing response for ref[m-1]\n", + " min_cost = min(diag, up, left) # find the minimum cost step\n", + " # classify the transformation ops based on the minimum cost step\n", + " if min_cost == diag: # diagonal -> two notes are aligned (match/replacement)\n", + " operations.append({\n", + " \"type\": \"match\" if replace_cost == 0 else \"replacement\",\n", + " \"response_idx\": n - 1,\n", + " \"ref_idx\": m - 1,\n", + " \"cost\": replace_cost,\n", + " })\n", + " n, m = n - 1, m - 1\n", + " elif min_cost == up: # vertical -> response[n-1] is extra (insertion)\n", + " operations.append({\"type\": \"extra\", \"response_idx\": n - 1,\n", + " \"ref_idx\": None, \"cost\": gap_penalty})\n", + " n -= 1\n", + " else: # horizontal -> response is missing for ref[m-1] (deletion)\n", + " operations.append({\"type\": \"missing\", \"response_idx\": None,\n", + " \"ref_idx\": m - 1, \"cost\": gap_penalty})\n", + " m -= 1\n", + " \n", + " operations.reverse() # reverse the operations to get them in order from first note to last\n", + " return operations, D" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "compareMusic", + "language": "python", + "name": "comparemusic" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From f46612b3ba300c09896ac5a406b1730c0007e46f Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Mon, 15 Jun 2026 22:50:39 +0100 Subject: [PATCH 09/22] edit distance algorithm, feedback to be fixed --- notebooks/DTW.ipynb | 395 --------------------------------- notebooks/Note_alignment.ipynb | 208 ++++++++++++++++- 2 files changed, 205 insertions(+), 398 deletions(-) delete mode 100644 notebooks/DTW.ipynb diff --git a/notebooks/DTW.ipynb b/notebooks/DTW.ipynb deleted file mode 100644 index 14153c4..0000000 --- a/notebooks/DTW.ipynb +++ /dev/null @@ -1,395 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 5, - "id": "96c2775c", - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "from typing import Any\n", - "from lf_toolkit.evaluation import Result, Params" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "a6822f11", - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "import json\n", - "\n", - "cwd = os.getcwd()\n", - "dir = os.path.dirname(cwd)\n", - "reference_path = os.path.join(dir, \"data\", \"referenceMIDI.json\")\n", - "response_path = os.path.join(dir, \"data\", \"responseMIDI.json\")\n", - "\n", - "with open(reference_path) as f1:\n", - " reference = json.load(f1)\n", - "\n", - "with open(response_path) as f2:\n", - " response = json.load(f2)" - ] - }, - { - "cell_type": "markdown", - "id": "8d3bba8e", - "metadata": {}, - "source": [ - "# Dynamic Time Warping" - ] - }, - { - "cell_type": "markdown", - "id": "a900434b", - "metadata": {}, - "source": [ - "The goal of DTW is to find an optimal possibly nonlinear alignment between response MIDI sequence to reference MIDI sequence.\n", - "\n", - "The algorithm can be found in this book (Chpater 3.2 Dynamic Time Warping):\n", - "M. Müller, Fundamentals of Music Processing. Cham: Springer International Publishing, 2021, ISBN: 9783030698072. DOI:https://doi.org/10.1007/978-3-030-69808-9.\n", - "\n", - "\n", - "Basic approach:\n", - "- Evaluating the local cost measure for each pair of elements in the response(X) and reference(Y) sequences. \n", - "- Dynamic programming to find an alignment path between X and Y having minimal overall cost, i.e. DTW distance. The algorithm computes a cumulative distance path, the timestamps of the target MIDI are warped so they perfectly align with the anchor points of the reference MIDI.\n", - "\n", - "However, this basic approach will not correctly handle the missing note case as expected, because it allows a note to match with multiple notes. Let's say, there is a note missing in the response, this algorithm tends to match a response note with two reference note, instead of reporting the missing problem.\n", - "\n", - "! need to modify this algorithm to make it handle the missing/extra problem correctly." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "9c568285", - "metadata": {}, - "outputs": [], - "source": [ - "def compute_cost(note1, note2):\n", - " \"\"\"\n", - " Compute the local cost measure for each pair of notes.\n", - " \n", - " Only pitch is involved in the cost calculation, since the purpose \n", - " is to check if the user played missing or extra notes.\n", - " \n", - " Args:\n", - " note1: dict with keys \"pitch\" (int), \"start\" (float), \"duration\" (float)\n", - " note2: dict with keys \"pitch\" (int), \"start\" (float), \"duration\" (float)\n", - " \n", - " Returns:\n", - " int: cost value >= 0 (lower means more similar pitch)\n", - " \"\"\"\n", - " return int(abs(note1[\"pitch\"] - note2[\"pitch\"]))\n", - "\n", - "\n", - "def cost_matrix(response_notes, ref_notes):\n", - " \"\"\"\n", - " Build the local cost matrix C of size (N x M).\n", - " C[i, j] = note_cost(ref_notes[i], response_notes[j])\n", - " \n", - " Args:\n", - " response_notes: list of dicts, each with keys \"pitch\", \"start\", \"duration\"\n", - " ref_notes: list of dicts, each with keys \"pitch\", \"start\", \"duration\"\n", - " \n", - " Returns:\n", - " numpy array of shape (N, M) - cost matrix where C[i,j] is the cost of \n", - " aligning response_notes[i] with ref_notes[j]\n", - " \"\"\"\n", - " N = len(response_notes)\n", - " M = len(ref_notes)\n", - " C= np.zeros((N, M))\n", - " \n", - " for i in range(N):\n", - " for j in range(M):\n", - " C[i, j] = compute_cost(response_notes[i], ref_notes[j])\n", - " return C\n", - "\n", - "\n", - "def accumulate_cost_matrix(C):\n", - " \"\"\"\n", - " Build the accumulated cost matrix D of size (N+1 x M+1) \n", - " using small trick for simplifying the initialization:\n", - " set:\n", - " D[0, 0] = 0\n", - " D[n, 0] = inf for n >= 1\n", - " D[0, m] = inf for m >= 1\n", - " for all n in [1..N] and m in [1..M]:\n", - " D[i, j] = C[i, j] + min(D[i-1, j], D[i, j-1], D[i-1, j-1])\n", - " \n", - " Args:\n", - " numpy array of shape (N, M) — the local cost matrix\n", - " \n", - " Returns:\n", - " numpy array of shape (N+1, M+1) — the accumulated cost matrix\n", - " \"\"\"\n", - " N, M = C.shape\n", - " D = np.full((N + 1, M + 1), np.inf)\n", - " D[0, 0] = 0.0\n", - " \n", - " for i in range(1, N + 1):\n", - " for j in range(1, M + 1):\n", - " D[i, j] = C[i - 1, j - 1] + min(\n", - " D[i - 1, j], # vertical step\n", - " D[i, j - 1], # horizontal step\n", - " D[i - 1, j - 1]) # diagonal step\n", - " \n", - " return D\n", - "\n", - "def backtrack_warping_path(D):\n", - " \"\"\"\n", - " Backtrack through the D to find the optimal warping path P.\n", - " \n", - " Args:\n", - " D: numpy array of shape (N+1, M+1) — the accumulated cost matrix\n", - " \n", - " Returns:\n", - " list of tuples — the optimal warping path as a list of (i, j) indices\n", - " ordered from the start (0, 0) to the end (N-1, M-1)\n", - " \"\"\"\n", - " N = D.shape[0] - 1\n", - " M = D.shape[1] - 1\n", - "\n", - " path = []\n", - " \n", - " while N > 0 and M > 0:\n", - " path.append((N-1, M-1))\n", - " # Find the minimum cost step\n", - " diag = D[N-1, M-1]\n", - " vertical = D[N-1, M ]\n", - " horizontal = D[N, M-1]\n", - " min_step = min(diag, vertical, horizontal)\n", - " if min_step == vertical:\n", - " N -= 1\n", - " elif min_step == horizontal:\n", - " M -= 1\n", - " else: # diagonal step\n", - " N -= 1\n", - " M -= 1\n", - " \n", - " # Reverse to get the path from start to end\n", - " path.reverse() \n", - " return path\n", - "\n", - "\n", - "def note_alignment_DTW(response_notes, ref_notes):\n", - " \"\"\"\n", - " DTW pipeline: build cost matrix -> build accumulated cost matrix -> backtrack\n", - " \n", - " Args:\n", - " response_notes: The student's response MIDI notes to evaluate\n", - " ref_notes: The reference MIDI note\n", - " \n", - " Returns:\n", - " path: list of (response_idx, ref_idx) pairs — the optimal alignment\n", - " C: local cost matrix (useful for visualisation)\n", - " D: accumulated cost matrix (useful for visualisation)\n", - " \"\"\"\n", - " C = cost_matrix(response_notes, ref_notes)\n", - " D = accumulate_cost_matrix(C)\n", - " path = backtrack_warping_path(D)\n", - " return path, C, D" - ] - }, - { - "cell_type": "markdown", - "id": "095ae680", - "metadata": {}, - "source": [ - "Evaluation" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "f2cc4262", - "metadata": {}, - "outputs": [], - "source": [ - "def evaluate_note_pair(response_note, ref_note, ref_idx,\n", - " timing_tolerance=0.1, duration_tolerance=0.1):\n", - " \"\"\"\n", - " Evaluate a single aligned note pair and return feedback.\n", - " \n", - " Args:\n", - " response_note: student's note dict\n", - " ref_note: reference note dict\n", - " ref_idx: 1-based display index (based on ref position)\n", - " timing_tolerance: consider as correct if start is within this tolerance\n", - " duration_tolerance: consider as correct if duration is within this tolerance\n", - " \n", - " Returns:\n", - " is_correct (bool), messages (list of str)\n", - " \"\"\"\n", - " messages = []\n", - " is_correct = True\n", - " \n", - " # Pitch check\n", - " if response_note[\"pitch\"] != ref_note[\"pitch\"]:\n", - " is_correct = False\n", - " messages.append(\n", - " f\"Note {ref_idx}: wrong pitch — expected {ref_note['pitch']}, \"\n", - " f\"played {response_note['pitch']}.\"\n", - " )\n", - " \n", - " # Timing check\n", - " timing_diff = abs(response_note[\"start\"] - ref_note[\"start\"])\n", - " if timing_diff > timing_tolerance:\n", - " is_correct = False\n", - " messages.append(f\"Note {ref_idx}: difference in start time: {timing_diff:.2f}s.\")\n", - " \n", - " # Duration check\n", - " duration_diff = abs(response_note[\"duration\"] - ref_note[\"duration\"])\n", - " if duration_diff > duration_tolerance:\n", - " is_correct = False\n", - " messages.append(f\"Note {ref_idx}: difference in duration: {duration_diff:.2f}s.\")\n", - " \n", - " if is_correct:\n", - " messages.append(f\"Note {ref_idx} (with pitch {ref_note['pitch']}) is correct.\")\n", - " \n", - " return is_correct, messages" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "99c84405", - "metadata": {}, - "outputs": [], - "source": [ - "def comparison(response, ref,\n", - " timing_tolerance=0.1, duration_tolerance=0.1):\n", - " \"\"\"\n", - " Compare student MIDI against reference MIDI after DTW-based alignment.\n", - " \n", - " Args:\n", - " response: The student's response MIDI\n", - " ref: The reference MIDI\n", - " timing_tolerance: seconds\n", - " duration_tolerance: seconds\n", - " \n", - " Returns:\n", - " all_correct (bool), feedback (list of str)\n", - " \"\"\"\n", - " response_notes = response[\"notes\"]\n", - " ref_notes = ref[\"notes\"]\n", - " \n", - " # Align using DTW — response first, ref second\n", - " path, C, D = note_alignment_DTW(response_notes, ref_notes)\n", - " \n", - " feedback = []\n", - " all_correct = True\n", - " \n", - " for response_idx, ref_idx in path:\n", - " is_correct, messages = evaluate_note_pair(\n", - " response_notes[response_idx], ref_notes[ref_idx],\n", - " ref_idx=ref_idx + 1,\n", - " timing_tolerance=timing_tolerance,\n", - " duration_tolerance=duration_tolerance,\n", - " )\n", - " if not is_correct:\n", - " all_correct = False\n", - " feedback.extend(messages)\n", - " \n", - " return all_correct, feedback" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "a203ee2d", - "metadata": {}, - "outputs": [], - "source": [ - "def evaluation_function(response: Any, answer: Any, params: Params) -> Result:\n", - " \"\"\"\n", - " Entry point for Lambda Feedback.\n", - " \n", - " Args:\n", - " response: student MIDI dict\n", - " answer: reference MIDI dict\n", - " params: optional extra parameters\n", - " \n", - " Returns:\n", - " Result with is_correct and feedback string\n", - " \"\"\"\n", - " all_correct, feedback = comparison(response, answer)\n", - " return Result(\n", - " is_correct=all_correct,\n", - " feedback_items=[(\"feedback\", \"\\n\".join(feedback))]\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "cd3befd9", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "False\n", - "Note 1 (with pitch 60) is correct.\n", - "Note 2: wrong pitch — expected 62, played 63.\n", - "Note 3: difference in start time: 0.15s.\n", - "Note 4: difference in duration: 0.20s.\n", - "Note 5: wrong pitch — expected 67, played 65.\n", - "Note 5: difference in start time: 0.70s.\n", - "Note 5: difference in duration: 0.20s.\n" - ] - } - ], - "source": [ - "is_correct, feedbacks = comparison(\n", - " response,\n", - " reference\n", - ")\n", - "\n", - "print(is_correct)\n", - "\n", - "for feedback in feedbacks:\n", - " print(feedback)" - ] - }, - { - "cell_type": "markdown", - "id": "25bc065e", - "metadata": {}, - "source": [ - "note5 should be a missing pitch!! Need to check the DTW algorithm!" - ] - }, - { - "cell_type": "markdown", - "id": "01bcd63e", - "metadata": {}, - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "compareMusic", - "language": "python", - "name": "comparemusic" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/Note_alignment.ipynb b/notebooks/Note_alignment.ipynb index 686bf5b..7589a4b 100644 --- a/notebooks/Note_alignment.ipynb +++ b/notebooks/Note_alignment.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 5, + "execution_count": 1, "id": "96c2775c", "metadata": {}, "outputs": [], @@ -14,7 +14,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 2, "id": "a6822f11", "metadata": {}, "outputs": [], @@ -359,9 +359,19 @@ "## Edit Distance" ] }, + { + "cell_type": "markdown", + "id": "adbaf01b", + "metadata": {}, + "source": [ + "a simplified version of this is applied here: https://www.math.univ-toulouse.fr/~mongeau/music.pdf \n", + "\n", + "Unlike standard DTW where off-diagonal moves has no cost and every note must be aligned to another, the Edit-distance approach allows a note to be explicitly left unaligned at the cost of gap_penalty, i.e. insertion represents an extra note, deletion represents a missing note, the moving direction during backtracking has an unambiguous meaning (diagonal = match/substitution, vertical = extra, horizontal = missing), we can classify each operation directly during backtracking." + ] + }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "81666295", "metadata": {}, "outputs": [], @@ -470,6 +480,198 @@ " operations.reverse() # reverse the operations to get them in order from first note to last\n", " return operations, D" ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "277f0d01", + "metadata": {}, + "outputs": [], + "source": [ + "def generate_feedback(operations, response_notes, ref_notes,\n", + " timing_tolerance=0.1, duration_tolerance=0.1):\n", + " \"\"\"\n", + " Evaluate the response MIDI and return feedback.\n", + " For 'match'/'substitution' operations, checks pitch, timing, and duration\n", + " of the aligned pair. \n", + " For 'missing'/'extra' operations, reports the note directly\n", + " \n", + " Args:\n", + " operations: list of transformation ops dicts\n", + " response_notes: list of note dicts from student's performance\n", + " ref_notes: list of note dicts from reference performance\n", + " timing_tolerance: consider as correct if start is within this tolerance\n", + " duration_tolerance: consider as correct if duration is within this tolerance\n", + " \n", + " Returns:\n", + " is_correct (bool), feedback (list of str)\n", + " \"\"\"\n", + "\n", + " feedback = []\n", + " is_correct = True\n", + "\n", + " for op in operations:\n", + " if op[\"type\"] in (\"match\", \"substitution\"):\n", + " response_note = response_notes[op[\"response_idx\"]]\n", + " ref_note = ref_notes[op[\"ref_idx\"]]\n", + " ref_idx = op[\"ref_idx\"] + 1 # 1-based for display\n", + " correct_note = True\n", + "\n", + " # Pitch check\n", + " if response_note[\"pitch\"] != ref_note[\"pitch\"]:\n", + " correct_note = False\n", + " feedback.append(\n", + " f\"Note {ref_idx}: wrong pitch — expected {ref_note['pitch']}, \"\n", + " f\"played {response_note['pitch']}.\"\n", + " )\n", + " \n", + " # Timing check\n", + " timing_diff = abs(response_note[\"start\"] - ref_note[\"start\"])\n", + " if timing_diff > timing_tolerance:\n", + " correct_note = False\n", + " feedback.append(f\"Note {ref_idx}: difference in start time: {timing_diff:.2f}s.\")\n", + " \n", + " # Duration check\n", + " duration_diff = abs(response_note[\"duration\"] - ref_note[\"duration\"])\n", + " if duration_diff > duration_tolerance:\n", + " correct_note = False\n", + " feedback.append(f\"Note {ref_idx}: difference in duration: {duration_diff:.2f}s.\")\n", + "\n", + " if correct_note:\n", + " feedback.append(f\"Note {ref_idx} (pitch {ref_note['pitch']}) is correct.\")\n", + " else:\n", + " is_correct = False\n", + " \n", + " elif op[\"type\"] == \"missing\":\n", + " is_correct = False\n", + " ref_idx = op[\"ref_idx\"]\n", + " feedback.append(\n", + " f\"Note {ref_idx + 1} (pitch {ref_notes[ref_idx]['pitch']}) \"\n", + " f\"is missing in your performance.\"\n", + " )\n", + " \n", + " elif op[\"type\"] == \"extra\":\n", + " is_correct = False\n", + " response_note = response_notes[op[\"response_idx\"]]\n", + " feedback.append(\n", + " f\"Extra note played: pitch {response_note['pitch']} \"\n", + " f\"at t={response_note['start']:.2f}s (not in reference).\"\n", + " )\n", + " \n", + " return is_correct, feedback\n", + "\n", + "\n", + "def compare_performance(responseMIDI, refMIDI, gap_penalty=6,\n", + " timing_tolerance=0.1, duration_tolerance=0.1):\n", + " \"\"\"\n", + " Compare student MIDI against reference MIDI\n", + " \n", + " Args:\n", + " responseMIDI: student's response MIDI dict\n", + " refMIDI: reference MIDI dict\n", + " gap_penalty: cost of leaving a note unaligned\n", + " timing_tolerance: consider as correct if start is within this tolerance\n", + " duration_tolerance: consider as correct if duration is within this tolerance\n", + " \n", + " Returns:\n", + " all_correct (bool), feedback (list of str)\n", + " \"\"\"\n", + " response_notes = responseMIDI[\"notes\"]\n", + " ref_notes = refMIDI[\"notes\"]\n", + " \n", + " # Step 1: align using edit distance with gap penalty,\n", + " # emitting match/substitution/missing/extra operations directly\n", + " operations, D = note_alignment_ED(response_notes, ref_notes, gap_penalty)\n", + " \n", + " # Step 2: turn the alignment operations into feedback messages\n", + " all_correct, feedback = generate_feedback(\n", + " operations, response_notes, ref_notes,\n", + " timing_tolerance=timing_tolerance,\n", + " duration_tolerance=duration_tolerance,\n", + " )\n", + " \n", + " return all_correct, feedback" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "dacd1ce1", + "metadata": {}, + "outputs": [], + "source": [ + "def evaluation_function(\n", + " response: Any,\n", + " answer: Any,\n", + " params: Params,\n", + ") -> Result:\n", + " \"\"\"\n", + " Function used to evaluate a student response.\n", + " ---\n", + " The handler function passes three arguments to evaluation_function():\n", + "\n", + " - `response` which are the answers provided by the student.\n", + " - `answer` which are the correct answers to compare against.\n", + " - `params` which are any extra parameters that may be useful,\n", + " e.g., error tolerances.\n", + "\n", + " The output of this function is what is returned as the API response\n", + " and therefore must be JSON-encodable. It must also conform to the\n", + " response schema.\n", + "\n", + " Any standard python library may be used, as well as any package\n", + " available on pip (provided it is added to requirements.txt).\n", + "\n", + " The way you wish to structure you code (all in this function, or\n", + " split into many) is entirely up to you. All that matters are the\n", + " return types and that evaluation_function() is the main function used\n", + " to output the evaluation response.\n", + " \"\"\"\n", + " all_correct, feedback = compare_performance(response, answer)\n", + "\n", + " return Result(\n", + " is_correct=all_correct,\n", + " feedback_items=[(\"feedback\", \"\\n\".join(feedback))]\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "c8497dc1", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "False\n", + "Note 1 (pitch 60) is correct.\n", + "Note 3: difference in start time: 0.15s.\n", + "Note 4: difference in duration: 0.20s.\n", + "Note 5 (pitch 67) is missing in your performance.\n" + ] + } + ], + "source": [ + "is_correct, feedbacks = compare_performance(\n", + " response,\n", + " reference\n", + ")\n", + "\n", + "print(is_correct)\n", + "\n", + "for feedback in feedbacks:\n", + " print(feedback)" + ] + }, + { + "cell_type": "markdown", + "id": "257ad97d", + "metadata": {}, + "source": [ + "missing feedback for pitch 2" + ] } ], "metadata": { From 0b1941fd98aec5293da4999defdf1e85a0335e60 Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Wed, 17 Jun 2026 20:20:27 +0100 Subject: [PATCH 10/22] fix the typo in generate_feedback --- notebooks/Note_alignment.ipynb | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/notebooks/Note_alignment.ipynb b/notebooks/Note_alignment.ipynb index 7589a4b..6716b3f 100644 --- a/notebooks/Note_alignment.ipynb +++ b/notebooks/Note_alignment.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": 10, "id": "96c2775c", "metadata": {}, "outputs": [], @@ -14,7 +14,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 11, "id": "a6822f11", "metadata": {}, "outputs": [], @@ -68,7 +68,7 @@ "- Dynamic programming to find an alignment path between X and Y having minimal overall cost, i.e. DTW distance. The algorithm computes a cumulative distance path, the timestamps of the target MIDI are warped so they perfectly align with the anchor points of the reference MIDI.\n", "\n", "\n", - "However, this basic approach will not correctly handle the missing note case as expected, because it allows a note to match with multiple notes. Let's say, there is a note missing in the response, this algorithm tends to match a response note with two reference note, instead of reporting the missing problem.\n", + "However, this basic approach will not correctly handle the missing note case as expected, because it allows a note to match with multiple notes, and each note must be paired. Let's say, there is a note missing in the response, this algorithm tends to match a response note with two reference note, instead of reporting the missing problem.\n", "\n", "! need to modify this algorithm to make it handle the missing/extra problem correctly. Cosider analysing each entry of the warping path." ] @@ -366,12 +366,12 @@ "source": [ "a simplified version of this is applied here: https://www.math.univ-toulouse.fr/~mongeau/music.pdf \n", "\n", - "Unlike standard DTW where off-diagonal moves has no cost and every note must be aligned to another, the Edit-distance approach allows a note to be explicitly left unaligned at the cost of gap_penalty, i.e. insertion represents an extra note, deletion represents a missing note, the moving direction during backtracking has an unambiguous meaning (diagonal = match/substitution, vertical = extra, horizontal = missing), we can classify each operation directly during backtracking." + "Unlike standard DTW where off-diagonal moves has no cost and every note must be aligned to another, the Edit-distance approach allows a note to be explicitly left unaligned at the cost of gap_penalty, i.e. insertion represents an extra note, deletion represents a missing note, the moving direction during backtracking has an unambiguous meaning (diagonal = match/replacement, vertical = extra, horizontal = missing), we can classify each operation directly during backtracking." ] }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 12, "id": "81666295", "metadata": {}, "outputs": [], @@ -483,7 +483,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 13, "id": "277f0d01", "metadata": {}, "outputs": [], @@ -492,7 +492,7 @@ " timing_tolerance=0.1, duration_tolerance=0.1):\n", " \"\"\"\n", " Evaluate the response MIDI and return feedback.\n", - " For 'match'/'substitution' operations, checks pitch, timing, and duration\n", + " For 'match'/'replacement' operations, checks pitch, timing, and duration\n", " of the aligned pair. \n", " For 'missing'/'extra' operations, reports the note directly\n", " \n", @@ -511,7 +511,7 @@ " is_correct = True\n", "\n", " for op in operations:\n", - " if op[\"type\"] in (\"match\", \"substitution\"):\n", + " if op[\"type\"] in (\"match\", \"replacement\"):\n", " response_note = response_notes[op[\"response_idx\"]]\n", " ref_note = ref_notes[op[\"ref_idx\"]]\n", " ref_idx = op[\"ref_idx\"] + 1 # 1-based for display\n", @@ -580,7 +580,7 @@ " ref_notes = refMIDI[\"notes\"]\n", " \n", " # Step 1: align using edit distance with gap penalty,\n", - " # emitting match/substitution/missing/extra operations directly\n", + " # emitting match/replacement/missing/extra operations directly\n", " operations, D = note_alignment_ED(response_notes, ref_notes, gap_penalty)\n", " \n", " # Step 2: turn the alignment operations into feedback messages\n", @@ -595,7 +595,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 14, "id": "dacd1ce1", "metadata": {}, "outputs": [], @@ -637,7 +637,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 15, "id": "c8497dc1", "metadata": {}, "outputs": [ @@ -647,6 +647,7 @@ "text": [ "False\n", "Note 1 (pitch 60) is correct.\n", + "Note 2: wrong pitch — expected 62, played 63.\n", "Note 3: difference in start time: 0.15s.\n", "Note 4: difference in duration: 0.20s.\n", "Note 5 (pitch 67) is missing in your performance.\n" @@ -664,14 +665,6 @@ "for feedback in feedbacks:\n", " print(feedback)" ] - }, - { - "cell_type": "markdown", - "id": "257ad97d", - "metadata": {}, - "source": [ - "missing feedback for pitch 2" - ] } ], "metadata": { From f9ee2509faf6c0a3c2437ceffd77f570a645d539 Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Wed, 17 Jun 2026 23:20:35 +0100 Subject: [PATCH 11/22] add some visualisation of edit distance result, add an outline for the feedback object --- notebooks/Note_alignment.ipynb | 319 ++++++++++++++++++++++++++++++++- 1 file changed, 311 insertions(+), 8 deletions(-) diff --git a/notebooks/Note_alignment.ipynb b/notebooks/Note_alignment.ipynb index 6716b3f..657e129 100644 --- a/notebooks/Note_alignment.ipynb +++ b/notebooks/Note_alignment.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 10, + "execution_count": 16, "id": "96c2775c", "metadata": {}, "outputs": [], @@ -14,7 +14,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 17, "id": "a6822f11", "metadata": {}, "outputs": [], @@ -371,7 +371,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 18, "id": "81666295", "metadata": {}, "outputs": [], @@ -481,9 +481,25 @@ " return operations, D" ] }, + { + "cell_type": "markdown", + "id": "31f3c137", + "metadata": {}, + "source": [ + "TODO: The gap_penaulty need careful consideration! To be modified." + ] + }, + { + "cell_type": "markdown", + "id": "8be10bf9", + "metadata": {}, + "source": [ + "# Feedback generation" + ] + }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 19, "id": "277f0d01", "metadata": {}, "outputs": [], @@ -590,12 +606,12 @@ " duration_tolerance=duration_tolerance,\n", " )\n", " \n", - " return all_correct, feedback" + " return all_correct, feedback, operations, D" ] }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 20, "id": "dacd1ce1", "metadata": {}, "outputs": [], @@ -637,7 +653,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 23, "id": "c8497dc1", "metadata": {}, "outputs": [ @@ -655,7 +671,7 @@ } ], "source": [ - "is_correct, feedbacks = compare_performance(\n", + "is_correct, feedbacks, operations, D = compare_performance(\n", " response,\n", " reference\n", ")\n", @@ -665,6 +681,293 @@ "for feedback in feedbacks:\n", " print(feedback)" ] + }, + { + "cell_type": "markdown", + "id": "db53794c", + "metadata": {}, + "source": [ + "#### TODO:" + ] + }, + { + "cell_type": "markdown", + "id": "acb5f42b", + "metadata": {}, + "source": [ + "break `generate_feedback` into three functions, one for note_level_feedback, one for stats, one for feedback message. " + ] + }, + { + "cell_type": "markdown", + "id": "56c2eed4", + "metadata": {}, + "source": [ + "feedback = {\n", + "\n", + " \"stats\": {\n", + "\n", + " \"pitch_all_correct\": bool, \n", + " \"timing_all_correct\": bool, \n", + " \"duration_all_correct\": bool,\n", + "\n", + " \"total_notes_in_reference\": int, \n", + " \"total_notes_missing\": int, \n", + " \"total_notes_extra\": int,\n", + " \"total_notes_wrong_pitch\": int,\n", + " \"total_notes_wrong_timing\": int,\n", + " \"total_notes_wrong_duration\": int,\n", + " \"total_notes_correct\": int,\n", + " },\n", + "\n", + " \"note_level_feedback\": [\n", + " \n", + " {\n", + " \"reference_index\": int,\n", + " \"response_index\": int,\n", + " \"operation_type\": str, # \"match\" / \"replacement\" / \"missing\" / \"extra\"\n", + " \"pitch_correct\": bool,\n", + " \"timing_correct\": bool,\n", + " \"duration_correct\": bool,\n", + " \"pitch_diff\": int, \n", + " \"timing_diff\": float, \n", + " \"duration_diff\": float, \n", + " },\n", + " ...\n", + " ],\n", + "\n", + " \"feedback_message\": str,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "864814b2", + "metadata": {}, + "outputs": [], + "source": [ + "def compute_stats(operations, response_notes, ref_notes):\n", + " \"\"\"\n", + " Compute summary counts and correctness booleans from note-level feedback.\n", + " \n", + " Args:\n", + " operations : list of operation (match/replacement/missing/extra)\n", + " response_notes : list of note dicts from response\n", + " ref_notes : list of note dicts from reference \n", + " \n", + " Returns:\n", + " stats : dict with keys:\n", + " \"pitch_all_correct\" -> bool\n", + " \"timing_all_correct\" -> bool\n", + " \"duration_all_correct\" -> bool\n", + " \"total_notes_in_reference\" -> int\n", + " \"total_notes_missing\" -> int (reference notes not played)\n", + " \"total_notes_extra\" -> int (response notes not in reference)\n", + " \"total_notes_wrong_pitch\" -> int (paired notes where pitch_correct=False)\n", + " \"total_notes_wrong_timing\" -> int (paired notes where timing_correct=False)\n", + " \"total_notes_wrong_duration\" -> int (paired notes where duration_correct=False)\n", + " \"total_notes_correct\" -> int (paired notes correct on all three dimensions)\n", + " \"\"\"\n", + " pass\n", + "\n", + "\n", + "def note_level_feedback(operations, response_notes, ref_notes):\n", + " \"\"\"\n", + " Build a list of note-level feedback dicts.\n", + "\n", + " Args:\n", + " operations : list of operation (match/replacement/missing/extra)\n", + " response_notes : list of note dicts from response\n", + " ref_notes : list of note dicts from reference \n", + " \n", + " Returns:\n", + " note_level_feedback : list of dicts, each dict contains:\n", + " \"reference_index\" -> int (1-based) or None if operation_type = extra\n", + " \"response_index\" -> int (1-based) or None if operation_type = missing\n", + " \"operation_type\" -> str: \"match\", \"replacement\", \"missing\", or \"extra\"\n", + " \"pitch_correct\" -> bool\n", + " \"timing_correct\" -> bool\n", + " \"duration_correct\" -> bool\n", + " \"pitch_diff\" -> int\n", + " \"timing_diff\" -> float\n", + " \"duration_diff\" -> float\n", + " \"\"\"\n", + " pass\n", + "\n", + "\n", + "def generate_feedback_message(note_level_feedback, stats):\n", + " \"\"\"\n", + " Generate a human-readable feedback string for the student.\n", + " \n", + " Args:\n", + " note_level_feedback : list\n", + " stats : dict\n", + " \n", + " Returns:\n", + " feedback_message : str \n", + " \"\"\"\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "id": "f3765faf", + "metadata": {}, + "source": [ + "### Visualisation" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "0216997f", + "metadata": {}, + "outputs": [], + "source": [ + "# use ChatGPT to help with the code for the cost matrix and alignment arrows, wrote the code myself\n", + "\n", + "import matplotlib.pyplot as plt\n", + "\n", + "def plot_cost_matrix(D, operations, response_notes, ref_notes):\n", + " fig, ax = plt.subplots(figsize=(8, 6))\n", + " \n", + " im = ax.imshow(D, origin=\"upper\", aspect=\"auto\", cmap=\"gray_r\")\n", + " plt.colorbar(im, ax=ax, label=\"Accumulated cost\")\n", + " \n", + " # Annotate every cell with its value\n", + " for i in range(D.shape[0]):\n", + " for j in range(D.shape[1]):\n", + " ax.text(j, i, f\"{D[i, j]:.0f}\",\n", + " ha=\"center\", va=\"center\", fontsize=8,\n", + " color=\"white\" if D[i, j] > D.max() * 0.5 else \"black\")\n", + " \n", + " # Reconstruct path coordinates from operations\n", + " # D has a padding row/column at index 0, so shift by +1\n", + " path_rows, path_cols = [0], [0] # start at (0,0) — the base case\n", + " for op in operations:\n", + " if op[\"type\"] in (\"match\", \"replacement\"):\n", + " path_rows.append(op[\"response_idx\"] + 1)\n", + " path_cols.append(op[\"ref_idx\"] + 1)\n", + " elif op[\"type\"] == \"extra\":\n", + " path_rows.append(op[\"response_idx\"] + 1)\n", + " path_cols.append(path_cols[-1]) # ref col stays the same\n", + " elif op[\"type\"] == \"missing\":\n", + " path_rows.append(path_rows[-1]) # response row stays the same\n", + " path_cols.append(op[\"ref_idx\"] + 1)\n", + " \n", + " ax.plot(path_cols, path_rows, \"ro-\", markersize=10, linewidth=2,\n", + " label=\"Optimal alignment path\")\n", + " \n", + " # Axis labels — empty string for the padding row/col\n", + " ref_labels = [\"\"] + [f\"$x_{j}$\" for j in range(len(ref_notes))]\n", + " response_labels = [\"\"] + [f\"$y_{i}$\" for i in range(len(response_notes))]\n", + " ax.set_xticks(range(len(ref_labels)))\n", + " ax.set_xticklabels(ref_labels, fontsize=8)\n", + " ax.set_yticks(range(len(response_labels)))\n", + " ax.set_yticklabels(response_labels, fontsize=8)\n", + " ax.set_xlabel(\"Reference\")\n", + " ax.set_ylabel(\"Response\")\n", + " ax.set_title(f\"Accumulated Cost Matrix D\")\n", + " ax.legend(loc=\"upper left\", fontsize=8)\n", + " \n", + " plt.tight_layout()\n", + " # plt.savefig(\"edit_distance_matrix.png\", dpi=150, bbox_inches=\"tight\")\n", + " # print(\"Saved: edit_distance_matrix.png\")\n", + " plt.show()\n", + " \n", + " \n", + "def plot_alignment_arrows(operations, response_notes, ref_notes):\n", + " fig, ax = plt.subplots(figsize=(12, 3))\n", + "\n", + " N = len(response_notes)\n", + " M = len(ref_notes)\n", + "\n", + " # Draw boxes for ref notes (top row, y=1) and response notes (bottom row, y=0)\n", + " for j, note in enumerate(ref_notes):\n", + " ax.add_patch(plt.Rectangle((j - 0.4, 0.75), 0.8, 0.5,\n", + " fill=False, edgecolor=\"black\", linewidth=1.5))\n", + " ax.text(j, 1.0, f\"$x_{{{j+1}}}$\\n{note['pitch']}\",\n", + " ha=\"center\", va=\"center\", fontsize=9)\n", + "\n", + " for i, note in enumerate(response_notes):\n", + " ax.add_patch(plt.Rectangle((i - 0.4, -0.25), 0.8, 0.5,\n", + " fill=False, edgecolor=\"black\", linewidth=1.5))\n", + " ax.text(i, 0.0, f\"$y_{{{i+1}}}$\\n{note['pitch']}\",\n", + " ha=\"center\", va=\"center\", fontsize=9)\n", + "\n", + " # Draw arrows between aligned pairs\n", + " for op in operations:\n", + " if op[\"type\"] in (\"match\", \"replacement\"):\n", + " colour = \"green\" if op[\"type\"] == \"match\" else \"red\"\n", + " ax.annotate(\"\",\n", + " xy =(op[\"response_idx\"], 0.25),\n", + " xytext = (op[\"ref_idx\"], 0.75),\n", + " arrowprops = dict(arrowstyle=\"<->\", color=colour,\n", + " lw=1.5, mutation_scale=12),\n", + " )\n", + " elif op[\"type\"] == \"missing\":\n", + " j = op[\"ref_idx\"]\n", + " ax.text(j, 1.45, \"missing\", ha=\"center\", va=\"center\",\n", + " fontsize=12, color=\"red\", fontweight=\"bold\")\n", + " elif op[\"type\"] == \"extra\":\n", + " i = op[\"response_idx\"]\n", + " ax.text(i, -0.45, \"extra\", ha=\"center\", va=\"center\",\n", + " fontsize=12, color=\"red\", fontweight=\"bold\")\n", + "\n", + " ax.text(-0.8, 1.0, \"Sequence X\\n(ref)\", ha=\"right\", va=\"center\", fontsize=9)\n", + " ax.text(-0.8, 0.0, \"Sequence Y\\n(response)\", ha=\"right\", va=\"center\", fontsize=9)\n", + "\n", + " ax.set_xlim(-1.2, max(N, M) - 0.4)\n", + " ax.set_ylim(-0.7, 1.7)\n", + " ax.axis(\"off\")\n", + " plt.tight_layout()\n", + " # plt.savefig(\"edit_distance_alignment.png\", dpi=150, bbox_inches=\"tight\")\n", + " # print(\"Saved: edit_distance_alignment.png\")\n", + " plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "3bd7b6f2", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAvEAAAJOCAYAAAA+pFhBAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAkZRJREFUeJzt3Qd8U+X3x/FvuoCyQYYMWYqouJChggMRBFkyFEUUUURBBSeCe/5d4FbcE0QRAWULiANFRdwL2SIqexfoSP6v84T011ZGV3qT5vN+vUJ7s/r0piTnnnue8/gCgUBAAAAAAKJGnNcDAAAAAJA3BPEAAABAlCGIBwAAAKIMQTwAAAAQZQjiAQAAgChDEA8AAABEGYJ4AAAAIMoQxAMAAABRhiAeAAAAiDIE8QAiWqNGjdSzZ09FmjPPPFNNmzb1ehgxI1L/DgDAKwTxQIS6++675fP5dMIJJ3g9lKhVt25dnX/++V4PQ4FAQOPGjdPZZ5+tqlWrqkSJEqpdu7ZOP/10Pffcc9q2bVtE/P4nnnii+5urVauW/H7/f25fvny54uLi3H1uvPHGIhlTuIR+19ClbNmyql+/vrp166Y333xTaWlpXg8RAPaLIB6IQBZAvfrqqy6Y+vbbb90F0Sk1NVXnnHOO+vbtq1atWmn+/PkuaLevXbt21fDhwzVy5EhFitKlS2v16tWaNWvWf2577bXXlJyc7Mm4fv/9d40fP75Qn7NmzZruAMsua9as0ZQpU3TSSSdp8ODB7uDZ9gMARCqCeCACffjhh1q5cqXeeOMNVa9eXS+++KLXQ0I+3XTTTZo8ebLef/993XLLLWrQoIGSkpLcAdp1113nDtAsOx0pbFzNmjVzAXtWFui+/vrrxbakxQ5OjjzySA0dOlSff/65O+vQo0cPr4cFAPtEEA9EIAvajz76aLVu3Vr9+/fXW2+9pZSUlL3e991339Upp5yicuXKqVKlSq5kY+HChbm+T3p6uisnuO222/7z3FbuYWUHWdlBxSWXXKKvv/7aZS0t+DnuuOM0b948d7tlmE8++WR3/WGHHeaC16zy+vP2xu4XKoOIj493Y+rdu7dWrFiReR+7zQ6E3nnnncz72jizshIXG6tln+1iz/vZZ59lu4+VVVjwbVlbu88ZZ5zhssK5sXbtWo0aNUqdOnVSu3bt9nqfevXquf2Z9SyMZeYtoLSym8qVK7vAOefPnDZtmntN7fWsWLGi+37SpEl5+v33xcZjz7Vly5bM6z766CP3fFnHWtivSehva8GCBTr11FNVqlQpDRs2bK818TNmzHA/x16brOzvzZ733nvvVX7Zvh8wYIC++uorzZ07N9/PAwDhRBAPRBgL/CxzO2jQILd9xRVXaMeOHS7g3Fvd/AUXXOACqB9//NFlD60U4PHHH8/TffLqr7/+0iOPPOLOFPz5558u6LFA1QL4Bx980JUCrVq1ygX5vXr10t9//63C9PHHH2eWQdjBjZVBWHBoY9i1a5e7j91Wp04d9/ND9/3+++8zn+P+++93QWaXLl20ZMkSLVu2TC1atFCbNm2yBfJ2EPXEE0/o4Ycfdr+HfR0yZIi2bt16wHFa4GsHAR06dMj173bllVe6wNR+xr///qtPP/3UfbWDm8WLF7v7/PDDD64U57TTTtOiRYvcvrZxWS33hg0bcvX774/9vdj933777czr7DW1ibyNGzcO22sS+tuyv9lnn31WS5cuda/J3rRv31633nqr+3uzn2Xs/la2ZPt7bweJeRE66Prkk08K9DwAEDYBABHloYceCpQtWzawbdu2zOvOOeecwMknn5ztfkuXLg3Ex8cHBg0atM/nys190tLSAvZWcOutt/7nttNOOy3QokWLbNdVq1bNjW/jxo2Z161evdo9h922fv36zOv//fffgM/nc79Tfn/e4YcfHujRo0fgQL7//nv3vLNnz868rk6dOoFevXr9574rVqwIJCQkBIYMGfKf20455ZRAy5Yt3fc///yze8577703231++ukn93udcMIJ+x3Tgw8+6B4/ZcqUQG788ssv7v5Dhw7Ndv2aNWsCycnJmb/L008/7e63YcOG/T7fvn7/fbF9b/vbnHfeeYETTzzRfb9ly5ZAqVKlAs8880xg3bp17mffcMMNhfqaGPv7KVGihPt9c9rb30FGRkagbdu2gQoVKrh9d+yxxwYOOeSQbH+D+/tda9asuc/bQ6/95ZdffsDnAgAvkIkHIszLL7+siy++WGXKlMm8zrLyX3zxhX799dfM62bOnKmMjAyXTd6X3NwnPywrbCUcITVq1HClOpaRt/KPkGrVqrn7WZa7MFn22bLF9nMTEhKylWVYVv1ArBTDynrOPffc/9xmmfgvv/zSTUi1TLqxbH1Wlo222vbCFvp5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8k0FQUlJSewKj8XHx3s9BCAiRGqZcbjGFdjTVSa/MeHxxx+vBg0auJaTFsSH2k2uWLFCRx99dOb9li9frhNPPFFeiZwCKQAAAKAIbd++XRs2bPhPiba1lLSJq6Z69equL/zYsWMz7/Prr7+6M67WocYrxSYTDwAAgOhgWfhwniEIZOnxvj87duxQ69at1aZNGzVs2FBr1651veKtD/wll1ySeb/HH39cZ555pi6++GIdccQReuGFF1wAf/bZZ8srMRPE26zitLQ0r4cBAACACFGtWjV9/fXXLstunWYqVKigUaNGueA860FGy5YtXeZ99OjRWr16tZsg26dPH0/HnhArp0r++uuvXB+VIf+y9llF3tnfqLVL5W8VAFCcRUomPjSf7/LLL9eB2ETXe++9V5EiIRYy8BbAJycnq0qVKrn/g7EXf8MG+XbsUKB0aalyZfuLC/dwo15KSorXQ4hq9qazefNmt7AEgTwAAIjZIN5KaCwYsgA+Vx07Nm+WXn9deuopKWvvzwYNpGuukfr2lSpUCOuYo/2gCQVjp/KsbRX7EgBQXIU7Ex8LYqY7Ta7+UGbOtLV2peuuk5Yty36bbdv1drvdDwgT3tgAAMCBxEwQf0AWmHfsKO3cGSylyVlLFbrObrf7FfNA/uGHH9aCBQvy/LjHHntMCxcuDMuYisM+/fHHH70eBgAAEZOwCtclFhDEh0poevQIBul+//73mN1u97P72+PyyWY22/K/F154oa677ro8Bb4PPvhgtvvnN+Deny+++MKNMa+++uor1181mgL6kSNHhmVlx5zPa/t0zZo1hf5zAABA7CGIN1YDbxMyDxTAh9j97P5vvJGvnW4tjI455hi3kID1HC1ZsqROO+20bIsI7M+8efP0zz//ZG6fcsopqlmzpiKNLV9co0YNRTpbke3ff/+NmucFACDakYkvuGI/sfWALKtuk1jz48kng5Nd83jaZsiQIbr00kv1yCOPZF5niwpYe6OuXbu6Tjr333+/C4I/+ugjt/BA37593faMGTNcdtuy8a+99ppuvPFGffbZZ661owXM9rhmzZq5QN+68lx11VWqVKmSHn30UaWnp+umm27KXIHsmWee0dy5c93y8EceeaSuvvpqN6nyQHL7OMs827gOPvhgN8HYHrd48WK1a9dOixYtcj1YGzdu7M4knHTSSfrkk0/cwUmv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a9ZMzZs3V6SjnAYAAACeZOLDdcmLP/74Q0cccYSeeuopff3112rXrp1uuOEGRToy8QAAAIhZV199tY466ijNnDlTcXFxmjt3rs444wz17NlTJ510kiIVmXgAAADEZCZ+06ZNmjNnjvr37+8CeNO6dWs1bNhQ7777riJZscnEBwIB9zUjI8ProcS01NRUr4cASbt27WI/eGzHjh1eDyHmbd26Neb3gdf4fxAZ+z8UI8XK/8+te547588oUaKEu2S1aNEi+f1+HX744dmub9SokX777TdFsmITxG/bts19/fnnn70eSkz74YcfvB4CAADIESOVL18+IvZJUlKSqlevrtq1a4f155QpU+Y/P+POO+/UXXfdtdf4sUKFCtmut+2lS5cqkhWbIL5GjRpatWqVypYtm+cJDZHCjhjtD85+j3Llynk9nJjEa+A9XgPv8Rp4j9cgMkT762AZeAtSLUaKFCVLltTy5cvDfuY+EAj8Jx7MmYU3ycnJe83aW7ea0qVLK5IVmyDe6phq1aql4sDeKKLxzaI44TXwHq+B93gNvMdrEBmi+XWIlAx8zkDeLpGgYcOG7qtl3Y855pjM6237tNNOUyRjYisAAABiUpUqVXTyySfrzTffzLzu22+/deXZ3bp1UyQrNpl4AAAAIK+sP/zpp5+url27ugmtb7zxhi688EK1adNGkYxMfASxWi2bdLG3mi3wGsQK/h94j9fAe7wGkYHXITY0adJEv/76q0499VQlJibqueeey5aZj1S+QCT2HQIAAACwT2TiAQAAgChDEA8AAABEGYJ4AAAAIMoQxAMAAABRhiAeMe3555/X4sWL3fc2x/uZZ57J3EbRmDBhgr744ovM7bfffjvbNsLvs88+0/vvv5+5/dFHH2nSpEns+iJk7zv2fhTyyy+/ZNtG+G3evFkPPvig0tPT3fbatWv1yCOPaPfu3ex+RCSCeMQ0W/b5hhtucN8PGTJE33//vQ477DCvhxVTKlWqpH79+rkPzhdeeMEFLtbuC0Wnbt26uvzyy13QMnfuXA0ePFgnnngiL0ERqlOnjh5++GF3ALts2TL16NFDzZo14zUoQhUqVHB//6+88oq2bNmijh07qn79+rR9RsSixSRimgWORx11lAsa/X6/xo4dq7i4OK1evdoF9SkpKS4TY/dB+HTu3FnJyclauXKlZs2apbJly2rMmDFu3zdt2lQvvfQSuz/Mhg8frp9++skFkNOmTXOBvZ2ZevHFF93rccUVV6hPnz68DmH0zjvv6NFHH9WOHTv07LPPup7VtvS7BfS2RH2XLl10yy238BqEkf0fsOC9Xr16uuSSS1yCIevZEfs/8NVXXykpKYnXAZ5jxVbEtISEBLVs2dKVDvzzzz8ugDfXX3+9WrVqpapVq7oMJeUd4dW6dWt3RsQCFgsYTbt27dxZkaFDh4b5pyP0GlgpwQcffOACeGPBo/3/WL9+vS699FJ3sFW+fHl2WJjY0u99+/bVrbfe6gJ4U7NmTb322muupGPYsGE65ZRT3AXhYZn3+Ph4Va9ePVsAb55++ml3MGUJHyASUE6DmGaZ3jVr1uiYY47R+PHjM69fsmSJrr32WvXu3VsZGRmZNZIofJMnT3Yr41188cUu6xtSpUoV97og/H788Uddc801uummm1xJU4gFMscdd5wL8O1rmTJleDnCZN26dS4DfPvtt+v11193pX7Ggkbb9y1atNBJJ53kzg4iPGyfd+vWTRdddJE+/vhj/f3339nm7pxzzjmU1iCiEMQjZlnAaAGkBe8WzN92222ZE5gsExNSqlQpJjaFiX1Q3nzzzZo6dapGjBjhalFXrVoVrh+HvbAD1p49e+qtt97S//3f/7kJlva6hNgB7I033qi77ror2/8LFJ6tW7fq7LPPdhl4uxx77LGunCZk9uzZOvLII93B1plnnsmuDwNL1lxwwQXubMg999yjK6+8UnfccYe7bdeuXfryyy911llnse8RUQjiEbNdCKx8xoJ4C9JtApmVbVhAY6ze0eqzbXKTTfYrXbq010Mudiw4/Prrr91rUKNGDZd5HzVqVOZrgKJhAbuVa5xwwgmuvOzll1/Wv//+627bvn27Kymw2mAmG4e3O5CVyvTq1ctt2wRX65YVYu9PdrbK6rTtQBeF77vvvnMHSHawauysVK1atVx2/r333nPJHjsj8s0337iDXiASMLEV2Atrt3fZZZe5Gnl7Ux80aBD7qYhZlwgrabI6+UMPPVTz5893B1wo2smuFuBXq1bNbdvckVC9PIrGzJkz3dkqO+jdsGGDe29q3rw5u78Ibdy4UX/++af7vn///u5A186WAF4jiAf2YdOmTS4LEwpgULTsLMjy5cszt60+PjTxGEXDaoLtTFTIEUccQU2wB2cNV6xY4Wrj7QDKvsI7dqawQYMG8vl8vAzwHEE8AAAAEGVIawEAAABRhiAeAAAAiDIE8QAAAECUIYgHAAAAogxBPAAAABBlErweAABEot9++81dKlSooDPOOMPr4QAAkA1BPIBiYdGiRW7VRWP95A8++GC3wmLZsmXz/FyPPfaY7rvvPrVu3dr1pyeIBwBEGvrEAygWRowYoVtvvVXdunWT3+/X77//rr/++ktvvPGGOnXqlKfnskWNBg8erIEDB4ZtvAAAFASZeADFhmXd33777cztiy66SJdddpnWrFnzn9V4v/zySyUkJOj444/XQQcd5K7fsWOHJk+erNWrV7uDAHuuZs2auRUa9/e40MqaM2bMUPfu3fXrr79q6dKlOumkk1SjRo3MlR5//vlntwJwkyZNsq18atfbGE855RT98MMPWrdunU444YS9rha8bNky/fTTT6pVq5Z7npwrR+7v5wAAig+CeADFVocOHTR69Gj9+++/ql69urvu9ddf15AhQ1yQHB8fr6+//lpPPPGE+vbtq5SUFE2aNEm7d+/WggULXGBtgboF8ft7nFmxYoUuuOACdezYUStXrtSRRx6p+vXrq2rVqurfv7+mTZumFi1auLMDdrDw/vvvu4y/GT9+vN58802VK1dOlStXduOwQH3q1Kk69dRT3X3S0tLc87z33nvu4MAOGipVqqQpU6YoMTFR6enpB/w5AIBiJAAAxcAjjzwSqFy5crbr7rnnnkCJEiUCKSkpbvvHH38MlC5dOrBgwYLM+8ydOzdQqlSpwKpVqzKvs+cZO3Zs5nZuHvfdd98F7C21f//+Ab/fn3m///u//wscd9xxga1bt2Zed+211wZatmyZuX3nnXcGfD5f4KOPPsq87pJLLgmcccYZmdt333134KCDDgosXrw487oPP/wwsGPHjlz/HABA8UEmHkCxkZqa6kpgQjXxjz76qG677TaVKlXK3W7Z7kMOOcRlzZcvX25JDHd9UlKS5s+fr3PPPXevz5uXx11zzTXZSlxeffVVnXzyyZo5c6Z7nF0s226P27Vrl0qWLOnu16hRIzeRNuT00093Nf4hdibgyiuv1KGHHpp5Xdu2bfP8cwAAxQNBPIBiw8pgrBzGSksWLlzoAl4LfEMsCLcSEytfyap9+/YqX778Pp83L4+zrjg5H2slOTkfa4G/lc2EgmsrjcnKatkt+A75888/1bBhw/2OMTc/BwBQPBDEAyiWE1stoG/Tpo169eqlOXPmuOus5rxmzZrZJr/mRl4el3OiqT32nHPO0dChQ1UQ1q9+w4YN+x1jYfwcAEB0YMVWAMWSZbKff/55ffLJJ5nZacucf/XVV5n95EOs68zOnTv3+Vz5fVzosa+88oor9cnKOuDkRbt27TRmzBhXKhRik1ttwmth/hwAQHQgEw+g2DrqqKNc95hbbrnFZal79Ojhykts8SbrA2917r/88os++OADffHFF5m18znl93HmoYcecq0jrWOMjcXaU86bN88F/9Y5JrceeOABtWzZUqeddprrgmMBvJ0ZsJaX1p2msH4OACA6kIkHUCzYxFDr0Z7TPffc49pCWhbdSl3Gjh3rJolaaYpN+qxTp46++eYb1woyxJ7Hrg/JzeMqVqzoSndy9mW3Mhzr/W7tH3/88UctXrzYHRRMnDgx8z6NGzfWmWeeme1x9vxZfx87cLDn6dKli2tvaeVC06dPV3Jycq5/DgCg+GDFVgAAACDKkIkHAAAAogxBPAAAABBlCOIBAACAKEMQDwAAAEQZgngAAAAgyhDEAwAAAFGGIB4AAACIMgTxAAAAQJQhiAcAAACiDEE8AAAAEGUI4gEAAIAoQxAPAAAAKLr8P4fnUiPz4damAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot_cost_matrix(D, operations, response[\"notes\"], reference[\"notes\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "7b54ee04", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABJoAAAEiCAYAAACxyLg5AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAPHdJREFUeJzt3Qm8TVX/x/Hf5ZquOUpSoQwNKtFEylBKg4rnKQ1Kc2lQT6NGiqJRIyWSNGhOafA0mVNShIhk6BFljGuO/X991/nvc88duDc2Z5+zP+9e93XP3efcfVfH3mfv9Vu/9VsZnud5BgAAAAAAAOygYju6AwAAAAAAAIBAEwAAAAAAAAJDRhMAAAAAAAACQaAJAAAAAAAAgSDQBAAAAAAAgEAQaAIAAAAAAEAgCDQBAAAAAAAgEASaAAAAAAAAEAgCTQAAAAAAAAgEgSYAAAAAAAAEgkATAAAAAAAAAkGgCQAAAAAAAIEg0AQAAAAAAIBAEGgCAAAAAABAIAg0AQAAAAAAIBAEmgAAAAAAABAIAk0AAAAAAAAIBIEmAAAAAAAABIJAEwAAAAAAAAJBoAkAAAAAAACBINAEAAAAAACAQBBoAgAAAAAAQCAINAEAAAAAACAQBJoAAAAAAAAQCAJNAAAAAAAACASBJgAAAAAAAASCQBMAAAAAAAACQaAJAAAAAAAAgcgMZjcAAAAAACAlLV5s9umnscd77WV20kmp/XeQVBme53nJbQIAAAAAAEiakSPNWraMPT7hBLPPP0/tv4OkYuocAAAAAAAAAsHUOQAAAAAAomzPPc06dYo9Pvjg1P87SCoCTQAAAAAApKJFi8xGjMhd8+jXX82++86sfn2zww7Lee0PP5j98otZ3bpmDRvm3k+lSmYtWuTsJ9GWLWZTpsR+V5V39t/frEEDs1Kl/vnrtvZ3CqrdtGSJ2ZgxZhUrmh15pFmFCvn//5cujb2mWDGzVq3Mypc3GzrUbP362PPnn29WsuQ/fFOxowg0AQAAAACQiqZPN7vkkpyaRwoyXXed2ebNsW2dO5s9+aTZRRfFAjC+du3M3n47FqCRmTNz78cv0v3zz2ZnnGE2a1buv6uAznnnmT33nFlGRtFft7W/k3d7drbZhRearV0b21a9utmHH5o1bpyz70GDYv+v/muqVjX74IPYtmXLYtvOOotAUxJQowkAAAAAgFSnIM9995mdeWYsc0j69TNr3drsiy/M2rePBX7kvffMXn658H3eeWdO8KhpU7NzzzU75phYIKt//5yAVlFfVxSzZ5tdeWWs3bVr52RuXXttzmu+/trs8stzgkz6W0cfbdahQ042E5KGjCYAAAAAAFLd77/HMpw0Za5v35zAzNixZjNmxKbMPf20WZcuse3jxpldfPG297lyZex7tWo5U9Rk1SqzIUNyfi7q64piwQKz8ePNmjSJTZ9TXSdNy5s40WzjxliG0hNPxLaJAk4vvBB7/MorsUwoJBUZTQAAAAAApLpDDokFmWTffXO2q06TgkyiukmJdZEKc845se9//GFWs6bZ6aeb3XCD2fvvx+of+QGkor6uKFTXSUEm2X332JQ4UWBJgSeZNCnn9R075jzWNL1M8mmSjX8BAAAAAABSXZUqOY8Tgy0K1vj8LCBRwe7CXHVVrD7Sm2+aTZ0ay4766KPYc5UrxzKPDjig6K8rCmVFJSpRIn+bN23K2Va6dM7j4sVj/+9//120v4WdgowmAAAAAACQn4p0n3ZabEqaVpTTFDlNhZMVK8wGDPhnrwtKvXo5jxXE8n37LTWaQoCMJgAAAAAAkF/37rHC25oKp2l3WVmx1ep8fpHvor4uKFqh7vPPY4/vvtts+XKzihVjxc9Vw0m1nJA0BJoAAAAAAEB+Bx9s9u67seLiealI9zXX/LPXBUV1nz77zOyll2Irz/XsaZaREQs03XZbTqCJek1JQaAJAAAAAIBUtNdeZp065QR7fDVq5GxXkXDfPvvkbG/YMHcwqKD93HOPWefOZsOHm/38c6wYtzKHDj3UrH17s/Ll/9nrtvZ3trbdLzSujCUpWzZn+6BBZv/+t9mIEbHaTHqsv3f11Tm1m8qV+6fvKAKQ4XlFqQAGAAAAAAAQEr/8YlanTu5tTz5pduONsccnnJAzvQ67FIEmAAAAAACQWo47LjZdrlWr2Mp6kyaZDR4cW1lP2zW1TsEm7HJMnQMAAAAAAKmlfn2zgQPNxozJvV2FyJ95hiBTEpHRBAAAAAAAUo+ymJS5NG+eWZkysXpUZ55pVqVKslsWaQSaAAAAAAAAEIhiwewGAAAAAAAAUUegCQAAAAAAAIEg0AQAAAAAAIBAEGgCAAAAAABAIAg0AQAAAAAAIBAEmgAAAAAAABAIAk0AAAAAAAAIBIEmAAAAAAAABIJAEwAAAAAAAAJBoAkAAAAAAACBINAEAAAAAACAQGQGsxtg5/A8z9auXcvbi6TKysqyjIyMUP0rcG4gLDg/AM4PIB2uH9xbISyyQnZubA8CTQg1BZnKlSuX7GYg4rKzs61s2bIWJpwbCAvOD4DzA0iH6wf3VgiL7JCdG9uDqXMAAAAAAAAIBBlNSBl//PFHykd2kTrWrFlj1apVs1TAuYFdjfMD4PwA0vn6wb0VdrU1KXJuFBWBJqQMBZkINAGcGwDXDoB7K4B+BxBeTJ0DAAAAAABAIAg0AQAAAAAAIBAEmgAAAAAAABAIAk0AAAAAAAAIBIEmAAAAAAAABIJAEwAAAAAAAAJBoAkAAAAAAACBINAEAAAAAACAQBBoAgAAAAAAQCAINAEAAAAAACAcgaa1a9fajz/+aD/99JNt3rzZomjVqlU2cuRIW7duXa7ty5cvt1GjRtmmTZuS1jYAAAAAAICUCDT179/f9tprLzv33HPtvPPOs9q1a9uLL75oUVO2bFm788477eabb45v27Jli5199tk2aNAgK1GiRFLbBwAAAAAAEOpA0++//26dO3d2gRRlM02ZMsW+++47W716da7XKctp1qxZNm3atAIze7Kzs23SpEm2cuVKlxk0ZswYt93zvHxZQn/99ZeNHTu2yPvXa/U72q/atnTp0nx/X7//yy+/uP8H/c1/0nZf8eLFbciQIfbKK6/Yxx9/7Lb17t3bfvvtN3vmmWcKeScBAAAAAAAiHmhauHChy9o59thj49v22GMPu+GGG+I/jx8/3urWrWtt27Z1GU/16tWzH374If78O++8Y9WrV7dLL73UDj74YLvyyivtlFNOiQd5WrZs6f6Ob+rUqXb66acXef96rYJhhx56qF199dW2zz772KuvvporEKXfOfHEE1320fHHH++muxVl33ntv//+1qdPH/f/8uGHH9oDDzxgQ4cOtXLlym3vWwz8Izofr7nmGhsxYkR827fffmudOnWyP//8k3cTkTZ79mz3Wb5s2bL4tr59+9q9996bb5ABiBoNlN1zzz25yiLo/mn48OFJbRcQBjfeeKMNGzYs/rNKhlx44YW5+ihAFM2bN8/dWy1evDi+7YUXXnAzfdQvQcR522nDhg1eo0aN3FefPn280aNHe+vXr48/v2rVKq969epe//7949v0ugYNGrjHS5cu9SpUqOC9+uqr7ud169Z5LVq08MqWLet+3rRpk+78vdmzZ8d/f8yYMV7FihWLtH/Ra8855xy3L+nbt69Xu3Zt93jlypVetWrVvF69esVfP3bsWG/WrFlF2vfWnHXWWa7djz322D98R1GQ7Oxs937qS4+xbTru6tat6475qVOnenvssYf3zjvv8Lal4bEX9vaF0XHHHef95z//iV8Patas6S1YsCDZzUpJYT/+wt6+sJk/f75XunRpb9y4ce7+rk2bNl6HDh28zZs3J7tpKSnsx1/Y2xc2zz//vLfPPvu4vor6CeojDBkyJNnNSllhPv7C3Lawat26tde5c2f3eODAgV6NGjW8OXPmJLtZKSk7zY6/7c5oKlmypMv66dKli33//fd27bXXWpUqVaxXr17ueT2naXTKCtIUOH3Vr1/fTUP7448/3POqbXT++ee715cuXdquv/76Iv/9wvbvU4ZRZmame3zcccfZ/PnzXYRVv79+/Xq77bbb4q9Vdpb2V9R9F0T/H1KhQoV/+I4CO+66665zx/fdd99tbdq0sUcffdTat2/vnjvmmGPcl6Z5AlH0+OOPW79+/dzU5p49e9rnn3/uMl01RVujb61bt7ZHHnkk2c0Edrl9993XZW3onqhjx46utqSynIoVK2ZLliyxK664wmWcJ2Z1AFFx2WWXWcWKFePXCWXC6jxJzADU7Iivvvoqqe0EkuGxxx5zNZofeughu+uuu+yzzz6z/fbbz8aNGxfve+hLmeWIllgEZjuVKlXKTcvRl+jAOumkk9wUNNVD0vS37t275/qd5s2bu7pMqp1UqVKlXM/l/TmvxOkNhe2/WrVq7mcFs3wKOKkTri9NkatcubK7icqrqPvOS/WqvvjiC/ddAThN/dOUOmBXUQBYRek1hU6daqV2+5544gl7++23SfVGZB1xxBHu+tSjRw83rbROnTpuuz7rdT3QuXP77be7hS3+/e9/J7u5wC51yy23uA7DkUceaV9++WV8kE5T6jRQpwE0DSrqPKpRowb/OogM1WJVEPaiiy6y+++/35XjSKT7LdWDTZyaDUTFIYccYieffLJ169bNJkyYYAceeKDbvmLFCvf4qquucj9z3Yie7Q40rVmzxgWa/BsR0Q28fv7f//7nDjoFht59913bbbfd4q9RplD58uVdoGfu3LnuQ1mZUKIb/3jDMjNdkMivmSQz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0n1F0r/++st1qP3s7unTp+d6ja4nnTp1ctcM7mkQFZs3b3alMDRw98ADD7htun+aNWtWrtfp+WbNmiWplUByKLh02223uXusI444wm1TkEmZTaLMWH35HnnkEVccXIN5iIZIB5o0jcg/MXz333+/CxJp+oSeb9u2rbu5eumll9y0CnXoNfJ92WWXubTySy65xMaOHWtTp051aYO33nqrnXbaaW5fRx11lI0ZM4abMux0OjaVxXTBBRfYU0895TrM6ij7AVA9X61aNRdkkmLFitm5557rMjmAdDdo0CA79dRT3ee3Mpu0DPWZZ54ZX4ZaU+US+VPq1MkA0n3KnO5v+vTp46Y5KDNDwdi8HQENVGi7pg0BUaH7e2W56p7/2WefdeeLpv1olexE77//vuts77///q7fULJkyaS1GdhVdO3Q+aA+h/oTe+65pwssVapUKd9rde7079/frrjiCtcHQTRE+l967ty5+Uarf/rpJ7vxxhtt/vz5LkCkrA+NVKjzrovHeeedZ08//bQb6ZZrrrnGdebVqe/evbs1atQovi/tW38D2BW1Z1QPQAXpdVP0zTffuDphb731lnv+t99+s7333jvX7+hnTaMDonB+6LzQDY6m/Kh2njoKmiaUl0arVV9PHWqWq0a601Sg5cuXu3qVr7/+uhuNVjkB1TDzabqpgrV6DRDFeystGqT+gc6FI4880gYPHhx/jfoDeo0G+5Qd2KBBA/cYiML5ocEJJWQsWbLEBZ4OPPBA14cuaBaRtitoi+iIdEaTivepKGxexx13nD3++OPx9PFXXnnFdciVESLqoGhanLKfdMGpUqWK7bfffi4olUgp6Duymh1QVMq8UGdBtZf8kWgFQJVh53cY8hZ21egCEJXzQx1qBf7VIdCxr3pNGoFTfQGftl999dVuyulXX32V1DYDu4KftadspSFDhrjHKvRdp04dl82hzGx1IjTAVrFiRbfgBBCl80OrU2vwQbVY/XNFZQp0Xog/8CzKmG3atKmrQ6MasEC6nx+LFi1y5Tl0ffD70L169cpXlkM/K2Dr96URDZEONKkqfkE3TcoE8f3444+ujofmaCfatGmT67RodZatUcd/9913D7jVQH5+5oU6BT4FQe+++253rGplxbzZS6pRkzfLCUjX80M1A/xVgBR01bTpX375JdcNk6ZCaxq0gkx8diMK9thj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8kl755134q/xPM8GDRrkMn7+/e9/24ABA9w237p16+zee+91wambbrrJRo4caaeccop7bvPmzdaiRQtbuHBhrr95+umnF3n/eu2oUaPstttuc4/1N5YtW5br91966SXr2LGj+38YMWJEkfed6KSTTrIrrrjCvXbTpk02c+ZMu/32212WU+XKlXfkbQaKnF04f/78fNvnzZtnK1eu5F1EpP3222+5PvvF/6zWtQaIKh3/Og80eJho+fLltmDBgqS1CwiDv/76y+bOnZtvu84NnSMAgJ0QaKpWrZrL5vnkk09yBWAqVKgQf3zppZe6qWMKJCnj6emnn7auXbvGnz/zzDPtq6++cq/bd999XbBnzJgx7jntU0EiBaMSP/DHjh1b5P3rtRdddJHVrl3brr76aps4caJddtll8ef1XK9evezEE0+0s88+2wXKfvzxxyLtOy/tR9PoFGC64IIL7LrrrnOBMmBX0HnTuHHjXNt0LDds2NCys7P5R0CkdevWzX02J3r44YfddaF48eJJaxeQbMWKFbMmTZrYl19+Gd+2ZcsWO/nkk23YsGFJbRuQbJMmTbJDDjkk14DE7Nmz3TYCTQCwDd4Oev31171atWp5lStX9lq1auX16NHDW7p0qXtu2rRpXmZmprd48eL463/66SevRIkS3vr1672JEyd6pUqV8pYsWRJ//sEHH/TKli3rHm/atEnRK2/27Nnx58eMGeNVrFixSPsXvbZ///7x58eOHeuVK1fOPZ4yZYpXvHhx79dff40/v2XLFm/t2rVF2ndBpk+f7pUpU8Zr3Lixt3Hjxu1+XxGTnZ3tjgF96TG2TueR3qdZs2bFj+WmTZt6PXv2dD9/8skn7ivxeEfqHnthb1/YDBgwwDvooIPiP8+dO9crX768N3nyZPfzsmXL3PVh9erVSWxl6gj78Rf29oVNmzZtvHvvvTf+8zPPPOM1aNDA+/vvv93P8+fP9yZMmBD/Gal9/IW9fWGi90f9ge+//z6+rXXr1l7Xrl1zvW706NG5+gzY9nvK8Qekv0zbQarNpK9ff/3VJk+e7OoS9evXz2VS6EsjxR06dMg3XUFpqErV3m+//axq1arx544++ugi/+3C9n/AAQe4n/3vUqVKFZfdocwjTcPbZ599XLaTLyMjw8qUKVPkfed10EEHua+zzjrLSpQoUeT/F2BH6TyqX7++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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot_alignment_arrows(operations, response[\"notes\"], reference[\"notes\"])" + ] } ], "metadata": { From c5a3ef0d095406e1d606d70bae79d3626ec84b2c Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Thu, 18 Jun 2026 22:31:29 +0100 Subject: [PATCH 12/22] modify the visualisation part --- notebooks/Note_alignment.ipynb | 63 ++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/notebooks/Note_alignment.ipynb b/notebooks/Note_alignment.ipynb index 657e129..34f2521 100644 --- a/notebooks/Note_alignment.ipynb +++ b/notebooks/Note_alignment.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 16, + "execution_count": 1, "id": "96c2775c", "metadata": {}, "outputs": [], @@ -14,7 +14,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 2, "id": "a6822f11", "metadata": {}, "outputs": [], @@ -371,7 +371,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 3, "id": "81666295", "metadata": {}, "outputs": [], @@ -499,7 +499,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 4, "id": "277f0d01", "metadata": {}, "outputs": [], @@ -611,7 +611,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 5, "id": "dacd1ce1", "metadata": {}, "outputs": [], @@ -653,7 +653,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 6, "id": "c8497dc1", "metadata": {}, "outputs": [ @@ -742,7 +742,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "864814b2", "metadata": {}, "outputs": [], @@ -820,7 +820,7 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 12, "id": "0216997f", "metadata": {}, "outputs": [], @@ -831,37 +831,42 @@ "\n", "def plot_cost_matrix(D, operations, response_notes, ref_notes):\n", " fig, ax = plt.subplots(figsize=(8, 6))\n", + " \n", + " # Slice off the padding row/column — they are only needed for the algorithm,\n", + " # not meaningful to display\n", + " D_display = D[1:, 1:]\n", " \n", - " im = ax.imshow(D, origin=\"upper\", aspect=\"auto\", cmap=\"gray_r\")\n", + " im = ax.imshow(D_display, origin=\"upper\", aspect=\"auto\", cmap=\"gray_r\")\n", " plt.colorbar(im, ax=ax, label=\"Accumulated cost\")\n", - " \n", + "\n", " # Annotate every cell with its value\n", - " for i in range(D.shape[0]):\n", - " for j in range(D.shape[1]):\n", - " ax.text(j, i, f\"{D[i, j]:.0f}\",\n", + " for i in range(D_display.shape[0]):\n", + " for j in range(D_display.shape[1]):\n", + " val = D_display[i, j]\n", + " ax.text(j, i, f\"{val:.0f}\",\n", " ha=\"center\", va=\"center\", fontsize=8,\n", - " color=\"white\" if D[i, j] > D.max() * 0.5 else \"black\")\n", - " \n", + " color=\"white\" if val > D_display.max() * 0.5 else \"black\")\n", + " \n", " # Reconstruct path coordinates from operations\n", - " # D has a padding row/column at index 0, so shift by +1\n", - " path_rows, path_cols = [0], [0] # start at (0,0) — the base case\n", + " # path coordinates — no +1 shift needed after slicing D\n", + " path_rows, path_cols = [], []\n", " for op in operations:\n", " if op[\"type\"] in (\"match\", \"replacement\"):\n", - " path_rows.append(op[\"response_idx\"] + 1)\n", - " path_cols.append(op[\"ref_idx\"] + 1)\n", + " path_rows.append(op[\"response_idx\"])\n", + " path_cols.append(op[\"ref_idx\"])\n", " elif op[\"type\"] == \"extra\":\n", - " path_rows.append(op[\"response_idx\"] + 1)\n", - " path_cols.append(path_cols[-1]) # ref col stays the same\n", + " path_rows.append(op[\"response_idx\"])\n", + " path_cols.append(path_cols[-1] if path_cols else 0)\n", " elif op[\"type\"] == \"missing\":\n", - " path_rows.append(path_rows[-1]) # response row stays the same\n", - " path_cols.append(op[\"ref_idx\"] + 1)\n", + " path_rows.append(path_rows[-1] if path_rows else 0)\n", + " path_cols.append(op[\"ref_idx\"])\n", " \n", " ax.plot(path_cols, path_rows, \"ro-\", markersize=10, linewidth=2,\n", " label=\"Optimal alignment path\")\n", " \n", - " # Axis labels — empty string for the padding row/col\n", - " ref_labels = [\"\"] + [f\"$x_{j}$\" for j in range(len(ref_notes))]\n", - " response_labels = [\"\"] + [f\"$y_{i}$\" for i in range(len(response_notes))]\n", + " # axis labels\n", + " ref_labels = [f\"$x_{j}$\" for j in range(len(ref_notes))]\n", + " response_labels = [f\"$y_{i}$\" for i in range(len(response_notes))]\n", " ax.set_xticks(range(len(ref_labels)))\n", " ax.set_xticklabels(ref_labels, fontsize=8)\n", " ax.set_yticks(range(len(response_labels)))\n", @@ -929,13 +934,13 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 13, "id": "3bd7b6f2", "metadata": {}, "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAvEAAAJOCAYAAAA+pFhBAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAkZRJREFUeJzt3Qd8U+X3x/FvuoCyQYYMWYqouJChggMRBFkyFEUUUURBBSeCe/5d4FbcE0QRAWULiANFRdwL2SIqexfoSP6v84T011ZGV3qT5vN+vUJ7s/r0piTnnnue8/gCgUBAAAAAAKJGnNcDAAAAAJA3BPEAAABAlCGIBwAAAKIMQTwAAAAQZQjiAQAAgChDEA8AAABEGYJ4AAAAIMoQxAMAAABRhiAeAAAAiDIE8QAiWqNGjdSzZ09FmjPPPFNNmzb1ehgxI1L/DgDAKwTxQIS6++675fP5dMIJJ3g9lKhVt25dnX/++V4PQ4FAQOPGjdPZZ5+tqlWrqkSJEqpdu7ZOP/10Pffcc9q2bVtE/P4nnnii+5urVauW/H7/f25fvny54uLi3H1uvPHGIhlTuIR+19ClbNmyql+/vrp166Y333xTaWlpXg8RAPaLIB6IQBZAvfrqqy6Y+vbbb90F0Sk1NVXnnHOO+vbtq1atWmn+/PkuaLevXbt21fDhwzVy5EhFitKlS2v16tWaNWvWf2577bXXlJyc7Mm4fv/9d40fP75Qn7NmzZruAMsua9as0ZQpU3TSSSdp8ODB7uDZ9gMARCqCeCACffjhh1q5cqXeeOMNVa9eXS+++KLXQ0I+3XTTTZo8ebLef/993XLLLWrQoIGSkpLcAdp1113nDtAsOx0pbFzNmjVzAXtWFui+/vrrxbakxQ5OjjzySA0dOlSff/65O+vQo0cPr4cFAPtEEA9EIAvajz76aLVu3Vr9+/fXW2+9pZSUlL3e991339Upp5yicuXKqVKlSq5kY+HChbm+T3p6uisnuO222/7z3FbuYWUHWdlBxSWXXKKvv/7aZS0t+DnuuOM0b948d7tlmE8++WR3/WGHHeaC16zy+vP2xu4XKoOIj493Y+rdu7dWrFiReR+7zQ6E3nnnncz72jizshIXG6tln+1iz/vZZ59lu4+VVVjwbVlbu88ZZ5zhssK5sXbtWo0aNUqdOnVSu3bt9nqfevXquf2Z9SyMZeYtoLSym8qVK7vAOefPnDZtmntN7fWsWLGi+37SpEl5+v33xcZjz7Vly5bM6z766CP3fFnHWtivSehva8GCBTr11FNVqlQpDRs2bK818TNmzHA/x16brOzvzZ733nvvVX7Zvh8wYIC++uorzZ07N9/PAwDhRBAPRBgL/CxzO2jQILd9xRVXaMeOHS7g3Fvd/AUXXOACqB9//NFlD60U4PHHH8/TffLqr7/+0iOPPOLOFPz5558u6LFA1QL4Bx980JUCrVq1ygX5vXr10t9//63C9PHHH2eWQdjBjZVBWHBoY9i1a5e7j91Wp04d9/ND9/3+++8zn+P+++93QWaXLl20ZMkSLVu2TC1atFCbNm2yBfJ2EPXEE0/o4Ycfdr+HfR0yZIi2bt16wHFa4GsHAR06dMj173bllVe6wNR+xr///qtPP/3UfbWDm8WLF7v7/PDDD64U57TTTtOiRYvcvrZxWS33hg0bcvX774/9vdj933777czr7DW1ibyNGzcO22sS+tuyv9lnn31WS5cuda/J3rRv31633nqr+3uzn2Xs/la2ZPt7bweJeRE66Prkk08K9DwAEDYBABHloYceCpQtWzawbdu2zOvOOeecwMknn5ztfkuXLg3Ex8cHBg0atM/nys190tLSAvZWcOutt/7nttNOOy3QokWLbNdVq1bNjW/jxo2Z161evdo9h922fv36zOv//fffgM/nc79Tfn/e4YcfHujRo0fgQL7//nv3vLNnz868rk6dOoFevXr9574rVqwIJCQkBIYMGfKf20455ZRAy5Yt3fc///yze8577703231++ukn93udcMIJ+x3Tgw8+6B4/ZcqUQG788ssv7v5Dhw7Ndv2aNWsCycnJmb/L008/7e63YcOG/T7fvn7/fbF9b/vbnHfeeYETTzzRfb9ly5ZAqVKlAs8880xg3bp17mffcMMNhfqaGPv7KVGihPt9c9rb30FGRkagbdu2gQoVKrh9d+yxxwYOOeSQbH+D+/tda9asuc/bQ6/95ZdffsDnAgAvkIkHIszLL7+siy++WGXKlMm8zrLyX3zxhX799dfM62bOnKmMjAyXTd6X3NwnPywrbCUcITVq1HClOpaRt/KPkGrVqrn7WZa7MFn22bLF9nMTEhKylWVYVv1ArBTDynrOPffc/9xmmfgvv/zSTUi1TLqxbH1Wlo222vbCFvp5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8k0FQUlJSewKj8XHx3s9BCAiRGqZcbjGFdjTVSa/MeHxxx+vBg0auJaTFsSH2k2uWLFCRx99dOb9li9frhNPPFFeiZwCKQAAAKAIbd++XRs2bPhPiba1lLSJq6Z69equL/zYsWMz7/Prr7+6M67WocYrxSYTDwAAgOhgWfhwniEIZOnxvj87duxQ69at1aZNGzVs2FBr1651veKtD/wll1ySeb/HH39cZ555pi6++GIdccQReuGFF1wAf/bZZ8srMRPE26zitLQ0r4cBAACACFGtWjV9/fXXLstunWYqVKigUaNGueA860FGy5YtXeZ99OjRWr16tZsg26dPH0/HnhArp0r++uuvXB+VIf+y9llF3tnfqLVL5W8VAFCcRUomPjSf7/LLL9eB2ETXe++9V5EiIRYy8BbAJycnq0qVKrn/g7EXf8MG+XbsUKB0aalyZfuLC/dwo15KSorXQ4hq9qazefNmt7AEgTwAAIjZIN5KaCwYsgA+Vx07Nm+WXn9deuopKWvvzwYNpGuukfr2lSpUCOuYo/2gCQVjp/KsbRX7EgBQXIU7Ex8LYqY7Ta7+UGbOtLV2peuuk5Yty36bbdv1drvdDwgT3tgAAMCBxEwQf0AWmHfsKO3cGSylyVlLFbrObrf7FfNA/uGHH9aCBQvy/LjHHntMCxcuDMuYisM+/fHHH70eBgAAEZOwCtclFhDEh0poevQIBul+//73mN1u97P72+PyyWY22/K/F154oa677ro8Bb4PPvhgtvvnN+Deny+++MKNMa+++uor1181mgL6kSNHhmVlx5zPa/t0zZo1hf5zAABA7CGIN1YDbxMyDxTAh9j97P5vvJGvnW4tjI455hi3kID1HC1ZsqROO+20bIsI7M+8efP0zz//ZG6fcsopqlmzpiKNLV9co0YNRTpbke3ff/+NmucFACDakYkvuGI/sfWALKtuk1jz48kng5Nd83jaZsiQIbr00kv1yCOPZF5niwpYe6OuXbu6Tjr333+/C4I/+ugjt/BA37593faMGTNcdtuy8a+99ppuvPFGffbZZ661owXM9rhmzZq5QN+68lx11VWqVKmSHn30UaWnp+umm27KXIHsmWee0dy5c93y8EceeaSuvvpqN6nyQHL7OMs827gOPvhgN8HYHrd48WK1a9dOixYtcj1YGzdu7M4knHTSSfrkk0/cwUmv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a9ZMzZs3V6SjnAYAAACeZOLDdcmLP/74Q0cccYSeeuopff3112rXrp1uuOEGRToy8QAAAIhZV199tY466ijNnDlTcXFxmjt3rs444wz17NlTJ510kiIVmXgAAADEZCZ+06ZNmjNnjvr37+8CeNO6dWs1bNhQ7777riJZscnEBwIB9zUjI8ProcS01NRUr4cASbt27WI/eGzHjh1eDyHmbd26Neb3gdf4fxAZ+z8UI8XK/8+te547588oUaKEu2S1aNEi+f1+HX744dmub9SokX777TdFsmITxG/bts19/fnnn70eSkz74YcfvB4CAADIESOVL18+IvZJUlKSqlevrtq1a4f155QpU+Y/P+POO+/UXXfdtdf4sUKFCtmut+2lS5cqkhWbIL5GjRpatWqVypYtm+cJDZHCjhjtD85+j3Llynk9nJjEa+A9XgPv8Rp4j9cgMkT762AZeAtSLUaKFCVLltTy5cvDfuY+EAj8Jx7MmYU3ycnJe83aW7ea0qVLK5IVmyDe6phq1aql4sDeKKLxzaI44TXwHq+B93gNvMdrEBmi+XWIlAx8zkDeLpGgYcOG7qtl3Y855pjM6237tNNOUyRjYisAAABiUpUqVXTyySfrzTffzLzu22+/deXZ3bp1UyQrNpl4AAAAIK+sP/zpp5+url27ugmtb7zxhi688EK1adNGkYxMfASxWi2bdLG3mi3wGsQK/h94j9fAe7wGkYHXITY0adJEv/76q0499VQlJibqueeey5aZj1S+QCT2HQIAAACwT2TiAQAAgChDEA8AAABEGYJ4AAAAIMoQxAMAAABRhiAeMe3555/X4sWL3fc2x/uZZ57J3EbRmDBhgr744ovM7bfffjvbNsLvs88+0/vvv5+5/dFHH2nSpEns+iJk7zv2fhTyyy+/ZNtG+G3evFkPPvig0tPT3fbatWv1yCOPaPfu3ex+RCSCeMQ0W/b5hhtucN8PGTJE33//vQ477DCvhxVTKlWqpH79+rkPzhdeeMEFLtbuC0Wnbt26uvzyy13QMnfuXA0ePFgnnngiL0ERqlOnjh5++GF3ALts2TL16NFDzZo14zUoQhUqVHB//6+88oq2bNmijh07qn79+rR9RsSixSRimgWORx11lAsa/X6/xo4dq7i4OK1evdoF9SkpKS4TY/dB+HTu3FnJyclauXKlZs2apbJly2rMmDFu3zdt2lQvvfQSuz/Mhg8frp9++skFkNOmTXOBvZ2ZevHFF93rccUVV6hPnz68DmH0zjvv6NFHH9WOHTv07LPPup7VtvS7BfS2RH2XLl10yy238BqEkf0fsOC9Xr16uuSSS1yCIevZEfs/8NVXXykpKYnXAZ5jxVbEtISEBLVs2dKVDvzzzz8ugDfXX3+9WrVqpapVq7oMJeUd4dW6dWt3RsQCFgsYTbt27dxZkaFDh4b5pyP0GlgpwQcffOACeGPBo/3/WL9+vS699FJ3sFW+fHl2WJjY0u99+/bVrbfe6gJ4U7NmTb322muupGPYsGE65ZRT3AXhYZn3+Ph4Va9ePVsAb55++ml3MGUJHyASUE6DmGaZ3jVr1uiYY47R+PHjM69fsmSJrr32WvXu3VsZGRmZNZIofJMnT3Yr41188cUu6xtSpUoV97og/H788Uddc801uummm1xJU4gFMscdd5wL8O1rmTJleDnCZN26dS4DfPvtt+v11193pX7Ggkbb9y1atNBJJ53kzg4iPGyfd+vWTRdddJE+/vhj/f3339nm7pxzzjmU1iCiEMQjZlnAaAGkBe8WzN92222ZE5gsExNSqlQpJjaFiX1Q3nzzzZo6dapGjBjhalFXrVoVrh+HvbAD1p49e+qtt97S//3f/7kJlva6hNgB7I033qi77ror2/8LFJ6tW7fq7LPPdhl4uxx77LGunCZk9uzZOvLII93B1plnnsmuDwNL1lxwwQXubMg999yjK6+8UnfccYe7bdeuXfryyy911llnse8RUQjiEbNdCKx8xoJ4C9JtApmVbVhAY6ze0eqzbXKTTfYrXbq010Mudiw4/Prrr91rUKNGDZd5HzVqVOZrgKJhAbuVa5xwwgmuvOzll1/Wv//+627bvn27Kymw2mAmG4e3O5CVyvTq1ctt2wRX65YVYu9PdrbK6rTtQBeF77vvvnMHSHawauysVK1atVx2/r333nPJHjsj8s0337iDXiASMLEV2Atrt3fZZZe5Gnl7Ux80aBD7qYhZlwgrabI6+UMPPVTz5893B1wo2smuFuBXq1bNbdvckVC9PIrGzJkz3dkqO+jdsGGDe29q3rw5u78Ibdy4UX/++af7vn///u5A186WAF4jiAf2YdOmTS4LEwpgULTsLMjy5cszt60+PjTxGEXDaoLtTFTIEUccQU2wB2cNV6xY4Wrj7QDKvsI7dqawQYMG8vl8vAzwHEE8AAAAEGVIawEAAABRhiAeAAAAiDIE8QAAAECUIYgHAAAAogxBPAAAABBlErweAABEot9++81dKlSooDPOOMPr4QAAkA1BPIBiYdGiRW7VRWP95A8++GC3wmLZsmXz/FyPPfaY7rvvPrVu3dr1pyeIBwBEGvrEAygWRowYoVtvvVXdunWT3+/X77//rr/++ktvvPGGOnXqlKfnskWNBg8erIEDB4ZtvAAAFASZeADFhmXd33777cztiy66SJdddpnWrFnzn9V4v/zySyUkJOj444/XQQcd5K7fsWOHJk+erNWrV7uDAHuuZs2auRUa9/e40MqaM2bMUPfu3fXrr79q6dKlOumkk1SjRo3MlR5//vlntwJwkyZNsq18atfbGE855RT98MMPWrdunU444YS9rha8bNky/fTTT6pVq5Z7npwrR+7v5wAAig+CeADFVocOHTR69Gj9+++/ql69urvu9ddf15AhQ1yQHB8fr6+//lpPPPGE+vbtq5SUFE2aNEm7d+/WggULXGBtgboF8ft7nFmxYoUuuOACdezYUStXrtSRRx6p+vXrq2rVqurfv7+mTZumFi1auLMDdrDw/vvvu4y/GT9+vN58802VK1dOlStXduOwQH3q1Kk69dRT3X3S0tLc87z33nvu4MAOGipVqqQpU6YoMTFR6enpB/w5AIBiJAAAxcAjjzwSqFy5crbr7rnnnkCJEiUCKSkpbvvHH38MlC5dOrBgwYLM+8ydOzdQqlSpwKpVqzKvs+cZO3Zs5nZuHvfdd98F7C21f//+Ab/fn3m///u//wscd9xxga1bt2Zed+211wZatmyZuX3nnXcGfD5f4KOPPsq87pJLLgmcccYZmdt333134KCDDgosXrw487oPP/wwsGPHjlz/HABA8UEmHkCxkZqa6kpgQjXxjz76qG677TaVKlXK3W7Z7kMOOcRlzZcvX25JDHd9UlKS5s+fr3PPPXevz5uXx11zzTXZSlxeffVVnXzyyZo5c6Z7nF0s226P27Vrl0qWLOnu16hRIzeRNuT00093Nf4hdibgyiuv1KGHHpp5Xdu2bfP8cwAAxQNBPIBiw8pgrBzGSksWLlzoAl4LfEMsCLcSEytfyap9+/YqX778Pp83L4+zrjg5H2slOTkfa4G/lc2EgmsrjcnKatkt+A75888/1bBhw/2OMTc/BwBQPBDEAyiWE1stoG/Tpo169eqlOXPmuOus5rxmzZrZJr/mRl4el3OiqT32nHPO0dChQ1UQ1q9+w4YN+x1jYfwcAEB0YMVWAMWSZbKff/55ffLJJ5nZacucf/XVV5n95EOs68zOnTv3+Vz5fVzosa+88oor9cnKOuDkRbt27TRmzBhXKhRik1ttwmth/hwAQHQgEw+g2DrqqKNc95hbbrnFZal79Ojhykts8SbrA2917r/88os++OADffHFF5m18znl93HmoYcecq0jrWOMjcXaU86bN88F/9Y5JrceeOABtWzZUqeddprrgmMBvJ0ZsJaX1p2msH4OACA6kIkHUCzYxFDr0Z7TPffc49pCWhbdSl3Gjh3rJolaaYpN+qxTp46++eYb1woyxJ7Hrg/JzeMqVqzoSndy9mW3Mhzr/W7tH3/88UctXrzYHRRMnDgx8z6NGzfWmWeeme1x9vxZfx87cLDn6dKli2tvaeVC06dPV3Jycq5/DgCg+GDFVgAAACDKkIkHAAAAogxBPAAAABBlCOIBAACAKEMQDwAAAEQZgngAAAAgyhDEAwAAAFGGIB4AAACIMgTxAAAAQJQhiAcAAACiDEE8AAAAEGUI4gEAAIAoQxAPAAAAKLr8P4fnUiPz4damAAAAAElFTkSuQmCC", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAvEAAAJOCAYAAAA+pFhBAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAg5xJREFUeJzt3Qd0VFUXhuFv0obeRZAmYEFEFBELqKACYqGrKCgoVizYu/72imJFsYEoiEpHURSxoaIgYAdUmoDSe5u0+699wsQkUpJMkmnvs9bInGk5uROTfffss4/P8zxPAAAAAKJGQrgnAAAAAKBgCOIBAACAKEMQDwAAAEQZgngAAAAgyhDEAwAAAFGGIB4AAACIMgTxAAAAQJQhiAcAAACiDEE8AAAAEGUI4gFEtEaNGumss85SpGnbtq2OOuqocE8jbkTqzwEAhAtBPBCh7rvvPvl8PjVv3jzcU4la+++/v84999xwT0Oe5+ndd9/V6aefrurVq8vv96tOnTpq06aNBg8erM2bN0fE93/ssce6n7natWsrMzPzP/cvWrRICQkJ7jE33XRTicypuAS/1+ClfPnyatCggbp27ao333xTaWlp4Z4iAOwRQTwQgSyAGjp0qAumZs+e7S6ITqmpqerSpYv69Omj448/XtOnT3dBu/3buXNn3X777XryyScVKcqWLavly5drypQp/7nv9ddfV5kyZcIyr3nz5mn06NFF+pq1atVyJ1h2Wblypd5//30dd9xx6t+/vzt5tuMAAJGKIB6IQB9//LGWLFmiN954QzVq1NArr7wS7imhkG6++Wa99957mjBhgu644w41bNhQKSkp7gTt+uuvdydolp2OFDavFi1auIA9Jwt0hw0bFrMlLXZy0rhxY91yyy36+uuv3acO3bt3D/e0AGC3COKBCGRB+2GHHaaTTjpJl1xyid566y1t27Ztl48dNWqUTjjhBFWoUEFVqlRxJRuzZs3K92PS09NdOcFdd931n9e2cg8rO8jJTiouvPBCzZgxw2UtLfg54ogj9NVXX7n7LcPcsmVLd/uBBx7ogtecCvr1dsUeFyyDSExMdHPq2bOnFi9enP0Yu89OhN55553sx9o8c7ISF5urZZ/tYq87bdq0XI+xsgoLvi1ra485+eSTXVY4P1atWqUXX3xRZ555ptq3b7/Lx9SvX98dz5yfwlhm3gJKK7upWrWqC5zzfs0PPvjAvaf2flauXNldHz9+fIG+/92x+dhrbdy4Mfu2Tz/91L1ezrkW9XsS/NmaOXOmTjzxRJUuXVq33XbbLmviJ0+e7L6OvTc52c+bve4DDzygwrJjf9lll+m7777TZ599VujXAYDiRBAPRBgL/Cxze+WVV7rx5Zdfrq1bt7qAc1d18+edd54LoH766SeXPbRSgKeffrpAjymoZcuWacCAAe6Tgr/++ssFPRaoWgD/6KOPulKgpUuXuiC/R48e+vvvv1WUPv/88+wyCDu5sTIICw5tDjt27HCPsfvq1avnvn7wsT/88EP2azz00EMuyOzUqZP+/PNPLVy4UMccc4xOOeWUXIG8nUQ988wzevzxx933Yf9ee+212rRp017naYGvnQScdtpp+f7errjiCheY2tdYsWKFvvzyS/evndz88ccf7jE//vijK8Vp3bq15s+f7461zctqudeuXZuv739P7OfFHv/2229n32bvqS3kbdKkSbG9J8GfLfuZfeGFF7RgwQL3nuxKhw4ddOedd7qfN/taxh5vZUt2vHd1klgQwZOuL774IqTXAYBi4wGIKI899phXvnx5b/Pmzdm3denSxWvZsmWuxy1YsMBLTEz0rrzyyt2+Vn4ek5aW5tmvgjvvvPM/97Vu3do75phjct227777uvmtW7cu+7bly5e717D71qxZk337ihUrPJ/P576nwn69gw8+2Ovevbu3Nz/88IN73U8++ST7tnr16nk9evT4z2MXL17sJSUleddee+1/7jvhhBO8Vq1aueu//PKLe80HHngg12N+/vln9301b958j3N69NFH3fPff/99Lz9+/fVX9/hbbrkl1+0rV670ypQpk/29PP/88+5xa9eu3ePr7e773x079na8zTnnnOMde+yx7vrGjRu90qVLe4MGDfJWr17tvvaNN95YpO+JsZ8fv9/vvt+8dvVzkJGR4bVr186rVKmSO3aHH364V7du3Vw/g3v6XmvVqrXb+4Pv/aWXXrrX1wKAcCATD0SY1157Tb1791a5cuWyb7Os/DfffKPffvst+7aPPvpIGRkZLpu8O/l5TGFYVthKOIL2228/V6pjGXkr/wjad9993eMsy12ULPts2WL7uklJSbnKMiyrvjdWimFlPWefffZ/7rNM/LfffusWpFom3Vi2PifLRltte1ELfr1u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hrR9e1YpTd7FhcHb7H57XIwH8o8//rhmzpxZYs+LBwMGDNBPP/0U7mkAABAxmfjiusQDgvhgCU337llB+t4WE9r99jh7/M7a5cJYvny57rnnHvXq1UvXX3+9Zs2ale/nPvroo7keXxyB8zfffOPmGMrzoiWgHzhwoObMmVPsr2vHZuXKlUX+dQAAQPwhiDdWA79t294D+CB7nD3+jTcKddB/+eUXNW3aVEuWLFHbtm3dlsCtW7fWyJEj8711/T///JM9PuGEE1SrVi1FmkidV17Tp0932zNHy+sCABDtyMSHLuYXtu6VZdVtEWthPPts1mLXAn5sc+2116pv376uvCKoefPmuvTSS9W5c2fXSeehhx5Sy5Yt9emnn2rVqlXq06ePG0+ePNll4S0b//rrr+umm27StGnT3Nbn++23n3teixYtXKBvXXmuuuoqValSxWWF09PTdfPNN6tBgwbuaw4aNEifffaZUlJS1LhxY1199dVuUeXe5Pd5OedlC1SffvppzZ8/X6effrrmzp2rTp066bDDDnNzPv744933aln8888/X23atHGvYcfoyCOPdAHx33//7Y5R5cqV9fzzz7vOQ/3791f9+vWzv+bo0aP1xRdfyO/364ILLtDhhx+e/TrHHXecu89OgHr06OFOMqZMmaIffvhBTz31lN566y1dc801Ovroo3N9H/bcY445xj13zZo16tmzpxubl19+2X2fycnJatSokS677DJ3LHb1usYWrNr3m3MOAAAABRV/mfijjspanBq87LdfVheagm6wY4+359nzc76evf4eBAIBFwxaEJ/TWWed5fqCB8tPLDC8+OKLXVedhg0b6owzztBvv/2mAw880GW3Lfg799xzVadOnVwlLPY8C6pr1Kih2rVrq0OHDi7wtWA2MTFRZ599dvbXtEDUXqNjx44uQLbgOT/y+7yc8+rXr58++OADF6yPHTvWldoES0tszpdffrmqVavmTgq6dOniTlyCr2EnKvvuu687GejWrZsL3C34t42g7OQm6P7779d7772nVq1auRMVC7b/+OOP7Nexk6eqVau6YNvuW716tTu29rp2gtS9e3d3zHb1fdgxtfnZCYMdw3nz5rn7jjrqKPe80047zQXmdqzN7l7XAvi8cwAAIN6QiQ9d/GXirbyhELXee3y9AvY9twyyBYR5WXC3cePG7LEF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lK6/XrrzTmnMGOnUU8M9WxTCxo0btXDhQqWlpal+/frax07QEBUyMjK0aNEidz0xMdG9fzllZmZq5cqVLuO07777un8R2WrWrKk6deq463///beWLVuW6/5y5cq593nHjh36888/5dknpIh4Bx54oCpXrqyffvrJvXd5VatWTc2bN9e0adO0beffWxQvMvGhI4iP9AD+jDOyymg8T29KulrSIZLmSxrheTrdHrd9e9bjJk0ikI8yEydO1Msvv6z9999fZcqU0TnnnEMQH0VSU1P17bffuhOw1atX65Zbbsm+z4L3sWPHuuDegoaUlBT17t3bvc+IXIcccog6dOjgAvnPPvvM/f8Z1LJlS91xxx3upLtSpUpKT09X//79tcVKHBGRqlSpohEjRqhBgwbatGmTateurTPPPFOzZs3K9bghQ4aoffv2OvzwwzV/vv2FBSIfQXykshIZy8BbAJ+ZqTRJFh6Mk3SypLGSbpaygvjMTCkhIevxljWqVCncs0c+WJZv8ODBevbZZ3XAAQdwzKJQ6dKldf7552vNmjUuCMjJsnlnnXWWOymzbO2bb77pAocTTjghbPPF3n366afu0q9fv//cd8YZZ+i1117TuHH2m1h66aWX1KxZM5e9RWSy7PvAgQM1ZcoUN37ooYd09913q0uXLtmPueyyyzRjxgwdd9xxYZxp/CETH8U18VdffbVGjx6dPZ4zZ446d+7MR5NBw4ZlldBYgC7pF/uB3xnAG/v189fO8hrHHmePf+ONknwbEQLL8h177LGqWLGifv75Z7J5MSZnaZT9sSpbtqySk5PDPS2E4IsvvlDr1q3diVinTp3cpyu//vorxzSCLViwIDuANytWrNB2+/R6p3r16qlnz5565JFHwjRDIAqDePto6/PPP3fXLUtlH0neeeed7o+d1Zn+8ccf7qPquGTZ9+eey3XTSqvVzPPGWXjwT97nPvts1vMR8f755x+tW7dON9xwg8vo2R+S2bNnh3taKAaLFy/W8uXL3Uf1iF7z5s1zn7706NHDXX744Qe3pgXRwUpprr/+eg0YMCD7Nvs09JprrnFxB8KTiS+uSzwIWznN8ccfr7feestdHzp0qI488kgdffTRrsbQ7lu7dq2rJZ0+fbr7OCyvQCDgLkFW6xYzrI3kggW5bipnH8/neZiNy+e8wYJ3e966dVntJxHRLBjY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9fvAnHjiiXo3jGvdCOKjyFNPPaWTTz5Zl1xySbingsLYvl0Jf/4pC9MtiLdfD+9L+kTSTXke6svMlJeQ4Epvtv/+u7RzAylENgsAcgb0nuepZs2aYZ0TCubHH390lzPOOOM/91mwbr+H7QQtMTFRd955p0uo/P333xzmCPbggw+6f6dOnfqfDH3jxo111llnuex827ZtddVVV7kL4isTf9XO9/zCCy9Uv379XNVDXnbyfvrpp7sTvyBLqsZlTfyVV16pUaNGZY9nzZqljh07uj96+C/7WPfnn392v2zmzJnjsvKILkkjRrhAPr+/WiyQ17ZtSnrrrWKeGYrDxx9/rPbBBcyICfPnz1e5cuVc4NemTRtt2LDhP+U2iB5W5mafpgTLa+zvqn0KYydoiC+vvfaa+1TNTuz2pEyZMu7EPXipXLmy4jKIP/DAA/X555+76xa49+/fX/fYAsC0NH377bfusqszoXi1YsUKVwN/11136c0339S1117rMkKIEp5X6D7xSS++yI6vUcYys4sWLVKrVq3CPRUUsVq1aum4447TMccc44J6+5uF6GQJMauHv+yyy7Kz8MZKbxBfO7YmJOQvHB4zZoz2339/tWjRQrfffnvY11CELYg//vjjXaBuXn31VR199NHuDGjjxo267rrrdNppp7ksx+7YmbPVnua8xLLSpUu7j3LsY56BAwe6M8ZPPvmEjHy0WLtWCQsXZte855c93p6ndeuKbWooeh999JFat27tFkEittgnopa1e/zxx9W0aVM1atQo3FNCIa1bt84F8JUqVXL/v44cOdIFf7EeT8STTXnixLyLmgvCgvcBAwa42Ov+++/XhAkT1K5dO2VkZOTr+XbSb1UVBb0vIoP4Zs2aacGCBVq+fLkGDx6cXbO2zz77uOB+bxmsRx55xH0UFrzUqVNHsczqaoNdEYz90ilfvnzYzwKRP9YHPhQ+3ueoYb/Qrfb21FNPDfdUUMRy1r/aJ8WWaLKP1xG97BMzOyG7++67te++++qPP/4IKdBDZGXi69SpkytWtNixsKz64fzzz3cLXi3RPHr0aBevWulkfli1iZ0AFPS+iFzYmpSU5GrPunTpogceeEBlbaObArCPMXIuIrMzrFgO5O0Pha2wtx9A+9c+BrRjmHf1NCKT28gpDzt3t3XuP9rP785FrvUl7appqFeuXInME6Gb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UvKttyr5vvsUGDFCme3ahXv6yKcNGzZo7ty5uW7z+/068sgjOYZRxH6npqSkuOu2K2ZaWlqu+6tWrarKlSu72uQtW7aEaZbxJ5J2bC1OF198sSudueKKK/TKK6+4Wngzbty4kF+bID7C/4D88MMPbgVzxYoVde6552bf9/PPP7t+o/aLZ+3atapVq5bOPvtsJSTw4Uq0WLhwob788svs8bx589ShQweC+CjhHXCAdowfnxXIb98uX0aGvpU0WdL3km6zrbztj4mV19jjt2+Xv3t3BcaMIZCPEva79ZNPPske20m3nWgTxEcX68Ftya5DDjlETzzxhHtfg3r06OESZPZ31mqTJ02apBkzZoR1vogt9erVcyeHloE/7LDDXLxmZTRFgSA+gtkvFAvcZ8+e7bIHOSUlJalfv34uK2QZ+eeee85ldu2HBdGhdevW7mLsPezdu7fatm0b7mmhALz993c17xamW97nip2Xf0+3/2U95r2EBPl79dJ2+/+5UiWOdYSzjNndd9+dPb7lllvUvn37sM4JBRfswX3ffff9575ff/1V77zzTnawbxvvEMSXjHjJxD/88MO69NJLNWTIEN1+++3q37+/LrjgAldJEaqwpW0vv/zy7P9xjO1cdcYZZ8jbmbXCnllGwQJ4Yx8T2qrm4MeFiD5fffWVCxisZyyiR9KIEXYG5gL4/LBAXtu2Kemtt4p5ZihqVtr4119/qWXLlhzcGPLLL7/ogAMOcLtl2uY7c+bMCfeUEIPq16+vBx54QIsXL9bw4cNd0tUy84888ohrL2kbQEVVEG9B6BdffOGuZ2Zm6tprr3XfoDXAt4DGmuFbdhJ7ZyUZFvwRAEavjz76iAxftPE8JQ0eXKinJr34Il1rovD/0TZt2ig5OTncU0ERsxIHC+CtPDW4Dg3FLx6701jC9fTTT9fYsWNded7555+voUOHqlGjRoXatTVsQXyrVq303XffuetW6G/ZDasztLPg2267zX30cOihh7q68F2xAH/Tpk25LvHo66+/1pIlS9S9e/dwTwWFZH807D0kwxdl1q5VwsKF2TXv+WWPt+dp3bpimxqKvqezrUGilCY22QLDl19+WePHj1fPnj3DPR3EierVq+vmm2926+GszaQF8lFTE9+sWTO3sM8+UrAgPrjAz35JBn9R2ope++as/U5e9hHErurb4ont7mUf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R3Szukx7P4Os9hbRyz6utzZ2QdZ20hbAInpZ/bRdrITRTtBs3QNig5W8+f1+t+4BiBYE8QAAAECUoSYeAAAAiDIE8QAAAECUIYgHAAAAogxBPAAAABBlCOIBAACAKEN/LADYhblz57pLpUqV3J4MAABEEoJ4ADFh/vz5mjNnjrtuO23WrFnT9dovX758gV/rqaee0oMPPqiTTjpJTZs2JYgHAEQc+sQDiAlPPPGE7rzzTnXt2tXtaDxv3jwtW7ZMb7zxhs4888wCvdYhhxyi/v37q1+/fsU2XwAAQkEmHkDMsKz722+/nT2+4IILdPHFF2vlypW5Hrd+/Xp9++23bsfNZs2aqVq1au72rVu36r333tPy5cvdSYC9VosWLdSwYcM9Pi+44+7kyZPVrVs3t1PrggULdNxxx2m//fZz9//555/65ZdftO++++rII490u0MG2e02xxNOOEE//vijVq9erebNm7vH5rVw4UL9/PPPbqdmex2fz5fr/j19HQBA7CCIBxCzTjvtNA0fPlwrVqxQjRo13G3Dhg3Ttdde64LkxMREzZgxQ88884z69Omjbdu2afz48QoEApo5c6YLrC1QtyB+T88zixcv1nnnnaczzjhDS5YsUePGjdWgQQNVr15dl1xyiT744AMdc8wx7tMBO1mYMGGCy/ib0aNH680331SFChVUtWpVNw8L1CdNmqQTTzzRPSYtLc29zpgxY9zJgZ00VKlSRe+//76Sk5OVnp6+168DAIghHgDEgAEDBnhVq1bNddv999/v+f1+b9u2bW78008/eWXLlvVmzpyZ/ZjPPvvMK126tLd06dLs2+x1Ro4cmT3Oz/PmzJnj2a/USy65xMvMzMx+3MMPP+wdccQR3qZNm7Jvu+6667xWrVplj++55x7P5/N5n376afZtF154oXfyySdnj++77z6vWrVq3h9//JF928cff+xt3bo1318HABA7yMQDiBmpqamuBCZYEz9w4EDdddddKl26tLvfst1169Z1WfNFixZZEsPdnpKSounTp+vss8/e5esW5HnXXHNNrhKXoUOHqmXLlvroo4/c8+xi2XZ73o4dO1SqVCn3uEaNGrmFtEFt2rRxNf5B9knAFVdcoQMOOCD7tnbt2hX46wAAYgNBPICYYWUwVg5jpSWzZs1yAa8FvkEWhFuJiZWv5NShQwdVrFhxt69bkOdZV5y8z7WSnLzPtcDfymaCwbWVxuRktewWfAf99ddfOuigg/Y4x/x8HQBAbCCIBxCTC1stoD/llFPUo0cPTZ061d1mNee1atXKtfg1PwryvLwLTe25Xbp00S233KJQWL/6tWvX7nGORfF1AADRgR1bAcQky2S/9NJL+uKLL7Kz05Y5/+6777L7yQdZ15nt27fv9rUK+7zgc4cMGeJKfXKyDjgF0b59e40YMcKVCgXZ4lZb8FqUXwcAEB3IxAOIWYceeqjrHnPHHXe4LHX37t1deYntwGp94K3O/ddff9XEiRP1zTffZNfO51XY55nHHnvMtY60jjE2F2tP+dVXX7ng3zrH5NcjjzyiVq1aqXXr1q4LjgXw9smAtby07jRF9XUAANGBTDyAmGALQ61He17333+/awtpWXQrdRk5cqRbJGqlKbbos169evr+++9dK8ggex27PSg/z6tcubIr3cnbl93KcKz3u7V//Omnn/THH3+4k4Jx48ZlP6ZJkyZq27ZtrufZ6+f8fuzEwV6nU6dOrr2llQt9+OGHKlOmTL6/DgAgdrBjKwAAABBlyMQDAAAAUYYgHgAAAIgyBPEAAABAlCGIBwAAAKIMQTwAAAAQZQjiAQAAgChDEA8AAABEGYJ4AAAAIMoQxAMAAABRhiAeAAAAiDIE8QAAAECUIYgHAAAAFF3+D4NdNY7FAW5xAAAAAElFTkSuQmCC", "text/plain": [ "
" ] @@ -950,7 +955,7 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": 14, "id": "7b54ee04", "metadata": {}, "outputs": [ From 6ebef2bb43bee7117d756145be80884e9dd2f41a Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Sat, 20 Jun 2026 23:43:02 +0100 Subject: [PATCH 13/22] evaluate the start time and duration via relative threshold, update the feedback generation function --- notebooks/Note_alignment.ipynb | 697 ++++++++++++++++++++++++++++----- 1 file changed, 602 insertions(+), 95 deletions(-) diff --git a/notebooks/Note_alignment.ipynb b/notebooks/Note_alignment.ipynb index 34f2521..6ee9018 100644 --- a/notebooks/Note_alignment.ipynb +++ b/notebooks/Note_alignment.ipynb @@ -109,7 +109,7 @@ " ref_notes: The reference MIDI note\n", " \n", " Returns:\n", - " path: list of (response_idx, ref_idx) pairs — the optimal alignment\n", + " path: list of (response_idx, reference_idx) pairs — the optimal alignment\n", " C: local cost matrix\n", " D: accumulated cost matrix\n", " \"\"\"\n", @@ -177,7 +177,7 @@ " Returns:\n", " list of event dicts, each one of:\n", " {'type': 'match' or 'replacement' or 'missing' or 'extra', \n", - " 'response_idx': int, 'ref_idx': int, 'cost': int}\n", + " 'response_idx': int, 'reference_idx': int, 'cost': int}\n", " \"\"\"" ] }, @@ -196,7 +196,7 @@ "metadata": {}, "outputs": [], "source": [ - "def evaluate_note_pair(response_note, ref_note, ref_idx,\n", + "def evaluate_note_pair(response_note, ref_note, reference_idx,\n", " timing_tolerance=0.1, duration_tolerance=0.1):\n", " \"\"\"\n", " Evaluate a single aligned note pair and return feedback.\n", @@ -204,7 +204,7 @@ " Args:\n", " response_note: student's note dict\n", " ref_note: reference note dict\n", - " ref_idx: 1-based display index (based on ref position)\n", + " reference_idx: 1-based display index (based on ref position)\n", " timing_tolerance: consider as correct if start is within this tolerance\n", " duration_tolerance: consider as correct if duration is within this tolerance\n", " \n", @@ -218,7 +218,7 @@ " if response_note[\"pitch\"] != ref_note[\"pitch\"]:\n", " is_correct = False\n", " feedback.append(\n", - " f\"Note {ref_idx}: wrong pitch — expected {ref_note['pitch']}, \"\n", + " f\"Note {reference_idx}: wrong pitch — expected {ref_note['pitch']}, \"\n", " f\"played {response_note['pitch']}.\"\n", " )\n", " \n", @@ -226,16 +226,16 @@ " timing_diff = abs(response_note[\"start\"] - ref_note[\"start\"])\n", " if timing_diff > timing_tolerance:\n", " is_correct = False\n", - " feedback.append(f\"Note {ref_idx}: difference in start time: {timing_diff:.2f}s.\")\n", + " feedback.append(f\"Note {reference_idx}: difference in start time: {timing_diff:.2f}s.\")\n", " \n", " # Duration check\n", " duration_diff = abs(response_note[\"duration\"] - ref_note[\"duration\"])\n", " if duration_diff > duration_tolerance:\n", " is_correct = False\n", - " feedback.append(f\"Note {ref_idx}: difference in duration: {duration_diff:.2f}s.\")\n", + " feedback.append(f\"Note {reference_idx}: difference in duration: {duration_diff:.2f}s.\")\n", " \n", " if is_correct:\n", - " feedback.append(f\"Note {ref_idx} (with pitch {ref_note['pitch']}) is correct.\")\n", + " feedback.append(f\"Note {reference_idx} (with pitch {ref_note['pitch']}) is correct.\")\n", " \n", " return is_correct, feedback" ] @@ -270,10 +270,10 @@ " feedback = []\n", " all_correct = True\n", " \n", - " for response_idx, ref_idx in path:\n", + " for response_idx, reference_idx in path:\n", " is_correct, feedback = evaluate_note_pair(\n", - " response_notes[response_idx], ref_notes[ref_idx],\n", - " ref_idx=ref_idx + 1,\n", + " response_notes[response_idx], ref_notes[reference_idx],\n", + " reference_idx=reference_idx + 1,\n", " timing_tolerance=timing_tolerance,\n", " duration_tolerance=duration_tolerance,\n", " )\n", @@ -372,6 +372,43 @@ { "cell_type": "code", "execution_count": 3, + "id": "9d64cb17", + "metadata": {}, + "outputs": [], + "source": [ + "def normalize_start_times(notes):\n", + " \"\"\"\n", + " Shift all notes so that the first note starts at t=0.\n", + " \n", + " Args:\n", + " notes: list of note dicts, each with at least a \"start\" key.\n", + " \n", + " Returns:\n", + " A new list of note dicts (copies, not the original objects), with\n", + " every \"start\" value shifted so notes[0][\"start\"] == 0. Returns an\n", + " empty list unchanged if notes is empty.\n", + " \"\"\"\n", + " if not notes:\n", + " return []\n", + " \n", + " first_start = notes[0][\"start\"]\n", + " \n", + " shifted_notes = []\n", + " for note in notes:\n", + " # Create a copy of the note dict with the \"start\" time shifted\n", + " note_copy = {\n", + " \"pitch\": note[\"pitch\"],\n", + " \"start\": note[\"start\"] - first_start,\n", + " \"duration\": note[\"duration\"],\n", + " }\n", + " shifted_notes.append(note_copy)\n", + " \n", + " return shifted_notes" + ] + }, + { + "cell_type": "code", + "execution_count": 4, "id": "81666295", "metadata": {}, "outputs": [], @@ -408,7 +445,7 @@ " operations: list of transformation ops dicts, in order from first note to last:\n", " {'type': 'match' or 'replacement' or 'missing' or 'extra', \n", " 'response_idx': int or None, \n", - " 'ref_idx': int or None, \n", + " 'reference_idx': int or None, \n", " 'cost': int}\n", " D: accumulated cost matrix, shape (N+1, M+1)\n", " \"\"\"\n", @@ -444,13 +481,13 @@ " if n == 0: \n", " # missing response for ref[m-1] (deletion)\n", " operations.append({\"type\": \"missing\", \"response_idx\": None,\n", - " \"ref_idx\": m - 1, \"cost\": gap_penalty})\n", + " \"reference_idx\": m - 1, \"cost\": gap_penalty})\n", " m -= 1\n", " # at the most left column, only vertical moves possible\n", " elif m == 0: \n", " # extra note response[n-1] (insertion)\n", " operations.append({\"type\": \"extra\", \"response_idx\": n - 1,\n", - " \"ref_idx\": None, \"cost\": gap_penalty})\n", + " \"reference_idx\": None, \"cost\": gap_penalty})\n", " n -= 1\n", " # for all other cases, we can move in any direction (diagonal, vertical, horizontal)\n", " else:\n", @@ -464,17 +501,17 @@ " operations.append({\n", " \"type\": \"match\" if replace_cost == 0 else \"replacement\",\n", " \"response_idx\": n - 1,\n", - " \"ref_idx\": m - 1,\n", + " \"reference_idx\": m - 1,\n", " \"cost\": replace_cost,\n", " })\n", " n, m = n - 1, m - 1\n", " elif min_cost == up: # vertical -> response[n-1] is extra (insertion)\n", " operations.append({\"type\": \"extra\", \"response_idx\": n - 1,\n", - " \"ref_idx\": None, \"cost\": gap_penalty})\n", + " \"reference_idx\": None, \"cost\": gap_penalty})\n", " n -= 1\n", " else: # horizontal -> response is missing for ref[m-1] (deletion)\n", " operations.append({\"type\": \"missing\", \"response_idx\": None,\n", - " \"ref_idx\": m - 1, \"cost\": gap_penalty})\n", + " \"reference_idx\": m - 1, \"cost\": gap_penalty})\n", " m -= 1\n", " \n", " operations.reverse() # reverse the operations to get them in order from first note to last\n", @@ -497,9 +534,17 @@ "# Feedback generation" ] }, + { + "cell_type": "markdown", + "id": "838e89c7", + "metadata": {}, + "source": [ + "#### Version 1.0" + ] + }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "277f0d01", "metadata": {}, "outputs": [], @@ -529,15 +574,15 @@ " for op in operations:\n", " if op[\"type\"] in (\"match\", \"replacement\"):\n", " response_note = response_notes[op[\"response_idx\"]]\n", - " ref_note = ref_notes[op[\"ref_idx\"]]\n", - " ref_idx = op[\"ref_idx\"] + 1 # 1-based for display\n", + " ref_note = ref_notes[op[\"reference_idx\"]]\n", + " reference_idx = op[\"reference_idx\"] + 1 # 1-based for display\n", " correct_note = True\n", "\n", " # Pitch check\n", " if response_note[\"pitch\"] != ref_note[\"pitch\"]:\n", " correct_note = False\n", " feedback.append(\n", - " f\"Note {ref_idx}: wrong pitch — expected {ref_note['pitch']}, \"\n", + " f\"Note {reference_idx}: wrong pitch — expected {ref_note['pitch']}, \"\n", " f\"played {response_note['pitch']}.\"\n", " )\n", " \n", @@ -545,24 +590,24 @@ " timing_diff = abs(response_note[\"start\"] - ref_note[\"start\"])\n", " if timing_diff > timing_tolerance:\n", " correct_note = False\n", - " feedback.append(f\"Note {ref_idx}: difference in start time: {timing_diff:.2f}s.\")\n", + " feedback.append(f\"Note {reference_idx}: difference in start time: {timing_diff:.2f}s.\")\n", " \n", " # Duration check\n", " duration_diff = abs(response_note[\"duration\"] - ref_note[\"duration\"])\n", " if duration_diff > duration_tolerance:\n", " correct_note = False\n", - " feedback.append(f\"Note {ref_idx}: difference in duration: {duration_diff:.2f}s.\")\n", + " feedback.append(f\"Note {reference_idx}: difference in duration: {duration_diff:.2f}s.\")\n", "\n", " if correct_note:\n", - " feedback.append(f\"Note {ref_idx} (pitch {ref_note['pitch']}) is correct.\")\n", + " feedback.append(f\"Note {reference_idx} (pitch {ref_note['pitch']}) is correct.\")\n", " else:\n", " is_correct = False\n", " \n", " elif op[\"type\"] == \"missing\":\n", " is_correct = False\n", - " ref_idx = op[\"ref_idx\"]\n", + " reference_idx = op[\"reference_idx\"]\n", " feedback.append(\n", - " f\"Note {ref_idx + 1} (pitch {ref_notes[ref_idx]['pitch']}) \"\n", + " f\"Note {reference_idx + 1} (pitch {ref_notes[reference_idx]['pitch']}) \"\n", " f\"is missing in your performance.\"\n", " )\n", " \n", @@ -700,62 +745,235 @@ }, { "cell_type": "markdown", - "id": "56c2eed4", + "id": "453b33d9", "metadata": {}, "source": [ - "feedback = {\n", - "\n", - " \"stats\": {\n", - "\n", - " \"pitch_all_correct\": bool, \n", - " \"timing_all_correct\": bool, \n", - " \"duration_all_correct\": bool,\n", - "\n", - " \"total_notes_in_reference\": int, \n", - " \"total_notes_missing\": int, \n", - " \"total_notes_extra\": int,\n", - " \"total_notes_wrong_pitch\": int,\n", - " \"total_notes_wrong_timing\": int,\n", - " \"total_notes_wrong_duration\": int,\n", - " \"total_notes_correct\": int,\n", - " },\n", - "\n", - " \"note_level_feedback\": [\n", - " \n", - " {\n", - " \"reference_index\": int,\n", - " \"response_index\": int,\n", - " \"operation_type\": str, # \"match\" / \"replacement\" / \"missing\" / \"extra\"\n", - " \"pitch_correct\": bool,\n", - " \"timing_correct\": bool,\n", - " \"duration_correct\": bool,\n", - " \"pitch_diff\": int, \n", - " \"timing_diff\": float, \n", - " \"duration_diff\": float, \n", - " },\n", - " ...\n", - " ],\n", - "\n", - " \"feedback_message\": str,\n", - "}" + "Proportional tolerances are used instead of fixed absolute thresholds, because a fixed tolerance (e.g. +/-0.5 s) is unfair: it is too tolerant for long notes and overly strict for short notes" ] }, { "cell_type": "code", - "execution_count": 7, - "id": "864814b2", + "execution_count": 16, + "id": "5288bfea", "metadata": {}, "outputs": [], "source": [ - "def compute_stats(operations, response_notes, ref_notes):\n", + "# Default thresholds/parameters for evaluation\n", + " \n", + "# Timing: |start_diff| / inter-onset interval(IOI) must be below this to be considered correct.\n", + "# e.g. 0.20 means the start can be off by up to 20% of the IOI between notes.\n", + "TIMING_RELATIVE_THRESHOLD = 0.20\n", + " \n", + "# Duration: |response_dur / ref_dur - 1| must be below this to be considered correct.\n", + "# e.g. 0.25 means the student's duration can be off by up to 25% of the reference.\n", + "DURATION_RELATIVE_THRESHOLD = 0.25\n", + " \n", + "# Median duration_ratio thresholds that trigger a global tempo comment.\n", + "GLOBAL_SLOW_THRESHOLD = 1.15 # median ratio > 1.15 → \"overall too slow\"\n", + "GLOBAL_FAST_THRESHOLD = 0.85 # median ratio < 0.85 → \"overall too fast\"\n", + "\n", + "\n", + "def estimate_global_timing(operations, response_notes, ref_notes):\n", " \"\"\"\n", - " Compute summary counts and correctness booleans from note-level feedback.\n", + " Estimate the student's overall tempo relative to the reference, by fitting\n", + " a straight line through the matched note start times:\n", + " response_start ≈ scale * ref_start + offset\n", + " where:\n", + " scale: represents the student's overall speed relative to the reference. \n", + " scale > 1 means the student is playing slower overall\n", + " scale < 1 means faster overall\n", + " offset: represents a constant time shift. \n", + " e.g. always starts 0.3s later than the reference.\n", + " \n", + " Args:\n", + " operations : list of operation (match/replacement/missing/extra)\n", + " response_notes : list of note dicts from response\n", + " ref_notes : list of note dicts from reference \n", " \n", + " Returns:\n", + " scale: float, estimated tempo ratio (1.0 = same speed as reference)\n", + " offset: float(seconds), estimated constant time shift\n", + " \"\"\"\n", + " # Collect (ref_start, response_start) pairs from matched/replaced notes only.\n", + " # Missing/extra notes have no pair, so they cannot contribute to the fit.\n", + " ref_starts = []\n", + " response_starts = []\n", + " for op in operations:\n", + " if op[\"type\"] in (\"match\", \"replacement\"):\n", + " ref_starts.append(ref_notes[op[\"reference_idx\"]][\"start\"])\n", + " response_starts.append(response_notes[op[\"response_idx\"]][\"start\"])\n", + " \n", + " # Not enough points for fitting a meaningful line — assume no drift in tempo.\n", + " if len(ref_starts) < 3:\n", + " return 1.0, 0.0\n", + " \n", + " x = np.array(ref_starts, dtype=float)\n", + " y = np.array(response_starts, dtype=float)\n", + " \n", + " # Least-squares line fit: y = scale * x + offset\n", + " scale, offset = np.polyfit(x, y, 1)\n", + " \n", + " return float(scale), float(offset)\n", + "\n", + "\n", + "def estimate_global_duration_scale(operations, response_notes, ref_notes):\n", + " \"\"\"\n", + " Estimate the student's overall note-length scale relative to the\n", + " reference, by fitting a line through the origin:\n", + " response_duration ≈ duration_scale * ref_duration\n", + " where:\n", + " duration_scale > 1 means notes are held longer overall (slower);\n", + " duration_scale < 1 means notes are held shorter overall (faster).\n", + "\n", + " Args:\n", + " operations: output of note_alignment_ED()\n", + " response_notes: list of student note dicts\n", + " ref_notes: list of reference note dicts\n", + "\n", + " Returns:\n", + " duration_scale (float): estimated duration ratio (1.0 = same as reference)\n", + " \"\"\"\n", + " ref_durations = []\n", + " response_durations = []\n", + " for op in operations:\n", + " if op[\"type\"] in (\"match\", \"replacement\"):\n", + " ref_durations.append(ref_notes[op[\"reference_idx\"]][\"duration\"])\n", + " response_durations.append(response_notes[op[\"response_idx\"]][\"duration\"])\n", + "\n", + " if len(ref_durations) < 3:\n", + " return 1.0\n", + "\n", + " x = np.array(ref_durations, dtype=float)\n", + " y = np.array(response_durations, dtype=float)\n", + "\n", + " # Least-squares fit through the origin: y = scale * x\n", + " # (closed-form solution: scale = sum(x*y) / sum(x*x))\n", + " duration_scale = float(np.sum(x * y) / np.sum(x * x))\n", + "\n", + " return duration_scale\n", + "\n", + "\n", + "def note_level_feedback(operations, response_notes, ref_notes, \n", + " timing_scale=1.0, timing_offset=0.0, duration_scale=1.0,\n", + " timing_relative_threshold=TIMING_RELATIVE_THRESHOLD,\n", + " duration_relative_threshold=DURATION_RELATIVE_THRESHOLD):\n", + " \"\"\"\n", + " Analyse each aligned note pair (or missing/extra event) and return a list\n", + " of note result dicts.\n", + "\n", " Args:\n", " operations : list of operation (match/replacement/missing/extra)\n", " response_notes : list of note dicts from response\n", " ref_notes : list of note dicts from reference \n", + " timing_scale : float, estimated tempo ratio (1.0 = same speed as reference)\n", + " timing_offset : float(seconds), estimated constant time shift (0.0 = no shift)\n", + " duration_scale: float, estimated overall duration ratio\n", + " timing_relative_threshold : float, relative tolerance for timing correctness\n", + " start can be off by up to 20%(default) of the IOI (inter-onset interval)\n", + " IOI is defined as gap between consecutive note start times\n", + " duration_relative_threshold : float, relative tolerance for duration correctness\n", + " duration is correct within +/-20%(default) of the reference duration\n", + " \n", + " Returns:\n", + " note_level_results : list of dicts, each dict contains:\n", + " \"reference_index\" -> int (1-based) or None if operation_type = extra\n", + " \"response_index\" -> int (1-based) or None if operation_type = missing\n", + " \"operation_type\" -> str: \"match\", \"replacement\", \"missing\", or \"extra\"\n", + " \"pitch_correct\" -> bool\n", + " \"pitch_diff\" -> int (semitones) or None if operation_type = missing/extra\n", + " \"timing_correct\" -> bool \n", + " \"timing_abs_diff\" -> float (seconds) or None if operation_type = missing/extra\n", + " “timing_relative_diff” -> float (seconds) or None if operation_type = missing/extra\n", + " \"duration_correct\" -> bool\n", + " \"duration_abs_diff\" -> float (seconds) or None if operation_type = missing/extra\n", + " \"duration_relative_diff\" -> float (seconds) or None if operation_type = missing/extra\n", + " \"\"\"\n", + " # Compute IOI for each reference note: ioi[m] = ref_notes[m][\"start\"] - ref_notes[m-1][\"start\"]\n", + " # floor at 0.05s to avoid division by zero issues\n", + " ref_ioi = [None] * len(ref_notes)\n", + " for m in range(1, len(ref_notes)):\n", + " interval = ref_notes[m][\"start\"] - ref_notes[m - 1][\"start\"]\n", + " ref_ioi[m] = max(interval, 0.05)\n", + " \n", + " note_level_results = []\n", + "\n", + " for op in operations:\n", + " res_idx = op[\"response_idx\"]\n", + " ref_idx = op[\"reference_idx\"]\n", + " op_type = op[\"type\"]\n", " \n", + " # Missing/extra notes: no pitch/timing/duration comparison is possible,\n", + " # so all the numeric fields are set to None.\n", + " if op_type in (\"missing\", \"extra\"):\n", + " note_level_results.append({\n", + " \"reference_index\": (ref_idx + 1) if ref_idx is not None else None,\n", + " \"response_index\": (res_idx + 1) if res_idx is not None else None,\n", + " \"operation_type\": op_type,\n", + " \"pitch_correct\": False,\n", + " \"pitch_diff\": None,\n", + " \"timing_correct\": False,\n", + " \"timing_abs_diff\": None,\n", + " \"timing_relative_diff\": None,\n", + " \"duration_correct\": False,\n", + " \"duration_abs_diff\": None,\n", + " \"duration_relative_diff\": None,\n", + " })\n", + " else:\n", + " # Matched (aligned) note pair\n", + " res_note = response_notes[res_idx]\n", + " ref_note = ref_notes[ref_idx]\n", + "\n", + " # Pitch\n", + " pitch_diff = int(abs(res_note[\"pitch\"] - ref_note[\"pitch\"]))\n", + " pitch_correct = (pitch_diff == 0)\n", + "\n", + " # Timing — residual after removing the global tempo trend\n", + " predicted_start = timing_scale * ref_note[\"start\"] + timing_offset\n", + " timing_abs_diff = abs(res_note[\"start\"] - predicted_start)\n", + " if ref_idx == 0:\n", + " # First note will start at 0, so no difference.\n", + " timing_relative_diff = None\n", + " timing_correct = True\n", + " else:\n", + " ioi = ref_ioi[ref_idx]\n", + " timing_relative_diff = timing_abs_diff / ioi\n", + " timing_correct = (timing_relative_diff <= timing_relative_threshold)\n", + "\n", + " # Duration — residual after removing the global duration-scale trend\n", + " predicted_duration = duration_scale * ref_note[\"duration\"]\n", + " duration_abs_diff = res_note[\"duration\"] - predicted_duration\n", + " ref_dur = max(ref_note[\"duration\"], 0.05) # floor at 0.05s to avoid division by zero issues\n", + " duration_relative_diff = duration_abs_diff / ref_dur\n", + " duration_correct = (abs(duration_relative_diff) <= duration_relative_threshold)\n", + " \n", + " note_level_results.append({\n", + " \"reference_index\": ref_idx + 1,\n", + " \"response_index\": res_idx + 1,\n", + " \"operation_type\": op_type,\n", + " \"pitch_correct\": pitch_correct,\n", + " \"pitch_diff\": pitch_diff,\n", + " \"timing_correct\": timing_correct,\n", + " \"timing_abs_diff\": timing_abs_diff,\n", + " \"timing_relative_diff\": timing_relative_diff,\n", + " \"duration_correct\": duration_correct,\n", + " \"duration_abs_diff\": duration_abs_diff,\n", + " \"duration_relative_diff\": duration_relative_diff,\n", + " })\n", + " \n", + " return note_level_results\n", + "\n", + "\n", + "def compute_stats(note_details, ref_notes, timing_scale=1.0, timing_offset=0.0, duration_scale=1.0):\n", + " \"\"\"\n", + " Compute summary counts and correctness booleans from note-level feedback.\n", + "\n", + " Args:\n", + " note_details : list of dicts, output of note_level_feedback()\n", + " ref_notes : list of reference note dicts\n", + " timing_scale : float, from estimate_global_timing()\n", + " timing_offset : float, from estimate_global_timing()\n", + " duration_scale : float, from estimate_global_duration_scale()\n", + "\n", " Returns:\n", " stats : dict with keys:\n", " \"pitch_all_correct\" -> bool\n", @@ -768,46 +986,335 @@ " \"total_notes_wrong_timing\" -> int (paired notes where timing_correct=False)\n", " \"total_notes_wrong_duration\" -> int (paired notes where duration_correct=False)\n", " \"total_notes_correct\" -> int (paired notes correct on all three dimensions)\n", + " \"timing_scale\" -> float\n", + " \"timing_offset\" -> float\n", + " \"duration_scale\" -> float\n", " \"\"\"\n", - " pass\n", + " paired = [n for n in note_details\n", + " if n[\"operation_type\"] in (\"match\", \"replacement\")]\n", + "\n", + " stats = {\n", + " \"pitch_all_correct\": all(n[\"pitch_correct\"] for n in paired),\n", + " \"timing_all_correct\": all(n[\"timing_correct\"] for n in paired),\n", + " \"duration_all_correct\": all(n[\"duration_correct\"] for n in paired),\n", + " \"total_notes_in_reference\": len(ref_notes),\n", + " \"total_notes_missing\": sum(1 for n in note_details if n[\"operation_type\"] == \"missing\"),\n", + " \"total_notes_extra\": sum(1 for n in note_details if n[\"operation_type\"] == \"extra\"),\n", + " \"total_notes_wrong_pitch\": sum(1 for n in paired if not n[\"pitch_correct\"]),\n", + " \"total_notes_wrong_timing\": sum(1 for n in paired if not n[\"timing_correct\"]),\n", + " \"total_notes_wrong_duration\": sum(1 for n in paired if not n[\"duration_correct\"]),\n", + " \"total_notes_correct\": sum(1 for n in paired\n", + " if n[\"pitch_correct\"] and n[\"timing_correct\"] and n[\"duration_correct\"]\n", + " ),\n", + " \"timing_scale\": timing_scale,\n", + " \"timing_offset\": timing_offset,\n", + " \"duration_scale\": duration_scale,\n", + " }\n", + "\n", + " return stats\n", "\n", "\n", - "def note_level_feedback(operations, response_notes, ref_notes):\n", + "def generate_feedback_message(note_details, response_notes, ref_notes, stats):\n", " \"\"\"\n", - " Build a list of note-level feedback dicts.\n", + " Generate human-readable feedback messages for the student.\n", "\n", " Args:\n", - " operations : list of operation (match/replacement/missing/extra)\n", - " response_notes : list of note dicts from response\n", - " ref_notes : list of note dicts from reference \n", - " \n", + " note_details: list of dicts, output of note_level_feedback()\n", + " response_notes: list of student note dicts\n", + " ref_notes: list of reference note dicts\n", + " stats: dict, output of compute_stats()\n", + "\n", " Returns:\n", - " note_level_feedback : list of dicts, each dict contains:\n", - " \"reference_index\" -> int (1-based) or None if operation_type = extra\n", - " \"response_index\" -> int (1-based) or None if operation_type = missing\n", - " \"operation_type\" -> str: \"match\", \"replacement\", \"missing\", or \"extra\"\n", - " \"pitch_correct\" -> bool\n", - " \"timing_correct\" -> bool\n", - " \"duration_correct\" -> bool\n", - " \"pitch_diff\" -> int\n", - " \"timing_diff\" -> float\n", - " \"duration_diff\" -> float\n", + " feedback_message (str)\n", " \"\"\"\n", - " pass\n", + " paired = [n for n in note_details\n", + " if n[\"operation_type\"] in (\"match\", \"replacement\")]\n", + "\n", + " timing_scale = stats[\"timing_scale\"]\n", + " timing_offset = stats[\"timing_offset\"]\n", + " duration_scale = stats[\"duration_scale\"]\n", + "\n", + " messages = []\n", + "\n", + " # Missing / extra notes\n", + " for n in note_details:\n", + " if n[\"operation_type\"] == \"missing\":\n", + " ref_zero_based = n[\"reference_index\"] - 1\n", + " pitch = ref_notes[ref_zero_based][\"pitch\"]\n", + " messages.append(\n", + " f\"Note {n['reference_index']} (pitch {pitch}) is missing in your performance.\"\n", + " )\n", + " elif n[\"operation_type\"] == \"extra\":\n", + " res_zero_based = n[\"response_index\"] - 1\n", + " extra = response_notes[res_zero_based]\n", + " messages.append(\n", + " f\"Extra note played: pitch {extra['pitch']} at t={extra['start']:.2f}s \")\n", + "\n", + " # Pitch errors\n", + " for n in paired:\n", + " if not n[\"pitch_correct\"]:\n", + " ref_zero_based = n[\"reference_index\"] - 1\n", + " res_zero_based = n[\"response_index\"] - 1\n", + " ref_p = ref_notes[ref_zero_based][\"pitch\"]\n", + " res_p = response_notes[res_zero_based][\"pitch\"]\n", + " messages.append(\n", + " f\"Note {n['reference_index']}: wrong pitch — \"\n", + " f\"expected {ref_p}, played {res_p} \"\n", + " f\"({n['pitch_diff']} semitone(s) off).\"\n", + " )\n", + "\n", + " # Global tempo trend\n", + " if timing_scale > GLOBAL_SLOW_THRESHOLD:\n", + " time_pct = (timing_scale - 1.0) * 100\n", + " duration_pct = (duration_scale - 1.0) * 100\n", + " messages.append(\n", + " f\"Overall, your tempo is slower than the reference \"\n", + " f\"(timing is about {time_pct:.0f}% behind the reference on average and \"\n", + " f\"notes are held about {duration_pct:.0f}% longer than the reference). \"\n", + " f\"No worries! You will get better when you practice more to get more familiar with it!\"\n", + " )\n", + " elif timing_scale < GLOBAL_FAST_THRESHOLD:\n", + " time_pct = (1.0 - timing_scale) * 100\n", + " duration_pct = (1.0 - duration_scale) * 100\n", + " messages.append(\n", + " f\"Overall, your tempo is faster than the reference \"\n", + " f\"(timing is about {time_pct:.0f}% ahead of the reference on average and \"\n", + " f\"notes are held about {duration_pct:.0f}% shorter than the reference). \"\n", + " f\"Don't rush even if you are confident in your performance.\" \n", + " f\"Slow down and give each note its full value.\"\n", + " )\n", + "\n", + " # Local timing errors - these are residuals after removing the global timing trend\n", + " for n in paired:\n", + " if not n[\"timing_correct\"]:\n", + " messages.append(\n", + " f\"Note {n['reference_index']}: timing is off by {n['timing_abs_diff']:.2f}s \"\n", + " f\"({n['timing_relative_diff'] * 100:.0f}% of the expected note interval), \"\n", + " f\"after accounting for the overall tempo trend.\"\n", + " )\n", + " \n", + " # Local duration errors — these are residuals after removing the global duration trend\n", + " for n in paired:\n", + " if not n[\"duration_correct\"]:\n", + " direction = \"longer\" if n[\"duration_abs_diff\"] > 0 else \"shorter\"\n", + " ref_zero_based = n[\"reference_index\"] - 1\n", + " ref_dur = ref_notes[ref_zero_based][\"duration\"]\n", + " duration_pct = abs(n[\"duration_relative_diff\"]) * 100\n", + " messages.append(\n", + " f\"Note {n['reference_index']}: duration is {abs(n['duration_abs_diff']):.2f}s \"\n", + " f\"{direction} than the reference ({ref_dur:.2f}s, i.e. \"\n", + " f\"{duration_pct:.0f}% off, after accounting for the overall duration trend) \"\n", + " )\n", "\n", + " # if All-correctmary\n", + " if not messages:\n", + " messages.append(\"Great performance! All notes are correct.\")\n", "\n", - "def generate_feedback_message(note_level_feedback, stats):\n", + " return \"\\n\".join(messages)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "a17eed61", + "metadata": {}, + "outputs": [], + "source": [ + "class FeedbackResult:\n", + " \"\"\"\n", + " Using a class (instead of returning a tuple) makes unit tests much clearer:\n", + " \n", + " result = compare_performance(response, reference)\n", + " self.assertFalse(result.is_correct)\n", + " self.assertEqual(result.stats[\"total_notes_missing\"], 1)\n", + " self.assertIn(\"missing\", result.feedback_message)\n", + " \n", + " Attributes\n", + " ----------\n", + " is_correct : bool\n", + " True only if every note is perfectly matched on pitch, timing, and duration.\n", + " stats : dict\n", + " Aggregate counts — see compute_stats() for the full key list.\n", + " note_details : list of dicts\n", + " Per-note analysis, one dict per alignment operation.\n", + " Each dict has the keys described in note_level_feedback().\n", + " feedback_message : str\n", + " Human-readable feedback string, ready to display to the student.\n", + " see generate_feedback_message() for details.\n", + " operations : list of dicts\n", + " Raw alignment operations from note_alignment_ED().\n", + " Kept here so visualisation helpers (plot_cost_matrix etc.) can use them.\n", + " D : numpy.ndarray\n", + " Accumulated cost matrix from the alignment step.\n", + " \"\"\"\n", + " \n", + " def __init__(self, is_correct, stats, note_details,\n", + " feedback_message, operations, D):\n", + " self.is_correct = is_correct\n", + " self.stats = stats\n", + " self.note_details = note_details\n", + " self.feedback_message = feedback_message\n", + " self.operations = operations\n", + " self.D = D\n", + " \n", + " def __repr__(self):\n", + " return (\n", + " f\"FeedbackResult(is_correct={self.is_correct}, \"\n", + " f\"stats={self.stats})\"\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "b5f03964", + "metadata": {}, + "outputs": [], + "source": [ + "def compare_performance_ED(responseMIDI, refMIDI, gap_penalty=6.0,\n", + " timing_relative_threshold=TIMING_RELATIVE_THRESHOLD,\n", + " duration_relative_threshold=DURATION_RELATIVE_THRESHOLD):\n", " \"\"\"\n", - " Generate a human-readable feedback string for the student.\n", + " Full pipeline: normalisation → alignment → estimate global trends \n", + " → note level evaluation → feedback.\n", " \n", " Args:\n", - " note_level_feedback : list\n", - " stats : dict\n", + " responseMIDI: student MIDI dict with key \"notes\"\n", + " refMIDI: reference MIDI dict with key \"notes\"\n", + " gap_penalty: cost of an unaligned note\n", + " timing_relative_threshold: see note_level_feedback()\n", + " duration_relative_threshold: see note_level_feedback()\n", " \n", " Returns:\n", - " feedback_message : str \n", + " FeedbackResult object containing all analysis results\n", " \"\"\"\n", - " pass" + " # Step 0: normalise start times \n", + " response_notes = normalize_start_times(responseMIDI[\"notes\"])\n", + " ref_notes = normalize_start_times(refMIDI[\"notes\"])\n", + " \n", + " # Step 1: align notes using edit distance\n", + " operations, D = note_alignment_ED(\n", + " response_notes, ref_notes, gap_penalty\n", + " )\n", + " \n", + " # Step 2: estimate the overall tempo trend\n", + " timing_scale, timing_offset = estimate_global_timing(\n", + " operations, response_notes, ref_notes\n", + " )\n", + " duration_scale = estimate_global_duration_scale(\n", + " operations, response_notes, ref_notes\n", + " )\n", + " \n", + " # Step 3: note-level evaluation\n", + " note_details = note_level_feedback(\n", + " operations, response_notes, ref_notes,\n", + " timing_scale=timing_scale, timing_offset=timing_offset,\n", + " duration_scale=duration_scale,\n", + " timing_relative_threshold=timing_relative_threshold,\n", + " duration_relative_threshold=duration_relative_threshold,\n", + " )\n", + " \n", + " # Step 4: compute summary statistics\n", + " stats = compute_stats(\n", + " note_details, ref_notes,\n", + " timing_scale=timing_scale, timing_offset=timing_offset,\n", + " duration_scale=duration_scale,\n", + " )\n", + " \n", + " # Step 5: generate the human-readable feedback text from those statistics\n", + " feedback_message = generate_feedback_message(\n", + " note_details, response_notes, ref_notes, stats\n", + " )\n", + " \n", + " # Step 6: overall pass/fail judgement, based purely on the stats above\n", + " is_correct = (\n", + " stats[\"total_notes_missing\"] == 0\n", + " and stats[\"total_notes_extra\"] == 0\n", + " and stats[\"pitch_all_correct\"]\n", + " and stats[\"timing_all_correct\"]\n", + " and stats[\"duration_all_correct\"]\n", + " )\n", + " \n", + " return FeedbackResult(\n", + " is_correct = is_correct,\n", + " stats = stats,\n", + " note_details = note_details,\n", + " feedback_message = feedback_message,\n", + " operations = operations,\n", + " D = D,\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "32160f0e", + "metadata": {}, + "outputs": [], + "source": [ + "def evaluation_function(\n", + " response: Any,\n", + " answer: Any,\n", + " params: Params,\n", + ") -> Result:\n", + " \"\"\"\n", + " Function used to evaluate a student response.\n", + " ---\n", + " The handler function passes three arguments to evaluation_function():\n", + "\n", + " - `response` which are the answers provided by the student.\n", + " - `answer` which are the correct answers to compare against.\n", + " - `params` which are any extra parameters that may be useful,\n", + " e.g., error tolerances.\n", + "\n", + " The output of this function is what is returned as the API response\n", + " and therefore must be JSON-encodable. It must also conform to the\n", + " response schema.\n", + "\n", + " Any standard python library may be used, as well as any package\n", + " available on pip (provided it is added to requirements.txt).\n", + "\n", + " The way you wish to structure you code (all in this function, or\n", + " split into many) is entirely up to you. All that matters are the\n", + " return types and that evaluation_function() is the main function used\n", + " to output the evaluation response.\n", + " \"\"\"\n", + " if params is None:\n", + " params = {}\n", + " \n", + " result = compare_performance_ED(\n", + " response,\n", + " answer,\n", + " gap_penalty = params.get(\"gap_penalty\", 6.0),\n", + " timing_relative_threshold = params.get(\"timing_relative_threshold\", TIMING_RELATIVE_THRESHOLD),\n", + " duration_relative_threshold = params.get(\"duration_relative_threshold\", DURATION_RELATIVE_THRESHOLD),\n", + " )\n", + " \n", + " return {\n", + " \"is_correct\": result.is_correct,\n", + " \"feedback\": result.feedback_message,\n", + " }" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "27e6eeb8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note 5 (pitch 67) is missing in your performance.\n", + "Note 2: wrong pitch — expected 62, played 63 (1 semitone(s) off).\n", + "Note 4: duration is 0.15s longer than the reference (0.50s, i.e. 30% off, after accounting for the overall duration trend) \n" + ] + } + ], + "source": [ + "result = compare_performance_ED(response, reference)\n", + "print(result.feedback_message)" ] }, { @@ -820,7 +1327,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "0216997f", "metadata": {}, "outputs": [], @@ -853,13 +1360,13 @@ " for op in operations:\n", " if op[\"type\"] in (\"match\", \"replacement\"):\n", " path_rows.append(op[\"response_idx\"])\n", - " path_cols.append(op[\"ref_idx\"])\n", + " path_cols.append(op[\"reference_idx\"])\n", " elif op[\"type\"] == \"extra\":\n", " path_rows.append(op[\"response_idx\"])\n", " path_cols.append(path_cols[-1] if path_cols else 0)\n", " elif op[\"type\"] == \"missing\":\n", " path_rows.append(path_rows[-1] if path_rows else 0)\n", - " path_cols.append(op[\"ref_idx\"])\n", + " path_cols.append(op[\"reference_idx\"])\n", " \n", " ax.plot(path_cols, path_rows, \"ro-\", markersize=10, linewidth=2,\n", " label=\"Optimal alignment path\")\n", @@ -907,12 +1414,12 @@ " colour = \"green\" if op[\"type\"] == \"match\" else \"red\"\n", " ax.annotate(\"\",\n", " xy =(op[\"response_idx\"], 0.25),\n", - " xytext = (op[\"ref_idx\"], 0.75),\n", + " xytext = (op[\"reference_idx\"], 0.75),\n", " arrowprops = dict(arrowstyle=\"<->\", color=colour,\n", " lw=1.5, mutation_scale=12),\n", " )\n", " elif op[\"type\"] == \"missing\":\n", - " j = op[\"ref_idx\"]\n", + " j = op[\"reference_idx\"]\n", " ax.text(j, 1.45, \"missing\", ha=\"center\", va=\"center\",\n", " fontsize=12, color=\"red\", fontweight=\"bold\")\n", " elif op[\"type\"] == \"extra\":\n", From 68248f6680436c7fcec49930d65aa5b00b1eb4d6 Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Sun, 21 Jun 2026 20:35:54 +0100 Subject: [PATCH 14/22] fix the logic in feedback, add overview feedback --- notebooks/Note_alignment.ipynb | 122 ++++++++++++++++++++++++--------- 1 file changed, 89 insertions(+), 33 deletions(-) diff --git a/notebooks/Note_alignment.ipynb b/notebooks/Note_alignment.ipynb index 6ee9018..aad34d8 100644 --- a/notebooks/Note_alignment.ipynb +++ b/notebooks/Note_alignment.ipynb @@ -753,7 +753,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "5288bfea", "metadata": {}, "outputs": [], @@ -1017,6 +1017,9 @@ "def generate_feedback_message(note_details, response_notes, ref_notes, stats):\n", " \"\"\"\n", " Generate human-readable feedback messages for the student.\n", + " Part 1 — Overview: summary of timing trend, duration trend, and total counts \n", + " of each error type (pitch/missing/extra)\n", + " Part 2 — Note-level feedback: indicate exactly which notes have which problems.\n", "\n", " Args:\n", " note_details: list of dicts, output of note_level_feedback()\n", @@ -1034,20 +1037,79 @@ " timing_offset = stats[\"timing_offset\"]\n", " duration_scale = stats[\"duration_scale\"]\n", "\n", - " messages = []\n", + " overview_messages = []\n", + " detail_messages = []\n", "\n", + " # ---------- Part 1: Overview ----------\n", + " # Tempo: acceptable / too slow / too fast ---\n", + " timing_pct = abs(timing_scale - 1.0) * 100\n", + " duration_pct = abs(duration_scale - 1.0) * 100\n", + " timing_direction = \"behind\" if timing_scale > 1.0 else \"ahead of\"\n", + " duration_direction = \"longer\" if duration_scale > 1.0 else \"shorter\"\n", + " if timing_scale > GLOBAL_SLOW_THRESHOLD:\n", + " overview_messages.append(\n", + " f\"Overall, your tempo is slower than the reference \"\n", + " f\"(timing is about {timing_pct:.0f}% {timing_direction} the reference in general while \"\n", + " f\"notes are held about {duration_pct:.0f}% {duration_direction} than the reference). \"\n", + " f\"No worries! You will get better when you practice more to get more familiar with it!\"\n", + " )\n", + " elif timing_scale < GLOBAL_FAST_THRESHOLD:\n", + " overview_messages.append(\n", + " f\"Overall, your tempo is faster than the reference \"\n", + " f\"(timing is about {timing_pct:.0f}% {timing_direction} the reference in general while \"\n", + " f\"notes are held about {duration_pct:.0f}% {duration_direction} than the reference). \"\n", + " f\"Don't rush even if you are confident in your performance.\" \n", + " f\"Slow down and give each note its full value.\"\n", + " )\n", + " else:\n", + " overview_messages.append(\n", + " f\"Timing: your overall tempo is within an acceptable range. Good job!\"\n", + " f\"The timing is about {timing_pct:.0f}% {timing_direction} the reference in general while \"\n", + " f\"notes are held about {duration_pct:.0f}% {duration_direction} than the reference.\"\n", + " )\n", + "\n", + " # wrong pitch counts\n", + " if stats[\"total_notes_wrong_pitch\"] > 0:\n", + " s = \"is\" if stats[\"total_notes_wrong_pitch\"] == 1 else \"are\"\n", + " note_word = \"note\" if stats[\"total_notes_wrong_pitch\"] == 1 else \"notes\"\n", + " overview_messages.append(\n", + " f\"There {s} {stats['total_notes_wrong_pitch']} {note_word} played with the wrong pitch.\"\n", + " )\n", + " else:\n", + " overview_messages.append(\"There are no pitch errors. Well done!\")\n", + " # missing counts\n", + " if stats[\"total_notes_missing\"] > 0:\n", + " s = \"is\" if stats[\"total_notes_missing\"] == 1 else \"are\"\n", + " note_word = \"note\" if stats[\"total_notes_missing\"] == 1 else \"notes\"\n", + " overview_messages.append(\n", + " f\"There {s} {stats['total_notes_missing']} {note_word} you missed from the reference.\"\n", + " )\n", + " else:\n", + " overview_messages.append(\"There are no missing notes. Great!\")\n", + " # extra counts\n", + " if stats[\"total_notes_extra\"] > 0:\n", + " s = \"is\" if stats[\"total_notes_extra\"] == 1 else \"are\"\n", + " note_word = \"note\" if stats[\"total_notes_extra\"] == 1 else \"notes\"\n", + " overview_messages.append(\n", + " f\"There {s} {stats['total_notes_extra']} extra {note_word} played during practice. \"\n", + " f\"You may need to adjust your fingering or hand position to avoid extra notes.\"\n", + " )\n", + " else:\n", + " overview_messages.append(\"There are no extra notes. Good job!\")\n", + "\n", + " # ---------- Part 2: Detail ----------\n", " # Missing / extra notes\n", " for n in note_details:\n", " if n[\"operation_type\"] == \"missing\":\n", " ref_zero_based = n[\"reference_index\"] - 1\n", " pitch = ref_notes[ref_zero_based][\"pitch\"]\n", - " messages.append(\n", + " detail_messages.append(\n", " f\"Note {n['reference_index']} (pitch {pitch}) is missing in your performance.\"\n", " )\n", " elif n[\"operation_type\"] == \"extra\":\n", " res_zero_based = n[\"response_index\"] - 1\n", " extra = response_notes[res_zero_based]\n", - " messages.append(\n", + " detail_messages.append(\n", " f\"Extra note played: pitch {extra['pitch']} at t={extra['start']:.2f}s \")\n", "\n", " # Pitch errors\n", @@ -1057,37 +1119,16 @@ " res_zero_based = n[\"response_index\"] - 1\n", " ref_p = ref_notes[ref_zero_based][\"pitch\"]\n", " res_p = response_notes[res_zero_based][\"pitch\"]\n", - " messages.append(\n", + " detail_messages.append(\n", " f\"Note {n['reference_index']}: wrong pitch — \"\n", " f\"expected {ref_p}, played {res_p} \"\n", " f\"({n['pitch_diff']} semitone(s) off).\"\n", " )\n", "\n", - " # Global tempo trend\n", - " if timing_scale > GLOBAL_SLOW_THRESHOLD:\n", - " time_pct = (timing_scale - 1.0) * 100\n", - " duration_pct = (duration_scale - 1.0) * 100\n", - " messages.append(\n", - " f\"Overall, your tempo is slower than the reference \"\n", - " f\"(timing is about {time_pct:.0f}% behind the reference on average and \"\n", - " f\"notes are held about {duration_pct:.0f}% longer than the reference). \"\n", - " f\"No worries! You will get better when you practice more to get more familiar with it!\"\n", - " )\n", - " elif timing_scale < GLOBAL_FAST_THRESHOLD:\n", - " time_pct = (1.0 - timing_scale) * 100\n", - " duration_pct = (1.0 - duration_scale) * 100\n", - " messages.append(\n", - " f\"Overall, your tempo is faster than the reference \"\n", - " f\"(timing is about {time_pct:.0f}% ahead of the reference on average and \"\n", - " f\"notes are held about {duration_pct:.0f}% shorter than the reference). \"\n", - " f\"Don't rush even if you are confident in your performance.\" \n", - " f\"Slow down and give each note its full value.\"\n", - " )\n", - "\n", " # Local timing errors - these are residuals after removing the global timing trend\n", " for n in paired:\n", " if not n[\"timing_correct\"]:\n", - " messages.append(\n", + " detail_messages.append(\n", " f\"Note {n['reference_index']}: timing is off by {n['timing_abs_diff']:.2f}s \"\n", " f\"({n['timing_relative_diff'] * 100:.0f}% of the expected note interval), \"\n", " f\"after accounting for the overall tempo trend.\"\n", @@ -1100,17 +1141,19 @@ " ref_zero_based = n[\"reference_index\"] - 1\n", " ref_dur = ref_notes[ref_zero_based][\"duration\"]\n", " duration_pct = abs(n[\"duration_relative_diff\"]) * 100\n", - " messages.append(\n", + " detail_messages.append(\n", " f\"Note {n['reference_index']}: duration is {abs(n['duration_abs_diff']):.2f}s \"\n", " f\"{direction} than the reference ({ref_dur:.2f}s, i.e. \"\n", " f\"{duration_pct:.0f}% off, after accounting for the overall duration trend) \"\n", " )\n", "\n", - " # if All-correctmary\n", - " if not messages:\n", - " messages.append(\"Great performance! All notes are correct.\")\n", - "\n", - " return \"\\n\".join(messages)" + " all_messages = overview_messages\n", + " if detail_messages:\n", + " all_messages = all_messages + [\"\"] + detail_messages # blank line as separator\n", + " else:\n", + " all_messages = all_messages + [\"\", \"Great performance! No further issues found.\"]\n", + " \n", + " return \"\\n\".join(all_messages)" ] }, { @@ -1317,6 +1360,19 @@ "print(result.feedback_message)" ] }, + { + "cell_type": "markdown", + "id": "a672fce0", + "metadata": {}, + "source": [ + "False\n", + "Note 1 (pitch 60) is correct.\n", + "Note 2: wrong pitch — expected 62, played 63.\n", + "Note 3: difference in start time: 0.15s.\n", + "Note 4: difference in duration: 0.20s.\n", + "Note 5 (pitch 67) is missing in your performance." + ] + }, { "cell_type": "markdown", "id": "f3765faf", From e42891ec40d16cd3bb88e42d960a5483fa1490b4 Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Sun, 21 Jun 2026 22:05:18 +0100 Subject: [PATCH 15/22] add some test cases --- notebooks/Note_alignment.ipynb | 171 +++++++++++++++++++++++++++++---- 1 file changed, 153 insertions(+), 18 deletions(-) diff --git a/notebooks/Note_alignment.ipynb b/notebooks/Note_alignment.ipynb index aad34d8..9c1dca8 100644 --- a/notebooks/Note_alignment.ipynb +++ b/notebooks/Note_alignment.ipynb @@ -732,7 +732,7 @@ "id": "db53794c", "metadata": {}, "source": [ - "#### TODO:" + "#### Version 2.0:" ] }, { @@ -740,7 +740,7 @@ "id": "acb5f42b", "metadata": {}, "source": [ - "break `generate_feedback` into three functions, one for note_level_feedback, one for stats, one for feedback message. " + "TODO: test with longer notes, tune all the parameters, fix and improve feedback messages" ] }, { @@ -753,7 +753,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "id": "5288bfea", "metadata": {}, "outputs": [], @@ -1063,7 +1063,7 @@ " )\n", " else:\n", " overview_messages.append(\n", - " f\"Timing: your overall tempo is within an acceptable range. Good job!\"\n", + " f\"Timing: your overall tempo is within an acceptable range. Good job! \"\n", " f\"The timing is about {timing_pct:.0f}% {timing_direction} the reference in general while \"\n", " f\"notes are held about {duration_pct:.0f}% {duration_direction} than the reference.\"\n", " )\n", @@ -1143,13 +1143,13 @@ " duration_pct = abs(n[\"duration_relative_diff\"]) * 100\n", " detail_messages.append(\n", " f\"Note {n['reference_index']}: duration is {abs(n['duration_abs_diff']):.2f}s \"\n", - " f\"{direction} than the reference ({ref_dur:.2f}s, i.e. \"\n", - " f\"{duration_pct:.0f}% off, after accounting for the overall duration trend) \"\n", + " f\"{direction} than the reference (i.e. \"\n", + " f\"{duration_pct:.0f}% off) after accounting for the overall duration trend \"\n", " )\n", "\n", - " all_messages = overview_messages\n", + " all_messages = [\"Overview: \"] + overview_messages\n", " if detail_messages:\n", - " all_messages = all_messages + [\"\"] + detail_messages # blank line as separator\n", + " all_messages = all_messages + [\"\", \"Detail: \"] + detail_messages\n", " else:\n", " all_messages = all_messages + [\"\", \"Great performance! No further issues found.\"]\n", " \n", @@ -1209,7 +1209,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 7, "id": "b5f03964", "metadata": {}, "outputs": [], @@ -1341,7 +1341,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 13, "id": "27e6eeb8", "metadata": {}, "outputs": [ @@ -1349,9 +1349,16 @@ "name": "stdout", "output_type": "stream", "text": [ + "Overview: \n", + "Timing: your overall tempo is within an acceptable range. Good job! The timing is about 3% behind the reference in general while notes are held about 10% longer than the reference.\n", + "There is 1 note played with the wrong pitch.\n", + "There is 1 note you missed from the reference.\n", + "There are no extra notes. Good job!\n", + "\n", + "Detail: \n", "Note 5 (pitch 67) is missing in your performance.\n", "Note 2: wrong pitch — expected 62, played 63 (1 semitone(s) off).\n", - "Note 4: duration is 0.15s longer than the reference (0.50s, i.e. 30% off, after accounting for the overall duration trend) \n" + "Note 4: duration is 0.15s longer than the reference (i.e. 30% off) after accounting for the overall duration trend \n" ] } ], @@ -1362,15 +1369,143 @@ }, { "cell_type": "markdown", - "id": "a672fce0", + "id": "e2043a53", + "metadata": {}, + "source": [ + "### Test" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "705bee47", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loaded 18 test cases\n", + " - perfect_performance (short)\n", + " - single_pitch_error (short)\n", + " - multiple_pitch_errors (short)\n", + " - missing_note (short)\n", + " - extra_note (short)\n", + " - missing_and_extra (short)\n", + " - global_tempo_slower (short)\n", + " - global_tempo_faster (short)\n", + " - global_duration_longer (short)\n", + " - local_timing_anomaly_only (short)\n", + " - local_duration_anomaly_only (short)\n", + " - first_note_no_timing_penalty (short)\n", + " - too_few_matches_for_global_fit (short)\n", + " - all_notes_missing (short)\n", + " - all_notes_extra (short)\n", + " - repeated_pitch_ambiguous_alignment (short)\n", + " - long_stress_test (long)\n", + " - long_perfect_performance (long)\n" + ] + } + ], + "source": [ + "test_path = os.path.join(dir, \"data\", \"test_cases.json\")\n", + "\n", + "with open(test_path) as f:\n", + " test_cases = json.load(f)\n", + "\n", + "print(f\"Loaded {len(test_cases)} test cases\")\n", + "for case in test_cases:\n", + " print(f\" - {case['case_id']} ({case['length_category']})\")" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "34952131", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Purpose: Both a missing note and an extra note occur, verifying the two gap types don't interfere with each other\n", + "\n", + "is_correct: False\n", + "stats: {'pitch_all_correct': True, 'timing_all_correct': True, 'duration_all_correct': True, 'total_notes_in_reference': 7, 'total_notes_missing': 1, 'total_notes_extra': 1, 'total_notes_wrong_pitch': 0, 'total_notes_wrong_timing': 0, 'total_notes_wrong_duration': 0, 'total_notes_correct': 6, 'timing_scale': 1.0, 'timing_offset': 7.251946429389433e-16, 'duration_scale': 1.0}\n", + "\n", + "Overview: \n", + "Timing: your overall tempo is within an acceptable range. Good job! The timing is about 0% ahead of the reference in general while notes are held about 0% shorter than the reference.\n", + "There are no pitch errors. Well done!\n", + "There is 1 note you missed from the reference.\n", + "There is 1 extra note played during practice. You may need to adjust your fingering or hand position to avoid extra notes.\n", + "\n", + "Detail: \n", + "Extra note played: pitch 90 at t=0.30s \n", + "Note 6 (pitch 65) is missing in your performance.\n" + ] + } + ], + "source": [ + "# Pick one case by id to inspect closely\n", + "case_id = \"missing_and_extra\"\n", + "case = next(c for c in test_cases if c[\"case_id\"] == case_id)\n", + "\n", + "print(\"Purpose:\", case[\"purpose\"])\n", + "print()\n", + "\n", + "result = compare_performance_ED(case[\"response\"], case[\"reference\"])\n", + "\n", + "print(\"is_correct:\", result.is_correct)\n", + "print(\"stats:\", result.stats)\n", + "print()\n", + "print(result.feedback_message)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "772e0e46", "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[perfect_performance ] is_correct=True missing=0 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", + "[single_pitch_error ] is_correct=False missing=0 extra=0 wrong_pitch=1 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", + "[multiple_pitch_errors ] is_correct=False missing=0 extra=0 wrong_pitch=3 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", + "[missing_note ] is_correct=False missing=1 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", + "[extra_note ] is_correct=False missing=0 extra=1 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", + "[missing_and_extra ] is_correct=False missing=1 extra=1 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", + "[global_tempo_slower ] is_correct=True missing=0 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.40 duration_scale=1.00\n", + "[global_tempo_faster ] is_correct=True missing=0 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=0.60 duration_scale=1.00\n", + "[global_duration_longer ] is_correct=True missing=0 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.80\n", + "[local_timing_anomaly_only ] is_correct=False missing=0 extra=0 wrong_pitch=0 wrong_timing=1 wrong_duration=0 timing_scale=0.99 duration_scale=1.00\n", + "[local_duration_anomaly_only ] is_correct=False missing=0 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=1 timing_scale=1.00 duration_scale=0.90\n", + "[first_note_no_timing_penalty ] is_correct=False missing=0 extra=0 wrong_pitch=1 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", + "[too_few_matches_for_global_fit ] is_correct=False missing=0 extra=0 wrong_pitch=0 wrong_timing=1 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", + "[all_notes_missing ] is_correct=False missing=4 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", + "[all_notes_extra ] is_correct=False missing=0 extra=4 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", + "[repeated_pitch_ambiguous_alignment ] is_correct=False missing=0 extra=0 wrong_pitch=1 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", + "[long_stress_test ] is_correct=False missing=1 extra=1 wrong_pitch=3 wrong_timing=2 wrong_duration=1 timing_scale=1.10 duration_scale=1.01\n", + "[long_perfect_performance ] is_correct=True missing=0 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n" + ] + } + ], "source": [ - "False\n", - "Note 1 (pitch 60) is correct.\n", - "Note 2: wrong pitch — expected 62, played 63.\n", - "Note 3: difference in start time: 0.15s.\n", - "Note 4: difference in duration: 0.20s.\n", - "Note 5 (pitch 67) is missing in your performance." + "for case in test_cases:\n", + " result = compare_performance_ED(case[\"response\"], case[\"reference\"])\n", + " print(\n", + " f\"[{case['case_id']:35s}] \"\n", + " f\"is_correct={result.is_correct!s:5} \"\n", + " f\"missing={result.stats['total_notes_missing']} \"\n", + " f\"extra={result.stats['total_notes_extra']} \"\n", + " f\"wrong_pitch={result.stats['total_notes_wrong_pitch']} \"\n", + " f\"wrong_timing={result.stats['total_notes_wrong_timing']} \"\n", + " f\"wrong_duration={result.stats['total_notes_wrong_duration']} \"\n", + " f\"timing_scale={result.stats['timing_scale']:.2f} \"\n", + " f\"duration_scale={result.stats['duration_scale']:.2f}\"\n", + " )" ] }, { From 9b86eed5c9dcba3629e422c74ba034856f366e95 Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Tue, 23 Jun 2026 22:10:37 +0100 Subject: [PATCH 16/22] deploy the evaluation functions --- evaluation_function/compare_MIDI.py | 715 ++++++++++++++++++++++++++++ evaluation_function/evaluation.py | 89 ++-- 2 files changed, 757 insertions(+), 47 deletions(-) create mode 100644 evaluation_function/compare_MIDI.py diff --git a/evaluation_function/compare_MIDI.py b/evaluation_function/compare_MIDI.py new file mode 100644 index 0000000..363b98e --- /dev/null +++ b/evaluation_function/compare_MIDI.py @@ -0,0 +1,715 @@ +""" +compare_MIDI.py +================ +Core MIDI evaluation pipeline for the compareMusic evaluation function. + +Pipeline overview (called in order by compare_performance_ED): + Step 0 -- normalize_start_times (make first note start at t = 0.0) + Step 1 -- note_alignment_ED (edit-distance alignment) + Step 2 -- estimate_global_timing (linear regression for tempo drift) + estimate_global_duration_scale + Step 3 -- note_level_feedback (per-note pitch / timing / duration check) + Step 4 -- compute_stats (summary counts) + Step 5 -- generate_feedback_message (human-readable text) +""" + + +import numpy as np + +# Default thresholds / parameters +# Teachers can override any of these via the params dict in evaluation_function. +# ------------------------------------------------------------------------------ +# Gap penalty: cost of leaving a note unaligned (insertion/deletion) +DEFAULT_GAP_PENALTY = 6 + +# Timing: |response_start - predicted_start| / IOI must be below this. +# e.g. 0.20 means the start can be off by up to 20% of the inter-onset interval. +TIMING_RELATIVE_THRESHOLD = 0.20 + +# Duration: |response_dur / ref_dur - 1| must be below this. +# e.g. 0.25 means the student's duration can be off by up to 25% of the reference. +DURATION_RELATIVE_THRESHOLD = 0.25 + +# Thresholds that trigger a global tempo comment in the overview. +GLOBAL_SLOW_THRESHOLD = 1.15 # timing_scale > 1.15 -> "overall too slow" +GLOBAL_FAST_THRESHOLD = 0.85 # timing_scale < 0.85 -> "overall too fast" + + +# Step 0 - make first note start at t = 0.0 +# ------------------------------------------------------------------------------ +def normalize_start_times(notes): + """ + Shift all notes so that the first note starts at t=0. + + Args: + notes: list of note dicts, each with at least a "start" key. + + Returns: + A new list of note dicts (copies, not the original objects), with + every "start" value shifted so notes[0]["start"] == 0. Returns an + empty list unchanged if notes is empty. + """ + if not notes: + return [] + + first_start = notes[0]["start"] + + shifted_notes = [] + for note in notes: + # Create a copy of the note dict with the "start" time shifted + note_copy = { + "pitch": note["pitch"], + "start": note["start"] - first_start, + "duration": note["duration"], + } + shifted_notes.append(note_copy) + + return shifted_notes + + +# Step 1 -- edit-distance alignment to identify missing/extra notes and pitch errors +# ------------------------------------------------------------------------------ +def compute_cost(note1, note2): + """ + Cost of aligning (replacing) one note with another, based on pitch. + + cost = 0: pitches are identical (a 'match'). + cost > 0: different pitches (a 'replacement') + + Args: + note1: dict with keys "pitch" (int), "start" (float), "duration" (float) + note2: dict with keys "pitch" (int), "start" (float), "duration" (float) + + Returns: + int: cost value >= 0 (lower means more similar pitch) + """ + return int(abs(note1["pitch"] - note2["pitch"])) + + +def note_alignment_ED(response_notes, ref_notes, gap_penalty=DEFAULT_GAP_PENALTY): + """ + Align notes using edit distance (ED). + The ED allows for insertions and deletions, which can be useful for + evaluating musical practice containing missing/extra notes. + + Args: + response_notes: The student's response MIDI notes to evaluate + ref_notes: The reference MIDI note + gap_penalty: cost of leaving a note unaligned (insertion/deletion) + + Returns: + operations: list of transformation ops dicts, in order from first note to last: + {'type': 'match' or 'replacement' or 'missing' or 'extra', + 'response_idx': int or None, + 'reference_idx': int or None, + 'cost': int} + D: accumulated cost matrix, shape (N+1, M+1) + """ + # the rows of D correspond to response notes + N = len(response_notes) + # the columns of D correspond to reference notes + M = len(ref_notes) + + # Build the accumulated cost matrix D of size (N+1 x M+1) + D = np.zeros((N + 1, M + 1), dtype=int) + + # Boundary conditions: aligning against an empty sequence means every note + # is unaligned, so the cost is n (or m) times the gap penalty. + for n in range(1, N + 1): + D[n, 0] = n * gap_penalty # n extra response notes + for m in range(1, M + 1): + D[0, m] = m * gap_penalty # m missing ref notes + + # Recursion (accumulated cost / score matrix D): + for n in range(1, N + 1): + for m in range(1, M + 1): + replace_cost = compute_cost(response_notes[n-1], ref_notes[m-1]) + D[n, m] = min( + D[n-1, m-1] + replace_cost, # diagonal: match or replacement + D[n-1, m] + gap_penalty, # vertical: extra note response[n-1] + D[n, m-1] + gap_penalty, # horizontal: missing response for ref[m-1] + ) + + # Backtrack and classify each transformation op based on movement direction in D + operations = [] + n, m = N, M + while n > 0 or m > 0: + # Boundary conditions: at the top row, only horizontal moves possible + if n == 0: + # Missing response for ref[m-1] (deletion) + operations.append({ + "type": "missing", + "response_idx": None, + "reference_idx": m - 1, + "cost": gap_penalty, + }) + m -= 1 + # At the leftmost column, only vertical moves possible + elif m == 0: + # Extra note response[n-1] (insertion) + operations.append({ + "type": "extra", + "response_idx": n - 1, + "reference_idx": None, + "cost": gap_penalty, + }) + n -= 1 + # For all other cases, we can move in any direction (diagonal, vertical, horizontal) + else: + replace_cost = compute_cost(response_notes[n - 1], ref_notes[m - 1]) + diag = D[n - 1, m - 1] + replace_cost # diagonal: match or replacement + up = D[n - 1, m] + gap_penalty # vertical: extra note response[n-1] + left = D[n, m - 1] + gap_penalty # horizontal: missing response for ref[m-1] + min_cost = min(diag, up, left) # find the minimum cost step + # classify the transformation ops based on the minimum cost step + if min_cost == diag: # Diagonal -> two notes are aligned (match or replacement) + operations.append({ + "type": "match" if replace_cost == 0 else "replacement", + "response_idx": n - 1, + "reference_idx": m - 1, + "cost": replace_cost, + }) + n, m = n - 1, m - 1 + elif min_cost == up: # Vertical -> response[n-1] is extra (insertion) + operations.append({ + "type": "extra", + "response_idx": n - 1, + "reference_idx": None, + "cost": gap_penalty, + }) + n -= 1 + else: # Horizontal -> response is missing for ref[m-1] (deletion) + operations.append({ + "type": "missing", + "response_idx": None, + "reference_idx": m - 1, + "cost": gap_penalty, + }) + m -= 1 + + operations.reverse() # Reverse to get ops in order from first note to last + return operations, D + + +# Step 2 -- estimate_global_timing and estimate_global_duration_scale +# ------------------------------------------------------------------------------ +def estimate_global_timing(operations, response_notes, ref_notes): + """ + Estimate the student's overall tempo relative to the reference, by fitting + a straight line through the matched note start times: + response_start ≈ scale * ref_start + offset + where: + scale > 1 means the student is playing slower overall + scale < 1 means the student is playing faster overall + offset captures any constant time shift + + Args: + operations: list of operation dicts (match/replacement/missing/extra) + response_notes: list of note dicts from response + ref_notes: list of note dicts from reference + + Returns: + scale: float, estimated tempo ratio (1.0 = same speed as reference) + offset: float (seconds), estimated constant time shift + """ + # Collect (ref_start, response_start) pairs from matched/replaced notes only. + # Missing/extra notes have no pair, so they cannot contribute to the fit. + ref_starts = [] + response_starts = [] + for op in operations: + if op["type"] in ("match", "replacement"): + ref_starts.append(ref_notes[op["reference_idx"]]["start"]) + response_starts.append(response_notes[op["response_idx"]]["start"]) + + # Not enough points for fitting a meaningful line — assume no drift in tempo. + if len(ref_starts) < 3: + return 1.0, 0.0 + + x = np.array(ref_starts, dtype=float) + y = np.array(response_starts, dtype=float) + + # Least-squares line fit: y = scale * x + offset + scale, offset = np.polyfit(x, y, 1) + + return float(scale), float(offset) + +def estimate_global_duration_scale(operations, response_notes, ref_notes): + """ + Estimate the student's overall note-length scale relative to the reference, + by fitting a line through the origin: + response_duration ≈ duration_scale * ref_duration + where: + duration_scale > 1 means notes are held longer overall + duration_scale < 1 means notes are held shorter overall + + Args: + operations: output of note_alignment_ED() + response_notes: list of student note dicts + ref_notes: list of reference note dicts + + Returns: + duration_scale (float): estimated duration ratio (1.0 = same as reference) + """ + ref_durations = [] + response_durations = [] + for op in operations: + if op["type"] in ("match", "replacement"): + ref_durations.append(ref_notes[op["reference_idx"]]["duration"]) + response_durations.append(response_notes[op["response_idx"]]["duration"]) + + if len(ref_durations) < 3: + return 1.0 + + x = np.array(ref_durations, dtype=float) + y = np.array(response_durations, dtype=float) + + # Least-squares fit through the origin: y = scale * x + # Closed-form solution: scale = sum(x*y) / sum(x*x) + duration_scale = float(np.sum(x * y) / np.sum(x * x)) + + return duration_scale + + +# Step 3 -- note_level_feedback +# ------------------------------------------------------------------------------ +def note_level_feedback(operations, response_notes, ref_notes, + timing_scale=1.0, timing_offset=0.0, duration_scale=1.0, + timing_relative_threshold=TIMING_RELATIVE_THRESHOLD, + duration_relative_threshold=DURATION_RELATIVE_THRESHOLD): + """ + Analyse each aligned note pair (or missing/extra event) and return a list + of note result dicts. + + Args: + operations: list of op dicts (match/replacement/missing/extra) + response_notes: list of note dicts from response + ref_notes: list of note dicts from reference + timing_scale: float, estimated tempo ratio (1.0 = same speed as reference) + timing_offset: float (seconds), estimated constant time shift + duration_scale: float, estimated overall duration ratio + timing_relative_threshold: float, relative tolerance for timing correctness + duration_relative_threshold: float, relative tolerance for duration correctness + + Returns: + note_level_results : list of dicts, each dict contains: + "reference_index" -> int (1-based) or None if operation_type = extra + "response_index" -> int (1-based) or None if operation_type = missing + "operation_type" -> str: "match", "replacement", "missing", or "extra" + "pitch_correct" -> bool + "pitch_diff" -> int (semitones) or None if operation_type = missing/extra + "timing_correct" -> bool + "timing_abs_diff" -> float (seconds) or None if operation_type = missing/extra + “timing_relative_diff” -> float (seconds) or None if operation_type = missing/extra + "duration_correct" -> bool + "duration_abs_diff"-> float (seconds) or None if operation_type = missing/extra + "duration_relative_diff" -> float (seconds) or None if operation_type = missing/extra + """ + # Compute IOI for each reference note: ioi[m] = ref_notes[m]["start"] - ref_notes[m-1]["start"] + # floor at 0.05s to avoid division by zero issues + ref_ioi = [None] * len(ref_notes) + for m in range(1, len(ref_notes)): + interval = ref_notes[m]["start"] - ref_notes[m - 1]["start"] + ref_ioi[m] = max(interval, 0.05) + + note_level_results = [] + + for op in operations: + res_idx = op["response_idx"] + ref_idx = op["reference_idx"] + op_type = op["type"] + + # Missing/extra notes: no pitch/timing/duration comparison is possible, + # so all the numeric fields are set to None. + if op_type in ("missing", "extra"): + note_level_results.append({ + "reference_index": (ref_idx + 1) if ref_idx is not None else None, + "response_index": (res_idx + 1) if res_idx is not None else None, + "operation_type": op_type, + "pitch_correct": False, + "pitch_diff": None, + "timing_correct": False, + "timing_abs_diff": None, + "timing_relative_diff": None, + "duration_correct": False, + "duration_abs_diff": None, + "duration_relative_diff": None, + }) + else: + # Matched (aligned) note pair + res_note = response_notes[res_idx] + ref_note = ref_notes[ref_idx] + + ## Pitch + pitch_diff = int(abs(res_note["pitch"] - ref_note["pitch"])) + pitch_correct = (pitch_diff == 0) + + # Timing — residual after removing the global tempo trend + predicted_start = timing_scale * ref_note["start"] + timing_offset + timing_abs_diff = abs(res_note["start"] - predicted_start) + if ref_idx == 0: + # First note will start at 0, so no difference. + timing_relative_diff = None + timing_correct = True + else: + ioi = ref_ioi[ref_idx] + timing_relative_diff = timing_abs_diff / ioi + timing_correct = (timing_relative_diff <= timing_relative_threshold) + + # Duration — residual after removing the global duration-scale trend + predicted_duration = duration_scale * ref_note["duration"] + duration_abs_diff = res_note["duration"] - predicted_duration + ref_dur = max(ref_note["duration"], 0.05) # floor at 0.05s to avoid division by zero issues + duration_relative_diff = duration_abs_diff / ref_dur + duration_correct = (abs(duration_relative_diff) <= duration_relative_threshold) + + note_level_results.append({ + "reference_index": ref_idx + 1, + "response_index": res_idx + 1, + "operation_type": op_type, + "pitch_correct": pitch_correct, + "pitch_diff": pitch_diff, + "timing_correct": timing_correct, + "timing_abs_diff": timing_abs_diff, + "timing_relative_diff": timing_relative_diff, + "duration_correct": duration_correct, + "duration_abs_diff": duration_abs_diff, + "duration_relative_diff": duration_relative_diff, + }) + + return note_level_results + + +# Step 4 -- compute_stats +# ------------------------------------------------------------------------------ +def compute_stats(note_details, ref_notes, timing_scale=1.0, + timing_offset=0.0, duration_scale=1.0): + """ + Compute summary counts and correctness booleans from note-level feedback. + + Args: + note_details: list of dicts, output of note_level_feedback() + ref_notes: list of reference note dicts + timing_scale: float, from estimate_global_timing() + timing_offset: float, from estimate_global_timing() + duration_scale: float, from estimate_global_duration_scale() + + Returns: + stats: dict with keys: + "pitch_all_correct" -> bool + "timing_all_correct" -> bool + "duration_all_correct" -> bool + "total_notes_in_reference" -> int + "total_notes_missing" -> int + "total_notes_extra" -> int + "total_notes_wrong_pitch" -> int + "total_notes_wrong_timing" -> int + "total_notes_wrong_duration" -> int + "total_notes_correct" -> int + "timing_scale" -> float + "timing_offset" -> float + "duration_scale" -> float + """ + paired = [n for n in note_details + if n["operation_type"] in ("match", "replacement")] + + stats = { + "pitch_all_correct": all(n["pitch_correct"] for n in paired), + "timing_all_correct": all(n["timing_correct"] for n in paired), + "duration_all_correct": all(n["duration_correct"] for n in paired), + "total_notes_in_reference": len(ref_notes), + "total_notes_missing": sum(1 for n in note_details if n["operation_type"] == "missing"), + "total_notes_extra": sum(1 for n in note_details if n["operation_type"] == "extra"), + "total_notes_wrong_pitch": sum(1 for n in paired if not n["pitch_correct"]), + "total_notes_wrong_timing": sum(1 for n in paired if not n["timing_correct"]), + "total_notes_wrong_duration": sum(1 for n in paired if not n["duration_correct"]), + "total_notes_correct": sum(1 for n in paired + if n["pitch_correct"] and n["timing_correct"] and n["duration_correct"] + ), + "timing_scale": timing_scale, + "timing_offset": timing_offset, + "duration_scale": duration_scale, + } + + return stats + + +# Step 5 -- generate_feedback_message +# ------------------------------------------------------------------------------ +def generate_feedback_message(note_details, response_notes, ref_notes, stats, + global_slow_threshold=GLOBAL_SLOW_THRESHOLD, + global_fast_threshold=GLOBAL_FAST_THRESHOLD): + """ + Generate human-readable feedback messages for the student. + + Part 1 - Overview: summary of timing trend, duration trend, and total counts + of each error type (pitch / missing / extra). + Part 2 - Detail: indicate exactly which notes have which problems. + + Args: + note_details: list of dicts, output of note_level_feedback() + response_notes: list of student note dicts + ref_notes: list of reference note dicts + stats: dict, output of compute_stats() + global_slow_threshold: timing_scale above this triggers "too slow" message + global_fast_threshold: timing_scale below this triggers "too fast" message + + Returns: + feedback_message (str) + """ + paired = [] + for n in note_details: + if n["operation_type"] in ("match", "replacement"): + paired.append(n) + + timing_scale = stats["timing_scale"] + timing_offset = stats["timing_offset"] + duration_scale = stats["duration_scale"] + + overview_messages = [] + detail_messages = [] + + # ---------- Part 1: Overview ---------- + # Tempo: acceptable / too slow / too fast --- + timing_pct = abs(timing_scale - 1.0) * 100 + duration_pct = abs(duration_scale - 1.0) * 100 + timing_direction = "behind" if timing_scale > 1.0 else "ahead of" + duration_direction = "longer" if duration_scale > 1.0 else "shorter" + + if timing_scale > global_slow_threshold: + overview_messages.append( + f"Overall, your tempo is slower than the reference " + f"(timing is about {timing_pct:.0f}% {timing_direction} the reference in general while " + f"notes are held about {duration_pct:.0f}% {duration_direction} than the reference). " + f"No worries! You will get better when you practice more to get more familiar with it!" + ) + elif timing_scale < global_fast_threshold: + overview_messages.append( + f"Overall, your tempo is faster than the reference " + f"(timing is about {timing_pct:.0f}% {timing_direction} the reference in general while " + f"notes are held about {duration_pct:.0f}% {duration_direction} than the reference). " + f"Don't rush even if you are confident in your performance." + f"Slow down and give each note its full value." + ) + else: + overview_messages.append( + f"Timing: your overall tempo is within an acceptable range. Good job! " + f"The timing is about {timing_pct:.0f}% {timing_direction} the reference in general while " + f"notes are held about {duration_pct:.0f}% {duration_direction} than the reference." + ) + + # Wrong pitch counts + if stats["total_notes_wrong_pitch"] > 0: + s = "is" if stats["total_notes_wrong_pitch"] == 1 else "are" + note_word = "note" if stats["total_notes_wrong_pitch"] == 1 else "notes" + overview_messages.append( + f"There {s} {stats['total_notes_wrong_pitch']} {note_word} played with the wrong pitch." + ) + else: + overview_messages.append("There are no pitch errors. Well done!") + # Missing counts + if stats["total_notes_missing"] > 0: + s = "is" if stats["total_notes_missing"] == 1 else "are" + note_word = "note" if stats["total_notes_missing"] == 1 else "notes" + overview_messages.append( + f"There {s} {stats['total_notes_missing']} {note_word} you missed from the reference." + ) + else: + overview_messages.append("There are no missing notes. Great!") + # Extra counts + if stats["total_notes_extra"] > 0: + s = "is" if stats["total_notes_extra"] == 1 else "are" + note_word = "note" if stats["total_notes_extra"] == 1 else "notes" + overview_messages.append( + f"There {s} {stats['total_notes_extra']} extra {note_word} played during practice. " + f"You may need to adjust your fingering or hand position to avoid extra notes." + ) + else: + overview_messages.append("There are no extra notes. Good job!") + + # ---------- Part 2: Detail ---------- + # Missing / extra notes + for n in note_details: + if n["operation_type"] == "missing": + ref_zero_based = n["reference_index"] - 1 + pitch = ref_notes[ref_zero_based]["pitch"] + detail_messages.append( + f"Note {n['reference_index']} (pitch {pitch}) is missing in your performance." + ) + elif n["operation_type"] == "extra": + res_zero_based = n["response_index"] - 1 + extra = response_notes[res_zero_based] + detail_messages.append( + f"Extra note played: pitch {extra['pitch']} at t={extra['start']:.2f}s ") + + # Pitch errors + for n in paired: + if not n["pitch_correct"]: + ref_zero_based = n["reference_index"] - 1 + res_zero_based = n["response_index"] - 1 + ref_p = ref_notes[ref_zero_based]["pitch"] + res_p = response_notes[res_zero_based]["pitch"] + detail_messages.append( + f"Note {n['reference_index']}: wrong pitch — " + f"expected {ref_p}, played {res_p} " + f"({n['pitch_diff']} semitone(s) off)." + ) + + # Local timing errors - these are residuals after removing the global timing trend + for n in paired: + if not n["timing_correct"]: + detail_messages.append( + f"Note {n['reference_index']}: timing is off by {n['timing_abs_diff']:.2f}s " + f"({n['timing_relative_diff'] * 100:.0f}% of the expected note interval), " + f"after accounting for the overall tempo trend." + ) + + # Local duration errors — these are residuals after removing the global duration trend + for n in paired: + if not n["duration_correct"]: + direction = "longer" if n["duration_abs_diff"] > 0 else "shorter" + ref_zero_based = n["reference_index"] - 1 + ref_dur = ref_notes[ref_zero_based]["duration"] + duration_pct = abs(n["duration_relative_diff"]) * 100 + detail_messages.append( + f"Note {n['reference_index']}: duration is {abs(n['duration_abs_diff']):.2f}s " + f"{direction} than the reference (i.e. " + f"{duration_pct:.0f}% off) after accounting for the overall duration trend " + ) + + all_messages = ["Overview: "] + overview_messages + + if detail_messages: + all_messages = all_messages + ["", "Detail: "] + detail_messages + else: + all_messages = all_messages + ["", "Great performance! No further issues found."] + + return "\n".join(all_messages) + + +# FeedbackResult class +# ------------------------------------------------------------------------------ +class FeedbackResult: + """ + Container for all outputs of compare_performance_ED(). + Using a class (instead of returning a tuple) makes unit tests much clearer: + result = compare_performance_ED(response, reference) + assert result.is_correct == False + assert result.stats["total_notes_missing"] == 1 + assert "missing" in result.feedback_message + + Attributes + ---------- + is_correct : bool + True only if every note is perfectly matched on pitch, timing, and duration. + stats : dict + Aggregate counts — see compute_stats() for the full key list. + note_details : list of dicts + Per-note analysis, one dict per alignment operation. + Each dict has the keys described in note_level_feedback(). + feedback_message : str + Human-readable feedback string, ready to display to the student. + see generate_feedback_message() for details. + operations : list of dicts + Raw alignment operations from note_alignment_ED(). + Kept here so visualisation helpers (plot_cost_matrix etc.) can use them. + D : numpy.ndarray + Accumulated cost matrix from the alignment step. + """ + + def __init__(self, is_correct, stats, note_details, + feedback_message, operations, D): + self.is_correct = is_correct + self.stats = stats + self.note_details = note_details + self.feedback_message = feedback_message + self.operations = operations + self.D = D + + def __repr__(self): + return ( + "FeedbackResult(is_correct=" + str(self.is_correct) + ", " + "stats=" + str(self.stats) + ")" + ) + + +# Pipeline +# ------------------------------------------------------------------------------ +def compare_performance_ED(responseMIDI, refMIDI, + gap_penalty=DEFAULT_GAP_PENALTY, + timing_relative_threshold=TIMING_RELATIVE_THRESHOLD, + duration_relative_threshold=DURATION_RELATIVE_THRESHOLD, + global_slow_threshold=GLOBAL_SLOW_THRESHOLD, + global_fast_threshold=GLOBAL_FAST_THRESHOLD): + """ + Full pipeline: normalisation -> alignment -> estimate global trends + -> note-level evaluation -> summary statistics -> feedback. + + Args: + responseMIDI: student MIDI dict with key "notes" + refMIDI: reference MIDI dict with key "notes" + gap_penalty: cost of an unaligned note + timing_relative_threshold: see note_level_feedback() + duration_relative_threshold: see note_level_feedback() + global_slow_threshold: see generate_feedback_message() + global_fast_threshold: see generate_feedback_message() + + Returns: + FeedbackResult object containing all analysis results + """ + # Step 0: Normalise start times + response_notes = normalize_start_times(responseMIDI["notes"]) + ref_notes = normalize_start_times(refMIDI["notes"]) + + # Step 1: Align notes using edit distance + operations, D = note_alignment_ED(response_notes, ref_notes, gap_penalty) + + # Step 2: Estimate the overall tempo trend + timing_scale, timing_offset = estimate_global_timing( + operations, response_notes, ref_notes + ) + duration_scale = estimate_global_duration_scale( + operations, response_notes, ref_notes + ) + + # Step 3: Note-level evaluation + note_details = note_level_feedback( + operations, response_notes, ref_notes, + timing_scale=timing_scale, + timing_offset=timing_offset, + duration_scale=duration_scale, + timing_relative_threshold=timing_relative_threshold, + duration_relative_threshold=duration_relative_threshold, + ) + + # Step 4: Compute summary statistics + stats = compute_stats( + note_details, ref_notes, + timing_scale=timing_scale, + timing_offset=timing_offset, + duration_scale=duration_scale, + ) + + # Step 5: Generate the human-readable feedback text + feedback_message = generate_feedback_message( + note_details, response_notes, ref_notes, stats, + global_slow_threshold=global_slow_threshold, + global_fast_threshold=global_fast_threshold, + ) + + # Step 6: Overall pass/fail judgement + is_correct = ( + stats["total_notes_missing"] == 0 + and stats["total_notes_extra"] == 0 + and stats["pitch_all_correct"] + and stats["timing_all_correct"] + and stats["duration_all_correct"] + ) + + return FeedbackResult( + is_correct=is_correct, + stats=stats, + note_details=note_details, + feedback_message=feedback_message, + operations=operations, + D=D, + ) \ No newline at end of file diff --git a/evaluation_function/evaluation.py b/evaluation_function/evaluation.py index 721722e..367856f 100755 --- a/evaluation_function/evaluation.py +++ b/evaluation_function/evaluation.py @@ -1,48 +1,24 @@ -from typing import Any -from lf_toolkit.evaluation import Result, Params - - -def compute_cost(note1, note2): - """ - Computes the cost used for Dynamic Time Warping. - Lower cost means the two notes are more similar. - """ - pass - -def note_alignment_DTW(responseNotes, refNotes): - """ - Use DTW to find the optimal alignment between response and reference MIDI notes. - """ - pass +""" +evaluation.py +============= +Lambda Feedback platform calls evaluation_function(response, answer, params) +and expects a dict back with at least "is_correct" and "feedback" keys. +All evaluation logic is in compare_music.py, this file is for the platform interface. +""" -def compare_notes(responseMIDI, - refMIDI, - timing_tolerance = 0.1, - duration_tolerance = 0.1): - """ - Compares student's response MIDI notes with reference MIDI notes, - based on pitch, timing, and duration with specified tolerances. - Args: - refMIDI: The reference MIDI note. - responseMIDI: The student's response MIDI note to evaluate. - timing_tolerance: consider as correct if start is within this tolerance. - duration_tolerance: consider as correct if duration is within this tolerance. - Returns: - bool: True if the notes match within the specified tolerances, False otherwise. - """ - ref_notes = refMIDI["notes"] - response_notes = responseMIDI["notes"] +from typing import Any +#from lf_toolkit.evaluation import Result, Params - aligned_notes = note_alignment_DTW(response_notes, ref_notes) +from compare_music import ( + compare_performance_ED, + DEFAULT_GAP_PENALTY, + TIMING_RELATIVE_THRESHOLD, + DURATION_RELATIVE_THRESHOLD, + GLOBAL_SLOW_THRESHOLD, + GLOBAL_FAST_THRESHOLD, +) - feedback = [] - all_correct = True - - # loop over each note pair - - - return all_correct, feedback def evaluation_function( response: Any, @@ -55,7 +31,7 @@ def evaluation_function( The handler function passes three arguments to evaluation_function(): - `response` which are the answers provided by the student. - - `answer` which are the correct answers to compare against. + - `answer` which are the correct answers to compare against.i.e. reference - `params` which are any extra parameters that may be useful, e.g., error tolerances. @@ -71,9 +47,28 @@ def evaluation_function( return types and that evaluation_function() is the main function used to output the evaluation response. """ - all_correct, feedback = compare_notes(response, answer) + if params is None: + params = {} + + result = compare_performance_ED( + response, + answer, + gap_penalty=params.get("gap_penalty", DEFAULT_GAP_PENALTY), + timing_relative_threshold=params.get( + "timing_relative_threshold", TIMING_RELATIVE_THRESHOLD + ), + duration_relative_threshold=params.get( + "duration_relative_threshold", DURATION_RELATIVE_THRESHOLD + ), + global_slow_threshold=params.get( + "global_slow_threshold", GLOBAL_SLOW_THRESHOLD + ), + global_fast_threshold=params.get( + "global_fast_threshold", GLOBAL_FAST_THRESHOLD + ), + ) - return Result( - is_correct=all_correct, - feedback_items=[("feedback", "\n".join(feedback))] - ) \ No newline at end of file + return { + "is_correct": result.is_correct, + "feedback": result.feedback_message, + } \ No newline at end of file From 80122918f57ace4597dacea68924eeccd23b5c77 Mon Sep 17 00:00:00 2001 From: JiayingZhao Date: Tue, 23 Jun 2026 22:13:04 +0100 Subject: [PATCH 17/22] Delete data directory --- data/referenceMIDI.json | 10 ---------- data/responseMIDI.json | 9 --------- 2 files changed, 19 deletions(-) delete mode 100644 data/referenceMIDI.json delete mode 100644 data/responseMIDI.json diff --git a/data/referenceMIDI.json b/data/referenceMIDI.json deleted file mode 100644 index 40c6794..0000000 --- a/data/referenceMIDI.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "performance_type": "reference", - "notes": [ - {"pitch": 60, "start": 0.00, "duration": 0.50}, - {"pitch": 62, "start": 0.60, "duration": 0.50}, - {"pitch": 64, "start": 1.20, "duration": 0.50}, - {"pitch": 65, "start": 1.80, "duration": 0.50}, - {"pitch": 67, "start": 2.50, "duration": 0.50} - ] -} \ No newline at end of file diff --git a/data/responseMIDI.json b/data/responseMIDI.json deleted file mode 100644 index 24d09df..0000000 --- a/data/responseMIDI.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "performance_type": "response", - "notes": [ - {"pitch": 60, "start": 0.00, "duration": 0.50}, - {"pitch": 63, "start": 0.60, "duration": 0.50}, - {"pitch": 64, "start": 1.35, "duration": 0.50}, - {"pitch": 65, "start": 1.80, "duration": 0.70} - ] -} \ No newline at end of file From 9133cfe359a5eb3d1b1a2eaa149a7a8d9f03c68e Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Tue, 23 Jun 2026 22:31:45 +0100 Subject: [PATCH 18/22] fix typo --- evaluation_function/evaluation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evaluation_function/evaluation.py b/evaluation_function/evaluation.py index 367856f..4c3ab8e 100755 --- a/evaluation_function/evaluation.py +++ b/evaluation_function/evaluation.py @@ -8,9 +8,9 @@ from typing import Any -#from lf_toolkit.evaluation import Result, Params +from lf_toolkit.evaluation import Result, Params -from compare_music import ( +from compare_MIDI import ( compare_performance_ED, DEFAULT_GAP_PENALTY, TIMING_RELATIVE_THRESHOLD, From 9f2d9bd1915563a0aba9d8d6b63df5c98a6fc034 Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Tue, 23 Jun 2026 22:59:33 +0100 Subject: [PATCH 19/22] add documentations --- config.json | 2 +- docs/dev.md | 95 ++++++++++++++++++++++++++++++++++++++++++++-------- docs/user.md | 43 ++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 17 deletions(-) diff --git a/config.json b/config.json index 717b48f..c51769f 100644 --- a/config.json +++ b/config.json @@ -1,3 +1,3 @@ { - "EvaluationFunctionName": "evaluation_function" + "EvaluationFunctionName": "compareMusic" } diff --git a/docs/dev.md b/docs/dev.md index 7659400..c40fc0f 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -1,29 +1,96 @@ -# YourFunctionName -*Brief description of what this evaluation function does, from the developer perspective* +# compareMusic + +Automated formative feedback on music practice. Compares a student's MIDI performance against a reference MIDI and generates formatve, note-level feedback covering pitch accuracy, timing, and note duration. ## Inputs -*Specific input parameters which can be supplied when the `eval` command is supplied to this function.* +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `gap_penalty` | float | `6` | Alignment cost for a missing or extra note. Increase this if the function incorrectly splits one wrong note into a "missing + extra" pair. | +| `timing_relative_threshold` | float | `0.20` | Timing tolerance as a fraction of the inter-onset interval (IOI). `0.20` means up to 20% of the interval between consecutive notes. | +| `duration_relative_threshold` | float | `0.25` | Duration tolerance as a fraction of the reference note's duration. `0.25` means up to 25%. | +| `global_slow_threshold` | float | `1.15` | If the student's overall tempo scale exceeds this value, the overview reports "your tempo is slower than the reference". | +| `global_fast_threshold` | float | `0.85` | If the student's overall tempo scale falls below this value, the overview reports "your tempo is faster than the reference". | + +Both `response` and `answer` (i.e. reference) must be a JSON object with a `notes` array: + +```json +{ + "notes": [ + {"pitch": 60, "start": 0.00, "duration": 0.50}, + {"pitch": 62, "start": 0.60, "duration": 0.50} + ] +} +``` + +where `pitch` is an integer representing MIDI note number (e.g. middle C = 60), `start` is float representing note onset time in seconds, and `duration` is float in seconds. ## Outputs -*Output schema/values for this function* -## Examples -*List of example inputs and outputs for this function, each under a different sub-heading* +| Field | Type | Description | +|-------|------|-------------| +| `is_correct` | bool | `true` only when there are no missing notes, no extra notes, all pitches correct, all timing within threshold, and all durations within threshold | +| `feedback` | string | Human-readable feedback string | + +The feedback string is divided into two sections: + +**Overview** — overall tempo judgement, and counts of pitch errors, missing notes, and extra notes. + +**Detail** — note-by-note breakdown of every specific issue found. -### Simple Evaluation +## Examples + +### Perfect performance + +```python +response = { + "notes": [ + {"pitch": 60, "start": 0.00, "duration": 0.50}, + {"pitch": 62, "start": 0.60, "duration": 0.50} + ] +} +answer = { + "notes": [ + {"pitch": 60, "start": 0.00, "duration": 0.50}, + {"pitch": 62, "start": 0.60, "duration": 0.50} + ] +} +params = {} +``` + ```python { - "example": { - "Something": "something" - } + "is_correct": True, + "feedback": "Overview: \nTiming: your overall tempo is within an acceptable range. Good job! ...\n\nGreat performance! No further issues found." } ``` - + +### Wrong pitch and missing note + +```python +response = { + "notes": [ + {"pitch": 60, "start": 0.00, "duration": 0.50}, + {"pitch": 63, "start": 0.60, "duration": 0.50}, + {"pitch": 64, "start": 1.35, "duration": 0.50}, + {"pitch": 65, "start": 1.80, "duration": 0.70} + ] +} +answer = { + "notes": [ + {"pitch": 60, "start": 0.00, "duration": 0.50}, + {"pitch": 62, "start": 0.60, "duration": 0.50}, + {"pitch": 64, "start": 1.20, "duration": 0.50}, + {"pitch": 65, "start": 1.80, "duration": 0.50}, + {"pitch": 67, "start": 2.50, "duration": 0.50} + ] +} +params = {} +``` + ```python { - "example": { - "Something": "something" - } + "is_correct": False, + "feedback": "Overview: \nTiming: your overall tempo is within an acceptable range. ...\nThere is 1 note played with the wrong pitch.\nThere is 1 note you missed from the reference.\nThere are no extra notes. Good job!\n\nDetail: \nNote 5 (pitch 67) is missing in your performance.\nNote 2: wrong pitch -- expected 62, played 63 (1 semitone(s) off)." } ``` \ No newline at end of file diff --git a/docs/user.md b/docs/user.md index 108f533..e088035 100644 --- a/docs/user.md +++ b/docs/user.md @@ -1,3 +1,42 @@ -# YourFunctionName +# compareMusic + +`compareMusic` automatically evaluates a student's MIDI music performance against a reference MIDI and returns structured, formative feedback on pitch accuracy, timing, and note duration. + +## What the student sees + +Feedback is returned in two parts: + +**Overview** — a summary of overall tempo, and counts of pitch errors, missing notes, and extra notes. + +**Detail** — a note-level feedback of every specific issue, including which notes were missed, which had the wrong pitch, and which were played noticeably early, late, or with an incorrect duration. The function separates **global tempo** (playing consistently faster or slower throughout) from **local timing errors** (a single note noticeably early or late relative to surrounding notes), which means student who plays the whole piece at 80% speed will receive one global tempo comment instead of repetitive comments on every note. + +## Setting up a question + +Set the **Answer** field to a JSON object representing the reference MIDI performance, e.g.: + +```json +{ + "notes": [ + {"pitch": 60, "start": 0.00, "duration": 0.50}, + {"pitch": 62, "start": 0.60, "duration": 0.50}, + {"pitch": 64, "start": 1.20, "duration": 0.50} + ] +} +``` + +where `pitch` is an integer representing MIDI note number (e.g. middle C = 60), `start` is float representing note onset time in seconds, and `duration` is float in seconds. + +The student's **Response** must be in the same format. + +## Adjusting strictness + +All parameters are adjustable. If not set, the defaults below are used. + +| Parameter | Default | What it controls | +|-----------|---------|-----------------| +| `timing_relative_threshold` | `0.20` | How much timing deviation is acceptable, as a fraction of the gap between consecutive notes. Lower = stricter. | +| `duration_relative_threshold` | `0.25` | How much duration deviation is acceptable, as a fraction of the reference note's duration. Lower = stricter. | +| `gap_penalty` | `6` | Controls note alignment. Increase this if the function incorrectly reports a wrong note as "missing + extra". | +| `global_slow_threshold` | `1.15` | Overall tempo more than 15% slower than reference triggers a "too slow" comment. | +| `global_fast_threshold` | `0.85` | Overall tempo more than 15% faster than reference triggers a "too fast" comment. | -Teacher-facing documentation for this function. \ No newline at end of file From 3ce7cd61312e85d892540a69d3ffe7b770cb0258 Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Wed, 24 Jun 2026 16:09:51 +0100 Subject: [PATCH 20/22] add tests, tests all passed for now --- evaluation_function/evaluation_test.py | 319 ++++++++++++++++++++----- 1 file changed, 255 insertions(+), 64 deletions(-) diff --git a/evaluation_function/evaluation_test.py b/evaluation_function/evaluation_test.py index e715e1f..73f5eaa 100755 --- a/evaluation_function/evaluation_test.py +++ b/evaluation_function/evaluation_test.py @@ -1,75 +1,266 @@ +""" +evaluation_tests.py +=================== +Unit tests for the compareMusic evaluation function. + Read the docs on how to use unittest here: + https://docs.python.org/3/library/unittest.html +Run locally with: python -m pytest evaluation_test.py -v + +Sections +-------- +1. Helper: make_midi +2. Tests for normalize_start_times +3. Tests for note_alignment_ED +4. Tests for estimate_global_timing and estimate_global_duration_scale +5. Tests for note_level_feedback and compute_stats +6. Tests for evaluation_function (Lambda Feedback integration) +7. Tests for parameter overrides +""" + + import unittest -from .evaluation import Params, evaluation_function import json +from .compare_MIDI import ( + normalize_start_times, + compute_cost, + note_alignment_ED, + estimate_global_timing, + estimate_global_duration_scale, + compare_performance_ED, + DEFAULT_GAP_PENALTY, + TIMING_RELATIVE_THRESHOLD, + DURATION_RELATIVE_THRESHOLD, + GLOBAL_SLOW_THRESHOLD, + GLOBAL_FAST_THRESHOLD, +) +from .evaluation import evaluation_function -with open("./data/referenceMIDI.json") as f: - reference = json.load(f) +# 1. Helper: make MIDI notes for testing +# ------------------------------------------------------------------------------ +def make_midi(pitches, starts, durations): + notes = [] + for i in range(len(pitches)): + notes.append({ + "pitch": pitches[i], + "start": starts[i], + "duration": durations[i], + }) + return {"notes": notes} -with open("./data/responseMIDI.json") as f: - response = json.load(f) -def make_midi(notes): - return {"notes": [{"pitch": p, "start": s, "duration": d} for p, s, d in notes]} +# 2. Tests for normalize_start_times +# ------------------------------------------------------------------------------ +class TestNormalizeStartTimes: -class TestEvaluationFunction(unittest.TestCase): - """ - TestCase Class used to test the algorithm. - --- - Tests are used here to check that the algorithm written - is working as it should. + def test_first_note_starts_at_zero(self): + notes = make_midi([60, 62], [1.0, 1.5], [0.5, 0.5])["notes"] + result = normalize_start_times(notes) + assert result[0]["start"] == 0.0 + + def test_relative_gaps_preserved(self): + notes = make_midi([60, 62], [1.0, 1.6], [0.5, 0.5])["notes"] + result = normalize_start_times(notes) + assert abs(result[1]["start"] - 0.6) < 0.0001 + + def test_pitch_and_duration_unchanged(self): + notes = make_midi([64], [2.0], [0.8])["notes"] + result = normalize_start_times(notes) + assert result[0]["pitch"] == 64 + assert result[0]["duration"] == 0.8 - It's best practise to write these tests first to get a - kind of 'specification' for how your algorithm should - work, and you should run these tests before committing - your code to AWS. - Read the docs on how to use unittest here: - https://docs.python.org/3/library/unittest.html +# 3. Tests for note_alignment_ED +# ------------------------------------------------------------------------------ +class TestNoteAlignmentED: + + def test_perfect_match_all_match_ops(self): + ref = make_midi([60, 62, 64], [0, 0.5, 1.0], [0.4, 0.4, 0.4]) + res = make_midi([60, 62, 64], [0, 0.5, 1.0], [0.4, 0.4, 0.4]) + operations, D = note_alignment_ED(res["notes"], ref["notes"]) + types = [op["type"] for op in operations] + assert all(t == "match" for t in types) + + def test_missing_note_detected(self): + # pitch 62 missing in response + ref = make_midi([60, 62, 64], [0, 0.5, 1.0], [0.4, 0.4, 0.4]) + res = make_midi([60, 64], [0, 1.0], [0.4, 0.4]) + operations, D = note_alignment_ED(res["notes"], ref["notes"]) + types = [op["type"] for op in operations] + assert "missing" in types + + def test_extra_note_detected(self): + # extra pitch 62 in response + ref = make_midi([60, 64], [0, 1.0], [0.4, 0.4]) + res = make_midi([60, 62, 64], [0, 0.5, 1.0], [0.4, 0.4, 0.4]) + operations, D = note_alignment_ED(res["notes"], ref["notes"]) + types = [op["type"] for op in operations] + assert "extra" in types + + def test_wrong_pitch_is_replacement(self): + ref = make_midi([60, 62], [0, 0.5], [0.4, 0.4]) + res = make_midi([60, 65], [0, 0.5], [0.4, 0.4]) + operations, D = note_alignment_ED(res["notes"], ref["notes"]) + replacements = [op for op in operations if op["type"] == "replacement"] + assert len(replacements) == 1 + + def test_ops_are_in_forward_order(self): + ref = make_midi([60, 62, 64], [0, 0.5, 1.0], [0.4, 0.4, 0.4]) + res = make_midi([60, 62, 64], [0, 0.5, 1.0], [0.4, 0.4, 0.4]) + operations, D = note_alignment_ED(res["notes"], ref["notes"]) + ref_indices = [op["reference_idx"] for op in operations if op["reference_idx"] is not None] + assert ref_indices == sorted(ref_indices) + + def test_cost_matrix_shape(self): + ref = make_midi([60, 62, 64], [0, 0.5, 1.0], [0.4, 0.4, 0.4]) + res = make_midi([60, 62], [0, 0.5], [0.4, 0.4]) + operations, D = note_alignment_ED(res["notes"], ref["notes"]) + assert D.shape == (len(res["notes"]) + 1, len(ref["notes"]) + 1) - Use evaluation_function() to check your algorithm works - as it should. + +# 4. Tests for estimate_global_timing and estimate_global_duration_scale +# ------------------------------------------------------------------------------ +class TestGlobalEstimations: + + def test_perfect_timing_scale_is_one(self): + ref = make_midi([60, 62, 64, 65], [0, 0.5, 1.0, 1.5], [0.4] * 4) + res = make_midi([60, 62, 64, 65], [0, 0.5, 1.0, 1.5], [0.4] * 4) + operations, D = note_alignment_ED(res["notes"], ref["notes"]) + scale, offset = estimate_global_timing(operations, res["notes"], ref["notes"]) + assert abs(scale - 1.0) < 0.01 + + def test_slower_playing_scale_greater_than_one(self): + ref = make_midi([60, 62, 64, 65], [0, 0.5, 1.0, 1.5], [0.4] * 4) + res = make_midi([60, 62, 64, 65], [0, 0.6, 1.2, 1.8], [0.4] * 4) # 20% slower + operations, D = note_alignment_ED(res["notes"], ref["notes"]) + scale, offset = estimate_global_timing(operations, res["notes"], ref["notes"]) + assert scale > 1.0 + + def test_perfect_duration_scale_is_one(self): + ref = make_midi([60, 62, 64, 65], [0, 0.5, 1.0, 1.5], [0.4] * 4) + res = make_midi([60, 62, 64, 65], [0, 0.5, 1.0, 1.5], [0.4] * 4) + operations, D = note_alignment_ED(res["notes"], ref["notes"]) + dur_scale = estimate_global_duration_scale(operations, res["notes"], ref["notes"]) + assert abs(dur_scale - 1.0) < 0.01 + + def test_fewer_than_3_matched_returns_defaults(self): + # Only 2 notes -- should return (1.0, 0.0) + ref = make_midi([60, 62], [0, 0.5], [0.4, 0.4]) + res = make_midi([60, 62], [0, 0.5], [0.4, 0.4]) + operations, D = note_alignment_ED(res["notes"], ref["notes"]) + scale, offset = estimate_global_timing(operations, res["notes"], ref["notes"]) + dur_scale = estimate_global_duration_scale(operations, res["notes"], ref["notes"]) + assert scale == 1.0 + assert offset == 0.0 + assert dur_scale == 1.0 + + +# 5. Tests for note_level_feedback and compute_stats +# ------------------------------------------------------------------------------ +class TestComparePerformanceED: + + def test_consistent_tempo_not_flagged_per_note(self): + """ + A student playing consistently 20% slower should NOT get + note-level timing warnings -- only a global tempo comment. + """ + ref = make_midi([60, 62, 64, 65], [0, 0.5, 1.0, 1.5], [0.4] * 4) + res = make_midi([60, 62, 64, 65], [0, 0.6, 1.2, 1.8], [0.4] * 4) + result = compare_performance_ED(res, ref) + for n in result.note_details: + if n["operation_type"] in ("match", "replacement"): + assert n["timing_correct"] == True + + def test_single_late_note_flagged(self): + """ + One note that is very late compared to the rest should be flagged + after the global trend is removed. + """ + ref = make_midi([60, 62, 64, 65], [0, 0.5, 1.0, 1.5], [0.4] * 4) + res = make_midi([60, 62, 64, 65], [0, 0.5, 1.8, 1.5], [0.4] * 4) # note 3 very late + result = compare_performance_ED(res, ref) + flagged = [n for n in result.note_details if not n["timing_correct"]] + assert len(flagged) > 0 + + def test_pitch_error_recorded_correctly(self): + ref = make_midi([60, 62], [0, 0.5], [0.4, 0.4]) + res = make_midi([60, 65], [0, 0.5], [0.4, 0.4]) # 3 semitones off + result = compare_performance_ED(res, ref) + replacements = [n for n in result.note_details if n["operation_type"] == "replacement"] + assert len(replacements) == 1 + assert replacements[0]["pitch_diff"] == 3 + assert replacements[0]["pitch_correct"] == False + + def test_all_correct_stats(self): + midi = make_midi([60, 62, 64, 65], [0, 0.5, 1.0, 1.5], [0.4] * 4) + result = compare_performance_ED(midi, midi) + assert result.stats["total_notes_missing"] == 0 + assert result.stats["total_notes_extra"] == 0 + assert result.stats["total_notes_wrong_pitch"] == 0 + assert result.stats["pitch_all_correct"] == True + + def test_missing_note_counted(self): + ref = make_midi([60, 62, 64], [0, 0.5, 1.0], [0.4, 0.4, 0.4]) + res = make_midi([60, 64], [0, 1.0], [0.4, 0.4]) + result = compare_performance_ED(res, ref) + assert result.stats["total_notes_missing"] == 1 + + def test_extra_note_counted(self): + ref = make_midi([60, 64], [0, 1.0], [0.4, 0.4]) + res = make_midi([60, 62, 64], [0, 0.5, 1.0], [0.4, 0.4, 0.4]) + result = compare_performance_ED(res, ref) + assert result.stats["total_notes_extra"] == 1 + + def test_total_notes_in_reference(self): + ref = make_midi([60, 62, 64], [0, 0.5, 1.0], [0.4, 0.4, 0.4]) + res = make_midi([60, 62, 64], [0, 0.5, 1.0], [0.4, 0.4, 0.4]) + result = compare_performance_ED(res, ref) + assert result.stats["total_notes_in_reference"] == 3 + + +# 6. Tests for evaluation_function (Lambda Feedback integration) +# ------------------------------------------------------------------------------ +class TestEvaluationFunction: + """ + the core logic is already covered by TestComparePerformanceED above. + simple checks here to ensure the interface is working as expected. """ + def test_perfect_performance_is_correct(self): + midi = make_midi([60, 62, 64, 65], [0, 0.5, 1.0, 1.5], [0.4] * 4) + result = evaluation_function(midi, midi, {}) + assert result["is_correct"] == True + + def test_pitch_error_is_not_correct(self): + ref = make_midi([60, 62], [0, 0.5], [0.4, 0.4]) + res = make_midi([60, 65], [0, 0.5], [0.4, 0.4]) + result = evaluation_function(res, ref, {}) + assert result["is_correct"] == False + - def test_incorrect_performance(self): - result = evaluation_function(response, reference, Params()).to_dict() - self.assertFalse(result["is_correct"]) - self.assertIn("feedback", result) - - def test_correct_notes(self): - midi = make_midi([(60, 0.0, 0.5), (62, 0.6, 0.5)]) - result = evaluation_function(midi, midi, Params()).to_dict() - self.assertTrue(result["is_correct"]) - - def test_wrong_pitch(self): - ref = make_midi([(60, 0.0, 0.5)]) - response = make_midi([(61, 0.0, 0.5)]) - result = evaluation_function(response, ref, Params()).to_dict() - self.assertFalse(result["is_correct"]) - self.assertIn("wrong", result["feedback"]) - - def test_timing_out_of_tolerance(self): - ref = make_midi([(60, 0.0, 0.5)]) - response = make_midi([(60, 0.5, 0.5)]) # difference of 0.5s, out of tolerance - result = evaluation_function(response, ref, Params()).to_dict() - self.assertFalse(result["is_correct"]) - self.assertIn("start time", result["feedback"]) - - def test_timing_within_tolerance(self): - ref = make_midi([(60, 0.0, 0.5)]) - response = make_midi([(60, 0.05, 0.5)]) # difference of 0.05s, within tolerance - result = evaluation_function(response, ref, Params()).to_dict() - self.assertTrue(result["is_correct"]) - - def test_missing_note(self): - ref = make_midi([(60, 0.0, 0.5), (62, 0.6, 0.5)]) - response = make_midi([(60, 0.0, 0.5)]) - result = evaluation_function(response, ref, Params()).to_dict() - self.assertFalse(result["is_correct"]) - self.assertIn("missing", result["feedback"]) - - def test_extra_note(self): - ref = make_midi([(60, 0.0, 0.5)]) - response = make_midi([(60, 0.0, 0.5), (64, 0.6, 0.5)]) - result = evaluation_function(response, ref, Params()).to_dict() - self.assertFalse(result["is_correct"]) - self.assertIn("extra", result["feedback"]) \ No newline at end of file +# 7. Tests for parameter overrides +# ------------------------------------------------------------------------------ +class TestParamOverrides: + + def test_tight_timing_threshold_triggers_warning(self): + """ + A very strict timing threshold should flag a note that is slightly late. + Note 3 is late while others are on time, so the residual is detectable. + """ + ref = make_midi([60, 62, 64, 65], [0, 0.5, 1.0, 1.5], [0.4] * 4) + res = make_midi([60, 62, 64, 65], [0, 0.5, 1.3, 1.5], [0.4] * 4) + result = compare_performance_ED( + res, ref, timing_relative_threshold=0.01 + ) + assert result.stats["total_notes_wrong_timing"] > 0 + + def test_custom_gap_penalty_passed_through(self): + # Note 2 is 3 semitones off (62 -> 65), so replacement cost = 3. + # A deletion + insertion each cost gap_penalty once, total = 2 * gap_penalty. + # gap_penalty=1 -> gap cost = 2*1 = 2 < 3 -> aligner prefers gap -> missing > 0 + # gap_penalty=6 -> gap cost = 2*6 = 12 > 3 -> aligner prefers replacement -> missing == 0 + # So the two assertions together prove that gap_penalty is being passed through. + ref = make_midi([60, 62], [0, 0.5], [0.4, 0.4]) + res = make_midi([60, 65], [0, 0.5], [0.4, 0.4]) # 3 semitones off + result_lenient = compare_performance_ED(res, ref, gap_penalty=1) + assert result_lenient.stats["total_notes_missing"] > 0 + result_default = compare_performance_ED(res, ref) + assert result_default.stats["total_notes_missing"] == 0 \ No newline at end of file From 0e899a56aa509a438fbbc2150626efb710a62329 Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Wed, 24 Jun 2026 17:04:55 +0100 Subject: [PATCH 21/22] tidy up the notebook --- ... Note_alignment_and_MIDI_evaluation.ipynb} | 1578 +---------------- 1 file changed, 93 insertions(+), 1485 deletions(-) rename notebooks/{Note_alignment.ipynb => Note_alignment_and_MIDI_evaluation.ipynb} (53%) diff --git a/notebooks/Note_alignment.ipynb b/notebooks/Note_alignment_and_MIDI_evaluation.ipynb similarity index 53% rename from notebooks/Note_alignment.ipynb rename to notebooks/Note_alignment_and_MIDI_evaluation.ipynb index 9c1dca8..7e0a8dd 100644 --- a/notebooks/Note_alignment.ipynb +++ b/notebooks/Note_alignment_and_MIDI_evaluation.ipynb @@ -2,25 +2,19 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": 4, "id": "96c2775c", "metadata": {}, "outputs": [], - "source": [ - "import numpy as np\n", - "from typing import Any\n", - "from lf_toolkit.evaluation import Result, Params" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "a6822f11", - "metadata": {}, - "outputs": [], "source": [ "import os\n", "import json\n", + "from evaluation_function.compare_MIDI import compare_performance_ED\n", + "import numpy as np\n", + "from typing import Any\n", + "from lf_toolkit.evaluation import Result, Params\n", + "\n", + "cwd = os.getcwd()\n", "\n", "cwd = os.getcwd()\n", "dir = os.path.dirname(cwd)\n", @@ -31,7 +25,7 @@ " reference = json.load(f1)\n", "\n", "with open(response_path) as f2:\n", - " response = json.load(f2)" + " response = json.load(f2)\n" ] }, { @@ -44,21 +38,12 @@ }, { "cell_type": "markdown", - "id": "519ed38f", - "metadata": {}, - "source": [ - "## DTW" - ] - }, - { - "cell_type": "markdown", - "id": "a900434b", + "id": "0b6a19f0", "metadata": {}, "source": [ "The goal of the note alignment is to find if the student played any missing or extra notes, and which note is missing/extra.\n", "\n", - "\n", - "Based on the findings during the project plan phase, DTW is commonly used for alignment. The algorithm can be found in this book (Chpater 3.2 Dynamic Time Warping):\n", + "Based on the findings during the project plan phase, Dynamic Time Warping (DTW) is commonly used for alignment. The algorithm can be found in this book (Chpater 3.2 Dynamic Time Warping):\n", "M. Müller, Fundamentals of Music Processing. Cham: Springer International Publishing, 2021, ISBN: 9783030698072. DOI:https://doi.org/10.1007/978-3-030-69808-9.\n", "\n", "In general, this DTW algorithm finds an optimal possibly nonlinear alignment between response MIDI sequence to reference MIDI sequence.\n", @@ -67,296 +52,7 @@ "- Evaluating the local cost measure for each pair of elements in the response(X) and reference(Y) sequences. \n", "- Dynamic programming to find an alignment path between X and Y having minimal overall cost, i.e. DTW distance. The algorithm computes a cumulative distance path, the timestamps of the target MIDI are warped so they perfectly align with the anchor points of the reference MIDI.\n", "\n", - "\n", - "However, this basic approach will not correctly handle the missing note case as expected, because it allows a note to match with multiple notes, and each note must be paired. Let's say, there is a note missing in the response, this algorithm tends to match a response note with two reference note, instead of reporting the missing problem.\n", - "\n", - "! need to modify this algorithm to make it handle the missing/extra problem correctly. Cosider analysing each entry of the warping path." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0f6ac5e5", - "metadata": {}, - "outputs": [], - "source": [ - "def compute_cost(note1, note2):\n", - " \"\"\"\n", - " Compute the local cost measure for each pair of notes.\n", - " \n", - " Only pitch is involved in the cost calculation, since the purpose \n", - " is to pair up notes with similar pitches.\n", - " \n", - " Args:\n", - " note1: dict with keys \"pitch\" (int), \"start\" (float), \"duration\" (float)\n", - " note2: dict with keys \"pitch\" (int), \"start\" (float), \"duration\" (float)\n", - " \n", - " Returns:\n", - " int: cost value >= 0 (lower means more similar pitch)\n", - " \"\"\"\n", - " return int(abs(note1[\"pitch\"] - note2[\"pitch\"]))\n", - "\n", - "\n", - "def note_alignment_DTW(response_notes, ref_notes):\n", - " \"\"\"\n", - " DTW pipeline: build cost matrix C -> build accumulated cost matrix D \n", - " -> backtrack to find the optimal warping path\n", - " - the Rows of C and D correspond to response notes\n", - " - the Columns of C and D correspond to reference notes\n", - " \n", - " Args:\n", - " response_notes: The student's response MIDI notes to evaluate\n", - " ref_notes: The reference MIDI note\n", - " \n", - " Returns:\n", - " path: list of (response_idx, reference_idx) pairs — the optimal alignment\n", - " C: local cost matrix\n", - " D: accumulated cost matrix\n", - " \"\"\"\n", - "\n", - " N = len(response_notes)\n", - " M = len(ref_notes)\n", - "\n", - " # step1: Build the local cost matrix C of size (N x M).\n", - " # C[i, j] = note_cost(ref_notes[i], response_notes[j])\n", - " C= np.zeros((N, M))\n", - " for i in range(N):\n", - " for j in range(M):\n", - " C[i, j] = compute_cost(response_notes[i], ref_notes[j])\n", - "\n", - " # step2: Build the accumulated cost matrix D of size (N+1 x M+1)\n", - " # using small trick for simplifying the initialization\n", - " # D[n, 0] = inf for n >= 1\n", - " # D[0, m] = inf for m >= 1\n", - " D = np.full((N + 1, M + 1), np.inf)\n", - " D[0, 0] = 0\n", - " # for all n in [1..N] and m in [1..M]:\n", - " # D[i, j] = C[i, j] + min(D[i-1, j], D[i, j-1], D[i-1, j-1])\n", - " for i in range(1, N + 1):\n", - " for j in range(1, M + 1):\n", - " D[i, j] = C[i - 1, j - 1] + min(\n", - " D[i - 1, j], # vertical step, multiple response notes are mapped to the same ref note\n", - " D[i, j - 1], # horizontal step, same response note is reused for multiple ref notes\n", - " D[i - 1, j - 1]) # diagonal step\n", - "\n", - " # step3: Backtrack through the D to find the optimal warping path P\n", - " path = []\n", - " n, m = N, M\n", - " while n > 0 and m > 0:\n", - " path.append((n-1, m-1))\n", - " # Find the minimum cost step\n", - " diag = D[n-1, m-1]\n", - " vertical = D[n-1, m]\n", - " horizontal = D[n, m-1]\n", - " min_step = min(diag, vertical, horizontal)\n", - " if min_step == vertical:\n", - " n -= 1\n", - " elif min_step == horizontal:\n", - " m -= 1\n", - " else: # diagonal step\n", - " n -= 1\n", - " m -= 1\n", - " # Reverse to get the path from start to end\n", - " path.reverse() \n", - "\n", - " return path, C, D\n", - "\n", - "\n", - "def path_classification(path, C):\n", - " \"\"\"\n", - " Classify the each entry of the alignment path into:\n", - " - correct: response pitch matches reference pitch (cost = 0)\n", - " - wrong: response pitch differs from reference pitch, but all the parings are one-to-one (cost > 0)\n", - " - missing: same response pitch is mapped to multiple reference pitches \n", - " - extra: multiple response pitches are mapped to the same reference pitch\n", - " \n", - " Args: \n", - " path: list of (response_idx, reference_idx) pairs from the backtrack warping path\n", - " C: local cost matrix (N x M)\n", - "\n", - " Returns:\n", - " list of event dicts, each one of:\n", - " {'type': 'match' or 'replacement' or 'missing' or 'extra', \n", - " 'response_idx': int, 'reference_idx': int, 'cost': int}\n", - " \"\"\"" - ] - }, - { - "cell_type": "markdown", - "id": "095ae680", - "metadata": {}, - "source": [ - "Evaluation" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f2cc4262", - "metadata": {}, - "outputs": [], - "source": [ - "def evaluate_note_pair(response_note, ref_note, reference_idx,\n", - " timing_tolerance=0.1, duration_tolerance=0.1):\n", - " \"\"\"\n", - " Evaluate a single aligned note pair and return feedback.\n", - " \n", - " Args:\n", - " response_note: student's note dict\n", - " ref_note: reference note dict\n", - " reference_idx: 1-based display index (based on ref position)\n", - " timing_tolerance: consider as correct if start is within this tolerance\n", - " duration_tolerance: consider as correct if duration is within this tolerance\n", - " \n", - " Returns:\n", - " is_correct (bool), feedback (list of str)\n", - " \"\"\"\n", - " feedback = []\n", - " is_correct = True\n", - " \n", - " # Pitch check\n", - " if response_note[\"pitch\"] != ref_note[\"pitch\"]:\n", - " is_correct = False\n", - " feedback.append(\n", - " f\"Note {reference_idx}: wrong pitch — expected {ref_note['pitch']}, \"\n", - " f\"played {response_note['pitch']}.\"\n", - " )\n", - " \n", - " # Timing check\n", - " timing_diff = abs(response_note[\"start\"] - ref_note[\"start\"])\n", - " if timing_diff > timing_tolerance:\n", - " is_correct = False\n", - " feedback.append(f\"Note {reference_idx}: difference in start time: {timing_diff:.2f}s.\")\n", - " \n", - " # Duration check\n", - " duration_diff = abs(response_note[\"duration\"] - ref_note[\"duration\"])\n", - " if duration_diff > duration_tolerance:\n", - " is_correct = False\n", - " feedback.append(f\"Note {reference_idx}: difference in duration: {duration_diff:.2f}s.\")\n", - " \n", - " if is_correct:\n", - " feedback.append(f\"Note {reference_idx} (with pitch {ref_note['pitch']}) is correct.\")\n", - " \n", - " return is_correct, feedback" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "99c84405", - "metadata": {}, - "outputs": [], - "source": [ - "def comparison(response, ref,\n", - " timing_tolerance=0.1, duration_tolerance=0.1):\n", - " \"\"\"\n", - " Compare student MIDI against reference MIDI after DTW-based alignment.\n", - " \n", - " Args:\n", - " response: The student's response MIDI\n", - " ref: The reference MIDI\n", - " timing_tolerance: seconds\n", - " duration_tolerance: seconds\n", - " \n", - " Returns:\n", - " all_correct (bool), feedback (list of str)\n", - " \"\"\"\n", - " response_notes = response[\"notes\"]\n", - " ref_notes = ref[\"notes\"]\n", - " \n", - " # Align using DTW — response first, ref second\n", - " path, C, D = note_alignment_DTW(response_notes, ref_notes)\n", - " \n", - " feedback = []\n", - " all_correct = True\n", - " \n", - " for response_idx, reference_idx in path:\n", - " is_correct, feedback = evaluate_note_pair(\n", - " response_notes[response_idx], ref_notes[reference_idx],\n", - " reference_idx=reference_idx + 1,\n", - " timing_tolerance=timing_tolerance,\n", - " duration_tolerance=duration_tolerance,\n", - " )\n", - " if not is_correct:\n", - " all_correct = False\n", - " feedback.extend(feedback)\n", - " \n", - " return all_correct, feedback" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a203ee2d", - "metadata": {}, - "outputs": [], - "source": [ - "def evaluation_function(response: Any, answer: Any, params: Params) -> Result:\n", - " \"\"\"\n", - " Entry point for Lambda Feedback.\n", - " \n", - " Args:\n", - " response: student MIDI dict\n", - " answer: reference MIDI dict\n", - " params: optional extra parameters\n", - " \n", - " Returns:\n", - " Result with is_correct and feedback string\n", - " \"\"\"\n", - " all_correct, feedback = comparison(response, answer)\n", - " return Result(\n", - " is_correct=all_correct,\n", - " feedback_items=[(\"feedback\", \"\\n\".join(feedback))]\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cd3befd9", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "False\n", - "Note 1 (with pitch 60) is correct.\n", - "Note 2: wrong pitch — expected 62, played 63.\n", - "Note 3: difference in start time: 0.15s.\n", - "Note 4: difference in duration: 0.20s.\n", - "Note 5: wrong pitch — expected 67, played 65.\n", - "Note 5: difference in start time: 0.70s.\n", - "Note 5: difference in duration: 0.20s.\n" - ] - } - ], - "source": [ - "is_correct, feedbacks = comparison(\n", - " response,\n", - " reference\n", - ")\n", - "\n", - "print(is_correct)\n", - "\n", - "for feedback in feedbacks:\n", - " print(feedback)" - ] - }, - { - "cell_type": "markdown", - "id": "25bc065e", - "metadata": {}, - "source": [ - "note5 should be a missing pitch!! Need to check the DTW algorithm!" - ] - }, - { - "cell_type": "markdown", - "id": "ded66276", - "metadata": {}, - "source": [ - "## Edit Distance" + "However, this basic approach will not correctly handle the missing note case as expected, because it allows a note to match with multiple notes, and each note must be paired. Let's say, there is a note missing in the response, this algorithm tends to match a response note with two reference note, instead of reporting the missing problem." ] }, { @@ -364,383 +60,11 @@ "id": "adbaf01b", "metadata": {}, "source": [ - "a simplified version of this is applied here: https://www.math.univ-toulouse.fr/~mongeau/music.pdf \n", - "\n", - "Unlike standard DTW where off-diagonal moves has no cost and every note must be aligned to another, the Edit-distance approach allows a note to be explicitly left unaligned at the cost of gap_penalty, i.e. insertion represents an extra note, deletion represents a missing note, the moving direction during backtracking has an unambiguous meaning (diagonal = match/replacement, vertical = extra, horizontal = missing), we can classify each operation directly during backtracking." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "9d64cb17", - "metadata": {}, - "outputs": [], - "source": [ - "def normalize_start_times(notes):\n", - " \"\"\"\n", - " Shift all notes so that the first note starts at t=0.\n", - " \n", - " Args:\n", - " notes: list of note dicts, each with at least a \"start\" key.\n", - " \n", - " Returns:\n", - " A new list of note dicts (copies, not the original objects), with\n", - " every \"start\" value shifted so notes[0][\"start\"] == 0. Returns an\n", - " empty list unchanged if notes is empty.\n", - " \"\"\"\n", - " if not notes:\n", - " return []\n", - " \n", - " first_start = notes[0][\"start\"]\n", - " \n", - " shifted_notes = []\n", - " for note in notes:\n", - " # Create a copy of the note dict with the \"start\" time shifted\n", - " note_copy = {\n", - " \"pitch\": note[\"pitch\"],\n", - " \"start\": note[\"start\"] - first_start,\n", - " \"duration\": note[\"duration\"],\n", - " }\n", - " shifted_notes.append(note_copy)\n", - " \n", - " return shifted_notes" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "81666295", - "metadata": {}, - "outputs": [], - "source": [ - "def compute_cost(note1, note2):\n", - " \"\"\"\n", - " Cost of aligning (replacing) one note with another, based on pitch.\n", - " \n", - " cost = 0: pitches are identical (a 'match'). \n", - " cost > 0: different pitches (a 'replacement')\n", - " \n", - " Args:\n", - " note1: dict with keys \"pitch\" (int), \"start\" (float), \"duration\" (float)\n", - " note2: dict with keys \"pitch\" (int), \"start\" (float), \"duration\" (float)\n", - " \n", - " Returns:\n", - " int: cost value >= 0 (lower means more similar pitch)\n", - " \"\"\"\n", - " return int(abs(note1[\"pitch\"] - note2[\"pitch\"]))\n", - "\n", - "\n", - "def note_alignment_ED(response_notes, ref_notes, gap_penalty=6):\n", - " \"\"\"\n", - " Align notes using edit distance (ED). \n", - " The ED allows for insertions and deletions, which can be useful for \n", - " evaluating musical practice containing missing/extra notes.\n", - " \n", - " Args:\n", - " response_notes: The student's response MIDI notes to evaluate\n", - " ref_notes: The reference MIDI note\n", - " gap_penalty: cost of leaving a note unaligned (insertion/deletion)\n", - " \n", - " Returns:\n", - " operations: list of transformation ops dicts, in order from first note to last:\n", - " {'type': 'match' or 'replacement' or 'missing' or 'extra', \n", - " 'response_idx': int or None, \n", - " 'reference_idx': int or None, \n", - " 'cost': int}\n", - " D: accumulated cost matrix, shape (N+1, M+1)\n", - " \"\"\"\n", - " # the rows of D correspond to response notes\n", - " N = len(response_notes)\n", - " # the columns of D correspond to reference notes\n", - " M = len(ref_notes)\n", - "\n", - " # Build the accumulated cost matrix D of size (N+1 x M+1)\n", - " D = np.zeros((N + 1, M + 1), dtype=int)\n", - " # Boundary conditions: aligning against an empty sequence means every note\n", - " # is unaligned, so the cost is n (or m) times the gap penalty.\n", - " for n in range(1, N + 1):\n", - " D[n, 0] = n * gap_penalty # n extra response notes\n", - " for m in range(1, M + 1):\n", - " D[0, m] = m * gap_penalty # m missing ref notes\n", - " # Recursion (accumulated cost / score matrix D):\n", - " for n in range(1, N + 1):\n", - " for m in range(1, M + 1):\n", - " replace_cost = compute_cost(response_notes[n-1], ref_notes[m-1])\n", - " D[n, m] = min(\n", - " D[n-1, m-1] + replace_cost, # diagonal: match or replacement\n", - " D[n-1, m] + gap_penalty, # vertical: extra note response[n-1]\n", - " D[n, m-1] + gap_penalty, # horizontal: missing response for ref[m-1]\n", - " )\n", - "\n", - " # Backtrack, classify each transformation ops based on movement direction in D\n", - " operations = []\n", - " n, m = N, M\n", - " while n > 0 or m > 0:\n", - " # boundary conditions: if we are at the most top row or left column, we can only move in one direction\n", - " # at the top row, only horizontal moves possible\n", - " if n == 0: \n", - " # missing response for ref[m-1] (deletion)\n", - " operations.append({\"type\": \"missing\", \"response_idx\": None,\n", - " \"reference_idx\": m - 1, \"cost\": gap_penalty})\n", - " m -= 1\n", - " # at the most left column, only vertical moves possible\n", - " elif m == 0: \n", - " # extra note response[n-1] (insertion)\n", - " operations.append({\"type\": \"extra\", \"response_idx\": n - 1,\n", - " \"reference_idx\": None, \"cost\": gap_penalty})\n", - " n -= 1\n", - " # for all other cases, we can move in any direction (diagonal, vertical, horizontal)\n", - " else:\n", - " replace_cost = compute_cost(response_notes[n-1], ref_notes[m-1])\n", - " diag = D[n-1, m-1] + replace_cost # diagonal: match or replacement\n", - " up = D[n-1, m] + gap_penalty # vertical: extra note response[n-1]\n", - " left = D[n, m-1] + gap_penalty # horizontal: missing response for ref[m-1]\n", - " min_cost = min(diag, up, left) # find the minimum cost step\n", - " # classify the transformation ops based on the minimum cost step\n", - " if min_cost == diag: # diagonal -> two notes are aligned (match/replacement)\n", - " operations.append({\n", - " \"type\": \"match\" if replace_cost == 0 else \"replacement\",\n", - " \"response_idx\": n - 1,\n", - " \"reference_idx\": m - 1,\n", - " \"cost\": replace_cost,\n", - " })\n", - " n, m = n - 1, m - 1\n", - " elif min_cost == up: # vertical -> response[n-1] is extra (insertion)\n", - " operations.append({\"type\": \"extra\", \"response_idx\": n - 1,\n", - " \"reference_idx\": None, \"cost\": gap_penalty})\n", - " n -= 1\n", - " else: # horizontal -> response is missing for ref[m-1] (deletion)\n", - " operations.append({\"type\": \"missing\", \"response_idx\": None,\n", - " \"reference_idx\": m - 1, \"cost\": gap_penalty})\n", - " m -= 1\n", - " \n", - " operations.reverse() # reverse the operations to get them in order from first note to last\n", - " return operations, D" - ] - }, - { - "cell_type": "markdown", - "id": "31f3c137", - "metadata": {}, - "source": [ - "TODO: The gap_penaulty need careful consideration! To be modified." - ] - }, - { - "cell_type": "markdown", - "id": "8be10bf9", - "metadata": {}, - "source": [ - "# Feedback generation" - ] - }, - { - "cell_type": "markdown", - "id": "838e89c7", - "metadata": {}, - "source": [ - "#### Version 1.0" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "277f0d01", - "metadata": {}, - "outputs": [], - "source": [ - "def generate_feedback(operations, response_notes, ref_notes,\n", - " timing_tolerance=0.1, duration_tolerance=0.1):\n", - " \"\"\"\n", - " Evaluate the response MIDI and return feedback.\n", - " For 'match'/'replacement' operations, checks pitch, timing, and duration\n", - " of the aligned pair. \n", - " For 'missing'/'extra' operations, reports the note directly\n", - " \n", - " Args:\n", - " operations: list of transformation ops dicts\n", - " response_notes: list of note dicts from student's performance\n", - " ref_notes: list of note dicts from reference performance\n", - " timing_tolerance: consider as correct if start is within this tolerance\n", - " duration_tolerance: consider as correct if duration is within this tolerance\n", - " \n", - " Returns:\n", - " is_correct (bool), feedback (list of str)\n", - " \"\"\"\n", - "\n", - " feedback = []\n", - " is_correct = True\n", - "\n", - " for op in operations:\n", - " if op[\"type\"] in (\"match\", \"replacement\"):\n", - " response_note = response_notes[op[\"response_idx\"]]\n", - " ref_note = ref_notes[op[\"reference_idx\"]]\n", - " reference_idx = op[\"reference_idx\"] + 1 # 1-based for display\n", - " correct_note = True\n", - "\n", - " # Pitch check\n", - " if response_note[\"pitch\"] != ref_note[\"pitch\"]:\n", - " correct_note = False\n", - " feedback.append(\n", - " f\"Note {reference_idx}: wrong pitch — expected {ref_note['pitch']}, \"\n", - " f\"played {response_note['pitch']}.\"\n", - " )\n", - " \n", - " # Timing check\n", - " timing_diff = abs(response_note[\"start\"] - ref_note[\"start\"])\n", - " if timing_diff > timing_tolerance:\n", - " correct_note = False\n", - " feedback.append(f\"Note {reference_idx}: difference in start time: {timing_diff:.2f}s.\")\n", - " \n", - " # Duration check\n", - " duration_diff = abs(response_note[\"duration\"] - ref_note[\"duration\"])\n", - " if duration_diff > duration_tolerance:\n", - " correct_note = False\n", - " feedback.append(f\"Note {reference_idx}: difference in duration: {duration_diff:.2f}s.\")\n", - "\n", - " if correct_note:\n", - " feedback.append(f\"Note {reference_idx} (pitch {ref_note['pitch']}) is correct.\")\n", - " else:\n", - " is_correct = False\n", - " \n", - " elif op[\"type\"] == \"missing\":\n", - " is_correct = False\n", - " reference_idx = op[\"reference_idx\"]\n", - " feedback.append(\n", - " f\"Note {reference_idx + 1} (pitch {ref_notes[reference_idx]['pitch']}) \"\n", - " f\"is missing in your performance.\"\n", - " )\n", - " \n", - " elif op[\"type\"] == \"extra\":\n", - " is_correct = False\n", - " response_note = response_notes[op[\"response_idx\"]]\n", - " feedback.append(\n", - " f\"Extra note played: pitch {response_note['pitch']} \"\n", - " f\"at t={response_note['start']:.2f}s (not in reference).\"\n", - " )\n", - " \n", - " return is_correct, feedback\n", - "\n", - "\n", - "def compare_performance(responseMIDI, refMIDI, gap_penalty=6,\n", - " timing_tolerance=0.1, duration_tolerance=0.1):\n", - " \"\"\"\n", - " Compare student MIDI against reference MIDI\n", - " \n", - " Args:\n", - " responseMIDI: student's response MIDI dict\n", - " refMIDI: reference MIDI dict\n", - " gap_penalty: cost of leaving a note unaligned\n", - " timing_tolerance: consider as correct if start is within this tolerance\n", - " duration_tolerance: consider as correct if duration is within this tolerance\n", - " \n", - " Returns:\n", - " all_correct (bool), feedback (list of str)\n", - " \"\"\"\n", - " response_notes = responseMIDI[\"notes\"]\n", - " ref_notes = refMIDI[\"notes\"]\n", - " \n", - " # Step 1: align using edit distance with gap penalty,\n", - " # emitting match/replacement/missing/extra operations directly\n", - " operations, D = note_alignment_ED(response_notes, ref_notes, gap_penalty)\n", - " \n", - " # Step 2: turn the alignment operations into feedback messages\n", - " all_correct, feedback = generate_feedback(\n", - " operations, response_notes, ref_notes,\n", - " timing_tolerance=timing_tolerance,\n", - " duration_tolerance=duration_tolerance,\n", - " )\n", - " \n", - " return all_correct, feedback, operations, D" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "dacd1ce1", - "metadata": {}, - "outputs": [], - "source": [ - "def evaluation_function(\n", - " response: Any,\n", - " answer: Any,\n", - " params: Params,\n", - ") -> Result:\n", - " \"\"\"\n", - " Function used to evaluate a student response.\n", - " ---\n", - " The handler function passes three arguments to evaluation_function():\n", - "\n", - " - `response` which are the answers provided by the student.\n", - " - `answer` which are the correct answers to compare against.\n", - " - `params` which are any extra parameters that may be useful,\n", - " e.g., error tolerances.\n", - "\n", - " The output of this function is what is returned as the API response\n", - " and therefore must be JSON-encodable. It must also conform to the\n", - " response schema.\n", - "\n", - " Any standard python library may be used, as well as any package\n", - " available on pip (provided it is added to requirements.txt).\n", - "\n", - " The way you wish to structure you code (all in this function, or\n", - " split into many) is entirely up to you. All that matters are the\n", - " return types and that evaluation_function() is the main function used\n", - " to output the evaluation response.\n", - " \"\"\"\n", - " all_correct, feedback = compare_performance(response, answer)\n", - "\n", - " return Result(\n", - " is_correct=all_correct,\n", - " feedback_items=[(\"feedback\", \"\\n\".join(feedback))]\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "c8497dc1", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "False\n", - "Note 1 (pitch 60) is correct.\n", - "Note 2: wrong pitch — expected 62, played 63.\n", - "Note 3: difference in start time: 0.15s.\n", - "Note 4: difference in duration: 0.20s.\n", - "Note 5 (pitch 67) is missing in your performance.\n" - ] - } - ], - "source": [ - "is_correct, feedbacks, operations, D = compare_performance(\n", - " response,\n", - " reference\n", - ")\n", + "Another note alignment for music comparison method is based on Edit Distance (ED), see https://www.math.univ-toulouse.fr/~mongeau/music.pdf, where the reference note sequence and the student's response sequence are aligned using dynamic programming. Instead of using a constant replacement cost as in the classic ED algroithm, here absolute pitch difference (measured in MIDI semitones) between two notes is used, so that matched note pairs have zero cost while larger pitch deviations have proportionally higher penalties.\n", "\n", - "print(is_correct)\n", + "Insertion and deletion operations are represented by a fixed gap penalty, corresponding to extra notes played by the student and missing notes from the reference sequence, respectively. The gap penalty is empirically set to 6. This value is chosen to encourage the alignment algorithm to interpret most pitch deviations as replacement (wrong pitches) rather than decomposing them into separate insertion and deletion operations. Such behaviour is more consistent with common music teaching practice, where a note played at the correct temporal position but with an incorrect pitch is typically regarded as a wrong note instead of a missing note accompanied by an extra note.\n", "\n", - "for feedback in feedbacks:\n", - " print(feedback)" - ] - }, - { - "cell_type": "markdown", - "id": "db53794c", - "metadata": {}, - "source": [ - "#### Version 2.0:" - ] - }, - { - "cell_type": "markdown", - "id": "acb5f42b", - "metadata": {}, - "source": [ - "TODO: test with longer notes, tune all the parameters, fix and improve feedback messages" + "After constructing the accumulated cost matrix, backtracking is performed to recover the optimal alignment path. Each aligned pair is classified as one of four operation types: match, replacement, missing, or extra. These structured operations are subsequently used to generate formative feedback for the learner." ] }, { @@ -753,595 +77,7 @@ }, { "cell_type": "code", - "execution_count": 12, - "id": "5288bfea", - "metadata": {}, - "outputs": [], - "source": [ - "# Default thresholds/parameters for evaluation\n", - " \n", - "# Timing: |start_diff| / inter-onset interval(IOI) must be below this to be considered correct.\n", - "# e.g. 0.20 means the start can be off by up to 20% of the IOI between notes.\n", - "TIMING_RELATIVE_THRESHOLD = 0.20\n", - " \n", - "# Duration: |response_dur / ref_dur - 1| must be below this to be considered correct.\n", - "# e.g. 0.25 means the student's duration can be off by up to 25% of the reference.\n", - "DURATION_RELATIVE_THRESHOLD = 0.25\n", - " \n", - "# Median duration_ratio thresholds that trigger a global tempo comment.\n", - "GLOBAL_SLOW_THRESHOLD = 1.15 # median ratio > 1.15 → \"overall too slow\"\n", - "GLOBAL_FAST_THRESHOLD = 0.85 # median ratio < 0.85 → \"overall too fast\"\n", - "\n", - "\n", - "def estimate_global_timing(operations, response_notes, ref_notes):\n", - " \"\"\"\n", - " Estimate the student's overall tempo relative to the reference, by fitting\n", - " a straight line through the matched note start times:\n", - " response_start ≈ scale * ref_start + offset\n", - " where:\n", - " scale: represents the student's overall speed relative to the reference. \n", - " scale > 1 means the student is playing slower overall\n", - " scale < 1 means faster overall\n", - " offset: represents a constant time shift. \n", - " e.g. always starts 0.3s later than the reference.\n", - " \n", - " Args:\n", - " operations : list of operation (match/replacement/missing/extra)\n", - " response_notes : list of note dicts from response\n", - " ref_notes : list of note dicts from reference \n", - " \n", - " Returns:\n", - " scale: float, estimated tempo ratio (1.0 = same speed as reference)\n", - " offset: float(seconds), estimated constant time shift\n", - " \"\"\"\n", - " # Collect (ref_start, response_start) pairs from matched/replaced notes only.\n", - " # Missing/extra notes have no pair, so they cannot contribute to the fit.\n", - " ref_starts = []\n", - " response_starts = []\n", - " for op in operations:\n", - " if op[\"type\"] in (\"match\", \"replacement\"):\n", - " ref_starts.append(ref_notes[op[\"reference_idx\"]][\"start\"])\n", - " response_starts.append(response_notes[op[\"response_idx\"]][\"start\"])\n", - " \n", - " # Not enough points for fitting a meaningful line — assume no drift in tempo.\n", - " if len(ref_starts) < 3:\n", - " return 1.0, 0.0\n", - " \n", - " x = np.array(ref_starts, dtype=float)\n", - " y = np.array(response_starts, dtype=float)\n", - " \n", - " # Least-squares line fit: y = scale * x + offset\n", - " scale, offset = np.polyfit(x, y, 1)\n", - " \n", - " return float(scale), float(offset)\n", - "\n", - "\n", - "def estimate_global_duration_scale(operations, response_notes, ref_notes):\n", - " \"\"\"\n", - " Estimate the student's overall note-length scale relative to the\n", - " reference, by fitting a line through the origin:\n", - " response_duration ≈ duration_scale * ref_duration\n", - " where:\n", - " duration_scale > 1 means notes are held longer overall (slower);\n", - " duration_scale < 1 means notes are held shorter overall (faster).\n", - "\n", - " Args:\n", - " operations: output of note_alignment_ED()\n", - " response_notes: list of student note dicts\n", - " ref_notes: list of reference note dicts\n", - "\n", - " Returns:\n", - " duration_scale (float): estimated duration ratio (1.0 = same as reference)\n", - " \"\"\"\n", - " ref_durations = []\n", - " response_durations = []\n", - " for op in operations:\n", - " if op[\"type\"] in (\"match\", \"replacement\"):\n", - " ref_durations.append(ref_notes[op[\"reference_idx\"]][\"duration\"])\n", - " response_durations.append(response_notes[op[\"response_idx\"]][\"duration\"])\n", - "\n", - " if len(ref_durations) < 3:\n", - " return 1.0\n", - "\n", - " x = np.array(ref_durations, dtype=float)\n", - " y = np.array(response_durations, dtype=float)\n", - "\n", - " # Least-squares fit through the origin: y = scale * x\n", - " # (closed-form solution: scale = sum(x*y) / sum(x*x))\n", - " duration_scale = float(np.sum(x * y) / np.sum(x * x))\n", - "\n", - " return duration_scale\n", - "\n", - "\n", - "def note_level_feedback(operations, response_notes, ref_notes, \n", - " timing_scale=1.0, timing_offset=0.0, duration_scale=1.0,\n", - " timing_relative_threshold=TIMING_RELATIVE_THRESHOLD,\n", - " duration_relative_threshold=DURATION_RELATIVE_THRESHOLD):\n", - " \"\"\"\n", - " Analyse each aligned note pair (or missing/extra event) and return a list\n", - " of note result dicts.\n", - "\n", - " Args:\n", - " operations : list of operation (match/replacement/missing/extra)\n", - " response_notes : list of note dicts from response\n", - " ref_notes : list of note dicts from reference \n", - " timing_scale : float, estimated tempo ratio (1.0 = same speed as reference)\n", - " timing_offset : float(seconds), estimated constant time shift (0.0 = no shift)\n", - " duration_scale: float, estimated overall duration ratio\n", - " timing_relative_threshold : float, relative tolerance for timing correctness\n", - " start can be off by up to 20%(default) of the IOI (inter-onset interval)\n", - " IOI is defined as gap between consecutive note start times\n", - " duration_relative_threshold : float, relative tolerance for duration correctness\n", - " duration is correct within +/-20%(default) of the reference duration\n", - " \n", - " Returns:\n", - " note_level_results : list of dicts, each dict contains:\n", - " \"reference_index\" -> int (1-based) or None if operation_type = extra\n", - " \"response_index\" -> int (1-based) or None if operation_type = missing\n", - " \"operation_type\" -> str: \"match\", \"replacement\", \"missing\", or \"extra\"\n", - " \"pitch_correct\" -> bool\n", - " \"pitch_diff\" -> int (semitones) or None if operation_type = missing/extra\n", - " \"timing_correct\" -> bool \n", - " \"timing_abs_diff\" -> float (seconds) or None if operation_type = missing/extra\n", - " “timing_relative_diff” -> float (seconds) or None if operation_type = missing/extra\n", - " \"duration_correct\" -> bool\n", - " \"duration_abs_diff\" -> float (seconds) or None if operation_type = missing/extra\n", - " \"duration_relative_diff\" -> float (seconds) or None if operation_type = missing/extra\n", - " \"\"\"\n", - " # Compute IOI for each reference note: ioi[m] = ref_notes[m][\"start\"] - ref_notes[m-1][\"start\"]\n", - " # floor at 0.05s to avoid division by zero issues\n", - " ref_ioi = [None] * len(ref_notes)\n", - " for m in range(1, len(ref_notes)):\n", - " interval = ref_notes[m][\"start\"] - ref_notes[m - 1][\"start\"]\n", - " ref_ioi[m] = max(interval, 0.05)\n", - " \n", - " note_level_results = []\n", - "\n", - " for op in operations:\n", - " res_idx = op[\"response_idx\"]\n", - " ref_idx = op[\"reference_idx\"]\n", - " op_type = op[\"type\"]\n", - " \n", - " # Missing/extra notes: no pitch/timing/duration comparison is possible,\n", - " # so all the numeric fields are set to None.\n", - " if op_type in (\"missing\", \"extra\"):\n", - " note_level_results.append({\n", - " \"reference_index\": (ref_idx + 1) if ref_idx is not None else None,\n", - " \"response_index\": (res_idx + 1) if res_idx is not None else None,\n", - " \"operation_type\": op_type,\n", - " \"pitch_correct\": False,\n", - " \"pitch_diff\": None,\n", - " \"timing_correct\": False,\n", - " \"timing_abs_diff\": None,\n", - " \"timing_relative_diff\": None,\n", - " \"duration_correct\": False,\n", - " \"duration_abs_diff\": None,\n", - " \"duration_relative_diff\": None,\n", - " })\n", - " else:\n", - " # Matched (aligned) note pair\n", - " res_note = response_notes[res_idx]\n", - " ref_note = ref_notes[ref_idx]\n", - "\n", - " # Pitch\n", - " pitch_diff = int(abs(res_note[\"pitch\"] - ref_note[\"pitch\"]))\n", - " pitch_correct = (pitch_diff == 0)\n", - "\n", - " # Timing — residual after removing the global tempo trend\n", - " predicted_start = timing_scale * ref_note[\"start\"] + timing_offset\n", - " timing_abs_diff = abs(res_note[\"start\"] - predicted_start)\n", - " if ref_idx == 0:\n", - " # First note will start at 0, so no difference.\n", - " timing_relative_diff = None\n", - " timing_correct = True\n", - " else:\n", - " ioi = ref_ioi[ref_idx]\n", - " timing_relative_diff = timing_abs_diff / ioi\n", - " timing_correct = (timing_relative_diff <= timing_relative_threshold)\n", - "\n", - " # Duration — residual after removing the global duration-scale trend\n", - " predicted_duration = duration_scale * ref_note[\"duration\"]\n", - " duration_abs_diff = res_note[\"duration\"] - predicted_duration\n", - " ref_dur = max(ref_note[\"duration\"], 0.05) # floor at 0.05s to avoid division by zero issues\n", - " duration_relative_diff = duration_abs_diff / ref_dur\n", - " duration_correct = (abs(duration_relative_diff) <= duration_relative_threshold)\n", - " \n", - " note_level_results.append({\n", - " \"reference_index\": ref_idx + 1,\n", - " \"response_index\": res_idx + 1,\n", - " \"operation_type\": op_type,\n", - " \"pitch_correct\": pitch_correct,\n", - " \"pitch_diff\": pitch_diff,\n", - " \"timing_correct\": timing_correct,\n", - " \"timing_abs_diff\": timing_abs_diff,\n", - " \"timing_relative_diff\": timing_relative_diff,\n", - " \"duration_correct\": duration_correct,\n", - " \"duration_abs_diff\": duration_abs_diff,\n", - " \"duration_relative_diff\": duration_relative_diff,\n", - " })\n", - " \n", - " return note_level_results\n", - "\n", - "\n", - "def compute_stats(note_details, ref_notes, timing_scale=1.0, timing_offset=0.0, duration_scale=1.0):\n", - " \"\"\"\n", - " Compute summary counts and correctness booleans from note-level feedback.\n", - "\n", - " Args:\n", - " note_details : list of dicts, output of note_level_feedback()\n", - " ref_notes : list of reference note dicts\n", - " timing_scale : float, from estimate_global_timing()\n", - " timing_offset : float, from estimate_global_timing()\n", - " duration_scale : float, from estimate_global_duration_scale()\n", - "\n", - " Returns:\n", - " stats : dict with keys:\n", - " \"pitch_all_correct\" -> bool\n", - " \"timing_all_correct\" -> bool\n", - " \"duration_all_correct\" -> bool\n", - " \"total_notes_in_reference\" -> int\n", - " \"total_notes_missing\" -> int (reference notes not played)\n", - " \"total_notes_extra\" -> int (response notes not in reference)\n", - " \"total_notes_wrong_pitch\" -> int (paired notes where pitch_correct=False)\n", - " \"total_notes_wrong_timing\" -> int (paired notes where timing_correct=False)\n", - " \"total_notes_wrong_duration\" -> int (paired notes where duration_correct=False)\n", - " \"total_notes_correct\" -> int (paired notes correct on all three dimensions)\n", - " \"timing_scale\" -> float\n", - " \"timing_offset\" -> float\n", - " \"duration_scale\" -> float\n", - " \"\"\"\n", - " paired = [n for n in note_details\n", - " if n[\"operation_type\"] in (\"match\", \"replacement\")]\n", - "\n", - " stats = {\n", - " \"pitch_all_correct\": all(n[\"pitch_correct\"] for n in paired),\n", - " \"timing_all_correct\": all(n[\"timing_correct\"] for n in paired),\n", - " \"duration_all_correct\": all(n[\"duration_correct\"] for n in paired),\n", - " \"total_notes_in_reference\": len(ref_notes),\n", - " \"total_notes_missing\": sum(1 for n in note_details if n[\"operation_type\"] == \"missing\"),\n", - " \"total_notes_extra\": sum(1 for n in note_details if n[\"operation_type\"] == \"extra\"),\n", - " \"total_notes_wrong_pitch\": sum(1 for n in paired if not n[\"pitch_correct\"]),\n", - " \"total_notes_wrong_timing\": sum(1 for n in paired if not n[\"timing_correct\"]),\n", - " \"total_notes_wrong_duration\": sum(1 for n in paired if not n[\"duration_correct\"]),\n", - " \"total_notes_correct\": sum(1 for n in paired\n", - " if n[\"pitch_correct\"] and n[\"timing_correct\"] and n[\"duration_correct\"]\n", - " ),\n", - " \"timing_scale\": timing_scale,\n", - " \"timing_offset\": timing_offset,\n", - " \"duration_scale\": duration_scale,\n", - " }\n", - "\n", - " return stats\n", - "\n", - "\n", - "def generate_feedback_message(note_details, response_notes, ref_notes, stats):\n", - " \"\"\"\n", - " Generate human-readable feedback messages for the student.\n", - " Part 1 — Overview: summary of timing trend, duration trend, and total counts \n", - " of each error type (pitch/missing/extra)\n", - " Part 2 — Note-level feedback: indicate exactly which notes have which problems.\n", - "\n", - " Args:\n", - " note_details: list of dicts, output of note_level_feedback()\n", - " response_notes: list of student note dicts\n", - " ref_notes: list of reference note dicts\n", - " stats: dict, output of compute_stats()\n", - "\n", - " Returns:\n", - " feedback_message (str)\n", - " \"\"\"\n", - " paired = [n for n in note_details\n", - " if n[\"operation_type\"] in (\"match\", \"replacement\")]\n", - "\n", - " timing_scale = stats[\"timing_scale\"]\n", - " timing_offset = stats[\"timing_offset\"]\n", - " duration_scale = stats[\"duration_scale\"]\n", - "\n", - " overview_messages = []\n", - " detail_messages = []\n", - "\n", - " # ---------- Part 1: Overview ----------\n", - " # Tempo: acceptable / too slow / too fast ---\n", - " timing_pct = abs(timing_scale - 1.0) * 100\n", - " duration_pct = abs(duration_scale - 1.0) * 100\n", - " timing_direction = \"behind\" if timing_scale > 1.0 else \"ahead of\"\n", - " duration_direction = \"longer\" if duration_scale > 1.0 else \"shorter\"\n", - " if timing_scale > GLOBAL_SLOW_THRESHOLD:\n", - " overview_messages.append(\n", - " f\"Overall, your tempo is slower than the reference \"\n", - " f\"(timing is about {timing_pct:.0f}% {timing_direction} the reference in general while \"\n", - " f\"notes are held about {duration_pct:.0f}% {duration_direction} than the reference). \"\n", - " f\"No worries! You will get better when you practice more to get more familiar with it!\"\n", - " )\n", - " elif timing_scale < GLOBAL_FAST_THRESHOLD:\n", - " overview_messages.append(\n", - " f\"Overall, your tempo is faster than the reference \"\n", - " f\"(timing is about {timing_pct:.0f}% {timing_direction} the reference in general while \"\n", - " f\"notes are held about {duration_pct:.0f}% {duration_direction} than the reference). \"\n", - " f\"Don't rush even if you are confident in your performance.\" \n", - " f\"Slow down and give each note its full value.\"\n", - " )\n", - " else:\n", - " overview_messages.append(\n", - " f\"Timing: your overall tempo is within an acceptable range. Good job! \"\n", - " f\"The timing is about {timing_pct:.0f}% {timing_direction} the reference in general while \"\n", - " f\"notes are held about {duration_pct:.0f}% {duration_direction} than the reference.\"\n", - " )\n", - "\n", - " # wrong pitch counts\n", - " if stats[\"total_notes_wrong_pitch\"] > 0:\n", - " s = \"is\" if stats[\"total_notes_wrong_pitch\"] == 1 else \"are\"\n", - " note_word = \"note\" if stats[\"total_notes_wrong_pitch\"] == 1 else \"notes\"\n", - " overview_messages.append(\n", - " f\"There {s} {stats['total_notes_wrong_pitch']} {note_word} played with the wrong pitch.\"\n", - " )\n", - " else:\n", - " overview_messages.append(\"There are no pitch errors. Well done!\")\n", - " # missing counts\n", - " if stats[\"total_notes_missing\"] > 0:\n", - " s = \"is\" if stats[\"total_notes_missing\"] == 1 else \"are\"\n", - " note_word = \"note\" if stats[\"total_notes_missing\"] == 1 else \"notes\"\n", - " overview_messages.append(\n", - " f\"There {s} {stats['total_notes_missing']} {note_word} you missed from the reference.\"\n", - " )\n", - " else:\n", - " overview_messages.append(\"There are no missing notes. Great!\")\n", - " # extra counts\n", - " if stats[\"total_notes_extra\"] > 0:\n", - " s = \"is\" if stats[\"total_notes_extra\"] == 1 else \"are\"\n", - " note_word = \"note\" if stats[\"total_notes_extra\"] == 1 else \"notes\"\n", - " overview_messages.append(\n", - " f\"There {s} {stats['total_notes_extra']} extra {note_word} played during practice. \"\n", - " f\"You may need to adjust your fingering or hand position to avoid extra notes.\"\n", - " )\n", - " else:\n", - " overview_messages.append(\"There are no extra notes. Good job!\")\n", - "\n", - " # ---------- Part 2: Detail ----------\n", - " # Missing / extra notes\n", - " for n in note_details:\n", - " if n[\"operation_type\"] == \"missing\":\n", - " ref_zero_based = n[\"reference_index\"] - 1\n", - " pitch = ref_notes[ref_zero_based][\"pitch\"]\n", - " detail_messages.append(\n", - " f\"Note {n['reference_index']} (pitch {pitch}) is missing in your performance.\"\n", - " )\n", - " elif n[\"operation_type\"] == \"extra\":\n", - " res_zero_based = n[\"response_index\"] - 1\n", - " extra = response_notes[res_zero_based]\n", - " detail_messages.append(\n", - " f\"Extra note played: pitch {extra['pitch']} at t={extra['start']:.2f}s \")\n", - "\n", - " # Pitch errors\n", - " for n in paired:\n", - " if not n[\"pitch_correct\"]:\n", - " ref_zero_based = n[\"reference_index\"] - 1\n", - " res_zero_based = n[\"response_index\"] - 1\n", - " ref_p = ref_notes[ref_zero_based][\"pitch\"]\n", - " res_p = response_notes[res_zero_based][\"pitch\"]\n", - " detail_messages.append(\n", - " f\"Note {n['reference_index']}: wrong pitch — \"\n", - " f\"expected {ref_p}, played {res_p} \"\n", - " f\"({n['pitch_diff']} semitone(s) off).\"\n", - " )\n", - "\n", - " # Local timing errors - these are residuals after removing the global timing trend\n", - " for n in paired:\n", - " if not n[\"timing_correct\"]:\n", - " detail_messages.append(\n", - " f\"Note {n['reference_index']}: timing is off by {n['timing_abs_diff']:.2f}s \"\n", - " f\"({n['timing_relative_diff'] * 100:.0f}% of the expected note interval), \"\n", - " f\"after accounting for the overall tempo trend.\"\n", - " )\n", - " \n", - " # Local duration errors — these are residuals after removing the global duration trend\n", - " for n in paired:\n", - " if not n[\"duration_correct\"]:\n", - " direction = \"longer\" if n[\"duration_abs_diff\"] > 0 else \"shorter\"\n", - " ref_zero_based = n[\"reference_index\"] - 1\n", - " ref_dur = ref_notes[ref_zero_based][\"duration\"]\n", - " duration_pct = abs(n[\"duration_relative_diff\"]) * 100\n", - " detail_messages.append(\n", - " f\"Note {n['reference_index']}: duration is {abs(n['duration_abs_diff']):.2f}s \"\n", - " f\"{direction} than the reference (i.e. \"\n", - " f\"{duration_pct:.0f}% off) after accounting for the overall duration trend \"\n", - " )\n", - "\n", - " all_messages = [\"Overview: \"] + overview_messages\n", - " if detail_messages:\n", - " all_messages = all_messages + [\"\", \"Detail: \"] + detail_messages\n", - " else:\n", - " all_messages = all_messages + [\"\", \"Great performance! No further issues found.\"]\n", - " \n", - " return \"\\n\".join(all_messages)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "a17eed61", - "metadata": {}, - "outputs": [], - "source": [ - "class FeedbackResult:\n", - " \"\"\"\n", - " Using a class (instead of returning a tuple) makes unit tests much clearer:\n", - " \n", - " result = compare_performance(response, reference)\n", - " self.assertFalse(result.is_correct)\n", - " self.assertEqual(result.stats[\"total_notes_missing\"], 1)\n", - " self.assertIn(\"missing\", result.feedback_message)\n", - " \n", - " Attributes\n", - " ----------\n", - " is_correct : bool\n", - " True only if every note is perfectly matched on pitch, timing, and duration.\n", - " stats : dict\n", - " Aggregate counts — see compute_stats() for the full key list.\n", - " note_details : list of dicts\n", - " Per-note analysis, one dict per alignment operation.\n", - " Each dict has the keys described in note_level_feedback().\n", - " feedback_message : str\n", - " Human-readable feedback string, ready to display to the student.\n", - " see generate_feedback_message() for details.\n", - " operations : list of dicts\n", - " Raw alignment operations from note_alignment_ED().\n", - " Kept here so visualisation helpers (plot_cost_matrix etc.) can use them.\n", - " D : numpy.ndarray\n", - " Accumulated cost matrix from the alignment step.\n", - " \"\"\"\n", - " \n", - " def __init__(self, is_correct, stats, note_details,\n", - " feedback_message, operations, D):\n", - " self.is_correct = is_correct\n", - " self.stats = stats\n", - " self.note_details = note_details\n", - " self.feedback_message = feedback_message\n", - " self.operations = operations\n", - " self.D = D\n", - " \n", - " def __repr__(self):\n", - " return (\n", - " f\"FeedbackResult(is_correct={self.is_correct}, \"\n", - " f\"stats={self.stats})\"\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "b5f03964", - "metadata": {}, - "outputs": [], - "source": [ - "def compare_performance_ED(responseMIDI, refMIDI, gap_penalty=6.0,\n", - " timing_relative_threshold=TIMING_RELATIVE_THRESHOLD,\n", - " duration_relative_threshold=DURATION_RELATIVE_THRESHOLD):\n", - " \"\"\"\n", - " Full pipeline: normalisation → alignment → estimate global trends \n", - " → note level evaluation → feedback.\n", - " \n", - " Args:\n", - " responseMIDI: student MIDI dict with key \"notes\"\n", - " refMIDI: reference MIDI dict with key \"notes\"\n", - " gap_penalty: cost of an unaligned note\n", - " timing_relative_threshold: see note_level_feedback()\n", - " duration_relative_threshold: see note_level_feedback()\n", - " \n", - " Returns:\n", - " FeedbackResult object containing all analysis results\n", - " \"\"\"\n", - " # Step 0: normalise start times \n", - " response_notes = normalize_start_times(responseMIDI[\"notes\"])\n", - " ref_notes = normalize_start_times(refMIDI[\"notes\"])\n", - " \n", - " # Step 1: align notes using edit distance\n", - " operations, D = note_alignment_ED(\n", - " response_notes, ref_notes, gap_penalty\n", - " )\n", - " \n", - " # Step 2: estimate the overall tempo trend\n", - " timing_scale, timing_offset = estimate_global_timing(\n", - " operations, response_notes, ref_notes\n", - " )\n", - " duration_scale = estimate_global_duration_scale(\n", - " operations, response_notes, ref_notes\n", - " )\n", - " \n", - " # Step 3: note-level evaluation\n", - " note_details = note_level_feedback(\n", - " operations, response_notes, ref_notes,\n", - " timing_scale=timing_scale, timing_offset=timing_offset,\n", - " duration_scale=duration_scale,\n", - " timing_relative_threshold=timing_relative_threshold,\n", - " duration_relative_threshold=duration_relative_threshold,\n", - " )\n", - " \n", - " # Step 4: compute summary statistics\n", - " stats = compute_stats(\n", - " note_details, ref_notes,\n", - " timing_scale=timing_scale, timing_offset=timing_offset,\n", - " duration_scale=duration_scale,\n", - " )\n", - " \n", - " # Step 5: generate the human-readable feedback text from those statistics\n", - " feedback_message = generate_feedback_message(\n", - " note_details, response_notes, ref_notes, stats\n", - " )\n", - " \n", - " # Step 6: overall pass/fail judgement, based purely on the stats above\n", - " is_correct = (\n", - " stats[\"total_notes_missing\"] == 0\n", - " and stats[\"total_notes_extra\"] == 0\n", - " and stats[\"pitch_all_correct\"]\n", - " and stats[\"timing_all_correct\"]\n", - " and stats[\"duration_all_correct\"]\n", - " )\n", - " \n", - " return FeedbackResult(\n", - " is_correct = is_correct,\n", - " stats = stats,\n", - " note_details = note_details,\n", - " feedback_message = feedback_message,\n", - " operations = operations,\n", - " D = D,\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "32160f0e", - "metadata": {}, - "outputs": [], - "source": [ - "def evaluation_function(\n", - " response: Any,\n", - " answer: Any,\n", - " params: Params,\n", - ") -> Result:\n", - " \"\"\"\n", - " Function used to evaluate a student response.\n", - " ---\n", - " The handler function passes three arguments to evaluation_function():\n", - "\n", - " - `response` which are the answers provided by the student.\n", - " - `answer` which are the correct answers to compare against.\n", - " - `params` which are any extra parameters that may be useful,\n", - " e.g., error tolerances.\n", - "\n", - " The output of this function is what is returned as the API response\n", - " and therefore must be JSON-encodable. It must also conform to the\n", - " response schema.\n", - "\n", - " Any standard python library may be used, as well as any package\n", - " available on pip (provided it is added to requirements.txt).\n", - "\n", - " The way you wish to structure you code (all in this function, or\n", - " split into many) is entirely up to you. All that matters are the\n", - " return types and that evaluation_function() is the main function used\n", - " to output the evaluation response.\n", - " \"\"\"\n", - " if params is None:\n", - " params = {}\n", - " \n", - " result = compare_performance_ED(\n", - " response,\n", - " answer,\n", - " gap_penalty = params.get(\"gap_penalty\", 6.0),\n", - " timing_relative_threshold = params.get(\"timing_relative_threshold\", TIMING_RELATIVE_THRESHOLD),\n", - " duration_relative_threshold = params.get(\"duration_relative_threshold\", DURATION_RELATIVE_THRESHOLD),\n", - " )\n", - " \n", - " return {\n", - " \"is_correct\": result.is_correct,\n", - " \"feedback\": result.feedback_message,\n", - " }" - ] - }, - { - "cell_type": "code", - "execution_count": 13, + "execution_count": 5, "id": "27e6eeb8", "metadata": {}, "outputs": [ @@ -1369,184 +105,54 @@ }, { "cell_type": "markdown", - "id": "e2043a53", - "metadata": {}, - "source": [ - "### Test" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "705bee47", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Loaded 18 test cases\n", - " - perfect_performance (short)\n", - " - single_pitch_error (short)\n", - " - multiple_pitch_errors (short)\n", - " - missing_note (short)\n", - " - extra_note (short)\n", - " - missing_and_extra (short)\n", - " - global_tempo_slower (short)\n", - " - global_tempo_faster (short)\n", - " - global_duration_longer (short)\n", - " - local_timing_anomaly_only (short)\n", - " - local_duration_anomaly_only (short)\n", - " - first_note_no_timing_penalty (short)\n", - " - too_few_matches_for_global_fit (short)\n", - " - all_notes_missing (short)\n", - " - all_notes_extra (short)\n", - " - repeated_pitch_ambiguous_alignment (short)\n", - " - long_stress_test (long)\n", - " - long_perfect_performance (long)\n" - ] - } - ], - "source": [ - "test_path = os.path.join(dir, \"data\", \"test_cases.json\")\n", - "\n", - "with open(test_path) as f:\n", - " test_cases = json.load(f)\n", - "\n", - "print(f\"Loaded {len(test_cases)} test cases\")\n", - "for case in test_cases:\n", - " print(f\" - {case['case_id']} ({case['length_category']})\")" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "id": "34952131", + "id": "f3765faf", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Purpose: Both a missing note and an extra note occur, verifying the two gap types don't interfere with each other\n", - "\n", - "is_correct: False\n", - "stats: {'pitch_all_correct': True, 'timing_all_correct': True, 'duration_all_correct': True, 'total_notes_in_reference': 7, 'total_notes_missing': 1, 'total_notes_extra': 1, 'total_notes_wrong_pitch': 0, 'total_notes_wrong_timing': 0, 'total_notes_wrong_duration': 0, 'total_notes_correct': 6, 'timing_scale': 1.0, 'timing_offset': 7.251946429389433e-16, 'duration_scale': 1.0}\n", - "\n", - "Overview: \n", - "Timing: your overall tempo is within an acceptable range. Good job! The timing is about 0% ahead of the reference in general while notes are held about 0% shorter than the reference.\n", - "There are no pitch errors. Well done!\n", - "There is 1 note you missed from the reference.\n", - "There is 1 extra note played during practice. You may need to adjust your fingering or hand position to avoid extra notes.\n", - "\n", - "Detail: \n", - "Extra note played: pitch 90 at t=0.30s \n", - "Note 6 (pitch 65) is missing in your performance.\n" - ] - } - ], "source": [ - "# Pick one case by id to inspect closely\n", - "case_id = \"missing_and_extra\"\n", - "case = next(c for c in test_cases if c[\"case_id\"] == case_id)\n", - "\n", - "print(\"Purpose:\", case[\"purpose\"])\n", - "print()\n", - "\n", - "result = compare_performance_ED(case[\"response\"], case[\"reference\"])\n", - "\n", - "print(\"is_correct:\", result.is_correct)\n", - "print(\"stats:\", result.stats)\n", - "print()\n", - "print(result.feedback_message)" + "### Visualisation" ] }, { "cell_type": "code", "execution_count": null, - "id": "772e0e46", + "id": "a8636c4c", "metadata": {}, "outputs": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "[perfect_performance ] is_correct=True missing=0 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", - "[single_pitch_error ] is_correct=False missing=0 extra=0 wrong_pitch=1 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", - "[multiple_pitch_errors ] is_correct=False missing=0 extra=0 wrong_pitch=3 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", - "[missing_note ] is_correct=False missing=1 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", - "[extra_note ] is_correct=False missing=0 extra=1 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", - "[missing_and_extra ] is_correct=False missing=1 extra=1 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", - "[global_tempo_slower ] is_correct=True missing=0 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.40 duration_scale=1.00\n", - "[global_tempo_faster ] is_correct=True missing=0 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=0.60 duration_scale=1.00\n", - "[global_duration_longer ] is_correct=True missing=0 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.80\n", - "[local_timing_anomaly_only ] is_correct=False missing=0 extra=0 wrong_pitch=0 wrong_timing=1 wrong_duration=0 timing_scale=0.99 duration_scale=1.00\n", - "[local_duration_anomaly_only ] is_correct=False missing=0 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=1 timing_scale=1.00 duration_scale=0.90\n", - "[first_note_no_timing_penalty ] is_correct=False missing=0 extra=0 wrong_pitch=1 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", - "[too_few_matches_for_global_fit ] is_correct=False missing=0 extra=0 wrong_pitch=0 wrong_timing=1 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", - "[all_notes_missing ] is_correct=False missing=4 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", - "[all_notes_extra ] is_correct=False missing=0 extra=4 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", - "[repeated_pitch_ambiguous_alignment ] is_correct=False missing=0 extra=0 wrong_pitch=1 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n", - "[long_stress_test ] is_correct=False missing=1 extra=1 wrong_pitch=3 wrong_timing=2 wrong_duration=1 timing_scale=1.10 duration_scale=1.01\n", - "[long_perfect_performance ] is_correct=True missing=0 extra=0 wrong_pitch=0 wrong_timing=0 wrong_duration=0 timing_scale=1.00 duration_scale=1.00\n" - ] + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAvEAAAJOCAYAAAA+pFhBAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAg5xJREFUeJzt3Qd0VFUXhuFv0obeRZAmYEFEFBELqKACYqGrKCgoVizYu/72imJFsYEoiEpHURSxoaIgYAdUmoDSe5u0+699wsQkUpJMkmnvs9bInGk5uROTfffss4/P8zxPAAAAAKJGQrgnAAAAAKBgCOIBAACAKEMQDwAAAEQZgngAAAAgyhDEAwAAAFGGIB4AAACIMgTxAAAAQJQhiAcAAACiDEE8AAAAEGUI4gFEtEaNGumss85SpGnbtq2OOuqocE8jbkTqzwEAhAtBPBCh7rvvPvl8PjVv3jzcU4la+++/v84999xwT0Oe5+ndd9/V6aefrurVq8vv96tOnTpq06aNBg8erM2bN0fE93/ssce6n7natWsrMzPzP/cvWrRICQkJ7jE33XRTicypuAS/1+ClfPnyatCggbp27ao333xTaWlp4Z4iAOwRQTwQgSyAGjp0qAumZs+e7S6ITqmpqerSpYv69Omj448/XtOnT3dBu/3buXNn3X777XryyScVKcqWLavly5drypQp/7nv9ddfV5kyZcIyr3nz5mn06NFF+pq1atVyJ1h2Wblypd5//30dd9xx6t+/vzt5tuMAAJGKIB6IQB9//LGWLFmiN954QzVq1NArr7wS7imhkG6++Wa99957mjBhgu644w41bNhQKSkp7gTt+uuvdydolp2OFDavFi1auIA9Jwt0hw0bFrMlLXZy0rhxY91yyy36+uuv3acO3bt3D/e0AGC3COKBCGRB+2GHHaaTTjpJl1xyid566y1t27Ztl48dNWqUTjjhBFWoUEFVqlRxJRuzZs3K92PS09NdOcFdd931n9e2cg8rO8jJTiouvPBCzZgxw2UtLfg54ogj9NVXX7n7LcPcsmVLd/uBBx7ogtecCvr1dsUeFyyDSExMdHPq2bOnFi9enP0Yu89OhN55553sx9o8c7ISF5urZZ/tYq87bdq0XI+xsgoLvi1ra485+eSTXVY4P1atWqUXX3xRZ555ptq3b7/Lx9SvX98dz5yfwlhm3gJKK7upWrWqC5zzfs0PPvjAvaf2flauXNldHz9+fIG+/92x+dhrbdy4Mfu2Tz/91L1ezrkW9XsS/NmaOXOmTjzxRJUuXVq33XbbLmviJ0+e7L6OvTc52c+bve4DDzygwrJjf9lll+m7777TZ599VujXAYDiRBAPRBgL/Cxze+WVV7rx5Zdfrq1bt7qAc1d18+edd54LoH766SeXPbRSgKeffrpAjymoZcuWacCAAe6Tgr/++ssFPRaoWgD/6KOPulKgpUuXuiC/R48e+vvvv1WUPv/88+wyCDu5sTIICw5tDjt27HCPsfvq1avnvn7wsT/88EP2azz00EMuyOzUqZP+/PNPLVy4UMccc4xOOeWUXIG8nUQ988wzevzxx933Yf9ee+212rRp017naYGvnQScdtpp+f7errjiCheY2tdYsWKFvvzyS/evndz88ccf7jE//vijK8Vp3bq15s+f7461zctqudeuXZuv739P7OfFHv/2229n32bvqS3kbdKkSbG9J8GfLfuZfeGFF7RgwQL3nuxKhw4ddOedd7qfN/taxh5vZUt2vHd1klgQwZOuL774IqTXAYBi4wGIKI899phXvnx5b/Pmzdm3denSxWvZsmWuxy1YsMBLTEz0rrzyyt2+Vn4ek5aW5tmvgjvvvPM/97Vu3do75phjct227777uvmtW7cu+7bly5e717D71qxZk337ihUrPJ/P576nwn69gw8+2Ovevbu3Nz/88IN73U8++ST7tnr16nk9evT4z2MXL17sJSUleddee+1/7jvhhBO8Vq1aueu//PKLe80HHngg12N+/vln9301b958j3N69NFH3fPff/99Lz9+/fVX9/hbbrkl1+0rV670ypQpk/29PP/88+5xa9eu3ePr7e773x079na8zTnnnOMde+yx7vrGjRu90qVLe4MGDfJWr17tvvaNN95YpO+JsZ8fv9/vvt+8dvVzkJGR4bVr186rVKmSO3aHH364V7du3Vw/g3v6XmvVqrXb+4Pv/aWXXrrX1wKAcCATD0SY1157Tb1791a5cuWyb7Os/DfffKPffvst+7aPPvpIGRkZLpu8O/l5TGFYVthKOIL2228/V6pjGXkr/wjad9993eMsy12ULPts2WL7uklJSbnKMiyrvjdWimFlPWefffZ/7rNM/LfffusWpFom3Vi2PifLRltte1ELfr1u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hrR9e1YpTd7FhcHb7H57XIwH8o8//rhmzpxZYs+LBwMGDNBPP/0U7mkAABAxmfjiusQDgvhgCU337llB+t4WE9r99jh7/M7a5cJYvny57rnnHvXq1UvXX3+9Zs2ale/nPvroo7keXxyB8zfffOPmGMrzoiWgHzhwoObMmVPsr2vHZuXKlUX+dQAAQPwhiDdWA79t294D+CB7nD3+jTcKddB/+eUXNW3aVEuWLFHbtm3dlsCtW7fWyJEj8711/T///JM9PuGEE1SrVi1FmkidV17Tp0932zNHy+sCABDtyMSHLuYXtu6VZdVtEWthPPts1mLXAn5sc+2116pv376uvCKoefPmuvTSS9W5c2fXSeehhx5Sy5Yt9emnn2rVqlXq06ePG0+ePNll4S0b//rrr+umm27StGnT3Nbn++23n3teixYtXKBvXXmuuuoqValSxWWF09PTdfPNN6tBgwbuaw4aNEifffaZUlJS1LhxY1199dVuUeXe5Pd5OedlC1SffvppzZ8/X6effrrmzp2rTp066bDDDnNzPv744933aln8888/X23atHGvYcfoyCOPdAHx33//7Y5R5cqV9fzzz7vOQ/3791f9+vWzv+bo0aP1xRdfyO/364ILLtDhhx+e/TrHHXecu89OgHr06OFOMqZMmaIffvhBTz31lN566y1dc801Ovroo3N9H/bcY445xj13zZo16tmzpxubl19+2X2fycnJatSokS677DJ3LHb1usYWrNr3m3MOAAAABRV/mfijjspanBq87LdfVheagm6wY4+359nzc76evf4eBAIBFwxaEJ/TWWed5fqCB8tPLDC8+OKLXVedhg0b6owzztBvv/2mAw880GW3Lfg799xzVadOnVwlLPY8C6pr1Kih2rVrq0OHDi7wtWA2MTFRZ599dvbXtEDUXqNjx44uQLbgOT/y+7yc8+rXr58++OADF6yPHTvWldoES0tszpdffrmqVavmTgq6dOniTlyCr2EnKvvuu687GejWrZsL3C34t42g7OQm6P7779d7772nVq1auRMVC7b/+OOP7Nexk6eqVau6YNvuW716tTu29rp2gtS9e3d3zHb1fdgxtfnZCYMdw3nz5rn7jjrqKPe80047zQXmdqzN7l7XAvi8cwAAIN6QiQ9d/GXirbyhELXee3y9AvY9twyyBYR5WXC3cePG7LEF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lK6/XrrzTmnMGOnUU8M9WxTCxo0btXDhQqWlpal+/frax07QEBUyMjK0aNEidz0xMdG9fzllZmZq5cqVLuO07777un8R2WrWrKk6deq463///beWLVuW6/5y5cq593nHjh36888/5dknpIh4Bx54oCpXrqyffvrJvXd5VatWTc2bN9e0adO0beffWxQvMvGhI4iP9AD+jDOyymg8T29KulrSIZLmSxrheTrdHrd9e9bjJk0ikI8yEydO1Msvv6z9999fZcqU0TnnnEMQH0VSU1P17bffuhOw1atX65Zbbsm+z4L3sWPHuuDegoaUlBT17t3bvc+IXIcccog6dOjgAvnPPvvM/f8Z1LJlS91xxx3upLtSpUpKT09X//79tcVKHBGRqlSpohEjRqhBgwbatGmTateurTPPPFOzZs3K9bghQ4aoffv2OvzwwzV/vv2FBSIfQXykshIZy8BbAJ+ZqTRJFh6Mk3SypLGSbpaygvjMTCkhIevxljWqVCncs0c+WJZv8ODBevbZZ3XAAQdwzKJQ6dKldf7552vNmjUuCMjJsnlnnXWWOymzbO2bb77pAocTTjghbPPF3n366afu0q9fv//cd8YZZ+i1117TuHH2m1h66aWX1KxZM5e9RWSy7PvAgQM1ZcoUN37ooYd09913q0uXLtmPueyyyzRjxgwdd9xxYZxp/CETH8U18VdffbVGjx6dPZ4zZ446d+7MR5NBw4ZlldBYgC7pF/uB3xnAG/v189fO8hrHHmePf+ONknwbEQLL8h177LGqWLGifv75Z7J5MSZnaZT9sSpbtqySk5PDPS2E4IsvvlDr1q3diVinTp3cpyu//vorxzSCLViwIDuANytWrNB2+/R6p3r16qlnz5565JFHwjRDIAqDePto6/PPP3fXLUtlH0neeeed7o+d1Zn+8ccf7qPquGTZ9+eey3XTSqvVzPPGWXjwT97nPvts1vMR8f755x+tW7dON9xwg8vo2R+S2bNnh3taKAaLFy/W8uXL3Uf1iF7z5s1zn7706NHDXX744Qe3pgXRwUpprr/+eg0YMCD7Nvs09JprrnFxB8KTiS+uSzwIWznN8ccfr7feestdHzp0qI488kgdffTRrsbQ7lu7dq2rJZ0+fbr7OCyvQCDgLkFW6xYzrI3kggW5bipnH8/neZiNy+e8wYJ3e966dVntJxHRLBjY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9fvAnHjiiXo3jGvdCOKjyFNPPaWTTz5Zl1xySbingsLYvl0Jf/4pC9MtiLdfD+9L+kTSTXke6svMlJeQ4Epvtv/+u7RzAylENgsAcgb0nuepZs2aYZ0TCubHH390lzPOOOM/91mwbr+H7QQtMTFRd955p0uo/P333xzmCPbggw+6f6dOnfqfDH3jxo111llnuex827ZtddVVV7kL4isTf9XO9/zCCy9Uv379XNVDXnbyfvrpp7sTvyBLqsZlTfyVV16pUaNGZY9nzZqljh07uj96+C/7WPfnn392v2zmzJnjsvKILkkjRrhAPr+/WiyQ17ZtSnrrrWKeGYrDxx9/rPbBBcyICfPnz1e5cuVc4NemTRtt2LDhP+U2iB5W5mafpgTLa+zvqn0KYydoiC+vvfaa+1TNTuz2pEyZMu7EPXipXLmy4jKIP/DAA/X555+76xa49+/fX/fYAsC0NH377bfusqszoXi1YsUKVwN/11136c0339S1117rMkKIEp5X6D7xSS++yI6vUcYys4sWLVKrVq3CPRUUsVq1aum4447TMccc44J6+5uF6GQJMauHv+yyy7Kz8MZKbxBfO7YmJOQvHB4zZoz2339/tWjRQrfffnvY11CELYg//vjjXaBuXn31VR199NHuDGjjxo267rrrdNppp7ksx+7YmbPVnua8xLLSpUu7j3LsY56BAwe6M8ZPPvmEjHy0WLtWCQsXZte855c93p6ndeuKbWooeh999JFat27tFkEittgnopa1e/zxx9W0aVM1atQo3FNCIa1bt84F8JUqVXL/v44cOdIFf7EeT8STTXnixLyLmgvCgvcBAwa42Ov+++/XhAkT1K5dO2VkZOTr+XbSb1UVBb0vIoP4Zs2aacGCBVq+fLkGDx6cXbO2zz77uOB+bxmsRx55xH0UFrzUqVNHsczqaoNdEYz90ilfvnzYzwKRP9YHPhQ+3ueoYb/Qrfb21FNPDfdUUMRy1r/aJ8WWaLKP1xG97BMzOyG7++67te++++qPP/4IKdBDZGXi69SpkytWtNixsKz64fzzz3cLXi3RPHr0aBevWulkfli1iZ0AFPS+iFzYmpSU5GrPunTpogceeEBlbaObArCPMXIuIrMzrFgO5O0Pha2wtx9A+9c+BrRjmHf1NCKT28gpDzt3t3XuP9rP785FrvUl7appqFeuXInME6Gb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UvKttyr5vvsUGDFCme3ahXv6yKcNGzZo7ty5uW7z+/068sgjOYZRxH6npqSkuOu2K2ZaWlqu+6tWrarKlSu72uQtW7aEaZbxJ5J2bC1OF198sSudueKKK/TKK6+4Wngzbty4kF+bID7C/4D88MMPbgVzxYoVde6552bf9/PPP7t+o/aLZ+3atapVq5bOPvtsJSTw4Uq0WLhwob788svs8bx589ShQweC+CjhHXCAdowfnxXIb98uX0aGvpU0WdL3km6zrbztj4mV19jjt2+Xv3t3BcaMIZCPEva79ZNPPske20m3nWgTxEcX68Ftya5DDjlETzzxhHtfg3r06OESZPZ31mqTJ02apBkzZoR1vogt9erVcyeHloE/7LDDXLxmZTRFgSA+gtkvFAvcZ8+e7bIHOSUlJalfv34uK2QZ+eeee85ldu2HBdGhdevW7mLsPezdu7fatm0b7mmhALz993c17xamW97nip2Xf0+3/2U95r2EBPl79dJ2+/+5UiWOdYSzjNndd9+dPb7lllvUvn37sM4JBRfswX3ffff9575ff/1V77zzTnawbxvvEMSXjHjJxD/88MO69NJLNWTIEN1+++3q37+/LrjgAldJEaqwpW0vv/zy7P9xjO1cdcYZZ8jbmbXCnllGwQJ4Yx8T2qrm4MeFiD5fffWVCxisZyyiR9KIEXYG5gL4/LBAXtu2Kemtt4p5ZihqVtr4119/qWXLlhzcGPLLL7/ogAMOcLtl2uY7c+bMCfeUEIPq16+vBx54QIsXL9bw4cNd0tUy84888ohrL2kbQEVVEG9B6BdffOGuZ2Zm6tprr3XfoDXAt4DGmuFbdhJ7ZyUZFvwRAEavjz76iAxftPE8JQ0eXKinJr34Il1rovD/0TZt2ig5OTncU0ERsxIHC+CtPDW4Dg3FLx6701jC9fTTT9fYsWNded7555+voUOHqlGjRoXatTVsQXyrVq303XffuetW6G/ZDasztLPg2267zX30cOihh7q68F2xAH/Tpk25LvHo66+/1pIlS9S9e/dwTwWFZH807D0kwxdl1q5VwsKF2TXv+WWPt+dp3bpimxqKvqezrUGilCY22QLDl19+WePHj1fPnj3DPR3EierVq+vmm2926+GszaQF8lFTE9+sWTO3sM8+UrAgPrjAz35JBn9R2ope++as/U5e9hHErurb4ont7mUf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R3Szukx7P4Os9hbRyz6utzZ2QdZ20hbAInpZ/bRdrITRTtBs3QNig5W8+f1+t+4BiBYE8QAAAECUoSYeAAAAiDIE8QAAAECUIYgHAAAAogxBPAAAABBlCOIBAACAKEN/LADYhblz57pLpUqV3J4MAABEEoJ4ADFh/vz5mjNnjrtuO23WrFnT9dovX758gV/rqaee0oMPPqiTTjpJTZs2JYgHAEQc+sQDiAlPPPGE7rzzTnXt2tXtaDxv3jwtW7ZMb7zxhs4888wCvdYhhxyi/v37q1+/fsU2XwAAQkEmHkDMsKz722+/nT2+4IILdPHFF2vlypW5Hrd+/Xp9++23bsfNZs2aqVq1au72rVu36r333tPy5cvdSYC9VosWLdSwYcM9Pi+44+7kyZPVrVs3t1PrggULdNxxx2m//fZz9//555/65ZdftO++++rII490u0MG2e02xxNOOEE//vijVq9erebNm7vH5rVw4UL9/PPPbqdmex2fz5fr/j19HQBA7CCIBxCzTjvtNA0fPlwrVqxQjRo13G3Dhg3Ttdde64LkxMREzZgxQ88884z69Omjbdu2afz48QoEApo5c6YLrC1QtyB+T88zixcv1nnnnaczzjhDS5YsUePGjdWgQQNVr15dl1xyiT744AMdc8wx7tMBO1mYMGGCy/ib0aNH680331SFChVUtWpVNw8L1CdNmqQTTzzRPSYtLc29zpgxY9zJgZ00VKlSRe+//76Sk5OVnp6+168DAIghHgDEgAEDBnhVq1bNddv999/v+f1+b9u2bW78008/eWXLlvVmzpyZ/ZjPPvvMK126tLd06dLs2+x1Ro4cmT3Oz/PmzJnj2a/USy65xMvMzMx+3MMPP+wdccQR3qZNm7Jvu+6667xWrVplj++55x7P5/N5n376afZtF154oXfyySdnj++77z6vWrVq3h9//JF928cff+xt3bo1318HABA7yMQDiBmpqamuBCZYEz9w4EDdddddKl26tLvfst1169Z1WfNFixZZEsPdnpKSounTp+vss8/e5esW5HnXXHNNrhKXoUOHqmXLlvroo4/c8+xi2XZ73o4dO1SqVCn3uEaNGrmFtEFt2rRxNf5B9knAFVdcoQMOOCD7tnbt2hX46wAAYgNBPICYYWUwVg5jpSWzZs1yAa8FvkEWhFuJiZWv5NShQwdVrFhxt69bkOdZV5y8z7WSnLzPtcDfymaCwbWVxuRktewWfAf99ddfOuigg/Y4x/x8HQBAbCCIBxCTC1stoD/llFPUo0cPTZ061d1mNee1atXKtfg1PwryvLwLTe25Xbp00S233KJQWL/6tWvX7nGORfF1AADRgR1bAcQky2S/9NJL+uKLL7Kz05Y5/+6777L7yQdZ15nt27fv9rUK+7zgc4cMGeJKfXKyDjgF0b59e40YMcKVCgXZ4lZb8FqUXwcAEB3IxAOIWYceeqjrHnPHHXe4LHX37t1deYntwGp94K3O/ddff9XEiRP1zTffZNfO51XY55nHHnvMtY60jjE2F2tP+dVXX7ng3zrH5NcjjzyiVq1aqXXr1q4LjgXw9smAtby07jRF9XUAANGBTDyAmGALQ61He17333+/awtpWXQrdRk5cqRbJGqlKbbos169evr+++9dK8ggex27PSg/z6tcubIr3cnbl93KcKz3u7V//Omnn/THH3+4k4Jx48ZlP6ZJkyZq27ZtrufZ6+f8fuzEwV6nU6dOrr2llQt9+OGHKlOmTL6/DgAgdrBjKwAAABBlyMQDAAAAUYYgHgAAAIgyBPEAAABAlCGIBwAAAKIMQTwAAAAQZQjiAQAAgChDEA8AAABEGYJ4AAAAIMoQxAMAAABRhiAeAAAAiDIE8QAAAECUIYgHAAAAFF3+D4NdNY7FAW5xAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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qOC9+uqr7ud169Z5LVq08MqWLet+3rRpk+78vdmzZ8d/f8yYMV7FihWLtH/Ra8855xy3L+nbt69Xu3Zt93jlypVetWrVvF69esVfP3bsWG/WrFlF2vfWnHXWWa7djz322D98R1GQ7Oxs937qS4+xbTru6tat6475qVOnenvssYf3zjvv8Lal4bEX9vaF0XHHHef95z//iV8Patas6S1YsCDZzUpJYT/+wt6+sJk/f75XunRpb9y4ce7+rk2bNl6HDh28zZs3J7tpKSnsx1/Y2xc2zz//vLfPPvu4vor6CeojDBkyJNnNSllhPv7C3Lawat26tde5c2f3eODAgV6NGjW8OXPmJLtZKSk7zY6/7c5oKlmypMv66dKli33//fd27bXXWpUqVaxXr17ueT2naXTKCtIUOH3Vr1/fTUP7448/3POqbXT++ee715cuXdquv/76Iv/9wvbvU4ZRZmame3zcccfZ/PnzXYRVv79+/Xq77bbb4q9Vdpb2V9R9F0T/H1KhQoV/+I4CO+66665zx/fdd99tbdq0sUcffdTat2/vnjvmmGPcl6Z5AlH0+OOPW79+/dzU5p49e9rnn3/uMl01RVujb61bt7ZHHnkk2c0Edrl9993XZW3onqhjx46utqSynIoVK2ZLliyxK664wmWcJ2Z1AFFx2WWXWcWKFePXCWXC6jxJzADU7Iivvvoqqe0EkuGxxx5zNZofeughu+uuu+yzzz6z/fbbz8aNGxfve+hLmeWIllgEZjuVKlXKTcvRl+jAOumkk9wUNNVD0vS37t275/qd5s2bu7pMqp1UqVKlXM/l/TmvxOkNhe2/WrVq7mcFs3wKOKkTri9NkatcubK7icqrqPvOS/WqvvjiC/ddAThN/dOUOmBXUQBYRek1hU6daqV2+5544gl7++23SfVGZB1xxBHu+tSjRw83rbROnTpuuz7rdT3QuXP77be7hS3+/e9/J7u5wC51yy23uA7DkUceaV9++WV8kE5T6jRQpwE0DSrqPKpRowb/OogM1WJVEPaiiy6y+++/35XjSKT7LdWDTZyaDUTFIYccYieffLJ169bNJkyYYAceeKDbvmLFCvf4qquucj9z3Yie7Q40rVmzxgWa/BsR0Q28fv7f//7nDjoFht59913bbbfd4q9RplD58uVdoGfu3LnuQ1mZUKIb/3jDMjNdkMivmSQz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0n1F0r/++st1qP3s7unTp+d6ja4nnTp1ctcM7mkQFZs3b3alMDRw98ADD7htun+aNWtWrtfp+WbNmiWplUByKLh02223uXusI444wm1TkEmZTaLMWH35HnnkEVccXIN5iIZIB5o0jcg/MXz333+/CxJp+oSeb9u2rbu5eumll9y0CnXoNfJ92WWXubTySy65xMaOHWtTp051aYO33nqrnXbaaW5fRx11lI0ZM4abMux0OjaVxXTBBRfYU0895TrM6ij7AVA9X61aNRdkkmLFitm5557rMjmAdDdo0CA79dRT3ee3Mpu0DPWZZ54ZX4ZaU+US+VPq1MkA0n3KnO5v+vTp46Y5KDNDwdi8HQENVGi7pg0BUaH7e2W56p7/2WefdeeLpv1olexE77//vuts77///q7fULJkyaS1GdhVdO3Q+aA+h/oTe+65pwssVapUKd9rde7079/frrjiCtcHQTRE+l967ty5+Uarf/rpJ7vxxhtt/vz5LkCkrA+NVKjzrovHeeedZ08//bQb6ZZrrrnGdebVqe/evbs1atQovi/tW38D2BW1Z1QPQAXpdVP0zTffuDphb731lnv+t99+s7333jvX7+hnTaMDonB+6LzQDY6m/Kh2njoKmiaUl0arVV9PHWqWq0a601Sg5cuXu3qVr7/+uhuNVjkB1TDzabqpgrV6DRDFeystGqT+gc6FI4880gYPHhx/jfoDeo0G+5Qd2KBBA/cYiML5ocEJJWQsWbLEBZ4OPPBA14cuaBaRtitoi+iIdEaTivepKGxexx13nD3++OPx9PFXXnnFdciVESLqoGhanLKfdMGpUqWK7bfffi4olUgp6Duymh1QVMq8UGdBtZf8kWgFQJVh53cY8hZ21egCEJXzQx1qBf7VIdCxr3pNGoFTfQGftl999dVuyulXX32V1DYDu4KftadspSFDhrjHKvRdp04dl82hzGx1IjTAVrFiRbfgBBCl80OrU2vwQbVY/XNFZQp0Xog/8CzKmG3atKmrQ6MasEC6nx+LFi1y5Tl0ffD70L169cpXlkM/K2Dr96URDZEONKkqfkE3TcoE8f3444+ujofmaCfatGmT67RodZatUcd/9913D7jVQH5+5oU6BT4FQe+++253rGplxbzZS6pRkzfLCUjX80M1A/xVgBR01bTpX375JdcNk6ZCaxq0gkx8diMK9thj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8kl755134q/xPM8GDRrkMn7+/e9/24ABA9w237p16+zee+91wambbrrJRo4caaeccop7bvPmzdaiRQtbuHBhrr95+umnF3n/eu2oUaPstttuc4/1N5YtW5br91966SXr2LGj+38YMWJEkfed6KSTTrIrrrjCvXbTpk02c+ZMu/32212WU+XKlXfkbQaKnF04f/78fNvnzZtnK1eu5F1EpP3222+5PvvF/6zWtQaIKh3/Og80eJho+fLltmDBgqS1CwiDv/76y+bOnZtvu84NnSMAgJ0QaKpWrZrL5vnkk09yBWAqVKgQf3zppZe6qWMKJCnj6emnn7auXbvGnz/zzDPtq6++cq/bd999XbBnzJgx7jntU0EiBaMSP/DHjh1b5P3rtRdddJHVrl3brr76aps4caJddtll8ef1XK9evezEE0+0s88+2wXKfvzxxyLtOy/tR9PoFGC64IIL7LrrrnOBMmBX0HnTuHHjXNt0LDds2NCys7P5R0CkdevWzX02J3r44YfddaF48eJJaxeQbMWKFbMmTZrYl19+Gd+2ZcsWO/nkk23YsGFJbRuQbJMmTbJDDjkk14DE7Nmz3TYCTQCwDd4Oev31171atWp5lStX9lq1auX16NHDW7p0qXtu2rRpXmZmprd48eL463/66SevRIkS3vr1672JEyd6pUqV8pYsWRJ//sEHH/TKli3rHm/atEnRK2/27Nnx58eMGeNVrFixSPsXvbZ///7x58eOHeuVK1fOPZ4yZYpXvHhx79dff40/v2XLFm/t2rVF2ndBpk+f7pUpU8Zr3Lixt3Hjxu1+XxGTnZ3tjgF96TG2TueR3qdZs2bFj+WmTZt6PXv2dD9/8skn7ivxeEfqHnthb1/YDBgwwDvooIPiP8+dO9crX768N3nyZPfzsmXL3PVh9erVSWxl6gj78Rf29oVNmzZtvHvvvTf+8zPPPOM1aNDA+/vvv93P8+fP9yZMmBD/Gal9/IW9fWGi90f9ge+//z6+rXXr1l7Xrl1zvW706NG5+gzY9nvK8Qekv0zbQarNpK9ff/3VJk+e7OoS9evXz2VS6EsjxR06dMg3XUFpqErV3m+//axq1arx544++ugi/+3C9n/AAQe4n/3vUqVKFZfdocwjTcPbZ599XLaTLyMjw8qUKVPkfed10EEHua+zzjrLSpQoUeT/F2BH6TyqX7++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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" } ], - "source": [ - "for case in test_cases:\n", - " result = compare_performance_ED(case[\"response\"], case[\"reference\"])\n", - " print(\n", - " f\"[{case['case_id']:35s}] \"\n", - " f\"is_correct={result.is_correct!s:5} \"\n", - " f\"missing={result.stats['total_notes_missing']} \"\n", - " f\"extra={result.stats['total_notes_extra']} \"\n", - " f\"wrong_pitch={result.stats['total_notes_wrong_pitch']} \"\n", - " f\"wrong_timing={result.stats['total_notes_wrong_timing']} \"\n", - " f\"wrong_duration={result.stats['total_notes_wrong_duration']} \"\n", - " f\"timing_scale={result.stats['timing_scale']:.2f} \"\n", - " f\"duration_scale={result.stats['duration_scale']:.2f}\"\n", - " )" - ] - }, - { - "cell_type": "markdown", - "id": "f3765faf", - "metadata": {}, - "source": [ - "### Visualisation" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0216997f", - "metadata": {}, - "outputs": [], "source": [ "# use ChatGPT to help with the code for the cost matrix and alignment arrows, wrote the code myself\n", - "\n", "import matplotlib.pyplot as plt\n", "\n", "def plot_cost_matrix(D, operations, response_notes, ref_notes):\n", " fig, ax = plt.subplots(figsize=(8, 6))\n", - " \n", - " # Slice off the padding row/column — they are only needed for the algorithm,\n", - " # not meaningful to display\n", " D_display = D[1:, 1:]\n", - " \n", " im = ax.imshow(D_display, origin=\"upper\", aspect=\"auto\", cmap=\"gray_r\")\n", " plt.colorbar(im, ax=ax, label=\"Accumulated cost\")\n", - "\n", - " # Annotate every cell with its value\n", " for i in range(D_display.shape[0]):\n", " for j in range(D_display.shape[1]):\n", " val = D_display[i, j]\n", " ax.text(j, i, f\"{val:.0f}\",\n", " ha=\"center\", va=\"center\", fontsize=8,\n", " color=\"white\" if val > D_display.max() * 0.5 else \"black\")\n", - " \n", - " # Reconstruct path coordinates from operations\n", - " # path coordinates — no +1 shift needed after slicing D\n", " path_rows, path_cols = [], []\n", " for op in operations:\n", " if op[\"type\"] in (\"match\", \"replacement\"):\n", @@ -1558,11 +164,8 @@ " elif op[\"type\"] == \"missing\":\n", " path_rows.append(path_rows[-1] if path_rows else 0)\n", " path_cols.append(op[\"reference_idx\"])\n", - " \n", " ax.plot(path_cols, path_rows, \"ro-\", markersize=10, linewidth=2,\n", " label=\"Optimal alignment path\")\n", - " \n", - " # axis labels\n", " ref_labels = [f\"$x_{j}$\" for j in range(len(ref_notes))]\n", " response_labels = [f\"$y_{i}$\" for i in range(len(response_notes))]\n", " ax.set_xticks(range(len(ref_labels)))\n", @@ -1571,42 +174,33 @@ " ax.set_yticklabels(response_labels, fontsize=8)\n", " ax.set_xlabel(\"Reference\")\n", " ax.set_ylabel(\"Response\")\n", - " ax.set_title(f\"Accumulated Cost Matrix D\")\n", + " ax.set_title(\"Accumulated Cost Matrix D\")\n", " ax.legend(loc=\"upper left\", fontsize=8)\n", - " \n", " plt.tight_layout()\n", - " # plt.savefig(\"edit_distance_matrix.png\", dpi=150, bbox_inches=\"tight\")\n", - " # print(\"Saved: edit_distance_matrix.png\")\n", " plt.show()\n", - " \n", - " \n", + "\n", + "\n", "def plot_alignment_arrows(operations, response_notes, ref_notes):\n", " fig, ax = plt.subplots(figsize=(12, 3))\n", - "\n", " N = len(response_notes)\n", " M = len(ref_notes)\n", - "\n", - " # Draw boxes for ref notes (top row, y=1) and response notes (bottom row, y=0)\n", " for j, note in enumerate(ref_notes):\n", " ax.add_patch(plt.Rectangle((j - 0.4, 0.75), 0.8, 0.5,\n", " fill=False, edgecolor=\"black\", linewidth=1.5))\n", " ax.text(j, 1.0, f\"$x_{{{j+1}}}$\\n{note['pitch']}\",\n", " ha=\"center\", va=\"center\", fontsize=9)\n", - "\n", " for i, note in enumerate(response_notes):\n", " ax.add_patch(plt.Rectangle((i - 0.4, -0.25), 0.8, 0.5,\n", " fill=False, edgecolor=\"black\", linewidth=1.5))\n", " ax.text(i, 0.0, f\"$y_{{{i+1}}}$\\n{note['pitch']}\",\n", " ha=\"center\", va=\"center\", fontsize=9)\n", - "\n", - " # Draw arrows between aligned pairs\n", " for op in operations:\n", " if op[\"type\"] in (\"match\", \"replacement\"):\n", " colour = \"green\" if op[\"type\"] == \"match\" else \"red\"\n", " ax.annotate(\"\",\n", - " xy =(op[\"response_idx\"], 0.25),\n", - " xytext = (op[\"reference_idx\"], 0.75),\n", - " arrowprops = dict(arrowstyle=\"<->\", color=colour,\n", + " xy=(op[\"response_idx\"], 0.25),\n", + " xytext=(op[\"reference_idx\"], 0.75),\n", + " arrowprops=dict(arrowstyle=\"<->\", color=colour,\n", " lw=1.5, mutation_scale=12),\n", " )\n", " elif op[\"type\"] == \"missing\":\n", @@ -1617,59 +211,73 @@ " i = op[\"response_idx\"]\n", " ax.text(i, -0.45, \"extra\", ha=\"center\", va=\"center\",\n", " fontsize=12, color=\"red\", fontweight=\"bold\")\n", - "\n", " ax.text(-0.8, 1.0, \"Sequence X\\n(ref)\", ha=\"right\", va=\"center\", fontsize=9)\n", " ax.text(-0.8, 0.0, \"Sequence Y\\n(response)\", ha=\"right\", va=\"center\", fontsize=9)\n", - "\n", " ax.set_xlim(-1.2, max(N, M) - 0.4)\n", " ax.set_ylim(-0.7, 1.7)\n", " ax.axis(\"off\")\n", " plt.tight_layout()\n", - " # plt.savefig(\"edit_distance_alignment.png\", dpi=150, bbox_inches=\"tight\")\n", - " # print(\"Saved: edit_distance_alignment.png\")\n", - " plt.show()" + " plt.show()\n", + "\n", + "result = compare_performance_ED(response, reference)\n", + "plot_cost_matrix(result.D, result.operations, response[\"notes\"], reference[\"notes\"])\n", + "plot_alignment_arrows(result.operations, response[\"notes\"], reference[\"notes\"])" ] }, { - "cell_type": "code", - "execution_count": 13, - "id": "3bd7b6f2", + "cell_type": "markdown", + "id": "9e5a7185", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAvEAAAJOCAYAAAA+pFhBAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAg5xJREFUeJzt3Qd0VFUXhuFv0obeRZAmYEFEFBELqKACYqGrKCgoVizYu/72imJFsYEoiEpHURSxoaIgYAdUmoDSe5u0+699wsQkUpJMkmnvs9bInGk5uROTfffss4/P8zxPAAAAAKJGQrgnAAAAAKBgCOIBAACAKEMQDwAAAEQZgngAAAAgyhDEAwAAAFGGIB4AAACIMgTxAAAAQJQhiAcAAACiDEE8AAAAEGUI4gFEtEaNGumss85SpGnbtq2OOuqocE8jbkTqzwEAhAtBPBCh7rvvPvl8PjVv3jzcU4la+++/v84999xwT0Oe5+ndd9/V6aefrurVq8vv96tOnTpq06aNBg8erM2bN0fE93/ssce6n7natWsrMzPzP/cvWrRICQkJ7jE33XRTicypuAS/1+ClfPnyatCggbp27ao333xTaWlp4Z4iAOwRQTwQgSyAGjp0qAumZs+e7S6ITqmpqerSpYv69Omj448/XtOnT3dBu/3buXNn3X777XryyScVKcqWLavly5drypQp/7nv9ddfV5kyZcIyr3nz5mn06NFF+pq1atVyJ1h2Wblypd5//30dd9xx6t+/vzt5tuMAAJGKIB6IQB9//LGWLFmiN954QzVq1NArr7wS7imhkG6++Wa99957mjBhgu644w41bNhQKSkp7gTt+uuvdydolp2OFDavFi1auIA9Jwt0hw0bFrMlLXZy0rhxY91yyy36+uuv3acO3bt3D/e0AGC3COKBCGRB+2GHHaaTTjpJl1xyid566y1t27Ztl48dNWqUTjjhBFWoUEFVqlRxJRuzZs3K92PS09NdOcFdd931n9e2cg8rO8jJTiouvPBCzZgxw2UtLfg54ogj9NVXX7n7LcPcsmVLd/uBBx7ogtecCvr1dsUeFyyDSExMdHPq2bOnFi9enP0Yu89OhN55553sx9o8c7ISF5urZZ/tYq87bdq0XI+xsgoLvi1ra485+eSTXVY4P1atWqUXX3xRZ555ptq3b7/Lx9SvX98dz5yfwlhm3gJKK7upWrWqC5zzfs0PPvjAvaf2flauXNldHz9+fIG+/92x+dhrbdy4Mfu2Tz/91L1ezrkW9XsS/NmaOXOmTjzxRJUuXVq33XbbLmviJ0+e7L6OvTc52c+bve4DDzygwrJjf9lll+m7777TZ599VujXAYDiRBAPRBgL/Cxze+WVV7rx5Zdfrq1bt7qAc1d18+edd54LoH766SeXPbRSgKeffrpAjymoZcuWacCAAe6Tgr/++ssFPRaoWgD/6KOPulKgpUuXuiC/R48e+vvvv1WUPv/88+wyCDu5sTIICw5tDjt27HCPsfvq1avnvn7wsT/88EP2azz00EMuyOzUqZP+/PNPLVy4UMccc4xOOeWUXIG8nUQ988wzevzxx933Yf9ee+212rRp017naYGvnQScdtpp+f7errjiCheY2tdYsWKFvvzyS/evndz88ccf7jE//vijK8Vp3bq15s+f7461zctqudeuXZuv739P7OfFHv/2229n32bvqS3kbdKkSbG9J8GfLfuZfeGFF7RgwQL3nuxKhw4ddOedd7qfN/taxh5vZUt2vHd1klgQwZOuL774IqTXAYBi4wGIKI899phXvnx5b/Pmzdm3denSxWvZsmWuxy1YsMBLTEz0rrzyyt2+Vn4ek5aW5tmvgjvvvPM/97Vu3do75phjct227777uvmtW7cu+7bly5e717D71qxZk337ihUrPJ/P576nwn69gw8+2Ovevbu3Nz/88IN73U8++ST7tnr16nk9evT4z2MXL17sJSUleddee+1/7jvhhBO8Vq1aueu//PKLe80HHngg12N+/vln9301b958j3N69NFH3fPff/99Lz9+/fVX9/hbbrkl1+0rV670ypQpk/29PP/88+5xa9eu3ePr7e773x079na8zTnnnOMde+yx7vrGjRu90qVLe4MGDfJWr17tvvaNN95YpO+JsZ8fv9/vvt+8dvVzkJGR4bVr186rVKmSO3aHH364V7du3Vw/g3v6XmvVqrXb+4Pv/aWXXrrX1wKAcCATD0SY1157Tb1791a5cuWyb7Os/DfffKPffvst+7aPPvpIGRkZLpu8O/l5TGFYVthKOIL2228/V6pjGXkr/wjad9993eMsy12ULPts2WL7uklJSbnKMiyrvjdWimFlPWefffZ/7rNM/LfffusWpFom3Vi2PifLRltte1ELfr1u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hrR9e1YpTd7FhcHb7H57XIwH8o8//rhmzpxZYs+LBwMGDNBPP/0U7mkAABAxmfjiusQDgvhgCU337llB+t4WE9r99jh7/M7a5cJYvny57rnnHvXq1UvXX3+9Zs2ale/nPvroo7keXxyB8zfffOPmGMrzoiWgHzhwoObMmVPsr2vHZuXKlUX+dQAAQPwhiDdWA79t294D+CB7nD3+jTcKddB/+eUXNW3aVEuWLFHbtm3dlsCtW7fWyJEj8711/T///JM9PuGEE1SrVi1FmkidV17Tp0932zNHy+sCABDtyMSHLuYXtu6VZdVtEWthPPts1mLXAn5sc+2116pv376uvCKoefPmuvTSS9W5c2fXSeehhx5Sy5Yt9emnn2rVqlXq06ePG0+ePNll4S0b//rrr+umm27StGnT3Nbn++23n3teixYtXKBvXXmuuuoqValSxWWF09PTdfPNN6tBgwbuaw4aNEifffaZUlJS1LhxY1199dVuUeXe5Pd5OedlC1SffvppzZ8/X6effrrmzp2rTp066bDDDnNzPv744933aln8888/X23atHGvYcfoyCOPdAHx33//7Y5R5cqV9fzzz7vOQ/3791f9+vWzv+bo0aP1xRdfyO/364ILLtDhhx+e/TrHHXecu89OgHr06OFOMqZMmaIffvhBTz31lN566y1dc801Ovroo3N9H/bcY445xj13zZo16tmzpxubl19+2X2fycnJatSokS677DJ3LHb1usYWrNr3m3MOAAAABRV/mfijjspanBq87LdfVheagm6wY4+359nzc76evf4eBAIBFwxaEJ/TWWed5fqCB8tPLDC8+OKLXVedhg0b6owzztBvv/2mAw880GW3Lfg799xzVadOnVwlLPY8C6pr1Kih2rVrq0OHDi7wtWA2MTFRZ599dvbXtEDUXqNjx44uQLbgOT/y+7yc8+rXr58++OADF6yPHTvWldoES0tszpdffrmqVavmTgq6dOniTlyCr2EnKvvuu687GejWrZsL3C34t42g7OQm6P7779d7772nVq1auRMVC7b/+OOP7Nexk6eqVau6YNvuW716tTu29rp2gtS9e3d3zHb1fdgxtfnZCYMdw3nz5rn7jjrqKPe80047zQXmdqzN7l7XAvi8cwAAIN6QiQ9d/GXirbyhELXee3y9AvY9twyyBYR5WXC3cePG7LEF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lK6/XrrzTmnMGOnUU8M9WxTCxo0btXDhQqWlpal+/frax07QEBUyMjK0aNEidz0xMdG9fzllZmZq5cqVLuO07777un8R2WrWrKk6deq463///beWLVuW6/5y5cq593nHjh36888/5dknpIh4Bx54oCpXrqyffvrJvXd5VatWTc2bN9e0adO0beffWxQvMvGhI4iP9AD+jDOyymg8T29KulrSIZLmSxrheTrdHrd9e9bjJk0ikI8yEydO1Msvv6z9999fZcqU0TnnnEMQH0VSU1P17bffuhOw1atX65Zbbsm+z4L3sWPHuuDegoaUlBT17t3bvc+IXIcccog6dOjgAvnPPvvM/f8Z1LJlS91xxx3upLtSpUpKT09X//79tcVKHBGRqlSpohEjRqhBgwbatGmTateurTPPPFOzZs3K9bghQ4aoffv2OvzwwzV/vv2FBSIfQXykshIZy8BbAJ+ZqTRJFh6Mk3SypLGSbpaygvjMTCkhIevxljWqVCncs0c+WJZv8ODBevbZZ3XAAQdwzKJQ6dKldf7552vNmjUuCMjJsnlnnXWWOymzbO2bb77pAocTTjghbPPF3n366afu0q9fv//cd8YZZ+i1117TuHH2m1h66aWX1KxZM5e9RWSy7PvAgQM1ZcoUN37ooYd09913q0uXLtmPueyyyzRjxgwdd9xxYZxp/CETH8U18VdffbVGjx6dPZ4zZ446d+7MR5NBw4ZlldBYgC7pF/uB3xnAG/v189fO8hrHHmePf+ONknwbEQLL8h177LGqWLGifv75Z7J5MSZnaZT9sSpbtqySk5PDPS2E4IsvvlDr1q3diVinTp3cpyu//vorxzSCLViwIDuANytWrNB2+/R6p3r16qlnz5565JFHwjRDIAqDePto6/PPP3fXLUtlH0neeeed7o+d1Zn+8ccf7qPquGTZ9+eey3XTSqvVzPPGWXjwT97nPvts1vMR8f755x+tW7dON9xwg8vo2R+S2bNnh3taKAaLFy/W8uXL3Uf1iF7z5s1zn7706NHDXX744Qe3pgXRwUpprr/+eg0YMCD7Nvs09JprrnFxB8KTiS+uSzwIWznN8ccfr7feestdHzp0qI488kgdffTRrsbQ7lu7dq2rJZ0+fbr7OCyvQCDgLkFW6xYzrI3kggW5bipnH8/neZiNy+e8wYJ3e966dVntJxHRLBjY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9fvAnHjiiXo3jGvdCOKjyFNPPaWTTz5Zl1xySbingsLYvl0Jf/4pC9MtiLdfD+9L+kTSTXke6svMlJeQ4Epvtv/+u7RzAylENgsAcgb0nuepZs2aYZ0TCubHH390lzPOOOM/91mwbr+H7QQtMTFRd955p0uo/P333xzmCPbggw+6f6dOnfqfDH3jxo111llnuex827ZtddVVV7kL4isTf9XO9/zCCy9Uv379XNVDXnbyfvrpp7sTvyBLqsZlTfyVV16pUaNGZY9nzZqljh07uj96+C/7WPfnn392v2zmzJnjsvKILkkjRrhAPr+/WiyQ17ZtSnrrrWKeGYrDxx9/rPbBBcyICfPnz1e5cuVc4NemTRtt2LDhP+U2iB5W5mafpgTLa+zvqn0KYydoiC+vvfaa+1TNTuz2pEyZMu7EPXipXLmy4jKIP/DAA/X555+76xa49+/fX/fYAsC0NH377bfusqszoXi1YsUKVwN/11136c0339S1117rMkKIEp5X6D7xSS++yI6vUcYys4sWLVKrVq3CPRUUsVq1aum4447TMccc44J6+5uF6GQJMauHv+yyy7Kz8MZKbxBfO7YmJOQvHB4zZoz2339/tWjRQrfffnvY11CELYg//vjjXaBuXn31VR199NHuDGjjxo267rrrdNppp7ksx+7YmbPVnua8xLLSpUu7j3LsY56BAwe6M8ZPPvmEjHy0WLtWCQsXZte855c93p6ndeuKbWooeh999JFat27tFkEittgnopa1e/zxx9W0aVM1atQo3FNCIa1bt84F8JUqVXL/v44cOdIFf7EeT8STTXnixLyLmgvCgvcBAwa42Ov+++/XhAkT1K5dO2VkZOTr+XbSb1UVBb0vIoP4Zs2aacGCBVq+fLkGDx6cXbO2zz77uOB+bxmsRx55xH0UFrzUqVNHsczqaoNdEYz90ilfvnzYzwKRP9YHPhQ+3ueoYb/Qrfb21FNPDfdUUMRy1r/aJ8WWaLKP1xG97BMzOyG7++67te++++qPP/4IKdBDZGXi69SpkytWtNixsKz64fzzz3cLXi3RPHr0aBevWulkfli1iZ0AFPS+iFzYmpSU5GrPunTpogceeEBlbaObArCPMXIuIrMzrFgO5O0Pha2wtx9A+9c+BrRjmHf1NCKT28gpDzt3t3XuP9rP785FrvUl7appqFeuXInME6Gb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UvKttyr5vvsUGDFCme3ahXv6yKcNGzZo7ty5uW7z+/068sgjOYZRxH6npqSkuOu2K2ZaWlqu+6tWrarKlSu72uQtW7aEaZbxJ5J2bC1OF198sSudueKKK/TKK6+4Wngzbty4kF+bID7C/4D88MMPbgVzxYoVde6552bf9/PPP7t+o/aLZ+3atapVq5bOPvtsJSTw4Uq0WLhwob788svs8bx589ShQweC+CjhHXCAdowfnxXIb98uX0aGvpU0WdL3km6zrbztj4mV19jjt2+Xv3t3BcaMIZCPEva79ZNPPske20m3nWgTxEcX68Ftya5DDjlETzzxhHtfg3r06OESZPZ31mqTJ02apBkzZoR1vogt9erVcyeHloE/7LDDXLxmZTRFgSA+gtkvFAvcZ8+e7bIHOSUlJalfv34uK2QZ+eeee85ldu2HBdGhdevW7mLsPezdu7fatm0b7mmhALz993c17xamW97nip2Xf0+3/2U95r2EBPl79dJ2+/+5UiWOdYSzjNndd9+dPb7lllvUvn37sM4JBRfswX3ffff9575ff/1V77zzTnawbxvvEMSXjHjJxD/88MO69NJLNWTIEN1+++3q37+/LrjgAldJEaqwpW0vv/zy7P9xjO1cdcYZZ8jbmbXCnllGwQJ4Yx8T2qrm4MeFiD5fffWVCxisZyyiR9KIEXYG5gL4/LBAXtu2Kemtt4p5ZihqVtr4119/qWXLlhzcGPLLL7/ogAMOcLtl2uY7c+bMCfeUEIPq16+vBx54QIsXL9bw4cNd0tUy84888ohrL2kbQEVVEG9B6BdffOGuZ2Zm6tprr3XfoDXAt4DGmuFbdhJ7ZyUZFvwRAEavjz76iAxftPE8JQ0eXKinJr34Il1rovD/0TZt2ig5OTncU0ERsxIHC+CtPDW4Dg3FLx6701jC9fTTT9fYsWNded7555+voUOHqlGjRoXatTVsQXyrVq303XffuetW6G/ZDasztLPg2267zX30cOihh7q68F2xAH/Tpk25LvHo66+/1pIlS9S9e/dwTwWFZH807D0kwxdl1q5VwsKF2TXv+WWPt+dp3bpimxqKvqezrUGilCY22QLDl19+WePHj1fPnj3DPR3EierVq+vmm2926+GszaQF8lFTE9+sWTO3sM8+UrAgPrjAz35JBn9R2ope++as/U5e9hHErurb4ont7mUf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R3Szukx7P4Os9hbRyz6utzZ2QdZ20hbAInpZ/bRdrITRTtBs3QNig5W8+f1+t+4BiBYE8QAAAECUoSYeAAAAiDIE8QAAAECUIYgHAAAAogxBPAAAABBlCOIBAACAKEN/LADYhblz57pLpUqV3J4MAABEEoJ4ADFh/vz5mjNnjrtuO23WrFnT9dovX758gV/rqaee0oMPPqiTTjpJTZs2JYgHAEQc+sQDiAlPPPGE7rzzTnXt2tXtaDxv3jwtW7ZMb7zxhs4888wCvdYhhxyi/v37q1+/fsU2XwAAQkEmHkDMsKz722+/nT2+4IILdPHFF2vlypW5Hrd+/Xp9++23bsfNZs2aqVq1au72rVu36r333tPy5cvdSYC9VosWLdSwYcM9Pi+44+7kyZPVrVs3t1PrggULdNxxx2m//fZz9//555/65ZdftO++++rII490u0MG2e02xxNOOEE//vijVq9erebNm7vH5rVw4UL9/PPPbqdmex2fz5fr/j19HQBA7CCIBxCzTjvtNA0fPlwrVqxQjRo13G3Dhg3Ttdde64LkxMREzZgxQ88884z69Omjbdu2afz48QoEApo5c6YLrC1QtyB+T88zixcv1nnnnaczzjhDS5YsUePGjdWgQQNVr15dl1xyiT744AMdc8wx7tMBO1mYMGGCy/ib0aNH680331SFChVUtWpVNw8L1CdNmqQTTzzRPSYtLc29zpgxY9zJgZ00VKlSRe+//76Sk5OVnp6+168DAIghHgDEgAEDBnhVq1bNddv999/v+f1+b9u2bW78008/eWXLlvVmzpyZ/ZjPPvvMK126tLd06dLs2+x1Ro4cmT3Oz/PmzJnj2a/USy65xMvMzMx+3MMPP+wdccQR3qZNm7Jvu+6667xWrVplj++55x7P5/N5n376afZtF154oXfyySdnj++77z6vWrVq3h9//JF928cff+xt3bo1318HABA7yMQDiBmpqamuBCZYEz9w4EDdddddKl26tLvfst1169Z1WfNFixZZEsPdnpKSounTp+vss8/e5esW5HnXXHNNrhKXoUOHqmXLlvroo4/c8+xi2XZ73o4dO1SqVCn3uEaNGrmFtEFt2rRxNf5B9knAFVdcoQMOOCD7tnbt2hX46wAAYgNBPICYYWUwVg5jpSWzZs1yAa8FvkEWhFuJiZWv5NShQwdVrFhxt69bkOdZV5y8z7WSnLzPtcDfymaCwbWVxuRktewWfAf99ddfOuigg/Y4x/x8HQBAbCCIBxCTC1stoD/llFPUo0cPTZ061d1mNee1atXKtfg1PwryvLwLTe25Xbp00S233KJQWL/6tWvX7nGORfF1AADRgR1bAcQky2S/9NJL+uKLL7Kz05Y5/+6777L7yQdZ15nt27fv9rUK+7zgc4cMGeJKfXKyDjgF0b59e40YMcKVCgXZ4lZb8FqUXwcAEB3IxAOIWYceeqjrHnPHHXe4LHX37t1deYntwGp94K3O/ddff9XEiRP1zTffZNfO51XY55nHHnvMtY60jjE2F2tP+dVXX7ng3zrH5NcjjzyiVq1aqXXr1q4LjgXw9smAtby07jRF9XUAANGBTDyAmGALQ61He17333+/awtpWXQrdRk5cqRbJGqlKbbos169evr+++9dK8ggex27PSg/z6tcubIr3cnbl93KcKz3u7V//Omnn/THH3+4k4Jx48ZlP6ZJkyZq27ZtrufZ6+f8fuzEwV6nU6dOrr2llQt9+OGHKlOmTL6/DgAgdrBjKwAAABBlyMQDAAAAUYYgHgAAAIgyBPEAAABAlCGIBwAAAKIMQTwAAAAQZQjiAQAAgChDEA8AAABEGYJ4AAAAIMoQxAMAAABRhiAeAAAAiDIE8QAAAECUIYgHAAAAFF3+D4NdNY7FAW5xAAAAAElFTkSuQmCC", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], "source": [ - "plot_cost_matrix(D, operations, response[\"notes\"], reference[\"notes\"])" + "### Summary" ] }, { - "cell_type": "code", - "execution_count": 14, - "id": "7b54ee04", + "cell_type": "markdown", + "id": "9ea553da", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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qOC9+uqr7ud169Z5LVq08MqWLet+3rRpk+78vdmzZ8d/f8yYMV7FihWLtH/Ra8855xy3L+nbt69Xu3Zt93jlypVetWrVvF69esVfP3bsWG/WrFlF2vfWnHXWWa7djz322D98R1GQ7Oxs937qS4+xbTru6tat6475qVOnenvssYf3zjvv8Lal4bEX9vaF0XHHHef95z//iV8Patas6S1YsCDZzUpJYT/+wt6+sJk/f75XunRpb9y4ce7+rk2bNl6HDh28zZs3J7tpKSnsx1/Y2xc2zz//vLfPPvu4vor6CeojDBkyJNnNSllhPv7C3Lawat26tde5c2f3eODAgV6NGjW8OXPmJLtZKSk7zY6/7c5oKlmypMv66dKli33//fd27bXXWpUqVaxXr17ueT2naXTKCtIUOH3Vr1/fTUP7448/3POqbXT++ee715cuXdquv/76Iv/9wvbvU4ZRZmame3zcccfZ/PnzXYRVv79+/Xq77bbb4q9Vdpb2V9R9F0T/H1KhQoV/+I4CO+66665zx/fdd99tbdq0sUcffdTat2/vnjvmmGPcl6Z5AlH0+OOPW79+/dzU5p49e9rnn3/uMl01RVujb61bt7ZHHnkk2c0Edrl9993XZW3onqhjx46utqSynIoVK2ZLliyxK664wmWcJ2Z1AFFx2WWXWcWKFePXCWXC6jxJzADU7Iivvvoqqe0EkuGxxx5zNZofeughu+uuu+yzzz6z/fbbz8aNGxfve+hLmeWIllgEZjuVKlXKTcvRl+jAOumkk9wUNNVD0vS37t275/qd5s2bu7pMqp1UqVKlXM/l/TmvxOkNhe2/WrVq7mcFs3wKOKkTri9NkatcubK7icqrqPvOS/WqvvjiC/ddAThN/dOUOmBXUQBYRek1hU6daqV2+5544gl7++23SfVGZB1xxBHu+tSjRw83rbROnTpuuz7rdT3QuXP77be7hS3+/e9/J7u5wC51yy23uA7DkUceaV9++WV8kE5T6jRQpwE0DSrqPKpRowb/OogM1WJVEPaiiy6y+++/35XjSKT7LdWDTZyaDUTFIYccYieffLJ169bNJkyYYAceeKDbvmLFCvf4qquucj9z3Yie7Q40rVmzxgWa/BsR0Q28fv7f//7nDjoFht59913bbbfd4q9RplD58uVdoGfu3LnuQ1mZUKIb/3jDMjNdkMivmSQz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0n1F0r/++st1qP3s7unTp+d6ja4nnTp1ctcM7mkQFZs3b3alMDRw98ADD7htun+aNWtWrtfp+WbNmiWplUByKLh02223uXusI444wm1TkEmZTaLMWH35HnnkEVccXIN5iIZIB5o0jcg/MXz333+/CxJp+oSeb9u2rbu5eumll9y0CnXoNfJ92WWXubTySy65xMaOHWtTp051aYO33nqrnXbaaW5fRx11lI0ZM4abMux0OjaVxXTBBRfYU0895TrM6ij7AVA9X61aNRdkkmLFitm5557rMjmAdDdo0CA79dRT3ee3Mpu0DPWZZ54ZX4ZaU+US+VPq1MkA0n3KnO5v+vTp46Y5KDNDwdi8HQENVGi7pg0BUaH7e2W56p7/2WefdeeLpv1olexE77//vuts77///q7fULJkyaS1GdhVdO3Q+aA+h/oTe+65pwssVapUKd9rde7079/frrjiCtcHQTRE+l967ty5+Uarf/rpJ7vxxhtt/vz5LkCkrA+NVKjzrovHeeedZ08//bQb6ZZrrrnGdebVqe/evbs1atQovi/tW38D2BW1Z1QPQAXpdVP0zTffuDphb731lnv+t99+s7333jvX7+hnTaMDonB+6LzQDY6m/Kh2njoKmiaUl0arVV9PHWqWq0a601Sg5cuXu3qVr7/+uhuNVjkB1TDzabqpgrV6DRDFeystGqT+gc6FI4880gYPHhx/jfoDeo0G+5Qd2KBBA/cYiML5ocEJJWQsWbLEBZ4OPPBA14cuaBaRtitoi+iIdEaTivepKGxexx13nD3++OPx9PFXXnnFdciVESLqoGhanLKfdMGpUqWK7bfffi4olUgp6Duymh1QVMq8UGdBtZf8kWgFQJVh53cY8hZ21egCEJXzQx1qBf7VIdCxr3pNGoFTfQGftl999dVuyulXX32V1DYDu4KftadspSFDhrjHKvRdp04dl82hzGx1IjTAVrFiRbfgBBCl80OrU2vwQbVY/XNFZQp0Xog/8CzKmG3atKmrQ6MasEC6nx+LFi1y5Tl0ffD70L169cpXlkM/K2Dr96URDZEONKkqfkE3TcoE8f3444+ujofmaCfatGmT67RodZatUcd/9913D7jVQH5+5oU6BT4FQe+++253rGplxbzZS6pRkzfLCUjX80M1A/xVgBR01bTpX375JdcNk6ZCaxq0gkx8diMK9thjDzfglnjtqF27thtA0/mhDoRGqlXfTF8rV650r3nmmWfc4hKaNgFE7d5KtVl1LuSdIqQpRDonlFUOROH8UGFvP8jknx/qOydSMEpTs1ViBtES6UDT4Ycf7lboyssvCCs6ebKysty0uLwK66RPmzbN/Q1gZ9ONjWoCfPvtt/GMJq2gpUw7dbA1DUiZGqNGjYoXA9exr3pjQLrTca7svuzs7HhGkzrNGnkT1W7StGhNpVOQqaD6AkA68uv56drh0yCapphqpS0FobTSHBBFWihCfQCdHxqw8++tFKDVdULZr6rp518zNGCha0hiGQ0gne+thg0b5haT8INNOj/8RVd8KlegAQwGJqIn0oEm1bO5/fbbXTRWq88V5Nhjj3U3WhrV85f71XQ4FTTLO1UukTrySjtniWzsCrrpUbFWFfju2LGjS/XWioeaSida5UGr0Sn1Wx1qTQnVDZJqcgDpTueElqZu0qSJ+9zXaLM60vr8FwVhNdp2ww035JpKd9FFF7lgLZDONIVUHWpNa1Ax4zfffNMd+zpfJLHTrHuhJ5980k2v21ZGN5AOtGCEapMp21Wr9m7YsMHeeOMNGzhwYHzmguq3KuNJAacRI0a4shyaagekO612rXplulZoIazvv//e1WHSOZLYH1agqUuXLvlKeCD9RboYuG6otNrQe++9t9XXKEKrDrvmm6rDoekWNWvWdBeSbRk5cqT7Xd28AbuCagZodTmlsuq4VuqqboB8PXv2dMeyitQr2KQpQizdjqhkbejcUN0MdQaUoaGMUwVoRZ/Td911V3xqHRAlqjs5Y8YMF4TVqrvqOKioa0GUuaFOtEoPAFGggQitIK2MDAVd1ZlWB1vUJ9CgcsuWLd1qprrP0rmk1beAdKfV4z766CM30K3p1grIqmi+zgWfCuNru74QPRlexCsC+1lHunAo0qoTREW8dUFJpIisRvI0J1sV9fUanzrsCiolrlCkpbRVKDBvbSf8M8o28zt/mvaytcwzIGrHXtjbh/QW9uMv7O1Degv78Rf29iG9hfn4C3PbkP7WpNnxF+mpc9KsWTO3MoTSYUuXLu1G9rYWtd3aFIpDDjkk18+K3WkVCr/+BwAAAAAAQBREPtAkfh2CoCgziilzAAAAAAAgaiJdowkAAAAAAADBIdAEAAAAAACAQBBoAgAAAAAAQCAINAEAAAAAACAQBJoAAAAAAAAQCAJNAAAAAAAACASBJgAAAAAAAASCQBMAAAAAAAACQaAJAAAAAAAAgSDQBAAAAAAAgEAQaAIAAAAAAEAgCDQBAAAAAAAgEJnB7AbY+dasWcPbjF0mlY63VGor0kMqHXOp1Fakh1Q65lKprUgPqXLMpUo7kT7WpNkxR6AJKaNatWrJbgIQSpwbAOcHwPUD4N4KCAumzgEAAAAAACAQGZ7necHsCgieDs+1a9fy1iKpsrKyLCMjI1T/CpwbCAvOD4DzA0iH6wf3VgiLrJCdG9uDQBMAAAAAAAACwdQ5IM1t3rI52U0Awmsz5wdQkC3eFje6D6CASwf3VgCwTQSagDT27LfPWmaPTGs3tF2ymwKET9OmZpmZZsOGJbslQKis3rDayj5Y1ir0rmAb/t6Q7OYAoTLoh0Hu3qrNK22S3RQACC0CTUCa0kh0r7G93OMPZ31oS9YsSXaTgPCYNcvs669jj7t3T3ZrgFDpM6GPrf97vWVvzLZBkwcluzlAqO6tHhjzgHv82a+f2cJVC5PdJAAIJQJNQJr64OcPbOHq2A3QZm+zPTr+0WQ3CQiPnj1zHk+ebDZhQjJbA4TGmo1r7Olvn47/3Htsb9u4eWNS2wSEhYJLc1bMiU8v1fkBAMiPQBOQpiNu3UflztJ4ZuIzZDUBfjbTq6/mfi/uu4/3BjCzft/1s6Vrl8bfi/l/zbfBkwfz3iDy3L3VyNz3Vv2/709WEwAUgEATkKbZTJMXT7ZSxUu5nyuXrmxrN60lqwnws5m2bDGrUiX2fhQrZvbpp2Q1IfKUzfTwuIfzvQ+aKkRWE6JO2Uxf/+9rK1GshPu5Spkq7rwgqwkA8iPQBKRxNlPzms3d9wZ7NHDfyWpC5CVmM9WqFfvePHaekNWEqFM205K1S6x2pdrxbXuW25OsJkReYjZT81qxa0b9qvXdd7KaACA/Ak1AmmYzlStZzlrWaum2VS9X3Y7Y6wiymgA/m6ltW7Py5WPvR7t2ZsWLk9WESEvMZrqpyU3x7bc2vdV9J6sJUeZnM5XOLG1t9m8Tz2g6vubxZDUBQAEINAFp5pWpr7jvXY7qYmVLlnWPMzIyrHvz2EjckB+HJLV9QNIowPTaa7HH3brlbK9WzaxTp9jjV2LnDxA1n//6uctm2r/y/nbOwefEt1/R6Ip4VtO4BeOS2kYgWV75MXZt6HxEZ6tYumK+eyv/3gsAEJP5/98BpAndBO1dfm/r2qyrvTT5pfj2U+uear1O6OVG4IBIysgwe+ghs6wss8aNcz+n7aVLm118cbJaBySVpgNd3fhqu+TwSyyzWM7tYZkSZWzov4ba2z+9bUfVOCqpbQSS5crGV1ql0pWsW/Nu9tZPb8W3t6jVwh476TGX6QQAyEGgCUgzrWq3cl95aeRNwScg0oGmm28u+LmqVc2efXZXtwgIDXWi+53ezz1evm55viCUX5cGiKJm+zZzXwXdWyVONQUAxDB1DgAAAAAAAIEg0AQAAAAAAIBAEGgCAAAAAABAIAg0AQAAAAAAIBAEmgAAAAAAABAIAk0AAAAAAAAIBIEmAAAAAAAABIJAEwAAAAAAAAJBoAkAAAAAAACBINAEAAAAAACAQBBoAgAAAAAAQCAINAEAAAAAACAQBJoAAAAAAAAQCAJNAAAAAAAACASBJgAAAAAAABBoAgAAAAAAQHiQ0QQAAAAAAIBAEGgCAAAAAABAIAg0AQAAAAAAIBAEmgAAAAAAABAIAk0AAAAAAAAIBIEmAAAAAAAABIJAEwAAAAAAAAJBoAkAAAAAAACBINAEAAAAAACAQBBoAgAAAAAAQCAINAEAAAAAACAQBJoAAAAAAAAQCAJNAAAAAAAACASBJiDNfDX3K/vPp/+x1RtW59rueZ71HtvbBnw/IGltA5LK88wee8ysX7/8zy1ZYnbttWaTJiWjZUDSrVi3wjoP72zfLvw233Oj5o2y6z++3tZsXJOUtgHJNnbBWOvySRdbuX5lvnurx79+3PpO7Ju0tgFAGGUmuwEAgtX3u7729k9vW1aJLNuz3J7x7R/P/tju+OIOq16uul3e6HLedkQz0HT77WabN5sddVTu57p2NXvxRbOMDLPGjZPVQiBpRs8fbc9Nes4++/UzG3/Z+Pj2dZvW2bnvnGuLsxdb+wPbW8vaLflXQuT0n9Tfhvw4xDKLZdpBux8U3z5y3ki7+b83W6XSleyaI69JahsBIEzIaALSTMdDOrrvT337VHz0WSNu3Ud1d48vPPTCpLYPSJpixczOPz/2+L77crb/8YfZ4MGxxx1j5w8QNSfud6LtnrW7zVkxx96c/mZ8+wvfv+CCTDUr1rRj9z02qW0EkqXjobFrQ7/v+tlf6//Kd2/l33sBAGIINAFp5oz6Z1jDPRta9sZs+2reV27bouxF9t3v37ksp1ua3pLsJgLJc/fdsYDThx+arf7/6aXvvRfLcmrTxuyYY/jXQSSVLVnWbjv2NvdYU4F8j4x/xH2/67i7rGTxkklrH5BMrfdrbU32bmLr/15vn8751G1btm6ZywTUedG1WVf+gQAgAYEmIM1kZGRY9+axEbZR80e579P+nOa+X3fkdbZ72d2T2j4gqerVM7vggtjjefNi30fFzhPr1i157QJCoPMRnV1W09yVc+Pb/GymTg07JbVtQNLvrVr8/73VvNg14+elP7vvVza60mpUqJHU9gFA2BBoAtI4q2nD5g3u5xXrV5DNBOTNalq2LPbzli1kMwF5spoSkc0E5GQ1bdqyKZ7RRDYTABSMQBOQ5llNPrKZgAKymnxkMwHxrKaqWVXj7wbZTED+rCYf2UwAUDACTUAaZzXVKB9L5S6eUZzaTEDerCZfw4bUZgISspquP+r6+Puh2jPUZgJyspr2r7y/e1wsoxi1mQBgKwg0AWk88nZHszvc47b12lKbCcib1dSkSexx99wj1EDU/eeY/1jpzNJWrmQ5u6ThJcluDhCqe6u7j787HnSiNhMAFCzD09qcANLW5i2brXix4sluBhBOWm2uOOcHkNcWb4tl6L+MDN4cIO+lg3srANgmAk0AAAAAAAAIRGYwuwF2DiXcrV27lrcXSZWVlRW6UX3ODYQF5wfA+QGky/UDQDAINCHUFGQqV65cspuBiMvOzrayZctamHBuICw4PwDODyBdrh8AgkExcAAAAAAAAASCjCakjD/++INRD+wya9assWrVqqXEO865gV2N8wPg/ADS/foBYPsRaELKUGot6bUA5wbAtQPg3goAEF5MnQMAAAAAAEAgCDQBAAAAAAAgEASaAAAAAAAAEAgCTQAAAAAAAAgEgSYAAAAAAAAEgkATAAAAAAAAAkGgCQAAAAAAAIEg0AQAAAAAAIBAEGgCAAAAAABAIAg0AQAAAAAAIBAEmgAAAAAAABCOQNOKFSvsgQcesPbt29s555xjzz77rK1fv96i5O+//7bzzz/fhgwZku+5zp0721NPPZWUdgEAAAAAAKRMoGnjxo3WvHlz++yzz+zcc8+18847z2bNmmVt27a1KMnMzLSLL77YrrnmGvf/7+vbt68NHz7cLrzwwqS2DwAAAAAAIPSBpqlTp7qvN99802UztWvXzp588kl755134q/xPM8GDRrkMn7+/e9/24ABA9w237p16+zee+91wambbrrJRo4caaeccop7bvPmzdaiRQtbuHBhrr95+umnF3n/eu2oUaPstttuc4/1N5YtW5br91966SXr2LGj+38YMWJEkfed6KSTTrIrrrjCvXbTpk02c+ZMu/32212WU+XKlXfkbQaKnF04f/78fNvnzZtnK1eu5F1EpP3222+5PvvF/6zWtQaIKh3/Og80eJho+fLltmDBgqS1CwiDv/76y+bOnZtvu84NnSMAgJ0QaKpWrZrL5vnkk09yBWAqVKgQf3zppZe6qWMKJCnj6emnn7auXbvGnz/zzDPtq6++cq/bd999XbBnzJgx7jntU0EiBaMSP/DHjh1b5P3rtRdddJHVrl3brr76aps4caJddtll8ef1XK9evezEE0+0s88+2wXKfvzxxyLtOy/tR9PoFGC64IIL7LrrrnOBMmBX0HnTuHHjXNt0LDds2NCys7P5R0CkdevWzX02J3r44YfddaF48eJJaxeQbMWKFbMmTZrYl19+Gd+2ZcsWO/nkk23YsGFJbRuQbJMmTbJDDjkk14DE7Nmz3TYCTQCwDd4Oev31171atWp5lStX9lq1auX16NHDW7p0qXtu2rRpXmZmprd48eL463/66SevRIkS3vr1672JEyd6pUqV8pYsWRJ//sEHH/TKli3rHm/atEnRK2/27Nnx58eMGeNVrFixSPsXvbZ///7x58eOHeuVK1fOPZ4yZYpXvHhx79dff40/v2XLFm/t2rVF2ndBpk+f7pUpU8Zr3Lixt3Hjxu1+XxGTnZ3tjgF96TG2TueR3qdZs2bFj+WmTZt6PXv2dD9/8skn7ivxeEfqHnthb1/YDBgwwDvooIPiP8+dO9crX768N3nyZPfzsmXL3PVh9erVSWxl6gj78Rf29oVNmzZtvHvvvTf+8zPPPOM1aNDA+/vvv93P8+fP9yZMmBD/Gal9/IW9fWGi90f9ge+//z6+rXXr1l7Xrl1zvW706NG5+gzY9nvK8Qekv0zbQarNpK9ff/3VJk+e7OoS9evXz2VS6EsjxR06dMg3XUFpqErV3m+//axq1arx544++ugi/+3C9n/AAQe4n/3vUqVKFZfdocwjTcPbZ599XLaTLyMjw8qUKVPkfed10EEHua+zzjrLSpQoUeT/F2BH6TyqX7++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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], "source": [ - "plot_alignment_arrows(operations, response[\"notes\"], reference[\"notes\"])" + "Step 0: Normalise the start times\n", + "- Adjust the start time of the first note to t=0 for both the reference and the response MIDI. This action will help eliminate the time gap between student start recording their practice and start playing the first note (if the gap exists).\n", + "\n", + "Step 1: Align notes using edit distance\n", + "- The purpose of note alignment here is to identify if there is any missing/extra notes. Unlike standard DTW where off-diagonal moves has no cost and every note must be aligned to another, the Edit-distance approach allows a note to be explicitly left unaligned at the cost of gap_penalty, i.e. allows for insertions (extra notes) and deletions (missing notes). Each aligned pair or unmatched element is classified into one of the four operation types according to the moving direction during backtracking:\n", + " - diagonal = match (identical pitch)/replacement (wrong pitch)\n", + " - vertical = extra (additional note played)\n", + " - horizontal = missing (note not played)\n", + " \n", + "Step 2: Estimate the overall tempo trend\n", + "- It is common for students to play at a slower tempo by setting the metronome to a slower pace during practice. Then after becoming more and more familiar with it, they may speed up unconsciously especially for the easier parts. Therefore, to avoid prompting the tempo problem for every single note in these cases, the global drift problem must be separated from local deviations. \n", + "- `estimate_global_timing` fits a linear regression over all matched note pairs: response_start ≈ scale × ref_start + offset, where scale represents the student's overall tempo relative to the reference (greater than 1.0 indicates playing slower; less than 1.0 indicates playing faster), and offset captures any constant time shift.\n", + "- `estimate_global_duration_scale` fits a least-squares regression, this regression line passes through the origin because note duration has no meaningful constant offset term: response_duration ≈ duration_scale × ref_duration, where duration_scale represents the general holding time for notes relative to the reference (greater than 1.0 indicates longer holding; less than 1.0 indicates shorter holding time)\n", + "- To ensure the fitting method is statistically and musically meaningful, if the amount of matched pairs available is smaller than three, the functions will assume there is no global tempo trend. \n", + "- The slow/fast decision mainly depends on the scale for timing.\n", + " \n", + "Step 3: note-level evaluation for matched pairs\n", + "\n", + "Pitch:\n", + "— A note is considered correct if and only if the pitch matches exactly (pitch_diff == 0). \n", + "- The absolute semitone difference is recorded for feedback.\n", + "Timing:\n", + "— The expected start time is predicted from the global trend line, then the relative difference in start time is calculated by ∣response_start − predicted_start∣ / inter-onset interval (IOI) of the reference note, \n", + "a note is considered correct if this relative difference is within a threshold.\n", + "- In the feedback, the amount of difference will be reported instead of directly indicate the correctness, so that the students will not be discourage to include their expressiveness in certain parts of the practice.\n", + "- The first note (ref_idx == 0) is always marked as timing-correct, since normalisation in Step 0 guarantees both sequences start at t = 0.\n", + "Duration \n", + "— The expected duration is predicted from the global duration scale, then the relative difference in duration is calculated by ∣response_duration − predicted_duration∣ / ref_duration, a note is considered correct if this relative difference is within a threshold.\n", + "\n", + "Step 4: Compute summary statistics\n", + "- Summary counts, including total notes missing, extra, wrong pitch, wrong timing, and wrong duration, as well as boolean flags indicating whether all paired notes are correct on each dimension. The global trend parameters (timing_scale, timing_offset, duration_scale) are also included. \n", + "\n", + "Step 5: Generate the human-readable feedback messages from those statistics and note-level feedback\n", + "- Overview provides a summary of the student's overall performance:\n", + " - Tempo judgement based on timing_scale: explicitly states whether the overall tempo is acceptable, too slow, or too fast, along with the duration_scale as supplementary context\n", + " - Total count of pitch errors, missing notes, and extra notes\n", + "- Detail provides actionable, note-level feedback for each issue identified:\n", + " - Missing/extra notes: identifies the specific note and pitch\n", + " - Pitch errors: states the expected and played pitch, and the semitone difference\n", + " - Local timing anomalies: reports the absolute and relative deviation after the global trend has been removed\n", + " - Local duration anomalies: reports the deviation direction and magnitude after the global duration scale has been removed" ] } ], From e9a46d8e93e46db91a065efbea11034a435f68da Mon Sep 17 00:00:00 2001 From: ada-3e212e610b Date: Wed, 24 Jun 2026 17:16:14 +0100 Subject: [PATCH 22/22] fix ModuleNotFoundError --- evaluation_function/evaluation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evaluation_function/evaluation.py b/evaluation_function/evaluation.py index 4c3ab8e..176fdaa 100755 --- a/evaluation_function/evaluation.py +++ b/evaluation_function/evaluation.py @@ -10,7 +10,7 @@ from typing import Any from lf_toolkit.evaluation import Result, Params -from compare_MIDI import ( +from .compare_MIDI import ( compare_performance_ED, DEFAULT_GAP_PENALTY, TIMING_RELATIVE_THRESHOLD,