commit c2a304642a77971df9dda29069d16d9398327a33 Author: FRIDAY Date: Thu Jun 25 07:24:34 2026 -0500 Initial FRIDAY Voice Console scaffold diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d8856e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7cdee6d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cb5e79 --- /dev/null +++ b/README.md @@ -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. diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..3c28131 --- /dev/null +++ b/deploy/.env.example @@ -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 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..edee640 --- /dev/null +++ b/deploy/docker-compose.yml @@ -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 diff --git a/deploy/friday-voice.traefik.yml b/deploy/friday-voice.traefik.yml new file mode 100644 index 0000000..d4b25ab --- /dev/null +++ b/deploy/friday-voice.traefik.yml @@ -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" diff --git a/friday_voice/__init__.py b/friday_voice/__init__.py new file mode 100644 index 0000000..0875e41 --- /dev/null +++ b/friday_voice/__init__.py @@ -0,0 +1,4 @@ +"""FRIDAY Voice Console backend package.""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/friday_voice/app.py b/friday_voice/app.py new file mode 100644 index 0000000..f58ca32 --- /dev/null +++ b/friday_voice/app.py @@ -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 "

FRIDAY Voice Console

Static UI missing.

" + + +@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 diff --git a/friday_voice/authorization.py b/friday_voice/authorization.py new file mode 100644 index 0000000..9e5e529 --- /dev/null +++ b/friday_voice/authorization.py @@ -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.", + ) diff --git a/friday_voice/config.py b/friday_voice/config.py new file mode 100644 index 0000000..e3e182d --- /dev/null +++ b/friday_voice/config.py @@ -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) + ), + ) diff --git a/friday_voice/hermes_client.py b/friday_voice/hermes_client.py new file mode 100644 index 0000000..af58e12 --- /dev/null +++ b/friday_voice/hermes_client.py @@ -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() diff --git a/friday_voice/speaker_store.py b/friday_voice/speaker_store.py new file mode 100644 index 0000000..5b3752b --- /dev/null +++ b/friday_voice/speaker_store.py @@ -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, + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0fb8d83 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +pydantic==2.10.4 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..1435871 --- /dev/null +++ b/tests/test_core.py @@ -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() diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..2d056a3 --- /dev/null +++ b/web/app.js @@ -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}`); + } +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0c1be28 --- /dev/null +++ b/web/index.html @@ -0,0 +1,36 @@ + + + + + + FRIDAY Voice Console + + + +
+
+

FRIDAY Voice Console

+

Talk to FRIDAY

+

Push-to-talk MVP. Audio stays inside the residence path: browser → gateway → Speaches → Hermes.

+
+ + +
+ +

Idle.

+
+ +
+

Transcript

+

+    
+ +
+

FRIDAY

+

+      
+    
+
+ + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..17d9ee9 --- /dev/null +++ b/web/style.css @@ -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%; +}