paces 0.0.3__tar.gz → 0.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {paces-0.0.3 → paces-0.0.4}/PKG-INFO +10 -1
- {paces-0.0.3 → paces-0.0.4}/README.md +3 -0
- {paces-0.0.3 → paces-0.0.4}/paces/__init__.py +3 -0
- paces-0.0.4/paces/measure.py +322 -0
- {paces-0.0.3 → paces-0.0.4}/paces/model.py +29 -2
- {paces-0.0.3 → paces-0.0.4}/paces/projection.py +2 -3
- {paces-0.0.3 → paces-0.0.4}/paces/segmenters.py +6 -0
- {paces-0.0.3 → paces-0.0.4}/paces/tools.py +37 -0
- {paces-0.0.3 → paces-0.0.4}/pyproject.toml +19 -1
- paces-0.0.4/tests/audio_synth.py +74 -0
- paces-0.0.4/tests/test_measure.py +259 -0
- {paces-0.0.3 → paces-0.0.4}/.gitignore +0 -0
- {paces-0.0.3 → paces-0.0.4}/LICENSE +0 -0
- {paces-0.0.3 → paces-0.0.4}/docs/README.md +0 -0
- {paces-0.0.3 → paces-0.0.4}/docs/alignment/README.md +0 -0
- {paces-0.0.3 → paces-0.0.4}/docs/poc-reference/README.md +0 -0
- {paces-0.0.3 → paces-0.0.4}/docs/poc-reference/artifacts/clips.json +0 -0
- {paces-0.0.3 → paces-0.0.4}/paces/__main__.py +0 -0
- {paces-0.0.3 → paces-0.0.4}/paces/edits.py +0 -0
- {paces-0.0.3 → paces-0.0.4}/paces/render.py +0 -0
- {paces-0.0.3 → paces-0.0.4}/tests/data/routine.json +0 -0
- {paces-0.0.3 → paces-0.0.4}/tests/poc_fixture.py +0 -0
- {paces-0.0.3 → paces-0.0.4}/tests/test_chapters.py +0 -0
- {paces-0.0.3 → paces-0.0.4}/tests/test_edits.py +0 -0
- {paces-0.0.3 → paces-0.0.4}/tests/test_roundtrip_poc.py +0 -0
- {paces-0.0.3 → paces-0.0.4}/tests/test_segment.py +0 -0
- {paces-0.0.3 → paces-0.0.4}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: paces
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: Turn instructional media into structured, interactive learning material
|
|
5
5
|
Project-URL: Homepage, https://github.com/thorwhalen/paces
|
|
6
6
|
Project-URL: Repository, https://github.com/thorwhalen/paces
|
|
@@ -11,11 +11,17 @@ License-File: LICENSE
|
|
|
11
11
|
Keywords: dance,instructional-video,learning-material,practice,segmentation,steps,tutorial
|
|
12
12
|
Requires-Python: >=3.10
|
|
13
13
|
Requires-Dist: pydantic>=2.6
|
|
14
|
+
Provides-Extra: audio
|
|
15
|
+
Requires-Dist: audioop-lts; (python_version >= '3.13') and extra == 'audio'
|
|
16
|
+
Requires-Dist: mixing[audio,beats]>=0.0.36; extra == 'audio'
|
|
17
|
+
Requires-Dist: numba>=0.59; extra == 'audio'
|
|
14
18
|
Provides-Extra: cli
|
|
15
19
|
Requires-Dist: argcomplete>=3; extra == 'cli'
|
|
16
20
|
Requires-Dist: argh>=0.30; extra == 'cli'
|
|
17
21
|
Provides-Extra: dev
|
|
18
22
|
Requires-Dist: argh>=0.30; extra == 'dev'
|
|
23
|
+
Requires-Dist: mixing[audio,beats]>=0.0.36; extra == 'dev'
|
|
24
|
+
Requires-Dist: numba>=0.59; extra == 'dev'
|
|
19
25
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
20
26
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
21
27
|
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
@@ -101,6 +107,9 @@ content (`OpenQuestion`), and human edits are protected from regeneration
|
|
|
101
107
|
|---|---|
|
|
102
108
|
| cut media into steps | `segment(media, steps=..., grid=...)` → `Segmentation` |
|
|
103
109
|
| explicit/human boundaries | `segment(media, boundaries=[...], steps=[names])` |
|
|
110
|
+
| use the video's own chapters | `segment(media, metadata=<yt-dlp info.json>)` |
|
|
111
|
+
| measure the grid from the media | `segment(local_media, steps=[(name, counts), ...])` — no grid needed; tempo + structure measured, origin estimated and flagged (`pip install paces[audio]`) |
|
|
112
|
+
| protect edits from regeneration | `apply_edits(doc, patches, by="user:you")` + `merge_regenerated(committed, fresh)` |
|
|
104
113
|
| the committed artifact | `to_document(seg, ...)` → `StepDocument` |
|
|
105
114
|
| a practice page | `render_html(doc)` |
|
|
106
115
|
| wall-clock times from counts | `resolve(doc)` |
|
|
@@ -75,6 +75,9 @@ content (`OpenQuestion`), and human edits are protected from regeneration
|
|
|
75
75
|
|---|---|
|
|
76
76
|
| cut media into steps | `segment(media, steps=..., grid=...)` → `Segmentation` |
|
|
77
77
|
| explicit/human boundaries | `segment(media, boundaries=[...], steps=[names])` |
|
|
78
|
+
| use the video's own chapters | `segment(media, metadata=<yt-dlp info.json>)` |
|
|
79
|
+
| measure the grid from the media | `segment(local_media, steps=[(name, counts), ...])` — no grid needed; tempo + structure measured, origin estimated and flagged (`pip install paces[audio]`) |
|
|
80
|
+
| protect edits from regeneration | `apply_edits(doc, patches, by="user:you")` + `merge_regenerated(committed, fresh)` |
|
|
78
81
|
| the committed artifact | `to_document(seg, ...)` → `StepDocument` |
|
|
79
82
|
| a practice page | `render_html(doc)` |
|
|
80
83
|
| wall-clock times from counts | `resolve(doc)` |
|
|
@@ -49,6 +49,7 @@ from paces.model import (
|
|
|
49
49
|
validate_document,
|
|
50
50
|
)
|
|
51
51
|
from paces.edits import apply_edits, merge_regenerated
|
|
52
|
+
from paces.measure import GridMeasurement, measure_grid
|
|
52
53
|
from paces.projection import to_document
|
|
53
54
|
from paces.render import render_html
|
|
54
55
|
from paces.segmenters import (
|
|
@@ -87,6 +88,8 @@ __all__ = [
|
|
|
87
88
|
"Capability",
|
|
88
89
|
"register",
|
|
89
90
|
"capabilities",
|
|
91
|
+
"measure_grid",
|
|
92
|
+
"GridMeasurement",
|
|
90
93
|
"to_document",
|
|
91
94
|
# editing & regeneration
|
|
92
95
|
"apply_edits",
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Measure a :class:`~paces.model.MetricGrid` from the media itself.
|
|
2
|
+
|
|
3
|
+
Roadmap issue #2: when no grid is supplied, the media can often supply its
|
|
4
|
+
own — the fleet's ``mixing`` package owns the primitives (speech/music
|
|
5
|
+
segmentation, beat tracking) and this module composes them, in the cost order
|
|
6
|
+
the POC proved out (``docs/01-what-was-built.md §3.1``):
|
|
7
|
+
|
|
8
|
+
1. **Macro-structure** — ``find_segments(strategy="speech_music")`` splits
|
|
9
|
+
talk from music; the longest music region is where a routine lives.
|
|
10
|
+
2. **Tempo** — ``beat_grid`` on that region (librosa under the hood).
|
|
11
|
+
3. **Origin** — the weak link, stated honestly: tempo alone cannot give the
|
|
12
|
+
phase. v1 anchors on the first detected beat of the music region, reports
|
|
13
|
+
LOW confidence, and flags the estimate for confirmation — per ADR-0003, a
|
|
14
|
+
default chosen automatically must be reported with its confidence and
|
|
15
|
+
overridable by one keyword (``grid=``).
|
|
16
|
+
|
|
17
|
+
Honesty under failure (adversarial review, PR #13): media with no beat
|
|
18
|
+
structure — silence, ambience, an empty file — yields a grid whose tempo is
|
|
19
|
+
honestly ``None`` (never ``"0"``: :class:`~paces.model.MetricGrid` refuses
|
|
20
|
+
non-positive tempi) plus a ``tempo-unmeasured`` flag, and the ``segment()``
|
|
21
|
+
facade keeps its returns-a-Segmentation-always contract.
|
|
22
|
+
|
|
23
|
+
The ``grid-measured`` capability wires this into :func:`~paces.segmenters.
|
|
24
|
+
segment`: media + a step list with durations, no full grid → measure, then
|
|
25
|
+
place exactly as ``grid-placed`` would. A *partial* ``grid=`` is honoured as
|
|
26
|
+
"what the caller knows" — unit and subdivisions always, and a caller-supplied
|
|
27
|
+
tempo or origin **wins over the measured value** (explicit beats inferred),
|
|
28
|
+
with a ``tempo-disagreement`` flag when the media measurably disagrees — the
|
|
29
|
+
POC's doc-said-100-video-says-129 lesson, surfaced as a diff instead of
|
|
30
|
+
silently resolved either way.
|
|
31
|
+
|
|
32
|
+
Everything heavy is imported lazily — ``import paces`` never pulls librosa —
|
|
33
|
+
and the capability preflights ``mixing``/``librosa`` (the ``[audio]`` extra).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from collections.abc import Mapping
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from fractions import Fraction
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
from paces.model import MetricGrid, decimal_str, seconds_per_unit
|
|
45
|
+
from paces.segmenters import (
|
|
46
|
+
Capability,
|
|
47
|
+
Segmentation,
|
|
48
|
+
_segment_grid_placed,
|
|
49
|
+
register,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
DFLT_UNIT = "eight"
|
|
53
|
+
DFLT_SUBDIVISIONS = 8
|
|
54
|
+
DFLT_SAMPLE_RATE = 22050
|
|
55
|
+
|
|
56
|
+
#: The origin estimate is the honest weak link: "first beat of the music
|
|
57
|
+
#: region" is right when the routine starts on bar one, and wrong by an intro.
|
|
58
|
+
DFLT_MEASURED_CONFIDENCE = 0.5
|
|
59
|
+
#: A caller-supplied origin removes the weak link; the tempo half is reliable.
|
|
60
|
+
KNOWN_ORIGIN_CONFIDENCE = 0.75
|
|
61
|
+
#: Earned when the declared routine length fits inside the music region.
|
|
62
|
+
FIT_BONUS = 0.2
|
|
63
|
+
FIT_TOLERANCE = 1.15 # the routine may overrun the region by 15% before we flag
|
|
64
|
+
UNMEASURED_CONFIDENCE = 0.2
|
|
65
|
+
#: A caller tempo further than this (relative) from the measured one is flagged.
|
|
66
|
+
TEMPO_DISAGREEMENT_RTOL = 0.02
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MediaDecodeError(ValueError):
|
|
70
|
+
"""The file exists but could not be read as audio."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _dec(value: float, *, places: int = 2) -> str:
|
|
74
|
+
return decimal_str(value, places=places)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
78
|
+
class GridMeasurement:
|
|
79
|
+
"""A measured grid, how much to trust it, and the evidence why."""
|
|
80
|
+
|
|
81
|
+
grid: MetricGrid
|
|
82
|
+
confidence: float
|
|
83
|
+
flags: tuple[str, ...] = ()
|
|
84
|
+
evidence: Mapping[str, Any] = field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _measured_tempo(beat_grid_result) -> tuple[str | None, float | None]:
|
|
88
|
+
"""The tempo as a wire decimal, or None when there is no beat structure.
|
|
89
|
+
|
|
90
|
+
mixing's ``beat_grid`` reports ``tempo_bpm = 0.0`` when librosa finds no
|
|
91
|
+
tempo; zero is 'unmeasured', never a value (a zero tempo is
|
|
92
|
+
unrepresentable in :class:`MetricGrid`, deliberately).
|
|
93
|
+
"""
|
|
94
|
+
tempo = float(beat_grid_result.tempo_bpm)
|
|
95
|
+
if tempo <= 0 or not len(beat_grid_result.beat_times):
|
|
96
|
+
return None, None
|
|
97
|
+
return _dec(tempo, places=1), tempo
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def measure_grid(
|
|
101
|
+
media: str,
|
|
102
|
+
*,
|
|
103
|
+
unit: str = DFLT_UNIT,
|
|
104
|
+
subdivisions: int = DFLT_SUBDIVISIONS,
|
|
105
|
+
tempo_bpm: str | float | None = None,
|
|
106
|
+
origin: str | float | None = None,
|
|
107
|
+
total_units: float | None = None,
|
|
108
|
+
sample_rate: int = DFLT_SAMPLE_RATE,
|
|
109
|
+
) -> GridMeasurement:
|
|
110
|
+
"""Measure tempo, macro-structure and (estimated) origin from *media*.
|
|
111
|
+
|
|
112
|
+
*media* is a local audio (or audio-bearing) file. *tempo_bpm* and
|
|
113
|
+
*origin*, when given, are what the caller already knows: they WIN over
|
|
114
|
+
the measured values (a disagreement is flagged, never silently resolved).
|
|
115
|
+
*total_units* (the sum of a step list's durations) buys a sanity check: a
|
|
116
|
+
routine that cannot fit inside the detected music region is flagged.
|
|
117
|
+
|
|
118
|
+
Returns a :class:`GridMeasurement`; whatever could not be measured stays
|
|
119
|
+
honestly ``None`` on the grid, with a flag naming it.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
import librosa
|
|
123
|
+
from mixing.audio import beat_grid, find_segments
|
|
124
|
+
except ImportError as error:
|
|
125
|
+
raise ImportError(
|
|
126
|
+
"measure_grid needs the [audio] extra — pip install 'paces[audio]'"
|
|
127
|
+
) from error
|
|
128
|
+
|
|
129
|
+
path = Path(media)
|
|
130
|
+
if not path.is_file():
|
|
131
|
+
raise FileNotFoundError(
|
|
132
|
+
f"measure_grid needs a local media file; {media!r} is not one "
|
|
133
|
+
"(download first — e.g. with yb — or supply grid= yourself)"
|
|
134
|
+
)
|
|
135
|
+
# A caller-supplied string is already a valid wire decimal: pass it
|
|
136
|
+
# through untouched so the declared value round-trips verbatim; only
|
|
137
|
+
# numbers get formatted.
|
|
138
|
+
known_tempo = (
|
|
139
|
+
tempo_bpm
|
|
140
|
+
if isinstance(tempo_bpm, str)
|
|
141
|
+
else None
|
|
142
|
+
if tempo_bpm is None
|
|
143
|
+
else _dec(float(tempo_bpm), places=1)
|
|
144
|
+
)
|
|
145
|
+
known_origin = (
|
|
146
|
+
origin
|
|
147
|
+
if isinstance(origin, str)
|
|
148
|
+
else None
|
|
149
|
+
if origin is None
|
|
150
|
+
else _dec(float(origin))
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
segments = find_segments(str(path), strategy="speech_music")
|
|
155
|
+
except Exception as error:
|
|
156
|
+
raise MediaDecodeError(
|
|
157
|
+
f"could not read {media!r} as audio ({type(error).__name__}) — "
|
|
158
|
+
"is it an audio/video file? Non-wav formats need ffmpeg installed."
|
|
159
|
+
) from error
|
|
160
|
+
evidence: dict[str, Any] = {
|
|
161
|
+
"segments": [
|
|
162
|
+
[round(s.start, 2), round(s.end, 2), s.label or ""] for s in segments
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
music = [s for s in segments if s.label == "music"]
|
|
166
|
+
flags: tuple[str, ...] = ()
|
|
167
|
+
|
|
168
|
+
if music:
|
|
169
|
+
region = max(music, key=lambda s: s.end - s.start)
|
|
170
|
+
try:
|
|
171
|
+
samples, rate = librosa.load(str(path), sr=sample_rate)
|
|
172
|
+
except Exception as error:
|
|
173
|
+
raise MediaDecodeError(
|
|
174
|
+
f"could not decode {media!r} as audio ({type(error).__name__})"
|
|
175
|
+
) from error
|
|
176
|
+
region_samples = samples[int(region.start * rate) : int(region.end * rate)]
|
|
177
|
+
bg = beat_grid(region_samples, sample_rate=rate)
|
|
178
|
+
measured_tempo, tempo_value = _measured_tempo(bg)
|
|
179
|
+
evidence.update(
|
|
180
|
+
music_span=[round(region.start, 2), round(region.end, 2)],
|
|
181
|
+
beat_count=len(bg.beat_times),
|
|
182
|
+
)
|
|
183
|
+
if measured_tempo is not None:
|
|
184
|
+
evidence["tempo_bpm"] = tempo_value
|
|
185
|
+
measured_origin = _dec(region.start + float(bg.beat_times[0]))
|
|
186
|
+
tempo_unmeasured_note = None
|
|
187
|
+
else:
|
|
188
|
+
tempo_unmeasured_note = "no beat structure in the music region"
|
|
189
|
+
measured_origin = None
|
|
190
|
+
else:
|
|
191
|
+
region = None
|
|
192
|
+
bg = beat_grid(str(path), sample_rate=sample_rate)
|
|
193
|
+
measured_tempo, tempo_value = _measured_tempo(bg)
|
|
194
|
+
measured_origin = None
|
|
195
|
+
if measured_tempo is not None:
|
|
196
|
+
evidence["tempo_bpm"] = tempo_value
|
|
197
|
+
tempo_unmeasured_note = None
|
|
198
|
+
else:
|
|
199
|
+
tempo_unmeasured_note = "no beat structure found"
|
|
200
|
+
flags += ("no-music-region",)
|
|
201
|
+
if tempo_unmeasured_note is not None:
|
|
202
|
+
suffix = " — using your tempoBpm" if known_tempo is not None else ""
|
|
203
|
+
flags += (f"tempo-unmeasured: {tempo_unmeasured_note}{suffix}",)
|
|
204
|
+
|
|
205
|
+
# Explicit beats inferred — but a disagreement is a finding, not a secret.
|
|
206
|
+
final_tempo = known_tempo or measured_tempo
|
|
207
|
+
if (
|
|
208
|
+
known_tempo is not None
|
|
209
|
+
and measured_tempo is not None
|
|
210
|
+
and abs(float(known_tempo) - float(measured_tempo))
|
|
211
|
+
> float(measured_tempo) * TEMPO_DISAGREEMENT_RTOL
|
|
212
|
+
):
|
|
213
|
+
flags += (
|
|
214
|
+
f"tempo-disagreement: you said {known_tempo} bpm, the media "
|
|
215
|
+
f"measures {measured_tempo} — using yours; drop tempoBpm from "
|
|
216
|
+
"grid= to use the measured value",
|
|
217
|
+
)
|
|
218
|
+
final_origin = known_origin or measured_origin
|
|
219
|
+
if known_origin is None and measured_origin is not None:
|
|
220
|
+
flags += (
|
|
221
|
+
f"origin-estimated: first beat of the music region "
|
|
222
|
+
f"({measured_origin} s) — override with grid= if the routine "
|
|
223
|
+
"starts later",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
grid = MetricGrid(
|
|
227
|
+
unit=unit,
|
|
228
|
+
subdivisions=subdivisions,
|
|
229
|
+
tempo_bpm=final_tempo,
|
|
230
|
+
origin=final_origin,
|
|
231
|
+
)
|
|
232
|
+
if final_origin is None:
|
|
233
|
+
flags += ("origin-unknown: pass grid= with an origin, or boundaries=",)
|
|
234
|
+
confidence = UNMEASURED_CONFIDENCE
|
|
235
|
+
elif region is None:
|
|
236
|
+
# The grid is only complete because the caller supplied the origin,
|
|
237
|
+
# and any tempo here was measured from a no-music recording (speech
|
|
238
|
+
# rhythm) — a placement can proceed, but not with real confidence.
|
|
239
|
+
confidence = UNMEASURED_CONFIDENCE
|
|
240
|
+
else:
|
|
241
|
+
confidence = (
|
|
242
|
+
KNOWN_ORIGIN_CONFIDENCE if known_origin else DFLT_MEASURED_CONFIDENCE
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
spu = seconds_per_unit(grid)
|
|
246
|
+
if total_units and total_units > 0 and spu and final_origin and region:
|
|
247
|
+
routine_s = total_units * spu
|
|
248
|
+
region_s = region.end - float(Fraction(final_origin))
|
|
249
|
+
evidence.update(routine_s=round(routine_s, 2), region_s=round(region_s, 2))
|
|
250
|
+
if routine_s > region_s * FIT_TOLERANCE:
|
|
251
|
+
flags += (
|
|
252
|
+
f"duration-mismatch: {_dec(total_units)} × {unit} = "
|
|
253
|
+
f"{routine_s:.1f} s but the music region holds {region_s:.1f} s "
|
|
254
|
+
"— the step list or the tempo may be wrong",
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
confidence += FIT_BONUS
|
|
258
|
+
return GridMeasurement(
|
|
259
|
+
grid=grid, confidence=min(confidence, 0.95), flags=flags, evidence=evidence
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _segment_grid_measured(inputs: Mapping[str, Any]) -> Segmentation:
|
|
264
|
+
"""Measure what the caller's partial ``grid=`` left unknown, then place
|
|
265
|
+
like ``grid-placed``.
|
|
266
|
+
|
|
267
|
+
With no grid at all the dance default is assumed — and flagged, because
|
|
268
|
+
an assumed unit is a guess, not a measurement. Whatever still cannot be
|
|
269
|
+
known (a beatless recording's origin) yields the honest partial result,
|
|
270
|
+
never an exception and never an invented placement.
|
|
271
|
+
"""
|
|
272
|
+
partial: MetricGrid | None = inputs["grid"]
|
|
273
|
+
unit = partial.unit if partial is not None else DFLT_UNIT
|
|
274
|
+
subdivisions = partial.subdivisions if partial is not None else DFLT_SUBDIVISIONS
|
|
275
|
+
total_units = sum(row["duration"] for row in inputs["steps"])
|
|
276
|
+
measurement = measure_grid(
|
|
277
|
+
inputs["media"],
|
|
278
|
+
unit=unit,
|
|
279
|
+
subdivisions=subdivisions,
|
|
280
|
+
tempo_bpm=partial.tempo_bpm if partial is not None else None,
|
|
281
|
+
origin=partial.origin if partial is not None else None,
|
|
282
|
+
total_units=total_units,
|
|
283
|
+
)
|
|
284
|
+
flags = measurement.flags
|
|
285
|
+
if partial is None:
|
|
286
|
+
flags += (
|
|
287
|
+
f"assumed-unit: {DFLT_UNIT} ({DFLT_SUBDIVISIONS} beats) — pass "
|
|
288
|
+
"grid={'unit': ..., 'subdivisions': ...} to change",
|
|
289
|
+
)
|
|
290
|
+
grid = measurement.grid
|
|
291
|
+
if grid.tempo_bpm is None or grid.origin is None:
|
|
292
|
+
return Segmentation(grid=grid, confidence=measurement.confidence, flags=flags)
|
|
293
|
+
placed = _segment_grid_placed({**inputs, "grid": grid})
|
|
294
|
+
return Segmentation(
|
|
295
|
+
steps=placed.steps,
|
|
296
|
+
boundaries=placed.boundaries,
|
|
297
|
+
unit=placed.unit,
|
|
298
|
+
grid=grid,
|
|
299
|
+
confidence=min(placed.confidence, measurement.confidence),
|
|
300
|
+
flags=placed.flags + flags,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
GRID_MEASURED = register(
|
|
305
|
+
Capability(
|
|
306
|
+
name="grid-measured",
|
|
307
|
+
gives="segmentation",
|
|
308
|
+
summary=(
|
|
309
|
+
"Measure the metric grid from the media itself (speech/music "
|
|
310
|
+
"split + beat tracking, via mixing), then place the step list "
|
|
311
|
+
"on it. Origin is estimated and flagged for confirmation."
|
|
312
|
+
),
|
|
313
|
+
target="paces.measure:_segment_grid_measured",
|
|
314
|
+
needs=frozenset({"media.local", "steps", "steps.durations"}),
|
|
315
|
+
requires=("mixing", "librosa"),
|
|
316
|
+
# Explicit information outranks inference: a full grid (1.2) and
|
|
317
|
+
# author chapters (0.75) both beat a measured guess.
|
|
318
|
+
base=0.7,
|
|
319
|
+
s_per_min=2.0,
|
|
320
|
+
resolution_s=0.5,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
@@ -27,7 +27,7 @@ from __future__ import annotations
|
|
|
27
27
|
from fractions import Fraction
|
|
28
28
|
from typing import Annotated, Any, Literal
|
|
29
29
|
|
|
30
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
31
31
|
from pydantic.alias_generators import to_camel
|
|
32
32
|
|
|
33
33
|
SCHEMA_VERSION = "0.1.0"
|
|
@@ -63,14 +63,28 @@ class MetricGrid(_Base):
|
|
|
63
63
|
|
|
64
64
|
Optional by design: a "reps" domain has no grid; a dance routine does —
|
|
65
65
|
and the grid is what drives the metronome and :func:`resolve`.
|
|
66
|
+
|
|
67
|
+
A non-positive tempo or subdivision count is unrepresentable, not merely
|
|
68
|
+
discouraged: a zero tempo makes every duration infinite and every
|
|
69
|
+
``seconds_per_unit`` division a crash, so "tempo unknown" is spelled
|
|
70
|
+
``tempo_bpm=None``, never ``"0"``.
|
|
66
71
|
"""
|
|
67
72
|
|
|
68
73
|
unit: Slug # "eight"
|
|
69
|
-
subdivisions: int = 1 # beats per unit (8 for an 8-count)
|
|
74
|
+
subdivisions: int = Field(default=1, ge=1) # beats per unit (8 for an 8-count)
|
|
70
75
|
tempo_bpm: Decimal | None = None # "129.2"
|
|
71
76
|
origin: Decimal | None = None # seconds into origin_source where unit 0 starts
|
|
72
77
|
origin_source: Slug | None = None
|
|
73
78
|
|
|
79
|
+
@field_validator("tempo_bpm")
|
|
80
|
+
@classmethod
|
|
81
|
+
def _tempo_must_be_positive(cls, value: str | None) -> str | None:
|
|
82
|
+
if value is not None and Fraction(value) <= 0:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"tempo_bpm must be positive; spell 'tempo unknown' as None"
|
|
85
|
+
)
|
|
86
|
+
return value
|
|
87
|
+
|
|
74
88
|
|
|
75
89
|
# ── sources & spans ─────────────────────────────────────────────────────────
|
|
76
90
|
|
|
@@ -235,6 +249,19 @@ class StepDocument(_Base):
|
|
|
235
249
|
attrs: dict[str, Any] = Field(default_factory=dict)
|
|
236
250
|
|
|
237
251
|
|
|
252
|
+
def decimal_str(value: float, *, places: int = 3) -> str:
|
|
253
|
+
"""A clean wire decimal from a number ('95.78', never '95.78000000001').
|
|
254
|
+
|
|
255
|
+
The one shared serialisation helper for computed values entering the
|
|
256
|
+
document's no-floats wire format.
|
|
257
|
+
|
|
258
|
+
>>> decimal_str(72.60000000000001, places=3)
|
|
259
|
+
'72.6'
|
|
260
|
+
"""
|
|
261
|
+
text = f"{float(value):.{places}f}".rstrip("0").rstrip(".")
|
|
262
|
+
return text or "0"
|
|
263
|
+
|
|
264
|
+
|
|
238
265
|
# ── serialisation (the git rules of docs/07 §6.5) ───────────────────────────
|
|
239
266
|
|
|
240
267
|
|
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
from collections.abc import Mapping
|
|
13
13
|
|
|
14
14
|
from paces.model import (
|
|
15
|
+
decimal_str,
|
|
15
16
|
Measure,
|
|
16
17
|
Origin,
|
|
17
18
|
Source,
|
|
@@ -28,9 +29,7 @@ DECIMAL_PLACES = 3
|
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def _dec(value: float, *, places: int = DECIMAL_PLACES) -> str:
|
|
31
|
-
|
|
32
|
-
text = f"{value:.{places}f}".rstrip("0").rstrip(".")
|
|
33
|
-
return text or "0"
|
|
32
|
+
return decimal_str(value, places=places)
|
|
34
33
|
|
|
35
34
|
|
|
36
35
|
def _as_source(source, *, default_id: str = "source") -> Source:
|
|
@@ -34,6 +34,7 @@ import math
|
|
|
34
34
|
import time
|
|
35
35
|
from collections.abc import Mapping, Sequence
|
|
36
36
|
from dataclasses import dataclass, field
|
|
37
|
+
from pathlib import Path
|
|
37
38
|
from typing import Any, Callable
|
|
38
39
|
|
|
39
40
|
from paces.model import MetricGrid, seconds_per_unit
|
|
@@ -271,6 +272,11 @@ def _facts(
|
|
|
271
272
|
facts = set()
|
|
272
273
|
if media:
|
|
273
274
|
facts.add("media")
|
|
275
|
+
try:
|
|
276
|
+
if Path(media).is_file(): # a directory is not measurable media
|
|
277
|
+
facts.add("media.local") # intrinsic measurement needs bytes
|
|
278
|
+
except OSError:
|
|
279
|
+
pass
|
|
274
280
|
if steps:
|
|
275
281
|
facts.add("steps")
|
|
276
282
|
if all(row["duration"] is not None for row in steps):
|
|
@@ -250,6 +250,42 @@ def merge(committed, fresh, *, output: str | None = None) -> dict:
|
|
|
250
250
|
return json.loads(text)
|
|
251
251
|
|
|
252
252
|
|
|
253
|
+
def measure_grid(
|
|
254
|
+
media: str,
|
|
255
|
+
*,
|
|
256
|
+
unit: str = "eight",
|
|
257
|
+
subdivisions: int = 8,
|
|
258
|
+
total_units: float | None = None,
|
|
259
|
+
output: str | None = None,
|
|
260
|
+
) -> dict:
|
|
261
|
+
"""Measure a metric grid (tempo, macro-structure, estimated origin) from
|
|
262
|
+
a local media file. Needs the [audio] extra.
|
|
263
|
+
|
|
264
|
+
Returns ``{"grid", "confidence", "flags", "evidence"}`` — origin is an
|
|
265
|
+
estimate (first beat of the music region) and the flags say so; override
|
|
266
|
+
with an explicit ``grid=`` on ``segment`` when it is wrong.
|
|
267
|
+
"""
|
|
268
|
+
from paces.measure import measure_grid as _measure_grid
|
|
269
|
+
|
|
270
|
+
measurement = _measure_grid(
|
|
271
|
+
media, unit=unit, subdivisions=subdivisions, total_units=total_units
|
|
272
|
+
)
|
|
273
|
+
payload = {
|
|
274
|
+
"grid": measurement.grid.model_dump(
|
|
275
|
+
mode="json", by_alias=True, exclude_none=True
|
|
276
|
+
),
|
|
277
|
+
"confidence": measurement.confidence,
|
|
278
|
+
"flags": list(measurement.flags),
|
|
279
|
+
"evidence": dict(measurement.evidence),
|
|
280
|
+
}
|
|
281
|
+
if output:
|
|
282
|
+
Path(output).write_text(
|
|
283
|
+
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
|
|
284
|
+
encoding="utf-8",
|
|
285
|
+
)
|
|
286
|
+
return payload
|
|
287
|
+
|
|
288
|
+
|
|
253
289
|
def list_segmenters() -> dict:
|
|
254
290
|
"""The registered segmentation capabilities: name → what it needs/gives."""
|
|
255
291
|
return {
|
|
@@ -272,5 +308,6 @@ _dispatch_funcs = [
|
|
|
272
308
|
merge,
|
|
273
309
|
resolve,
|
|
274
310
|
validate,
|
|
311
|
+
measure_grid,
|
|
275
312
|
list_segmenters,
|
|
276
313
|
]
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "paces"
|
|
9
|
-
version = "0.0.
|
|
9
|
+
version = "0.0.4"
|
|
10
10
|
description = "Turn instructional media into structured, interactive learning material"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
license = "MIT"
|
|
@@ -37,6 +37,20 @@ paces = "paces.__main__:main"
|
|
|
37
37
|
|
|
38
38
|
[project.optional-dependencies]
|
|
39
39
|
cli = ["argh>=0.30", "argcomplete>=3"]
|
|
40
|
+
audio = [
|
|
41
|
+
# Grid measurement (issue #2): the fleet's `mixing` owns speech/music
|
|
42
|
+
# segmentation and beat tracking; [beats] pulls librosa, [audio] pulls
|
|
43
|
+
# soundfile. Floor 0.0.36: `beat_grid` and `find_segments(strategy=
|
|
44
|
+
# "speech_music")` ship there.
|
|
45
|
+
"mixing[beats,audio]>=0.0.36",
|
|
46
|
+
# librosa resolves numba transitively; without an explicit floor uv can
|
|
47
|
+
# backtrack to unbuildable numba 0.53/llvmlite 0.36 when the newest
|
|
48
|
+
# numpy outruns numba's caps.
|
|
49
|
+
"numba>=0.59",
|
|
50
|
+
# pydub (via mixing[audio]) imports the stdlib `audioop`, removed in
|
|
51
|
+
# Python 3.13; the official backport keeps 3.13+ installs working.
|
|
52
|
+
"audioop-lts; python_version>='3.13'",
|
|
53
|
+
]
|
|
40
54
|
dev = [
|
|
41
55
|
"pytest>=7.0",
|
|
42
56
|
"pytest-cov>=4.0",
|
|
@@ -44,6 +58,10 @@ dev = [
|
|
|
44
58
|
# The CLI smoke test runs `python -m paces ...`; without argh in dev it
|
|
45
59
|
# would fail rather than silently skip — keep it mirrored with [cli].
|
|
46
60
|
"argh>=0.30",
|
|
61
|
+
# The grid-measurement tests exercise the real mixing/librosa path on
|
|
62
|
+
# synthesized audio; mirrored with [audio] so they run rather than skip.
|
|
63
|
+
"mixing[beats,audio]>=0.0.36",
|
|
64
|
+
"numba>=0.59",
|
|
47
65
|
]
|
|
48
66
|
docs = [
|
|
49
67
|
"sphinx>=6.0",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Synthesize practice-video-shaped audio for the grid-measurement tests.
|
|
2
|
+
|
|
3
|
+
The shape mirrors the POC's macro-structure (docs/01 §3.1): the teacher talks
|
|
4
|
+
(amplitude-modulated noise at a syllabic ~4 Hz), a beat of silence, then the
|
|
5
|
+
run-through — music with a sub-bass kick on a known tempo. Everything is
|
|
6
|
+
deterministic (seeded) and synthesized at test time: no media files in the
|
|
7
|
+
repo, no network, no cost.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
SAMPLE_RATE = 22050
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def practice_audio(
|
|
18
|
+
*,
|
|
19
|
+
bpm: float = 129.2,
|
|
20
|
+
speech_s: float = 8.0,
|
|
21
|
+
silence_s: float = 1.5,
|
|
22
|
+
music_s: float = 30.0,
|
|
23
|
+
sample_rate: int = SAMPLE_RATE,
|
|
24
|
+
seed: int = 0,
|
|
25
|
+
) -> tuple[np.ndarray, dict]:
|
|
26
|
+
"""Speech, a pause, then kick-driven music at *bpm*.
|
|
27
|
+
|
|
28
|
+
Returns ``(samples, truth)`` where ``truth`` carries the ground truth the
|
|
29
|
+
tests assert against (music start/end, bpm).
|
|
30
|
+
"""
|
|
31
|
+
rng = np.random.default_rng(seed)
|
|
32
|
+
|
|
33
|
+
t_speech = np.arange(int(speech_s * sample_rate)) / sample_rate
|
|
34
|
+
speech = (
|
|
35
|
+
rng.normal(0, 0.15, t_speech.size)
|
|
36
|
+
* (0.55 + 0.45 * np.sin(2 * np.pi * 3.8 * t_speech)) ** 2
|
|
37
|
+
)
|
|
38
|
+
speech *= np.sin(2 * np.pi * 0.4 * t_speech) > -0.6 # breathing pauses
|
|
39
|
+
|
|
40
|
+
silence = np.zeros(int(silence_s * sample_rate))
|
|
41
|
+
|
|
42
|
+
beat_period = 60 / bpm
|
|
43
|
+
t_music = np.arange(int(music_s * sample_rate)) / sample_rate
|
|
44
|
+
music = 0.05 * rng.normal(0, 1, t_music.size) # hiss bed
|
|
45
|
+
music += 0.15 * np.sin(2 * np.pi * 110 * t_music) # sustained tone
|
|
46
|
+
for k in range(int(music_s / beat_period)):
|
|
47
|
+
start = int(k * beat_period * sample_rate)
|
|
48
|
+
end = min(start + int(0.09 * sample_rate), t_music.size)
|
|
49
|
+
n = end - start
|
|
50
|
+
envelope = np.exp(-np.arange(n) / (0.02 * sample_rate))
|
|
51
|
+
music[start:end] += (
|
|
52
|
+
0.9 * np.sin(2 * np.pi * 55 * np.arange(n) / sample_rate) * envelope
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
samples = np.concatenate([speech, silence, music]).astype(np.float32)
|
|
56
|
+
truth = {
|
|
57
|
+
"bpm": bpm,
|
|
58
|
+
"music_start_s": speech_s + silence_s,
|
|
59
|
+
"music_end_s": speech_s + silence_s + music_s,
|
|
60
|
+
"sample_rate": sample_rate,
|
|
61
|
+
}
|
|
62
|
+
return samples, truth
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def speech_only_audio(
|
|
66
|
+
*, duration_s: float = 12.0, sample_rate: int = SAMPLE_RATE, seed: int = 1
|
|
67
|
+
) -> np.ndarray:
|
|
68
|
+
"""Talk with no music anywhere — the origin-unknown case."""
|
|
69
|
+
t = np.arange(int(duration_s * sample_rate)) / sample_rate
|
|
70
|
+
rng = np.random.default_rng(seed)
|
|
71
|
+
speech = (
|
|
72
|
+
rng.normal(0, 0.15, t.size) * (0.55 + 0.45 * np.sin(2 * np.pi * 4.1 * t)) ** 2
|
|
73
|
+
)
|
|
74
|
+
return (speech * (np.sin(2 * np.pi * 0.3 * t) > -0.7)).astype(np.float32)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Grid measurement (issue #2): tempo + macro-structure measured, origin
|
|
2
|
+
estimated and flagged — on synthesized audio with known ground truth.
|
|
3
|
+
|
|
4
|
+
These tests exercise the REAL mixing/librosa path (no mocks); the audio is
|
|
5
|
+
deterministic, generated at test time, and never leaves tmp_path. They fail —
|
|
6
|
+
not skip — when the [audio]/dev deps are missing: a silently skipped suite
|
|
7
|
+
proves nothing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
import soundfile as sf
|
|
14
|
+
|
|
15
|
+
from audio_synth import SAMPLE_RATE, practice_audio, speech_only_audio
|
|
16
|
+
from paces.measure import measure_grid
|
|
17
|
+
from paces.segmenters import segment
|
|
18
|
+
|
|
19
|
+
BPM = 129.2
|
|
20
|
+
MUSIC_START = 9.5 # 8 s speech + 1.5 s silence
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture(scope="module")
|
|
24
|
+
def practice_wav(tmp_path_factory) -> str:
|
|
25
|
+
samples, _ = practice_audio(bpm=BPM)
|
|
26
|
+
path = tmp_path_factory.mktemp("audio") / "practice.wav"
|
|
27
|
+
sf.write(str(path), samples, SAMPLE_RATE)
|
|
28
|
+
return str(path)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture(scope="module")
|
|
32
|
+
def speech_wav(tmp_path_factory) -> str:
|
|
33
|
+
path = tmp_path_factory.mktemp("audio") / "speech.wav"
|
|
34
|
+
sf.write(str(path), speech_only_audio(), SAMPLE_RATE)
|
|
35
|
+
return str(path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_measure_finds_tempo_structure_and_estimated_origin(practice_wav):
|
|
39
|
+
measurement = measure_grid(practice_wav)
|
|
40
|
+
grid = measurement.grid
|
|
41
|
+
assert grid.unit == "eight" and grid.subdivisions == 8
|
|
42
|
+
assert float(grid.tempo_bpm) == pytest.approx(BPM, abs=0.5)
|
|
43
|
+
# origin: first beat of the music region — near, at or after music start
|
|
44
|
+
assert float(grid.origin) == pytest.approx(MUSIC_START, abs=1.0)
|
|
45
|
+
start, end = measurement.evidence["music_span"]
|
|
46
|
+
assert start == pytest.approx(MUSIC_START, abs=0.5)
|
|
47
|
+
assert any(flag.startswith("origin-estimated") for flag in measurement.flags)
|
|
48
|
+
assert 0 < measurement.confidence < 0.9 # an estimate, never certainty
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_fitting_routine_length_raises_confidence(practice_wav):
|
|
52
|
+
# 7 eights ≈ 26 s fits the ~30 s music region; 40 eights ≈ 148 s cannot
|
|
53
|
+
fits = measure_grid(practice_wav, total_units=7)
|
|
54
|
+
overruns = measure_grid(practice_wav, total_units=40)
|
|
55
|
+
assert fits.confidence > overruns.confidence
|
|
56
|
+
assert not any("duration-mismatch" in flag for flag in fits.flags)
|
|
57
|
+
assert any("duration-mismatch" in flag for flag in overruns.flags)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_speech_only_media_is_honest_about_the_origin(speech_wav):
|
|
61
|
+
measurement = measure_grid(speech_wav)
|
|
62
|
+
assert measurement.grid.origin is None
|
|
63
|
+
assert "no-music-region" in measurement.flags
|
|
64
|
+
assert measurement.confidence <= 0.2
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_missing_file_names_the_fix():
|
|
68
|
+
with pytest.raises(FileNotFoundError, match="grid="):
|
|
69
|
+
measure_grid("nope-not-here.wav")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── the grid-measured capability, end to end ────────────────────────────────
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_segment_measures_when_no_grid_is_given(practice_wav):
|
|
76
|
+
seg = segment(practice_wav, steps=[("intro", 2), ("chorus", 2), ("outro", 3)])
|
|
77
|
+
assert seg.method == "grid-measured"
|
|
78
|
+
assert seg.unit == "eight"
|
|
79
|
+
assert seg.grid is not None
|
|
80
|
+
assert float(seg.grid.tempo_bpm) == pytest.approx(BPM, abs=0.5)
|
|
81
|
+
# placement happened on the measured grid
|
|
82
|
+
assert len(seg.steps) == 3
|
|
83
|
+
assert seg.steps[0].spans[0][0] == pytest.approx(float(seg.grid.origin))
|
|
84
|
+
spu = 8 * 60 / float(seg.grid.tempo_bpm)
|
|
85
|
+
assert seg.steps[1].spans[0][0] == pytest.approx(
|
|
86
|
+
float(seg.grid.origin) + 2 * spu, abs=0.05
|
|
87
|
+
)
|
|
88
|
+
# honesty: the estimate and the assumed unit are both flagged
|
|
89
|
+
assert any(flag.startswith("origin-estimated") for flag in seg.flags)
|
|
90
|
+
assert any(flag.startswith("assumed-unit") for flag in seg.flags)
|
|
91
|
+
assert seg.confidence <= 0.7
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_partial_grid_supplies_the_unit_and_suppresses_the_assumption(practice_wav):
|
|
95
|
+
seg = segment(
|
|
96
|
+
practice_wav,
|
|
97
|
+
steps=[("a", 4), ("b", 4)],
|
|
98
|
+
grid={"unit": "bar", "subdivisions": 4},
|
|
99
|
+
)
|
|
100
|
+
assert seg.method == "grid-measured"
|
|
101
|
+
assert seg.unit == "bar" and seg.grid.subdivisions == 4
|
|
102
|
+
assert not any(flag.startswith("assumed-unit") for flag in seg.flags)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_explicit_grid_still_outranks_measurement(practice_wav):
|
|
106
|
+
seg = segment(
|
|
107
|
+
practice_wav,
|
|
108
|
+
steps=[("a", 4), ("b", 4)],
|
|
109
|
+
grid={"unit": "eight", "subdivisions": 8, "tempoBpm": "120", "origin": "3"},
|
|
110
|
+
)
|
|
111
|
+
assert seg.method == "grid-placed"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_remote_media_cannot_select_measurement():
|
|
115
|
+
seg = segment("https://example.com/video.mp4", steps=[("a", 2), ("b", 2)])
|
|
116
|
+
assert "no-signal" in seg.flags # media.local is the gate
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_speech_only_segmentation_reports_what_it_still_needs(speech_wav):
|
|
120
|
+
seg = segment(speech_wav, steps=[("a", 2), ("b", 2)])
|
|
121
|
+
assert seg.method == "grid-measured"
|
|
122
|
+
assert seg.steps == () # no invented placement
|
|
123
|
+
assert seg.grid is not None and seg.grid.origin is None
|
|
124
|
+
assert "no-music-region" in seg.flags
|
|
125
|
+
assert any("origin-unknown" in flag for flag in seg.flags)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_measure_grid_tool_is_json_ready(practice_wav):
|
|
129
|
+
import json
|
|
130
|
+
|
|
131
|
+
from paces.tools import measure_grid as measure_tool
|
|
132
|
+
|
|
133
|
+
payload = measure_tool(practice_wav, total_units=7)
|
|
134
|
+
json.dumps(payload)
|
|
135
|
+
assert payload["grid"]["tempoBpm"]
|
|
136
|
+
assert payload["evidence"]["music_span"]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── adversarial-review regressions (PR #13) ────────────────────────────────
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@pytest.fixture(scope="module")
|
|
143
|
+
def silence_wav(tmp_path_factory) -> str:
|
|
144
|
+
import numpy as np
|
|
145
|
+
|
|
146
|
+
path = tmp_path_factory.mktemp("audio") / "silence.wav"
|
|
147
|
+
sf.write(str(path), np.zeros(int(10 * SAMPLE_RATE), dtype="float32"), SAMPLE_RATE)
|
|
148
|
+
return str(path)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_silence_never_crashes_and_never_claims_a_tempo(silence_wav):
|
|
152
|
+
"""F1: beatless media used to reach ZeroDivisionError via tempo '0'."""
|
|
153
|
+
measurement = measure_grid(silence_wav)
|
|
154
|
+
assert measurement.grid.tempo_bpm is None # never '0'
|
|
155
|
+
assert any("tempo-unmeasured" in flag for flag in measurement.flags)
|
|
156
|
+
seg = segment(silence_wav, steps=[("a", 2), ("b", 2)]) # the facade contract
|
|
157
|
+
assert seg.method == "grid-measured"
|
|
158
|
+
assert seg.steps == () # no invented placement
|
|
159
|
+
assert seg.confidence <= 0.2
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_tiny_and_empty_files_return_honest_results(tmp_path):
|
|
163
|
+
import numpy as np
|
|
164
|
+
|
|
165
|
+
tiny = tmp_path / "tiny.wav"
|
|
166
|
+
sf.write(str(tiny), np.zeros(int(0.05 * SAMPLE_RATE), dtype="float32"), SAMPLE_RATE)
|
|
167
|
+
seg = segment(str(tiny), steps=[("a", 2)])
|
|
168
|
+
assert seg.steps == () and seg.confidence <= 0.2
|
|
169
|
+
|
|
170
|
+
empty = tmp_path / "empty.wav"
|
|
171
|
+
sf.write(str(empty), np.zeros(0, dtype="float32"), SAMPLE_RATE)
|
|
172
|
+
seg = segment(str(empty), steps=[("a", 2)]) # must not raise
|
|
173
|
+
assert seg.steps == ()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_metric_grid_refuses_zero_tempo_and_zero_subdivisions():
|
|
177
|
+
from paces.model import MetricGrid
|
|
178
|
+
|
|
179
|
+
with pytest.raises(Exception, match="positive"):
|
|
180
|
+
MetricGrid(unit="eight", tempo_bpm="0")
|
|
181
|
+
with pytest.raises(Exception, match="positive"):
|
|
182
|
+
MetricGrid(unit="eight", tempo_bpm="-5")
|
|
183
|
+
with pytest.raises(Exception):
|
|
184
|
+
MetricGrid(unit="eight", subdivisions=0)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_partial_grid_tempo_wins_and_disagreement_is_flagged(practice_wav):
|
|
188
|
+
"""F2: the caller's tempo is used, and the media's disagreement named."""
|
|
189
|
+
seg = segment(
|
|
190
|
+
practice_wav,
|
|
191
|
+
steps=[("a", 2), ("b", 2)],
|
|
192
|
+
grid={"unit": "eight", "subdivisions": 8, "tempoBpm": "200"},
|
|
193
|
+
)
|
|
194
|
+
assert seg.method == "grid-measured"
|
|
195
|
+
assert seg.grid.tempo_bpm == "200"
|
|
196
|
+
assert any(flag.startswith("tempo-disagreement") for flag in seg.flags)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_partial_grid_origin_wins_and_is_not_estimated(practice_wav):
|
|
200
|
+
seg = segment(
|
|
201
|
+
practice_wav,
|
|
202
|
+
steps=[("a", 2), ("b", 2)],
|
|
203
|
+
grid={"unit": "eight", "subdivisions": 8, "origin": "12.5"},
|
|
204
|
+
)
|
|
205
|
+
assert seg.method == "grid-measured"
|
|
206
|
+
assert seg.grid.origin == "12.5"
|
|
207
|
+
assert not any(flag.startswith("origin-estimated") for flag in seg.flags)
|
|
208
|
+
assert seg.steps[0].spans[0][0] == pytest.approx(12.5)
|
|
209
|
+
assert seg.confidence >= 0.7 # the weak link was supplied, not guessed
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_directory_media_is_not_measurable(tmp_path):
|
|
213
|
+
"""F3: a directory passes exists() but is not media."""
|
|
214
|
+
seg = segment(str(tmp_path), steps=[("a", 2), ("b", 2)])
|
|
215
|
+
assert "no-signal" in seg.flags
|
|
216
|
+
with pytest.raises(FileNotFoundError, match="not one"):
|
|
217
|
+
measure_grid(str(tmp_path))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_non_audio_file_gets_an_informative_error(tmp_path):
|
|
221
|
+
from paces.measure import MediaDecodeError
|
|
222
|
+
|
|
223
|
+
junk = tmp_path / "not-audio.json"
|
|
224
|
+
junk.write_text('{"hello": "world"}', encoding="utf-8")
|
|
225
|
+
with pytest.raises(MediaDecodeError, match="as audio"):
|
|
226
|
+
measure_grid(str(junk))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_zero_total_units_earns_no_fit_bonus(practice_wav):
|
|
230
|
+
from paces.measure import DFLT_MEASURED_CONFIDENCE
|
|
231
|
+
|
|
232
|
+
measurement = measure_grid(practice_wav, total_units=0)
|
|
233
|
+
assert measurement.confidence == DFLT_MEASURED_CONFIDENCE
|
|
234
|
+
assert "routine_s" not in measurement.evidence
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_known_values_round_trip_verbatim(practice_wav):
|
|
238
|
+
"""Round 2 F1: an explicitly supplied string is never reformatted."""
|
|
239
|
+
measurement = measure_grid(practice_wav, tempo_bpm="129.25", origin="12.345")
|
|
240
|
+
assert measurement.grid.tempo_bpm == "129.25"
|
|
241
|
+
assert measurement.grid.origin == "12.345"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_known_origin_on_no_music_media_stays_low_confidence(speech_wav):
|
|
245
|
+
"""Round 2 F2: a speech-rhythm tempo must not earn origin-level trust."""
|
|
246
|
+
measurement = measure_grid(speech_wav, origin="3.0")
|
|
247
|
+
assert measurement.grid.origin == "3.0"
|
|
248
|
+
assert measurement.confidence <= 0.2
|
|
249
|
+
if measurement.grid.tempo_bpm is not None:
|
|
250
|
+
assert "no-music-region" in measurement.flags
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_tempo_unmeasured_wording_acknowledges_a_caller_tempo(silence_wav):
|
|
254
|
+
measurement = measure_grid(silence_wav, tempo_bpm="120")
|
|
255
|
+
assert measurement.grid.tempo_bpm == "120"
|
|
256
|
+
assert any(
|
|
257
|
+
"tempo-unmeasured" in flag and "using your tempoBpm" in flag
|
|
258
|
+
for flag in measurement.flags
|
|
259
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|