Initial FRIDAY Voice Console scaffold

This commit is contained in:
2026-06-25 07:24:34 -05:00
commit c2a304642a
17 changed files with 997 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
"""FRIDAY Voice Console backend package."""
__all__ = ["__version__"]
__version__ = "0.1.0"
+226
View File
@@ -0,0 +1,226 @@
from __future__ import annotations
import hashlib
import json
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from .authorization import classify_voice_authorization
from .config import Settings
from .hermes_client import ask_hermes
from .speaker_store import SpeakerStore
settings = Settings.from_env()
data_dir = Path(settings.data_dir)
speaker_store = SpeakerStore(data_dir / "speaker_profiles.json")
app = FastAPI(title="FRIDAY Voice Console", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
static_dir = Path(__file__).resolve().parent.parent / "web"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=static_dir), name="static")
class AskRequest(BaseModel):
text: str = Field(min_length=1)
speaker_id: str | None = None
confidence: float = 0.0
class AskResponse(BaseModel):
reply: str
authorization_level: str
read_only: bool
requires_confirmation: bool
reason: str
class IdentifyRequest(BaseModel):
embedding: list[float]
class EnrollRequest(BaseModel):
speaker_id: str
display_name: str | None = None
source: str = "manual"
embedding: list[float]
@app.get("/", response_class=HTMLResponse)
def index() -> str:
index_path = static_dir / "index.html"
if index_path.exists():
return index_path.read_text(encoding="utf-8")
return "<h1>FRIDAY Voice Console</h1><p>Static UI missing.</p>"
@app.get("/health")
def health() -> dict[str, Any]:
return {
"status": "ok",
"speaches_url": settings.speaches_url,
"hermes_source": settings.hermes_source,
"speaker_profiles": len(speaker_store.profiles),
}
@app.post("/api/ask", response_model=AskResponse)
def ask(request: AskRequest) -> AskResponse:
decision = classify_voice_authorization(
request.speaker_id,
request.confidence,
settings,
)
prompt = _build_prompt(request.text, decision.level, decision.read_only)
try:
reply = ask_hermes(prompt, settings)
except Exception as exc: # pragma: no cover - integration boundary
raise HTTPException(status_code=502, detail=f"Hermes call failed: {exc}") from exc
return AskResponse(
reply=reply,
authorization_level=decision.level,
read_only=decision.read_only,
requires_confirmation=decision.requires_confirmation,
reason=decision.reason,
)
@app.post("/api/enroll")
def enroll(request: EnrollRequest) -> dict[str, Any]:
speaker_store.add_embedding(
request.speaker_id,
request.embedding,
request.source,
display_name=request.display_name,
)
return {"ok": True, "speaker_id": request.speaker_id}
@app.post("/api/identify")
def identify(request: IdentifyRequest) -> dict[str, Any]:
match = speaker_store.identify(request.embedding)
decision = classify_voice_authorization(match.speaker_id, match.confidence, settings)
return {
"speaker_id": match.speaker_id,
"display_name": match.display_name,
"confidence": match.confidence,
"authorization_level": decision.level,
"read_only": decision.read_only,
"requires_confirmation": decision.requires_confirmation,
}
@app.post("/api/transcribe")
async def transcribe(request: Request) -> dict[str, Any]:
payload = await request.body()
if not payload:
raise HTTPException(status_code=400, detail="empty audio upload")
result = _post_multipart(
f"{settings.speaches_url}/v1/audio/transcriptions",
files={
"file": ("speech.webm", payload, request.headers.get("content-type", "audio/webm")),
},
data={"model": settings.whisper_model},
)
return {"text": result.get("text", ""), "provider_response": result}
@app.post("/api/speak")
async def speak(request: Request) -> FileResponse:
body = await request.json()
text = str(body.get("text", ""))
if not text.strip():
raise HTTPException(status_code=400, detail="empty text")
payload = {
"model": settings.tts_model,
"input": text,
"voice": settings.tts_voice,
"response_format": "mp3",
}
audio = _post_json_bytes(f"{settings.speaches_url}/v1/audio/speech", payload)
data_dir.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
path = data_dir / f"tts-{digest}.mp3"
path.write_bytes(audio)
return FileResponse(path, media_type="audio/mpeg", filename=path.name)
def _build_prompt(text: str, voice_level: str, read_only: bool) -> str:
if read_only:
safety = "Voice authorization is read-only; do not perform environment writes or reveal secrets."
else:
safety = "Voice authorization matched the boss; still require explicit confirmation for destructive or credential actions."
return "\n".join(
[
"[FRIDAY Voice Console]",
"Voice tier: " + voice_level,
"Safety: " + safety,
"",
"User said: " + text,
]
)
def _post_json_bytes(url: str, payload: dict[str, Any]) -> bytes:
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=120) as response:
return response.read()
except urllib.error.HTTPError as exc: # pragma: no cover - integration boundary
detail = exc.read().decode("utf-8", "replace")
raise HTTPException(status_code=502, detail=detail) from exc
def _post_multipart(
url: str,
files: dict[str, tuple[str, bytes, str]],
data: dict[str, str],
) -> dict[str, Any]:
boundary = "----friday-voice-boundary"
body = bytearray()
for name, value in data.items():
body.extend(f"--{boundary}\r\n".encode())
body.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
body.extend(str(value).encode())
body.extend(b"\r\n")
for name, (filename, payload, content_type) in files.items():
body.extend(f"--{boundary}\r\n".encode())
body.extend(
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode()
)
body.extend(f"Content-Type: {content_type}\r\n\r\n".encode())
body.extend(payload)
body.extend(b"\r\n")
body.extend(f"--{boundary}--\r\n".encode())
request = urllib.request.Request(
url,
data=bytes(body),
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=120) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc: # pragma: no cover - integration boundary
detail = exc.read().decode("utf-8", "replace")
raise HTTPException(status_code=502, detail=detail) from exc
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from dataclasses import dataclass
from .config import Settings
@dataclass(frozen=True, slots=True)
class AuthorizationDecision:
level: str
read_only: bool
requires_confirmation: bool
reason: str
def classify_voice_authorization(
speaker_id: str | None,
confidence: float,
settings: Settings | None = None,
) -> AuthorizationDecision:
"""Map speaker identity confidence to a safety tier.
Voice identity is advisory. Dangerous changes still require explicit approval
elsewhere in the protocol.
"""
settings = settings or Settings()
if not speaker_id:
return AuthorizationDecision(
level="unknown",
read_only=True,
requires_confirmation=True,
reason="No enrolled speaker matched this utterance.",
)
if confidence >= settings.speaker_confirmed_threshold:
return AuthorizationDecision(
level="confirmed",
read_only=False,
requires_confirmation=False,
reason=f"Speaker {speaker_id} matched above confirmed threshold.",
)
if confidence >= settings.speaker_probable_threshold:
return AuthorizationDecision(
level="probable",
read_only=False,
requires_confirmation=True,
reason=f"Speaker {speaker_id} matched above probable threshold only.",
)
return AuthorizationDecision(
level="unknown",
read_only=True,
requires_confirmation=True,
reason=f"Speaker {speaker_id} confidence below probable threshold.",
)
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass
DEFAULT_SPEACHES_URL = "http://192.168.86.150:8000"
DEFAULT_HERMES_COMMAND = "hermes"
DEFAULT_HERMES_SOURCE = "friday-voice"
DEFAULT_CONFIRMED_THRESHOLD = 0.85
DEFAULT_PROBABLE_THRESHOLD = 0.70
DEFAULT_DATA_DIR = "/data"
DEFAULT_WHISPER_MODEL = "deepdml/faster-whisper-large-v3-turbo-ct2"
DEFAULT_TTS_MODEL = "speaches-ai/Kokoro-82M-v1.0-ONNX"
DEFAULT_TTS_VOICE = "af_nova"
@dataclass(slots=True)
class Settings:
"""Runtime settings for the FRIDAY voice gateway.
Defaults match Jeremy's residence services from the wiki crawl.
"""
speaches_url: str = DEFAULT_SPEACHES_URL
hermes_command: str = DEFAULT_HERMES_COMMAND
hermes_source: str = DEFAULT_HERMES_SOURCE
speaker_confirmed_threshold: float = DEFAULT_CONFIRMED_THRESHOLD
speaker_probable_threshold: float = DEFAULT_PROBABLE_THRESHOLD
data_dir: str = DEFAULT_DATA_DIR
whisper_model: str = DEFAULT_WHISPER_MODEL
tts_model: str = DEFAULT_TTS_MODEL
tts_voice: str = DEFAULT_TTS_VOICE
@classmethod
def from_env(cls, environ: dict[str, str] | None = None) -> "Settings":
import os
env = os.environ if environ is None else environ
return cls(
speaches_url=env.get("SPEACHES_URL", DEFAULT_SPEACHES_URL),
hermes_command=env.get("HERMES_COMMAND", DEFAULT_HERMES_COMMAND),
hermes_source=env.get("HERMES_SOURCE", DEFAULT_HERMES_SOURCE),
data_dir=env.get("DATA_DIR", DEFAULT_DATA_DIR),
whisper_model=env.get("WHISPER_MODEL", DEFAULT_WHISPER_MODEL),
tts_model=env.get("TTS_MODEL", DEFAULT_TTS_MODEL),
tts_voice=env.get("TTS_VOICE", DEFAULT_TTS_VOICE),
speaker_confirmed_threshold=float(
env.get("SPEAKER_CONFIRMED_THRESHOLD", DEFAULT_CONFIRMED_THRESHOLD)
),
speaker_probable_threshold=float(
env.get("SPEAKER_PROBABLE_THRESHOLD", DEFAULT_PROBABLE_THRESHOLD)
),
)
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import subprocess
from .config import Settings
def build_cli_command(text: str, settings: Settings) -> list[str]:
return [
settings.hermes_command,
"chat",
"-q",
text,
"--source",
settings.hermes_source,
]
def ask_hermes(text: str, settings: Settings) -> str:
command = build_cli_command(text, settings)
completed = subprocess.run(
command,
check=True,
capture_output=True,
text=True,
timeout=300,
)
return completed.stdout.strip()
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import json
import math
from dataclasses import asdict, dataclass, field
from pathlib import Path
from time import time
from typing import Iterable
def cosine_similarity(left: Iterable[float], right: Iterable[float]) -> float:
a = [float(x) for x in left]
b = [float(x) for x in right]
if len(a) != len(b) or not a:
return 0.0
dot = sum(x * y for x, y in zip(a, b, strict=True))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(y * y for y in b))
if norm_a == 0.0 or norm_b == 0.0:
return 0.0
return dot / (norm_a * norm_b)
@dataclass(slots=True)
class SpeakerEmbedding:
vector: list[float]
source: str
created_at: float = field(default_factory=time)
@dataclass(slots=True)
class SpeakerProfile:
speaker_id: str
display_name: str
embeddings: list[SpeakerEmbedding] = field(default_factory=list)
@dataclass(frozen=True, slots=True)
class SpeakerMatch:
speaker_id: str | None
confidence: float
display_name: str | None = None
class SpeakerStore:
"""Small JSON-backed speaker profile store for the MVP.
This intentionally stores embeddings, not raw audio. Raw clip retention can be
added later for unknown-speaker review.
"""
def __init__(self, path: Path):
self.path = path
self.profiles: dict[str, SpeakerProfile] = {}
self._load()
def add_embedding(
self,
speaker_id: str,
vector: Iterable[float],
source: str,
display_name: str | None = None,
) -> None:
profile = self.profiles.get(speaker_id)
if profile is None:
profile = SpeakerProfile(
speaker_id=speaker_id,
display_name=display_name or speaker_id,
)
self.profiles[speaker_id] = profile
profile.embeddings.append(
SpeakerEmbedding(vector=[float(x) for x in vector], source=source)
)
self.save()
def identify(self, vector: Iterable[float]) -> SpeakerMatch:
probe = [float(x) for x in vector]
best = SpeakerMatch(speaker_id=None, confidence=0.0)
for profile in self.profiles.values():
for embedding in profile.embeddings:
score = cosine_similarity(probe, embedding.vector)
if score > best.confidence:
best = SpeakerMatch(
speaker_id=profile.speaker_id,
display_name=profile.display_name,
confidence=score,
)
return best
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
data = {speaker_id: asdict(profile) for speaker_id, profile in self.profiles.items()}
self.path.write_text(json.dumps(data, indent=2), encoding="utf-8")
def _load(self) -> None:
if not self.path.exists():
return
raw = json.loads(self.path.read_text(encoding="utf-8"))
for speaker_id, profile_data in raw.items():
embeddings = [
SpeakerEmbedding(**embedding)
for embedding in profile_data.get("embeddings", [])
]
self.profiles[speaker_id] = SpeakerProfile(
speaker_id=profile_data.get("speaker_id", speaker_id),
display_name=profile_data.get("display_name", speaker_id),
embeddings=embeddings,
)