fix: mobile tab timeout — idle session reaper + wake lock + visibility reconnect
Backend:
- Session.last_subscriber_left_at: monotonic timestamp set when last SSE subscriber
leaves, cleared when a new one arrives
- Session.subscriber_count(): replaces len(_subscribers) access from outside the model
- session_store._reaper_loop(): kills sessions with no subscribers for >SESSION_IDLE_TTL_S
(default 90s); runs every TTL/2 seconds via asyncio.create_task at startup
- session_store._reaper_loop_once(): single-cycle variant for deterministic tests
- app/main.py lifespan: starts reaper on startup, cancels it cleanly on shutdown
- config.py: SESSION_IDLE_TTL_S setting (90s default, overridable per-env)
Frontend:
- useWakeLock.ts: Screen Wake Lock API wrapper; acquires on connect, releases on
disconnect; degrades silently when unsupported (battery saver, iOS Safari)
- useToneStream.ts: visibilitychange handler — on hidden: closes EventSource without
ending backend session (grace window stays open); on visible: GET /session/{id}
liveness check, reconnects SSE + re-acquires wake lock if alive, sets expired=true
and calls store.reset() if reaped
- ComposeBar.vue: surfaces expired state with calm 'Session timed out' notice
(not an error — expected behaviour on long screen-off)
Tests:
- test_reaper.py: 7 tests covering subscriber idle tracking, reaper eligibility
(kills idle, spares active subscriber, spares within-TTL)
This commit is contained in:
parent
321abe0646
commit
1bc47b8e0f
30 changed files with 4660 additions and 67 deletions
14
Dockerfile
14
Dockerfile
|
|
@ -4,11 +4,21 @@ WORKDIR /app
|
|||
|
||||
# System deps for audio inference (cf-voice real mode only)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libsndfile1 \
|
||||
libsndfile1 git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# GIT_TOKEN: Forgejo token for private circuitforge-core install.
|
||||
# Passed at build time via compose.cloud.yml build.args — never baked into a layer.
|
||||
ARG GIT_TOKEN
|
||||
|
||||
# Install public sibling + private circuitforge-core (token consumed here, not cached)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
pip install --no-cache-dir \
|
||||
"git+https://${GIT_TOKEN}@git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git@main"
|
||||
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir -e .
|
||||
RUN pip install --no-cache-dir -e . --no-deps
|
||||
|
||||
COPY app/ app/
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# linnet
|
||||
|
||||
Real-time tone annotation — Elcor-style emotional/tonal subtext for ND and autistic users
|
||||
Local-first voice transcription with speaker diarization, meeting notes, dictation, and real-time tone annotation — tonal subtext labels for calls, appointments, and conversations
|
||||
|
|
@ -2,15 +2,19 @@
|
|||
#
|
||||
# Receives raw PCM Int16 audio chunks from the browser's AudioWorkletProcessor.
|
||||
# Each message is a binary frame: 16kHz mono Int16 PCM.
|
||||
# The backend accumulates chunks until cf-voice processes them.
|
||||
#
|
||||
# Notation v0.1.x: audio is accepted and acknowledged but inference runs
|
||||
# through the background ContextClassifier (started at session creation),
|
||||
# not inline here. This endpoint is wired for the real audio path
|
||||
# (Navigation v0.2.x) where chunks feed the STT + diarizer directly.
|
||||
# When CF_VOICE_URL is set (cf-voice sidecar allocated by cf-orch), each chunk
|
||||
# is base64-encoded and forwarded to cf-voice /classify. The resulting tone
|
||||
# events are broadcast to SSE subscribers via the session store.
|
||||
#
|
||||
# When CF_VOICE_URL is unset (local dev / mock mode), chunks are acknowledged
|
||||
# but not forwarded — the in-process ContextClassifier.stream() generates
|
||||
# synthetic frames independently.
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
|
|
@ -19,18 +23,19 @@ from app.services import session_store
|
|||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/session", tags=["audio"])
|
||||
|
||||
_SESSION_START: dict[str, float] = {}
|
||||
|
||||
|
||||
@router.websocket("/{session_id}/audio")
|
||||
async def audio_ws(websocket: WebSocket, session_id: str) -> None:
|
||||
"""
|
||||
WebSocket endpoint for binary PCM audio upload.
|
||||
|
||||
Clients (browser AudioWorkletProcessor) send binary frames.
|
||||
Server acknowledges each frame with {"ok": true}.
|
||||
Clients (browser AudioWorkletProcessor) send binary Int16 frames.
|
||||
Server acknowledges each frame with {"ok": true, "bytes": N}.
|
||||
|
||||
In mock mode (CF_VOICE_MOCK=1) the session's ContextClassifier generates
|
||||
synthetic frames independently -- audio sent here is accepted but not
|
||||
processed. Real inference wiring happens in Navigation v0.2.x.
|
||||
When CF_VOICE_URL is configured, each chunk is forwarded to the cf-voice
|
||||
sidecar and the resulting tone events are broadcast to SSE subscribers.
|
||||
"""
|
||||
session = session_store.get_session(session_id)
|
||||
if session is None:
|
||||
|
|
@ -38,12 +43,17 @@ async def audio_ws(websocket: WebSocket, session_id: str) -> None:
|
|||
return
|
||||
|
||||
await websocket.accept()
|
||||
_SESSION_START[session_id] = time.monotonic()
|
||||
logger.info("Audio WS connected for session %s", session_id)
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_bytes()
|
||||
# Notation v0.1.x: acknowledge receipt; real inference in v0.2.x
|
||||
timestamp = time.monotonic() - _SESSION_START.get(session_id, 0.0)
|
||||
await websocket.send_json({"ok": True, "bytes": len(data)})
|
||||
# Forward to cf-voice sidecar (no-op if CF_VOICE_URL is unset)
|
||||
audio_b64 = base64.b64encode(data).decode()
|
||||
await session_store.forward_audio_chunk(session, audio_b64, timestamp)
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Audio WS disconnected for session %s", session_id)
|
||||
_SESSION_START.pop(session_id, None)
|
||||
|
|
|
|||
5
app/api/corrections.py
Normal file
5
app/api/corrections.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# app/api/corrections.py — tone annotation correction submissions
|
||||
from circuitforge_core.api import make_corrections_router
|
||||
from app.db import get_db
|
||||
|
||||
router = make_corrections_router(get_db=get_db, product="linnet")
|
||||
71
app/api/samples.py
Normal file
71
app/api/samples.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# app/api/samples.py — Demo text samples for model testing via the imitate tab
|
||||
#
|
||||
# Returns pre-curated sentences with varied emotional subtext so that
|
||||
# external tools (e.g. avocet imitate tab) can feed them to local LLMs
|
||||
# and evaluate tone annotation quality without a live audio session.
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/samples", tags=["samples"])
|
||||
|
||||
# Each sample has a text field (the raw utterance) and a context field
|
||||
# describing the situation — both fed into the imitate prompt template.
|
||||
_SAMPLES: list[dict] = [
|
||||
{
|
||||
"text": "Sure, I can take care of that.",
|
||||
"context": "Employee responding to manager's request during a tense project meeting.",
|
||||
"category": "compliance",
|
||||
},
|
||||
{
|
||||
"text": "That's a really interesting approach.",
|
||||
"context": "Colleague reacting to a proposal they visibly disagree with.",
|
||||
"category": "polite_disagreement",
|
||||
},
|
||||
{
|
||||
"text": "I'll have it done by end of day.",
|
||||
"context": "Developer already working at capacity, second deadline added without discussion.",
|
||||
"category": "overcommitment",
|
||||
},
|
||||
{
|
||||
"text": "No, it's fine. Don't worry about it.",
|
||||
"context": "Person whose suggestion was dismissed without acknowledgment.",
|
||||
"category": "suppressed_hurt",
|
||||
},
|
||||
{
|
||||
"text": "I guess we could try it your way.",
|
||||
"context": "Team lead conceding after strong pushback, despite having a valid concern.",
|
||||
"category": "reluctant_agreement",
|
||||
},
|
||||
{
|
||||
"text": "That's one way to look at it.",
|
||||
"context": "Researcher responding to a peer who has misread their data.",
|
||||
"category": "implicit_correction",
|
||||
},
|
||||
{
|
||||
"text": "I appreciate you letting me know.",
|
||||
"context": "Employee informed their project is being cancelled with two hours notice.",
|
||||
"category": "formal_distress",
|
||||
},
|
||||
{
|
||||
"text": "We should probably circle back on this.",
|
||||
"context": "Manager avoiding a decision they don't want to make.",
|
||||
"category": "deferral",
|
||||
},
|
||||
{
|
||||
"text": "I just want to make sure I'm understanding correctly.",
|
||||
"context": "Autistic person who has been given contradictory instructions twice already.",
|
||||
"category": "clarification_exhaustion",
|
||||
},
|
||||
{
|
||||
"text": "Happy to help however I can!",
|
||||
"context": "Volunteer who has already put in 60 hours this month responding to another ask.",
|
||||
"category": "masking_burnout",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_samples(limit: int = 5) -> list[dict]:
|
||||
"""Return a slice of demo annotation samples for model testing."""
|
||||
return _SAMPLES[:max(1, min(limit, len(_SAMPLES)))]
|
||||
|
|
@ -22,7 +22,7 @@ class SessionResponse(BaseModel):
|
|||
@router.post("/start", response_model=SessionResponse)
|
||||
async def start_session(req: StartRequest = StartRequest()) -> SessionResponse:
|
||||
"""Start a new annotation session and begin streaming VoiceFrames."""
|
||||
session = session_store.create_session(elcor=req.elcor)
|
||||
session = await session_store.create_session(elcor=req.elcor)
|
||||
return SessionResponse(
|
||||
session_id=session.session_id,
|
||||
state=session.state,
|
||||
|
|
@ -33,7 +33,7 @@ async def start_session(req: StartRequest = StartRequest()) -> SessionResponse:
|
|||
@router.delete("/{session_id}/end")
|
||||
async def end_session(session_id: str) -> dict:
|
||||
"""Stop a session and release its classifier."""
|
||||
removed = session_store.end_session(session_id)
|
||||
removed = await session_store.end_session(session_id)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
return {"session_id": session_id, "state": "stopped"}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ class Settings:
|
|||
# DEMO: auto-kill sessions after this many seconds of inactivity
|
||||
demo_session_ttl_s: int = int(os.getenv("DEMO_SESSION_TTL_S", "300")) # 5 min
|
||||
|
||||
# All modes: kill a session this many seconds after its last SSE subscriber
|
||||
# disconnects. Covers mobile tab timeout / screen lock / crash.
|
||||
# Set generously enough to survive a brief screen-off without losing the session,
|
||||
# but short enough that zombie sessions don't accumulate.
|
||||
session_idle_ttl_s: int = int(os.getenv("SESSION_IDLE_TTL_S", "90"))
|
||||
|
||||
# CLOUD: where Caddy injects the cf_session user token
|
||||
cloud_session_header: str = os.getenv("CLOUD_SESSION_HEADER", "X-CF-Session")
|
||||
cloud_data_root: str = os.getenv("CLOUD_DATA_ROOT", "/devl/linnet-cloud-data")
|
||||
|
|
@ -25,5 +31,17 @@ class Settings:
|
|||
linnet_frontend_port: int = int(os.getenv("LINNET_FRONTEND_PORT", "8521"))
|
||||
linnet_base_url: str = os.getenv("LINNET_BASE_URL", "")
|
||||
|
||||
# cf-orch coordinator URL — used to request a managed cf-voice instance per session.
|
||||
# When set, each session allocates a cf-voice sidecar via the coordinator REST API.
|
||||
cf_orch_url: str = os.getenv("CF_ORCH_URL", "")
|
||||
|
||||
# Static cf-voice sidecar URL — legacy override (bypasses cf-orch, points directly
|
||||
# at a running cf-voice process). Takes precedence over cf-orch allocation when set.
|
||||
cf_voice_url: str = os.getenv("CF_VOICE_URL", "")
|
||||
|
||||
# Local SQLite DB for corrections storage. Shared across users on a single instance;
|
||||
# corrections contain text only (no audio). Cloud mode uses cloud_data_root prefix.
|
||||
linnet_db: str = os.getenv("LINNET_DB", "data/linnet.db")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
37
app/db.py
Normal file
37
app/db.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# app/db.py — SQLite connection and migration runner for Linnet
|
||||
#
|
||||
# Used only for corrections storage (tone annotation training data).
|
||||
# Session state is in-memory; this DB persists user-submitted corrections.
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
from circuitforge_core.db import run_migrations
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||
|
||||
|
||||
def _db_path() -> Path:
|
||||
path = Path(settings.linnet_db)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(_db_path()), check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
run_migrations(conn, _MIGRATIONS_DIR)
|
||||
return conn
|
||||
|
||||
|
||||
def get_db() -> Iterator[sqlite3.Connection]:
|
||||
"""FastAPI dependency — yields a connection, closes on teardown."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
19
app/main.py
19
app/main.py
|
|
@ -3,22 +3,35 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api import audio, events, export, history, sessions
|
||||
from app.api import audio, corrections, events, export, history, samples, sessions
|
||||
from app.config import settings
|
||||
from app.services import session_store
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: begin idle session reaper
|
||||
session_store.start_reaper()
|
||||
yield
|
||||
# Shutdown: cancel the reaper cleanly
|
||||
await session_store.stop_reaper()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Linnet",
|
||||
description="Real-time tone annotation — Elcor-style subtext for ND/autistic users",
|
||||
description="Real-time tone annotation — tonal subtext labels for ND/autistic users",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# ── Mode middleware (applied before CORS so headers are always present) ──────
|
||||
|
|
@ -58,6 +71,8 @@ app.include_router(events.router)
|
|||
app.include_router(history.router)
|
||||
app.include_router(audio.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(samples.router)
|
||||
app.include_router(corrections.router, prefix="/corrections", tags=["corrections"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
|
|
|||
21
app/migrations/001_corrections.sql
Normal file
21
app/migrations/001_corrections.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- Migration 001: Corrections table for tone annotation training data.
|
||||
-- Users can rate annotations (up/down) and submit corrected versions.
|
||||
-- Only opted_in=1 rows are exported to the Avocet SFT pipeline.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS corrections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL DEFAULT '', -- session_id or event_id
|
||||
product TEXT NOT NULL DEFAULT 'linnet',
|
||||
correction_type TEXT NOT NULL DEFAULT 'annotation',
|
||||
input_text TEXT NOT NULL, -- the utterance that was annotated
|
||||
original_output TEXT NOT NULL, -- the LLM annotation produced
|
||||
corrected_output TEXT NOT NULL DEFAULT '', -- user's correction (empty = thumbs up)
|
||||
rating TEXT NOT NULL DEFAULT 'down', -- 'up' | 'down'
|
||||
context TEXT NOT NULL DEFAULT '{}', -- JSON: session_id, model, elcor flag, etc.
|
||||
opted_in INTEGER NOT NULL DEFAULT 0, -- 1 = user consented to share for training
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_corrections_product ON corrections (product);
|
||||
CREATE INDEX IF NOT EXISTS idx_corrections_opted_in ON corrections (opted_in);
|
||||
CREATE INDEX IF NOT EXISTS idx_corrections_item_id ON corrections (item_id);
|
||||
32
app/models/queue_event.py
Normal file
32
app/models/queue_event.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# app/models/queue_event.py — call queue state and environment events
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueueEvent:
|
||||
"""
|
||||
Call queue state or environmental classification from cf-voice AST.
|
||||
|
||||
event_type is either "queue" or "environ".
|
||||
Broadcasts via SSE as `event: queue-event` or `event: environ-event`.
|
||||
Not stored in session history.
|
||||
"""
|
||||
session_id: str
|
||||
event_type: str # "queue" or "environ"
|
||||
label: str # e.g. "hold_music", "ringback", "call_center", "quiet"
|
||||
confidence: float
|
||||
timestamp: float
|
||||
|
||||
def to_sse(self) -> str:
|
||||
payload = {
|
||||
"event_type": self.event_type,
|
||||
"session_id": self.session_id,
|
||||
"label": self.label,
|
||||
"confidence": self.confidence,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
sse_name = "queue-event" if self.event_type == "queue" else "environ-event"
|
||||
return f"event: {sse_name}\ndata: {json.dumps(payload)}\n\n"
|
||||
|
|
@ -5,13 +5,19 @@ import asyncio
|
|||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from app.models.tone_event import ToneEvent
|
||||
|
||||
SessionState = Literal["starting", "running", "stopped"]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SessionEvent(Protocol):
|
||||
"""Any event that can be broadcast over SSE from a session."""
|
||||
def to_sse(self) -> str: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""
|
||||
|
|
@ -26,14 +32,30 @@ class Session:
|
|||
created_at: float = field(default_factory=time.monotonic)
|
||||
elcor: bool = False
|
||||
|
||||
# cf-voice sidecar — populated by session_store._allocate_voice() when cf-orch
|
||||
# is configured. Empty string means in-process fallback is active.
|
||||
cf_voice_url: str = ""
|
||||
cf_voice_allocation_id: str = ""
|
||||
|
||||
# History: last 50 events retained for GET /session/{id}/history
|
||||
history: list[ToneEvent] = field(default_factory=list)
|
||||
_subscribers: list[asyncio.Queue] = field(default_factory=list, repr=False)
|
||||
|
||||
# Idle tracking: monotonic timestamp of when the last subscriber left.
|
||||
# None means at least one subscriber is currently active, or the session
|
||||
# has never had one yet (just started). The reaper uses this to detect
|
||||
# abandoned sessions after a mobile tab timeout.
|
||||
last_subscriber_left_at: float | None = field(default=None, repr=False)
|
||||
|
||||
def subscriber_count(self) -> int:
|
||||
return len(self._subscribers)
|
||||
|
||||
def subscribe(self) -> asyncio.Queue:
|
||||
"""Add an SSE subscriber. Returns its dedicated queue."""
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
self._subscribers.append(q)
|
||||
# Reset idle clock — someone is watching again
|
||||
self.last_subscriber_left_at = None
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: asyncio.Queue) -> None:
|
||||
|
|
@ -41,12 +63,16 @@ class Session:
|
|||
self._subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
# Start the idle clock when the last subscriber leaves
|
||||
if not self._subscribers:
|
||||
self.last_subscriber_left_at = time.monotonic()
|
||||
|
||||
def broadcast(self, event: ToneEvent) -> None:
|
||||
"""Fan out a ToneEvent to all current SSE subscribers."""
|
||||
if len(self.history) >= 50:
|
||||
self.history.pop(0)
|
||||
self.history.append(event)
|
||||
def broadcast(self, event: SessionEvent) -> None:
|
||||
"""Fan out any SessionEvent (tone, speaker, etc.) to all SSE subscribers."""
|
||||
if isinstance(event, ToneEvent):
|
||||
if len(self.history) >= 50:
|
||||
self.history.pop(0)
|
||||
self.history.append(event)
|
||||
|
||||
dead: list[asyncio.Queue] = []
|
||||
for q in self._subscribers:
|
||||
|
|
|
|||
29
app/models/speaker_event.py
Normal file
29
app/models/speaker_event.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# app/models/speaker_event.py — speaker classification event
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpeakerEvent:
|
||||
"""
|
||||
Speaker/environment classification from cf-voice.
|
||||
|
||||
Broadcasts via SSE as `event: speaker-event` alongside tone-events.
|
||||
Not stored in session history (tone history only).
|
||||
"""
|
||||
session_id: str
|
||||
label: str # e.g. "human", "no_speaker", "ivr_synth"
|
||||
confidence: float
|
||||
timestamp: float
|
||||
|
||||
def to_sse(self) -> str:
|
||||
payload = {
|
||||
"event_type": "speaker",
|
||||
"session_id": self.session_id,
|
||||
"label": self.label,
|
||||
"confidence": self.confidence,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
return f"event: speaker-event\ndata: {json.dumps(payload)}\n\n"
|
||||
29
app/models/transcript_event.py
Normal file
29
app/models/transcript_event.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# app/models/transcript_event.py — live STT transcript event
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptEvent:
|
||||
"""
|
||||
Live transcription segment from cf-voice Whisper STT.
|
||||
|
||||
Broadcasts via SSE as `event: transcript-event`. Not stored in history.
|
||||
The label field carries the raw transcript text.
|
||||
"""
|
||||
session_id: str
|
||||
text: str
|
||||
speaker_id: str # diarization label, e.g. "SPEAKER_00" or "speaker_a"
|
||||
timestamp: float
|
||||
|
||||
def to_sse(self) -> str:
|
||||
payload = {
|
||||
"event_type": "transcript",
|
||||
"session_id": self.session_id,
|
||||
"text": self.text,
|
||||
"speaker_id": self.speaker_id,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
return f"event: transcript-event\ndata: {json.dumps(payload)}\n\n"
|
||||
|
|
@ -8,16 +8,22 @@ from cf_voice.models import VoiceFrame
|
|||
from app.models.tone_event import ToneEvent
|
||||
|
||||
|
||||
def annotate(frame: VoiceFrame, session_id: str, elcor: bool = False) -> ToneEvent | None:
|
||||
def annotate(
|
||||
frame: VoiceFrame,
|
||||
session_id: str,
|
||||
elcor: bool = False,
|
||||
threshold: float = 0.10,
|
||||
) -> ToneEvent | None:
|
||||
"""
|
||||
Convert a VoiceFrame into a session ToneEvent.
|
||||
|
||||
Returns None if the frame is below the reliability threshold (confidence
|
||||
too low to annotate confidently). Callers should skip None results.
|
||||
Returns None if the frame is below the reliability threshold. The default
|
||||
0.25 is calibrated for real wav2vec2 SER inference, which routinely scores
|
||||
0.15-0.45 on conversational audio. The mock used 0.6 (mock confidence was
|
||||
artificially inflated).
|
||||
|
||||
elcor=True switches subtext to Mass Effect Elcor prefix format.
|
||||
This is an easter egg -- do not pass elcor=True by default.
|
||||
elcor=True switches subtext to bracketed tone-prefix format (easter egg).
|
||||
"""
|
||||
if not frame.is_reliable():
|
||||
if not frame.is_reliable(threshold=threshold):
|
||||
return None
|
||||
return ToneEvent.from_voice_frame(frame, session_id=session_id, elcor=elcor)
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from cf_voice.context import ContextClassifier
|
||||
|
||||
from app.config import settings
|
||||
from app.models.session import Session
|
||||
from app.models.tone_event import ToneEvent
|
||||
from app.services.annotator import annotate
|
||||
|
|
@ -15,19 +15,37 @@ logger = logging.getLogger(__name__)
|
|||
# Module-level singleton store — one per process
|
||||
_sessions: dict[str, Session] = {}
|
||||
_tasks: dict[str, asyncio.Task] = {}
|
||||
_reaper_task: asyncio.Task | None = None
|
||||
|
||||
# Audio accumulation buffer per session.
|
||||
# We accumulate 100ms PCM chunks until we have CLASSIFY_WINDOW_MS of audio,
|
||||
# then fire a single /classify call. Real emotion models need ≥500ms context.
|
||||
_CLASSIFY_WINDOW_MS = 1000 # ms of audio per classify call; wav2vec2 needs ≥1s context
|
||||
_CHUNK_MS = 100 # AudioWorklet sends 1600 samples @ 16kHz = 100ms
|
||||
_CHUNKS_PER_WINDOW = _CLASSIFY_WINDOW_MS // _CHUNK_MS # 10 chunks
|
||||
_audio_buffers: dict[str, list[bytes]] = {}
|
||||
|
||||
|
||||
def create_session(elcor: bool = False) -> Session:
|
||||
"""Create a new session and start its ContextClassifier background task."""
|
||||
async def create_session(elcor: bool = False) -> Session:
|
||||
"""Create a new session and start its ContextClassifier background task.
|
||||
|
||||
If CF_ORCH_URL is configured, requests a managed cf-voice instance before
|
||||
starting the classifier. Falls back to in-process mock if allocation fails.
|
||||
"""
|
||||
session = Session(elcor=elcor)
|
||||
_sessions[session.session_id] = session
|
||||
await _allocate_voice(session)
|
||||
task = asyncio.create_task(
|
||||
_run_classifier(session),
|
||||
name=f"classifier-{session.session_id}",
|
||||
)
|
||||
_tasks[session.session_id] = task
|
||||
session.state = "running"
|
||||
logger.info("Session %s started", session.session_id)
|
||||
logger.info(
|
||||
"Session %s started (voice=%s)",
|
||||
session.session_id,
|
||||
session.cf_voice_url or "in-process",
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
|
|
@ -40,7 +58,7 @@ def active_session_count() -> int:
|
|||
return sum(1 for s in _sessions.values() if s.state == "running")
|
||||
|
||||
|
||||
def end_session(session_id: str) -> bool:
|
||||
async def end_session(session_id: str) -> bool:
|
||||
"""Stop and remove a session. Returns True if it existed."""
|
||||
session = _sessions.pop(session_id, None)
|
||||
if session is None:
|
||||
|
|
@ -49,17 +67,83 @@ def end_session(session_id: str) -> bool:
|
|||
task = _tasks.pop(session_id, None)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
_audio_buffers.pop(session_id, None)
|
||||
await _release_voice(session)
|
||||
logger.info("Session %s ended", session_id)
|
||||
return True
|
||||
|
||||
|
||||
async def _allocate_voice(session: Session) -> None:
|
||||
"""Request a managed cf-voice instance from cf-orch. No-op if CF_ORCH_URL is unset."""
|
||||
# Static override takes precedence — skip cf-orch allocation
|
||||
if settings.cf_voice_url:
|
||||
session.cf_voice_url = settings.cf_voice_url
|
||||
return
|
||||
if not settings.cf_orch_url:
|
||||
return
|
||||
|
||||
import httpx
|
||||
|
||||
url = settings.cf_orch_url.rstrip("/") + "/api/services/cf-voice/allocate"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(url, json={"caller": "linnet", "ttl_s": 3600.0})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
session.cf_voice_url = data["url"]
|
||||
session.cf_voice_allocation_id = data["allocation_id"]
|
||||
logger.info(
|
||||
"cf-orch allocated cf-voice for session %s: %s (alloc=%s)",
|
||||
session.session_id, data["url"], data["allocation_id"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"cf-orch allocation failed for session %s: %s — falling back to in-process",
|
||||
session.session_id, exc,
|
||||
)
|
||||
|
||||
|
||||
async def _release_voice(session: Session) -> None:
|
||||
"""Release a cf-voice allocation back to cf-orch. No-op if not orch-managed."""
|
||||
if not session.cf_voice_allocation_id or not settings.cf_orch_url:
|
||||
return
|
||||
|
||||
import httpx
|
||||
|
||||
url = (
|
||||
settings.cf_orch_url.rstrip("/")
|
||||
+ f"/api/services/cf-voice/allocations/{session.cf_voice_allocation_id}"
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
await client.delete(url)
|
||||
logger.info("Released cf-voice allocation for session %s", session.session_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"cf-orch release failed for session %s: %s", session.session_id, exc
|
||||
)
|
||||
|
||||
|
||||
async def _run_classifier(session: Session) -> None:
|
||||
"""
|
||||
Background task: stream VoiceFrames from cf-voice and broadcast ToneEvents.
|
||||
Background task: stream VoiceFrames and broadcast ToneEvents.
|
||||
|
||||
Starts the ContextClassifier (mock or real depending on CF_VOICE_MOCK),
|
||||
converts each frame via annotator.annotate(), and fans out to subscribers.
|
||||
Two modes:
|
||||
- Sidecar (cf-orch allocated or CF_VOICE_URL set): holds the session open
|
||||
while audio chunks are forwarded to cf-voice via forward_audio_chunk().
|
||||
- In-process (no sidecar): runs ContextClassifier.from_env() directly.
|
||||
Used for local dev and mock mode when CF_ORCH_URL is unset or allocation failed.
|
||||
"""
|
||||
if session.cf_voice_url:
|
||||
await _run_classifier_sidecar(session)
|
||||
else:
|
||||
await _run_classifier_inprocess(session)
|
||||
|
||||
|
||||
async def _run_classifier_inprocess(session: Session) -> None:
|
||||
"""In-process path: ContextClassifier.stream() — used when CF_VOICE_URL is unset."""
|
||||
from cf_voice.context import ContextClassifier
|
||||
|
||||
classifier = ContextClassifier.from_env()
|
||||
try:
|
||||
async for frame in classifier.stream():
|
||||
|
|
@ -74,3 +158,199 @@ async def _run_classifier(session: Session) -> None:
|
|||
await classifier.stop()
|
||||
session.state = "stopped"
|
||||
logger.info("Classifier stopped for session %s", session.session_id)
|
||||
|
||||
|
||||
async def _run_classifier_sidecar(session: Session) -> None:
|
||||
"""
|
||||
Sidecar path: wait for audio chunks forwarded via forward_audio_chunk().
|
||||
|
||||
The sidecar does not self-generate frames — audio arrives from the browser
|
||||
WebSocket and is sent to cf-voice /classify. This task holds the session
|
||||
open and handles cleanup.
|
||||
"""
|
||||
try:
|
||||
while session.state == "running":
|
||||
await asyncio.sleep(1.0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
session.state = "stopped"
|
||||
logger.info("Sidecar session ended for session %s", session.session_id)
|
||||
|
||||
|
||||
async def forward_audio_chunk(
|
||||
session: Session,
|
||||
audio_b64: str,
|
||||
timestamp: float,
|
||||
) -> None:
|
||||
"""
|
||||
Accumulate PCM chunks into 500ms windows, then forward to cf-voice /classify.
|
||||
|
||||
The AudioWorklet sends 100ms chunks; we buffer 10 of them (_CHUNKS_PER_WINDOW)
|
||||
before firing a classify call. This gives wav2vec2 a full second of context
|
||||
and keeps the classify rate at 1/s instead of 10/s.
|
||||
|
||||
No-op when no cf-voice sidecar is allocated (in-process path handles its own input).
|
||||
"""
|
||||
voice_url = session.cf_voice_url
|
||||
if not voice_url:
|
||||
return
|
||||
|
||||
import base64 as _b64
|
||||
import httpx
|
||||
|
||||
from app.models.speaker_event import SpeakerEvent
|
||||
from cf_voice.models import VoiceFrame
|
||||
|
||||
# Decode incoming chunk and append to per-session buffer
|
||||
raw = _b64.b64decode(audio_b64)
|
||||
buf = _audio_buffers.setdefault(session.session_id, [])
|
||||
buf.append(raw)
|
||||
|
||||
if len(buf) < _CHUNKS_PER_WINDOW:
|
||||
return # not enough audio yet — wait for more chunks
|
||||
|
||||
# Flush: concatenate window, reset buffer
|
||||
window_bytes = b"".join(buf)
|
||||
buf.clear()
|
||||
window_b64 = _b64.b64encode(window_bytes).decode()
|
||||
|
||||
url = voice_url.rstrip("/") + "/classify"
|
||||
payload = {
|
||||
"audio_chunk": window_b64,
|
||||
"timestamp": timestamp,
|
||||
"elcor": session.elcor,
|
||||
"session_id": session.session_id,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.post(url, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
logger.warning("cf-voice sidecar call failed for session %s: %s", session.session_id, exc)
|
||||
return
|
||||
|
||||
from app.models.queue_event import QueueEvent
|
||||
from app.models.transcript_event import TranscriptEvent
|
||||
|
||||
for ev in data.get("events", []):
|
||||
etype = ev.get("event_type")
|
||||
|
||||
if etype == "tone":
|
||||
frame = VoiceFrame(
|
||||
label=ev["label"],
|
||||
confidence=ev["confidence"],
|
||||
speaker_id=ev.get("speaker_id", ""),
|
||||
shift_magnitude=ev.get("shift_magnitude", 0.0),
|
||||
timestamp=ev["timestamp"],
|
||||
)
|
||||
tone = annotate(frame, session_id=session.session_id, elcor=session.elcor)
|
||||
if tone is not None:
|
||||
session.broadcast(tone)
|
||||
|
||||
elif etype == "speaker":
|
||||
speaker = SpeakerEvent(
|
||||
session_id=session.session_id,
|
||||
label=ev["label"],
|
||||
confidence=ev.get("confidence", 1.0),
|
||||
timestamp=ev["timestamp"],
|
||||
)
|
||||
session.broadcast(speaker)
|
||||
|
||||
elif etype == "transcript":
|
||||
transcript = TranscriptEvent(
|
||||
session_id=session.session_id,
|
||||
text=ev["label"],
|
||||
speaker_id=ev.get("speaker_id", "speaker_a"),
|
||||
timestamp=ev["timestamp"],
|
||||
)
|
||||
session.broadcast(transcript)
|
||||
|
||||
elif etype in ("queue", "environ"):
|
||||
queue_ev = QueueEvent(
|
||||
session_id=session.session_id,
|
||||
event_type=etype,
|
||||
label=ev["label"],
|
||||
confidence=ev.get("confidence", 1.0),
|
||||
timestamp=ev["timestamp"],
|
||||
)
|
||||
session.broadcast(queue_ev)
|
||||
|
||||
|
||||
# ── Idle session reaper ───────────────────────────────────────────────────────
|
||||
|
||||
async def _reaper_loop() -> None:
|
||||
"""
|
||||
Periodically kill sessions with no active SSE subscribers.
|
||||
|
||||
A session becomes eligible for reaping when:
|
||||
- Its last SSE subscriber disconnected more than SESSION_IDLE_TTL_S seconds ago
|
||||
- It has not been explicitly ended (state != "stopped")
|
||||
|
||||
This covers the common mobile pattern: screen locks → browser suspends tab →
|
||||
EventSource closes → SSE subscriber count drops to zero. If the user doesn't
|
||||
return within the TTL window, the session is cleaned up automatically.
|
||||
|
||||
The reaper runs every REAP_INTERVAL_S seconds (half the TTL, so the worst-case
|
||||
overshoot is TTL + REAP_INTERVAL_S).
|
||||
"""
|
||||
ttl = settings.session_idle_ttl_s
|
||||
interval = max(15, ttl // 2)
|
||||
logger.info("Session reaper started (TTL=%ds, check every %ds)", ttl, interval)
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
now = time.monotonic()
|
||||
to_reap = [
|
||||
sid
|
||||
for sid, session in list(_sessions.items())
|
||||
if (
|
||||
session.state == "running"
|
||||
and session.subscriber_count() == 0
|
||||
and session.last_subscriber_left_at is not None
|
||||
and (now - session.last_subscriber_left_at) > ttl
|
||||
)
|
||||
]
|
||||
for sid in to_reap:
|
||||
logger.info(
|
||||
"Reaping idle session %s (no subscribers for >%ds)", sid, ttl
|
||||
)
|
||||
await end_session(sid)
|
||||
|
||||
|
||||
async def _reaper_loop_once() -> None:
|
||||
"""Single reaper pass — used by tests to avoid sleeping."""
|
||||
ttl = settings.session_idle_ttl_s
|
||||
now = time.monotonic()
|
||||
to_reap = [
|
||||
sid
|
||||
for sid, session in list(_sessions.items())
|
||||
if (
|
||||
session.state == "running"
|
||||
and session.subscriber_count() == 0
|
||||
and session.last_subscriber_left_at is not None
|
||||
and (now - session.last_subscriber_left_at) > ttl
|
||||
)
|
||||
]
|
||||
for sid in to_reap:
|
||||
logger.info("Reaping idle session %s (no subscribers for >%ds)", sid, ttl)
|
||||
await end_session(sid)
|
||||
|
||||
|
||||
def start_reaper() -> None:
|
||||
"""Start the idle session reaper background task. Called from app lifespan."""
|
||||
global _reaper_task
|
||||
if _reaper_task is None or _reaper_task.done():
|
||||
_reaper_task = asyncio.create_task(_reaper_loop(), name="session-reaper")
|
||||
|
||||
|
||||
async def stop_reaper() -> None:
|
||||
"""Cancel the reaper task cleanly. Called from app lifespan shutdown."""
|
||||
global _reaper_task
|
||||
if _reaper_task and not _reaper_task.done():
|
||||
_reaper_task.cancel()
|
||||
try:
|
||||
await _reaper_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_reaper_task = None
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
GIT_TOKEN: ${GIT_TOKEN:-}
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
|
|
@ -31,6 +33,8 @@ services:
|
|||
volumes:
|
||||
- /devl/linnet-cloud-data:/devl/linnet-cloud-data
|
||||
- ${HOME}/.config/circuitforge:/root/.config/circuitforge:ro
|
||||
# Live-reload app code without rebuilding during active dev
|
||||
- ./app:/app/app:ro
|
||||
networks:
|
||||
- linnet-cloud-net
|
||||
|
||||
|
|
|
|||
11
compose.yml
11
compose.yml
|
|
@ -1,12 +1,21 @@
|
|||
services:
|
||||
linnet-api:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
GIT_TOKEN: ${GIT_TOKEN:-}
|
||||
ports:
|
||||
- "${LINNET_PORT:-8522}:8522"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
CF_VOICE_MOCK: "${CF_VOICE_MOCK:-1}"
|
||||
volumes:
|
||||
# Live-reload app code without rebuilding the image in dev
|
||||
- ./app:/app/app:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
linnet-frontend:
|
||||
|
|
|
|||
BIN
data/linnet.db
Normal file
BIN
data/linnet.db
Normal file
Binary file not shown.
3148
frontend/package-lock.json
generated
Normal file
3148
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -19,15 +19,27 @@
|
|||
<span class="session-id">{{ store.sessionId?.slice(0, 8) }}…</span>
|
||||
</div>
|
||||
|
||||
<label class="elcor-toggle" title="Elcor subtext mode">
|
||||
<label class="elcor-toggle" title="Tone prefix mode">
|
||||
<input type="checkbox" v-model="elcorLocal" disabled />
|
||||
Elcor
|
||||
Prefix
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="btn-mic"
|
||||
:class="{ active: capturing }"
|
||||
@click="handleMicToggle"
|
||||
:aria-label="capturing ? 'Stop microphone' : 'Start microphone'"
|
||||
>
|
||||
{{ capturing ? "Mic on" : "Mic off" }}
|
||||
</button>
|
||||
|
||||
<button class="btn-stop" @click="handleStop">End session</button>
|
||||
</template>
|
||||
|
||||
<p v-if="error" class="compose-error">{{ error }}</p>
|
||||
<p v-if="expired" class="compose-notice">
|
||||
Session timed out after inactivity. Start a new one to continue.
|
||||
</p>
|
||||
<p v-else-if="error" class="compose-error">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -35,9 +47,11 @@
|
|||
import { ref } from "vue";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import { useToneStream } from "../composables/useToneStream";
|
||||
import { useAudioCapture } from "../composables/useAudioCapture";
|
||||
|
||||
const store = useSessionStore();
|
||||
const { connect, disconnect } = useToneStream();
|
||||
const { connect, disconnect, expired } = useToneStream();
|
||||
const { start: startMic, stop: stopMic, capturing } = useAudioCapture();
|
||||
|
||||
const starting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
|
@ -57,9 +71,23 @@ async function handleStart() {
|
|||
}
|
||||
|
||||
async function handleStop() {
|
||||
if (capturing.value) stopMic();
|
||||
disconnect();
|
||||
await store.endSession();
|
||||
}
|
||||
|
||||
async function handleMicToggle() {
|
||||
error.value = null;
|
||||
try {
|
||||
if (capturing.value) {
|
||||
stopMic();
|
||||
} else {
|
||||
await startMic(store.sessionId!);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : "Mic access failed";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
|
@ -121,10 +149,34 @@ export default { name: "ComposeBar" };
|
|||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.btn-mic {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border, #2a2d3a);
|
||||
background: transparent;
|
||||
color: var(--color-muted, #6b7280);
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-mic.active {
|
||||
background: #dc262622;
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.compose-error {
|
||||
width: 100%;
|
||||
font-size: 0.75rem;
|
||||
color: #ef4444;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.compose-notice {
|
||||
width: 100%;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
316
frontend/src/components/CorrectionWidget.vue
Normal file
316
frontend/src/components/CorrectionWidget.vue
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
<template>
|
||||
<div class="correction-widget" :class="{ 'correction-widget--expanded': expanded }">
|
||||
<!-- Idle: thumbs row -->
|
||||
<div v-if="state === 'idle'" class="rating-row">
|
||||
<span class="rating-label">Was this annotation accurate?</span>
|
||||
<button
|
||||
type="button"
|
||||
class="thumb-btn thumb-btn--up"
|
||||
aria-label="Accurate"
|
||||
@click="rate('up')"
|
||||
>👍</button>
|
||||
<button
|
||||
type="button"
|
||||
class="thumb-btn thumb-btn--down"
|
||||
aria-label="Inaccurate — submit correction"
|
||||
@click="rate('down')"
|
||||
>👎</button>
|
||||
</div>
|
||||
|
||||
<!-- Thumbs-down: correction form -->
|
||||
<form v-else-if="state === 'correcting'" class="correction-form" @submit.prevent="submit">
|
||||
<label class="field-label" for="corrected-output">
|
||||
What should the annotation say?
|
||||
</label>
|
||||
<textarea
|
||||
id="corrected-output"
|
||||
v-model="correctedOutput"
|
||||
class="correction-textarea"
|
||||
rows="3"
|
||||
placeholder="e.g. [RELUCTANTLY] [MASKING_STRESS] I'll have it done by end of day."
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
<label class="optin-label">
|
||||
<input v-model="optedIn" type="checkbox" class="optin-check" />
|
||||
Share this correction anonymously to improve the model
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-cancel" @click="cancel">Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-submit"
|
||||
:disabled="submitting || !correctedOutput.trim()"
|
||||
>
|
||||
{{ submitting ? 'Saving…' : 'Submit correction' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="correction-error" role="alert">{{ error }}</p>
|
||||
</form>
|
||||
|
||||
<!-- Thumbs-up: optional praise form -->
|
||||
<form v-else-if="state === 'praising'" class="correction-form" @submit.prevent="submitPraise">
|
||||
<label class="field-label" for="praise-input">
|
||||
What worked well? <span class="field-optional">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="praise-input"
|
||||
v-model="praiseText"
|
||||
class="correction-textarea"
|
||||
rows="2"
|
||||
placeholder="e.g. Caught the sarcasm without over-labeling it."
|
||||
autofocus
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-cancel" @click="submitPraise">Skip</button>
|
||||
<button type="submit" class="btn-submit" :disabled="submitting">
|
||||
{{ submitting ? 'Saving…' : 'Submit' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="correction-error" role="alert">{{ error }}</p>
|
||||
</form>
|
||||
|
||||
<!-- Thumbs-up submitted -->
|
||||
<div v-else-if="state === 'done-up'" class="done-msg done-msg--up">
|
||||
✓ Thanks — glad that was helpful.
|
||||
</div>
|
||||
|
||||
<!-- Correction submitted -->
|
||||
<div v-else-if="state === 'done-down'" class="done-msg done-msg--down">
|
||||
✓ Correction saved{{ optedIn ? ' and flagged for training' : '' }}.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
itemId?: string
|
||||
inputText: string
|
||||
originalOutput: string
|
||||
correctionType?: string
|
||||
apiPath?: string
|
||||
context?: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
type State = 'idle' | 'praising' | 'correcting' | 'done-up' | 'done-down'
|
||||
|
||||
const state = ref<State>('idle')
|
||||
const correctedOutput = ref('')
|
||||
const praiseText = ref('')
|
||||
const optedIn = ref(false)
|
||||
const submitting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const expanded = computed(() => state.value === 'correcting' || state.value === 'praising')
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE ?? "";
|
||||
const endpoint = computed(() => `${apiBase}${props.apiPath ?? '/corrections'}`)
|
||||
|
||||
function rate(rating: 'up' | 'down') {
|
||||
if (rating === 'up') {
|
||||
state.value = 'praising'
|
||||
} else {
|
||||
state.value = 'correcting'
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
state.value = 'idle'
|
||||
correctedOutput.value = ''
|
||||
praiseText.value = ''
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
await submitRating('down', correctedOutput.value.trim())
|
||||
}
|
||||
|
||||
async function submitPraise() {
|
||||
const praise = praiseText.value.trim()
|
||||
const context = { ...props.context ?? {}, ...(praise ? { praise } : {}) }
|
||||
await submitRating('up', '', context)
|
||||
}
|
||||
|
||||
async function submitRating(
|
||||
rating: 'up' | 'down',
|
||||
correction: string,
|
||||
contextOverride?: Record<string, unknown>,
|
||||
) {
|
||||
submitting.value = true
|
||||
error.value = null
|
||||
// Thumbs-up rows contain no user text — auto-consent so they export for training.
|
||||
const effectiveOptIn = rating === 'up' ? true : optedIn.value
|
||||
const context = contextOverride ?? props.context ?? {}
|
||||
try {
|
||||
const res = await fetch(endpoint.value, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
item_id: props.itemId ?? '',
|
||||
product: 'linnet',
|
||||
correction_type: props.correctionType ?? 'annotation',
|
||||
input_text: props.inputText,
|
||||
original_output: props.originalOutput,
|
||||
corrected_output: correction,
|
||||
rating,
|
||||
context,
|
||||
opted_in: effectiveOptIn,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body?.detail ?? `HTTP ${res.status}`)
|
||||
}
|
||||
state.value = rating === 'up' ? 'done-up' : 'done-down'
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Something went wrong — please try again.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.correction-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
border-top: 1px solid var(--color-border, #2a2d3a);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted, #6b7280);
|
||||
transition: padding 0.15s ease;
|
||||
}
|
||||
|
||||
.correction-widget--expanded {
|
||||
padding: var(--space-3, 0.75rem);
|
||||
}
|
||||
|
||||
/* Rating row */
|
||||
.rating-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
}
|
||||
|
||||
.rating-label {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted, #6b7280);
|
||||
}
|
||||
|
||||
.thumb-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border, #2a2d3a);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: background 0.1s ease, border-color 0.1s ease;
|
||||
}
|
||||
|
||||
.thumb-btn:hover {
|
||||
background: var(--color-surface, #1a1d27);
|
||||
border-color: var(--color-accent, #7c6af7);
|
||||
}
|
||||
|
||||
/* Correction form */
|
||||
.correction-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #e2e8f0);
|
||||
}
|
||||
|
||||
.field-optional {
|
||||
font-weight: 400;
|
||||
color: var(--color-muted, #6b7280);
|
||||
}
|
||||
|
||||
.correction-textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
background: var(--color-bg, #0f1117);
|
||||
border: 1px solid var(--color-border, #2a2d3a);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
color: var(--color-text, #e2e8f0);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.78rem;
|
||||
padding: var(--space-2, 0.5rem);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.correction-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent, #7c6af7);
|
||||
}
|
||||
|
||||
.optin-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-muted, #6b7280);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.optin-check {
|
||||
accent-color: var(--color-accent, #7c6af7);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border, #2a2d3a);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
color: var(--color-muted, #6b7280);
|
||||
font-size: 0.78rem;
|
||||
padding: var(--space-1, 0.25rem) var(--space-3, 0.75rem);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: var(--color-accent, #7c6af7);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: var(--space-1, 0.25rem) var(--space-3, 0.75rem);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.correction-error {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-error, #dc2626);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Done states */
|
||||
.done-msg {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.done-msg--up { color: var(--color-success, #16a34a); }
|
||||
.done-msg--down { color: var(--color-accent, #7c6af7); }
|
||||
</style>
|
||||
|
|
@ -1,5 +1,24 @@
|
|||
<template>
|
||||
<div class="now-panel" :data-affect="affect">
|
||||
<!-- Row 1: speaker type + queue state badges -->
|
||||
<div class="now-badges">
|
||||
<div v-if="speakerLabel" class="now-speaker" :data-speaker="speakerKind">
|
||||
{{ speakerLabel }}
|
||||
</div>
|
||||
<div v-if="queueLabel" class="now-queue-badge" :data-queue="queueKind">
|
||||
{{ queueLabel }}
|
||||
</div>
|
||||
<div v-if="environLabel" class="now-environ-badge" :data-environ="environKind">
|
||||
{{ environLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live transcript strip -->
|
||||
<div v-if="transcriptText" class="now-transcript">
|
||||
"{{ transcriptText }}"
|
||||
</div>
|
||||
|
||||
<!-- Tone annotation -->
|
||||
<div class="now-label">{{ label }}</div>
|
||||
<div v-if="subtext" class="now-subtext">{{ subtext }}</div>
|
||||
<div class="now-meta">
|
||||
|
|
@ -14,16 +33,29 @@
|
|||
<span v-else>~</span>
|
||||
shift {{ (shiftMagnitude * 100).toFixed(0) }}%
|
||||
</div>
|
||||
<CorrectionWidget
|
||||
v-if="props.event"
|
||||
:item-id="props.event.session_id"
|
||||
:input-text="''"
|
||||
:original-output="annotationOutput"
|
||||
correction-type="annotation"
|
||||
api-path="/corrections"
|
||||
:context="{ session_id: props.event.session_id, label: props.event.label }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ToneEvent } from "../stores/session";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import CorrectionWidget from "./CorrectionWidget.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
event: ToneEvent | null;
|
||||
}>();
|
||||
|
||||
const store = useSessionStore();
|
||||
|
||||
const label = computed(() => props.event?.label ?? "—");
|
||||
const confidence = computed(() => props.event?.confidence ?? 0);
|
||||
const subtext = computed(() => props.event?.subtext ?? null);
|
||||
|
|
@ -31,6 +63,62 @@ const affect = computed(() => props.event?.affect ?? "neutral");
|
|||
const prosodyFlags = computed(() => props.event?.prosody_flags ?? []);
|
||||
const shiftMagnitude = computed(() => props.event?.shift_magnitude ?? 0);
|
||||
const shiftDirection = computed(() => props.event?.shift_direction ?? "stable");
|
||||
const annotationOutput = computed(() => {
|
||||
if (!props.event) return "";
|
||||
const parts = [`[${props.event.label}]`];
|
||||
if (props.event.subtext) parts.push(props.event.subtext);
|
||||
return parts.join(" ");
|
||||
});
|
||||
|
||||
// Speaker badge — hidden when no_speaker or no session
|
||||
const SPEAKER_LABELS: Record<string, string> = {
|
||||
human_single: "Human",
|
||||
human_multi: "Group",
|
||||
ivr_synth: "IVR",
|
||||
no_speaker: "",
|
||||
transfer: "Transfer",
|
||||
};
|
||||
const speakerLabel = computed(() => {
|
||||
const raw = store.currentSpeaker?.label;
|
||||
if (!raw) return null;
|
||||
const mapped = SPEAKER_LABELS[raw] ?? raw;
|
||||
return mapped || null;
|
||||
});
|
||||
const speakerKind = computed(() => store.currentSpeaker?.label ?? "");
|
||||
|
||||
// Queue state badge (hold_music, ringback, silence, etc.)
|
||||
const QUEUE_LABELS: Record<string, string> = {
|
||||
hold_music: "On Hold",
|
||||
ringback: "Ringing",
|
||||
busy: "Busy",
|
||||
dtmf_tone: "DTMF",
|
||||
silence: "Silence",
|
||||
dead_air: "Dead Air",
|
||||
};
|
||||
const queueLabel = computed(() => {
|
||||
const raw = store.currentQueue?.label;
|
||||
if (!raw || raw === "silence") return null;
|
||||
return QUEUE_LABELS[raw] ?? raw;
|
||||
});
|
||||
const queueKind = computed(() => store.currentQueue?.label ?? "");
|
||||
|
||||
// Environment badge (call_center, music, noise, etc.)
|
||||
const ENVIRON_LABELS: Record<string, string> = {
|
||||
call_center: "Call Centre",
|
||||
music: "Music",
|
||||
background_shift: "Shift",
|
||||
noise_floor_change: "Noise",
|
||||
quiet: "",
|
||||
};
|
||||
const environLabel = computed(() => {
|
||||
const raw = store.currentEnviron?.label;
|
||||
if (!raw || raw === "quiet") return null;
|
||||
return ENVIRON_LABELS[raw] ?? raw;
|
||||
});
|
||||
const environKind = computed(() => store.currentEnviron?.label ?? "");
|
||||
|
||||
// Live transcript strip
|
||||
const transcriptText = computed(() => store.currentTranscript?.text ?? null);
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
|
@ -60,6 +148,64 @@ export default { name: "NowPanel" };
|
|||
.now-panel[data-affect="dismissive"] { border-color: #a78bfa44; }
|
||||
.now-panel[data-affect="urgent"] { border-color: #fbbf2444; }
|
||||
|
||||
.now-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
min-height: 1.4rem;
|
||||
}
|
||||
|
||||
/* Shared pill base for speaker / queue / environ badges */
|
||||
.now-speaker,
|
||||
.now-queue-badge,
|
||||
.now-environ-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.15em 0.6em;
|
||||
border-radius: 999px;
|
||||
background: var(--color-border, #2a2d3a);
|
||||
color: var(--color-muted, #6b7280);
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
/* Speaker type colours */
|
||||
.now-speaker[data-speaker="human_single"] { background: #1e3a2f; color: #34d399; border-color: #34d39933; }
|
||||
.now-speaker[data-speaker="human_multi"] { background: #1e3a2f; color: #34d399; border-color: #34d39933; }
|
||||
.now-speaker[data-speaker="ivr_synth"] { background: #1e2a3a; color: #60a5fa; border-color: #60a5fa33; }
|
||||
.now-speaker[data-speaker="transfer"] { background: #2a2a1e; color: #fbbf24; border-color: #fbbf2433; }
|
||||
|
||||
/* Queue state colours */
|
||||
.now-queue-badge[data-queue="hold_music"] { background: #2a1e3a; color: #a78bfa; border-color: #a78bfa33; }
|
||||
.now-queue-badge[data-queue="ringback"] { background: #2a2a1e; color: #fbbf24; border-color: #fbbf2433; }
|
||||
.now-queue-badge[data-queue="busy"] { background: #3a1e1e; color: #f87171; border-color: #f8717133; }
|
||||
.now-queue-badge[data-queue="dtmf_tone"] { background: #1e2a3a; color: #60a5fa; border-color: #60a5fa33; }
|
||||
.now-queue-badge[data-queue="dead_air"] { background: #1e1e1e; color: #4b5563; border-color: #4b556333; }
|
||||
|
||||
/* Environment colours */
|
||||
.now-environ-badge[data-environ="call_center"] { background: #1e2a3a; color: #60a5fa; border-color: #60a5fa22; }
|
||||
.now-environ-badge[data-environ="music"] { background: #2a1e3a; color: #a78bfa; border-color: #a78bfa22; }
|
||||
.now-environ-badge[data-environ="background_shift"] { background: #2a2a1e; color: #fbbf24; border-color: #fbbf2422; }
|
||||
.now-environ-badge[data-environ="noise_floor_change"] { background: #2a1e1e; color: #f87171; border-color: #f8717122; }
|
||||
|
||||
/* Live transcript strip */
|
||||
.now-transcript {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted, #6b7280);
|
||||
font-style: italic;
|
||||
border-left: 2px solid var(--color-border, #2a2d3a);
|
||||
padding-left: 0.6rem;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.now-label {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
|
|
|
|||
|
|
@ -21,12 +21,13 @@ export function useAudioCapture() {
|
|||
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||
ctx = new AudioContext({ sampleRate: 16000 });
|
||||
|
||||
await ctx.audioWorklet.addModule("/audio-processor.js");
|
||||
await ctx.audioWorklet.addModule(`${import.meta.env.BASE_URL}audio-processor.js`);
|
||||
|
||||
source = ctx.createMediaStreamSource(stream);
|
||||
const processor = new AudioWorkletNode(ctx, "pcm-processor");
|
||||
|
||||
const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/session/${sessionId}/audio`;
|
||||
const apiBase = import.meta.env.VITE_API_BASE ?? "";
|
||||
const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${apiBase}/session/${sessionId}/audio`;
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
|
|
|||
|
|
@ -1,43 +1,117 @@
|
|||
/**
|
||||
* useToneStream — manages the SSE connection for a session's tone-event stream.
|
||||
* useToneStream — SSE connection + wake lock + visibility reconnect.
|
||||
*
|
||||
* Usage:
|
||||
* const { connect, disconnect, connected } = useToneStream();
|
||||
* connect(sessionId); // opens EventSource
|
||||
* disconnect(); // closes it
|
||||
* On page hide (screen lock, tab switch): closes EventSource without ending
|
||||
* the session. The backend reaper gives the session a 90s grace window.
|
||||
*
|
||||
* On page show: re-acquires the wake lock, checks whether the session is
|
||||
* still alive on the server, then either reconnects SSE or surfaces an
|
||||
* "expired" flag so the UI can prompt the user to start a new session.
|
||||
*/
|
||||
import { ref, onUnmounted } from "vue";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import {
|
||||
useSessionStore,
|
||||
type SpeakerEvent,
|
||||
type TranscriptEvent,
|
||||
type QueueEvent,
|
||||
} from "../stores/session";
|
||||
import { useWakeLock } from "./useWakeLock";
|
||||
|
||||
export function useToneStream() {
|
||||
const connected = ref(false);
|
||||
const expired = ref(false); // true when the session was reaped while hidden
|
||||
let source: EventSource | null = null;
|
||||
let activeSessionId: string | null = null;
|
||||
const store = useSessionStore();
|
||||
const wakeLock = useWakeLock();
|
||||
|
||||
function connect(sessionId: string) {
|
||||
if (source) disconnect();
|
||||
source = new EventSource(`/session/${sessionId}/stream`);
|
||||
// ── SSE wiring ──────────────────────────────────────────────────────────────
|
||||
|
||||
function _attachListeners() {
|
||||
if (!source) return;
|
||||
|
||||
source.addEventListener("tone-event", (e: MessageEvent) => {
|
||||
try {
|
||||
const evt = JSON.parse(e.data);
|
||||
store.pushEvent(evt);
|
||||
} catch {
|
||||
// malformed frame — ignore
|
||||
}
|
||||
try { store.pushEvent(JSON.parse(e.data)); } catch { /* malformed */ }
|
||||
});
|
||||
source.addEventListener("speaker-event", (e: MessageEvent) => {
|
||||
try { store.updateSpeaker(JSON.parse(e.data) as SpeakerEvent); } catch { /* */ }
|
||||
});
|
||||
source.addEventListener("transcript-event", (e: MessageEvent) => {
|
||||
try { store.updateTranscript(JSON.parse(e.data) as TranscriptEvent); } catch { /* */ }
|
||||
});
|
||||
source.addEventListener("queue-event", (e: MessageEvent) => {
|
||||
try { store.updateQueue(JSON.parse(e.data) as QueueEvent); } catch { /* */ }
|
||||
});
|
||||
source.addEventListener("environ-event", (e: MessageEvent) => {
|
||||
try { store.updateQueue(JSON.parse(e.data) as QueueEvent); } catch { /* */ }
|
||||
});
|
||||
|
||||
source.onopen = () => { connected.value = true; };
|
||||
source.onerror = () => { connected.value = false; };
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
function connect(sessionId: string) {
|
||||
if (source) _closeSource();
|
||||
activeSessionId = sessionId;
|
||||
expired.value = false;
|
||||
const apiBase = import.meta.env.VITE_API_BASE ?? "";
|
||||
source = new EventSource(`${apiBase}/session/${sessionId}/stream`);
|
||||
_attachListeners();
|
||||
wakeLock.acquire();
|
||||
}
|
||||
|
||||
function _closeSource() {
|
||||
source?.close();
|
||||
source = null;
|
||||
connected.value = false;
|
||||
}
|
||||
|
||||
onUnmounted(disconnect);
|
||||
function disconnect() {
|
||||
_closeSource();
|
||||
activeSessionId = null;
|
||||
wakeLock.release();
|
||||
}
|
||||
|
||||
return { connect, disconnect, connected };
|
||||
// ── Visibility handling ─────────────────────────────────────────────────────
|
||||
|
||||
async function _handleVisibilityChange() {
|
||||
if (document.visibilityState === "hidden") {
|
||||
// Close the SSE connection without ending the backend session.
|
||||
// The reaper gives us SESSION_IDLE_TTL_S (default 90s) before cleanup.
|
||||
_closeSource();
|
||||
return;
|
||||
}
|
||||
|
||||
// Page became visible again — check if session survived the absence.
|
||||
if (!activeSessionId) return;
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE ?? "";
|
||||
try {
|
||||
const resp = await fetch(`${apiBase}/session/${activeSessionId}`);
|
||||
if (resp.ok) {
|
||||
// Session still alive — reconnect SSE and wake lock
|
||||
const apiBase2 = import.meta.env.VITE_API_BASE ?? "";
|
||||
source = new EventSource(`${apiBase2}/session/${activeSessionId}/stream`);
|
||||
_attachListeners();
|
||||
wakeLock.acquire();
|
||||
} else {
|
||||
// Session was reaped (404) or errored — surface to UI
|
||||
expired.value = true;
|
||||
store.reset();
|
||||
activeSessionId = null;
|
||||
}
|
||||
} catch {
|
||||
// Network error on return — don't immediately expire, user may be offline
|
||||
// briefly. They can retry manually.
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", _handleVisibilityChange);
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect();
|
||||
document.removeEventListener("visibilitychange", _handleVisibilityChange);
|
||||
});
|
||||
|
||||
return { connect, disconnect, connected, expired };
|
||||
}
|
||||
|
|
|
|||
57
frontend/src/composables/useWakeLock.ts
Normal file
57
frontend/src/composables/useWakeLock.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* useWakeLock — Screen Wake Lock API wrapper.
|
||||
*
|
||||
* Prevents the screen from dimming/sleeping while an annotation session is
|
||||
* active. The browser *always* releases the lock on page hide (tab switch,
|
||||
* screen lock), so we re-acquire on visibilitychange: visible.
|
||||
*
|
||||
* Degrades silently when the API is unavailable (Firefox < 126, iOS Safari,
|
||||
* low-power mode). The session still works; screen may time out.
|
||||
*/
|
||||
import { ref, onUnmounted } from "vue";
|
||||
|
||||
export function useWakeLock() {
|
||||
const supported = "wakeLock" in navigator;
|
||||
const active = ref(false);
|
||||
|
||||
let lock: WakeLockSentinel | null = null;
|
||||
|
||||
async function acquire() {
|
||||
if (!supported) return;
|
||||
try {
|
||||
lock = await navigator.wakeLock.request("screen");
|
||||
active.value = true;
|
||||
lock.addEventListener("release", () => {
|
||||
active.value = false;
|
||||
lock = null;
|
||||
});
|
||||
} catch {
|
||||
// Denied: battery saver, low-power mode, permissions policy.
|
||||
// Silent fail — the session continues without it.
|
||||
active.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function release() {
|
||||
lock?.release();
|
||||
lock = null;
|
||||
active.value = false;
|
||||
}
|
||||
|
||||
// Re-acquire after returning to the tab — the browser releases the lock
|
||||
// automatically on page hide, so we must request it again on page show.
|
||||
async function handleVisibility() {
|
||||
if (document.visibilityState === "visible" && lock === null && active.value === false) {
|
||||
// Only re-acquire if we were previously active (i.e. a session is running).
|
||||
// The caller controls this by only calling acquire() when a session starts.
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: the caller is responsible for calling acquire() when a session starts
|
||||
// and release() when it ends. The visibility re-acquire is handled inside
|
||||
// the SSE composable which coordinates both wake lock and reconnect together.
|
||||
|
||||
onUnmounted(release);
|
||||
|
||||
return { supported, active, acquire, release, handleVisibility };
|
||||
}
|
||||
|
|
@ -15,15 +15,45 @@ export interface ToneEvent {
|
|||
prosody_flags: string[];
|
||||
}
|
||||
|
||||
export interface SpeakerEvent {
|
||||
event_type: "speaker";
|
||||
session_id: string;
|
||||
label: string;
|
||||
confidence: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface TranscriptEvent {
|
||||
event_type: "transcript";
|
||||
session_id: string;
|
||||
text: string;
|
||||
speaker_id: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface QueueEvent {
|
||||
event_type: "queue" | "environ";
|
||||
session_id: string;
|
||||
label: string;
|
||||
confidence: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export const useSessionStore = defineStore("session", () => {
|
||||
const sessionId = ref<string | null>(null);
|
||||
const elcor = ref(false);
|
||||
const state = ref<"idle" | "running" | "stopped">("idle");
|
||||
const events = ref<ToneEvent[]>([]);
|
||||
const latest = computed(() => events.value.at(-1) ?? null);
|
||||
const currentSpeaker = ref<SpeakerEvent | null>(null);
|
||||
const currentTranscript = ref<TranscriptEvent | null>(null);
|
||||
const currentQueue = ref<QueueEvent | null>(null);
|
||||
const currentEnviron = ref<QueueEvent | null>(null);
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE ?? "";
|
||||
|
||||
async function startSession(withElcor = false) {
|
||||
const resp = await fetch("/session/start", {
|
||||
const resp = await fetch(`${apiBase}/session/start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ elcor: withElcor }),
|
||||
|
|
@ -38,7 +68,7 @@ export const useSessionStore = defineStore("session", () => {
|
|||
|
||||
async function endSession() {
|
||||
if (!sessionId.value) return;
|
||||
await fetch(`/session/${sessionId.value}/end`, { method: "DELETE" });
|
||||
await fetch(`${apiBase}/session/${sessionId.value}/end`, { method: "DELETE" });
|
||||
state.value = "stopped";
|
||||
}
|
||||
|
||||
|
|
@ -48,12 +78,38 @@ export const useSessionStore = defineStore("session", () => {
|
|||
if (events.value.length > 200) events.value.shift();
|
||||
}
|
||||
|
||||
function updateSpeaker(evt: SpeakerEvent) {
|
||||
currentSpeaker.value = evt;
|
||||
}
|
||||
|
||||
function updateTranscript(evt: TranscriptEvent) {
|
||||
currentTranscript.value = evt;
|
||||
}
|
||||
|
||||
function updateQueue(evt: QueueEvent) {
|
||||
if (evt.event_type === "queue") {
|
||||
currentQueue.value = evt;
|
||||
} else {
|
||||
currentEnviron.value = evt;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
sessionId.value = null;
|
||||
state.value = "idle";
|
||||
events.value = [];
|
||||
elcor.value = false;
|
||||
currentSpeaker.value = null;
|
||||
currentTranscript.value = null;
|
||||
currentQueue.value = null;
|
||||
currentEnviron.value = null;
|
||||
}
|
||||
|
||||
return { sessionId, elcor, state, events, latest, startSession, endSession, pushEvent, reset };
|
||||
return {
|
||||
sessionId, elcor, state, events, latest,
|
||||
currentSpeaker, currentTranscript, currentQueue, currentEnviron,
|
||||
startSession, endSession, pushEvent,
|
||||
updateSpeaker, updateTranscript, updateQueue,
|
||||
reset,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import vue from "@vitejs/plugin-vue";
|
|||
import UnoCSS from "unocss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: process.env.VITE_BASE_URL ?? "/",
|
||||
plugins: [vue(), UnoCSS()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 8521,
|
||||
proxy: {
|
||||
"/session": {
|
||||
|
|
@ -16,6 +18,10 @@ export default defineConfig({
|
|||
target: "http://localhost:8522",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/corrections": {
|
||||
target: "http://localhost:8522",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Install local sibling packages from Forgejo before the linnet package itself.
|
||||
# cf-voice is public. circuitforge-core is private — token injected at build time
|
||||
# via GIT_TOKEN build arg (see Dockerfile + compose.cloud.yml).
|
||||
git+https://git.opensourcesolarpunk.com/Circuit-Forge/cf-voice.git@main
|
||||
|
||||
# Runtime deps (mirrors pyproject.toml — keep in sync)
|
||||
fastapi>=0.111
|
||||
uvicorn[standard]>=0.29
|
||||
websockets>=12.0
|
||||
python-dotenv>=1.0
|
||||
httpx>=0.27
|
||||
124
tests/test_reaper.py
Normal file
124
tests/test_reaper.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# tests/test_reaper.py — idle session reaper and subscriber idle tracking
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.session import Session
|
||||
from app.services import session_store
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_sessions():
|
||||
"""Ensure the session store is empty before and after each test."""
|
||||
session_store._sessions.clear()
|
||||
session_store._tasks.clear()
|
||||
session_store._audio_buffers.clear()
|
||||
yield
|
||||
session_store._sessions.clear()
|
||||
session_store._tasks.clear()
|
||||
session_store._audio_buffers.clear()
|
||||
|
||||
|
||||
# ── Session model: subscriber idle tracking ───────────────────────────────────
|
||||
|
||||
def test_last_subscriber_left_at_none_on_start():
|
||||
s = Session()
|
||||
assert s.last_subscriber_left_at is None
|
||||
|
||||
|
||||
def test_last_subscriber_left_at_set_on_unsubscribe():
|
||||
s = Session()
|
||||
q = s.subscribe()
|
||||
assert s.last_subscriber_left_at is None # someone is watching
|
||||
s.unsubscribe(q)
|
||||
assert s.last_subscriber_left_at is not None
|
||||
|
||||
|
||||
def test_last_subscriber_left_at_cleared_on_resubscribe():
|
||||
s = Session()
|
||||
q = s.subscribe()
|
||||
s.unsubscribe(q)
|
||||
assert s.last_subscriber_left_at is not None
|
||||
# New subscriber arrives — idle clock resets
|
||||
q2 = s.subscribe()
|
||||
assert s.last_subscriber_left_at is None
|
||||
s.unsubscribe(q2)
|
||||
|
||||
|
||||
def test_subscriber_count():
|
||||
s = Session()
|
||||
assert s.subscriber_count() == 0
|
||||
q1 = s.subscribe()
|
||||
q2 = s.subscribe()
|
||||
assert s.subscriber_count() == 2
|
||||
s.unsubscribe(q1)
|
||||
assert s.subscriber_count() == 1
|
||||
s.unsubscribe(q2)
|
||||
assert s.subscriber_count() == 0
|
||||
|
||||
|
||||
# ── Reaper logic ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaper_kills_idle_session(monkeypatch):
|
||||
"""
|
||||
A session with no subscribers for longer than the TTL should be reaped.
|
||||
We monkeypatch SESSION_IDLE_TTL_S to 0 and manually invoke _reaper_loop
|
||||
for a single cycle.
|
||||
"""
|
||||
monkeypatch.setattr(session_store.settings, "session_idle_ttl_s", 0)
|
||||
|
||||
session = await session_store.create_session()
|
||||
sid = session.session_id
|
||||
|
||||
# Simulate a subscriber connecting then leaving
|
||||
q = session.subscribe()
|
||||
session.unsubscribe(q)
|
||||
|
||||
# Force last_subscriber_left_at into the past
|
||||
session.last_subscriber_left_at = time.monotonic() - 1.0
|
||||
|
||||
assert sid in session_store._sessions
|
||||
|
||||
# Run one reaper cycle directly
|
||||
await session_store._reaper_loop_once()
|
||||
|
||||
assert sid not in session_store._sessions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaper_spares_session_with_active_subscriber(monkeypatch):
|
||||
"""A session that still has an active SSE subscriber must not be reaped."""
|
||||
monkeypatch.setattr(session_store.settings, "session_idle_ttl_s", 0)
|
||||
|
||||
session = await session_store.create_session()
|
||||
sid = session.session_id
|
||||
|
||||
# Subscriber is still connected — idle clock is None
|
||||
_q = session.subscribe()
|
||||
assert session.last_subscriber_left_at is None
|
||||
|
||||
await session_store._reaper_loop_once()
|
||||
|
||||
assert sid in session_store._sessions
|
||||
session.unsubscribe(_q)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaper_spares_session_within_ttl(monkeypatch):
|
||||
"""A session that lost its subscriber recently (within TTL) must survive."""
|
||||
monkeypatch.setattr(session_store.settings, "session_idle_ttl_s", 3600)
|
||||
|
||||
session = await session_store.create_session()
|
||||
sid = session.session_id
|
||||
|
||||
q = session.subscribe()
|
||||
session.unsubscribe(q)
|
||||
# last_subscriber_left_at is just now — well within TTL
|
||||
|
||||
await session_store._reaper_loop_once()
|
||||
|
||||
assert sid in session_store._sessions
|
||||
Loading…
Reference in a new issue