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..9b0cf99 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,50 @@ 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. + # + # 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: - 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 +473,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 +489,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..bb3ea33 100644 --- a/test_prep_wake_samples.py +++ b/test_prep_wake_samples.py @@ -258,6 +258,153 @@ 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_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. @@ -387,6 +534,10 @@ 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_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)