Siddu2004-2006 commited on
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 CHANGED
@@ -19,9 +19,7 @@ WORKDIR $HOME/app
19
  # ── Install Python deps ─────────────────────────────────────────────
20
  COPY --chown=user pyproject.toml uv.lock* ./
21
 
22
- # Use parakeet extra
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.1.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
- "torch>=2.0.0",
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 = "parakeet"
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.1.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 # deferred: heavy import
15
-
16
  self._model = WhisperModel(
17
- settings.whisper_model,
18
  device=settings.whisper_device,
19
- compute_type=settings.whisper_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(audio, beam_size=1, language="en")
 
 
 
 
 
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 # 2 hours
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
- processor: AudioProcessor | None = None
35
 
36
  try:
37
- # ── Auth frame ────────────────────────────────────────────────────────
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
- # Both models must be ready; if still loading, audio frames are dropped
74
- processor = AudioProcessor(vad_model=vad_model) if vad_model is not None else None
75
- groq_client = GroqClient(settings.groq_api_key) if settings.groq_api_key else None
 
 
 
 
76
  ts_service = (
77
  TranscriptionService(websocket=websocket, engine=asr_engine, groq_client=groq_client)
78
- if asr_engine else None
 
79
  )
80
- if processor is None:
81
- logger.warning("Session %s: VAD not ready, audio will be dropped until loaded", session_id)
82
  if groq_client is None:
83
- logger.warning("Session %s: GROQ_API_KEY not set, suggestions disabled", session_id)
84
 
85
  # ── Message loop ──────────────────────────────────────────────────────
86
  while True:
87
- elapsed = (datetime.now(tz=timezone.utc) - active_sessions[session_id]).total_seconds()
 
 
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
- if segment is not None:
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("Session %s: unrecognised frame: %s", session_id, message["text"])
 
 
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 processor is not None:
122
- processor.reset()
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["question", "insight", "action"]
 
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
- SAMPLE_RATE = 16000
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
- has_speech = self._has_speech(audio)
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
- self._speech_buffer.clear()
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
- from collections import deque
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 assistant.
15
- Analyze the recent meeting transcript and generate ONE brief, actionable suggestion.
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
- Respond ONLY in this exact JSON format — no other text:
18
- {"category": "question", "text": "..."}
19
 
20
- Categories:
21
- - "question": A clarifying question the participant could ask
22
- - "insight": A key observation or important point worth noting
23
- - "action": A concrete next step or task to follow up on
24
 
25
- Keep the suggestion under 15 words. Be specific and direct."""
 
 
 
26
 
27
  _MAX_RETRIES = 3
28
- _MAX_CONTEXT_SEGMENTS = 10
 
 
29
 
30
 
31
  @dataclass
32
  class SuggestionResult:
33
  text: str
34
- category: Literal["question", "insight", "action"] = "insight"
 
35
 
36
 
37
  class GroqClient:
38
- """
39
- Async Groq API client with per-session transcript context, exponential
40
- backoff (3 retries), and JSON-based category classification.
41
- """
42
-
43
- MODEL = "llama-3.1-8b-instant"
44
-
45
- def __init__(self, api_key: str, _client=None) -> None:
46
  if _client is not None:
47
  self._client = _client
48
  else:
49
- from groq import AsyncGroq # deferred: not needed if key absent
50
  self._client = AsyncGroq(api_key=api_key)
51
- self._context: deque[str] = deque(maxlen=_MAX_CONTEXT_SEGMENTS)
 
 
 
 
 
 
 
 
52
 
53
  def add_transcription(self, text: str) -> None:
54
- """Append a transcription segment to the rolling context window."""
55
- if text.strip():
56
- self._context.append(text.strip())
 
 
 
 
 
 
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
- user_prompt = "Recent transcript:\n" + "\n".join(
68
- f"[{i + 1}] {seg}" for i, seg in enumerate(self._context)
69
- ) + "\n\nGenerate one suggestion."
 
 
 
 
 
 
 
 
 
 
 
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 attempt %d/%d failed (%s), retrying in %ds",
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", "insight")
130
- if category not in ("question", "insight", "action"):
131
- category = "insight"
132
- return SuggestionResult(text=text, category=category)
 
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
- async def transcribe_and_send(self, audio: np.ndarray) -> None:
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.1.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", marker = "extra == 'whisper'", specifier = ">=1.0.0" },
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", "whisper"]
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"