wavewash 0.1.0a1__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.
- wavewash/__init__.py +2 -0
- wavewash/__main__.py +2 -0
- wavewash/alignment_checker.py +87 -0
- wavewash/audio_fixer.py +283 -0
- wavewash/audio_scanner.py +61 -0
- wavewash/cli.py +83 -0
- wavewash/config.py +208 -0
- wavewash/dedup_detector.py +189 -0
- wavewash/diversity_report.py +104 -0
- wavewash/logger_setup.py +159 -0
- wavewash/metadata_checker.py +172 -0
- wavewash/pipeline.py +585 -0
- wavewash/quality_checker.py +430 -0
- wavewash/review_sampler.py +100 -0
- wavewash/server.py +468 -0
- wavewash/static/assets/index-CK5HsmPs.css +2 -0
- wavewash/static/assets/index-DTvS4S6_.js +68 -0
- wavewash/static/favicon.svg +1 -0
- wavewash/static/icons.svg +24 -0
- wavewash/static/index.html +17 -0
- wavewash/transcript_checker.py +158 -0
- wavewash/vad_module.py +105 -0
- wavewash-0.1.0a1.dist-info/METADATA +153 -0
- wavewash-0.1.0a1.dist-info/RECORD +28 -0
- wavewash-0.1.0a1.dist-info/WHEEL +5 -0
- wavewash-0.1.0a1.dist-info/entry_points.txt +2 -0
- wavewash-0.1.0a1.dist-info/licenses/LICENSE +29 -0
- wavewash-0.1.0a1.dist-info/top_level.txt +1 -0
wavewash/__init__.py
ADDED
wavewash/__main__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
alignment_checker.py
|
|
3
|
+
----------------------
|
|
4
|
+
Partial stand-in for real forced-alignment timestamp verification.
|
|
5
|
+
|
|
6
|
+
What this IS: a fast, dependency-light sanity check that flags transcripts
|
|
7
|
+
whose word count is wildly implausible for the clip's duration (e.g. a
|
|
8
|
+
40-word transcript on a 1-second clip, or 2 words on a 30-second clip).
|
|
9
|
+
This catches the most common real-world label bug -- a transcript that
|
|
10
|
+
belongs to a completely different, wrongly-matched audio file -- without
|
|
11
|
+
needing an aligner installed.
|
|
12
|
+
|
|
13
|
+
What this is NOT: real forced alignment. It does not tell you where each
|
|
14
|
+
word falls in time, and it will not catch a transcript that's plausible in
|
|
15
|
+
length but wrong in content (that's what the WER check in
|
|
16
|
+
transcript_checker.py is for) or off-by-a-few-hundred-ms boundary drift.
|
|
17
|
+
|
|
18
|
+
If you need real timestamp-level alignment QA (e.g. verifying segment
|
|
19
|
+
boundaries for TTS training), install the Montreal Forced Aligner (MFA) and
|
|
20
|
+
call it per-file; the hook point is `run_external_aligner()` below -- fill
|
|
21
|
+
in the subprocess call for your installed `mfa align` command and wire its
|
|
22
|
+
output (word-level timestamps) into whatever boundary-drift check you need.
|
|
23
|
+
Not bundled here because MFA needs its own conda environment, pretrained
|
|
24
|
+
acoustic models, and pronunciation dictionaries (multi-GB, per-language) --
|
|
25
|
+
too heavy to ship inside a general-purpose pipeline.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Optional, Tuple
|
|
30
|
+
|
|
31
|
+
# Typical sustainable speaking rates for read/conversational speech.
|
|
32
|
+
# Outside this range for a whole clip almost always means a duration/transcript
|
|
33
|
+
# mismatch rather than genuinely fast/slow speech.
|
|
34
|
+
MIN_PLAUSIBLE_WORDS_PER_SEC = 0.8
|
|
35
|
+
MAX_PLAUSIBLE_WORDS_PER_SEC = 5.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def speaking_rate_sanity_check(transcript: str, duration_sec: float) -> Tuple[Optional[float], list]:
|
|
39
|
+
"""
|
|
40
|
+
Returns (words_per_second, issues). issues is empty if the rate looks
|
|
41
|
+
plausible; contains an ALIGNMENT_SUSPECT string if not.
|
|
42
|
+
"""
|
|
43
|
+
if not transcript or duration_sec is None or duration_sec <= 0:
|
|
44
|
+
return None, []
|
|
45
|
+
|
|
46
|
+
word_count = len(transcript.strip().split())
|
|
47
|
+
if word_count == 0:
|
|
48
|
+
return None, []
|
|
49
|
+
|
|
50
|
+
rate = word_count / duration_sec
|
|
51
|
+
issues = []
|
|
52
|
+
if rate < MIN_PLAUSIBLE_WORDS_PER_SEC:
|
|
53
|
+
issues.append(
|
|
54
|
+
f"ALIGNMENT_SUSPECT: {word_count} words over {duration_sec:.1f}s "
|
|
55
|
+
f"({rate:.2f} words/sec) is implausibly slow -- transcript may belong "
|
|
56
|
+
f"to a different/longer recording"
|
|
57
|
+
)
|
|
58
|
+
elif rate > MAX_PLAUSIBLE_WORDS_PER_SEC:
|
|
59
|
+
issues.append(
|
|
60
|
+
f"ALIGNMENT_SUSPECT: {word_count} words over {duration_sec:.1f}s "
|
|
61
|
+
f"({rate:.2f} words/sec) is implausibly fast -- transcript may belong "
|
|
62
|
+
f"to a different/shorter recording"
|
|
63
|
+
)
|
|
64
|
+
return rate, issues
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run_external_aligner(wav_path: Path, transcript: str, aligner_cmd: Optional[str] = None):
|
|
68
|
+
"""
|
|
69
|
+
Hook for plugging in a real forced aligner (e.g. Montreal Forced Aligner).
|
|
70
|
+
Not implemented by default -- raises NotImplementedError so it's obvious
|
|
71
|
+
this needs to be filled in for your environment rather than silently
|
|
72
|
+
doing nothing. Example sketch for MFA (once installed and configured):
|
|
73
|
+
|
|
74
|
+
import subprocess, json
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
["mfa", "align_one", str(wav_path), transcript_file, dictionary, acoustic_model, out_dir],
|
|
77
|
+
capture_output=True, text=True
|
|
78
|
+
)
|
|
79
|
+
# parse out_dir/*.TextGrid for word-level timestamps, then compare
|
|
80
|
+
# against your expected segment boundaries.
|
|
81
|
+
"""
|
|
82
|
+
raise NotImplementedError(
|
|
83
|
+
"run_external_aligner() is a hook, not an implementation -- wire in "
|
|
84
|
+
"your installed forced aligner (e.g. MFA) here if you need true "
|
|
85
|
+
"word-level timestamp verification. speaking_rate_sanity_check() "
|
|
86
|
+
"above is the lightweight alternative that ships and runs by default."
|
|
87
|
+
)
|
wavewash/audio_fixer.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""
|
|
2
|
+
audio_fixer.py
|
|
3
|
+
---------------
|
|
4
|
+
Attempts to automatically repair the issues quality_checker.py finds.
|
|
5
|
+
Every fix function takes (audio, sr) and returns (new_audio, new_sr, fix_description or None).
|
|
6
|
+
If a fix isn't possible/safe, it returns the audio unchanged and no description --
|
|
7
|
+
the caller decides whether the *remaining* issue is fatal.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import librosa
|
|
12
|
+
import soundfile as sf
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Tuple, Optional, List
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import pyloudnorm as pyln
|
|
18
|
+
HAVE_PYLOUDNORM = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
HAVE_PYLOUDNORM = False
|
|
21
|
+
|
|
22
|
+
from .config import PurposeProfile
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fix_resample(audio: np.ndarray, sr: int, target_sr: int) -> Tuple[np.ndarray, int, Optional[str]]:
|
|
26
|
+
if sr == target_sr:
|
|
27
|
+
return audio, sr, None
|
|
28
|
+
if audio.ndim == 1:
|
|
29
|
+
new_audio = librosa.resample(audio.astype(np.float32), orig_sr=sr, target_sr=target_sr)
|
|
30
|
+
else:
|
|
31
|
+
chans = [librosa.resample(audio[:, c].astype(np.float32), orig_sr=sr, target_sr=target_sr)
|
|
32
|
+
for c in range(audio.shape[1])]
|
|
33
|
+
new_audio = np.stack(chans, axis=1)
|
|
34
|
+
return new_audio, target_sr, f"resampled {sr}Hz -> {target_sr}Hz"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fix_channels(audio: np.ndarray, target_channels: int) -> Tuple[np.ndarray, Optional[str]]:
|
|
38
|
+
cur_channels = 1 if audio.ndim == 1 else audio.shape[1]
|
|
39
|
+
if cur_channels == target_channels:
|
|
40
|
+
return audio, None
|
|
41
|
+
if target_channels == 1 and cur_channels > 1:
|
|
42
|
+
return np.mean(audio, axis=1), f"downmixed {cur_channels}ch -> mono"
|
|
43
|
+
if target_channels == 2 and cur_channels == 1:
|
|
44
|
+
return np.stack([audio, audio], axis=1), "upmixed mono -> stereo (duplicated channel)"
|
|
45
|
+
return audio, None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fix_dc_offset(audio: np.ndarray) -> Tuple[np.ndarray, Optional[str]]:
|
|
49
|
+
if audio.ndim == 1:
|
|
50
|
+
offset = np.mean(audio)
|
|
51
|
+
if abs(offset) < 1e-5:
|
|
52
|
+
return audio, None
|
|
53
|
+
return audio - offset, f"removed DC offset ({offset:.4f})"
|
|
54
|
+
offsets = np.mean(audio, axis=0)
|
|
55
|
+
if np.max(np.abs(offsets)) < 1e-5:
|
|
56
|
+
return audio, None
|
|
57
|
+
return audio - offsets, f"removed DC offset (per-channel)"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def fix_trim_silence(audio: np.ndarray, sr: int, top_db: float = 35.0) -> Tuple[np.ndarray, Optional[str]]:
|
|
61
|
+
mono_for_detection = audio if audio.ndim == 1 else np.mean(audio, axis=1)
|
|
62
|
+
try:
|
|
63
|
+
_, index = librosa.effects.trim(mono_for_detection.astype(np.float32), top_db=top_db)
|
|
64
|
+
except Exception:
|
|
65
|
+
return audio, None
|
|
66
|
+
start, end = index
|
|
67
|
+
if start == 0 and end == len(mono_for_detection):
|
|
68
|
+
return audio, None
|
|
69
|
+
trimmed = audio[start:end] if audio.ndim == 1 else audio[start:end, :]
|
|
70
|
+
if len(trimmed) < 0.05 * sr: # don't trim down to almost nothing
|
|
71
|
+
return audio, None
|
|
72
|
+
return trimmed, f"trimmed leading/trailing silence ({start/sr:.2f}s / " \
|
|
73
|
+
f"{(len(mono_for_detection)-end)/sr:.2f}s removed)"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fix_loudness(audio: np.ndarray, sr: int, target_lufs: float) -> Tuple[np.ndarray, Optional[str]]:
|
|
77
|
+
if not HAVE_PYLOUDNORM:
|
|
78
|
+
return audio, None
|
|
79
|
+
try:
|
|
80
|
+
meter = pyln.Meter(sr)
|
|
81
|
+
data = audio if audio.ndim > 1 else audio.reshape(-1, 1)
|
|
82
|
+
current_lufs = meter.integrated_loudness(data)
|
|
83
|
+
if current_lufs == float("-inf"):
|
|
84
|
+
return audio, None
|
|
85
|
+
normalized = pyln.normalize.loudness(data, current_lufs, target_lufs)
|
|
86
|
+
# prevent new clipping introduced by normalization
|
|
87
|
+
peak = np.max(np.abs(normalized))
|
|
88
|
+
if peak > 0.999:
|
|
89
|
+
normalized = normalized * (0.999 / peak)
|
|
90
|
+
result = normalized if audio.ndim > 1 else normalized.reshape(-1)
|
|
91
|
+
return result, f"normalized loudness {current_lufs:.1f} -> {target_lufs:.1f} LUFS"
|
|
92
|
+
except Exception:
|
|
93
|
+
return audio, None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def fix_declip(audio: np.ndarray, sr: int, max_run_ms: float = 8.0, threshold: float = 0.999) -> Tuple[np.ndarray, Optional[str]]:
|
|
97
|
+
"""
|
|
98
|
+
De-clip by interpolating across clipped runs up to `max_run_ms` long.
|
|
99
|
+
Runs longer than that are left alone -- too much of the original waveform
|
|
100
|
+
is genuinely gone at that point; interpolating across it would be
|
|
101
|
+
fabricating audio, not repairing it, and the file's overall clipped_ratio
|
|
102
|
+
should fail the quality check and get rejected instead.
|
|
103
|
+
|
|
104
|
+
Uses cubic interpolation (via scipy) when there are enough surrounding
|
|
105
|
+
samples to fit one -- a meaningfully smoother, more natural-sounding
|
|
106
|
+
repair than a straight line for anything beyond a couple of samples.
|
|
107
|
+
Falls back to linear interpolation for very short runs or right at the
|
|
108
|
+
start/end of the file, where a cubic fit isn't well-defined.
|
|
109
|
+
|
|
110
|
+
NOTE ON SCOPE: this is still parameter-free interpolation, not a real
|
|
111
|
+
declipping model (e.g. sparse-reconstruction or consistent-signal-recovery
|
|
112
|
+
algorithms). It's a solid, fast improvement over pure linear interpolation
|
|
113
|
+
within the run lengths it targets -- not a substitute for a dedicated
|
|
114
|
+
restoration tool on heavily clipped material.
|
|
115
|
+
"""
|
|
116
|
+
max_run_samples = max(1, int(max_run_ms / 1000 * sr))
|
|
117
|
+
|
|
118
|
+
def declip_channel(x: np.ndarray) -> Tuple[np.ndarray, int, int]:
|
|
119
|
+
clipped_mask = np.abs(x) >= threshold
|
|
120
|
+
if not clipped_mask.any():
|
|
121
|
+
return x, 0, 0
|
|
122
|
+
x = x.copy()
|
|
123
|
+
n = len(x)
|
|
124
|
+
i = 0
|
|
125
|
+
fixed_runs = 0
|
|
126
|
+
skipped_runs = 0
|
|
127
|
+
while i < n:
|
|
128
|
+
if clipped_mask[i]:
|
|
129
|
+
j = i
|
|
130
|
+
while j < n and clipped_mask[j]:
|
|
131
|
+
j += 1
|
|
132
|
+
run_len = j - i
|
|
133
|
+
if run_len > max_run_samples:
|
|
134
|
+
skipped_runs += 1
|
|
135
|
+
i = j
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# gather a few good samples on either side for a cubic fit
|
|
139
|
+
context = 3
|
|
140
|
+
left_start = max(0, i - context)
|
|
141
|
+
right_end = min(n, j + context)
|
|
142
|
+
have_left = i - left_start
|
|
143
|
+
have_right = right_end - j
|
|
144
|
+
|
|
145
|
+
if run_len >= 3 and have_left >= 2 and have_right >= 2:
|
|
146
|
+
try:
|
|
147
|
+
from scipy.interpolate import CubicSpline
|
|
148
|
+
xs = np.concatenate([np.arange(left_start, i), np.arange(j, right_end)])
|
|
149
|
+
ys = np.concatenate([x[left_start:i], x[j:right_end]])
|
|
150
|
+
cs = CubicSpline(xs, ys)
|
|
151
|
+
x[i:j] = cs(np.arange(i, j))
|
|
152
|
+
except Exception:
|
|
153
|
+
left = x[i - 1] if i > 0 else 0.0
|
|
154
|
+
right = x[j] if j < n else 0.0
|
|
155
|
+
x[i:j] = np.linspace(left, right, run_len)
|
|
156
|
+
else:
|
|
157
|
+
left = x[i - 1] if i > 0 else 0.0
|
|
158
|
+
right = x[j] if j < n else 0.0
|
|
159
|
+
x[i:j] = np.linspace(left, right, run_len)
|
|
160
|
+
|
|
161
|
+
# a repaired stretch shouldn't itself re-clip -- keep it just under scale
|
|
162
|
+
x[i:j] = np.clip(x[i:j], -0.999, 0.999)
|
|
163
|
+
fixed_runs += 1
|
|
164
|
+
i = j
|
|
165
|
+
else:
|
|
166
|
+
i += 1
|
|
167
|
+
return x, fixed_runs, skipped_runs
|
|
168
|
+
|
|
169
|
+
if audio.ndim == 1:
|
|
170
|
+
fixed, runs, skipped = declip_channel(audio)
|
|
171
|
+
else:
|
|
172
|
+
fixed = audio.copy()
|
|
173
|
+
runs = skipped = 0
|
|
174
|
+
for c in range(audio.shape[1]):
|
|
175
|
+
fixed[:, c], r, s = declip_channel(audio[:, c])
|
|
176
|
+
runs += r
|
|
177
|
+
skipped += s
|
|
178
|
+
|
|
179
|
+
if runs == 0:
|
|
180
|
+
return audio, None
|
|
181
|
+
desc = f"interpolated {runs} clipped run(s) (up to {max_run_ms:.0f}ms each)"
|
|
182
|
+
if skipped:
|
|
183
|
+
desc += f"; {skipped} longer run(s) left untouched (too long to repair honestly)"
|
|
184
|
+
return fixed, desc
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def fix_denoise(audio: np.ndarray, sr: int, strength: float = 0.8) -> Tuple[np.ndarray, Optional[str]]:
|
|
188
|
+
"""
|
|
189
|
+
Spectral-subtraction noise reduction (repair for LOW_SNR). The noise profile is
|
|
190
|
+
measured from the quietest ~15% of the clip, so it adapts to each recording.
|
|
191
|
+
Pure numpy/librosa -- no extra dependency.
|
|
192
|
+
|
|
193
|
+
Honest limits: classical DSP, not a trained model. Good on steady background noise
|
|
194
|
+
(hiss, hum, fans, room tone); weak on non-stationary noise (traffic, other voices,
|
|
195
|
+
music bleed). The result is only kept if it measurably improves the estimated SNR --
|
|
196
|
+
otherwise the original is returned and NO fix is claimed, so a failed repair can
|
|
197
|
+
never masquerade as a success.
|
|
198
|
+
"""
|
|
199
|
+
from scipy.ndimage import uniform_filter
|
|
200
|
+
from .quality_checker import estimate_snr_db
|
|
201
|
+
|
|
202
|
+
n_fft, hop = 2048, 512
|
|
203
|
+
min_gain = 10 ** (-25 / 20) # never remove more than ~25 dB in any bin
|
|
204
|
+
|
|
205
|
+
def process(x: np.ndarray) -> np.ndarray:
|
|
206
|
+
x = x.astype(np.float32)
|
|
207
|
+
S = librosa.stft(x, n_fft=n_fft, hop_length=hop)
|
|
208
|
+
mag, phase = np.abs(S), np.angle(S)
|
|
209
|
+
# noise profile from the quietest frames
|
|
210
|
+
frame_energy = mag.mean(axis=0)
|
|
211
|
+
k = max(2, int(0.15 * mag.shape[1]))
|
|
212
|
+
quiet = mag[:, np.argsort(frame_energy)[:k]]
|
|
213
|
+
noise_est = quiet.mean(axis=1, keepdims=True) + 0.5 * quiet.std(axis=1, keepdims=True)
|
|
214
|
+
gain = 1.0 - float(strength) * (noise_est / (mag + 1e-9))
|
|
215
|
+
gain = np.clip(gain, min_gain, 1.0)
|
|
216
|
+
gain = uniform_filter(gain, size=(3, 3)) # smooth to avoid "musical noise"
|
|
217
|
+
y = librosa.istft(mag * gain * np.exp(1j * phase), hop_length=hop, length=len(x))
|
|
218
|
+
return y.astype(np.float32)
|
|
219
|
+
|
|
220
|
+
mono_before = audio if audio.ndim == 1 else audio.mean(axis=1)
|
|
221
|
+
snr_before = estimate_snr_db(mono_before.astype(np.float32), sr)
|
|
222
|
+
|
|
223
|
+
if audio.ndim == 1:
|
|
224
|
+
cleaned = process(audio)
|
|
225
|
+
else:
|
|
226
|
+
cleaned = np.stack([process(audio[:, c]) for c in range(audio.shape[1])], axis=1)
|
|
227
|
+
|
|
228
|
+
mono_after = cleaned if cleaned.ndim == 1 else cleaned.mean(axis=1)
|
|
229
|
+
snr_after = estimate_snr_db(mono_after, sr)
|
|
230
|
+
if snr_after < snr_before + 1.0:
|
|
231
|
+
return audio, None
|
|
232
|
+
return cleaned, f"reduced background noise (estimated SNR {snr_before:.1f} -> {snr_after:.1f} dB)"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def apply_fix_pipeline(audio: np.ndarray, sr: int, profile: PurposeProfile,
|
|
236
|
+
issues: List[str]) -> Tuple[np.ndarray, int, List[str]]:
|
|
237
|
+
"""
|
|
238
|
+
Runs the relevant fixers in a sensible order based on which issues were found
|
|
239
|
+
(and the profile's on/off flags). Returns (fixed_audio, fixed_sr, list_of_fix_descriptions).
|
|
240
|
+
"""
|
|
241
|
+
applied = []
|
|
242
|
+
|
|
243
|
+
if profile.attempt_declip and any("CLIPPING" in i for i in issues):
|
|
244
|
+
audio, desc = fix_declip(audio, sr, max_run_ms=profile.declip_max_run_ms)
|
|
245
|
+
if desc:
|
|
246
|
+
applied.append(desc)
|
|
247
|
+
|
|
248
|
+
if profile.auto_fix_dc_offset and any("DC_OFFSET" in i for i in issues):
|
|
249
|
+
audio, desc = fix_dc_offset(audio)
|
|
250
|
+
if desc:
|
|
251
|
+
applied.append(desc)
|
|
252
|
+
|
|
253
|
+
if getattr(profile, "enable_denoise", False) and any("LOW_SNR" in i for i in issues):
|
|
254
|
+
audio, desc = fix_denoise(audio, sr, strength=getattr(profile, "denoise_strength", 0.8))
|
|
255
|
+
if desc:
|
|
256
|
+
applied.append(desc)
|
|
257
|
+
|
|
258
|
+
if profile.trim_silence_edges:
|
|
259
|
+
audio, desc = fix_trim_silence(audio, sr)
|
|
260
|
+
if desc:
|
|
261
|
+
applied.append(desc)
|
|
262
|
+
|
|
263
|
+
if profile.auto_resample:
|
|
264
|
+
audio, sr, desc = fix_resample(audio, sr, profile.target_sample_rate)
|
|
265
|
+
if desc:
|
|
266
|
+
applied.append(desc)
|
|
267
|
+
|
|
268
|
+
audio, desc = fix_channels(audio, profile.target_channels)
|
|
269
|
+
if desc:
|
|
270
|
+
applied.append(desc)
|
|
271
|
+
|
|
272
|
+
if profile.auto_normalize_loudness:
|
|
273
|
+
audio, desc = fix_loudness(audio, sr, profile.target_lufs)
|
|
274
|
+
if desc:
|
|
275
|
+
applied.append(desc)
|
|
276
|
+
|
|
277
|
+
return audio, sr, applied
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def save_audio(audio: np.ndarray, sr: int, out_path: Path, bit_depth: int = 16):
|
|
281
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
subtype = "PCM_16" if bit_depth == 16 else "PCM_24"
|
|
283
|
+
sf.write(str(out_path), audio, sr, subtype=subtype)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
audio_scanner.py
|
|
3
|
+
-----------------
|
|
4
|
+
Locates audio files no matter how the user points at them:
|
|
5
|
+
- a single .wav file
|
|
6
|
+
- a flat folder full of .wav files
|
|
7
|
+
- a "main folder" containing many subfolders, each with .wav files, to any depth
|
|
8
|
+
|
|
9
|
+
Usage is deliberately forgiving: pass any path and it figures out what it is.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List
|
|
14
|
+
from .config import SUPPORTED_EXTENSIONS, EXTRA_READABLE_EXTENSIONS
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def find_audio_files(input_path: str, include_extra_formats: bool = False) -> List[Path]:
|
|
18
|
+
"""
|
|
19
|
+
Returns a sorted list of audio file paths found at input_path.
|
|
20
|
+
|
|
21
|
+
- If input_path is a single file, returns [that file] (if it's a supported type).
|
|
22
|
+
- If input_path is a directory, walks it recursively (any depth of subfolders)
|
|
23
|
+
and returns every matching audio file found.
|
|
24
|
+
"""
|
|
25
|
+
p = Path(input_path).expanduser().resolve()
|
|
26
|
+
exts = set(SUPPORTED_EXTENSIONS)
|
|
27
|
+
if include_extra_formats:
|
|
28
|
+
exts |= EXTRA_READABLE_EXTENSIONS
|
|
29
|
+
|
|
30
|
+
if not p.exists():
|
|
31
|
+
raise FileNotFoundError(f"Input path does not exist: {p}")
|
|
32
|
+
|
|
33
|
+
if p.is_file():
|
|
34
|
+
if p.suffix.lower() in exts:
|
|
35
|
+
return [p]
|
|
36
|
+
raise ValueError(f"'{p}' is not a supported audio file "
|
|
37
|
+
f"(supported: {sorted(exts)})")
|
|
38
|
+
|
|
39
|
+
if p.is_dir():
|
|
40
|
+
found = []
|
|
41
|
+
for ext in exts:
|
|
42
|
+
found.extend(p.rglob(f"*{ext}"))
|
|
43
|
+
found.extend(p.rglob(f"*{ext.upper()}"))
|
|
44
|
+
# de-dup (case-insensitive glob can double up on some filesystems) and sort;
|
|
45
|
+
# skip macOS resource-fork junk ("._name.wav", __MACOSX/) that unzipping leaves behind --
|
|
46
|
+
# those aren't audio and would otherwise show up as "corrupt" files.
|
|
47
|
+
found = sorted(f for f in set(found)
|
|
48
|
+
if not f.name.startswith("._") and "__MACOSX" not in f.parts)
|
|
49
|
+
return found
|
|
50
|
+
|
|
51
|
+
raise ValueError(f"Unrecognized path type: {p}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def describe_input(files: List[Path], input_path: str) -> str:
|
|
55
|
+
"""Small human-readable summary of what was found, for logging."""
|
|
56
|
+
p = Path(input_path)
|
|
57
|
+
if p.is_file():
|
|
58
|
+
return f"Single file provided: {p.name}"
|
|
59
|
+
subfolders = {f.parent for f in files}
|
|
60
|
+
return (f"Folder scan of '{p}': found {len(files)} audio file(s) "
|
|
61
|
+
f"across {len(subfolders)} folder(s) (including subfolders).")
|
wavewash/cli.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""wavewash command line entry point.
|
|
2
|
+
|
|
3
|
+
wavewash app # launch the web UI on http://127.0.0.1:8420
|
|
4
|
+
wavewash run ... # run the QC pipeline from the terminal (same flags as before)
|
|
5
|
+
wavewash doctor # check that the install works and show optional extras
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import importlib
|
|
9
|
+
import importlib.util
|
|
10
|
+
import platform
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
|
|
15
|
+
_REQUIRED = ["numpy", "scipy", "soundfile", "librosa", "pyloudnorm", "sklearn", "tqdm",
|
|
16
|
+
"fastapi", "uvicorn", "pydantic"]
|
|
17
|
+
_OPTIONAL = {
|
|
18
|
+
"langdetect": "language check for transcripts",
|
|
19
|
+
"matplotlib": "plots in reports",
|
|
20
|
+
"torch": "Silero VAD (--use-real-vad)",
|
|
21
|
+
"whisper": "Whisper WER check (--asr-model whisper)",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _doctor() -> int:
|
|
26
|
+
print(f"wavewash {__version__} | Python {platform.python_version()} | {platform.platform()}")
|
|
27
|
+
bad = 0
|
|
28
|
+
print("\nRequired:")
|
|
29
|
+
for m in _REQUIRED:
|
|
30
|
+
try:
|
|
31
|
+
mod = importlib.import_module(m)
|
|
32
|
+
print(f" ok {m} {getattr(mod, '__version__', '')}")
|
|
33
|
+
except Exception as e: # noqa: BLE001
|
|
34
|
+
bad += 1
|
|
35
|
+
print(f" MISSING {m} ({e})")
|
|
36
|
+
print("\nOptional extras (not needed for the default checks):")
|
|
37
|
+
for m, why in _OPTIONAL.items():
|
|
38
|
+
found = importlib.util.find_spec(m) is not None
|
|
39
|
+
print(f" {'ok ' if found else 'absent '} {m:<11} {why}")
|
|
40
|
+
try:
|
|
41
|
+
import soundfile as sf
|
|
42
|
+
print(f"\nlibsndfile: {sf.__libsndfile_version__}")
|
|
43
|
+
except Exception: # noqa: BLE001
|
|
44
|
+
pass
|
|
45
|
+
from .server import FRONTEND_DIST
|
|
46
|
+
ui = (FRONTEND_DIST / "index.html").exists()
|
|
47
|
+
print(f"Bundled web UI: {'present' if ui else 'MISSING'}")
|
|
48
|
+
bad += 0 if ui else 1
|
|
49
|
+
print("\nResult:", "all good" if not bad else f"{bad} problem(s) found")
|
|
50
|
+
return 1 if bad else 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv=None) -> int:
|
|
54
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
55
|
+
parser = argparse.ArgumentParser(prog="wavewash", description="WaveWash — audio QC & data prep for ML training data")
|
|
56
|
+
parser.add_argument("--version", action="version", version=f"wavewash {__version__}")
|
|
57
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
58
|
+
pa = sub.add_parser("app", help="launch the web UI (local only)")
|
|
59
|
+
pa.add_argument("--port", type=int, default=8420)
|
|
60
|
+
pa.add_argument("--no-browser", action="store_true", help="don't open a browser tab")
|
|
61
|
+
sub.add_parser("run", help="run the QC pipeline (pass --help after 'run' for its flags)", add_help=False)
|
|
62
|
+
sub.add_parser("doctor", help="check the installation")
|
|
63
|
+
|
|
64
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
65
|
+
parser.print_help()
|
|
66
|
+
return 0
|
|
67
|
+
if argv[0] == "run":
|
|
68
|
+
from .pipeline import main as run_main
|
|
69
|
+
run_main(argv[1:])
|
|
70
|
+
return 0
|
|
71
|
+
args = parser.parse_args(argv)
|
|
72
|
+
if args.cmd == "doctor":
|
|
73
|
+
return _doctor()
|
|
74
|
+
if args.cmd == "app":
|
|
75
|
+
from .server import serve
|
|
76
|
+
serve(port=args.port, open_browser=not args.no_browser)
|
|
77
|
+
return 0
|
|
78
|
+
parser.print_help()
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
raise SystemExit(main())
|