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 +1 -0
- audiotrove/base.py +60 -0
- audiotrove/cli/__init__.py +0 -0
- audiotrove/cli/main.py +297 -0
- audiotrove/document.py +25 -0
- audiotrove/executor/__init__.py +0 -0
- audiotrove/executor/local.py +381 -0
- audiotrove/exporters/__init__.py +5 -0
- audiotrove/exporters/tts_manifest.py +71 -0
- audiotrove/filters/__init__.py +0 -0
- audiotrove/filters/duration.py +30 -0
- audiotrove/filters/enhance.py +80 -0
- audiotrove/filters/snr.py +63 -0
- audiotrove/filters/vad.py +365 -0
- audiotrove/io/__init__.py +0 -0
- audiotrove/io/readers.py +176 -0
- audiotrove/io/writers.py +30 -0
- audiotrove/pipelines/__init__.py +5 -0
- audiotrove/pipelines/tts.py +70 -0
- audiotrove/transformers/__init__.py +5 -0
- audiotrove/transformers/silence_trim.py +76 -0
- audiotrove/utils/__init__.py +0 -0
- audiotrove/utils/hashing.py +5 -0
- audiotrove-0.1.0.dist-info/METADATA +352 -0
- audiotrove-0.1.0.dist-info/RECORD +29 -0
- audiotrove-0.1.0.dist-info/WHEEL +5 -0
- audiotrove-0.1.0.dist-info/entry_points.txt +2 -0
- audiotrove-0.1.0.dist-info/licenses/LICENSE +21 -0
- audiotrove-0.1.0.dist-info/top_level.txt +1 -0
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
|