paces 0.0.20__tar.gz → 0.0.22__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.20 → paces-0.0.22}/PKG-INFO +1 -1
- {paces-0.0.20 → paces-0.0.22}/paces/measure.py +15 -4
- {paces-0.0.20 → paces-0.0.22}/paces/model.py +5 -1
- {paces-0.0.20 → paces-0.0.22}/paces/segmenters.py +114 -29
- {paces-0.0.20 → paces-0.0.22}/pyproject.toml +1 -1
- {paces-0.0.20 → paces-0.0.22}/tests/test_ask.py +2 -1
- {paces-0.0.20 → paces-0.0.22}/tests/test_segment.py +138 -0
- {paces-0.0.20 → paces-0.0.22}/.gitignore +0 -0
- {paces-0.0.20 → paces-0.0.22}/LICENSE +0 -0
- {paces-0.0.20 → paces-0.0.22}/README.md +0 -0
- {paces-0.0.20 → paces-0.0.22}/docs/README.md +0 -0
- {paces-0.0.20 → paces-0.0.22}/docs/alignment/README.md +0 -0
- {paces-0.0.20 → paces-0.0.22}/docs/poc-reference/README.md +0 -0
- {paces-0.0.20 → paces-0.0.22}/docs/poc-reference/artifacts/clips.json +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/__init__.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/__main__.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/ask.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/bodies/__init__.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/bodies/_base.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/bodies/derivation.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/bodies/document.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/bodies/signal.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/bodies/step.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/corrections.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/cues.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/data/ask/ask.css +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/data/ask/ask.html +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/data/ask/ask.js +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/derivation.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/edits.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/evidence.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/excerpts.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/pose.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/projection.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/render.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/segmentation.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/timed_text.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/paces/tools.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/audio_synth.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/data/body_schemas.json +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/data/routine.json +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/poc_fixture.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_chapters.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_cli_surface.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_corrections.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_cues.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_derivation.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_edit_protection_residuals.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_edits.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_evidence.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_evidence_schemas.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_excerpts.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_measure.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_pose.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_render_media.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_roundtrip_poc.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_smoke.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/test_vertical_slice.py +0 -0
- {paces-0.0.20 → paces-0.0.22}/tests/video_synth.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.22
|
|
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
|
|
@@ -45,6 +45,7 @@ from paces.model import MetricGrid, decimal_str, seconds_per_unit
|
|
|
45
45
|
from paces.segmenters import (
|
|
46
46
|
Capability,
|
|
47
47
|
Segmentation,
|
|
48
|
+
check_step_durations,
|
|
48
49
|
place_on_grid,
|
|
49
50
|
register,
|
|
50
51
|
)
|
|
@@ -231,7 +232,7 @@ def measure_grid(
|
|
|
231
232
|
flags += (f"tempo-unmeasured: {tempo_unmeasured_note}{suffix}",)
|
|
232
233
|
|
|
233
234
|
# Explicit beats inferred — but a disagreement is a finding, not a secret.
|
|
234
|
-
final_tempo = known_tempo
|
|
235
|
+
final_tempo = known_tempo if known_tempo is not None else measured_tempo
|
|
235
236
|
if (
|
|
236
237
|
known_tempo is not None
|
|
237
238
|
and measured_tempo is not None
|
|
@@ -243,7 +244,7 @@ def measure_grid(
|
|
|
243
244
|
f"measures {measured_tempo} — using yours; drop tempoBpm from "
|
|
244
245
|
"grid= to use the measured value",
|
|
245
246
|
)
|
|
246
|
-
final_origin = known_origin
|
|
247
|
+
final_origin = known_origin if known_origin is not None else measured_origin
|
|
247
248
|
if known_origin is None and measured_origin is not None:
|
|
248
249
|
flags += (
|
|
249
250
|
f"origin-estimated: first beat of the music region "
|
|
@@ -267,11 +268,19 @@ def measure_grid(
|
|
|
267
268
|
confidence = UNMEASURED_CONFIDENCE
|
|
268
269
|
else:
|
|
269
270
|
confidence = (
|
|
270
|
-
KNOWN_ORIGIN_CONFIDENCE
|
|
271
|
+
KNOWN_ORIGIN_CONFIDENCE
|
|
272
|
+
if known_origin is not None
|
|
273
|
+
else DFLT_MEASURED_CONFIDENCE
|
|
271
274
|
)
|
|
272
275
|
|
|
273
276
|
spu = seconds_per_unit(grid)
|
|
274
|
-
if
|
|
277
|
+
if (
|
|
278
|
+
total_units
|
|
279
|
+
and total_units > 0
|
|
280
|
+
and spu
|
|
281
|
+
and final_origin is not None
|
|
282
|
+
and region is not None
|
|
283
|
+
):
|
|
275
284
|
routine_s = total_units * spu
|
|
276
285
|
region_s = region.end - float(Fraction(final_origin))
|
|
277
286
|
evidence.update(routine_s=round(routine_s, 2), region_s=round(region_s, 2))
|
|
@@ -300,6 +309,8 @@ def _segment_grid_measured(inputs: Mapping[str, Any]) -> Segmentation:
|
|
|
300
309
|
partial: MetricGrid | None = inputs["grid"]
|
|
301
310
|
unit = partial.unit if partial is not None else DFLT_UNIT
|
|
302
311
|
subdivisions = partial.subdivisions if partial is not None else DFLT_SUBDIVISIONS
|
|
312
|
+
# bad durations fail here, before the costly measurement, not after it
|
|
313
|
+
check_step_durations(inputs["steps"], who="grid-measured")
|
|
303
314
|
total_units = sum(row["duration"] for row in inputs["steps"])
|
|
304
315
|
measurement = measure_grid(
|
|
305
316
|
inputs["media"],
|
|
@@ -345,7 +345,11 @@ def resolve(doc: StepDocument) -> dict:
|
|
|
345
345
|
"""
|
|
346
346
|
grid = doc.metric
|
|
347
347
|
spu = seconds_per_unit(grid) if grid is not None else None
|
|
348
|
-
origin =
|
|
348
|
+
origin = (
|
|
349
|
+
float(Fraction(grid.origin))
|
|
350
|
+
if grid is not None and grid.origin is not None
|
|
351
|
+
else None
|
|
352
|
+
)
|
|
349
353
|
|
|
350
354
|
def _wall(offset_units: float) -> float | None:
|
|
351
355
|
if spu is None or origin is None:
|
|
@@ -39,7 +39,7 @@ from __future__ import annotations
|
|
|
39
39
|
import importlib
|
|
40
40
|
import math
|
|
41
41
|
import time
|
|
42
|
-
from collections.abc import Mapping, Sequence
|
|
42
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
43
43
|
from dataclasses import dataclass, field
|
|
44
44
|
from pathlib import Path
|
|
45
45
|
from typing import Any, Callable
|
|
@@ -131,18 +131,31 @@ def _slugify(text: str) -> str:
|
|
|
131
131
|
return slug or "step"
|
|
132
132
|
|
|
133
133
|
|
|
134
|
-
def unique_slug_ids(
|
|
134
|
+
def unique_slug_ids(
|
|
135
|
+
names: Sequence[str], *, fallback: str = "step", taken: Iterable[str] = ()
|
|
136
|
+
) -> list[str]:
|
|
135
137
|
"""One unique, git-readable id per name; unnamed ones get ``{fallback}-{i}``.
|
|
136
138
|
|
|
139
|
+
An id already in ``taken`` (or produced earlier in this call) gets a
|
|
140
|
+
``-2``, ``-3``… suffix — the first one free. ``taken`` is only read.
|
|
141
|
+
|
|
137
142
|
>>> unique_slug_ids(["Turn", "", "Turn"], fallback="cue")
|
|
138
143
|
['turn', 'cue-2', 'turn-2']
|
|
144
|
+
>>> unique_slug_ids(["Turn", "Turn", "Turn 2"])
|
|
145
|
+
['turn', 'turn-2', 'turn-2-2']
|
|
146
|
+
>>> unique_slug_ids(["Turn"], taken={"turn"})
|
|
147
|
+
['turn-2']
|
|
139
148
|
"""
|
|
140
|
-
|
|
149
|
+
used = set(taken)
|
|
141
150
|
ids = []
|
|
142
151
|
for index, name in enumerate(names):
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
152
|
+
base = _slugify(name) if name else f"{fallback}-{index + 1}"
|
|
153
|
+
candidate, n = base, 1
|
|
154
|
+
while candidate in used:
|
|
155
|
+
n += 1
|
|
156
|
+
candidate = f"{base}-{n}"
|
|
157
|
+
used.add(candidate)
|
|
158
|
+
ids.append(candidate)
|
|
146
159
|
return ids
|
|
147
160
|
|
|
148
161
|
|
|
@@ -150,29 +163,104 @@ def _normalize_steps(steps: Sequence | None) -> list[dict]:
|
|
|
150
163
|
"""Accept names, (name, duration) pairs, or mappings; emit uniform dicts.
|
|
151
164
|
|
|
152
165
|
Uniform shape: ``{id, name, duration (float units or None), spans, children}``.
|
|
166
|
+
Ids are unique across the whole tree, as a document requires. An id the
|
|
167
|
+
caller gave is kept as given, and two equal ones are a ``ValueError``
|
|
168
|
+
naming both paths. A generated one is the name's slug, suffixed ``-2``,
|
|
169
|
+
``-3``… when taken; generation runs breadth-first (every top-level row,
|
|
170
|
+
then each level of children), so editing children never renames a parent.
|
|
171
|
+
A generated id still follows position among same-named steps — give
|
|
172
|
+
repeated steps that must keep their identity across edits explicit ids.
|
|
173
|
+
|
|
174
|
+
>>> [r["id"] for r in _normalize_steps(["Turn", "Turn", {"name": "x", "id": "turn-2"}])]
|
|
175
|
+
['turn', 'turn-3', 'turn-2']
|
|
153
176
|
"""
|
|
177
|
+
rows = _step_rows(steps, where="steps")
|
|
178
|
+
|
|
179
|
+
def _paths(rows: list[dict], where: str):
|
|
180
|
+
for i, row in enumerate(rows):
|
|
181
|
+
yield f"{where}[{i}]", row
|
|
182
|
+
yield from _paths(row["children"], f"{where}[{i}].children")
|
|
183
|
+
|
|
184
|
+
explicit: dict[str, str] = {}
|
|
185
|
+
for path, row in _paths(rows, "steps"):
|
|
186
|
+
if row["id"] is None:
|
|
187
|
+
continue
|
|
188
|
+
if row["id"] in explicit:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"duplicate step id {row['id']!r} at {explicit[row['id']]} and {path}"
|
|
191
|
+
)
|
|
192
|
+
explicit[row["id"]] = path
|
|
193
|
+
|
|
194
|
+
taken = set(explicit)
|
|
195
|
+
level = rows
|
|
196
|
+
while level:
|
|
197
|
+
pending = [row for row in level if row["id"] is None]
|
|
198
|
+
ids = unique_slug_ids(
|
|
199
|
+
[row.get("name") or "step" for row in pending], taken=taken
|
|
200
|
+
)
|
|
201
|
+
for row, step_id in zip(pending, ids):
|
|
202
|
+
row["id"] = step_id
|
|
203
|
+
taken.update(ids)
|
|
204
|
+
level = [child for row in level for child in row["children"]]
|
|
205
|
+
return rows
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _walk_rows(rows: list[dict]):
|
|
209
|
+
"""Every row of a normalised step tree, parents before children."""
|
|
210
|
+
for row in rows:
|
|
211
|
+
yield row
|
|
212
|
+
yield from _walk_rows(row["children"])
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _undated_ids(rows: list[dict]) -> list[str]:
|
|
216
|
+
"""Ids of the (nested) normalised rows that have no duration."""
|
|
217
|
+
return [row["id"] for row in _walk_rows(rows) if row["duration"] is None]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def check_step_durations(rows: list[dict], *, who: str = "place_on_grid") -> None:
|
|
221
|
+
"""Raise ``ValueError`` unless every (nested) row has a positive, finite
|
|
222
|
+
duration — the precondition for placing a step list on a grid. ``rows``
|
|
223
|
+
are normalised step rows (what :func:`segment` hands a segmenter in
|
|
224
|
+
``inputs["steps"]``), not the raw forms :func:`segment` accepts."""
|
|
225
|
+
undated = _undated_ids(rows)
|
|
226
|
+
if undated:
|
|
227
|
+
raise ValueError(f"{who} needs a duration for every step: {undated}")
|
|
228
|
+
bad = [
|
|
229
|
+
row["id"]
|
|
230
|
+
for row in _walk_rows(rows)
|
|
231
|
+
if not (math.isfinite(row["duration"]) and row["duration"] > 0)
|
|
232
|
+
]
|
|
233
|
+
if bad:
|
|
234
|
+
raise ValueError(f"{who} needs positive, finite durations: {bad}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _step_rows(steps: Sequence | None, *, where: str) -> list[dict]:
|
|
154
238
|
if steps is None:
|
|
155
239
|
return []
|
|
156
240
|
rows: list[dict] = []
|
|
157
241
|
for i, item in enumerate(steps):
|
|
242
|
+
is_sequence = isinstance(item, Sequence) and not isinstance(item, bytes)
|
|
158
243
|
if isinstance(item, str):
|
|
159
244
|
row = {"name": item}
|
|
160
245
|
elif isinstance(item, Mapping):
|
|
161
246
|
row = dict(item)
|
|
162
|
-
elif
|
|
247
|
+
elif is_sequence and len(item) == 2:
|
|
163
248
|
name, duration = item
|
|
164
249
|
row = {"name": name, "duration": duration}
|
|
165
250
|
else:
|
|
251
|
+
got = f"a {len(item)}-item sequence" if is_sequence else type(item).__name__
|
|
166
252
|
raise TypeError(
|
|
167
|
-
f"
|
|
168
|
-
f"mapping — got {
|
|
253
|
+
f"{where}[{i}]: expected a name, a (name, duration) pair, or a "
|
|
254
|
+
f"mapping — got {got}"
|
|
169
255
|
)
|
|
170
|
-
row
|
|
256
|
+
row["id"] = row.get("id")
|
|
171
257
|
row["duration"] = (
|
|
172
258
|
None if row.get("duration") is None else float(row["duration"])
|
|
173
259
|
)
|
|
174
260
|
row["spans"] = [tuple(map(float, s)) for s in row.get("spans", ())]
|
|
175
|
-
row["children"] =
|
|
261
|
+
row["children"] = _step_rows(
|
|
262
|
+
row.get("children") or row.get("steps"), where=f"{where}[{i}].children"
|
|
263
|
+
)
|
|
176
264
|
rows.append(row)
|
|
177
265
|
return rows
|
|
178
266
|
|
|
@@ -296,8 +384,8 @@ def _facts(
|
|
|
296
384
|
pass
|
|
297
385
|
if steps:
|
|
298
386
|
facts.add("steps")
|
|
299
|
-
if
|
|
300
|
-
facts.add("steps.durations")
|
|
387
|
+
if not _undated_ids(steps):
|
|
388
|
+
facts.add("steps.durations") # children too: placement needs them all
|
|
301
389
|
if any(row["spans"] for row in steps):
|
|
302
390
|
facts.add("steps.spans")
|
|
303
391
|
if boundaries:
|
|
@@ -371,6 +459,11 @@ def segment(
|
|
|
371
459
|
chapters = _normalize_chapters(metadata)
|
|
372
460
|
subtitles, subtitles_problem = _subtitle_cues(metadata)
|
|
373
461
|
problem_flags = (subtitles_problem,) if subtitles_problem else ()
|
|
462
|
+
undated = _undated_ids(step_rows)
|
|
463
|
+
if undated and len(undated) < sum(1 for _ in _walk_rows(step_rows)):
|
|
464
|
+
# durations were given but not for all: say which, so a refused or
|
|
465
|
+
# unplaced result never just asks for what the caller supplied
|
|
466
|
+
problem_flags += (f"steps-undated: {undated}",)
|
|
374
467
|
transcript_cues = normalize_timed_text(transcript)
|
|
375
468
|
# fail fast: a malformed log raises before any segmenter runs
|
|
376
469
|
correction_log = (
|
|
@@ -412,9 +505,15 @@ def segment(
|
|
|
412
505
|
raise ValueError(f"unknown segmenter {segmenter!r}; registered: {known}")
|
|
413
506
|
missing_facts = capability.needs - facts
|
|
414
507
|
if missing_facts:
|
|
508
|
+
undated_note = (
|
|
509
|
+
f"; steps without a duration: {undated}"
|
|
510
|
+
if "steps.durations" in missing_facts and undated
|
|
511
|
+
else ""
|
|
512
|
+
)
|
|
415
513
|
raise ValueError(
|
|
416
514
|
f"segmenter {capability.name!r} needs inputs you did not "
|
|
417
|
-
f"provide: {sorted(missing_facts)} (facts present:
|
|
515
|
+
f"provide: {sorted(missing_facts)} (facts present: "
|
|
516
|
+
f"{sorted(facts)}){undated_note}"
|
|
418
517
|
)
|
|
419
518
|
else:
|
|
420
519
|
candidates = [
|
|
@@ -516,11 +615,6 @@ def place_on_grid(steps: Sequence, grid: MetricGrid | Mapping) -> Segmentation:
|
|
|
516
615
|
([((1.0, 5.0),), ((5.0, 13.0),)], (1.0, 5.0, 13.0))
|
|
517
616
|
"""
|
|
518
617
|
|
|
519
|
-
def _all_rows(rows: list[dict]):
|
|
520
|
-
for row in rows:
|
|
521
|
-
yield row
|
|
522
|
-
yield from _all_rows(row["children"])
|
|
523
|
-
|
|
524
618
|
grid = _normalize_grid(grid)
|
|
525
619
|
rows = _normalize_steps(steps)
|
|
526
620
|
if grid is None:
|
|
@@ -534,16 +628,7 @@ def place_on_grid(steps: Sequence, grid: MetricGrid | Mapping) -> Segmentation:
|
|
|
534
628
|
raise ValueError(f"place_on_grid needs a grid with {' and '.join(missing)}")
|
|
535
629
|
if not rows:
|
|
536
630
|
raise ValueError("place_on_grid needs at least one step")
|
|
537
|
-
|
|
538
|
-
if undated:
|
|
539
|
-
raise ValueError(f"place_on_grid needs a duration for every step: {undated}")
|
|
540
|
-
bad = [
|
|
541
|
-
row["id"]
|
|
542
|
-
for row in _all_rows(rows)
|
|
543
|
-
if not (math.isfinite(row["duration"]) and row["duration"] > 0)
|
|
544
|
-
]
|
|
545
|
-
if bad:
|
|
546
|
-
raise ValueError(f"place_on_grid needs positive, finite durations: {bad}")
|
|
631
|
+
check_step_durations(rows)
|
|
547
632
|
spu = seconds_per_unit(grid)
|
|
548
633
|
origin = float(grid.origin)
|
|
549
634
|
|
|
@@ -456,7 +456,8 @@ def test_js_repeated_names_are_separate_rows():
|
|
|
456
456
|
"""verse/chorus/verse: each occurrence is marked on its own, and replays."""
|
|
457
457
|
names = ["verse", "chorus", "verse"]
|
|
458
458
|
proposal = propose(segment(boundaries=CUTS, steps=names))
|
|
459
|
-
|
|
459
|
+
# ids are unique now (#47), but rows stay the key: names still repeat
|
|
460
|
+
assert [s["id"] for s in proposal["steps"]] == ["verse", "chorus", "verse-2"]
|
|
460
461
|
answers = _answers_after(proposal, [{"type": "absent", "index": 2, "on": True}])
|
|
461
462
|
assert [(c["verb"], c["anchorT"]) for c in answers["corrections"]] == [
|
|
462
463
|
("absent", "25")
|
|
@@ -199,3 +199,141 @@ def test_place_on_grid_names_what_is_missing(steps, grid, match):
|
|
|
199
199
|
|
|
200
200
|
with pytest.raises(ValueError, match=match):
|
|
201
201
|
place_on_grid(steps, grid)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@pytest.mark.parametrize("origin, start", [("0", 0.0), ("0.0", 0.0), ("51.2", 51.2)])
|
|
205
|
+
def test_resolve_computes_wall_clock_from_any_origin_including_zero(origin, start):
|
|
206
|
+
"""An origin of 0 is a real origin, not a missing one (#46)."""
|
|
207
|
+
from paces.model import resolve
|
|
208
|
+
|
|
209
|
+
doc = to_document(
|
|
210
|
+
segment(steps=[("a", 1)], grid={**GRID, "origin": origin}),
|
|
211
|
+
doc_id="d",
|
|
212
|
+
title="t",
|
|
213
|
+
)
|
|
214
|
+
assert resolve(doc)["steps"][0]["start_s"] == pytest.approx(start)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_resolve_without_an_origin_has_no_wall_clock():
|
|
218
|
+
from paces.model import resolve
|
|
219
|
+
|
|
220
|
+
doc = to_document(segment(steps=[("a", 1)], grid=GRID), doc_id="d", title="t")
|
|
221
|
+
doc = doc.model_copy(
|
|
222
|
+
update={"metric": doc.metric.model_copy(update={"origin": None})}
|
|
223
|
+
)
|
|
224
|
+
assert resolve(doc)["steps"][0]["start_s"] is None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_resolve_treats_a_numeric_zero_origin_as_an_origin():
|
|
228
|
+
"""A grid updated in place skips wire validation; 0 must still be 0 (#46)."""
|
|
229
|
+
from paces.model import resolve
|
|
230
|
+
|
|
231
|
+
doc = to_document(segment(steps=[("a", 1)], grid=GRID), doc_id="d", title="t")
|
|
232
|
+
doc = doc.model_copy(update={"metric": doc.metric.model_copy(update={"origin": 0})})
|
|
233
|
+
assert resolve(doc)["steps"][0]["start_s"] == 0.0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_duplicate_step_names_get_unique_ids_tree_wide():
|
|
237
|
+
"""Edits, locks and the evidence layer address steps by id (#47)."""
|
|
238
|
+
steps = [
|
|
239
|
+
("Turn", 2),
|
|
240
|
+
("Turn", 2),
|
|
241
|
+
{"name": "Kick", "duration": 4, "children": [("Turn", 2), ("Turn", 2)]},
|
|
242
|
+
]
|
|
243
|
+
seg = segment(steps=steps, grid=GRID)
|
|
244
|
+
ids = [seg.steps[0].id, seg.steps[1].id, *(c.id for c in seg.steps[2].children)]
|
|
245
|
+
assert ids == ["turn", "turn-2", "turn-3", "turn-4"]
|
|
246
|
+
doc = to_document(seg, doc_id="d", title="t")
|
|
247
|
+
from paces.model import validate_document
|
|
248
|
+
|
|
249
|
+
assert not [i for i in validate_document(doc) if "duplicate step id" in i]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_an_explicit_step_id_is_kept_and_never_reused():
|
|
253
|
+
seg = segment(
|
|
254
|
+
steps=[("Turn", 1), {"name": "x", "id": "turn", "duration": 1}], grid=GRID
|
|
255
|
+
)
|
|
256
|
+
assert [s.id for s in seg.steps] == ["turn-2", "turn"]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_a_step_that_is_neither_name_pair_nor_mapping_is_named_in_the_error():
|
|
260
|
+
with pytest.raises(TypeError, match=r"steps\[1\].*3-item sequence"):
|
|
261
|
+
segment(steps=[("a", 1), ("b", 1, "extra")], grid=GRID)
|
|
262
|
+
with pytest.raises(TypeError, match=r"steps\[0\]\.children\[0\]"):
|
|
263
|
+
segment(steps=[{"name": "p", "duration": 1, "children": [3]}], grid=GRID)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_bytes_is_not_a_name_duration_pair():
|
|
267
|
+
with pytest.raises(TypeError, match=r"steps\[0\].*got bytes"):
|
|
268
|
+
segment(steps=[b"ab"], grid=GRID)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_duplicate_explicit_ids_name_both_paths():
|
|
272
|
+
steps = [
|
|
273
|
+
{"name": "a", "id": "x", "duration": 1},
|
|
274
|
+
("b", 1),
|
|
275
|
+
{"name": "c", "duration": 2, "children": [("d", 1), {"name": "e", "id": "x"}]},
|
|
276
|
+
]
|
|
277
|
+
with pytest.raises(
|
|
278
|
+
ValueError, match=r"'x' at steps\[0\] and steps\[2\]\.children\[1\]"
|
|
279
|
+
):
|
|
280
|
+
segment(steps=steps, grid=GRID)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_generated_ids_are_breadth_first_so_children_never_rename_parents():
|
|
284
|
+
before = segment(steps=[{"name": "Turn", "duration": 2}, ("Turn", 2)], grid=GRID)
|
|
285
|
+
after = segment(
|
|
286
|
+
steps=[{"name": "Turn", "duration": 2, "children": [("Turn", 2)]}, ("Turn", 2)],
|
|
287
|
+
grid=GRID,
|
|
288
|
+
)
|
|
289
|
+
assert [s.id for s in before.steps] == [s.id for s in after.steps]
|
|
290
|
+
assert [s.id for s in after.steps] == ["turn", "turn-2"]
|
|
291
|
+
assert after.steps[0].children[0].id == "turn-3"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_partly_undated_steps_are_named_in_a_flag():
|
|
295
|
+
"""The refused state's 'try:' must not read as if no durations were given."""
|
|
296
|
+
steps = [("a", 2), {"name": "p", "duration": 2, "children": ["u"]}]
|
|
297
|
+
seg = segment(steps=steps, grid=GRID)
|
|
298
|
+
assert "steps-undated: ['u']" in seg.flags
|
|
299
|
+
assert not [f for f in segment(steps=["a"], grid=GRID).flags if "undated" in f]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _forbid_measuring(monkeypatch):
|
|
303
|
+
import paces.measure
|
|
304
|
+
|
|
305
|
+
def _measure_grid(*args, **kwargs):
|
|
306
|
+
raise AssertionError("measure_grid must not be called")
|
|
307
|
+
|
|
308
|
+
monkeypatch.setattr(paces.measure, "measure_grid", _measure_grid)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_an_undated_child_keeps_placement_from_being_selected(tmp_path, monkeypatch):
|
|
312
|
+
"""'steps.durations' covers children, so no placing segmenter is chosen —
|
|
313
|
+
and grid-measured is never asked to measure for a list it cannot place."""
|
|
314
|
+
steps = [{"name": "p", "duration": 2, "children": ["undated"]}]
|
|
315
|
+
seg = segment(steps=steps, grid=GRID)
|
|
316
|
+
assert seg.method != "grid-placed"
|
|
317
|
+
media = tmp_path / "song.wav"
|
|
318
|
+
media.write_bytes(b"")
|
|
319
|
+
_forbid_measuring(monkeypatch)
|
|
320
|
+
with pytest.raises(
|
|
321
|
+
ValueError,
|
|
322
|
+
match=r"needs inputs you did not provide: \['steps\.durations'\]"
|
|
323
|
+
r".*steps without a duration: \['undated'\]",
|
|
324
|
+
):
|
|
325
|
+
segment(str(media), steps=steps, segmenter="grid-measured")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@pytest.mark.parametrize("bad", [0, -1, float("nan")])
|
|
329
|
+
def test_grid_measured_rejects_bad_durations_before_measuring(
|
|
330
|
+
bad, tmp_path, monkeypatch
|
|
331
|
+
):
|
|
332
|
+
pytest.importorskip("mixing")
|
|
333
|
+
pytest.importorskip("librosa")
|
|
334
|
+
media = tmp_path / "song.wav"
|
|
335
|
+
media.write_bytes(b"")
|
|
336
|
+
_forbid_measuring(monkeypatch)
|
|
337
|
+
steps = [("a", 1), {"name": "p", "duration": 2, "children": [("c", bad)]}]
|
|
338
|
+
with pytest.raises(ValueError, match=r"positive, finite durations: \['c'\]"):
|
|
339
|
+
segment(str(media), steps=steps, segmenter="grid-measured")
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|