Spaces:
Sleeping
Sleeping
Commit ·
42ff9dc
1
Parent(s): 53745bd
Deploy server 2026-05-08 — latency overhaul, meeting coach AI, client-side VAD, bounded queue, runtime model switching
Browse files- Dockerfile +1 -5
- pyproject.toml +2 -7
- server/app/config.py +2 -2
- server/app/main.py +2 -13
- server/app/models/asr.py +9 -0
- server/app/models/whisper_engine.py +12 -6
- server/app/routers/websocket.py +70 -17
- server/app/schemas/messages.py +10 -2
- server/app/services/audio_processor.py +4 -74
- server/app/services/groq_client.py +102 -47
- server/app/services/session_context.py +11 -0
- server/app/services/transcription.py +51 -12
- uv.lock +4 -35
Dockerfile
CHANGED
|
@@ -19,9 +19,7 @@ WORKDIR $HOME/app
|
|
| 19 |
# ── Install Python deps ─────────────────────────────────────────────
|
| 20 |
COPY --chown=user pyproject.toml uv.lock* ./
|
| 21 |
|
| 22 |
-
|
| 23 |
-
ARG ASR_EXTRA=parakeet
|
| 24 |
-
RUN uv sync --frozen --no-dev --extra ${ASR_EXTRA}
|
| 25 |
|
| 26 |
# ── Copy application code ───────────────────────────────────────────
|
| 27 |
COPY --chown=user server/app/ ./app/
|
|
@@ -33,10 +31,8 @@ USER user
|
|
| 33 |
ENV PATH="$HOME/app/.venv/bin:$PATH"
|
| 34 |
|
| 35 |
# Redirect caches to /tmp (writable in HF Spaces)
|
| 36 |
-
ENV TORCH_HOME=/tmp/torch_cache
|
| 37 |
ENV HF_HOME=/tmp/hf_cache
|
| 38 |
ENV NUMBA_CACHE_DIR=/tmp/numba_cache
|
| 39 |
-
ENV MPLCONFIGDIR=/tmp/matplotlib
|
| 40 |
|
| 41 |
# Defaults for HF environment
|
| 42 |
ENV ASR_MODEL_TYPE=parakeet
|
|
|
|
| 19 |
# ── Install Python deps ─────────────────────────────────────────────
|
| 20 |
COPY --chown=user pyproject.toml uv.lock* ./
|
| 21 |
|
| 22 |
+
RUN uv sync --frozen --no-dev --extra parakeet
|
|
|
|
|
|
|
| 23 |
|
| 24 |
# ── Copy application code ───────────────────────────────────────────
|
| 25 |
COPY --chown=user server/app/ ./app/
|
|
|
|
| 31 |
ENV PATH="$HOME/app/.venv/bin:$PATH"
|
| 32 |
|
| 33 |
# Redirect caches to /tmp (writable in HF Spaces)
|
|
|
|
| 34 |
ENV HF_HOME=/tmp/hf_cache
|
| 35 |
ENV NUMBA_CACHE_DIR=/tmp/numba_cache
|
|
|
|
| 36 |
|
| 37 |
# Defaults for HF environment
|
| 38 |
ENV ASR_MODEL_TYPE=parakeet
|
pyproject.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
[project]
|
| 2 |
name = "meeting-ai-assistant"
|
| 3 |
-
version = "0.
|
| 4 |
requires-python = ">=3.11, <3.12"
|
| 5 |
dependencies = [
|
| 6 |
"fastapi>=0.111.0",
|
|
@@ -9,20 +9,15 @@ dependencies = [
|
|
| 9 |
"python-dotenv>=1.0.0",
|
| 10 |
"websockets>=12.0",
|
| 11 |
"numpy>=1.24.0",
|
| 12 |
-
"
|
| 13 |
-
"silero-vad>=4.0.0",
|
| 14 |
"groq>=0.9.0",
|
| 15 |
]
|
| 16 |
|
| 17 |
-
# Install with: uv sync --extra parakeet OR uv sync --extra whisper
|
| 18 |
[project.optional-dependencies]
|
| 19 |
parakeet = [
|
| 20 |
"nemo_toolkit[asr]>=1.23.0",
|
| 21 |
"soundfile>=0.12.0",
|
| 22 |
]
|
| 23 |
-
whisper = [
|
| 24 |
-
"faster-whisper>=1.0.0",
|
| 25 |
-
]
|
| 26 |
|
| 27 |
[dependency-groups]
|
| 28 |
dev = [
|
|
|
|
| 1 |
[project]
|
| 2 |
name = "meeting-ai-assistant"
|
| 3 |
+
version = "0.2.0"
|
| 4 |
requires-python = ">=3.11, <3.12"
|
| 5 |
dependencies = [
|
| 6 |
"fastapi>=0.111.0",
|
|
|
|
| 9 |
"python-dotenv>=1.0.0",
|
| 10 |
"websockets>=12.0",
|
| 11 |
"numpy>=1.24.0",
|
| 12 |
+
"faster-whisper>=1.0.0",
|
|
|
|
| 13 |
"groq>=0.9.0",
|
| 14 |
]
|
| 15 |
|
|
|
|
| 16 |
[project.optional-dependencies]
|
| 17 |
parakeet = [
|
| 18 |
"nemo_toolkit[asr]>=1.23.0",
|
| 19 |
"soundfile>=0.12.0",
|
| 20 |
]
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
[dependency-groups]
|
| 23 |
dev = [
|
server/app/config.py
CHANGED
|
@@ -16,10 +16,10 @@ class Settings(BaseSettings):
|
|
| 16 |
api_keys: str = ""
|
| 17 |
admin_key: str = ""
|
| 18 |
groq_api_key: str = ""
|
| 19 |
-
whisper_model: str = "base.en"
|
| 20 |
whisper_device: str = "cpu"
|
| 21 |
whisper_compute_type: str = "float32"
|
| 22 |
-
asr_model_type: str = "
|
| 23 |
vad_threshold: float = 0.5
|
| 24 |
chunk_duration_ms: int = 300
|
| 25 |
max_silence_ms: int = 800
|
|
|
|
| 16 |
api_keys: str = ""
|
| 17 |
admin_key: str = ""
|
| 18 |
groq_api_key: str = ""
|
| 19 |
+
whisper_model: str = "base.en" # valid: tiny.en | base.en | small.en | medium.en
|
| 20 |
whisper_device: str = "cpu"
|
| 21 |
whisper_compute_type: str = "float32"
|
| 22 |
+
asr_model_type: str = "whisper"
|
| 23 |
vad_threshold: float = 0.5
|
| 24 |
chunk_duration_ms: int = 300
|
| 25 |
max_silence_ms: int = 800
|
server/app/main.py
CHANGED
|
@@ -21,18 +21,11 @@ logger = logging.getLogger(__name__)
|
|
| 21 |
|
| 22 |
|
| 23 |
async def _load_models(app: FastAPI) -> None:
|
| 24 |
-
"""Load Silero VAD (fast) then ASR engine (slow) in a background thread."""
|
| 25 |
try:
|
| 26 |
-
from silero_vad import load_silero_vad # deferred: requires torch
|
| 27 |
-
|
| 28 |
-
loop = asyncio.get_event_loop()
|
| 29 |
-
app.state.vad_model = await loop.run_in_executor(None, load_silero_vad)
|
| 30 |
-
logger.info("Silero VAD loaded")
|
| 31 |
-
|
| 32 |
engine = get_asr_engine()
|
|
|
|
| 33 |
await loop.run_in_executor(None, engine.load_model)
|
| 34 |
app.state.asr_engine = engine
|
| 35 |
-
app.state.whisper_loaded = True
|
| 36 |
logger.info("ASR engine loaded (%s)", type(engine).__name__)
|
| 37 |
except Exception:
|
| 38 |
logger.exception("Model loading failed")
|
|
@@ -41,18 +34,14 @@ async def _load_models(app: FastAPI) -> None:
|
|
| 41 |
@asynccontextmanager
|
| 42 |
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
| 43 |
app.state.active_sessions: dict = {}
|
| 44 |
-
app.state.whisper_loaded: bool = False
|
| 45 |
app.state.asr_engine = None
|
| 46 |
-
app.state.vad_model = None
|
| 47 |
-
|
| 48 |
asyncio.create_task(_load_models(app))
|
| 49 |
-
|
| 50 |
yield
|
| 51 |
|
| 52 |
|
| 53 |
app = FastAPI(
|
| 54 |
title="Meeting AI Assistant",
|
| 55 |
-
version="0.
|
| 56 |
lifespan=lifespan,
|
| 57 |
)
|
| 58 |
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
async def _load_models(app: FastAPI) -> None:
|
|
|
|
| 24 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
engine = get_asr_engine()
|
| 26 |
+
loop = asyncio.get_event_loop()
|
| 27 |
await loop.run_in_executor(None, engine.load_model)
|
| 28 |
app.state.asr_engine = engine
|
|
|
|
| 29 |
logger.info("ASR engine loaded (%s)", type(engine).__name__)
|
| 30 |
except Exception:
|
| 31 |
logger.exception("Model loading failed")
|
|
|
|
| 34 |
@asynccontextmanager
|
| 35 |
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
| 36 |
app.state.active_sessions: dict = {}
|
|
|
|
| 37 |
app.state.asr_engine = None
|
|
|
|
|
|
|
| 38 |
asyncio.create_task(_load_models(app))
|
|
|
|
| 39 |
yield
|
| 40 |
|
| 41 |
|
| 42 |
app = FastAPI(
|
| 43 |
title="Meeting AI Assistant",
|
| 44 |
+
version="0.2.0",
|
| 45 |
lifespan=lifespan,
|
| 46 |
)
|
| 47 |
|
server/app/models/asr.py
CHANGED
|
@@ -33,3 +33,12 @@ def get_asr_engine() -> ASREngine:
|
|
| 33 |
|
| 34 |
from app.models.whisper_engine import WhisperEngine
|
| 35 |
return WhisperEngine()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
from app.models.whisper_engine import WhisperEngine
|
| 35 |
return WhisperEngine()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
WHISPER_ALLOWLIST = ("tiny.en", "base.en", "small.en", "medium.en")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def get_whisper_engine(model_name: str) -> ASREngine:
|
| 42 |
+
"""Return a WhisperEngine configured for the given model name."""
|
| 43 |
+
from app.models.whisper_engine import WhisperEngine
|
| 44 |
+
return WhisperEngine(model_name=model_name)
|
server/app/models/whisper_engine.py
CHANGED
|
@@ -7,22 +7,28 @@ from app.models.asr import ASREngine, ASRResult
|
|
| 7 |
|
| 8 |
|
| 9 |
class WhisperEngine(ASREngine):
|
| 10 |
-
def __init__(self) -> None:
|
| 11 |
self._model = None
|
|
|
|
| 12 |
|
| 13 |
def load_model(self) -> None:
|
| 14 |
-
from faster_whisper import WhisperModel
|
| 15 |
-
|
| 16 |
self._model = WhisperModel(
|
| 17 |
-
|
| 18 |
device=settings.whisper_device,
|
| 19 |
-
compute_type=
|
|
|
|
| 20 |
)
|
| 21 |
|
| 22 |
def transcribe_audio(self, audio: np.ndarray, sample_rate: int = 16000) -> ASRResult:
|
| 23 |
if self._model is None:
|
| 24 |
raise RuntimeError("WhisperEngine: model not loaded")
|
| 25 |
-
segments, _ = self._model.transcribe(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
text = " ".join(seg.text for seg in segments).strip()
|
| 27 |
return ASRResult(text=text)
|
| 28 |
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
class WhisperEngine(ASREngine):
|
| 10 |
+
def __init__(self, model_name: str | None = None) -> None:
|
| 11 |
self._model = None
|
| 12 |
+
self._model_name = model_name or settings.whisper_model
|
| 13 |
|
| 14 |
def load_model(self) -> None:
|
| 15 |
+
from faster_whisper import WhisperModel
|
|
|
|
| 16 |
self._model = WhisperModel(
|
| 17 |
+
self._model_name,
|
| 18 |
device=settings.whisper_device,
|
| 19 |
+
compute_type="int8",
|
| 20 |
+
cpu_threads=2,
|
| 21 |
)
|
| 22 |
|
| 23 |
def transcribe_audio(self, audio: np.ndarray, sample_rate: int = 16000) -> ASRResult:
|
| 24 |
if self._model is None:
|
| 25 |
raise RuntimeError("WhisperEngine: model not loaded")
|
| 26 |
+
segments, _ = self._model.transcribe(
|
| 27 |
+
audio,
|
| 28 |
+
beam_size=1,
|
| 29 |
+
language="en",
|
| 30 |
+
condition_on_previous_text=False,
|
| 31 |
+
)
|
| 32 |
text = " ".join(seg.text for seg in segments).strip()
|
| 33 |
return ASRResult(text=text)
|
| 34 |
|
server/app/routers/websocket.py
CHANGED
|
@@ -10,31 +10,36 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
| 10 |
|
| 11 |
from app.auth import verify_api_key
|
| 12 |
from app.config import settings
|
|
|
|
| 13 |
from app.schemas.messages import (
|
| 14 |
AuthErrorMsg,
|
| 15 |
AuthSuccessMsg,
|
| 16 |
ControlFrame,
|
|
|
|
|
|
|
| 17 |
PongMsg,
|
| 18 |
ReadyMsg,
|
| 19 |
)
|
| 20 |
from app.services.audio_processor import AudioProcessor
|
| 21 |
from app.services.groq_client import GroqClient
|
|
|
|
| 22 |
from app.services.transcription import TranscriptionService
|
| 23 |
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
router = APIRouter()
|
|
|
|
| 26 |
|
| 27 |
-
SESSION_TIMEOUT_SECONDS = 7200
|
| 28 |
|
| 29 |
|
| 30 |
@router.websocket("/ws")
|
| 31 |
async def websocket_endpoint(websocket: WebSocket) -> None:
|
| 32 |
await websocket.accept()
|
| 33 |
session_id: str | None = None
|
| 34 |
-
|
| 35 |
|
| 36 |
try:
|
| 37 |
-
# ── Auth
|
| 38 |
raw = await websocket.receive_text()
|
| 39 |
try:
|
| 40 |
data = json.loads(raw)
|
|
@@ -68,23 +73,29 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
| 68 |
logger.info("WS session %s opened", session_id)
|
| 69 |
|
| 70 |
# ── Per-session pipeline ──────────────────────────────────────────────
|
| 71 |
-
vad_model = getattr(websocket.app.state, "vad_model", None)
|
| 72 |
asr_engine = getattr(websocket.app.state, "asr_engine", None)
|
| 73 |
-
|
| 74 |
-
processor = AudioProcessor(
|
| 75 |
-
groq_client =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
ts_service = (
|
| 77 |
TranscriptionService(websocket=websocket, engine=asr_engine, groq_client=groq_client)
|
| 78 |
-
if asr_engine
|
|
|
|
| 79 |
)
|
| 80 |
-
if
|
| 81 |
-
|
| 82 |
if groq_client is None:
|
| 83 |
-
logger.warning("Session %s: GROQ_API_KEY not set
|
| 84 |
|
| 85 |
# ── Message loop ──────────────────────────────────────────────────────
|
| 86 |
while True:
|
| 87 |
-
elapsed = (
|
|
|
|
|
|
|
| 88 |
if elapsed > SESSION_TIMEOUT_SECONDS:
|
| 89 |
logger.info("Session %s exceeded 2h cap, closing", session_id)
|
| 90 |
break
|
|
@@ -98,15 +109,16 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
| 98 |
if "bytes" in message and message["bytes"] is not None:
|
| 99 |
if processor is not None and ts_service is not None:
|
| 100 |
segment = processor.process_chunk(message["bytes"])
|
| 101 |
-
|
| 102 |
-
asyncio.create_task(ts_service.transcribe_and_send(segment))
|
| 103 |
continue
|
| 104 |
|
| 105 |
if "text" in message and message["text"] is not None:
|
| 106 |
try:
|
| 107 |
ctrl = ControlFrame.model_validate_json(message["text"])
|
| 108 |
except Exception:
|
| 109 |
-
logger.warning(
|
|
|
|
|
|
|
| 110 |
continue
|
| 111 |
|
| 112 |
if ctrl.type == "ping":
|
|
@@ -114,12 +126,53 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
| 114 |
elif ctrl.type == "stop_session":
|
| 115 |
logger.info("Session %s: stop_session received", session_id)
|
| 116 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
except WebSocketDisconnect:
|
| 119 |
logger.info("Session %s disconnected", session_id)
|
| 120 |
finally:
|
| 121 |
-
if
|
| 122 |
-
|
| 123 |
active_sessions = getattr(websocket.app.state, "active_sessions", {})
|
| 124 |
if session_id and session_id in active_sessions:
|
| 125 |
del active_sessions[session_id]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
from app.auth import verify_api_key
|
| 12 |
from app.config import settings
|
| 13 |
+
from app.models.asr import WHISPER_ALLOWLIST, get_whisper_engine
|
| 14 |
from app.schemas.messages import (
|
| 15 |
AuthErrorMsg,
|
| 16 |
AuthSuccessMsg,
|
| 17 |
ControlFrame,
|
| 18 |
+
ErrorMsg,
|
| 19 |
+
ModelChangedMsg,
|
| 20 |
PongMsg,
|
| 21 |
ReadyMsg,
|
| 22 |
)
|
| 23 |
from app.services.audio_processor import AudioProcessor
|
| 24 |
from app.services.groq_client import GroqClient
|
| 25 |
+
from app.services.session_context import SessionContext
|
| 26 |
from app.services.transcription import TranscriptionService
|
| 27 |
|
| 28 |
logger = logging.getLogger(__name__)
|
| 29 |
router = APIRouter()
|
| 30 |
+
_model_switch_lock = asyncio.Lock()
|
| 31 |
|
| 32 |
+
SESSION_TIMEOUT_SECONDS = 7200
|
| 33 |
|
| 34 |
|
| 35 |
@router.websocket("/ws")
|
| 36 |
async def websocket_endpoint(websocket: WebSocket) -> None:
|
| 37 |
await websocket.accept()
|
| 38 |
session_id: str | None = None
|
| 39 |
+
ts_service: TranscriptionService | None = None
|
| 40 |
|
| 41 |
try:
|
| 42 |
+
# ── Auth ──────────────────────────────────────────────────────────────
|
| 43 |
raw = await websocket.receive_text()
|
| 44 |
try:
|
| 45 |
data = json.loads(raw)
|
|
|
|
| 73 |
logger.info("WS session %s opened", session_id)
|
| 74 |
|
| 75 |
# ── Per-session pipeline ──────────────────────────────────────────────
|
|
|
|
| 76 |
asr_engine = getattr(websocket.app.state, "asr_engine", None)
|
| 77 |
+
session_context = SessionContext()
|
| 78 |
+
processor = AudioProcessor()
|
| 79 |
+
groq_client = (
|
| 80 |
+
GroqClient(settings.groq_api_key, context=session_context)
|
| 81 |
+
if settings.groq_api_key
|
| 82 |
+
else None
|
| 83 |
+
)
|
| 84 |
ts_service = (
|
| 85 |
TranscriptionService(websocket=websocket, engine=asr_engine, groq_client=groq_client)
|
| 86 |
+
if asr_engine
|
| 87 |
+
else None
|
| 88 |
)
|
| 89 |
+
if ts_service:
|
| 90 |
+
ts_service.start()
|
| 91 |
if groq_client is None:
|
| 92 |
+
logger.warning("Session %s: GROQ_API_KEY not set — suggestions disabled", session_id)
|
| 93 |
|
| 94 |
# ── Message loop ──────────────────────────────────────────────────────
|
| 95 |
while True:
|
| 96 |
+
elapsed = (
|
| 97 |
+
datetime.now(tz=timezone.utc) - active_sessions[session_id]
|
| 98 |
+
).total_seconds()
|
| 99 |
if elapsed > SESSION_TIMEOUT_SECONDS:
|
| 100 |
logger.info("Session %s exceeded 2h cap, closing", session_id)
|
| 101 |
break
|
|
|
|
| 109 |
if "bytes" in message and message["bytes"] is not None:
|
| 110 |
if processor is not None and ts_service is not None:
|
| 111 |
segment = processor.process_chunk(message["bytes"])
|
| 112 |
+
ts_service.enqueue(segment)
|
|
|
|
| 113 |
continue
|
| 114 |
|
| 115 |
if "text" in message and message["text"] is not None:
|
| 116 |
try:
|
| 117 |
ctrl = ControlFrame.model_validate_json(message["text"])
|
| 118 |
except Exception:
|
| 119 |
+
logger.warning(
|
| 120 |
+
"Session %s: unrecognised frame: %s", session_id, message["text"]
|
| 121 |
+
)
|
| 122 |
continue
|
| 123 |
|
| 124 |
if ctrl.type == "ping":
|
|
|
|
| 126 |
elif ctrl.type == "stop_session":
|
| 127 |
logger.info("Session %s: stop_session received", session_id)
|
| 128 |
break
|
| 129 |
+
elif ctrl.type == "set_model":
|
| 130 |
+
model_name = ctrl.model or ""
|
| 131 |
+
if model_name not in WHISPER_ALLOWLIST:
|
| 132 |
+
await websocket.send_text(
|
| 133 |
+
ErrorMsg(
|
| 134 |
+
message=f"Invalid model '{model_name}'. Valid: {WHISPER_ALLOWLIST}",
|
| 135 |
+
code="INVALID_MODEL",
|
| 136 |
+
).model_dump_json()
|
| 137 |
+
)
|
| 138 |
+
elif _model_switch_lock.locked():
|
| 139 |
+
await websocket.send_text(
|
| 140 |
+
ErrorMsg(
|
| 141 |
+
message="Model switch already in progress, try again shortly",
|
| 142 |
+
code="MODEL_SWITCH_BUSY",
|
| 143 |
+
).model_dump_json()
|
| 144 |
+
)
|
| 145 |
+
else:
|
| 146 |
+
asyncio.create_task(
|
| 147 |
+
_switch_model(websocket.app, model_name, websocket, _model_switch_lock)
|
| 148 |
+
)
|
| 149 |
|
| 150 |
except WebSocketDisconnect:
|
| 151 |
logger.info("Session %s disconnected", session_id)
|
| 152 |
finally:
|
| 153 |
+
if ts_service is not None:
|
| 154 |
+
await ts_service.stop()
|
| 155 |
active_sessions = getattr(websocket.app.state, "active_sessions", {})
|
| 156 |
if session_id and session_id in active_sessions:
|
| 157 |
del active_sessions[session_id]
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
async def _switch_model(app, model_name: str, websocket: WebSocket, lock: asyncio.Lock) -> None:
|
| 161 |
+
async with lock:
|
| 162 |
+
await websocket.send_text(
|
| 163 |
+
ModelChangedMsg(model=model_name, status="loading").model_dump_json()
|
| 164 |
+
)
|
| 165 |
+
try:
|
| 166 |
+
engine = get_whisper_engine(model_name)
|
| 167 |
+
loop = asyncio.get_event_loop()
|
| 168 |
+
await loop.run_in_executor(None, engine.load_model)
|
| 169 |
+
app.state.asr_engine = engine
|
| 170 |
+
logger.info("Model switched to %s", model_name)
|
| 171 |
+
await websocket.send_text(
|
| 172 |
+
ModelChangedMsg(model=model_name, status="ready").model_dump_json()
|
| 173 |
+
)
|
| 174 |
+
except Exception as exc:
|
| 175 |
+
logger.error("Model switch failed: %s", exc)
|
| 176 |
+
await websocket.send_text(
|
| 177 |
+
ErrorMsg(message=str(exc), code="MODEL_SWITCH_ERROR").model_dump_json()
|
| 178 |
+
)
|
server/app/schemas/messages.py
CHANGED
|
@@ -33,10 +33,17 @@ class TranscriptionMsg(BaseModel):
|
|
| 33 |
class SuggestionMsg(BaseModel):
|
| 34 |
type: Literal["suggestion"] = "suggestion"
|
| 35 |
text: str
|
| 36 |
-
category: Literal["
|
|
|
|
| 37 |
timestamp: float
|
| 38 |
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
class ErrorMsg(BaseModel):
|
| 41 |
type: Literal["error"] = "error"
|
| 42 |
message: str
|
|
@@ -55,4 +62,5 @@ class AuthFrame(BaseModel):
|
|
| 55 |
|
| 56 |
|
| 57 |
class ControlFrame(BaseModel):
|
| 58 |
-
type: Literal["start_session", "stop_session", "ping"]
|
|
|
|
|
|
| 33 |
class SuggestionMsg(BaseModel):
|
| 34 |
type: Literal["suggestion"] = "suggestion"
|
| 35 |
text: str
|
| 36 |
+
category: Literal["answer", "question", "action", "summary", "flag"]
|
| 37 |
+
context: str = ""
|
| 38 |
timestamp: float
|
| 39 |
|
| 40 |
|
| 41 |
+
class ModelChangedMsg(BaseModel):
|
| 42 |
+
type: Literal["model_changed"] = "model_changed"
|
| 43 |
+
model: str
|
| 44 |
+
status: Literal["loading", "ready"]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
class ErrorMsg(BaseModel):
|
| 48 |
type: Literal["error"] = "error"
|
| 49 |
message: str
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
class ControlFrame(BaseModel):
|
| 65 |
+
type: Literal["start_session", "stop_session", "ping", "set_model"]
|
| 66 |
+
model: str | None = None
|
server/app/services/audio_processor.py
CHANGED
|
@@ -1,84 +1,14 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import logging
|
| 4 |
-
from collections import deque
|
| 5 |
-
|
| 6 |
import numpy as np
|
| 7 |
-
import torch
|
| 8 |
-
|
| 9 |
-
from app.config import settings
|
| 10 |
-
|
| 11 |
-
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
|
| 14 |
class AudioProcessor:
|
| 15 |
-
"""
|
| 16 |
-
Accepts 300 ms PCM chunks (16-bit signed, 16 kHz mono) from the WebSocket,
|
| 17 |
-
runs Silero VAD on each chunk, and returns a float32 numpy array whenever a
|
| 18 |
-
complete speech segment ends (silence threshold crossed or 30-s hard cap hit).
|
| 19 |
-
"""
|
| 20 |
|
| 21 |
-
|
| 22 |
-
MIN_SPEECH_SAMPLES = SAMPLE_RATE // 2 # 0.5 s — ignore sub-sentence blips
|
| 23 |
-
MAX_SPEECH_SAMPLES = SAMPLE_RATE * 30 # 30 s hard cap → forced flush
|
| 24 |
-
|
| 25 |
-
def __init__(self, vad_model=None) -> None:
|
| 26 |
-
if vad_model is not None:
|
| 27 |
-
self._vad_model = vad_model
|
| 28 |
-
else:
|
| 29 |
-
from silero_vad import load_silero_vad # deferred: requires torch
|
| 30 |
-
self._vad_model = load_silero_vad()
|
| 31 |
-
self._threshold = settings.vad_threshold
|
| 32 |
-
# How many consecutive silent chunks trigger a flush
|
| 33 |
-
self._max_silence_chunks = max(1, round(settings.max_silence_ms / settings.chunk_duration_ms))
|
| 34 |
-
self._speech_buffer: deque[float] = deque(maxlen=self.MAX_SPEECH_SAMPLES)
|
| 35 |
-
self._silence_chunks: int = 0
|
| 36 |
-
|
| 37 |
-
def process_chunk(self, pcm_bytes: bytes) -> np.ndarray | None:
|
| 38 |
-
"""
|
| 39 |
-
Process one PCM frame. Returns a speech segment (float32) when speech ends,
|
| 40 |
-
otherwise None.
|
| 41 |
-
"""
|
| 42 |
audio = np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
if has_speech:
|
| 46 |
-
self._speech_buffer.extend(audio.tolist())
|
| 47 |
-
self._silence_chunks = 0
|
| 48 |
-
else:
|
| 49 |
-
self._silence_chunks += 1
|
| 50 |
-
if (
|
| 51 |
-
self._silence_chunks >= self._max_silence_chunks
|
| 52 |
-
and len(self._speech_buffer) >= self.MIN_SPEECH_SAMPLES
|
| 53 |
-
):
|
| 54 |
-
return self._flush()
|
| 55 |
-
|
| 56 |
-
if len(self._speech_buffer) >= self.MAX_SPEECH_SAMPLES:
|
| 57 |
-
logger.warning("Speech buffer at 30-s cap, forcing flush")
|
| 58 |
-
return self._flush()
|
| 59 |
-
|
| 60 |
-
return None
|
| 61 |
|
| 62 |
def reset(self) -> None:
|
| 63 |
-
|
| 64 |
-
self._silence_chunks = 0
|
| 65 |
-
|
| 66 |
-
# ── private ───────────────────────────────────────────────────────────────
|
| 67 |
-
|
| 68 |
-
def _flush(self) -> np.ndarray:
|
| 69 |
-
segment = np.array(list(self._speech_buffer), dtype=np.float32)
|
| 70 |
-
self._speech_buffer.clear()
|
| 71 |
-
self._silence_chunks = 0
|
| 72 |
-
return segment
|
| 73 |
-
|
| 74 |
-
def _has_speech(self, audio: np.ndarray) -> bool:
|
| 75 |
-
from silero_vad import get_speech_timestamps # deferred: requires torch
|
| 76 |
-
|
| 77 |
-
tensor = torch.tensor(audio)
|
| 78 |
-
stamps = get_speech_timestamps(
|
| 79 |
-
tensor,
|
| 80 |
-
self._vad_model,
|
| 81 |
-
threshold=self._threshold,
|
| 82 |
-
sampling_rate=self.SAMPLE_RATE,
|
| 83 |
-
)
|
| 84 |
-
return len(stamps) > 0
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
import numpy as np
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
class AudioProcessor:
|
| 7 |
+
"""Converts raw PCM bytes to normalized float32 ndarray. VAD is handled client-side."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
def process_chunk(self, pcm_bytes: bytes) -> np.ndarray:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
audio = np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
| 11 |
+
return np.clip(audio, -1.0, 1.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
def reset(self) -> None:
|
| 14 |
+
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/app/services/groq_client.py
CHANGED
|
@@ -4,69 +4,108 @@ import asyncio
|
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
import re
|
| 7 |
-
|
| 8 |
from dataclasses import dataclass
|
| 9 |
-
from typing import Literal
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
_SYSTEM_PROMPT = """\
|
| 14 |
-
You are a real-time meeting
|
| 15 |
-
Analyze the
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
- "insight": A key observation or important point worth noting
|
| 23 |
-
- "action": A concrete next step or task to follow up on
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
_MAX_RETRIES = 3
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
@dataclass
|
| 32 |
class SuggestionResult:
|
| 33 |
text: str
|
| 34 |
-
category: Literal["
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
class GroqClient:
|
| 38 |
-
""
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
if _client is not None:
|
| 47 |
self._client = _client
|
| 48 |
else:
|
| 49 |
-
from groq import AsyncGroq
|
| 50 |
self._client = AsyncGroq(api_key=api_key)
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
def add_transcription(self, text: str) -> None:
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
async def get_suggestion(self) -> SuggestionResult | None:
|
| 59 |
-
|
| 60 |
-
Call Groq with the current context. Retries up to 3 times with
|
| 61 |
-
exponential backoff on rate-limit and 5xx errors.
|
| 62 |
-
Returns None on failure or empty context.
|
| 63 |
-
"""
|
| 64 |
-
if not self._context:
|
| 65 |
return None
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
for attempt in range(_MAX_RETRIES):
|
| 72 |
try:
|
|
@@ -84,32 +123,49 @@ class GroqClient:
|
|
| 84 |
if result:
|
| 85 |
logger.info("Groq suggestion [%s]: %r", result.category, result.text)
|
| 86 |
return result
|
| 87 |
-
|
| 88 |
except Exception as exc:
|
| 89 |
should_retry = _is_retryable(exc)
|
| 90 |
if not should_retry or attempt == _MAX_RETRIES - 1:
|
| 91 |
logger.error("Groq failed after %d attempt(s): %s", attempt + 1, exc)
|
| 92 |
return None
|
| 93 |
wait = 2 ** attempt
|
| 94 |
-
logger.warning("Groq
|
| 95 |
-
attempt + 1, _MAX_RETRIES, exc, wait)
|
| 96 |
await asyncio.sleep(wait)
|
| 97 |
|
| 98 |
return None
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
# ── helpers ────────────────────────────────────────────────────────────────────
|
| 102 |
|
| 103 |
def _parse_suggestion(raw: str) -> SuggestionResult | None:
|
| 104 |
-
"""Extract JSON from LLM output; fall back to regex on malformed responses."""
|
| 105 |
-
# Try direct JSON parse first
|
| 106 |
try:
|
| 107 |
data = json.loads(raw.strip())
|
| 108 |
return _build_result(data)
|
| 109 |
except (json.JSONDecodeError, ValueError):
|
| 110 |
pass
|
| 111 |
|
| 112 |
-
# Regex fallback: grab the first {...} block
|
| 113 |
match = re.search(r"\{[^}]+\}", raw, re.DOTALL)
|
| 114 |
if match:
|
| 115 |
try:
|
|
@@ -126,14 +182,14 @@ def _build_result(data: dict) -> SuggestionResult | None:
|
|
| 126 |
text = str(data.get("text", "")).strip()
|
| 127 |
if not text:
|
| 128 |
return None
|
| 129 |
-
category = data.get("category", "
|
| 130 |
-
if category not in
|
| 131 |
-
category = "
|
| 132 |
-
|
|
|
|
| 133 |
|
| 134 |
|
| 135 |
def _is_retryable(exc: Exception) -> bool:
|
| 136 |
-
"""Return True for rate-limit and server-side errors worth retrying."""
|
| 137 |
try:
|
| 138 |
from groq import APIStatusError, RateLimitError
|
| 139 |
if isinstance(exc, RateLimitError):
|
|
@@ -142,5 +198,4 @@ def _is_retryable(exc: Exception) -> bool:
|
|
| 142 |
return exc.status_code >= 500
|
| 143 |
except ImportError:
|
| 144 |
pass
|
| 145 |
-
# Retry on generic network errors
|
| 146 |
return isinstance(exc, (TimeoutError, OSError))
|
|
|
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
import re
|
| 7 |
+
import time
|
| 8 |
from dataclasses import dataclass
|
| 9 |
+
from typing import TYPE_CHECKING, Literal
|
| 10 |
+
|
| 11 |
+
if TYPE_CHECKING:
|
| 12 |
+
from app.services.session_context import SessionContext
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
_SYSTEM_PROMPT = """\
|
| 17 |
+
You are a real-time meeting coach embedded in the participant's browser.
|
| 18 |
+
Analyze the full meeting context and recent transcript, then generate ONE response.
|
| 19 |
+
|
| 20 |
+
Rules:
|
| 21 |
+
- If a question was asked but not answered → provide a direct, concise answer
|
| 22 |
+
- If a claim needs challenging → suggest a clarifying question
|
| 23 |
+
- If a decision or action item was implied → flag it explicitly
|
| 24 |
+
- If a topic just resolved → offer a brief summary
|
| 25 |
+
- If conversation stalled → suggest a re-engagement prompt
|
| 26 |
+
|
| 27 |
+
Respond ONLY in this exact JSON format:
|
| 28 |
+
{"category": "answer|question|action|summary|flag", "text": "...", "context": "one-line reason why this is relevant now"}
|
| 29 |
+
|
| 30 |
+
Keep "text" under 20 words. Keep "context" under 10 words. Be specific and direct."""
|
| 31 |
|
| 32 |
+
_SUMMARY_PROMPT = """\
|
| 33 |
+
You are summarizing a business meeting.
|
| 34 |
|
| 35 |
+
Current summary:
|
| 36 |
+
{current_summary}
|
|
|
|
|
|
|
| 37 |
|
| 38 |
+
New transcript segments:
|
| 39 |
+
{new_segments}
|
| 40 |
+
|
| 41 |
+
Write an updated summary under 300 words. Preserve all decisions, action items, and open questions. Use bullet points."""
|
| 42 |
|
| 43 |
_MAX_RETRIES = 3
|
| 44 |
+
_SUMMARY_TRIGGER = 10
|
| 45 |
+
_SUGGESTION_DEBOUNCE_S = 2.0
|
| 46 |
+
VALID_CATEGORIES = ("answer", "question", "action", "summary", "flag")
|
| 47 |
|
| 48 |
|
| 49 |
@dataclass
|
| 50 |
class SuggestionResult:
|
| 51 |
text: str
|
| 52 |
+
category: Literal["answer", "question", "action", "summary", "flag"] = "question"
|
| 53 |
+
context: str = ""
|
| 54 |
|
| 55 |
|
| 56 |
class GroqClient:
|
| 57 |
+
MODEL = "llama-3.3-70b-versatile"
|
| 58 |
+
|
| 59 |
+
def __init__(
|
| 60 |
+
self,
|
| 61 |
+
api_key: str,
|
| 62 |
+
context: "SessionContext | None" = None,
|
| 63 |
+
_client=None,
|
| 64 |
+
) -> None:
|
| 65 |
if _client is not None:
|
| 66 |
self._client = _client
|
| 67 |
else:
|
| 68 |
+
from groq import AsyncGroq
|
| 69 |
self._client = AsyncGroq(api_key=api_key)
|
| 70 |
+
|
| 71 |
+
if context is not None:
|
| 72 |
+
self._context = context
|
| 73 |
+
else:
|
| 74 |
+
from app.services.session_context import SessionContext
|
| 75 |
+
self._context = SessionContext()
|
| 76 |
+
|
| 77 |
+
self._last_suggestion_time: float = 0.0
|
| 78 |
+
self._background_tasks: set[asyncio.Task] = set()
|
| 79 |
|
| 80 |
def add_transcription(self, text: str) -> None:
|
| 81 |
+
if not text.strip():
|
| 82 |
+
return
|
| 83 |
+
self._context.recent.append(text.strip())
|
| 84 |
+
self._context.unsummarized_count += 1
|
| 85 |
+
if self._context.unsummarized_count >= _SUMMARY_TRIGGER:
|
| 86 |
+
task = asyncio.create_task(self._update_summary())
|
| 87 |
+
self._background_tasks.add(task)
|
| 88 |
+
task.add_done_callback(self._background_tasks.discard)
|
| 89 |
+
self._context.unsummarized_count = 0
|
| 90 |
|
| 91 |
async def get_suggestion(self) -> SuggestionResult | None:
|
| 92 |
+
if not self._context.recent:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
return None
|
| 94 |
|
| 95 |
+
now = time.monotonic()
|
| 96 |
+
if now - self._last_suggestion_time < _SUGGESTION_DEBOUNCE_S:
|
| 97 |
+
return None
|
| 98 |
+
self._last_suggestion_time = now
|
| 99 |
+
|
| 100 |
+
summary_section = self._context.summary or "Meeting just started."
|
| 101 |
+
recent_transcript = "\n".join(
|
| 102 |
+
f"[{i + 1}] {seg}" for i, seg in enumerate(self._context.recent)
|
| 103 |
+
)
|
| 104 |
+
user_prompt = (
|
| 105 |
+
f"Meeting context so far:\n{summary_section}\n\n"
|
| 106 |
+
f"Recent transcript:\n{recent_transcript}\n\n"
|
| 107 |
+
"Generate one coaching response."
|
| 108 |
+
)
|
| 109 |
|
| 110 |
for attempt in range(_MAX_RETRIES):
|
| 111 |
try:
|
|
|
|
| 123 |
if result:
|
| 124 |
logger.info("Groq suggestion [%s]: %r", result.category, result.text)
|
| 125 |
return result
|
|
|
|
| 126 |
except Exception as exc:
|
| 127 |
should_retry = _is_retryable(exc)
|
| 128 |
if not should_retry or attempt == _MAX_RETRIES - 1:
|
| 129 |
logger.error("Groq failed after %d attempt(s): %s", attempt + 1, exc)
|
| 130 |
return None
|
| 131 |
wait = 2 ** attempt
|
| 132 |
+
logger.warning("Groq retry %d/%d in %ds", attempt + 1, _MAX_RETRIES, wait)
|
|
|
|
| 133 |
await asyncio.sleep(wait)
|
| 134 |
|
| 135 |
return None
|
| 136 |
|
| 137 |
+
async def _update_summary(self) -> None:
|
| 138 |
+
if not self._context.recent:
|
| 139 |
+
return
|
| 140 |
+
new_segments = "\n".join(f"- {seg}" for seg in self._context.recent)
|
| 141 |
+
prompt = _SUMMARY_PROMPT.format(
|
| 142 |
+
current_summary=self._context.summary or "No summary yet.",
|
| 143 |
+
new_segments=new_segments,
|
| 144 |
+
)
|
| 145 |
+
try:
|
| 146 |
+
response = await self._client.chat.completions.create(
|
| 147 |
+
model=self.MODEL,
|
| 148 |
+
messages=[{"role": "user", "content": prompt}],
|
| 149 |
+
temperature=0.3,
|
| 150 |
+
max_tokens=400,
|
| 151 |
+
)
|
| 152 |
+
new_summary = (response.choices[0].message.content or "").strip()
|
| 153 |
+
if new_summary:
|
| 154 |
+
self._context.summary = new_summary
|
| 155 |
+
logger.info("Meeting summary updated (%d chars)", len(new_summary))
|
| 156 |
+
except Exception as exc:
|
| 157 |
+
logger.warning("Summary update failed: %s", exc)
|
| 158 |
+
|
| 159 |
|
| 160 |
# ── helpers ────────────────────────────────────────────────────────────────────
|
| 161 |
|
| 162 |
def _parse_suggestion(raw: str) -> SuggestionResult | None:
|
|
|
|
|
|
|
| 163 |
try:
|
| 164 |
data = json.loads(raw.strip())
|
| 165 |
return _build_result(data)
|
| 166 |
except (json.JSONDecodeError, ValueError):
|
| 167 |
pass
|
| 168 |
|
|
|
|
| 169 |
match = re.search(r"\{[^}]+\}", raw, re.DOTALL)
|
| 170 |
if match:
|
| 171 |
try:
|
|
|
|
| 182 |
text = str(data.get("text", "")).strip()
|
| 183 |
if not text:
|
| 184 |
return None
|
| 185 |
+
category = data.get("category", "question")
|
| 186 |
+
if category not in VALID_CATEGORIES:
|
| 187 |
+
category = "question"
|
| 188 |
+
context = str(data.get("context", "")).strip()
|
| 189 |
+
return SuggestionResult(text=text, category=category, context=context)
|
| 190 |
|
| 191 |
|
| 192 |
def _is_retryable(exc: Exception) -> bool:
|
|
|
|
| 193 |
try:
|
| 194 |
from groq import APIStatusError, RateLimitError
|
| 195 |
if isinstance(exc, RateLimitError):
|
|
|
|
| 198 |
return exc.status_code >= 500
|
| 199 |
except ImportError:
|
| 200 |
pass
|
|
|
|
| 201 |
return isinstance(exc, (TimeoutError, OSError))
|
server/app/services/session_context.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections import deque
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class SessionContext:
|
| 9 |
+
summary: str = ""
|
| 10 |
+
recent: deque = field(default_factory=lambda: deque(maxlen=5))
|
| 11 |
+
unsummarized_count: int = 0
|
server/app/services/transcription.py
CHANGED
|
@@ -15,11 +15,6 @@ logger = logging.getLogger(__name__)
|
|
| 15 |
|
| 16 |
|
| 17 |
class TranscriptionService:
|
| 18 |
-
"""
|
| 19 |
-
Runs ASR in a thread-pool executor, sends a transcription WS event, then
|
| 20 |
-
fires a Groq suggestion request concurrently (non-blocking).
|
| 21 |
-
"""
|
| 22 |
-
|
| 23 |
def __init__(
|
| 24 |
self,
|
| 25 |
websocket: WebSocket,
|
|
@@ -29,11 +24,55 @@ class TranscriptionService:
|
|
| 29 |
self._ws = websocket
|
| 30 |
self._engine = engine
|
| 31 |
self._groq = groq_client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
|
| 34 |
-
if not self._engine.is_loaded():
|
| 35 |
-
logger.warning("ASR engine not ready — dropping segment (%d samples)", len(audio))
|
| 36 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
t0 = time.perf_counter()
|
| 39 |
try:
|
|
@@ -52,7 +91,6 @@ class TranscriptionService:
|
|
| 52 |
if not result.text:
|
| 53 |
return
|
| 54 |
|
| 55 |
-
# ── Send transcription immediately ────────────────────────────────────
|
| 56 |
await self._ws.send_text(
|
| 57 |
TranscriptionMsg(
|
| 58 |
text=result.text,
|
|
@@ -62,13 +100,13 @@ class TranscriptionService:
|
|
| 62 |
).model_dump_json()
|
| 63 |
)
|
| 64 |
|
| 65 |
-
# ── Fire Groq suggestion concurrently (non-blocking) ─────────────────
|
| 66 |
if self._groq is not None:
|
| 67 |
self._groq.add_transcription(result.text)
|
| 68 |
-
asyncio.create_task(self._send_suggestion())
|
|
|
|
|
|
|
| 69 |
|
| 70 |
async def _send_suggestion(self) -> None:
|
| 71 |
-
"""Fetch a suggestion from Groq and send it; never raises."""
|
| 72 |
assert self._groq is not None
|
| 73 |
try:
|
| 74 |
suggestion = await self._groq.get_suggestion()
|
|
@@ -78,6 +116,7 @@ class TranscriptionService:
|
|
| 78 |
SuggestionMsg(
|
| 79 |
text=suggestion.text,
|
| 80 |
category=suggestion.category,
|
|
|
|
| 81 |
timestamp=time.time(),
|
| 82 |
).model_dump_json()
|
| 83 |
)
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
class TranscriptionService:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def __init__(
|
| 19 |
self,
|
| 20 |
websocket: WebSocket,
|
|
|
|
| 24 |
self._ws = websocket
|
| 25 |
self._engine = engine
|
| 26 |
self._groq = groq_client
|
| 27 |
+
self._queue: asyncio.Queue[np.ndarray] = asyncio.Queue(maxsize=3)
|
| 28 |
+
self._worker_task: asyncio.Task | None = None
|
| 29 |
+
self._pending_suggestions: set[asyncio.Task] = set()
|
| 30 |
+
|
| 31 |
+
def start(self) -> None:
|
| 32 |
+
self._worker_task = asyncio.create_task(self._worker())
|
| 33 |
+
|
| 34 |
+
async def stop(self) -> None:
|
| 35 |
+
if self._worker_task:
|
| 36 |
+
self._worker_task.cancel()
|
| 37 |
+
try:
|
| 38 |
+
await self._worker_task
|
| 39 |
+
except asyncio.CancelledError:
|
| 40 |
+
pass
|
| 41 |
+
for task in list(self._pending_suggestions):
|
| 42 |
+
task.cancel()
|
| 43 |
+
if self._pending_suggestions:
|
| 44 |
+
await asyncio.gather(*self._pending_suggestions, return_exceptions=True)
|
| 45 |
|
| 46 |
+
def enqueue(self, audio: np.ndarray) -> None:
|
| 47 |
+
if not self._engine or not self._engine.is_loaded():
|
|
|
|
| 48 |
return
|
| 49 |
+
if self._queue.full():
|
| 50 |
+
try:
|
| 51 |
+
self._queue.get_nowait()
|
| 52 |
+
self._queue.task_done()
|
| 53 |
+
except asyncio.QueueEmpty:
|
| 54 |
+
pass
|
| 55 |
+
try:
|
| 56 |
+
self._queue.put_nowait(audio)
|
| 57 |
+
except asyncio.QueueFull:
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
async def _worker(self) -> None:
|
| 61 |
+
while True:
|
| 62 |
+
audio = await self._queue.get()
|
| 63 |
+
try:
|
| 64 |
+
await self._process(audio)
|
| 65 |
+
except Exception as exc:
|
| 66 |
+
logger.exception("Worker error: %s", exc)
|
| 67 |
+
finally:
|
| 68 |
+
self._queue.task_done()
|
| 69 |
+
|
| 70 |
+
async def _process(self, audio: np.ndarray) -> None:
|
| 71 |
+
await self._ws.send_text(
|
| 72 |
+
TranscriptionMsg(
|
| 73 |
+
text="", is_final=False, timestamp=time.time(), confidence=0.0
|
| 74 |
+
).model_dump_json()
|
| 75 |
+
)
|
| 76 |
|
| 77 |
t0 = time.perf_counter()
|
| 78 |
try:
|
|
|
|
| 91 |
if not result.text:
|
| 92 |
return
|
| 93 |
|
|
|
|
| 94 |
await self._ws.send_text(
|
| 95 |
TranscriptionMsg(
|
| 96 |
text=result.text,
|
|
|
|
| 100 |
).model_dump_json()
|
| 101 |
)
|
| 102 |
|
|
|
|
| 103 |
if self._groq is not None:
|
| 104 |
self._groq.add_transcription(result.text)
|
| 105 |
+
task = asyncio.create_task(self._send_suggestion())
|
| 106 |
+
self._pending_suggestions.add(task)
|
| 107 |
+
task.add_done_callback(self._pending_suggestions.discard)
|
| 108 |
|
| 109 |
async def _send_suggestion(self) -> None:
|
|
|
|
| 110 |
assert self._groq is not None
|
| 111 |
try:
|
| 112 |
suggestion = await self._groq.get_suggestion()
|
|
|
|
| 116 |
SuggestionMsg(
|
| 117 |
text=suggestion.text,
|
| 118 |
category=suggestion.category,
|
| 119 |
+
context=suggestion.context,
|
| 120 |
timestamp=time.time(),
|
| 121 |
).model_dump_json()
|
| 122 |
)
|
uv.lock
CHANGED
|
@@ -1290,16 +1290,15 @@ wheels = [
|
|
| 1290 |
|
| 1291 |
[[package]]
|
| 1292 |
name = "meeting-ai-assistant"
|
| 1293 |
-
version = "0.
|
| 1294 |
source = { virtual = "." }
|
| 1295 |
dependencies = [
|
| 1296 |
{ name = "fastapi" },
|
|
|
|
| 1297 |
{ name = "groq" },
|
| 1298 |
{ name = "numpy" },
|
| 1299 |
{ name = "pydantic-settings" },
|
| 1300 |
{ name = "python-dotenv" },
|
| 1301 |
-
{ name = "silero-vad" },
|
| 1302 |
-
{ name = "torch" },
|
| 1303 |
{ name = "uvicorn", extra = ["standard"] },
|
| 1304 |
{ name = "websockets" },
|
| 1305 |
]
|
|
@@ -1309,9 +1308,6 @@ parakeet = [
|
|
| 1309 |
{ name = "nemo-toolkit", extra = ["asr"] },
|
| 1310 |
{ name = "soundfile" },
|
| 1311 |
]
|
| 1312 |
-
whisper = [
|
| 1313 |
-
{ name = "faster-whisper" },
|
| 1314 |
-
]
|
| 1315 |
|
| 1316 |
[package.dev-dependencies]
|
| 1317 |
dev = [
|
|
@@ -1324,19 +1320,17 @@ dev = [
|
|
| 1324 |
[package.metadata]
|
| 1325 |
requires-dist = [
|
| 1326 |
{ name = "fastapi", specifier = ">=0.111.0" },
|
| 1327 |
-
{ name = "faster-whisper",
|
| 1328 |
{ name = "groq", specifier = ">=0.9.0" },
|
| 1329 |
{ name = "nemo-toolkit", extras = ["asr"], marker = "extra == 'parakeet'", specifier = ">=1.23.0" },
|
| 1330 |
{ name = "numpy", specifier = ">=1.24.0" },
|
| 1331 |
{ name = "pydantic-settings", specifier = ">=2.2.0" },
|
| 1332 |
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
| 1333 |
-
{ name = "silero-vad", specifier = ">=4.0.0" },
|
| 1334 |
{ name = "soundfile", marker = "extra == 'parakeet'", specifier = ">=0.12.0" },
|
| 1335 |
-
{ name = "torch", specifier = ">=2.0.0" },
|
| 1336 |
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
|
| 1337 |
{ name = "websockets", specifier = ">=12.0" },
|
| 1338 |
]
|
| 1339 |
-
provides-extras = ["parakeet"
|
| 1340 |
|
| 1341 |
[package.metadata.requires-dev]
|
| 1342 |
dev = [
|
|
@@ -2580,20 +2574,6 @@ wheels = [
|
|
| 2580 |
{ url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" },
|
| 2581 |
]
|
| 2582 |
|
| 2583 |
-
[[package]]
|
| 2584 |
-
name = "silero-vad"
|
| 2585 |
-
version = "6.2.1"
|
| 2586 |
-
source = { registry = "https://pypi.org/simple" }
|
| 2587 |
-
dependencies = [
|
| 2588 |
-
{ name = "packaging" },
|
| 2589 |
-
{ name = "torch" },
|
| 2590 |
-
{ name = "torchaudio" },
|
| 2591 |
-
]
|
| 2592 |
-
sdist = { url = "https://files.pythonhosted.org/packages/32/d3/e31f526482782764aa4f70e20fd4545cf2e4a81a60b6fb0f089f6d107991/silero_vad-6.2.1.tar.gz", hash = "sha256:b23062b0e39fad17b1266fc23c1e7b4290219dbe82ce08510889e32f681f4b3b", size = 28913811, upload-time = "2026-02-24T08:41:59.329Z" }
|
| 2593 |
-
wheels = [
|
| 2594 |
-
{ url = "https://files.pythonhosted.org/packages/0b/2b/48566f29a8b53d856ceb1994f209122749b3fda0a733a07e82047257de7a/silero_vad-6.2.1-py3-none-any.whl", hash = "sha256:09de93c4d874bb19c53e62a47dd38be5f163cedad2b5599583231f2a84ef79cb", size = 9146242, upload-time = "2026-02-24T08:41:56.955Z" },
|
| 2595 |
-
]
|
| 2596 |
-
|
| 2597 |
[[package]]
|
| 2598 |
name = "six"
|
| 2599 |
version = "1.17.0"
|
|
@@ -2876,17 +2856,6 @@ wheels = [
|
|
| 2876 |
{ url = "https://files.pythonhosted.org/packages/d1/bd/9912d30b68845256aabbb4a40aeefeef3c3b20db5211ccda653544ada4b6/torch-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:73e24aaf8f36ab90d95cd1761208b2eb70841c2a9ca1a3f9061b39fc5331b708", size = 114519675, upload-time = "2026-03-23T18:11:52.995Z" },
|
| 2877 |
]
|
| 2878 |
|
| 2879 |
-
[[package]]
|
| 2880 |
-
name = "torchaudio"
|
| 2881 |
-
version = "2.11.0"
|
| 2882 |
-
source = { registry = "https://pypi.org/simple" }
|
| 2883 |
-
wheels = [
|
| 2884 |
-
{ url = "https://files.pythonhosted.org/packages/94/77/0eec7f175d88f312296bd5b11c23bd58da37c1021f53da3db4df449ce3ee/torchaudio-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:492dd64645e9d0bb843e94f1d9a4d1e31426262ffc594fafecc1697df9df5eb9", size = 684142, upload-time = "2026-03-23T18:13:36.805Z" },
|
| 2885 |
-
{ url = "https://files.pythonhosted.org/packages/b3/f9/6f7ebe071b44592c85269762b55b63ab0a091b5f479f73544738f7564a1e/torchaudio-2.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:73dab4841f94d888bc7c2aed7b5547c643edc974306919fe1adfb65d57cccf4b", size = 1626527, upload-time = "2026-03-23T18:13:39.011Z" },
|
| 2886 |
-
{ url = "https://files.pythonhosted.org/packages/ac/70/17408e0d154d0c894537a88dcbadc48e8ad3b6e1ef4a1dabda5d40245ee0/torchaudio-2.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1a07ec72fd6f26a588c39b5f029e0130d16bb40bc4221635580bf8fb18fcbc80", size = 1771930, upload-time = "2026-03-23T18:13:37.963Z" },
|
| 2887 |
-
{ url = "https://files.pythonhosted.org/packages/c9/75/b6d03fc75b409bdaec597274d1bdd4213db716ed16f6801386b31d59c551/torchaudio-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:bb59ba4452bbbe95d75ad3ef18df9824955625f36698ce9a5998a4a9f3c1ba1d", size = 328658, upload-time = "2026-03-23T18:13:44.545Z" },
|
| 2888 |
-
]
|
| 2889 |
-
|
| 2890 |
[[package]]
|
| 2891 |
name = "torchmetrics"
|
| 2892 |
version = "1.9.0"
|
|
|
|
| 1290 |
|
| 1291 |
[[package]]
|
| 1292 |
name = "meeting-ai-assistant"
|
| 1293 |
+
version = "0.2.0"
|
| 1294 |
source = { virtual = "." }
|
| 1295 |
dependencies = [
|
| 1296 |
{ name = "fastapi" },
|
| 1297 |
+
{ name = "faster-whisper" },
|
| 1298 |
{ name = "groq" },
|
| 1299 |
{ name = "numpy" },
|
| 1300 |
{ name = "pydantic-settings" },
|
| 1301 |
{ name = "python-dotenv" },
|
|
|
|
|
|
|
| 1302 |
{ name = "uvicorn", extra = ["standard"] },
|
| 1303 |
{ name = "websockets" },
|
| 1304 |
]
|
|
|
|
| 1308 |
{ name = "nemo-toolkit", extra = ["asr"] },
|
| 1309 |
{ name = "soundfile" },
|
| 1310 |
]
|
|
|
|
|
|
|
|
|
|
| 1311 |
|
| 1312 |
[package.dev-dependencies]
|
| 1313 |
dev = [
|
|
|
|
| 1320 |
[package.metadata]
|
| 1321 |
requires-dist = [
|
| 1322 |
{ name = "fastapi", specifier = ">=0.111.0" },
|
| 1323 |
+
{ name = "faster-whisper", specifier = ">=1.0.0" },
|
| 1324 |
{ name = "groq", specifier = ">=0.9.0" },
|
| 1325 |
{ name = "nemo-toolkit", extras = ["asr"], marker = "extra == 'parakeet'", specifier = ">=1.23.0" },
|
| 1326 |
{ name = "numpy", specifier = ">=1.24.0" },
|
| 1327 |
{ name = "pydantic-settings", specifier = ">=2.2.0" },
|
| 1328 |
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
|
|
|
| 1329 |
{ name = "soundfile", marker = "extra == 'parakeet'", specifier = ">=0.12.0" },
|
|
|
|
| 1330 |
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
|
| 1331 |
{ name = "websockets", specifier = ">=12.0" },
|
| 1332 |
]
|
| 1333 |
+
provides-extras = ["parakeet"]
|
| 1334 |
|
| 1335 |
[package.metadata.requires-dev]
|
| 1336 |
dev = [
|
|
|
|
| 2574 |
{ url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" },
|
| 2575 |
]
|
| 2576 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2577 |
[[package]]
|
| 2578 |
name = "six"
|
| 2579 |
version = "1.17.0"
|
|
|
|
| 2856 |
{ url = "https://files.pythonhosted.org/packages/d1/bd/9912d30b68845256aabbb4a40aeefeef3c3b20db5211ccda653544ada4b6/torch-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:73e24aaf8f36ab90d95cd1761208b2eb70841c2a9ca1a3f9061b39fc5331b708", size = 114519675, upload-time = "2026-03-23T18:11:52.995Z" },
|
| 2857 |
]
|
| 2858 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2859 |
[[package]]
|
| 2860 |
name = "torchmetrics"
|
| 2861 |
version = "1.9.0"
|