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 ADDED
@@ -0,0 +1,2 @@
1
+ """WaveWash — audio QC & data prep for ML training data."""
2
+ __version__ = "0.1.0a1"
wavewash/__main__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
@@ -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
+ )
@@ -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())