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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paces
3
- Version: 0.0.3
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
- """A clean decimal string from a float ('95.78', never '95.78000000001')."""
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.3"
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