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 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)