sonicproof 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.
- sonicproof/__init__.py +1 -0
- sonicproof/audio.py +284 -0
- sonicproof/cache.py +87 -0
- sonicproof/cli.py +978 -0
- sonicproof/data/refbank.npz +0 -0
- sonicproof/errors.py +40 -0
- sonicproof/judge.py +411 -0
- sonicproof/library.py +669 -0
- sonicproof/mcp_server.py +186 -0
- sonicproof/model.py +284 -0
- sonicproof/qc.py +530 -0
- sonicproof/server.py +1231 -0
- sonicproof/service.py +505 -0
- sonicproof/skill/SKILL.md +117 -0
- sonicproof/thumbs.py +82 -0
- sonicproof/visual.py +166 -0
- sonicproof/vocab.py +494 -0
- sonicproof/web/assets/index-CiLt6BfN.css +1 -0
- sonicproof/web/assets/index-j8h5oOAC.js +3 -0
- sonicproof/web/index.html +15 -0
- sonicproof-0.1.0.dist-info/METADATA +341 -0
- sonicproof-0.1.0.dist-info/RECORD +26 -0
- sonicproof-0.1.0.dist-info/WHEEL +4 -0
- sonicproof-0.1.0.dist-info/entry_points.txt +3 -0
- sonicproof-0.1.0.dist-info/licenses/LICENSE +202 -0
- sonicproof-0.1.0.dist-info/licenses/NOTICE +18 -0
sonicproof/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
sonicproof/audio.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Audio I/O: decode anything (soundfile, falling back to ffmpeg) into float32 arrays."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import soundfile as sf
|
|
14
|
+
from scipy.signal import resample_poly
|
|
15
|
+
|
|
16
|
+
from .errors import InputError, SetupError
|
|
17
|
+
|
|
18
|
+
MODEL_SR = 16_000
|
|
19
|
+
SILENCE_DB = -60.0 # frames (10 ms RMS) at or below this are silence
|
|
20
|
+
MIN_SIGNAL_S = (
|
|
21
|
+
0.01 # less sound than one 10 ms frame above SILENCE_DB counts as silent (a 50 ms click is not)
|
|
22
|
+
)
|
|
23
|
+
_CHUNK = 1 << 20 # samples per block when scanning long files, to keep memory flat
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Audio:
|
|
28
|
+
"""Decoded audio. ``data`` is (frames, channels) float32 in [-1, 1]."""
|
|
29
|
+
|
|
30
|
+
data: np.ndarray
|
|
31
|
+
sr: int
|
|
32
|
+
path: Path | None = None
|
|
33
|
+
subtype: str | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def channels(self) -> int:
|
|
37
|
+
return self.data.shape[1]
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def duration(self) -> float:
|
|
41
|
+
return self.data.shape[0] / self.sr
|
|
42
|
+
|
|
43
|
+
def mono(self) -> np.ndarray:
|
|
44
|
+
"""Channel average for the model. NaN / inf samples (float files) become 0 / ±1 so they cannot
|
|
45
|
+
poison the embedding; qc still sees and reports them in ``data``."""
|
|
46
|
+
m = self.data.mean(axis=1).astype(np.float32)
|
|
47
|
+
return m if np.isfinite(m).all() else np.nan_to_num(m, nan=0.0, posinf=1.0, neginf=-1.0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def frame_rms_db(x: np.ndarray, sr: int, ms: float = 10.0) -> np.ndarray:
|
|
51
|
+
"""RMS level (dBFS) of consecutive ``ms`` frames. ``x`` is (frames,) or (frames, channels); for several
|
|
52
|
+
channels the frame RMS is taken over all of them, so out-of-phase stereo does not cancel. A file shorter
|
|
53
|
+
than one frame gives a single value; an empty one gives ``[-240]``."""
|
|
54
|
+
x = x if x.ndim == 2 else x[:, None]
|
|
55
|
+
n = max(1, int(sr * ms / 1000))
|
|
56
|
+
frames = len(x) // n
|
|
57
|
+
if frames == 0:
|
|
58
|
+
ms_all = float(np.mean(x.astype(np.float64) ** 2)) if x.size else 0.0
|
|
59
|
+
return np.array([20 * np.log10(max(np.sqrt(ms_all), 1e-12))])
|
|
60
|
+
out = np.empty(frames)
|
|
61
|
+
step = max(1, _CHUNK // n) # frames per block
|
|
62
|
+
for f0 in range(0, frames, step):
|
|
63
|
+
f1 = min(frames, f0 + step)
|
|
64
|
+
blk = x[f0 * n : f1 * n].reshape(f1 - f0, n, x.shape[1])
|
|
65
|
+
out[f0:f1] = np.square(blk, dtype=np.float32).mean(axis=(1, 2))
|
|
66
|
+
return 20 * np.log10(np.maximum(np.sqrt(out), 1e-12))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_silent(a: Audio | np.ndarray, sr: int | None = None) -> bool:
|
|
70
|
+
"""True for audio with (almost) nothing in it: no 10 ms frame above -60 dBFS (the same rule as qc's
|
|
71
|
+
silence check; short one-shots such as UI clicks are not silent). Takes an :class:`Audio` or a raw
|
|
72
|
+
array (pass ``sr``; defaults to the model's 16 kHz)."""
|
|
73
|
+
if isinstance(a, Audio):
|
|
74
|
+
x, sr = a.data, a.sr
|
|
75
|
+
else:
|
|
76
|
+
x, sr = np.asarray(a), sr or MODEL_SR
|
|
77
|
+
if x.size == 0:
|
|
78
|
+
return True
|
|
79
|
+
x = np.nan_to_num(x, nan=0.0, posinf=1.0, neginf=-1.0) if not np.isfinite(x).all() else x
|
|
80
|
+
env = frame_rms_db(x, sr, 10)
|
|
81
|
+
frame_s = max(1, int(sr * 0.01)) / sr
|
|
82
|
+
return float(np.count_nonzero(env > SILENCE_DB)) * min(frame_s, len(x) / sr) < MIN_SIGNAL_S - 1e-9
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_NO_AUDIO = ("does not contain any stream", "matches no streams", "no audio stream", "Output file is empty")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _ffmpeg_decode(path: Path, sr: int | None = None, mono: bool = False) -> tuple[np.ndarray, int]:
|
|
89
|
+
if shutil.which("ffmpeg") is None:
|
|
90
|
+
raise SetupError(
|
|
91
|
+
f"Cannot read {path.name}: this format needs ffmpeg, which is not installed "
|
|
92
|
+
"(brew install ffmpeg / apt install ffmpeg).",
|
|
93
|
+
code="needs_ffmpeg",
|
|
94
|
+
file=str(path),
|
|
95
|
+
)
|
|
96
|
+
probe_sr = sr or _ffprobe_sr(path) or 48_000
|
|
97
|
+
channels = 1 if mono else (_ffprobe_channels(path) or 2)
|
|
98
|
+
cmd = [
|
|
99
|
+
"ffmpeg",
|
|
100
|
+
"-v",
|
|
101
|
+
"error",
|
|
102
|
+
"-nostdin",
|
|
103
|
+
"-i",
|
|
104
|
+
str(path),
|
|
105
|
+
"-vn",
|
|
106
|
+
"-f",
|
|
107
|
+
"f32le",
|
|
108
|
+
"-acodec",
|
|
109
|
+
"pcm_f32le",
|
|
110
|
+
"-ar",
|
|
111
|
+
str(probe_sr),
|
|
112
|
+
"-ac",
|
|
113
|
+
str(channels),
|
|
114
|
+
"-",
|
|
115
|
+
]
|
|
116
|
+
out = subprocess.run(cmd, capture_output=True, check=False)
|
|
117
|
+
stderr = out.stderr.decode(errors="ignore").strip()
|
|
118
|
+
if out.returncode != 0:
|
|
119
|
+
# keep ffmpeg's own (long) output as the cause, not in the one-line message
|
|
120
|
+
cause = RuntimeError(f"ffmpeg: {stderr}")
|
|
121
|
+
if any(s in stderr for s in _NO_AUDIO):
|
|
122
|
+
raise InputError(f"{path.name} has no audio track.", code="no_audio", file=str(path)) from cause
|
|
123
|
+
raise InputError(
|
|
124
|
+
f"Cannot read {path.name}: not a supported audio/video file, or it is damaged.",
|
|
125
|
+
code="unreadable",
|
|
126
|
+
file=str(path),
|
|
127
|
+
) from cause
|
|
128
|
+
usable = len(out.stdout) // (4 * channels) * 4 * channels
|
|
129
|
+
data = np.frombuffer(out.stdout[:usable], dtype=np.float32).reshape(-1, channels)
|
|
130
|
+
return data.copy(), probe_sr
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _ffprobe(path: Path, entry: str) -> int | None:
|
|
134
|
+
if shutil.which("ffprobe") is None:
|
|
135
|
+
return None
|
|
136
|
+
out = subprocess.run(
|
|
137
|
+
[
|
|
138
|
+
"ffprobe",
|
|
139
|
+
"-v",
|
|
140
|
+
"error",
|
|
141
|
+
"-select_streams",
|
|
142
|
+
"a:0",
|
|
143
|
+
"-show_entries",
|
|
144
|
+
f"stream={entry}",
|
|
145
|
+
"-of",
|
|
146
|
+
"default=nw=1:nk=1",
|
|
147
|
+
str(path),
|
|
148
|
+
],
|
|
149
|
+
capture_output=True,
|
|
150
|
+
text=True,
|
|
151
|
+
check=False,
|
|
152
|
+
)
|
|
153
|
+
try:
|
|
154
|
+
return int(out.stdout.strip().splitlines()[0])
|
|
155
|
+
except (ValueError, IndexError):
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _ffprobe_sr(path: Path) -> int | None:
|
|
160
|
+
return _ffprobe(path, "sample_rate")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _ffprobe_channels(path: Path) -> int | None:
|
|
164
|
+
return _ffprobe(path, "channels")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Formats libsndfile decodes without ffmpeg.
|
|
168
|
+
_SNDFILE_NATIVE = {
|
|
169
|
+
".wav",
|
|
170
|
+
".wave",
|
|
171
|
+
".flac",
|
|
172
|
+
".ogg",
|
|
173
|
+
".oga",
|
|
174
|
+
".mp3",
|
|
175
|
+
".aif",
|
|
176
|
+
".aiff",
|
|
177
|
+
".aifc",
|
|
178
|
+
".caf",
|
|
179
|
+
".w64",
|
|
180
|
+
".rf64",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def load(path: str | Path) -> Audio:
|
|
185
|
+
"""Load a file at its native rate and channel count.
|
|
186
|
+
|
|
187
|
+
Raises :class:`InputError` (not_found / is_dir / empty / unreadable / no_audio) for files it can't use and
|
|
188
|
+
:class:`SetupError` (needs_ffmpeg) when the format needs ffmpeg and it is missing (a broken WAV / FLAC /
|
|
189
|
+
OGG / MP3 is ``unreadable`` either way). A valid file with zero
|
|
190
|
+
frames loads fine (``qc`` reports it as ``file.empty``)."""
|
|
191
|
+
path = Path(path)
|
|
192
|
+
if not path.exists():
|
|
193
|
+
raise InputError(f"No such file: {path}", code="not_found", file=str(path))
|
|
194
|
+
if path.is_dir():
|
|
195
|
+
raise InputError(f"{path} is a folder, not an audio file.", code="is_dir", file=str(path))
|
|
196
|
+
try:
|
|
197
|
+
size = path.stat().st_size
|
|
198
|
+
except OSError as e:
|
|
199
|
+
raise InputError(f"Cannot read {path.name}: {e.strerror}.", code="unreadable", file=str(path)) from e
|
|
200
|
+
if size == 0:
|
|
201
|
+
raise InputError(f"{path.name} is empty (0 bytes).", code="empty", file=str(path))
|
|
202
|
+
try:
|
|
203
|
+
info = sf.info(str(path))
|
|
204
|
+
data, sr = sf.read(str(path), dtype="float32", always_2d=True)
|
|
205
|
+
return Audio(data=data, sr=sr, path=path, subtype=info.subtype)
|
|
206
|
+
except (sf.SoundFileError, RuntimeError, TypeError, ValueError) as e:
|
|
207
|
+
if path.suffix.lower() in _SNDFILE_NATIVE and shutil.which("ffmpeg") is None:
|
|
208
|
+
# a format libsndfile reads itself: it is the file that is broken, not a missing decoder
|
|
209
|
+
raise InputError(
|
|
210
|
+
f"Cannot read {path.name}: not a valid {path.suffix.lstrip('.').upper()} file.",
|
|
211
|
+
code="unreadable",
|
|
212
|
+
file=str(path),
|
|
213
|
+
) from e
|
|
214
|
+
data, sr = _ffmpeg_decode(path)
|
|
215
|
+
return Audio(data=data, sr=sr, path=path)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def resample(x: np.ndarray, sr_from: int, sr_to: int) -> np.ndarray:
|
|
219
|
+
if sr_from == sr_to:
|
|
220
|
+
return x.astype(np.float32)
|
|
221
|
+
g = np.gcd(sr_from, sr_to)
|
|
222
|
+
return resample_poly(x, sr_to // g, sr_from // g, axis=0).astype(np.float32)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def load_for_model(path: str | Path) -> np.ndarray:
|
|
226
|
+
"""Mono 16 kHz float32 — the input EmbeddingGemma 2 expects."""
|
|
227
|
+
a = load(path)
|
|
228
|
+
if len(a.data) == 0:
|
|
229
|
+
return np.zeros(0, np.float32)
|
|
230
|
+
return resample(a.mono(), a.sr, MODEL_SR)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _looks_like_sidecar(meta: dict) -> bool:
|
|
234
|
+
return any(k in meta for k in ("prompt", "text", "description", "prompt_used")) or any(
|
|
235
|
+
str(k).startswith("sound_") for k in meta
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def read_sidecar(path: str | Path) -> dict | None:
|
|
240
|
+
"""Stable Audio (and similar) tools write ``<file>.json`` next to the audio; some write ``<stem>.json``.
|
|
241
|
+
|
|
242
|
+
Always returns a dict or None. ``<stem>.json`` is only taken when it carries a prompt-like field
|
|
243
|
+
(prompt / text / description / prompt_used / sound_*), so an unrelated JSON with the same name is
|
|
244
|
+
ignored."""
|
|
245
|
+
path = Path(path)
|
|
246
|
+
exact, stem = path.with_name(path.name + ".json"), path.with_suffix(".json")
|
|
247
|
+
for cand in (exact, stem):
|
|
248
|
+
if cand == path or not cand.is_file():
|
|
249
|
+
continue
|
|
250
|
+
try:
|
|
251
|
+
meta = json.loads(cand.read_text(encoding="utf-8"))
|
|
252
|
+
except (ValueError, OSError): # JSONDecodeError and UnicodeDecodeError are ValueErrors
|
|
253
|
+
continue
|
|
254
|
+
if isinstance(meta, dict) and (cand == exact or _looks_like_sidecar(meta)):
|
|
255
|
+
return meta
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def sidecar_prompt(path: str | Path) -> str | None:
|
|
260
|
+
meta = read_sidecar(path)
|
|
261
|
+
if not meta:
|
|
262
|
+
return None
|
|
263
|
+
parts = []
|
|
264
|
+
for key in ("prompt", "text", "description"):
|
|
265
|
+
val = meta.get(key)
|
|
266
|
+
if isinstance(val, str) and val.strip():
|
|
267
|
+
parts.append(val.strip())
|
|
268
|
+
break
|
|
269
|
+
# Stable Audio SFX: the structured sound_* fields carry most of the description. Only "what" and
|
|
270
|
+
# "how" are used — recording-space words ("outdoor, close-up, dry") blur the match.
|
|
271
|
+
fields = meta.get("sound_fields")
|
|
272
|
+
if isinstance(fields, dict):
|
|
273
|
+
parts += [
|
|
274
|
+
fields[k].strip()
|
|
275
|
+
for k in ("source", "action")
|
|
276
|
+
if isinstance(fields.get(k), str) and fields[k].strip()
|
|
277
|
+
]
|
|
278
|
+
if parts:
|
|
279
|
+
return ", ".join(parts)
|
|
280
|
+
used = meta.get("prompt_used")
|
|
281
|
+
if isinstance(used, str) and used.strip():
|
|
282
|
+
# Drop the trained-in "TrackType: X, VocalType: Y," prefix.
|
|
283
|
+
return re.sub(r"^(\w+Type:\s*[^,]+,\s*)+", "", used.strip())
|
|
284
|
+
return None
|
sonicproof/cache.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""On-disk embedding cache so re-checking a file is instant.
|
|
2
|
+
|
|
3
|
+
Lives in ``SONICPROOF_CACHE``, else ``%LOCALAPPDATA%\\sonicproof\\Cache`` on Windows, else
|
|
4
|
+
``$XDG_CACHE_HOME/sonicproof`` / ``~/.cache/sonicproof``. Writes are atomic (unique temp file + rename) so
|
|
5
|
+
threads and processes can share it; an unreadable entry is simply a miss.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from .model import MODEL_ID, WINDOW_SECONDS, embed_tag
|
|
20
|
+
|
|
21
|
+
CACHE_VERSION = 2 # bump when preprocessing changes (resampling, windowing, frame sampling …)
|
|
22
|
+
EMBED_KINDS = {"audio", "image", "video", "vocab"} # depend on the model's size, dtype and snapshot
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cache_dir() -> Path:
|
|
26
|
+
root = os.environ.get("SONICPROOF_CACHE") or _default_root()
|
|
27
|
+
p = Path(root).expanduser()
|
|
28
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
return p
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _default_root() -> str:
|
|
33
|
+
if sys.platform == "win32" and os.environ.get("LOCALAPPDATA"):
|
|
34
|
+
return os.path.join(os.environ["LOCALAPPDATA"], "sonicproof", "Cache")
|
|
35
|
+
return os.path.join(os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"), "sonicproof")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _key(kind: str, *parts: str) -> str:
|
|
39
|
+
h = hashlib.sha1()
|
|
40
|
+
base = [f"v{CACHE_VERSION}", MODEL_ID, f"w{WINDOW_SECONDS}"]
|
|
41
|
+
if kind in EMBED_KINDS:
|
|
42
|
+
base.append(embed_tag())
|
|
43
|
+
for part in (*base, *parts):
|
|
44
|
+
h.update(part.encode())
|
|
45
|
+
h.update(b"\0")
|
|
46
|
+
return h.hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def file_digest(path: str | Path) -> str:
|
|
50
|
+
h = hashlib.sha1()
|
|
51
|
+
with open(path, "rb") as f:
|
|
52
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
53
|
+
h.update(chunk)
|
|
54
|
+
return h.hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_array(kind: str, *parts: str) -> np.ndarray | None:
|
|
58
|
+
p = cache_dir() / kind / f"{_key(kind, *parts)}.npy"
|
|
59
|
+
try:
|
|
60
|
+
a = np.load(p, allow_pickle=False)
|
|
61
|
+
except Exception: # missing, half-written by an older version, corrupt: treat as a miss
|
|
62
|
+
return None
|
|
63
|
+
if a.dtype.kind == "f" and not np.isfinite(a).all() and kind != "level":
|
|
64
|
+
return None # an embedding with NaN in it (written before inputs were sanitised): recompute
|
|
65
|
+
return a
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_array(arr: np.ndarray, kind: str, *parts: str) -> None:
|
|
69
|
+
if arr.dtype.kind == "f" and not np.isfinite(arr).all() and kind != "level":
|
|
70
|
+
return # never cache a broken embedding
|
|
71
|
+
d = cache_dir() / kind
|
|
72
|
+
d.mkdir(exist_ok=True)
|
|
73
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".", suffix=".tmp")
|
|
74
|
+
dest = d / f"{_key(kind, *parts)}.npy"
|
|
75
|
+
try:
|
|
76
|
+
with os.fdopen(fd, "wb") as f:
|
|
77
|
+
np.save(f, arr)
|
|
78
|
+
for attempt in range(4):
|
|
79
|
+
try:
|
|
80
|
+
os.replace(tmp, dest)
|
|
81
|
+
return
|
|
82
|
+
except PermissionError: # Windows: another process has the old file open for a moment
|
|
83
|
+
if attempt == 3:
|
|
84
|
+
return # best effort: the next run computes it again
|
|
85
|
+
time.sleep(0.05 * (attempt + 1))
|
|
86
|
+
finally:
|
|
87
|
+
Path(tmp).unlink(missing_ok=True)
|