Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/practicelens/api/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 27 additions & 3 deletions src/practicelens/diagnostics/input_suitability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/practicelens/domain/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/practicelens/reporting/input_suitability_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 53 additions & 1 deletion tests/unit/test_input_suitability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand All @@ -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, ...],
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/test_json_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/test_reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"


Expand Down Expand Up @@ -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,
Expand Down
Loading