New modules shipped (from Linnet integration): - acoustic.py: AST (MIT/ast-finetuned-audioset-10-10-0.4593) replaces YAMNet stub; 527 AudioSet classes mapped to queue/speaker/environ/scene labels; _LABEL_MAP includes hold_music, ringback, DTMF, background_shift, AMD signal chain - accent.py: facebook/mms-lid-126 language ID → regional accent labels (en_gb, en_us, en_au, fr, es, de, zh, …); lazy-loaded, gated by CF_VOICE_ACCENT - privacy.py: compound privacy risk scorer — public_env, background_voices, nature scene, accent signals; returns 0–3 score without storing any audio - prosody.py: openSMILE-backed prosody extractor (sarcasm_risk, flat_f0_score, speech_rate, pitch_range); mock mode returns neutral values - dimensional.py: audeering/wav2vec2-large-robust-12-ft-emotion-msp-dim valence/arousal/dominance scorer; gated by CF_VOICE_DIMENSIONAL - trajectory.py: rolling buffer for arousal/valence deltas, trend detection (escalating/suppressed/stable), coherence scoring, suppression/reframe flags - telephony.py: TelephonyBackend Protocol + MockTelephonyBackend + SignalWireBackend + FreeSWITCHBackend; CallSession dataclass; make_telephony() factory - app.py: FastAPI service (port 8007) — /health + /classify; accepts base64 PCM chunks, returns full AudioEventOut including dimensional/prosody/accent fields - prefs.py: voice preference helpers (elcor_mode, confidence_threshold, whisper_model, elcor_prior_frames); cf-core and env-var fallback Tests: fix stale tests (YAMNetAcousticBackend → ASTAcousticBackend, scene field added to AcousticResult, speaker_at gap now resolves dominant speaker not UNKNOWN, make_io real path returns MicVoiceIO when sounddevice installed). 78 tests passing. Closes #2, #3.
181 lines
6.3 KiB
Python
181 lines
6.3 KiB
Python
# cf_voice/prefs.py — user preference hooks for cf-core preferences module
|
||
#
|
||
# MIT licensed. Provides voice-specific preference keys and helpers.
|
||
#
|
||
# When circuitforge_core is installed, reads/writes from the shared preference
|
||
# store (LocalFileStore or cloud backend). When it is not installed (standalone
|
||
# cf-voice use), falls back to environment variables only.
|
||
#
|
||
# Preference paths use dot-separated notation (cf-core convention):
|
||
# "voice.elcor_mode" bool — Elcor-style tone annotations
|
||
# "voice.confidence_threshold" float — minimum confidence to emit a frame
|
||
# "voice.whisper_model" str — faster-whisper model size
|
||
# "voice.elcor_prior_frames" int — rolling context window for Elcor LLM
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── Preference key constants ──────────────────────────────────────────────────
|
||
|
||
PREF_ELCOR_MODE = "voice.elcor_mode"
|
||
PREF_CONFIDENCE_THRESHOLD = "voice.confidence_threshold"
|
||
PREF_WHISPER_MODEL = "voice.whisper_model"
|
||
PREF_ELCOR_PRIOR_FRAMES = "voice.elcor_prior_frames"
|
||
|
||
# Defaults used when neither preference store nor environment has a value
|
||
_DEFAULTS: dict[str, Any] = {
|
||
PREF_ELCOR_MODE: False,
|
||
PREF_CONFIDENCE_THRESHOLD: 0.55,
|
||
PREF_WHISPER_MODEL: "small",
|
||
PREF_ELCOR_PRIOR_FRAMES: 4,
|
||
}
|
||
|
||
# ── Environment variable fallbacks ────────────────────────────────────────────
|
||
|
||
_ENV_KEYS: dict[str, str] = {
|
||
PREF_ELCOR_MODE: "CF_VOICE_ELCOR",
|
||
PREF_CONFIDENCE_THRESHOLD: "CF_VOICE_CONFIDENCE_THRESHOLD",
|
||
PREF_WHISPER_MODEL: "CF_VOICE_WHISPER_MODEL",
|
||
PREF_ELCOR_PRIOR_FRAMES: "CF_VOICE_ELCOR_PRIOR_FRAMES",
|
||
}
|
||
|
||
_COERCE: dict[str, type] = {
|
||
PREF_ELCOR_MODE: bool,
|
||
PREF_CONFIDENCE_THRESHOLD: float,
|
||
PREF_WHISPER_MODEL: str,
|
||
PREF_ELCOR_PRIOR_FRAMES: int,
|
||
}
|
||
|
||
|
||
def _from_env(pref_path: str) -> Any:
|
||
"""Read a preference from its environment variable fallback."""
|
||
env_key = _ENV_KEYS.get(pref_path)
|
||
if env_key is None:
|
||
return None
|
||
raw = os.environ.get(env_key)
|
||
if raw is None:
|
||
return None
|
||
coerce = _COERCE.get(pref_path, str)
|
||
try:
|
||
if coerce is bool:
|
||
return raw.strip().lower() in ("1", "true", "yes")
|
||
return coerce(raw)
|
||
except (ValueError, TypeError):
|
||
logger.warning("prefs: could not parse env %s=%r as %s", env_key, raw, coerce)
|
||
return None
|
||
|
||
|
||
def _cf_core_store():
|
||
"""Return the cf-core default preference store, or None if not available."""
|
||
try:
|
||
from circuitforge_core.preferences import store as _store_mod
|
||
return _store_mod._DEFAULT_STORE
|
||
except ImportError:
|
||
return None
|
||
|
||
|
||
# ── Public API ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def get_voice_pref(
|
||
pref_path: str,
|
||
user_id: str | None = None,
|
||
store=None,
|
||
) -> Any:
|
||
"""
|
||
Read a voice preference value.
|
||
|
||
Resolution order:
|
||
1. Explicit store (passed in by caller — used for testing or cloud backends)
|
||
2. cf-core LocalFileStore (if circuitforge_core is installed)
|
||
3. Environment variable fallback
|
||
4. Built-in default
|
||
|
||
pref_path One of the PREF_* constants, e.g. PREF_ELCOR_MODE.
|
||
user_id Passed to the store for cloud backends; local store ignores it.
|
||
"""
|
||
# 1. Explicit store
|
||
if store is not None:
|
||
val = store.get(user_id=user_id, path=pref_path, default=None)
|
||
if val is not None:
|
||
return val
|
||
|
||
# 2. cf-core default store
|
||
cf_store = _cf_core_store()
|
||
if cf_store is not None:
|
||
val = cf_store.get(user_id=user_id, path=pref_path, default=None)
|
||
if val is not None:
|
||
return val
|
||
|
||
# 3. Environment variable
|
||
env_val = _from_env(pref_path)
|
||
if env_val is not None:
|
||
return env_val
|
||
|
||
# 4. Built-in default
|
||
return _DEFAULTS.get(pref_path)
|
||
|
||
|
||
def set_voice_pref(
|
||
pref_path: str,
|
||
value: Any,
|
||
user_id: str | None = None,
|
||
store=None,
|
||
) -> None:
|
||
"""
|
||
Write a voice preference value.
|
||
|
||
Writes to the explicit store if provided, otherwise to the cf-core default
|
||
store. Raises RuntimeError if neither is available (env-only mode has no
|
||
writable persistence).
|
||
"""
|
||
target = store or _cf_core_store()
|
||
if target is None:
|
||
raise RuntimeError(
|
||
"No writable preference store available. "
|
||
"Install circuitforge_core or pass a store explicitly."
|
||
)
|
||
target.set(user_id=user_id, path=pref_path, value=value)
|
||
|
||
|
||
def is_elcor_enabled(user_id: str | None = None, store=None) -> bool:
|
||
"""
|
||
Convenience: return True if the user has Elcor annotation mode enabled.
|
||
|
||
Elcor mode switches tone subtext from generic format ("Tone: Frustrated")
|
||
to the Mass Effect Elcor prefix format ("With barely concealed frustration:").
|
||
It is an accessibility feature for autistic and ND users who benefit from
|
||
explicit tonal annotation. Opt-in, local-only — no data leaves the device.
|
||
|
||
Defaults to False.
|
||
"""
|
||
return bool(get_voice_pref(PREF_ELCOR_MODE, user_id=user_id, store=store))
|
||
|
||
|
||
def get_confidence_threshold(user_id: str | None = None, store=None) -> float:
|
||
"""Return the minimum confidence threshold for emitting VoiceFrames (0.0–1.0)."""
|
||
return float(
|
||
get_voice_pref(PREF_CONFIDENCE_THRESHOLD, user_id=user_id, store=store)
|
||
)
|
||
|
||
|
||
def get_whisper_model(user_id: str | None = None, store=None) -> str:
|
||
"""Return the faster-whisper model name to use (e.g. "small", "medium")."""
|
||
return str(get_voice_pref(PREF_WHISPER_MODEL, user_id=user_id, store=store))
|
||
|
||
|
||
def get_elcor_prior_frames(user_id: str | None = None, store=None) -> int:
|
||
"""
|
||
Return the number of prior VoiceFrames to include as context for Elcor
|
||
label generation. Larger windows produce more contextually aware annotations
|
||
but increase LLM prompt length and latency.
|
||
|
||
Default: 4 frames (~8–10 seconds of rolling context at 2s intervals).
|
||
"""
|
||
return int(
|
||
get_voice_pref(PREF_ELCOR_PRIOR_FRAMES, user_id=user_id, store=store)
|
||
)
|