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