From aad7e477a9abecfe1cfc9d483920f832bc4d6abc Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 19:30:02 +0600 Subject: [PATCH 1/7] PR1.3: add duration diagnostic fields to input suitability --- src/practicelens/domain/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/practicelens/domain/models.py b/src/practicelens/domain/models.py index 446d707..787b9d1 100644 --- a/src/practicelens/domain/models.py +++ b/src/practicelens/domain/models.py @@ -144,6 +144,8 @@ class InputSuitabilitySummary: reference_duration_s: Seconds = Seconds(0.0) take_duration_s: Seconds = Seconds(0.0) duration_ratio: float = 0.0 + duration_diagnostic: str = "duration_ratio_unavailable" + duration_diagnostic_message: str | None = None alignment_coverage: float = 0.0 voiced_frame_coverage: float = 0.0 reference_voiced_frame_coverage: float = 0.0 From 76b41d30919c5b3d8e1cd72c3daf8238c219f177 Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 19:38:00 +0600 Subject: [PATCH 2/7] PR1.3: derive explicit duration mismatch diagnostic --- .../diagnostics/input_suitability.py | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/practicelens/diagnostics/input_suitability.py b/src/practicelens/diagnostics/input_suitability.py index 3f2b3be..150d482 100644 --- a/src/practicelens/diagnostics/input_suitability.py +++ b/src/practicelens/diagnostics/input_suitability.py @@ -15,6 +15,10 @@ _VOICED_LOW_MIN = 0.15 _ONSET_PRESENT_MIN = 2 _SCORE_DIGITS = 6 +_DURATION_WARNING_MESSAGE = ( + "Take duration differs substantially from the reference. Possible causes include extra silence, " + "a restart, a missing section, or unrelated material." +) def summarize_input_suitability( @@ -27,6 +31,8 @@ def summarize_input_suitability( reference_duration_s = _duration_s(reference) take_duration_s = _duration_s(take) duration_ratio = _duration_ratio(reference_duration_s, take_duration_s) + duration_diagnostic = _duration_diagnostic(reference_duration_s, take_duration_s, duration_ratio) + duration_diagnostic_message = _duration_diagnostic_message(duration_diagnostic) alignment_coverage = _round_ratio(alignment.coverage_ratio) reference_voiced_coverage = _round_ratio(_voiced_ratio(reference)) take_voiced_coverage = _round_ratio(_voiced_ratio(take)) @@ -39,13 +45,13 @@ def summarize_input_suitability( low_confidence = False reasons: list[str] = [] - if reference_duration_s <= 0.0 or take_duration_s <= 0.0: + if duration_diagnostic == "duration_ratio_unavailable": low_confidence = True reasons.append("Reference or take duration is unavailable.") - elif _DURATION_RATIO_WARNING_MIN <= duration_ratio <= _DURATION_RATIO_WARNING_MAX: + elif duration_diagnostic == "duration_ratio_ok": reasons.append("Take duration is comparable to the reference.") else: - reasons.append("Take duration differs from the reference.") + reasons.append(duration_diagnostic_message or "Take duration differs from the reference.") risk_points += 1 if duration_ratio < _DURATION_RATIO_LOW_MIN or duration_ratio > _DURATION_RATIO_LOW_MAX: low_confidence = True @@ -81,6 +87,8 @@ def summarize_input_suitability( reference_duration_s=Seconds(_round_ratio(reference_duration_s)), take_duration_s=Seconds(_round_ratio(take_duration_s)), duration_ratio=duration_ratio, + duration_diagnostic=duration_diagnostic, + duration_diagnostic_message=duration_diagnostic_message, alignment_coverage=alignment_coverage, voiced_frame_coverage=voiced_frame_coverage, reference_voiced_frame_coverage=reference_voiced_coverage, @@ -106,6 +114,22 @@ def _duration_ratio(reference_duration_s: float, take_duration_s: float) -> floa return _round_ratio(take_duration_s / reference_duration_s) +def _duration_diagnostic(reference_duration_s: float, take_duration_s: float, duration_ratio: float) -> str: + if reference_duration_s <= 0.0 or take_duration_s <= 0.0: + return "duration_ratio_unavailable" + if duration_ratio < _DURATION_RATIO_WARNING_MIN: + return "take_much_shorter_than_reference" + if duration_ratio > _DURATION_RATIO_WARNING_MAX: + return "take_much_longer_than_reference" + return "duration_ratio_ok" + + +def _duration_diagnostic_message(duration_diagnostic: str) -> str | None: + if duration_diagnostic in {"take_much_shorter_than_reference", "take_much_longer_than_reference"}: + return _DURATION_WARNING_MESSAGE + return None + + def _voiced_ratio(bundle: FeatureBundle) -> float: if not bundle.voiced_mask: return 0.0 From 6db29e807e2d1867dc05f5fb3e2bb740f033c476 Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 19:45:14 +0600 Subject: [PATCH 3/7] PR1.3: include duration diagnostic in suitability payload --- src/practicelens/reporting/input_suitability_payload.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/practicelens/reporting/input_suitability_payload.py b/src/practicelens/reporting/input_suitability_payload.py index 822a29d..3586573 100644 --- a/src/practicelens/reporting/input_suitability_payload.py +++ b/src/practicelens/reporting/input_suitability_payload.py @@ -10,6 +10,8 @@ def input_suitability_to_payload(summary: InputSuitabilitySummary) -> dict[str, "reference_duration_s": summary.reference_duration_s, "take_duration_s": summary.take_duration_s, "duration_ratio": summary.duration_ratio, + "duration_diagnostic": summary.duration_diagnostic, + "duration_diagnostic_message": summary.duration_diagnostic_message, "alignment_coverage": summary.alignment_coverage, "voiced_frame_coverage": summary.voiced_frame_coverage, "reference_voiced_frame_coverage": summary.reference_voiced_frame_coverage, From 9a2a168ec54106823cf44842061c466b1908a349 Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 20:11:51 +0600 Subject: [PATCH 4/7] PR1.3: type duration diagnostic payload fields --- src/practicelens/api/contracts.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/practicelens/api/contracts.py b/src/practicelens/api/contracts.py index 82fceeb..6fdf622 100644 --- a/src/practicelens/api/contracts.py +++ b/src/practicelens/api/contracts.py @@ -100,6 +100,8 @@ class InputSuitabilitySummaryPayload(TypedDict): reference_duration_s: float take_duration_s: float duration_ratio: float + duration_diagnostic: str + duration_diagnostic_message: str | None alignment_coverage: float voiced_frame_coverage: float reference_voiced_frame_coverage: float From 97196da27e88b2b09764cedecbbff95659171c2d Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 20:16:33 +0600 Subject: [PATCH 5/7] PR1.3: test duration mismatch diagnostics --- tests/unit/test_input_suitability.py | 54 +++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_input_suitability.py b/tests/unit/test_input_suitability.py index 2d949d8..cac231d 100644 --- a/tests/unit/test_input_suitability.py +++ b/tests/unit/test_input_suitability.py @@ -22,6 +22,8 @@ def test_input_suitability_summary_reports_ok_when_evidence_is_strong() -> None: assert summary.reference_duration_s == 3.0 assert summary.take_duration_s == 3.0 assert summary.duration_ratio == 1.0 + assert summary.duration_diagnostic == "duration_ratio_ok" + assert summary.duration_diagnostic_message is None assert summary.alignment_coverage == 0.9 assert summary.voiced_frame_coverage == 1.0 assert summary.onset_evidence == "present" @@ -44,7 +46,13 @@ def test_input_suitability_summary_reports_warning_when_duration_differs() -> No assert summary.status == "warning" assert summary.duration_ratio == 0.666667 - assert "Take duration differs from the reference." in summary.reasons + assert summary.duration_diagnostic == "take_much_shorter_than_reference" + assert summary.duration_diagnostic_message is not None + assert "extra silence" in summary.duration_diagnostic_message + assert "restart" in summary.duration_diagnostic_message + assert "missing section" in summary.duration_diagnostic_message + assert "unrelated material" in summary.duration_diagnostic_message + assert summary.duration_diagnostic_message in summary.reasons def test_input_suitability_summary_reports_low_confidence_when_evidence_is_thin() -> None: @@ -63,11 +71,55 @@ def test_input_suitability_summary_reports_low_confidence_when_evidence_is_thin( ) assert summary.status == "low_confidence" + assert summary.duration_diagnostic == "take_much_shorter_than_reference" assert summary.alignment_coverage == 0.4 assert summary.voiced_frame_coverage == 0.0 assert summary.onset_evidence == "absent" +def test_input_suitability_duration_diagnostic_reports_much_longer_take() -> None: + summary = summarize_input_suitability( + _feature_bundle( + time_axis_s=(0.0, 1.0, 2.0), + voiced_mask=(True, True, True), + onset_times_s=(0.5, 1.5), + ), + _feature_bundle( + time_axis_s=(0.0, 1.0, 2.0, 3.0, 4.0), + voiced_mask=(True, True, True, True, True), + onset_times_s=(0.5, 1.5), + ), + AlignmentPath(pairs=(), total_cost=0.0, coverage_ratio=0.9), + ) + + assert summary.status == "warning" + assert summary.duration_ratio == 2.0 + assert summary.duration_diagnostic == "take_much_longer_than_reference" + assert summary.duration_diagnostic_message is not None + assert "extra silence" in summary.duration_diagnostic_message + + +def test_input_suitability_duration_diagnostic_reports_acceptable_duration() -> None: + summary = summarize_input_suitability( + _feature_bundle( + time_axis_s=(0.0, 1.0, 2.0, 3.0), + voiced_mask=(True, True, True, True), + onset_times_s=(0.5, 1.5), + ), + _feature_bundle( + time_axis_s=(0.0, 1.0, 2.0, 3.2), + voiced_mask=(True, True, True, True), + onset_times_s=(0.5, 1.5), + ), + AlignmentPath(pairs=(), total_cost=0.0, coverage_ratio=0.9), + ) + + assert summary.status == "ok" + assert summary.duration_ratio == 1.066667 + assert summary.duration_diagnostic == "duration_ratio_ok" + assert summary.duration_diagnostic_message is None + + def _feature_bundle( *, time_axis_s: tuple[float, ...], From c077c09c29d72e0c70e074537f905393cb304c7c Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 20:17:27 +0600 Subject: [PATCH 6/7] PR1.3: update JSON contract for duration diagnostic --- tests/unit/test_json_report.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/test_json_report.py b/tests/unit/test_json_report.py index 4998fda..4621901 100644 --- a/tests/unit/test_json_report.py +++ b/tests/unit/test_json_report.py @@ -45,6 +45,8 @@ def _sample_report() -> AnalysisReport: reference_duration_s=8.0, take_duration_s=8.0, duration_ratio=1.0, + duration_diagnostic="duration_ratio_ok", + duration_diagnostic_message=None, alignment_coverage=0.95, voiced_frame_coverage=0.8, reference_voiced_frame_coverage=0.85, @@ -113,6 +115,8 @@ def test_report_json_payload_has_stable_top_level_contract() -> None: "reference_duration_s": 8.0, "take_duration_s": 8.0, "duration_ratio": 1.0, + "duration_diagnostic": "duration_ratio_ok", + "duration_diagnostic_message": None, "alignment_coverage": 0.95, "voiced_frame_coverage": 0.8, "reference_voiced_frame_coverage": 0.85, From 12437723bf389f2c9193062223cd93da9a42c6db Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 20:20:18 +0600 Subject: [PATCH 7/7] PR1.3: update debug contract for duration diagnostic --- tests/unit/test_reporting.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/test_reporting.py b/tests/unit/test_reporting.py index c72b70e..8a28105 100644 --- a/tests/unit/test_reporting.py +++ b/tests/unit/test_reporting.py @@ -60,6 +60,8 @@ def _sample_report() -> AnalysisReport: reference_duration_s=8.0, take_duration_s=8.0, duration_ratio=1.0, + duration_diagnostic="duration_ratio_ok", + duration_diagnostic_message=None, alignment_coverage=0.95, voiced_frame_coverage=0.8, reference_voiced_frame_coverage=0.85, @@ -94,6 +96,7 @@ def test_report_to_json_payload_is_serializable() -> None: assert payload["overview"]["mode"] == "reference" assert payload["scores"][0]["name"] == "pitch_fidelity" assert payload["input_suitability"]["status"] == "ok" + assert payload["input_suitability"]["duration_diagnostic"] == "duration_ratio_ok" assert payload["artifacts"][1]["kind"] == "csv_report" @@ -150,6 +153,8 @@ def test_report_to_debug_payload_is_serializable() -> None: "reference_duration_s": 8.0, "take_duration_s": 8.0, "duration_ratio": 1.0, + "duration_diagnostic": "duration_ratio_ok", + "duration_diagnostic_message": None, "alignment_coverage": 0.95, "voiced_frame_coverage": 0.8, "reference_voiced_frame_coverage": 0.85,