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
+16
View File
@@ -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
View File
@@ -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"]
+91
View File
@@ -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.
+8
View File
@@ -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
+39
View File
@@ -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
+30
View File
@@ -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"
+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,
)
+3
View File
@@ -0,0 +1,3 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
pydantic==2.10.4
+79
View File
@@ -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
View File
@@ -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}`);
}
});
+36
View File
@@ -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>
+91
View File
@@ -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%;
}