paces 0.0.21__tar.gz → 0.0.23__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.
Files changed (59) hide show
  1. {paces-0.0.21 → paces-0.0.23}/PKG-INFO +1 -1
  2. {paces-0.0.21 → paces-0.0.23}/paces/measure.py +3 -0
  3. {paces-0.0.21 → paces-0.0.23}/paces/segmenters.py +116 -31
  4. {paces-0.0.21 → paces-0.0.23}/pyproject.toml +1 -1
  5. {paces-0.0.21 → paces-0.0.23}/tests/test_ask.py +2 -1
  6. {paces-0.0.21 → paces-0.0.23}/tests/test_segment.py +136 -1
  7. {paces-0.0.21 → paces-0.0.23}/.gitignore +0 -0
  8. {paces-0.0.21 → paces-0.0.23}/LICENSE +0 -0
  9. {paces-0.0.21 → paces-0.0.23}/README.md +0 -0
  10. {paces-0.0.21 → paces-0.0.23}/docs/README.md +0 -0
  11. {paces-0.0.21 → paces-0.0.23}/docs/alignment/README.md +0 -0
  12. {paces-0.0.21 → paces-0.0.23}/docs/poc-reference/README.md +0 -0
  13. {paces-0.0.21 → paces-0.0.23}/docs/poc-reference/artifacts/clips.json +0 -0
  14. {paces-0.0.21 → paces-0.0.23}/paces/__init__.py +0 -0
  15. {paces-0.0.21 → paces-0.0.23}/paces/__main__.py +0 -0
  16. {paces-0.0.21 → paces-0.0.23}/paces/ask.py +0 -0
  17. {paces-0.0.21 → paces-0.0.23}/paces/bodies/__init__.py +0 -0
  18. {paces-0.0.21 → paces-0.0.23}/paces/bodies/_base.py +0 -0
  19. {paces-0.0.21 → paces-0.0.23}/paces/bodies/derivation.py +0 -0
  20. {paces-0.0.21 → paces-0.0.23}/paces/bodies/document.py +0 -0
  21. {paces-0.0.21 → paces-0.0.23}/paces/bodies/signal.py +0 -0
  22. {paces-0.0.21 → paces-0.0.23}/paces/bodies/step.py +0 -0
  23. {paces-0.0.21 → paces-0.0.23}/paces/corrections.py +0 -0
  24. {paces-0.0.21 → paces-0.0.23}/paces/cues.py +0 -0
  25. {paces-0.0.21 → paces-0.0.23}/paces/data/ask/ask.css +0 -0
  26. {paces-0.0.21 → paces-0.0.23}/paces/data/ask/ask.html +0 -0
  27. {paces-0.0.21 → paces-0.0.23}/paces/data/ask/ask.js +0 -0
  28. {paces-0.0.21 → paces-0.0.23}/paces/derivation.py +0 -0
  29. {paces-0.0.21 → paces-0.0.23}/paces/edits.py +0 -0
  30. {paces-0.0.21 → paces-0.0.23}/paces/evidence.py +0 -0
  31. {paces-0.0.21 → paces-0.0.23}/paces/excerpts.py +0 -0
  32. {paces-0.0.21 → paces-0.0.23}/paces/model.py +0 -0
  33. {paces-0.0.21 → paces-0.0.23}/paces/pose.py +0 -0
  34. {paces-0.0.21 → paces-0.0.23}/paces/projection.py +0 -0
  35. {paces-0.0.21 → paces-0.0.23}/paces/render.py +0 -0
  36. {paces-0.0.21 → paces-0.0.23}/paces/segmentation.py +0 -0
  37. {paces-0.0.21 → paces-0.0.23}/paces/timed_text.py +0 -0
  38. {paces-0.0.21 → paces-0.0.23}/paces/tools.py +0 -0
  39. {paces-0.0.21 → paces-0.0.23}/tests/audio_synth.py +0 -0
  40. {paces-0.0.21 → paces-0.0.23}/tests/data/body_schemas.json +0 -0
  41. {paces-0.0.21 → paces-0.0.23}/tests/data/routine.json +0 -0
  42. {paces-0.0.21 → paces-0.0.23}/tests/poc_fixture.py +0 -0
  43. {paces-0.0.21 → paces-0.0.23}/tests/test_chapters.py +0 -0
  44. {paces-0.0.21 → paces-0.0.23}/tests/test_cli_surface.py +0 -0
  45. {paces-0.0.21 → paces-0.0.23}/tests/test_corrections.py +0 -0
  46. {paces-0.0.21 → paces-0.0.23}/tests/test_cues.py +0 -0
  47. {paces-0.0.21 → paces-0.0.23}/tests/test_derivation.py +0 -0
  48. {paces-0.0.21 → paces-0.0.23}/tests/test_edit_protection_residuals.py +0 -0
  49. {paces-0.0.21 → paces-0.0.23}/tests/test_edits.py +0 -0
  50. {paces-0.0.21 → paces-0.0.23}/tests/test_evidence.py +0 -0
  51. {paces-0.0.21 → paces-0.0.23}/tests/test_evidence_schemas.py +0 -0
  52. {paces-0.0.21 → paces-0.0.23}/tests/test_excerpts.py +0 -0
  53. {paces-0.0.21 → paces-0.0.23}/tests/test_measure.py +0 -0
  54. {paces-0.0.21 → paces-0.0.23}/tests/test_pose.py +0 -0
  55. {paces-0.0.21 → paces-0.0.23}/tests/test_render_media.py +0 -0
  56. {paces-0.0.21 → paces-0.0.23}/tests/test_roundtrip_poc.py +0 -0
  57. {paces-0.0.21 → paces-0.0.23}/tests/test_smoke.py +0 -0
  58. {paces-0.0.21 → paces-0.0.23}/tests/test_vertical_slice.py +0 -0
  59. {paces-0.0.21 → paces-0.0.23}/tests/video_synth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paces
