ADR-227: Add BackgroundNoiseAugmentor and MicrophoneNoiseAugmentor stages - #231
Conversation
- background_noise_stage.py: BackgroundNoiseAugmentor with NoiseProvider protocol; noise_file always chosen for hash stability; noise_start_s bounds derived from file durations at runtime; all three keys always present in applied_values - mic_noise_stage.py: MicrophoneNoiseAugmentor with Gaussian noise seeded from output_seed - speech_05_add_background_noise.py, speech_06_add_mic_noise.py: entry-point scripts - params.py: AddBackgroundNoiseParams, AddMicNoiseParams; PipelineParams.load() extended - params.yaml: add_background_noise and add_mic_noise sections - _doc_speech.md: updated stages table and key design decisions
jodavis-claude
left a comment
There was a problem hiding this comment.
ADR-227 Review: BackgroundNoiseAugmentor and MicrophoneNoiseAugmentor
Build: clean (0 warnings, 0 errors). All 271 Python unit tests pass. All .NET unit and E2E tests pass.
All exit criteria from the brief are met: both stage classes are present, all three applied_values keys are always present in BackgroundNoiseAugmentor, choose() always runs for hash stability, noise_start_s/noise_volume are 0.0 when not applied, both _derive_id formats match the spec, entry-point scripts are present, and the required unit tests are all implemented.
Issues requiring changes
Priority 1 — Correctness
-
_read_duration_sblocks the event loop thread (background_noise_stage.pyline 61).future.result()is called from the event loop thread, blocking it for the duration of the thread-pool execution. CONTRIBUTING.md prohibits blocking calls on async code paths. The implementation does work correctly (the worker thread creates its own event loop, avoiding deadlock), but the pattern is the Python equivalent of.Resulton a Task. Theexecutor.submitform should also uselambda: asyncio.run(_read())instead ofasyncio.run, _read()(the pre-created coroutine form, though valid, is non-obvious). The deeper fix is to move duration reads into_generate_outputwhere an event loop is available for properawait. -
list_files()[0].parentraisesIndexErroron empty noise directory (background_noise_stage.pylines 106 and 151). The noise directory is inferred fromlist_files()[0].parentrather than being stored directly. If the directory is empty,[0]raisesIndexErrorwith no useful error message.list_files()is also called three times across_get_applied_valuesand_generate_outputwith no caching, re-globbing the filesystem on each call. The fix is to store_noise_dirdirectly in the constructor (or cache the firstlist_files()result).
Priority 4 — Documentation / style (non-blocking)
_DirectoryNoiseProvider.list_files()gives no actionable error on empty directory (speech_05_add_background_noise.pyline 30). The error eventually surfaces asValueError("options must be non-empty")fromVariationGenerator.choose()— a confusing message when the root cause is a misconfigured--noise-dir. Adding a guard inlist_files()or__init__would make misconfiguration immediately diagnosable.
Priority 5 — Style (non-blocking)
from typing import Anyis imported but unused intest_background_noise_stage.py(line 9) andtest_mic_noise_stage.py(line 9).import pytestis imported but unused intest_background_noise_stage.py(line 12) andtest_mic_noise_stage.py(line 11).
The two correctness issues (#1 and #2) require changes before merge.
…ve redundant list_files() calls Issue 1: Changed executor.submit(asyncio.run, _read()) to executor.submit(lambda: asyncio.run(_read())) so the coroutine is created inside the worker thread. The old form passed a pre-created coroutine object across threads, which is fragile (the coroutine is bound to the originating thread's context). The lambda form creates a fresh coroutine in the worker thread per asyncio.run() semantics. Issues 2 & 3: Added noise_dir: Path constructor parameter to BackgroundNoiseAugmentor and stored it as _noise_dir. Both _get_applied_values (line 106) and _generate_output (line 151) previously called self._noise_provider.list_files()[0].parent to recover the noise directory — re-globbing the filesystem on each call and raising an opaque IndexError on an empty directory. _noise_dir eliminates both calls.
…d test_mic_noise_stage.py Issues 4 & 5: 'from typing import Any' and 'import pytest' were imported but never referenced in either test file. Also updates test_background_noise_stage.py to pass the new noise_dir parameter required by the BackgroundNoiseAugmentor constructor.
…or empty noise directory
Issue 6: _DirectoryNoiseProvider.list_files() now raises ValueError with the directory path
if no WAV files are found. Previously it returned [] which caused VariationGenerator.choose()
to raise ValueError("options must be non-empty") — an opaque message that didn't identify
the misconfigured path. The new guard surfaces the root cause immediately.
Also passes args.noise_dir as the new noise_dir constructor parameter added to
BackgroundNoiseAugmentor.
jodavis-claude
left a comment
There was a problem hiding this comment.
Placeholder — submitting to clear pending review state before adding replies.
jodavis-claude
left a comment
There was a problem hiding this comment.
Sign-off review — all 6 prior threads resolved; no new issues found.
Thread outcomes:
-
Priority 1 —
executor.submitcoroutine form /future.result()blocking the event loop (PRRT_kwDOQYcoLM6KZwqe): Developer applied the lambda fix correctly (executor.submit(lambda: asyncio.run(_read()))). Rationale for not restructuring into_generate_outputis accepted — moving the duration reads there would diverge the skip-check hash from the_get_applied_valueshash, causing every sample to regenerate on every run. Thefuture.result()block is the least-bad option under the currentModifierStagecontract. Thread resolved. -
Priority 1 — Redundant
list_files()[0].parent+IndexErrorrisk on line 106 (PRRT_kwDOQYcoLM6KZwxo): Fixed.noise_dir: Pathadded as constructor parameter;self._noise_dirstored and used throughout.list_files()[0].parentcalls eliminated. Thread resolved (outdated). -
Priority 1 — Third
list_files()[0].parentcall in_generate_output(PRRT_kwDOQYcoLM6KZw1l): Fixed. Sameself._noise_dir / noise_filepattern applied. Thread resolved (outdated). -
Style — unused
from typing import Anyin test files (PRRT_kwDOQYcoLM6KZw4j): Fixed. Import removed from both test files. Thread resolved (outdated). -
Style — unused
import pytestin test files (PRRT_kwDOQYcoLM6KZw7K): Fixed. Import removed from both test files. Thread resolved (outdated). -
Correctness (minor) —
_DirectoryNoiseProvider.list_files()did not validate empty directory (PRRT_kwDOQYcoLM6KZw-2): Fixed.list_files()now raisesValueError(f"No WAV files found in noise directory: {self._noise_dir}")before returning an empty list. Thread resolved (outdated).
Modified files scanned for new issues: ml/pipeline/speech/background_noise_stage.py, ml/pipeline/stages/speech_05_add_background_noise.py, ml/test/pipeline/speech/test_background_noise_stage.py, ml/test/pipeline/speech/test_mic_noise_stage.py — no new Priority 1–4 issues found.
Approved for human review.
jodavis-claude
left a comment
There was a problem hiding this comment.
Sign-off review — all 6 prior threads resolved; no new Priority 1–4 issues found in modified files.
Thread outcomes:
- Priority 1 — executor.submit coroutine form / future.result() blocking (PRRT_kwDOQYcoLM6KZwqe): Lambda fix verified — executor.submit(lambda: asyncio.run(_read())) creates the coroutine inside the worker thread. Developer rationale for not restructuring into _generate_output accepted: moving noise_start_s computation there would diverge the skip-check hash and break the ModifierStage contract. Resolved.
- Priority 1 — Redundant list_files()[0].parent + IndexError risk at line 106 (PRRT_kwDOQYcoLM6KZwxo): Fixed. noise_dir: Path constructor parameter added; self._noise_dir used throughout. Resolved (outdated).
- Priority 1 — Third list_files()[0].parent call in _generate_output (PRRT_kwDOQYcoLM6KZw1l): Fixed. self._noise_dir / noise_file pattern applied. Resolved (outdated).
- Style — unused from typing import Any in test files (PRRT_kwDOQYcoLM6KZw4j): Fixed. Import removed from both test files. Resolved (outdated).
- Style — unused import pytest in test files (PRRT_kwDOQYcoLM6KZw7K): Fixed. Import removed from both test files. Resolved (outdated).
- Correctness (minor) — _DirectoryNoiseProvider.list_files() no empty-directory validation (PRRT_kwDOQYcoLM6KZw-2): Fixed. Raises ValueError with directory path if no WAV files found. Resolved (outdated).
Scan of modified files (background_noise_stage.py, speech_05_add_background_noise.py, test_background_noise_stage.py, test_mic_noise_stage.py): no new issues found. The lambda closure correctly captures per-call audio_reader and path bindings; noise mixing arithmetic (add + clip + astype float32) is correct; empty-directory validation is present and actionable.
There was a problem hiding this comment.
Pull request overview
This PR adds two new ML pipeline augmentation stages for speech data generation: one for mixing in environmental background noise and one for adding Gaussian microphone noise, including CLI entry-points, params wiring, docs, and unit tests.
Changes:
- Added
BackgroundNoiseAugmentor(noise file selection + bounded start offset + mixing/clipping) andMicrophoneNoiseAugmentor(seeded Gaussian noise + clipping). - Added stage entry-point scripts and new
params.yamlsections + params parsing dataclasses. - Added/updated documentation and unit tests covering applied-values, ID derivation, generation, and params loading.
Confidence: 92/100 (main uncertainty: whether the pipeline guarantees a single, fixed sample rate across generated speech WAVs and noise WAVs; current code does not enforce it).
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| ml/pipeline/speech/background_noise_stage.py | New background-noise mixing stage + duration-bounded noise slicing |
| ml/pipeline/speech/mic_noise_stage.py | New microphone Gaussian-noise stage (seeded, reproducible) |
| ml/pipeline/stages/speech_05_add_background_noise.py | New CLI entry-point wiring for background noise stage |
| ml/pipeline/stages/speech_06_add_mic_noise.py | New CLI entry-point wiring for mic noise stage |
| ml/pipeline/stages/params.py | Added params dataclasses + parsing for the two new stages |
| ml/params.yaml | Added default config sections for background noise and mic noise |
| ml/pipeline/speech/_doc_speech.md | Documented new stages and design decisions |
| ml/test/pipeline/speech/test_background_noise_stage.py | Unit tests for background noise stage behavior |
| ml/test/pipeline/speech/test_mic_noise_stage.py | Unit tests for mic noise stage behavior |
| ml/test/pipeline/stages/test_params.py | Extended params load tests for new stage config sections |
Per user decision (PRRT_kwDOQYcoLM6KbINz, PRRT_kwDOQYcoLM6KbIOC, PRRT_kwDOQYcoLM6KjMkD): noise_start_s is no longer varied — it is always 0.0. This eliminates the hacky synchronous _read_duration_s helper that called asyncio.run inside a ThreadPoolExecutor to avoid blocking the event loop. noise_start_s is still stored in applied_values for hash stability. Update _doc_speech.md to remove the now-obsolete bounds-derivation design note.
…oiseAugmentor Per Copilot review (PRRT_kwDOQYcoLM6KbINm) and user decision (PRRT_kwDOQYcoLM6KjhqB): only add Gaussian noise if amplitude > 0. Use audio.samples directly on the no-noise path instead of creating an unnecessary copy. New array is only allocated when mixing occurs.
- NoiseProvider.list_files() now returns list[tuple[str, AudioData]] so that implementations can load and resample files once at construction time, avoiding per-sample I/O and ensuring sample rate consistency. - _DirectoryNoiseProvider gains a sample_rate: int constructor parameter; it loads all WAV files eagerly via librosa (resampling to sample_rate), satisfying the sample-rate equality contract. - BackgroundNoiseAugmentor removes noise_dir from its constructor (no longer needed; noise audio is retrieved directly from the provider). - _generate_output in both BackgroundNoiseAugmentor and MicrophoneNoiseAugmentor returns input_sample unchanged when noise_volume / amplitude == 0.0 — no file is written and no I/O occurs, eliminating unnecessary file copies. - MicrophoneNoiseAugmentor._derive_id returns input_sample.id when amplitude == 0.0 (no _mic### suffix added for unmodified samples). - A sample_rate: int field is added to PipelineParams (pipeline-level param) with a sample_rate: 16000 default in params.yaml. - Added ValueError guard in _generate_output when noise and audio sample rates differ, with a diagnostic message explaining the fix. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Work item: ADR-227 — Implement
BackgroundNoiseAugmentorandMicrophoneNoiseAugmentor, twoModifierStagesubclasses that add environmental noise and microphone noise toAudioSamplemanifests, along with their DVC entry-point scripts.Changes
ml/pipeline/speech/background_noise_stage.py— New file:NoiseProviderprotocol andBackgroundNoiseAugmentor(ModifierStage[AudioSample, AudioSample]).choose()always called for hash stability; all three applied_values keys always present;noise_start_sbounds derived from runtime file durations viaAudioReader.ml/pipeline/speech/mic_noise_stage.py— New file:MicrophoneNoiseAugmentor(ModifierStage[AudioSample, AudioSample]). Generates Gaussian noise seeded fromoutput_seed. Single applied_values key:mic_noise_amplitude.ml/pipeline/stages/speech_05_add_background_noise.py— New entry-point:--input-manifest-dir,--noise-dir,--output-dir; constructsBackgroundNoiseAugmentorwith_DirectoryNoiseProvider.ml/pipeline/stages/speech_06_add_mic_noise.py— New entry-point:--input-manifest-dir,--output-dir; constructsMicrophoneNoiseAugmentor.ml/pipeline/stages/params.py— AddedAddBackgroundNoiseParamsandAddMicNoiseParamsdataclasses; extendedPipelineParamsandPipelineParams.load()to parse the new sections.ml/params.yaml— Addedstages.add_background_noiseandstages.add_mic_noisesections with default values.ml/pipeline/speech/_doc_speech.md— Updated stages table to include both new stages; added design decisions forNoiseProvider, hash-stable noise file selection, runtime duration bounds, and Gaussian mic noise seeding.ml/test/pipeline/speech/test_background_noise_stage.py— New unit tests forBackgroundNoiseAugmentor(applied_values keys, noise_start_s clamping, derive_id format, generate_output, skip path).ml/test/pipeline/speech/test_mic_noise_stage.py— New unit tests forMicrophoneNoiseAugmentor(applied_values keys, derive_id format, generate_output, skip path).ml/test/pipeline/stages/test_params.py— AddedTestAddBackgroundNoiseParamsLoadandTestAddMicNoiseParamsLoadtest classes.Design decisions
choose()is always called regardless ofshould_vary. The noise filename is always selected and stored inapplied_valuesfor hash stability. Onlynoise_start_sandnoise_volumeare set to 0.0 when the variation is not applied. All three keys are always present.noise_start_sbounds are derived at runtime from file durations.AudioReaderis called for both the noise file and the audio sample. When the noise file is shorter than the audio sample,max_start_sis forced to 0.0._get_applied_valuesis synchronous per the base class contract. A_read_duration_s()helper detects whether a running event loop exists and usesThreadPoolExecutor+asyncio.run()accordingly, satisfying both test and production paths.[-1.0, 1.0]. The spec did not specify clipping; this is the standard approach and was recommended in the brief.output_seedvianp.random.default_rng(output_seed).normal(0, amplitude, len(samples))for reproducibility.dvc.yamlwiring is out of scope — deferred to ADR-231 per the brief.