linnet/app/models/tone_event.py
pyr0ball 7e14f9135e feat: Notation v0.1.x scaffold — full backend + frontend + tests
FastAPI backend (port 8522):
- Session lifecycle: POST /session/start, DELETE /session/{id}/end, GET /session/{id}
- SSE stream: GET /session/{id}/stream — per-subscriber asyncio.Queue fan-out, 15s heartbeat
- History: GET /session/{id}/history with min_confidence + limit filters
- Audio: WS /session/{id}/audio — binary PCM ingestion stub (real inference in v0.2.x)
- Export: GET /session/{id}/export — downloadable JSON session log
- ContextClassifier background task per session (CF_VOICE_MOCK=1 in dev)
- ToneEvent SSE wire format per cf-core#40 (locked field names)
- Tier gate: CFG-LNNT- prefix check, 402 for paid features

Vue 3 frontend (port 8521, Vite + UnoCSS + Pinia):
- NowPanel: affect-aware border tint, subtext, prosody flags, shift indicator
- HistoryStrip: horizontal scroll, last 8 events with affect color
- ComposeBar: start/stop session, SSE connection lifecycle
- useToneStream: EventSource composable
- useAudioCapture: AudioWorklet → Int16 PCM → WebSocket (v0.1.x stub)
- audio-processor.js: 100ms chunk accumulator in AudioWorklet thread
- Respects prefers-reduced-motion globally

26 tests passing, manage.sh, Dockerfile, compose.yml included.
2026-04-06 18:23:52 -07:00

88 lines
2.8 KiB
Python

# app/models/tone_event.py — Linnet's internal ToneEvent model
#
# Wraps cf_voice.events.ToneEvent for SSE serialisation.
# The wire format (JSON field names) is locked per cf-core#40.
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from cf_voice.events import ToneEvent as VoiceToneEvent
from cf_voice.events import tone_event_from_voice_frame
from cf_voice.models import VoiceFrame
@dataclass
class ToneEvent:
"""
Linnet's session-scoped ToneEvent — ready for SSE serialisation.
Extends cf_voice.events.ToneEvent with session_id and serialisation helpers.
Field names are the stable SSE wire format (cf-core#40).
"""
label: str
confidence: float
speaker_id: str
shift_magnitude: float
timestamp: float
session_id: str
subtext: str | None = None
affect: str = "neutral"
shift_direction: str = "stable"
prosody_flags: list[str] = field(default_factory=list)
elcor: bool = False
@classmethod
def from_voice_frame(
cls,
frame: VoiceFrame,
session_id: str,
elcor: bool = False,
) -> "ToneEvent":
"""Convert a cf_voice VoiceFrame into a session-scoped ToneEvent."""
voice_event = tone_event_from_voice_frame(
frame_label=frame.label,
frame_confidence=frame.confidence,
shift_magnitude=frame.shift_magnitude,
timestamp=frame.timestamp,
elcor=elcor,
)
return cls(
label=frame.label,
confidence=frame.confidence,
speaker_id=frame.speaker_id,
shift_magnitude=frame.shift_magnitude,
timestamp=frame.timestamp,
session_id=session_id,
subtext=voice_event.subtext,
affect=voice_event.affect,
shift_direction=voice_event.shift_direction,
prosody_flags=voice_event.prosody_flags,
elcor=elcor,
)
def to_sse(self) -> str:
"""
Serialise to SSE wire format.
Returns the full SSE message string including event type, data,
and trailing blank line:
event: tone-event\ndata: {...}\n\n
"""
payload = {
"event_type": "tone",
"label": self.label,
"confidence": self.confidence,
"speaker_id": self.speaker_id,
"shift_magnitude": self.shift_magnitude,
"timestamp": self.timestamp,
"session_id": self.session_id,
"subtext": self.subtext,
"affect": self.affect,
"shift_direction": self.shift_direction,
"prosody_flags": self.prosody_flags,
}
return f"event: tone-event\ndata: {json.dumps(payload)}\n\n"
def is_reliable(self, threshold: float = 0.6) -> bool:
return self.confidence >= threshold