3
- Version: 0.0.21
3
+ Version: 0.0.23
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
  )
@@ -308,6 +309,8 @@ def _segment_grid_measured(inputs: Mapping[str, Any]) -> Segmentation:
308
309
  partial: MetricGrid | None = inputs["grid"]
309
310
  unit = partial.unit if partial is not None else DFLT_UNIT
310
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")
311
314
  total_units = sum(row["duration"] for row in inputs["steps"])
312
315
  measurement = measure_grid(
313
316
  inputs["media"],
@@ -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(names: Sequence[str], *, fallback: str = "step") -> list[str]:
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
- seen: dict[str, int] = {}
149
+ used = set(taken)
141
150
  ids = []
142
151
  for index, name in enumerate(names):
143
- slug = _slugify(name) if name else f"{fallback}-{index + 1}"
144
- seen[slug] = seen.get(slug, 0) + 1
145
- ids.append(slug if seen[slug] == 1 else f"{slug}-{seen[slug]}")
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 isinstance(item, Sequence):
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"steps[{i}]: expected a name, a (name, duration) pair, or a "
168
- f"mapping — got {type(item).__name__}"
253
+ f"{where}[{i}]: expected a name, a (name, duration) pair, or a "
254
+ f"mapping — got {got}"
169
255
  )
170
- row.setdefault("id", _slugify(row.get("name", "")) or f"step-{i + 1}")
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"] = _normalize_steps(row.get("children") or row.get("steps"))
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,10 +384,10 @@ def _facts(
296
384
  pass
297
385
  if steps:
298
386
  facts.add("steps")
299
- if all(row["duration"] is not None for row in steps):
300
- facts.add("steps.durations")
301
- if any(row["spans"] for row in steps):
302
- facts.add("steps.spans")
387
+ if not _undated_ids(steps):
388
+ facts.add("steps.durations") # children too: placement needs them all
389
+ if any(row["spans"] for row in _walk_rows(steps)):
390
+ facts.add("steps.spans") # like steps.durations: the whole tree
303
391
  if boundaries:
304
392
  facts.add("boundaries")
305
393
  if grid is not None and grid.tempo_bpm is not None and grid.origin is not None:
@@ -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: {sorted(facts)})"
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
- undated = [row["id"] for row in _all_rows(rows) if row["duration"] is None]
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
 
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "paces"
9
- version = "0.0.21"
9
+ version = "0.0.23"
10
10
  description = "Turn instructional media into structured, interactive learning material"
