Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<stem>_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,
Expand Down
13 changes: 13 additions & 0 deletions docs/wake-threshold-tuning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<stem>_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
Expand Down
89 changes: 77 additions & 12 deletions prep_wake_samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from __future__ import annotations

import argparse
import re
import shutil
import subprocess
import sys
Expand All @@ -38,6 +39,11 @@

TARGET_SR = 16000
AUDIO_EXTS = {".wav", ".flac", ".ogg", ".m4a", ".mp3", ".aac", ".mp4", ".wma"}
# prep writes each clip as "<stem>_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$")


# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -309,15 +315,37 @@ 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 "<stem>_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,
label: str,
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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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

Expand Down
151 changes: 151 additions & 0 deletions test_prep_wake_samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<stem>_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.

Expand Down Expand Up @@ -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)
Expand Down
Loading