From 990795d80112be336bd2dc161c14b90f3ce96d9d Mon Sep 17 00:00:00 2001 From: Joe Amditis <6799804+jamditis@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:38:43 -0400 Subject: [PATCH 1/2] Add prep_wake_samples --clean to clear a re-recorded take's orphan clips (#8) Re-recording a take with fewer utterances and re-running prep into a populated --output dir left the previous run's higher-numbered clips on disk. Training and eval glob every clip in the directory, so those orphans were silently folded into the next run's data while process() reported only the count it had just written. prep already detected the leftovers and warned; --clean now removes them in the same run so the reported count matches what is on disk. Deletion is scoped to the current run's own stems -- a "_NNN.wav" clip whose stem this run wrote -- so a source recording, an unrelated take's clips, or hand-curated audio in the directory is named but never auto-deleted. A stray --output can't wipe files prep did not create, and without --clean the warn-and-name behavior is unchanged. The stem match is a greedy "(.+)_\d{3,}\.wav" so an orphan pairs back to its disambiguated collider stem (computah-1_002.wav -> computah-1, not computah) and the >999-segment case (:03d is a minimum width, so index 1000 is take_1000.wav) still parses. Tests cover the re-run-with-fewer-clips case with and without --clean, that --clean spares files this run did not record, and disambiguated-stem orphan cleanup. Orphans of a stem the current run does not write -- a dropped input, or a collider whose disambiguated stem changed between runs -- stay warn-only, since prep can't tell them from data the user curated on purpose. A manifest would let --clean reach those safely; filed as #84. Refs #8 wake-20260723T1005-20d30f --- CHANGELOG.md | 10 +++ docs/wake-threshold-tuning.md | 13 ++++ prep_wake_samples.py | 80 +++++++++++++++++++---- test_prep_wake_samples.py | 120 ++++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d202a8f..6072ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ All notable changes to computah are recorded here. The format follows ## [Unreleased] ### Added +- `prep_wake_samples.py --clean` (#8): removes leftover clips of a take the run + just re-recorded (a `_NNN.wav` clip whose stem this run wrote) from + `--output`, so re-recording with fewer utterances no longer strands orphaned + higher-numbered clips that training and eval would still read. Off by default + (deletion is opt-in) and deliberately narrow: a source recording, an unrelated + take's clips, or a hand-curated clip whose stem this run did not write is named + but never auto-deleted. Without `--clean` the run still warns and names the + leftover count. `test_prep_wake_samples.py` covers the re-run-with-fewer-clips + case with and without `--clean`, and that `--clean` spares files this run did + not record. - Configurable request endpointing (#15): the trailing-silence window that ends a captured request and the max-request cap that bounds a runaway are now config keys (`endpoint_silence_ms`, `max_request_ms`, milliseconds) instead of fixed constants, diff --git a/docs/wake-threshold-tuning.md b/docs/wake-threshold-tuning.md index 181f8af..5b624a8 100644 --- a/docs/wake-threshold-tuning.md +++ b/docs/wake-threshold-tuning.md @@ -30,6 +30,19 @@ and background takes, then split them into clips with `prep_wake_samples.py`: `samples/` is gitignored: these are personal voice recordings and never go in the repo. The evaluation script below is committed; the audio it reads is not. +Re-running prep into a populated `samples/` dir refreshes it in place. If a +re-recording yields fewer clips than last time, the extra clips from the old run +would linger and the training globs would still read them, so prep warns and +names the count. Pass `--clean` to remove those leftovers in the same run. It is +deliberately narrow: it only deletes leftover clips of a take this run +re-recorded (a `_NNN.wav` clip whose stem the run wrote), so a source +recording or a hand-curated clip for a different stem is named but left in place. + +```bash +.venv/bin/python prep_wake_samples.py --input computah_normal.wav \ + --output samples/positive --label positive --clean +``` + ## 2. Run the sweep ```bash diff --git a/prep_wake_samples.py b/prep_wake_samples.py index e99bf77..1312f5c 100644 --- a/prep_wake_samples.py +++ b/prep_wake_samples.py @@ -24,6 +24,7 @@ from __future__ import annotations import argparse +import re import shutil import subprocess import sys @@ -38,6 +39,11 @@ TARGET_SR = 16000 AUDIO_EXTS = {".wav", ".flac", ".ogg", ".m4a", ".mp3", ".aac", ".mp4", ".wma"} +# prep writes each clip as "_NNN.wav" (see _write_unique; NNN is >=3 digits, +# since :03d is a minimum width). --clean pairs this shape with the current run's +# stems so it only removes a re-recorded take's own leftover clips, never a source +# take, an unrelated take's clips, or hand-added audio a user left in the output dir. +_CLIP_NAME = re.compile(r"(.+)_\d{3,}\.wav$") # --------------------------------------------------------------------------- # @@ -309,6 +315,20 @@ def _stale_clips(out_dir: Path, written: set[Path]) -> list[Path]: ) +def _summarize(paths: list[Path], limit: int = 3) -> str: + """A short "a, b, c, +N more" list of clip names for a one-line status.""" + shown = ", ".join(p.name for p in paths[:limit]) + if len(paths) > limit: + shown += f", +{len(paths) - limit} more" + return shown + + +def _clip_stem(name: str) -> str | None: + """The stem of a "_NNN.wav" clip name, or None if it isn't one.""" + m = _CLIP_NAME.fullmatch(name) + return m.group(1) if m else None + + def process( inputs: list[Path], out_dir: Path, @@ -316,8 +336,16 @@ def process( min_gap_s: float, min_dur_s: float, max_dur_s: float, + clean: bool = False, ) -> int: - """Process every input file; return the total clip count written.""" + """Process every input file; return the total clip count written. + + With ``clean`` set, a re-recorded take's leftover clips (a prior run's + now-unused higher-numbered clips for a stem this run wrote) are removed after + this run writes, so its own count matches what is on disk. It stays narrow on + purpose: an unrelated take's clips, a source recording, or hand-added audio is + named but never deleted, so a stray ``--output`` never silently wipes files. + """ # Checked before any audio is decoded, so an occupied --output is named once # rather than surfacing as whichever exception the run happens to reach first: # mkdir() raises FileExistsError once a clip is ready to write, and a run that @@ -363,21 +391,41 @@ def process( total = len(written) print(f"\nwrote {total} {label} clip(s) to {out_dir}") - # Clips this run did not write are left from an earlier run over a different + # Files this run did not write are left from an earlier run over a different # input set. Training and eval read every clip in the directory, so they - # would score as extra data nobody asked for. Deleting them here would be - # too eager (the dir is the user's), so say so and name the fix. + # would score as extra data nobody asked for. --clean removes the leftover + # clips of a take this run just re-recorded (same stem, now-unused index), so + # the reported count matches what is on disk. It is deliberately narrow: a + # clip whose stem this run did not write -- an unrelated take, a source + # recording, hand-added audio -- is named but never auto-deleted, because it + # is indistinguishable from data the user curated on purpose. + run_stems = set(stems) stale = _stale_clips(out_dir, written) if stale: - shown = ", ".join(p.name for p in stale[:3]) - if len(stale) > 3: - shown += f", +{len(stale) - 3} more" - print( - f"warning: {len(stale)} clip(s) in {out_dir} are left from an earlier " - f"run ({shown}). Training and eval read every clip in this directory, " - f"so delete them or clear {out_dir} and rerun.", - file=sys.stderr, + removable = ( + [p for p in stale if _clip_stem(p.name) in run_stems] if clean else [] ) + for p in removable: + p.unlink() + if removable: + print( + f"removed {len(removable)} clip(s) left from an earlier run " + f"({_summarize(removable)})" + ) + lingering = [p for p in stale if p not in removable] + if lingering: + fix = ( + "these are not clips this run re-recorded, so --clean leaves them; " + "remove them by hand" + if clean + else "rerun with --clean to remove them, or clear the directory by hand" + ) + print( + f"warning: {len(lingering)} file(s) in {out_dir} are left from an " + f"earlier run ({_summarize(lingering)}). Training and eval read " + f"every clip in this directory, so {fix}.", + file=sys.stderr, + ) if all_durs: a = np.array(all_durs) print( @@ -416,6 +464,13 @@ def main() -> int: p.add_argument( "--max-dur", type=float, default=3.0, help="drop segments longer than this (s)" ) + p.add_argument( + "--clean", + action="store_true", + help="remove a re-recorded take's leftover clips in --output (a prior run's " + "now-unused higher-numbered clips for a take in this run); leaves unrelated " + "or hand-added audio in place", + ) args = p.parse_args() total = process( @@ -425,6 +480,7 @@ def main() -> int: args.min_gap, args.min_dur, args.max_dur, + clean=args.clean, ) return 0 if total > 0 else 1 diff --git a/test_prep_wake_samples.py b/test_prep_wake_samples.py index d852d31..262ad17 100644 --- a/test_prep_wake_samples.py +++ b/test_prep_wake_samples.py @@ -258,6 +258,123 @@ def test_rerun_refreshes_populated_dir(d: Path) -> None: ) +def test_rerun_with_fewer_clips_orphans_then_clean(d: Path) -> None: + """Issue #8: re-recording with fewer utterances must not silently keep orphans. + + The first run writes five clips. Re-recording the same take with two + utterances and rerunning overwrites take_000/_001 but strands take_002..004. + Without --clean those orphans linger (the warn path, so training would read + them); with clean=True they are removed and the returned count equals the + files on disk -- the issue's acceptance criteria. + """ + src = d / "fewer_src" + src.mkdir() + take = src / "take.wav" + sf.write( + take, make_bursts(5, 0.5, 0.8, prep.TARGET_SR), prep.TARGET_SR, subtype="PCM_16" + ) + out = d / "fewer_out" + first = prep.process([take], out, "positive", 0.3, 0.2, 3.0) + check(first == 5, f"first run writes five clips (first={first})") + + # Re-record the same file with fewer utterances, then rerun into the same dir. + sf.write( + take, make_bursts(2, 0.5, 0.8, prep.TARGET_SR), prep.TARGET_SR, subtype="PCM_16" + ) + without = prep.process([take], out, "positive", 0.3, 0.2, 3.0) + present = sorted(p.name for p in out.glob("*.wav")) + check( + without == 2 and len(present) == 5, + f"without --clean the three orphans linger (wrote={without}, files={present})", + ) + + cleaned = prep.process([take], out, "positive", 0.3, 0.2, 3.0, clean=True) + present = sorted(p.name for p in out.glob("*.wav")) + check( + cleaned == 2 and present == ["take_000.wav", "take_001.wav"], + f"--clean removes the orphans so the count matches files on disk " + f"(wrote={cleaned}, files={present})", + ) + + +def test_clean_spares_files_this_run_did_not_record(d: Path) -> None: + """--clean removes only the re-recorded take's own orphan clips. A source + take, and a hand-curated clip that happens to match "_NNN.wav" but + whose stem this run did not write, are both left in place. + """ + src = d / "spare_src" + src.mkdir() + take = src / "take.wav" + sf.write( + take, make_bursts(3, 0.5, 0.8, prep.TARGET_SR), prep.TARGET_SR, subtype="PCM_16" + ) + out = d / "spare_out" + prep.process([take], out, "positive", 0.3, 0.2, 3.0) # take_000.._002 + + # A raw recording (no clip shape) and a hand-curated clip whose stem this run + # never writes -- both must survive --clean. + for name in ("my_recording.wav", "custom_001.wav"): + sf.write( + out / name, + make_bursts(1, 0.5, 0.8, prep.TARGET_SR), + prep.TARGET_SR, + subtype="PCM_16", + ) + + # Re-record with fewer utterances and rerun with --clean: only the take_* + # orphan is removed; the source take and the foreign clip are untouched. + sf.write( + take, make_bursts(2, 0.5, 0.8, prep.TARGET_SR), prep.TARGET_SR, subtype="PCM_16" + ) + prep.process([take], out, "positive", 0.3, 0.2, 3.0, clean=True) + present = sorted(p.name for p in out.glob("*.wav")) + check( + "my_recording.wav" in present + and "custom_001.wav" in present + and "take_002.wav" not in present, + f"--clean removes only the re-recorded take's orphans ({present})", + ) + + +def test_clean_removes_disambiguated_stem_orphans(d: Path) -> None: + """--clean must clean orphans of a *disambiguated* stem too (#7 x #8). + + Two inputs sharing a basename get stems "computah" and "computah-1". The + greedy match in _CLIP_NAME is load-bearing here: an orphan + "computah-1_002.wav" has to pair back to the run stem "computah-1", not + "computah", or --clean would strand it for training to read. + """ + a = d / "disambig_a" + b = d / "disambig_b" + a.mkdir() + b.mkdir() + for dir_ in (a, b): + sf.write( + dir_ / "computah.wav", + make_bursts(3, 0.5, 0.8, prep.TARGET_SR), + prep.TARGET_SR, + subtype="PCM_16", + ) + out = d / "disambig_out" + inputs = [a / "computah.wav", b / "computah.wav"] + prep.process(inputs, out, "positive", 0.3, 0.2, 3.0) # computah_* + computah-1_*, 6 + + # Re-record both takes shorter, then rerun with --clean. + for dir_ in (a, b): + sf.write( + dir_ / "computah.wav", + make_bursts(1, 0.5, 0.8, prep.TARGET_SR), + prep.TARGET_SR, + subtype="PCM_16", + ) + prep.process(inputs, out, "positive", 0.3, 0.2, 3.0, clean=True) + present = sorted(p.name for p in out.glob("*.wav")) + check( + present == ["computah-1_000.wav", "computah_000.wav"], + f"--clean clears orphans of both the bare and disambiguated stems ({present})", + ) + + def test_in_run_collision_raises(d: Path) -> None: """Two clips in one run mapping to the same file must fail loud. @@ -387,6 +504,9 @@ def main() -> int: test_output_path_not_a_directory(d) test_duplicate_basename_no_overwrite(d) test_rerun_refreshes_populated_dir(d) + test_rerun_with_fewer_clips_orphans_then_clean(d) + test_clean_spares_files_this_run_did_not_record(d) + test_clean_removes_disambiguated_stem_orphans(d) test_in_run_collision_raises(d) test_refresh_after_adding_collider_leaves_no_orphans(d) test_stem_assignment_ignores_input_order(d) From 3d120c8f961ede7ff6a4a182965d076bc928a8e6 Mon Sep 17 00:00:00 2001 From: Joe Amditis <6799804+jamditis@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:59:01 -0400 Subject: [PATCH 2/2] Scope prep --clean by written stems, not input stems (#8) The cloud review caught a data-loss hole in the --clean stem-scoping: run_stems was built from the input list (`stems`), so a take that produced zero clips this run -- a silent or bad re-recording, or thresholds that dropped every segment -- still had its stem in run_stems, and --clean then deleted that stem's prior good clips while writing no replacement. The narrow "only a re-recorded take's own orphans" guarantee has to key off what the run actually wrote, not what it tried to record. Derive run_stems from `written` (each written path is a `_NNN.wav`, so its stem reads back through _clip_stem). A take that wrote nothing contributes no stem, so its earlier clips linger and warn instead of being wiped, and the only copy stays safe. test_clean_spares_prior_clips_when_rerun_writes_nothing reproduces it: a good 3-clip run, then a rerun whose --min-dur drops every segment; with --clean the prior clips must survive. It fails on the old input-stem scoping and passes on the written-stem scoping. Refs #8 wake-20260723T1005-20d30f --- prep_wake_samples.py | 11 ++++++++++- test_prep_wake_samples.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/prep_wake_samples.py b/prep_wake_samples.py index 1312f5c..9b0cf99 100644 --- a/prep_wake_samples.py +++ b/prep_wake_samples.py @@ -399,7 +399,16 @@ def process( # clip whose stem this run did not write -- an unrelated take, a source # recording, hand-added audio -- is named but never auto-deleted, because it # is indistinguishable from data the user curated on purpose. - run_stems = set(stems) + # + # Scope by the stems this run actually WROTE a clip for, read back from + # `written`, not by the input list `stems`: an input that produced zero clips + # this run (silence, a bad re-recording, thresholds that dropped every + # segment) has no new clip to make its earlier good clips stale, so --clean + # must not delete them. Keying off written paths leaves them to linger and + # warn instead of wiping the only copy. + run_stems = { + stem for stem in (_clip_stem(p.name) for p in written) if stem is not None + } stale = _stale_clips(out_dir, written) if stale: removable = ( diff --git a/test_prep_wake_samples.py b/test_prep_wake_samples.py index 262ad17..bb3ea33 100644 --- a/test_prep_wake_samples.py +++ b/test_prep_wake_samples.py @@ -375,6 +375,36 @@ def test_clean_removes_disambiguated_stem_orphans(d: Path) -> None: ) +def test_clean_spares_prior_clips_when_rerun_writes_nothing(d: Path) -> None: + """A re-recording that yields zero clips must not let --clean wipe the good + run before it. The take's stem is still in the input list, but it wrote + nothing this run, so its earlier clips are the only copy -- --clean has to + leave them. Scoping by written stems (not input stems) is what protects them. + """ + src = d / "wipe_src" + src.mkdir() + take = src / "take.wav" + sf.write( + take, make_bursts(3, 0.5, 0.8, prep.TARGET_SR), prep.TARGET_SR, subtype="PCM_16" + ) + out = d / "wipe_out" + first = prep.process([take], out, "positive", 0.3, 0.2, 3.0) # take_000.._002 + check(first == 3, f"first run writes the good clips (got {first})") + + # Re-record badly: a --min-dur longer than every burst drops all segments, so + # this run writes nothing. Without the written-stem scoping, --clean would read + # the take's prior clips as stale-for-its-own-stem and delete the only copy. + sf.write( + take, make_bursts(3, 0.5, 0.8, prep.TARGET_SR), prep.TARGET_SR, subtype="PCM_16" + ) + second = prep.process([take], out, "positive", 0.3, 5.0, 10.0, clean=True) + present = sorted(p.name for p in out.glob("*.wav")) + check( + second == 0 and present == ["take_000.wav", "take_001.wav", "take_002.wav"], + f"--clean keeps the prior good clips when the rerun writes nothing ({present})", + ) + + def test_in_run_collision_raises(d: Path) -> None: """Two clips in one run mapping to the same file must fail loud. @@ -507,6 +537,7 @@ def main() -> int: test_rerun_with_fewer_clips_orphans_then_clean(d) test_clean_spares_files_this_run_did_not_record(d) test_clean_removes_disambiguated_stem_orphans(d) + test_clean_spares_prior_clips_when_rerun_writes_nothing(d) test_in_run_collision_raises(d) test_refresh_after_adding_collider_leaves_no_orphans(d) test_stem_assignment_ignores_input_order(d)