11
11
  readme = "README.md"
12
12
  license = "MIT"
@@ -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
- assert [s["id"] for s in proposal["steps"]] == names # ids repeat
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")
@@ -8,7 +8,13 @@ import pytest
8
8
 
9
9
  from paces.model import MetricGrid
10
10
  from paces.projection import to_document
11
- from paces.segmenters import Capability, capabilities, register, segment
11
+ from paces.segmenters import (
12
+ Capability,
13
+ Segmentation,
14
+ capabilities,
15
+ register,
16
+ segment,
17
+ )
12
18
 
13
19
  GRID = {"unit": "eight", "subdivisions": 8, "tempoBpm": "129.2", "origin": "51.2"}
14
20
  #: The POC routine as (name, eights) pairs — kickoff's "common real case".
@@ -231,3 +237,132 @@ def test_resolve_treats_a_numeric_zero_origin_as_an_origin():
231
237
  doc = to_document(segment(steps=[("a", 1)], grid=GRID), doc_id="d", title="t")
232
238
  doc = doc.model_copy(update={"metric": doc.metric.model_copy(update={"origin": 0})})
233
239
  assert resolve(doc)["steps"][0]["start_s"] == 0.0
240
+
241
+
242
+ def test_duplicate_step_names_get_unique_ids_tree_wide():
243
+ """Edits, locks and the evidence layer address steps by id (#47)."""
244
+ steps = [
245
+ ("Turn", 2),
246
+ ("Turn", 2),
247
+ {"name": "Kick", "duration": 4, "children": [("Turn", 2), ("Turn", 2)]},
248
+ ]
249
+ seg = segment(steps=steps, grid=GRID)
250
+ ids = [seg.steps[0].id, seg.steps[1].id, *(c.id for c in seg.steps[2].children)]
251
+ assert ids == ["turn", "turn-2", "turn-3", "turn-4"]
252
+ doc = to_document(seg, doc_id="d", title="t")
253
+ from paces.model import validate_document
254
+
255
+ assert not [i for i in validate_document(doc) if "duplicate step id" in i]
256
+
257
+
258
+ def test_an_explicit_step_id_is_kept_and_never_reused():
259
+ seg = segment(
260
+ steps=[("Turn", 1), {"name": "x", "id": "turn", "duration": 1}], grid=GRID
261
+ )
262
+ assert [s.id for s in seg.steps] == ["turn-2", "turn"]
263
+
264
+
265
+ def test_a_step_that_is_neither_name_pair_nor_mapping_is_named_in_the_error():
266
+ with pytest.raises(TypeError, match=r"steps\[1\].*3-item sequence"):
267
+ segment(steps=[("a", 1), ("b", 1, "extra")], grid=GRID)
268
+ with pytest.raises(TypeError, match=r"steps\[0\]\.children\[0\]"):
269
+ segment(steps=[{"name": "p", "duration": 1, "children": [3]}], grid=GRID)
270
+
271
+
272
+ def test_bytes_is_not_a_name_duration_pair():
273
+ with pytest.raises(TypeError, match=r"steps\[0\].*got bytes"):
274
+ segment(steps=[b"ab"], grid=GRID)
275
+
276
+
277
+ def test_duplicate_explicit_ids_name_both_paths():
278
+ steps = [
279
+ {"name": "a", "id": "x", "duration": 1},
280
+ ("b", 1),
281
+ {"name": "c", "duration": 2, "children": [("d", 1), {"name": "e", "id": "x"}]},
282
+ ]
283
+ with pytest.raises(
284
+ ValueError, match=r"'x' at steps\[0\] and steps\[2\]\.children\[1\]"
285
+ ):
286
+ segment(steps=steps, grid=GRID)
287
+
288
+
289
+ def test_generated_ids_are_breadth_first_so_children_never_rename_parents():
290
+ before = segment(steps=[{"name": "Turn", "duration": 2}, ("Turn", 2)], grid=GRID)
291
+ after = segment(
292
+ steps=[{"name": "Turn", "duration": 2, "children": [("Turn", 2)]}, ("Turn", 2)],
293
+ grid=GRID,
294
+ )
295
+ assert [s.id for s in before.steps] == [s.id for s in after.steps]
296
+ assert [s.id for s in after.steps] == ["turn", "turn-2"]
297
+ assert after.steps[0].children[0].id == "turn-3"
298
+
299
+
300
+ def test_partly_undated_steps_are_named_in_a_flag():
301
+ """The refused state's 'try:' must not read as if no durations were given."""
302
+ steps = [("a", 2), {"name": "p", "duration": 2, "children": ["u"]}]
303
+ seg = segment(steps=steps, grid=GRID)
304
+ assert "steps-undated: ['u']" in seg.flags
305
+ assert not [f for f in segment(steps=["a"], grid=GRID).flags if "undated" in f]
306
+
307
+
308
+ def _forbid_measuring(monkeypatch):
309
+ import paces.measure
310
+
311
+ def _measure_grid(*args, **kwargs):
312
+ raise AssertionError("measure_grid must not be called")
313
+
314
+ monkeypatch.setattr(paces.measure, "measure_grid", _measure_grid)
315
+
316
+
317
+ def test_an_undated_child_keeps_placement_from_being_selected(tmp_path, monkeypatch):
318
+ """'steps.durations' covers children, so no placing segmenter is chosen —
319
+ and grid-measured is never asked to measure for a list it cannot place."""
320
+ steps = [{"name": "p", "duration": 2, "children": ["undated"]}]
321
+ seg = segment(steps=steps, grid=GRID)
322
+ assert seg.method != "grid-placed"
323
+ media = tmp_path / "song.wav"
324
+ media.write_bytes(b"")
325
+ _forbid_measuring(monkeypatch)
326
+ with pytest.raises(
327
+ ValueError,
328
+ match=r"needs inputs you did not provide: \['steps\.durations'\]"
329
+ r".*steps without a duration: \['undated'\]",
330
+ ):
331
+ segment(str(media), steps=steps, segmenter="grid-measured")
332
+
333
+
334
+ @pytest.mark.parametrize("bad", [0, -1, float("nan")])
335
+ def test_grid_measured_rejects_bad_durations_before_measuring(
336
+ bad, tmp_path, monkeypatch
337
+ ):
338
+ pytest.importorskip("mixing")
339
+ pytest.importorskip("librosa")
340
+ media = tmp_path / "song.wav"
341
+ media.write_bytes(b"")
342
+ _forbid_measuring(monkeypatch)
343
+ steps = [("a", 1), {"name": "p", "duration": 2, "children": [("c", bad)]}]
344
+ with pytest.raises(ValueError, match=r"positive, finite durations: \['c'\]"):
345
+ segment(str(media), steps=steps, segmenter="grid-measured")
346
+
347
+
348
+ def _spans_reader(inputs):
349
+ return Segmentation(boundaries=(0.0, 1.0))
350
+
351
+
352
+ def test_the_spans_fact_reads_the_whole_step_tree():
353
+ """A span on a child counts, as a duration on a child does (#51): a
354
+ segmenter that needs ``steps.spans`` is selectable from a nested span."""
355
+ needs_spans = Capability(
356
+ name="needs-spans",
357
+ gives="segmentation",
358
+ summary="test double",
359
+ target=f"{__name__}:_spans_reader",
360
+ needs=frozenset({"steps", "steps.spans"}),
361
+ )
362
+ catalog = {"needs-spans": needs_spans}
363
+ nested = [
364
+ {"name": "p", "duration": 2, "children": [{"name": "c", "spans": [(1, 2)]}]}
365
+ ]
366
+ assert segment(steps=nested, catalog=catalog).method == "needs-spans"
367
+ refused = segment(steps=[("p", 2)], catalog=catalog)
368
+ assert refused.method == "" and "no-signal" in refused.flags
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