Initial FRIDAY Voice Console scaffold
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.DS_Store
|
||||
/data/
|
||||
*.mp3
|
||||
*.wav
|
||||
*.webm
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DATA_DIR=/data
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY friday_voice ./friday_voice
|
||||
COPY web ./web
|
||||
|
||||
RUN useradd -r -u 1024 -g users friday \
|
||||
&& mkdir -p /data \
|
||||
&& chown -R 1024:100 /data /app
|
||||
|
||||
USER 1024:100
|
||||
|
||||
EXPOSE 8320
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=20s \
|
||||
CMD curl -fsS http://127.0.0.1:8320/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "friday_voice.app:app", "--host", "0.0.0.0", "--port", "8320"]
|
||||
@@ -0,0 +1,91 @@
|
||||
# FRIDAY Voice Console
|
||||
|
||||
Push-to-talk MVP for talking to FRIDAY from a browser/phone, with a speaker-identification foundation for Jeremy's voice.
|
||||
|
||||
## Status
|
||||
|
||||
MVP scaffold complete:
|
||||
|
||||
- Browser UI records audio and sends it to the backend.
|
||||
- Backend transcribes through Speaches on the GPU machine.
|
||||
- Backend routes text into Hermes via `hermes chat -q`.
|
||||
- Backend speaks FRIDAY's response through Speaches/Kokoro.
|
||||
- JSON-backed speaker profile store supports voice embeddings and confidence thresholds.
|
||||
- Unknown speakers are treated as read-only.
|
||||
|
||||
## Runtime defaults
|
||||
|
||||
- Speaches: `http://192.168.86.150:8000`
|
||||
- Hermes source: `friday-voice`
|
||||
- Confirmed speaker threshold: `0.85`
|
||||
- Probable speaker threshold: `0.70`
|
||||
- Data directory in container: `/data`
|
||||
|
||||
## Local verification
|
||||
|
||||
```bash
|
||||
cd /mnt/c/Users/Jeremy/projects/friday-voice
|
||||
python3 -m unittest discover -s tests -v
|
||||
python3 -m compileall friday_voice
|
||||
node --check web/app.js
|
||||
```
|
||||
|
||||
## Deployment layout
|
||||
|
||||
Source project:
|
||||
|
||||
```text
|
||||
/mnt/c/Users/Jeremy/projects/friday-voice/
|
||||
```
|
||||
|
||||
Compose definition:
|
||||
|
||||
```text
|
||||
/mnt/c/Users/Jeremy/projects/docker/compose/hf-pdocker-01/friday-voice/docker-compose.yml
|
||||
```
|
||||
|
||||
Traefik dynamic config draft:
|
||||
|
||||
```text
|
||||
/mnt/c/Users/Jeremy/projects/docker/stacks/traefik/dynamic/friday-voice.yml
|
||||
```
|
||||
|
||||
Target host layout:
|
||||
|
||||
```text
|
||||
/opt/friday-voice/ # application source/build context
|
||||
/mnt/docker-data/compose/hf-pdocker-01/friday-voice/
|
||||
/mnt/docker-data/configs/friday-voice/ # speaker profiles + generated TTS cache
|
||||
/mnt/docker-data/traefik/dynamic/friday-voice.yml
|
||||
```
|
||||
|
||||
Deploy protocol, after review:
|
||||
|
||||
```bash
|
||||
# From workstation
|
||||
rsync -av --delete /mnt/c/Users/Jeremy/projects/friday-voice/ glyptic@192.168.86.192:/opt/friday-voice/
|
||||
rsync -av /mnt/c/Users/Jeremy/projects/docker/compose/hf-pdocker-01/friday-voice/ glyptic@192.168.86.192:/mnt/docker-data/compose/hf-pdocker-01/friday-voice/
|
||||
rsync -av /mnt/c/Users/Jeremy/projects/docker/stacks/traefik/dynamic/friday-voice.yml glyptic@192.168.86.192:/mnt/docker-data/traefik/dynamic/friday-voice.yml
|
||||
|
||||
# On hf-pdocker-01
|
||||
cd /mnt/docker-data/compose/hf-pdocker-01/friday-voice
|
||||
docker compose up -d --build
|
||||
curl -fsS http://127.0.0.1:8320/health
|
||||
```
|
||||
|
||||
## Speaker enrollment MVP
|
||||
|
||||
The current MVP exposes `/api/enroll` and `/api/identify` for embedding vectors. The next build slice should add actual audio-to-embedding extraction using SpeechBrain ECAPA or resemblyzer, then wire that into the browser enrollment flow.
|
||||
|
||||
Recommended thresholds:
|
||||
|
||||
- `>= 0.85`: confirmed Jeremy
|
||||
- `0.70-0.84`: probable Jeremy, confirmation required for sensitive actions
|
||||
- `< 0.70`: unknown speaker, read-only
|
||||
|
||||
## Security notes
|
||||
|
||||
- Voice identity is advisory, not a sole authorization factor.
|
||||
- Unknown speakers are read-only.
|
||||
- Environment writes still require explicit authorization and wiki documentation.
|
||||
- Secrets are never returned by voice.
|
||||
@@ -0,0 +1,8 @@
|
||||
SPEACHES_URL=http://192.168.86.150:8000
|
||||
HERMES_COMMAND=hermes
|
||||
HERMES_SOURCE=friday-voice
|
||||
WHISPER_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
|
||||
TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
|
||||
TTS_VOICE=af_nova
|
||||
SPEAKER_CONFIRMED_THRESHOLD=0.85
|
||||
SPEAKER_PROBABLE_THRESHOLD=0.70
|
||||
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
friday-voice:
|
||||
build:
|
||||
context: /opt/friday-voice
|
||||
dockerfile: Dockerfile
|
||||
image: friday-voice:latest
|
||||
container_name: friday-voice
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8320:8320"
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/friday-voice:/data
|
||||
environment:
|
||||
- DATA_DIR=/data
|
||||
- SPEACHES_URL=${SPEACHES_URL:-http://192.168.86.150:8000}
|
||||
- HERMES_COMMAND=${HERMES_COMMAND:-hermes}
|
||||
- HERMES_SOURCE=${HERMES_SOURCE:-friday-voice}
|
||||
- WHISPER_MODEL=${WHISPER_MODEL:-deepdml/faster-whisper-large-v3-turbo-ct2}
|
||||
- TTS_MODEL=${TTS_MODEL:-speaches-ai/Kokoro-82M-v1.0-ONNX}
|
||||
- TTS_VOICE=${TTS_VOICE:-af_nova}
|
||||
- SPEAKER_CONFIRMED_THRESHOLD=${SPEAKER_CONFIRMED_THRESHOLD:-0.85}
|
||||
- SPEAKER_PROBABLE_THRESHOLD=${SPEAKER_PROBABLE_THRESHOLD:-0.70}
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8320/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: friday-voice
|
||||
@@ -0,0 +1,30 @@
|
||||
http:
|
||||
routers:
|
||||
friday-voice:
|
||||
rule: "Host(`friday.hobbs.farm`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers-allow-mic
|
||||
service: friday-voice
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
friday-voice-http:
|
||||
rule: "Host(`friday.hobbs.farm`)"
|
||||
entryPoints:
|
||||
- web
|
||||
middlewares:
|
||||
- friday-voice-https-redirect
|
||||
service: friday-voice
|
||||
|
||||
middlewares:
|
||||
friday-voice-https-redirect:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
permanent: true
|
||||
|
||||
services:
|
||||
friday-voice:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.86.192:8320"
|
||||
@@ -0,0 +1,4 @@
|
||||
"""FRIDAY Voice Console backend package."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "0.1.0"
|
||||
@@ -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
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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)
|
||||
),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
pydantic==2.10.4
|
||||
@@ -0,0 +1,79 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from friday_voice.authorization import classify_voice_authorization
|
||||
from friday_voice.config import Settings
|
||||
from friday_voice.hermes_client import build_cli_command
|
||||
from friday_voice.speaker_store import SpeakerStore, cosine_similarity
|
||||
|
||||
|
||||
class SpeakerMathTests(unittest.TestCase):
|
||||
def test_cosine_similarity_handles_identical_orthogonal_and_zero_vectors(self):
|
||||
self.assertAlmostEqual(cosine_similarity([1, 2, 3], [1, 2, 3]), 1.0)
|
||||
self.assertAlmostEqual(cosine_similarity([1, 0], [0, 1]), 0.0)
|
||||
self.assertEqual(cosine_similarity([0, 0], [1, 2]), 0.0)
|
||||
|
||||
def test_speaker_store_identifies_best_matching_profile(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = SpeakerStore(Path(tmp) / "speakers.json")
|
||||
store.add_embedding("jeremy", [1.0, 0.0, 0.0], source="office")
|
||||
store.add_embedding("other", [0.0, 1.0, 0.0], source="test")
|
||||
|
||||
match = store.identify([0.98, 0.02, 0.0])
|
||||
|
||||
self.assertEqual(match.speaker_id, "jeremy")
|
||||
self.assertGreater(match.confidence, 0.95)
|
||||
|
||||
def test_speaker_store_persists_profiles(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "speakers.json"
|
||||
SpeakerStore(path).add_embedding("jeremy", [0.1, 0.2, 0.3], source="phone")
|
||||
|
||||
reloaded = SpeakerStore(path)
|
||||
|
||||
self.assertEqual(len(reloaded.profiles["jeremy"].embeddings), 1)
|
||||
self.assertEqual(reloaded.profiles["jeremy"].embeddings[0].source, "phone")
|
||||
|
||||
|
||||
class AuthorizationTests(unittest.TestCase):
|
||||
def test_authorization_thresholds_map_voice_confidence_to_risk_level(self):
|
||||
self.assertEqual(classify_voice_authorization("jeremy", 0.88).level, "confirmed")
|
||||
self.assertEqual(classify_voice_authorization("jeremy", 0.75).level, "probable")
|
||||
self.assertEqual(classify_voice_authorization("jeremy", 0.50).level, "unknown")
|
||||
self.assertEqual(classify_voice_authorization(None, 0.99).level, "unknown")
|
||||
|
||||
def test_unknown_speaker_is_read_only(self):
|
||||
decision = classify_voice_authorization(None, 0.0)
|
||||
|
||||
self.assertTrue(decision.read_only)
|
||||
self.assertTrue(decision.requires_confirmation)
|
||||
|
||||
|
||||
class ConfigAndHermesTests(unittest.TestCase):
|
||||
def test_settings_defaults_match_residence_services(self):
|
||||
settings = Settings()
|
||||
|
||||
self.assertEqual(settings.speaches_url, "http://192.168.86.150:8000")
|
||||
self.assertEqual(settings.hermes_command, "hermes")
|
||||
self.assertEqual(settings.speaker_confirmed_threshold, 0.85)
|
||||
|
||||
def test_settings_from_env_uses_defaults_when_env_is_empty(self):
|
||||
settings = Settings.from_env({})
|
||||
|
||||
self.assertEqual(settings.speaches_url, "http://192.168.86.150:8000")
|
||||
self.assertEqual(settings.speaker_confirmed_threshold, 0.85)
|
||||
|
||||
def test_hermes_cli_command_uses_single_query_mode_and_source(self):
|
||||
command = build_cli_command("status please", Settings(hermes_command="hermes"))
|
||||
|
||||
self.assertEqual(command[:3], ["hermes", "chat", "-q"])
|
||||
self.assertIn("status please", command)
|
||||
self.assertIn("--source", command)
|
||||
self.assertIn("friday-voice", command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
const recordButton = document.getElementById('recordButton');
|
||||
const sendTextButton = document.getElementById('sendTextButton');
|
||||
const textInput = document.getElementById('textInput');
|
||||
const statusEl = document.getElementById('status');
|
||||
const transcriptEl = document.getElementById('transcript');
|
||||
const replyEl = document.getElementById('reply');
|
||||
const audioReply = document.getElementById('audioReply');
|
||||
|
||||
let recorder;
|
||||
let chunks = [];
|
||||
|
||||
function setStatus(text) {
|
||||
statusEl.textContent = text;
|
||||
}
|
||||
|
||||
async function askFriday(text) {
|
||||
setStatus('Asking FRIDAY...');
|
||||
const response = await fetch('/api/ask', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, speaker_id: null, confidence: 0 })
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = await response.json();
|
||||
replyEl.textContent = data.reply;
|
||||
setStatus(`Authorization: ${data.authorization_level}; ${data.reason}`);
|
||||
await speak(data.reply);
|
||||
}
|
||||
|
||||
async function speak(text) {
|
||||
const response = await fetch('/api/speak', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const blob = await response.blob();
|
||||
audioReply.src = URL.createObjectURL(blob);
|
||||
await audioReply.play().catch(() => {});
|
||||
}
|
||||
|
||||
async function transcribe(blob) {
|
||||
setStatus('Transcribing...');
|
||||
const form = new FormData();
|
||||
form.append('audio', blob, 'utterance.webm');
|
||||
const response = await fetch('/api/transcribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'audio/webm' },
|
||||
body: blob
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = await response.json();
|
||||
transcriptEl.textContent = data.text;
|
||||
textInput.value = data.text;
|
||||
return data.text;
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
chunks = [];
|
||||
recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
||||
recorder.addEventListener('dataavailable', event => {
|
||||
if (event.data.size > 0) chunks.push(event.data);
|
||||
});
|
||||
recorder.addEventListener('stop', async () => {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
recordButton.classList.remove('recording');
|
||||
recordButton.textContent = 'Hold to Talk';
|
||||
try {
|
||||
const text = await transcribe(new Blob(chunks, { type: 'audio/webm' }));
|
||||
if (text.trim()) await askFriday(text);
|
||||
} catch (error) {
|
||||
setStatus(`Error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
recorder.start();
|
||||
recordButton.classList.add('recording');
|
||||
recordButton.textContent = 'Listening... release to send';
|
||||
setStatus('Listening...');
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (recorder && recorder.state === 'recording') recorder.stop();
|
||||
}
|
||||
|
||||
recordButton.addEventListener('pointerdown', startRecording);
|
||||
recordButton.addEventListener('pointerup', stopRecording);
|
||||
recordButton.addEventListener('pointerleave', stopRecording);
|
||||
sendTextButton.addEventListener('click', async () => {
|
||||
const text = textInput.value.trim();
|
||||
if (!text) return;
|
||||
transcriptEl.textContent = text;
|
||||
try {
|
||||
await askFriday(text);
|
||||
} catch (error) {
|
||||
setStatus(`Error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FRIDAY Voice Console</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="card hero">
|
||||
<p class="eyebrow">FRIDAY Voice Console</p>
|
||||
<h1>Talk to FRIDAY</h1>
|
||||
<p class="muted">Push-to-talk MVP. Audio stays inside the residence path: browser → gateway → Speaches → Hermes.</p>
|
||||
<div class="controls">
|
||||
<button id="recordButton">Hold to Talk</button>
|
||||
<button id="sendTextButton" class="secondary">Send Text</button>
|
||||
</div>
|
||||
<textarea id="textInput" placeholder="Or type a request here for testing..."></textarea>
|
||||
<p id="status" class="status">Idle.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Transcript</h2>
|
||||
<pre id="transcript"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>FRIDAY</h2>
|
||||
<pre id="reply"></pre>
|
||||
<audio id="audioReply" controls></audio>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #05070a;
|
||||
color: #f5f7fb;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top left, #15324a 0, #05070a 42rem);
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 20px;
|
||||
padding: 1.25rem;
|
||||
background: rgba(7, 12, 18, 0.82);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: clamp(2.25rem, 8vw, 4.5rem);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #80d8ff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.muted, .status {
|
||||
color: #aeb8c5;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0.85rem 1.25rem;
|
||||
color: #03121f;
|
||||
background: #80d8ff;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.recording {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
color: #f5f7fb;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 6rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #f5f7fb;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
color: #dbeafe;
|
||||
}
|
||||
|
||||
audio {
|
||||
width: 100%;
|
||||
}
|
||||
Reference in New Issue
Block a user