audient 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.
- audient/__init__.py +0 -0
- audient/_cli.py +58 -0
- audient/_version.py +24 -0
- audient/cleaning.py +104 -0
- audient/features.py +190 -0
- audient/logs.py +128 -0
- audient/migrations/0001_session_id.sql +31 -0
- audient/server.py +2122 -0
- audient/static/assets/index-BOr5Eso6.css +1 -0
- audient/static/assets/index-CU8Mh-45.js +125 -0
- audient/static/audio-capture-worklet.js +78 -0
- audient/static/favicon.svg +1 -0
- audient/static/icons.svg +24 -0
- audient/static/index.html +14 -0
- audient/store.py +89 -0
- audient/tools.py +217 -0
- audient-0.1.0.dist-info/METADATA +343 -0
- audient-0.1.0.dist-info/RECORD +75 -0
- audient-0.1.0.dist-info/WHEEL +4 -0
- audient-0.1.0.dist-info/entry_points.txt +2 -0
- audio_http/__init__.py +6 -0
- audio_http/app.py +179 -0
- audio_http/origins.py +41 -0
- audio_http/routes/__init__.py +0 -0
- audio_http/routes/_helpers.py +43 -0
- audio_http/routes/audio.py +86 -0
- audio_http/routes/concepts.py +143 -0
- audio_http/routes/concepts_writes.py +346 -0
- audio_http/routes/control.py +259 -0
- audio_http/routes/events.py +129 -0
- audio_http/routes/events_writes.py +226 -0
- audio_http/routes/growth.py +92 -0
- audio_http/routes/ingest.py +221 -0
- audio_http/routes/specialists.py +51 -0
- audio_http/routes/status.py +34 -0
- audio_http/routes/trace.py +98 -0
- audio_http/routes/turns.py +180 -0
- audio_http/schemas.py +92 -0
- audio_http/sse.py +143 -0
- classifier/__init__.py +2 -0
- classifier/fastagent.config.yaml +50 -0
- classifier/loop.py +460 -0
- classifier/notifications.py +9 -0
- classifier/trace_capture.py +126 -0
- classifier/transcript_capture.py +159 -0
- perception/__init__.py +0 -0
- perception/embeddings.py +107 -0
- perception/pipeline/__init__.py +0 -0
- perception/pipeline/channels.py +78 -0
- perception/pipeline/items.py +119 -0
- perception/pipeline/stages.py +495 -0
- perception/segmentation.py +363 -0
- perception/service.py +2114 -0
- perception/session.py +22 -0
- perception/sources/__init__.py +24 -0
- perception/sources/base.py +38 -0
- perception/sources/file.py +51 -0
- perception/sources/mic.py +55 -0
- perception/sources/mic_default.py +180 -0
- perception/sources/net.py +225 -0
- perception/sources/registry.py +79 -0
- perception/specialists/__init__.py +0 -0
- perception/specialists/ast/adapter.py +90 -0
- perception/specialists/ast/spec.yaml +75 -0
- perception/specialists/base.py +86 -0
- perception/specialists/birdnet/adapter.py +69 -0
- perception/specialists/birdnet/spec.yaml +65 -0
- perception/specialists/registry.py +104 -0
- perception/specialists/whisper/adapter.py +61 -0
- perception/specialists/whisper/spec.yaml +70 -0
- perception/storage/__init__.py +0 -0
- perception/storage/concept_memory.py +1319 -0
- perception/storage/events_db.py +355 -0
- perception/storage/persistence.py +212 -0
- perception/stream.py +1833 -0
audient/__init__.py
ADDED
|
File without changes
|
audient/_cli.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Console-script entry point.
|
|
2
|
+
|
|
3
|
+
Prints a "loading" line before importing anything heavy so users see
|
|
4
|
+
feedback within the first tick, then imports the real ``server.main``.
|
|
5
|
+
Module-top imports in ``server`` pull in librosa, torch (via
|
|
6
|
+
transformers), fastapi, uvicorn, and fastmcp — collectively 1–3 seconds
|
|
7
|
+
of import time on a warm cache, longer on cold. Without this bootstrap
|
|
8
|
+
the user stares at a blank prompt for the full duration and can't tell
|
|
9
|
+
if the process is doing anything.
|
|
10
|
+
|
|
11
|
+
Also parses --log-level / --quiet / --verbose EARLY (before the heavy
|
|
12
|
+
imports) so logging.getLogger(...) calls at module-import time already
|
|
13
|
+
respect the user's chosen level. Otherwise their first N log lines
|
|
14
|
+
would use whatever level the default logger picked up, then flip mid-
|
|
15
|
+
boot when server.main() re-configures.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import logging
|
|
21
|
+
|
|
22
|
+
from .logs import configure, resolve_level
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _peek_log_flags() -> tuple[str | None, bool, bool]:
|
|
26
|
+
"""Extract just the log-related flags from sys.argv without
|
|
27
|
+
committing to the full CLI parser (which lives in server.py and
|
|
28
|
+
can't be imported yet — that would defeat the deferred-import
|
|
29
|
+
point of this bootstrap)."""
|
|
30
|
+
p = argparse.ArgumentParser(add_help=False)
|
|
31
|
+
p.add_argument("--log-level", default=None)
|
|
32
|
+
p.add_argument("-q", "--quiet", action="store_true", default=False)
|
|
33
|
+
p.add_argument("-v", "--verbose", action="store_true", default=False)
|
|
34
|
+
args, _ = p.parse_known_args()
|
|
35
|
+
return args.log_level, args.quiet, args.verbose
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main() -> None:
|
|
39
|
+
cli_level, quiet, verbose = _peek_log_flags()
|
|
40
|
+
configure(level=resolve_level(
|
|
41
|
+
cli_level=cli_level, quiet=quiet, verbose=verbose,
|
|
42
|
+
))
|
|
43
|
+
logger = logging.getLogger("audient")
|
|
44
|
+
logger.info(
|
|
45
|
+
"audient: loading (Python deps take a moment; "
|
|
46
|
+
"first run also downloads ~600MB of model weights)…"
|
|
47
|
+
)
|
|
48
|
+
# Best-effort flush so the loading line reaches the terminal
|
|
49
|
+
# before ~2s of blocking `from .server import ...` begins.
|
|
50
|
+
for h in logging.getLogger().handlers:
|
|
51
|
+
try:
|
|
52
|
+
h.flush()
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
from .server import main as _server_main
|
|
57
|
+
|
|
58
|
+
_server_main()
|
audient/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
audient/cleaning.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Cleaning ops. Every op takes (samples, sample_rate) and returns new
|
|
2
|
+
samples — the store layer (or `invoke_specialist`) wraps results."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import librosa
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy.signal import butter, sosfiltfilt
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def bandpass(y: np.ndarray, sr: int, lo_hz: float, hi_hz: float, order: int = 4) -> np.ndarray:
|
|
12
|
+
nyq = sr / 2
|
|
13
|
+
lo = max(lo_hz, 1.0) / nyq
|
|
14
|
+
hi = min(hi_hz, nyq - 1.0) / nyq
|
|
15
|
+
if not 0 < lo < hi < 1:
|
|
16
|
+
raise ValueError(f"invalid band: lo={lo_hz} hi={hi_hz} sr={sr}")
|
|
17
|
+
sos = butter(order, [lo, hi], btype="band", output="sos")
|
|
18
|
+
return sosfiltfilt(sos, y).astype(y.dtype, copy=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize(y: np.ndarray, method: str = "peak", target_dbfs: float = -3.0) -> np.ndarray:
|
|
22
|
+
"""Normalize amplitude. `peak` scales the absolute maximum to `target_dbfs`
|
|
23
|
+
(default -3 dB to leave headroom); `rms` scales RMS to `target_dbfs`."""
|
|
24
|
+
target = 10 ** (target_dbfs / 20.0)
|
|
25
|
+
if method == "peak":
|
|
26
|
+
peak = float(np.max(np.abs(y)))
|
|
27
|
+
if peak < 1e-12:
|
|
28
|
+
return y
|
|
29
|
+
return (y * (target / peak)).astype(y.dtype, copy=False)
|
|
30
|
+
if method == "rms":
|
|
31
|
+
rms = float(np.sqrt(np.mean(y**2)))
|
|
32
|
+
if rms < 1e-12:
|
|
33
|
+
return y
|
|
34
|
+
return (y * (target / rms)).astype(y.dtype, copy=False)
|
|
35
|
+
raise ValueError(f"unknown normalize method: {method!r} (use 'peak' or 'rms')")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def hpss(y: np.ndarray, sr: int, component: str = "both") -> tuple[np.ndarray, np.ndarray] | np.ndarray:
|
|
39
|
+
"""Harmonic-Percussive Source Separation (librosa). Splits audio into:
|
|
40
|
+
- harmonic: tonal/sustained content (speech, music, sustained tones)
|
|
41
|
+
- percussive: transient/click-like content (knocks, footsteps, drums)
|
|
42
|
+
|
|
43
|
+
Useful when two overlapping sources fall into different timbral
|
|
44
|
+
categories — e.g., a doorbell ringing while someone is talking
|
|
45
|
+
(speech → harmonic; doorbell ring transients → percussive).
|
|
46
|
+
|
|
47
|
+
`component`:
|
|
48
|
+
- `"harmonic"`: return only the harmonic component.
|
|
49
|
+
- `"percussive"`: return only the percussive component.
|
|
50
|
+
- `"both"` (default): return (harmonic, percussive) as a tuple.
|
|
51
|
+
"""
|
|
52
|
+
h, p = librosa.effects.hpss(y)
|
|
53
|
+
if component == "harmonic":
|
|
54
|
+
return h.astype(y.dtype, copy=False)
|
|
55
|
+
if component == "percussive":
|
|
56
|
+
return p.astype(y.dtype, copy=False)
|
|
57
|
+
if component == "both":
|
|
58
|
+
return h.astype(y.dtype, copy=False), p.astype(y.dtype, copy=False)
|
|
59
|
+
raise ValueError(f"hpss component must be 'harmonic', 'percussive', or 'both'; got {component!r}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def spectral_subtract(
|
|
63
|
+
signal: np.ndarray,
|
|
64
|
+
noise_profile: np.ndarray,
|
|
65
|
+
sr: int,
|
|
66
|
+
*,
|
|
67
|
+
over_subtraction: float = 1.0,
|
|
68
|
+
spectral_floor: float = 0.05,
|
|
69
|
+
n_fft: int = 2048,
|
|
70
|
+
hop_length: int = 512,
|
|
71
|
+
) -> np.ndarray:
|
|
72
|
+
"""Classic spectral subtraction. Given a noise-only window (`noise_profile`)
|
|
73
|
+
and a mixed signal window (`signal`), estimate the noise spectrum and
|
|
74
|
+
subtract it from the signal's magnitude spectrum bin-by-bin. Phase is
|
|
75
|
+
preserved from the signal.
|
|
76
|
+
|
|
77
|
+
Effective when:
|
|
78
|
+
- The noise is approximately stationary (rain, HVAC hum, traffic).
|
|
79
|
+
- You can find a noise-only stretch in the recording to use as the
|
|
80
|
+
profile.
|
|
81
|
+
|
|
82
|
+
Less effective when:
|
|
83
|
+
- The "noise" is non-stationary (music, speech).
|
|
84
|
+
- The signal and noise share spectral bands tightly (two voices,
|
|
85
|
+
two broadband mechanical sources).
|
|
86
|
+
|
|
87
|
+
Params:
|
|
88
|
+
`over_subtraction`: multiplier on the noise estimate. 1.0 = plain
|
|
89
|
+
subtraction. Higher values suppress noise more aggressively but
|
|
90
|
+
introduce musical-noise artifacts.
|
|
91
|
+
`spectral_floor`: minimum residual ratio (0..1) — bins are floored at
|
|
92
|
+
`spectral_floor * |signal_bin|` to avoid sharp zeros that cause
|
|
93
|
+
artifacts. 0.05 is a conservative default.
|
|
94
|
+
"""
|
|
95
|
+
S_sig = librosa.stft(signal.astype(np.float32), n_fft=n_fft, hop_length=hop_length)
|
|
96
|
+
S_noise = librosa.stft(noise_profile.astype(np.float32), n_fft=n_fft, hop_length=hop_length)
|
|
97
|
+
mag_sig = np.abs(S_sig)
|
|
98
|
+
phase_sig = np.angle(S_sig)
|
|
99
|
+
mag_noise = np.abs(S_noise).mean(axis=1, keepdims=True)
|
|
100
|
+
subtracted = mag_sig - over_subtraction * mag_noise
|
|
101
|
+
mag_clean = np.maximum(subtracted, spectral_floor * mag_sig)
|
|
102
|
+
S_clean = mag_clean * np.exp(1j * phase_sig)
|
|
103
|
+
y_clean = librosa.istft(S_clean, n_fft=n_fft, hop_length=hop_length, length=len(signal))
|
|
104
|
+
return y_clean.astype(signal.dtype, copy=False)
|
audient/features.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Symbolic feature extraction for audio clips.
|
|
2
|
+
|
|
3
|
+
Functions accept a (samples, sample_rate) pair, never an audio_id — keeps
|
|
4
|
+
this module testable without the store.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import librosa
|
|
12
|
+
import numpy as np
|
|
13
|
+
from scipy.signal import find_peaks
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _round(x: float, n: int = 3) -> float:
|
|
17
|
+
return float(np.round(float(x), n))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def spectral_peaks(y: np.ndarray, sr: int, top_n: int = 5) -> list[dict[str, float]]:
|
|
21
|
+
if len(y) < 1024:
|
|
22
|
+
return []
|
|
23
|
+
spec = np.abs(np.fft.rfft(y * np.hanning(len(y))))
|
|
24
|
+
freqs = np.fft.rfftfreq(len(y), 1 / sr)
|
|
25
|
+
spec_db = 20 * np.log10(spec + 1e-12)
|
|
26
|
+
min_dist = max(5, int(50 / (sr / len(y))))
|
|
27
|
+
peaks, props = find_peaks(spec_db, prominence=6.0, distance=min_dist)
|
|
28
|
+
if len(peaks) == 0:
|
|
29
|
+
return []
|
|
30
|
+
order = np.argsort(props["prominences"])[::-1][:top_n]
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
"freq_hz": _round(freqs[peaks[i]], 1),
|
|
34
|
+
"magnitude_db": _round(spec_db[peaks[i]] - spec_db.max(), 1),
|
|
35
|
+
"prominence_db": _round(props["prominences"][i], 1),
|
|
36
|
+
}
|
|
37
|
+
for i in order
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def mains_hum(peaks: list[dict[str, float]]) -> dict[str, float] | None:
|
|
42
|
+
"""Detect 50/60 Hz mains leakage including 2nd/3rd harmonics.
|
|
43
|
+
|
|
44
|
+
A harmonic peak alone is not sufficient evidence — male vocal F0
|
|
45
|
+
(~120–200 Hz) overlaps the 120/150/180 Hz harmonic bands. Only claim
|
|
46
|
+
a mains family when the fundamental (50 or 60 Hz) is also present
|
|
47
|
+
with non-trivial prominence."""
|
|
48
|
+
def near(freq: float, target: float, tol: float = 3.0) -> bool:
|
|
49
|
+
return abs(freq - target) <= tol
|
|
50
|
+
|
|
51
|
+
fund_50 = next((p for p in peaks if near(p["freq_hz"], 50) and p["prominence_db"] >= 6), None)
|
|
52
|
+
fund_60 = next((p for p in peaks if near(p["freq_hz"], 60) and p["prominence_db"] >= 6), None)
|
|
53
|
+
if not fund_50 and not fund_60:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
fundamental = fund_50 or fund_60
|
|
57
|
+
f0 = 50.0 if fund_50 else 60.0
|
|
58
|
+
harmonics = []
|
|
59
|
+
for mult in (2, 3, 4):
|
|
60
|
+
match = next((p for p in peaks if near(p["freq_hz"], f0 * mult)), None)
|
|
61
|
+
if match:
|
|
62
|
+
harmonics.append({"freq_hz": match["freq_hz"], "prominence_db": match["prominence_db"]})
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
"fundamental_hz": fundamental["freq_hz"],
|
|
66
|
+
"prominence_db": fundamental["prominence_db"],
|
|
67
|
+
"family_hz": f0,
|
|
68
|
+
"harmonics": harmonics,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def voicing(y: np.ndarray, sr: int) -> dict[str, float]:
|
|
73
|
+
"""Two stable, interpretable voicing features:
|
|
74
|
+
- `harmonic_ratio`: fraction of signal energy in the harmonic (vs
|
|
75
|
+
percussive) component. Speech, sustained instruments, and bird songs
|
|
76
|
+
all read high here.
|
|
77
|
+
- `zero_crossing_rate`: time-domain rate of sign changes. Voiced speech
|
|
78
|
+
sits in ~0.02-0.15; fricatives and noise push it higher.
|
|
79
|
+
|
|
80
|
+
Computed on energetic frames only (silence trimmed) so pauses don't
|
|
81
|
+
suppress the harmonic ratio. For an actual speech-vs-not decision call
|
|
82
|
+
`vad` instead — it does frame-level analysis with proper segments.
|
|
83
|
+
"""
|
|
84
|
+
y_trim, _ = librosa.effects.trim(y, top_db=30)
|
|
85
|
+
if len(y_trim) < sr // 10:
|
|
86
|
+
y_trim = y
|
|
87
|
+
harm, _ = librosa.effects.hpss(y_trim)
|
|
88
|
+
harm_ratio = float(np.sum(harm**2) / (np.sum(y_trim**2) + 1e-12))
|
|
89
|
+
zcr = float(librosa.feature.zero_crossing_rate(y_trim).mean())
|
|
90
|
+
return {
|
|
91
|
+
"harmonic_ratio": _round(harm_ratio),
|
|
92
|
+
"zero_crossing_rate": _round(zcr),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def temporal_stability(y: np.ndarray, sr: int) -> dict[str, float]:
|
|
97
|
+
centroid_t = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
|
|
98
|
+
rms_t = librosa.feature.rms(y=y, frame_length=2048, hop_length=512)[0]
|
|
99
|
+
rms_t_db = 20 * np.log10(rms_t + 1e-12)
|
|
100
|
+
half = len(y) // 2
|
|
101
|
+
peaks_a = spectral_peaks(y[:half], sr, top_n=1) if half > 1024 else []
|
|
102
|
+
peaks_b = spectral_peaks(y[half:], sr, top_n=1) if half > 1024 else []
|
|
103
|
+
peak_drift = (
|
|
104
|
+
abs(peaks_a[0]["freq_hz"] - peaks_b[0]["freq_hz"])
|
|
105
|
+
if peaks_a and peaks_b else 0.0
|
|
106
|
+
)
|
|
107
|
+
centroid_cv = float(centroid_t.std() / (centroid_t.mean() + 1e-12))
|
|
108
|
+
return {
|
|
109
|
+
"centroid_std_hz": _round(float(centroid_t.std()), 1),
|
|
110
|
+
"centroid_cv": _round(centroid_cv),
|
|
111
|
+
"rms_std_db": _round(float(rms_t_db.std()), 1),
|
|
112
|
+
"peak_drift_hz": _round(peak_drift, 1),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def clipping_analysis(y: np.ndarray) -> dict[str, Any]:
|
|
117
|
+
"""Plateau-based clipping detection. Single full-scale samples (a typical
|
|
118
|
+
normalized peak) don't count — real clipping holds the cap for multiple
|
|
119
|
+
consecutive samples because the original signal was above the cap and got
|
|
120
|
+
truncated to a flat line. 3-sample minimum follows pro-audio convention
|
|
121
|
+
(foobar2000; Adobe Audition uses 4). Returns the supporting data so the
|
|
122
|
+
agent can reason about borderline cases itself."""
|
|
123
|
+
at_full = np.abs(y) >= 0.999
|
|
124
|
+
n_clipped = int(at_full.sum())
|
|
125
|
+
fraction = float(n_clipped / len(y)) if len(y) else 0.0
|
|
126
|
+
|
|
127
|
+
longest_run = 0
|
|
128
|
+
cur = 0
|
|
129
|
+
for v in at_full:
|
|
130
|
+
if v:
|
|
131
|
+
cur += 1
|
|
132
|
+
if cur > longest_run:
|
|
133
|
+
longest_run = cur
|
|
134
|
+
else:
|
|
135
|
+
cur = 0
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"n_clipped_samples": n_clipped,
|
|
139
|
+
"fraction": _round(fraction, 6),
|
|
140
|
+
"longest_run_samples": int(longest_run),
|
|
141
|
+
"clipping_likely": bool(longest_run >= 3),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def summarize(y: np.ndarray, sr: int) -> dict[str, Any]:
|
|
146
|
+
"""High-level overview — what `summarize(audio_id)` returns."""
|
|
147
|
+
duration = len(y) / sr
|
|
148
|
+
peaks = spectral_peaks(y, sr, top_n=5)
|
|
149
|
+
rms = float(np.sqrt(np.mean(y**2)))
|
|
150
|
+
peak_amp = float(np.max(np.abs(y)))
|
|
151
|
+
frame_rms = librosa.feature.rms(y=y, frame_length=2048, hop_length=512)[0]
|
|
152
|
+
noise_floor_db = float(20 * np.log10(np.percentile(frame_rms, 10) + 1e-12))
|
|
153
|
+
signal_db = float(20 * np.log10(np.percentile(frame_rms, 95) + 1e-12))
|
|
154
|
+
return {
|
|
155
|
+
"duration_s": _round(duration, 2),
|
|
156
|
+
"sample_rate": int(sr),
|
|
157
|
+
"rms": _round(rms, 4),
|
|
158
|
+
"peak_amplitude": _round(peak_amp, 4),
|
|
159
|
+
"noise_floor_dbfs": _round(noise_floor_db, 1),
|
|
160
|
+
"snr_db": _round(signal_db - noise_floor_db, 1),
|
|
161
|
+
"dominant_peaks": peaks[:3],
|
|
162
|
+
"mains_hum": mains_hum(peaks),
|
|
163
|
+
"clipping": clipping_analysis(y),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def spectrum(y: np.ndarray, sr: int) -> dict[str, Any]:
|
|
168
|
+
"""What `spectrum(audio_id, ...)` returns."""
|
|
169
|
+
centroid = float(librosa.feature.spectral_centroid(y=y, sr=sr).mean())
|
|
170
|
+
rolloff = float(librosa.feature.spectral_rolloff(y=y, sr=sr, roll_percent=0.85).mean())
|
|
171
|
+
bandwidth = float(librosa.feature.spectral_bandwidth(y=y, sr=sr).mean())
|
|
172
|
+
flatness = float(librosa.feature.spectral_flatness(y=y).mean())
|
|
173
|
+
return {
|
|
174
|
+
"centroid_hz": _round(centroid, 1),
|
|
175
|
+
"rolloff_85pct_hz": _round(rolloff, 1),
|
|
176
|
+
"bandwidth_hz": _round(bandwidth, 1),
|
|
177
|
+
"flatness": _round(flatness),
|
|
178
|
+
"peaks": spectral_peaks(y, sr, top_n=5),
|
|
179
|
+
"temporal": temporal_stability(y, sr),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def stats(y: np.ndarray, sr: int) -> dict[str, Any]:
|
|
184
|
+
"""What `stats(audio_id, ...)` returns."""
|
|
185
|
+
kurt = float(((y - y.mean()) ** 4).mean() / (y.std() ** 4 + 1e-12) - 3)
|
|
186
|
+
return {
|
|
187
|
+
"rms": _round(float(np.sqrt(np.mean(y**2))), 4),
|
|
188
|
+
"kurtosis_excess": _round(kurt, 2),
|
|
189
|
+
"voicing": voicing(y, sr),
|
|
190
|
+
}
|
audient/logs.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Central logging setup.
|
|
2
|
+
|
|
3
|
+
Every module that emits diagnostics does::
|
|
4
|
+
|
|
5
|
+
from audient.logs import get_logger
|
|
6
|
+
logger = get_logger(__name__)
|
|
7
|
+
logger.info("stream: CLAP loaded (17.4s)")
|
|
8
|
+
|
|
9
|
+
Output shape (default INFO level)::
|
|
10
|
+
|
|
11
|
+
20:45:12.847 INFO stream: CLAP loaded (17.4s)
|
|
12
|
+
20:45:13.201 WARNING arbiter stop: RuntimeError: ...
|
|
13
|
+
20:45:13.202 ERROR arbiter task died: cannot reuse coroutine
|
|
14
|
+
|
|
15
|
+
`configure()` is called once from ``audient._cli`` (or by tests that
|
|
16
|
+
need a specific level). Level resolution priority — first non-None
|
|
17
|
+
wins:
|
|
18
|
+
|
|
19
|
+
1. explicit CLI arg (``--log-level``, ``--quiet``, ``--verbose``)
|
|
20
|
+
2. ``AUDIENT_LOG_LEVEL`` env var (case-insensitive)
|
|
21
|
+
3. default: ``INFO``
|
|
22
|
+
|
|
23
|
+
Third-party loggers (fast-agent, uvicorn, httpx, transformers,
|
|
24
|
+
huggingface_hub) get their own level set to WARNING unless the user
|
|
25
|
+
opts into DEBUG — otherwise the transcript is dominated by their
|
|
26
|
+
noise on every model load.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
from typing import Literal
|
|
34
|
+
|
|
35
|
+
_LEVELS = ("debug", "info", "warning", "error")
|
|
36
|
+
LevelName = Literal["debug", "info", "warning", "error"]
|
|
37
|
+
|
|
38
|
+
_configured = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _level_from_name(name: str) -> int:
|
|
42
|
+
return getattr(logging, name.upper(), logging.INFO)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_level(
|
|
46
|
+
*,
|
|
47
|
+
cli_level: str | None = None,
|
|
48
|
+
quiet: bool = False,
|
|
49
|
+
verbose: bool = False,
|
|
50
|
+
) -> int:
|
|
51
|
+
"""CLI > env > default. Returns a numeric logging level.
|
|
52
|
+
|
|
53
|
+
``--quiet`` and ``--verbose`` are shortcuts. Explicit
|
|
54
|
+
``--log-level`` overrides them if both are given.
|
|
55
|
+
"""
|
|
56
|
+
if cli_level:
|
|
57
|
+
return _level_from_name(cli_level)
|
|
58
|
+
if quiet:
|
|
59
|
+
return logging.WARNING
|
|
60
|
+
if verbose:
|
|
61
|
+
return logging.DEBUG
|
|
62
|
+
env = os.environ.get("AUDIENT_LOG_LEVEL")
|
|
63
|
+
if env:
|
|
64
|
+
return _level_from_name(env)
|
|
65
|
+
return logging.INFO
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def configure(level: int = logging.INFO) -> None:
|
|
69
|
+
"""Idempotent root-logger setup with a stderr handler.
|
|
70
|
+
|
|
71
|
+
Repeat calls change the level but do not attach duplicate handlers.
|
|
72
|
+
"""
|
|
73
|
+
global _configured
|
|
74
|
+
root = logging.getLogger()
|
|
75
|
+
if not _configured:
|
|
76
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
77
|
+
# `%(asctime)s` alone gives second-resolution; we want ms for
|
|
78
|
+
# perf diagnostics (model warm timings, ingestion cadence, arbiter
|
|
79
|
+
# decisions). Use `%(msecs)03.0f` on the same record for the
|
|
80
|
+
# sub-second slice.
|
|
81
|
+
handler.setFormatter(logging.Formatter(
|
|
82
|
+
fmt="%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s",
|
|
83
|
+
datefmt="%H:%M:%S",
|
|
84
|
+
))
|
|
85
|
+
root.addHandler(handler)
|
|
86
|
+
_configured = True
|
|
87
|
+
root.setLevel(level)
|
|
88
|
+
|
|
89
|
+
# Third-party noise: pin to WARNING unless we're in DEBUG. The
|
|
90
|
+
# user set the level on THEIR code — they don't want CLAP's
|
|
91
|
+
# tokenizer chatter or uvicorn's per-request access lines under
|
|
92
|
+
# a plain `--verbose`.
|
|
93
|
+
third_party_level = logging.DEBUG if level <= logging.DEBUG else logging.WARNING
|
|
94
|
+
for name in (
|
|
95
|
+
"uvicorn",
|
|
96
|
+
"uvicorn.error",
|
|
97
|
+
"uvicorn.access",
|
|
98
|
+
"httpx",
|
|
99
|
+
"httpcore",
|
|
100
|
+
"transformers",
|
|
101
|
+
"huggingface_hub",
|
|
102
|
+
"urllib3",
|
|
103
|
+
"asyncio",
|
|
104
|
+
# FastMCP / mcp SDK internal chatter. Per-request lines like
|
|
105
|
+
# "Processing request of type ListToolsRequest" and
|
|
106
|
+
# "Created new transport with session ID: <uuid>" and the
|
|
107
|
+
# classifier's periodic "Processing request of type
|
|
108
|
+
# PingRequest" all land here at INFO. Suppressed unless the
|
|
109
|
+
# user opts into DEBUG.
|
|
110
|
+
"mcp",
|
|
111
|
+
"mcp.server",
|
|
112
|
+
"mcp.server.lowlevel",
|
|
113
|
+
"mcp.server.streamable_http",
|
|
114
|
+
"fastmcp",
|
|
115
|
+
# fast-agent-mcp client side.
|
|
116
|
+
"fast_agent",
|
|
117
|
+
):
|
|
118
|
+
logging.getLogger(name).setLevel(third_party_level)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_logger(name: str) -> logging.Logger:
|
|
122
|
+
"""Preferred accessor. Mirrors ``logging.getLogger`` — kept as a
|
|
123
|
+
thin re-export so we have a single import surface for future
|
|
124
|
+
migrations (e.g. structured logging)."""
|
|
125
|
+
return logging.getLogger(name)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = ["configure", "get_logger", "resolve_level", "LevelName"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- Phase 02.4 Plan 03 — add session_id to the events table.
|
|
2
|
+
--
|
|
3
|
+
-- session_id is the wire-level discriminator that Phase 02.4 hard-codes
|
|
4
|
+
-- to 'default' everywhere. Adding it as a NOT NULL column with a
|
|
5
|
+
-- DEFAULT 'default' clause is the canonical SQLite ADD-COLUMN shape:
|
|
6
|
+
-- atomic, backward-compatible (existing rows get the default), and
|
|
7
|
+
-- preserves the events table's primary-key shape.
|
|
8
|
+
--
|
|
9
|
+
-- event_embeddings is a sqlite-vec `vec0` virtual table (see
|
|
10
|
+
-- concept_memory._init_schema CREATE VIRTUAL TABLE ... USING vec0(...)).
|
|
11
|
+
-- vec0 does NOT support ALTER. We track session_id via a sidecar
|
|
12
|
+
-- table keyed by event_id, backfilled from the events table the
|
|
13
|
+
-- first time this migration runs. event_embeddings_session is the
|
|
14
|
+
-- canonical mapping; future queries that need the session id of an
|
|
15
|
+
-- embedding row JOIN through this table.
|
|
16
|
+
--
|
|
17
|
+
-- Idempotence is guaranteed by the schema_migrations table in
|
|
18
|
+
-- events_db.run_pending_migrations — each filename is recorded once
|
|
19
|
+
-- on first apply, and a re-run is a no-op.
|
|
20
|
+
|
|
21
|
+
ALTER TABLE events ADD COLUMN session_id TEXT NOT NULL DEFAULT 'default';
|
|
22
|
+
|
|
23
|
+
CREATE INDEX IF NOT EXISTS events_session_id_idx ON events (session_id);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS event_embeddings_session (
|
|
26
|
+
event_id TEXT PRIMARY KEY,
|
|
27
|
+
session_id TEXT NOT NULL DEFAULT 'default'
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
INSERT OR IGNORE INTO event_embeddings_session (event_id, session_id)
|
|
31
|
+
SELECT event_id, 'default' FROM events;
|