-
Notifications
You must be signed in to change notification settings - Fork 0
ADR-227: Add BackgroundNoiseAugmentor and MicrophoneNoiseAugmentor stages #231
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
58a177c
ADR-227: Add BackgroundNoiseAugmentor and MicrophoneNoiseAugmentor
ElwoodMoves fdfa5b7
ADR-227: Fix executor.submit coroutine form; store _noise_dir to remo…
ElwoodMoves c729bd3
ADR-227: Remove unused imports from test_background_noise_stage.py an…
ElwoodMoves 905e7f3
ADR-227: Pass noise_dir to BackgroundNoiseAugmentor; add validation f…
ElwoodMoves 0dc95ec
ADR-227: Remove noise_start_s variation and _read_duration_s
ElwoodMoves b85d2b2
ADR-227: Avoid output_samples copy when amplitude is 0 in MicrophoneN…
ElwoodMoves 852e879
ADR-227: Redesign NoiseProvider; return input when noise not applied
ElwoodMoves File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| """BackgroundNoiseAugmentor: mix environmental noise into WAV audio samples.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from pathlib import Path | ||
| from typing import Any, Protocol | ||
|
|
||
| import numpy as np | ||
|
|
||
| from pipeline.core.manifest import ManifestStore | ||
| from pipeline.core.modifier_stage import ModifierStage | ||
| from pipeline.core.randomization import MinMaxFilter, VariationGenerator | ||
| from pipeline.core.sample import AudioSample | ||
| from pipeline.io.audio_io import AudioData, AudioReader, AudioWriter | ||
| from pipeline.stages import conventions | ||
| from pipeline.stages.params import AddBackgroundNoiseParams | ||
|
|
||
|
|
||
| class NoiseProvider(Protocol): | ||
| """Protocol for supplying pre-loaded noise audio data. | ||
|
|
||
| Implementations load and resample noise files at construction time so that | ||
| each call to list_files() is cheap and all returned audio uses the same | ||
| sample rate. | ||
|
|
||
| Returns a list of (filename, AudioData) pairs. The filename is used for | ||
| hash-stable variation selection; AudioData is used for mixing. | ||
| """ | ||
|
|
||
| def list_files(self) -> list[tuple[str, AudioData]]: ... | ||
|
|
||
|
|
||
| class BackgroundNoiseAugmentor(ModifierStage[AudioSample, AudioSample]): | ||
| """Augmentation stage that mixes environmental noise into WAV samples. | ||
|
|
||
| The noise file is always chosen (even when not applied) so that the | ||
| content hash remains stable across configuration changes. Only | ||
| noise_volume is set to 0.0 when the noise is not applied. noise_start_s | ||
| is always 0.0 (noise always mixed from the start of the noise file). | ||
|
|
||
| When noise_volume == 0.0, the input sample is returned unchanged — no | ||
| file is written and no I/O occurs. | ||
|
|
||
| Noise mixing (when applied): slice noise for len(audio) samples, | ||
| zero-pad if noise is shorter, multiply by noise_volume, add, clip to [-1.0, 1.0]. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| output_dir: Path, | ||
| manifest_store: ManifestStore, | ||
| audio_reader: AudioReader, | ||
| audio_writer: AudioWriter, | ||
| input_dir: Path, | ||
| noise_provider: NoiseProvider, | ||
| params: AddBackgroundNoiseParams, | ||
| ) -> None: | ||
| super().__init__(output_dir, manifest_store) | ||
| self._audio_reader = audio_reader | ||
| self._audio_writer = audio_writer | ||
| self._input_dir = input_dir | ||
| self._noise_provider = noise_provider | ||
| self._vary_probability = params.vary_probability | ||
| self._volume_filter = MinMaxFilter(params.volume_min, params.volume_max, precision=2) | ||
|
|
||
| def _get_applied_values( | ||
| self, sample: AudioSample, generator: VariationGenerator | ||
| ) -> dict[str, Any]: | ||
| # Always choose a noise file for hash stability, even when not applied. | ||
| # list_files() returns pre-loaded (name, AudioData) pairs. | ||
| noise_items = self._noise_provider.list_files() | ||
| noise_file: str = generator.choose("noise_file", sorted([name for name, _ in noise_items])) | ||
|
|
||
| if generator.should_vary("noise", self._vary_probability): | ||
| noise_volume = generator.generate("noise_volume", self._volume_filter) | ||
| else: | ||
| noise_volume = 0.0 | ||
|
|
||
| return { | ||
| "noise_file": noise_file, | ||
| "noise_start_s": 0.0, # Always zero — noise is mixed from the start of the file. | ||
| "noise_volume": float(noise_volume), | ||
| } | ||
|
|
||
| def _derive_id(self, input_sample: AudioSample, applied_values: dict[str, Any]) -> str: | ||
| noise_file: str = applied_values["noise_file"] | ||
| noise_volume: float = applied_values["noise_volume"] | ||
| noise_filestem = Path(noise_file).stem | ||
| return f"{input_sample.id}_{noise_filestem}_v{int(noise_volume * 100)}" | ||
|
|
||
| async def _generate_output( | ||
| self, | ||
| input_sample: AudioSample, | ||
| output_id: str, | ||
| output_seed: int, | ||
| applied_values: dict[str, Any], | ||
| parent_content_hash: str, | ||
| ) -> AudioSample: | ||
| noise_volume: float = applied_values["noise_volume"] | ||
|
|
||
| # When not applied, return the input sample unchanged — no I/O. | ||
| if noise_volume == 0.0: | ||
| return input_sample | ||
|
|
||
| noise_file: str = applied_values["noise_file"] | ||
| noise_start_s: float = applied_values["noise_start_s"] | ||
|
|
||
| input_path = self._input_dir / input_sample.path | ||
| audio = await self._audio_reader.read(input_path) | ||
|
|
||
| # Look up the pre-loaded noise audio from the provider. | ||
| noise_items = self._noise_provider.list_files() | ||
| noise_audio = next(data for name, data in noise_items if name == noise_file) | ||
|
|
||
| if noise_audio.sample_rate != audio.sample_rate: | ||
| raise ValueError( | ||
| f"Noise file '{noise_file}' has sample_rate {noise_audio.sample_rate} Hz " | ||
| f"but input audio has sample_rate {audio.sample_rate} Hz. " | ||
| f"Configure _DirectoryNoiseProvider with the pipeline sample_rate so that " | ||
| f"noise files are resampled at load time." | ||
| ) | ||
|
|
||
| start_sample = int(noise_start_s * noise_audio.sample_rate) | ||
| n_needed = len(audio.samples) | ||
| noise_slice = noise_audio.samples[start_sample: start_sample + n_needed] | ||
|
|
||
| # Zero-pad if noise slice is shorter than audio | ||
| if len(noise_slice) < n_needed: | ||
| noise_slice = np.pad(noise_slice, (0, n_needed - len(noise_slice))) | ||
|
|
||
| output_samples: np.ndarray = np.clip( | ||
| audio.samples + noise_volume * noise_slice, -1.0, 1.0 | ||
| ).astype(np.float32) | ||
|
|
||
| self._output_dir.mkdir(parents=True, exist_ok=True) | ||
| output_path = conventions.sample_file_path(self._output_dir, output_id, "wav") | ||
| await self._audio_writer.write(output_path, AudioData(samples=output_samples, sample_rate=audio.sample_rate)) | ||
|
|
||
| content_hash = self._compute_content_hash( | ||
| parent_content_hash, output_seed, applied_values | ||
| ) | ||
|
|
||
| return AudioSample( | ||
| id=output_id, | ||
| seed=output_seed, | ||
| content_hash=content_hash, | ||
| path=Path(f"{output_id}.wav"), | ||
| parent_content_hash=parent_content_hash, | ||
| transcript=input_sample.transcript, | ||
| applied_values=applied_values, | ||
| ) | ||
|
jodavis marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| """MicrophoneNoiseAugmentor: add Gaussian microphone noise to WAV audio samples. | ||
|
|
||
| mic_noise_amplitude is stored as 0.0 when should_vary returns False. | ||
| When amplitude == 0.0, the input sample is returned unchanged — no file is written. | ||
| Gaussian noise is seeded from output_seed for reproducibility. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| import numpy as np | ||
|
|
||
| from pipeline.core.manifest import ManifestStore | ||
| from pipeline.core.modifier_stage import ModifierStage | ||
| from pipeline.core.randomization import MinMaxFilter, VariationGenerator | ||
| from pipeline.core.sample import AudioSample | ||
| from pipeline.io.audio_io import AudioData, AudioReader, AudioWriter | ||
| from pipeline.stages import conventions | ||
| from pipeline.stages.params import AddMicNoiseParams | ||
|
|
||
|
|
||
| class MicrophoneNoiseAugmentor(ModifierStage[AudioSample, AudioSample]): | ||
| """Augmentation stage that adds Gaussian microphone noise to WAV samples. | ||
|
|
||
| mic_noise_amplitude is stored as 0.0 when the noise is not applied. When | ||
| amplitude == 0.0, the input sample is returned unchanged — no file is | ||
| written and no I/O occurs. | ||
|
|
||
| Gaussian noise uses output_seed for reproducibility via numpy's default_rng. | ||
|
|
||
| input_dir is the directory containing the input WAV files referenced by | ||
| AudioSample.path. This is typically the output directory of the preceding stage. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| output_dir: Path, | ||
| manifest_store: ManifestStore, | ||
| audio_reader: AudioReader, | ||
| audio_writer: AudioWriter, | ||
| input_dir: Path, | ||
| params: AddMicNoiseParams, | ||
| ) -> None: | ||
| super().__init__(output_dir, manifest_store) | ||
| self._audio_reader = audio_reader | ||
| self._audio_writer = audio_writer | ||
| self._input_dir = input_dir | ||
| self._vary_probability = params.vary_probability | ||
| self._amplitude_filter = MinMaxFilter(params.amplitude_min, params.amplitude_max, precision=3) | ||
|
|
||
| def _get_applied_values( | ||
| self, sample: AudioSample, generator: VariationGenerator | ||
| ) -> dict[str, Any]: | ||
| if generator.should_vary("mic_noise_amplitude", self._vary_probability): | ||
| amplitude = generator.generate("mic_noise_amplitude", self._amplitude_filter) | ||
| else: | ||
| amplitude = 0.0 | ||
|
|
||
| return { | ||
| "mic_noise_amplitude": float(amplitude), | ||
| } | ||
|
|
||
| def _derive_id(self, input_sample: AudioSample, applied_values: dict[str, Any]) -> str: | ||
| amplitude: float = applied_values["mic_noise_amplitude"] | ||
| if amplitude == 0.0: | ||
| # No modification — return the input id unchanged. | ||
| return input_sample.id | ||
| return f"{input_sample.id}_mic{int(amplitude * 1000)}" | ||
|
jodavis marked this conversation as resolved.
|
||
|
|
||
| async def _generate_output( | ||
| self, | ||
| input_sample: AudioSample, | ||
| output_id: str, | ||
| output_seed: int, | ||
| applied_values: dict[str, Any], | ||
| parent_content_hash: str, | ||
| ) -> AudioSample: | ||
| amplitude: float = applied_values["mic_noise_amplitude"] | ||
|
|
||
| # When not applied, return the input sample unchanged — no I/O. | ||
| if amplitude == 0.0: | ||
| return input_sample | ||
|
|
||
| input_path = self._input_dir / input_sample.path | ||
| audio = await self._audio_reader.read(input_path) | ||
|
|
||
| rng = np.random.default_rng(output_seed) | ||
| noise = rng.normal(0, amplitude, len(audio.samples)).astype(np.float32) | ||
| output_samples: np.ndarray = np.clip(audio.samples + noise, -1.0, 1.0).astype(np.float32) | ||
|
|
||
| self._output_dir.mkdir(parents=True, exist_ok=True) | ||
| output_path = conventions.sample_file_path(self._output_dir, output_id, "wav") | ||
| await self._audio_writer.write(output_path, AudioData(samples=output_samples, sample_rate=audio.sample_rate)) | ||
|
jodavis marked this conversation as resolved.
|
||
|
|
||
| content_hash = self._compute_content_hash( | ||
| parent_content_hash, output_seed, applied_values | ||
| ) | ||
|
|
||
| return AudioSample( | ||
| id=output_id, | ||
| seed=output_seed, | ||
| content_hash=content_hash, | ||
| path=Path(f"{output_id}.wav"), | ||
| parent_content_hash=parent_content_hash, | ||
| transcript=input_sample.transcript, | ||
| applied_values=applied_values, | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.