audiotrove 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
audiotrove/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
audiotrove/base.py ADDED
@@ -0,0 +1,60 @@
1
+ from abc import ABC, abstractmethod
2
+ from audiotrove.document import AudioDocument
3
+
4
+
5
+ class AudioFilter(ABC):
6
+ @abstractmethod
7
+ def filter(self, doc: AudioDocument) -> bool:
8
+ pass
9
+
10
+ @property
11
+ @abstractmethod
12
+ def name(self) -> str:
13
+ pass
14
+
15
+
16
+ class AudioTransformer(ABC):
17
+ @abstractmethod
18
+ def transform(self, doc: AudioDocument) -> AudioDocument:
19
+ pass
20
+
21
+ @property
22
+ @abstractmethod
23
+ def name(self) -> str:
24
+ pass
25
+
26
+
27
+ class AudioFanOutTransformer(ABC):
28
+ """Transformer that can emit zero, one, or many documents from a single input.
29
+
30
+ Unlike AudioTransformer (which always returns exactly one document), a fan-out
31
+ transformer enables segmentation and expansion operations. For example, VAD
32
+ segmentation splits a long file into multiple speech segments, each becoming
33
+ its own AudioDocument for independent filtering and processing.
34
+
35
+ Use cases:
36
+ - Segmentation: split a file by detected speech regions (VADSegmenter)
37
+ - Augmentation: expand a document into multiple variants
38
+ - Filtering that returns variable-length output without discarding
39
+
40
+ The returned documents should have deterministic doc_ids derived from the
41
+ parent doc_id plus segment metadata (e.g., start/end times) so re-running
42
+ the pipeline is idempotent and checkpoint-safe.
43
+ """
44
+
45
+ @abstractmethod
46
+ def transform(self, doc: AudioDocument) -> list[AudioDocument]:
47
+ """Transform one document into zero, one, or many documents.
48
+
49
+ Args:
50
+ doc: The input AudioDocument.
51
+
52
+ Returns:
53
+ A list of AudioDocument objects (may be empty).
54
+ """
55
+ pass
56
+
57
+ @property
58
+ @abstractmethod
59
+ def name(self) -> str:
60
+ pass
File without changes
audiotrove/cli/main.py ADDED
@@ -0,0 +1,297 @@
1
+ import click
2
+ from rich.console import Console
3
+ from rich.table import Table
4
+ from pathlib import Path
5
+ import logging
6
+
7
+ from audiotrove import __version__
8
+
9
+ console = Console()
10
+
11
+
12
+ @click.group()
13
+ @click.version_option(version=__version__)
14
+ @click.option("-v", "--verbose", is_flag=True, help="Enable verbose (DEBUG) logging.")
15
+ def cli(verbose):
16
+ """AudioTrove: Open-source audio data curation pipeline."""
17
+ if verbose:
18
+ logging.basicConfig(level=logging.DEBUG)
19
+ else:
20
+ logging.basicConfig(level=logging.WARNING)
21
+
22
+
23
+ @cli.command()
24
+ @click.argument("input_path")
25
+ @click.argument("output_path")
26
+ @click.option(
27
+ "--vad-threshold",
28
+ default=0.3,
29
+ type=float,
30
+ show_default=True,
31
+ help="Minimum speech ratio (0-1) to keep a clip.",
32
+ )
33
+ @click.option(
34
+ "--snr-min",
35
+ default=15.0,
36
+ type=float,
37
+ show_default=True,
38
+ help="Minimum SNR in dB to keep a clip.",
39
+ )
40
+ @click.option(
41
+ "--format",
42
+ "output_format",
43
+ type=click.Choice(["jsonl"]),
44
+ default="jsonl",
45
+ show_default=True,
46
+ help="Output format.",
47
+ )
48
+ @click.option("--checkpoint", default=None, help="Path to checkpoint database for resumable runs.")
49
+ @click.option(
50
+ "--workers",
51
+ default=1,
52
+ type=int,
53
+ show_default=True,
54
+ help="Number of worker processes for parallel execution.",
55
+ )
56
+ @click.option(
57
+ "--extensions",
58
+ default="wav",
59
+ show_default=True,
60
+ help='Comma-separated audio file extensions to process (e.g., "wav,mp3,flac").',
61
+ )
62
+ @click.option(
63
+ "--segment",
64
+ is_flag=True,
65
+ default=False,
66
+ help="Split audio into per-speech-segment sub-documents (VAD fan-out). Each speech segment becomes its own JSONL entry.",
67
+ )
68
+ @click.option(
69
+ "--enhance",
70
+ is_flag=True,
71
+ default=False,
72
+ help="Optional neural denoising via DeepFilterNet2 (requires pip install audiotrove[enhance]). Runs before VAD/SNR filtering.",
73
+ )
74
+ @click.option(
75
+ "--tts", is_flag=True, default=False, help="Run the TTS curation and manifest pipeline."
76
+ )
77
+ @click.option(
78
+ "--tts-min-duration",
79
+ default=2.0,
80
+ type=float,
81
+ show_default=True,
82
+ help="Minimum TTS clip duration in seconds.",
83
+ )
84
+ @click.option(
85
+ "--tts-max-duration",
86
+ default=15.0,
87
+ type=float,
88
+ show_default=True,
89
+ help="Maximum TTS clip duration in seconds.",
90
+ )
91
+ @click.option(
92
+ "--tts-snr-min", default=20.0, type=float, show_default=True, help="Minimum TTS SNR in dB."
93
+ )
94
+ def curate(
95
+ input_path,
96
+ output_path,
97
+ vad_threshold,
98
+ snr_min,
99
+ output_format,
100
+ checkpoint,
101
+ workers,
102
+ extensions,
103
+ segment,
104
+ enhance,
105
+ tts,
106
+ tts_min_duration,
107
+ tts_max_duration,
108
+ tts_snr_min,
109
+ ):
110
+ """Curate audio files from INPUT_PATH into OUTPUT_PATH.
111
+
112
+ Applies VAD and SNR filters, writes JSONL manifest.
113
+ """
114
+ from audiotrove.io.readers import LocalAudioReader
115
+ from audiotrove.io.writers import JSONLWriter
116
+ from audiotrove.filters.vad import SileroVADFilter
117
+ from audiotrove.filters.snr import SNRFilter
118
+ from audiotrove.executor.local import LocalExecutor
119
+
120
+ if enhance:
121
+ from audiotrove.filters.enhance import DeepFilterEnhancer
122
+
123
+ # Validate paths
124
+ input_p = Path(input_path)
125
+ output_p = Path(output_path)
126
+
127
+ if not input_p.exists():
128
+ console.print(f"[red]Error: Input path does not exist: {input_path}[/red]")
129
+ raise SystemExit(1)
130
+
131
+ output_p.mkdir(parents=True, exist_ok=True)
132
+
133
+ if tts:
134
+ from audiotrove.pipelines.tts import tts_pipeline
135
+
136
+ summary = tts_pipeline(
137
+ input_path=input_path,
138
+ output_path=output_path,
139
+ min_duration=tts_min_duration,
140
+ max_duration=tts_max_duration,
141
+ snr_min=tts_snr_min,
142
+ extensions=[extension.strip().lower() for extension in extensions.split(",")],
143
+ workers=workers,
144
+ )
145
+ console.print("[cyan]TTS curation pipeline complete[/cyan]")
146
+ console.print(f" Kept: {summary['kept']}")
147
+ console.print(f" Filtered: {summary['filtered']}")
148
+ console.print(f" Duration: {summary['total_duration_seconds']:.2f}s")
149
+ for output_file in summary["output_files"]:
150
+ console.print(f" Output: {output_file}")
151
+ return
152
+
153
+ # Build pipeline
154
+ pipeline = []
155
+ if enhance:
156
+ pipeline.append(DeepFilterEnhancer())
157
+ # Treat a vad_threshold of 0.0 as "no VAD filtering" (accept all audio).
158
+ if vad_threshold > 0.0 and vad_threshold < 1.0:
159
+ pipeline.append(SileroVADFilter(min_speech_ratio=vad_threshold))
160
+ if snr_min > 0:
161
+ pipeline.append(SNRFilter(min_snr_db=snr_min))
162
+
163
+ if not pipeline:
164
+ console.print(
165
+ "[yellow]Warning: No filters in pipeline. All audio will pass through.[/yellow]"
166
+ )
167
+
168
+ # Parse extensions
169
+ exts = [e.strip().lower() for e in extensions.split(",")]
170
+
171
+ # Reader: build glob patterns for each extension
172
+ if input_p.is_dir():
173
+ patterns = [str(input_p / f"*.{ext}") for ext in exts]
174
+ else:
175
+ # If input is a file, just use that single file
176
+ patterns = [str(input_p)]
177
+
178
+ reader = LocalAudioReader(patterns)
179
+ output_manifest = output_p / "manifest.jsonl"
180
+ writer = JSONLWriter(str(output_manifest))
181
+
182
+ # Executor
183
+ checkpoint_db = checkpoint or str(output_p / "checkpoint.db")
184
+ # Optionally insert segmentation
185
+ if "segment" in locals() and segment:
186
+ from audiotrove.filters.vad import VADSegmenter
187
+
188
+ # Insert segmenter after VAD filter (if present), otherwise at start
189
+ vad_idx = next(
190
+ (i for i, b in enumerate(pipeline) if getattr(b, "name", "") == "silero_vad"), None
191
+ )
192
+ insert_at = (vad_idx + 1) if vad_idx is not None else 0
193
+ pipeline.insert(insert_at, VADSegmenter(threshold=0.5))
194
+ console.print(" [cyan]Segmentation: enabled (VADSegmenter)[/cyan]")
195
+
196
+ executor = LocalExecutor(pipeline=pipeline, checkpoint_path=checkpoint_db, num_workers=workers)
197
+
198
+ # Run
199
+ console.print("[cyan]Starting curation pipeline[/cyan]")
200
+ console.print(f" Input: {input_path}")
201
+ console.print(f" Output: {output_manifest}")
202
+ console.print(f" Checkpoint: {checkpoint_db}")
203
+ console.print(f" Workers: {workers}")
204
+ console.print(f" Extensions: {', '.join(exts)}")
205
+ console.print(f" Filters: {', '.join(b.name for b in pipeline) or 'none'}")
206
+ if enhance:
207
+ console.print("Enhancement enabled (DeepFilterNet2). First run downloads ~60MB model.")
208
+ console.print(
209
+ f" Segmentation: {'enabled' if 'segment' in locals() and segment else 'disabled'}"
210
+ )
211
+ console.print()
212
+
213
+ stats = executor.run(reader, writer)
214
+
215
+ # Print results
216
+ table = Table(title="Curation Results")
217
+ table.add_column("Metric", style="cyan")
218
+ table.add_column("Count", style="magenta")
219
+ table.add_row("Processed", str(stats["processed"]))
220
+ table.add_row("Kept", str(stats["kept"]))
221
+ table.add_row("Filtered", str(stats["skipped"]))
222
+ console.print(table)
223
+
224
+ console.print(f"[green]✓ Manifest written to {output_manifest}[/green]")
225
+
226
+
227
+ @cli.command()
228
+ @click.argument("input_path")
229
+ @click.option(
230
+ "--extensions",
231
+ default="wav",
232
+ show_default=True,
233
+ help='Comma-separated audio extensions to inspect (e.g. "wav,mp3,flac").',
234
+ )
235
+ @click.option(
236
+ "--limit",
237
+ default=None,
238
+ type=int,
239
+ show_default=True,
240
+ help="Show stats for first N files. Cap the number of files inspected (default: all).",
241
+ )
242
+ def inspect(input_path, extensions, limit):
243
+ """Show statistics for an audio directory without filtering."""
244
+ from audiotrove.io.readers import LocalAudioReader
245
+ import numpy as np
246
+
247
+ input_p = Path(input_path)
248
+ if not input_p.exists():
249
+ console.print(f"[red]Error: Input path does not exist: {input_path}[/red]")
250
+ raise SystemExit(1)
251
+
252
+ exts = [e.strip().lower() for e in extensions.split(",")]
253
+
254
+ if input_p.is_dir():
255
+ patterns = [str(input_p / f"**/*.{ext}") for ext in exts]
256
+ else:
257
+ patterns = [str(input_p)]
258
+
259
+ reader = LocalAudioReader(patterns, min_duration_seconds=0.0, max_duration_seconds=None)
260
+
261
+ durations = []
262
+ format_counts = {}
263
+ count = 0
264
+
265
+ for doc in reader:
266
+ if doc is None:
267
+ continue
268
+ durations.append(doc.duration_seconds)
269
+ ext = Path(doc.source_path).suffix.lstrip(".").lower()
270
+ format_counts[ext] = format_counts.get(ext, 0) + 1
271
+ count += 1
272
+ if limit and count >= limit:
273
+ break
274
+
275
+ if not durations:
276
+ console.print("[yellow]No audio files found.[/yellow]")
277
+ return
278
+
279
+ durations = np.array(durations)
280
+ total_hours = durations.sum() / 3600
281
+
282
+ table = Table(title=f"Audio Directory Stats ({len(durations)} files)")
283
+ table.add_column("Statistic", style="cyan")
284
+ table.add_column("Value", style="magenta")
285
+ table.add_row("Files", str(len(durations)))
286
+ table.add_row("Total duration", f"{total_hours:.2f}h ({durations.sum():.0f}s)")
287
+ table.add_row("Mean duration", f"{durations.mean():.2f}s")
288
+ table.add_row("Median duration", f"{np.median(durations):.2f}s")
289
+ table.add_row("Min duration", f"{durations.min():.2f}s")
290
+ table.add_row("Max duration", f"{durations.max():.2f}s")
291
+ for fmt, cnt in sorted(format_counts.items()):
292
+ table.add_row(f"Format: .{fmt}", str(cnt))
293
+ console.print(table)
294
+
295
+
296
+ if __name__ == "__main__":
297
+ cli()
audiotrove/document.py ADDED
@@ -0,0 +1,25 @@
1
+ from dataclasses import dataclass, field
2
+ import numpy as np
3
+
4
+
5
+ @dataclass
6
+ class AudioDocument:
7
+ audio: np.ndarray
8
+ sample_rate: int
9
+ source_path: str
10
+ duration_seconds: float
11
+ doc_id: str
12
+ metadata: dict = field(default_factory=dict)
13
+
14
+ def __eq__(self, other):
15
+ """Custom equality to handle numpy array comparison."""
16
+ if not isinstance(other, AudioDocument):
17
+ return False
18
+ return (
19
+ np.array_equal(self.audio, other.audio)
20
+ and self.sample_rate == other.sample_rate
21
+ and self.source_path == other.source_path
22
+ and self.duration_seconds == other.duration_seconds
23
+ and self.doc_id == other.doc_id
24
+ and self.metadata == other.metadata
25
+ )
File without changes