audex 1.0.7a3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. audex/__init__.py +9 -0
  2. audex/__main__.py +7 -0
  3. audex/cli/__init__.py +189 -0
  4. audex/cli/apis/__init__.py +12 -0
  5. audex/cli/apis/init/__init__.py +34 -0
  6. audex/cli/apis/init/gencfg.py +130 -0
  7. audex/cli/apis/init/setup.py +330 -0
  8. audex/cli/apis/init/vprgroup.py +125 -0
  9. audex/cli/apis/serve.py +141 -0
  10. audex/cli/args.py +356 -0
  11. audex/cli/exceptions.py +44 -0
  12. audex/cli/helper/__init__.py +0 -0
  13. audex/cli/helper/ansi.py +193 -0
  14. audex/cli/helper/display.py +288 -0
  15. audex/config/__init__.py +64 -0
  16. audex/config/core/__init__.py +30 -0
  17. audex/config/core/app.py +29 -0
  18. audex/config/core/audio.py +45 -0
  19. audex/config/core/logging.py +163 -0
  20. audex/config/core/session.py +11 -0
  21. audex/config/helper/__init__.py +1 -0
  22. audex/config/helper/client/__init__.py +1 -0
  23. audex/config/helper/client/http.py +28 -0
  24. audex/config/helper/client/websocket.py +21 -0
  25. audex/config/helper/provider/__init__.py +1 -0
  26. audex/config/helper/provider/dashscope.py +13 -0
  27. audex/config/helper/provider/unisound.py +18 -0
  28. audex/config/helper/provider/xfyun.py +23 -0
  29. audex/config/infrastructure/__init__.py +31 -0
  30. audex/config/infrastructure/cache.py +51 -0
  31. audex/config/infrastructure/database.py +48 -0
  32. audex/config/infrastructure/recorder.py +32 -0
  33. audex/config/infrastructure/store.py +19 -0
  34. audex/config/provider/__init__.py +18 -0
  35. audex/config/provider/transcription.py +109 -0
  36. audex/config/provider/vpr.py +99 -0
  37. audex/container.py +40 -0
  38. audex/entity/__init__.py +468 -0
  39. audex/entity/doctor.py +109 -0
  40. audex/entity/doctor.pyi +51 -0
  41. audex/entity/fields.py +401 -0
  42. audex/entity/segment.py +115 -0
  43. audex/entity/segment.pyi +38 -0
  44. audex/entity/session.py +133 -0
  45. audex/entity/session.pyi +47 -0
  46. audex/entity/utterance.py +142 -0
  47. audex/entity/utterance.pyi +48 -0
  48. audex/entity/vp.py +68 -0
  49. audex/entity/vp.pyi +35 -0
  50. audex/exceptions.py +157 -0
  51. audex/filters/__init__.py +692 -0
  52. audex/filters/generated/__init__.py +21 -0
  53. audex/filters/generated/doctor.py +987 -0
  54. audex/filters/generated/segment.py +723 -0
  55. audex/filters/generated/session.py +978 -0
  56. audex/filters/generated/utterance.py +939 -0
  57. audex/filters/generated/vp.py +815 -0
  58. audex/helper/__init__.py +1 -0
  59. audex/helper/hash.py +33 -0
  60. audex/helper/mixin.py +65 -0
  61. audex/helper/net.py +19 -0
  62. audex/helper/settings/__init__.py +830 -0
  63. audex/helper/settings/fields.py +317 -0
  64. audex/helper/stream.py +153 -0
  65. audex/injectors/__init__.py +1 -0
  66. audex/injectors/config.py +12 -0
  67. audex/injectors/lifespan.py +7 -0
  68. audex/lib/__init__.py +1 -0
  69. audex/lib/cache/__init__.py +383 -0
  70. audex/lib/cache/inmemory.py +513 -0
  71. audex/lib/database/__init__.py +83 -0
  72. audex/lib/database/sqlite.py +406 -0
  73. audex/lib/exporter.py +189 -0
  74. audex/lib/injectors/__init__.py +1 -0
  75. audex/lib/injectors/cache.py +25 -0
  76. audex/lib/injectors/container.py +47 -0
  77. audex/lib/injectors/exporter.py +26 -0
  78. audex/lib/injectors/recorder.py +33 -0
  79. audex/lib/injectors/server.py +17 -0
  80. audex/lib/injectors/session.py +18 -0
  81. audex/lib/injectors/sqlite.py +24 -0
  82. audex/lib/injectors/store.py +13 -0
  83. audex/lib/injectors/transcription.py +42 -0
  84. audex/lib/injectors/usb.py +12 -0
  85. audex/lib/injectors/vpr.py +65 -0
  86. audex/lib/injectors/wifi.py +7 -0
  87. audex/lib/recorder.py +844 -0
  88. audex/lib/repos/__init__.py +149 -0
  89. audex/lib/repos/container.py +23 -0
  90. audex/lib/repos/database/__init__.py +1 -0
  91. audex/lib/repos/database/sqlite.py +672 -0
  92. audex/lib/repos/decorators.py +74 -0
  93. audex/lib/repos/doctor.py +286 -0
  94. audex/lib/repos/segment.py +302 -0
  95. audex/lib/repos/session.py +285 -0
  96. audex/lib/repos/tables/__init__.py +70 -0
  97. audex/lib/repos/tables/doctor.py +137 -0
  98. audex/lib/repos/tables/segment.py +113 -0
  99. audex/lib/repos/tables/session.py +140 -0
  100. audex/lib/repos/tables/utterance.py +131 -0
  101. audex/lib/repos/tables/vp.py +102 -0
  102. audex/lib/repos/utterance.py +288 -0
  103. audex/lib/repos/vp.py +286 -0
  104. audex/lib/restful.py +251 -0
  105. audex/lib/server/__init__.py +97 -0
  106. audex/lib/server/auth.py +98 -0
  107. audex/lib/server/handlers.py +248 -0
  108. audex/lib/server/templates/index.html.j2 +226 -0
  109. audex/lib/server/templates/login.html.j2 +111 -0
  110. audex/lib/server/templates/static/script.js +68 -0
  111. audex/lib/server/templates/static/style.css +579 -0
  112. audex/lib/server/types.py +123 -0
  113. audex/lib/session.py +503 -0
  114. audex/lib/store/__init__.py +238 -0
  115. audex/lib/store/localfile.py +411 -0
  116. audex/lib/transcription/__init__.py +33 -0
  117. audex/lib/transcription/dashscope.py +525 -0
  118. audex/lib/transcription/events.py +62 -0
  119. audex/lib/usb.py +554 -0
  120. audex/lib/vpr/__init__.py +38 -0
  121. audex/lib/vpr/unisound/__init__.py +185 -0
  122. audex/lib/vpr/unisound/types.py +469 -0
  123. audex/lib/vpr/xfyun/__init__.py +483 -0
  124. audex/lib/vpr/xfyun/types.py +679 -0
  125. audex/lib/websocket/__init__.py +8 -0
  126. audex/lib/websocket/connection.py +485 -0
  127. audex/lib/websocket/pool.py +991 -0
  128. audex/lib/wifi.py +1146 -0
  129. audex/lifespan.py +75 -0
  130. audex/service/__init__.py +27 -0
  131. audex/service/decorators.py +73 -0
  132. audex/service/doctor/__init__.py +652 -0
  133. audex/service/doctor/const.py +36 -0
  134. audex/service/doctor/exceptions.py +96 -0
  135. audex/service/doctor/types.py +54 -0
  136. audex/service/export/__init__.py +236 -0
  137. audex/service/export/const.py +17 -0
  138. audex/service/export/exceptions.py +34 -0
  139. audex/service/export/types.py +21 -0
  140. audex/service/injectors/__init__.py +1 -0
  141. audex/service/injectors/container.py +53 -0
  142. audex/service/injectors/doctor.py +34 -0
  143. audex/service/injectors/export.py +27 -0
  144. audex/service/injectors/session.py +49 -0
  145. audex/service/session/__init__.py +754 -0
  146. audex/service/session/const.py +34 -0
  147. audex/service/session/exceptions.py +67 -0
  148. audex/service/session/types.py +91 -0
  149. audex/types.py +39 -0
  150. audex/utils.py +287 -0
  151. audex/valueobj/__init__.py +81 -0
  152. audex/valueobj/common/__init__.py +1 -0
  153. audex/valueobj/common/auth.py +84 -0
  154. audex/valueobj/common/email.py +16 -0
  155. audex/valueobj/common/ops.py +22 -0
  156. audex/valueobj/common/phone.py +84 -0
  157. audex/valueobj/common/version.py +72 -0
  158. audex/valueobj/session.py +19 -0
  159. audex/valueobj/utterance.py +15 -0
  160. audex/view/__init__.py +51 -0
  161. audex/view/container.py +17 -0
  162. audex/view/decorators.py +303 -0
  163. audex/view/pages/__init__.py +1 -0
  164. audex/view/pages/dashboard/__init__.py +286 -0
  165. audex/view/pages/dashboard/wifi.py +407 -0
  166. audex/view/pages/login.py +110 -0
  167. audex/view/pages/recording.py +348 -0
  168. audex/view/pages/register.py +202 -0
  169. audex/view/pages/sessions/__init__.py +196 -0
  170. audex/view/pages/sessions/details.py +224 -0
  171. audex/view/pages/sessions/export.py +443 -0
  172. audex/view/pages/settings.py +374 -0
  173. audex/view/pages/voiceprint/__init__.py +1 -0
  174. audex/view/pages/voiceprint/enroll.py +195 -0
  175. audex/view/pages/voiceprint/update.py +195 -0
  176. audex/view/static/css/dashboard.css +452 -0
  177. audex/view/static/css/glass.css +22 -0
  178. audex/view/static/css/global.css +541 -0
  179. audex/view/static/css/login.css +386 -0
  180. audex/view/static/css/recording.css +439 -0
  181. audex/view/static/css/register.css +293 -0
  182. audex/view/static/css/sessions/styles.css +501 -0
  183. audex/view/static/css/settings.css +186 -0
  184. audex/view/static/css/voiceprint/enroll.css +43 -0
  185. audex/view/static/css/voiceprint/styles.css +209 -0
  186. audex/view/static/css/voiceprint/update.css +44 -0
  187. audex/view/static/images/logo.svg +95 -0
  188. audex/view/static/js/recording.js +42 -0
  189. audex-1.0.7a3.dist-info/METADATA +361 -0
  190. audex-1.0.7a3.dist-info/RECORD +192 -0
  191. audex-1.0.7a3.dist-info/WHEEL +4 -0
  192. audex-1.0.7a3.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,754 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import builtins
5
+ import contextlib
6
+ import datetime
7
+ import typing as t
8
+
9
+ from audex import utils
10
+ from audex.entity.segment import Segment
11
+ from audex.entity.session import Session
12
+ from audex.entity.utterance import Utterance
13
+ from audex.exceptions import NoActiveSessionError
14
+ from audex.filters.generated import segment_filter
15
+ from audex.filters.generated import session_filter
16
+ from audex.filters.generated import utterance_filter
17
+ from audex.filters.generated import vp_filter
18
+ from audex.helper.mixin import LoggingMixin
19
+ from audex.helper.stream import AsyncStream
20
+ from audex.lib.cache import KVCache
21
+ from audex.lib.recorder import AudioFormat
22
+ from audex.lib.recorder import AudioRecorder
23
+ from audex.lib.repos.doctor import DoctorRepository
24
+ from audex.lib.repos.segment import SegmentRepository
25
+ from audex.lib.repos.session import SessionRepository
26
+ from audex.lib.repos.utterance import UtteranceRepository
27
+ from audex.lib.repos.vp import VPRepository
28
+ from audex.lib.session import SessionManager
29
+ from audex.lib.transcription import Transcription
30
+ from audex.lib.transcription import TranscriptionError
31
+ from audex.lib.transcription import events
32
+ from audex.lib.vpr import VPR
33
+ from audex.lib.vpr import VPRError
34
+ from audex.service import BaseService
35
+ from audex.service.decorators import require_auth
36
+ from audex.service.session.const import ErrorMessages
37
+ from audex.service.session.exceptions import InternalSessionServiceError
38
+ from audex.service.session.exceptions import RecordingError
39
+ from audex.service.session.exceptions import SessionNotFoundError
40
+ from audex.service.session.exceptions import SessionServiceError
41
+ from audex.service.session.types import CreateSessionCommand
42
+ from audex.service.session.types import Delta
43
+ from audex.service.session.types import Done
44
+ from audex.service.session.types import SessionStats
45
+ from audex.service.session.types import Start
46
+ from audex.service.session.types import UpdateSessionCommand
47
+ from audex.types import AbstractSession
48
+ from audex.valueobj.utterance import Speaker
49
+
50
+
51
+ class SessionServiceConfig(t.NamedTuple):
52
+ """SessionService configuration."""
53
+
54
+ audio_key_prefix: str = "audio"
55
+ segment_buffer_ms: int = 1000
56
+ sr: int = 16000
57
+ vpr_sr: int = 16000
58
+ vpr_threshold: float = 0.6
59
+
60
+
61
+ class SessionService(BaseService):
62
+ """Service for managing recording sessions."""
63
+
64
+ __logtag__ = "audex.service.session"
65
+
66
+ def __init__(
67
+ self,
68
+ session_manager: SessionManager,
69
+ cache: KVCache,
70
+ config: SessionServiceConfig,
71
+ doctor_repo: DoctorRepository,
72
+ session_repo: SessionRepository,
73
+ segment_repo: SegmentRepository,
74
+ utterance_repo: UtteranceRepository,
75
+ vp_repo: VPRepository,
76
+ vpr: VPR,
77
+ transcription: Transcription,
78
+ recorder: AudioRecorder,
79
+ ):
80
+ super().__init__(session_manager=session_manager, cache=cache, doctor_repo=doctor_repo)
81
+ self.config = config
82
+ self.session_repo = session_repo
83
+ self.segment_repo = segment_repo
84
+ self.utterance_repo = utterance_repo
85
+ self.vp_repo = vp_repo
86
+ self.vpr = vpr
87
+ self.transcription = transcription
88
+ self.recorder = recorder
89
+
90
+ @require_auth
91
+ async def create(self, command: CreateSessionCommand) -> Session:
92
+ """Create a new recording session."""
93
+ try:
94
+ session = Session(
95
+ doctor_id=command.doctor_id,
96
+ patient_name=command.patient_name,
97
+ clinic_number=command.clinic_number,
98
+ medical_record_number=command.medical_record_number,
99
+ diagnosis=command.diagnosis,
100
+ notes=command.notes,
101
+ )
102
+
103
+ uid = await self.session_repo.create(session)
104
+ self.logger.info(f"Created session {uid} for doctor {command.doctor_id}")
105
+ return session
106
+
107
+ except SessionServiceError:
108
+ raise
109
+ except Exception as e:
110
+ self.logger.error(f"Failed to create session: {e}")
111
+ raise InternalSessionServiceError(ErrorMessages.SESSION_CREATE_FAILED) from e
112
+
113
+ @require_auth
114
+ async def get(self, session_id: str) -> Session | None:
115
+ """Get session by ID."""
116
+ try:
117
+ return await self.session_repo.read(session_id)
118
+ except Exception as e:
119
+ self.logger.error(f"Failed to get session {session_id}: {e}")
120
+ raise InternalSessionServiceError() from e
121
+
122
+ @require_auth
123
+ async def delete(self, session_id: str) -> None:
124
+ """Delete a session and all associated data."""
125
+ try:
126
+ deleted = await self.session_repo.delete(session_id)
127
+ if not deleted:
128
+ raise SessionNotFoundError(
129
+ ErrorMessages.SESSION_NOT_FOUND,
130
+ session_id=session_id,
131
+ )
132
+
133
+ f = utterance_filter().session_id.eq(session_id)
134
+ await self.utterance_repo.delete_many(f.build())
135
+
136
+ self.logger.info(f"Deleted session {session_id} and associated data")
137
+
138
+ except SessionNotFoundError:
139
+ raise
140
+ except Exception as e:
141
+ self.logger.error(f"Failed to delete session {session_id}: {e}")
142
+ raise InternalSessionServiceError(ErrorMessages.SESSION_DELETE_FAILED) from e
143
+
144
+ @require_auth
145
+ async def list(
146
+ self,
147
+ doctor_id: str,
148
+ page_index: int = 0,
149
+ page_size: int = 20,
150
+ ) -> builtins.list[Session]:
151
+ """List sessions for a doctor."""
152
+ try:
153
+ f = session_filter().doctor_id.eq(doctor_id).created_at.desc()
154
+ return await self.session_repo.list(
155
+ f.build(),
156
+ page_index=page_index,
157
+ page_size=page_size,
158
+ )
159
+ except Exception as e:
160
+ self.logger.error(f"Failed to list sessions for doctor {doctor_id}: {e}")
161
+ raise InternalSessionServiceError() from e
162
+
163
+ @require_auth
164
+ async def update(self, command: UpdateSessionCommand) -> Session:
165
+ """Update session details."""
166
+ try:
167
+ session = await self.session_repo.read(command.session_id)
168
+ if session is None:
169
+ raise SessionNotFoundError(
170
+ ErrorMessages.SESSION_NOT_FOUND,
171
+ session_id=command.session_id,
172
+ )
173
+
174
+ if command.patient_name is not None:
175
+ session.patient_name = command.patient_name
176
+ if command.clinic_number is not None:
177
+ session.clinic_number = command.clinic_number
178
+ if command.medical_record_number is not None:
179
+ session.medical_record_number = command.medical_record_number
180
+ if command.diagnosis is not None:
181
+ session.diagnosis = command.diagnosis
182
+ if command.notes is not None:
183
+ session.notes = command.notes
184
+
185
+ await self.session_repo.update(session)
186
+
187
+ self.logger.info(f"Updated session {command.session_id}")
188
+ return session
189
+
190
+ except SessionNotFoundError:
191
+ raise
192
+ except Exception as e:
193
+ self.logger.error(f"Failed to update session {command.session_id}: {e}")
194
+ raise InternalSessionServiceError(ErrorMessages.SESSION_UPDATE_FAILED) from e
195
+
196
+ @require_auth
197
+ async def stats(self) -> SessionStats:
198
+ """Get session statistics for the current doctor."""
199
+ try:
200
+ doctor_id = t.cast(str, await self.session_manager.get_doctor_id())
201
+
202
+ # Check cache first
203
+ cache_key = self.cache.key_builder.build("session_stats", doctor_id)
204
+ if await self.cache.contains(cache_key):
205
+ self.logger.debug(f"Session stats for doctor {doctor_id} fetched from cache")
206
+ return await self.cache.get(cache_key) # type: ignore
207
+
208
+ # Calculate total sessions
209
+ f_total = session_filter().doctor_id.eq(doctor_id)
210
+ total_sessions = await self.session_repo.count(f_total.build())
211
+
212
+ # Fetch all session IDs in pages of 100
213
+ session_ids = []
214
+ for page in range((total_sessions + 99) // 100):
215
+ sessions = await self.session_repo.list(
216
+ f_total.build(),
217
+ page_index=page,
218
+ page_size=100,
219
+ )
220
+ session_ids.extend([s.id for s in sessions])
221
+
222
+ # Calculate total duration across all sessions
223
+ total_duration_ms = 0
224
+ for sid in session_ids:
225
+ total_duration_ms += await self.segment_repo.sum_duration_by_session(sid)
226
+ total_duration_in_minutes = total_duration_ms // 60000
227
+
228
+ # Calculate sessions in the current month
229
+ now = utils.utcnow()
230
+ month_start = datetime.datetime(now.year, now.month, 1, tzinfo=datetime.UTC)
231
+ f_month = session_filter().doctor_id.eq(doctor_id).created_at.gte(month_start)
232
+ sessions_count_in_this_month = await self.session_repo.count(f_month.build())
233
+
234
+ stats = SessionStats(
235
+ sessions_count_in_this_month=sessions_count_in_this_month,
236
+ total_sessions_count=total_sessions,
237
+ total_duration_in_minutes=total_duration_in_minutes,
238
+ )
239
+
240
+ # Cache the stats
241
+ await self.cache.setx(cache_key, stats, ttl=60) # Cache for 1 minutes
242
+ self.logger.info(f"Calculated session stats for doctor {doctor_id}")
243
+ return stats
244
+
245
+ except Exception as e:
246
+ self.logger.error(f"Failed to get session stats: {e}")
247
+ raise InternalSessionServiceError() from e
248
+
249
+ @require_auth
250
+ async def complete(self, session_id: str) -> Session:
251
+ """Mark session as completed."""
252
+ try:
253
+ session = await self.session_repo.read(session_id)
254
+ if session is None:
255
+ raise SessionNotFoundError(
256
+ ErrorMessages.SESSION_NOT_FOUND,
257
+ session_id=session_id,
258
+ )
259
+
260
+ session.complete()
261
+ await self.session_repo.update(session)
262
+
263
+ self.logger.info(f"Completed session {session_id}")
264
+ return session
265
+
266
+ except SessionNotFoundError:
267
+ raise
268
+ except Exception as e:
269
+ self.logger.error(f"Failed to complete session {session_id}: {e}")
270
+ raise InternalSessionServiceError(ErrorMessages.SESSION_UPDATE_FAILED) from e
271
+
272
+ @require_auth
273
+ async def cancel(self, session_id: str) -> Session:
274
+ """Cancel a session."""
275
+ try:
276
+ session = await self.session_repo.read(session_id)
277
+ if session is None:
278
+ raise SessionNotFoundError(
279
+ ErrorMessages.SESSION_NOT_FOUND,
280
+ session_id=session_id,
281
+ )
282
+
283
+ session.cancel()
284
+ await self.session_repo.update(session)
285
+
286
+ self.logger.info(f"Cancelled session {session_id}")
287
+ return session
288
+
289
+ except SessionNotFoundError:
290
+ raise
291
+ except Exception as e:
292
+ self.logger.error(f"Failed to cancel session {session_id}: {e}")
293
+ raise InternalSessionServiceError(ErrorMessages.SESSION_UPDATE_FAILED) from e
294
+
295
+ @require_auth
296
+ async def get_utterances(
297
+ self,
298
+ session_id: str,
299
+ page_index: int = 0,
300
+ page_size: int = 100,
301
+ ) -> builtins.list[Utterance]:
302
+ """Get utterances for a session."""
303
+ try:
304
+ f = utterance_filter().session_id.eq(session_id).sequence.asc()
305
+ return await self.utterance_repo.list(
306
+ f.build(),
307
+ page_index=page_index,
308
+ page_size=page_size,
309
+ )
310
+ except Exception as e:
311
+ self.logger.error(f"Failed to get utterances for session {session_id}: {e}")
312
+ raise InternalSessionServiceError() from e
313
+
314
+ @require_auth
315
+ async def session(self, session_id: str) -> SessionContext:
316
+ """Create a session context for recording."""
317
+ try:
318
+ user_session = await self.session_manager.get_session()
319
+ if not user_session:
320
+ raise NoActiveSessionError(ErrorMessages.NO_ACTIVE_SESSION)
321
+
322
+ f = vp_filter().doctor_id.eq(user_session.doctor_id).is_active.eq(True)
323
+ vp = await self.vp_repo.first(f.build())
324
+ if not vp:
325
+ raise SessionServiceError(ErrorMessages.NO_VOICEPRINT_FOUND)
326
+
327
+ session = await self.session_repo.read(session_id)
328
+ if session is None:
329
+ raise SessionNotFoundError(
330
+ ErrorMessages.SESSION_NOT_FOUND,
331
+ session_id=session_id,
332
+ )
333
+
334
+ session.start()
335
+ await self.session_repo.update(session)
336
+
337
+ return SessionContext(
338
+ config=self.config,
339
+ session=session,
340
+ session_repo=self.session_repo,
341
+ segment_repo=self.segment_repo,
342
+ utterance_repo=self.utterance_repo,
343
+ vpr=self.vpr,
344
+ transcription=self.transcription,
345
+ recorder=self.recorder,
346
+ vpr_uid=vp.vpr_uid,
347
+ )
348
+
349
+ except (NoActiveSessionError, SessionNotFoundError, SessionServiceError):
350
+ raise
351
+ except Exception as e:
352
+ self.logger.error(f"Failed to create session context: {e}")
353
+ raise InternalSessionServiceError() from e
354
+
355
+
356
+ class _VPRTask(t.NamedTuple):
357
+ """VPR verification task."""
358
+
359
+ sequence: int
360
+ text: str
361
+ started_at: float # Absolute timestamp from Start event
362
+ ended_at: float # Absolute timestamp from Done event
363
+
364
+
365
+ class _UtteranceData(t.TypedDict):
366
+ started_at: float # Absolute timestamp from Start event
367
+ text: str
368
+ sequence: int | None
369
+
370
+
371
+ class SessionContext(LoggingMixin, AbstractSession):
372
+ """Context for managing an active recording session with async
373
+ VPR."""
374
+
375
+ __logtag__ = "audex.service.session:SessionContext"
376
+
377
+ def __init__(
378
+ self,
379
+ config: SessionServiceConfig,
380
+ session: Session,
381
+ session_repo: SessionRepository,
382
+ segment_repo: SegmentRepository,
383
+ utterance_repo: UtteranceRepository,
384
+ vpr: VPR,
385
+ transcription: Transcription,
386
+ recorder: AudioRecorder,
387
+ vpr_uid: str,
388
+ ):
389
+ super().__init__()
390
+ self.config = config
391
+ self.session = session
392
+ self.session_repo = session_repo
393
+ self.segment_repo = segment_repo
394
+ self.utterance_repo = utterance_repo
395
+ self.vpr = vpr
396
+ self.transcription = transcription
397
+ self.recorder = recorder
398
+ self.vpr_uid = vpr_uid
399
+
400
+ self.transcription_session = transcription.session(fmt="pcm", sample_rate=self.config.sr)
401
+ self._utterance_sequence = 0
402
+
403
+ # VPR async processing
404
+ self._vpr_queue: asyncio.Queue[_VPRTask] = asyncio.Queue()
405
+ self._vpr_results: dict[int, bool] = {} # sequence -> is_doctor
406
+ self._vpr_worker_task: asyncio.Task[None] | None = None
407
+
408
+ # Audio streaming
409
+ self._audio_sender_task: asyncio.Task[None] | None = None
410
+
411
+ # Utterance tracking (utterance_id -> data)
412
+ self._utterances: dict[str, _UtteranceData] = {}
413
+
414
+ async def start(self) -> None:
415
+ """Start the recording session."""
416
+ try:
417
+ # Start transcription
418
+ try:
419
+ await self.transcription_session.start()
420
+ except TranscriptionError as e:
421
+ self.logger.error(f"Failed to start transcription: {e}")
422
+ raise RecordingError(ErrorMessages.TRANSCRIPTION_START_FAILED) from e
423
+
424
+ # Start audio recording
425
+ try:
426
+ await self.recorder.start(self.config.audio_key_prefix, self.session.id)
427
+ except Exception as e:
428
+ self.logger.error(f"Failed to start recording: {e}")
429
+ with contextlib.suppress(BaseException):
430
+ await self.transcription_session.close()
431
+ raise RecordingError(ErrorMessages.RECORDING_START_FAILED) from e
432
+
433
+ # Get current utterance sequence
434
+ f = utterance_filter().session_id.eq(self.session.id)
435
+ last_utterance = await self.utterance_repo.first(f.sequence.desc().build())
436
+ self._utterance_sequence = 0 if last_utterance is None else last_utterance.sequence
437
+
438
+ # Start VPR worker
439
+ self._vpr_worker_task = asyncio.create_task(self._vpr_worker())
440
+
441
+ # Start audio sender
442
+ self._audio_sender_task = asyncio.create_task(self._audio_sender())
443
+
444
+ self.logger.info(f"Started session context for {self.session.id}")
445
+
446
+ except RecordingError:
447
+ raise
448
+ except Exception as e:
449
+ self.logger.error(f"Failed to start session: {e}")
450
+ raise InternalSessionServiceError(ErrorMessages.RECORDING_START_FAILED) from e
451
+
452
+ async def _audio_sender(self) -> None:
453
+ """Continuously send audio frames to transcription."""
454
+ try:
455
+ async for frame in self.recorder.stream(
456
+ chunk_size=self.config.sr // 10, # 100ms chunks
457
+ format=AudioFormat.PCM,
458
+ rate=self.config.sr,
459
+ channels=1,
460
+ ):
461
+ await self.transcription_session.send(frame)
462
+ except asyncio.CancelledError:
463
+ self.logger.debug("Audio sender cancelled")
464
+ except Exception as e:
465
+ self.logger.error(f"Audio sender error: {e}")
466
+
467
+ async def _vpr_worker(self) -> None:
468
+ """Background worker for VPR verification."""
469
+ try:
470
+ while True:
471
+ task = await self._vpr_queue.get()
472
+
473
+ try:
474
+ # Calculate audio segment time range with buffer
475
+ buffer_seconds = self.config.segment_buffer_ms / 1000.0
476
+ utterance_start = datetime.datetime.fromtimestamp(
477
+ task.started_at - buffer_seconds,
478
+ tz=datetime.UTC,
479
+ )
480
+ utterance_end = datetime.datetime.fromtimestamp(
481
+ task.ended_at + buffer_seconds,
482
+ tz=datetime.UTC,
483
+ )
484
+
485
+ # Extract audio segment
486
+ audio_segment = await self.recorder.segment(
487
+ started_at=utterance_start,
488
+ ended_at=utterance_end,
489
+ rate=self.config.vpr_sr,
490
+ channels=1,
491
+ format=AudioFormat.MP3,
492
+ )
493
+
494
+ self.logger.debug(
495
+ f"VPR seq={task.sequence}: extracted audio "
496
+ f"from {utterance_start.isoformat()} to {utterance_end.isoformat()} "
497
+ f"(duration={(task.ended_at - task.started_at):.2f}s)"
498
+ )
499
+
500
+ # VPR verification
501
+ try:
502
+ vpr_score = await self.vpr.verify(
503
+ uid=self.vpr_uid,
504
+ data=audio_segment,
505
+ sr=self.config.vpr_sr,
506
+ )
507
+ is_doctor = vpr_score >= self.config.vpr_threshold
508
+
509
+ self.logger.debug(
510
+ f"VPR seq={task.sequence}: score={vpr_score:.3f}, "
511
+ f"is_doctor={is_doctor}, text={task.text[:30]}..."
512
+ )
513
+ except VPRError as e:
514
+ self.logger.warning(f"VPR verification failed: {e}")
515
+ is_doctor = False
516
+
517
+ # Store result
518
+ self._vpr_results[task.sequence] = is_doctor
519
+
520
+ # Store utterance to database
521
+ await self._store_utterance(
522
+ sequence=task.sequence,
523
+ text=task.text,
524
+ started_at=task.started_at,
525
+ ended_at=task.ended_at,
526
+ is_doctor=is_doctor,
527
+ )
528
+
529
+ except Exception as e:
530
+ self.logger.error(f"VPR worker error for seq={task.sequence}: {e}")
531
+ self._vpr_results[task.sequence] = False
532
+
533
+ finally:
534
+ self._vpr_queue.task_done()
535
+
536
+ except asyncio.CancelledError:
537
+ self.logger.debug("VPR worker cancelled")
538
+
539
+ async def _store_utterance(
540
+ self,
541
+ sequence: int,
542
+ text: str,
543
+ started_at: float,
544
+ ended_at: float,
545
+ is_doctor: bool,
546
+ ) -> None:
547
+ """Store utterance to database."""
548
+ try:
549
+ f = segment_filter().session_id.eq(self.session.id)
550
+ current_segment = await self.segment_repo.first(f.sequence.desc().build())
551
+ segment_id = current_segment.id if current_segment else "unknown"
552
+
553
+ speaker = Speaker.DOCTOR if is_doctor else Speaker.PATIENT
554
+
555
+ utterance = Utterance(
556
+ session_id=self.session.id,
557
+ segment_id=segment_id,
558
+ sequence=sequence,
559
+ speaker=speaker,
560
+ text=text,
561
+ confidence=None,
562
+ start_time_ms=int(started_at * 1000),
563
+ end_time_ms=int(ended_at * 1000),
564
+ timestamp=utils.utcnow(),
565
+ )
566
+
567
+ await self.utterance_repo.create(utterance)
568
+
569
+ self.logger.debug(
570
+ f"Stored utterance {utterance.id} (seq={sequence}, speaker={speaker.value})"
571
+ )
572
+
573
+ except Exception as e:
574
+ self.logger.error(f"Failed to store utterance seq={sequence}: {e}")
575
+
576
+ async def close(self) -> None:
577
+ """Close the session context."""
578
+ try:
579
+ # Cancel audio sender
580
+ if self._audio_sender_task:
581
+ self._audio_sender_task.cancel()
582
+ with contextlib.suppress(asyncio.CancelledError):
583
+ await self._audio_sender_task
584
+
585
+ # Finish transcription
586
+ try:
587
+ await self.transcription_session.finish()
588
+ await self.transcription_session.close()
589
+ except Exception as e:
590
+ self.logger.warning(f"Failed to close transcription: {e}")
591
+
592
+ # Wait for VPR queue to finish
593
+ if self._vpr_queue:
594
+ await self._vpr_queue.join()
595
+
596
+ # Cancel VPR worker
597
+ if self._vpr_worker_task:
598
+ self._vpr_worker_task.cancel()
599
+ with contextlib.suppress(asyncio.CancelledError):
600
+ await self._vpr_worker_task
601
+
602
+ # Stop recorder
603
+ try:
604
+ segment_data = await self.recorder.stop()
605
+ except Exception as e:
606
+ self.logger.error(f"Failed to stop recording: {e}")
607
+ raise RecordingError(ErrorMessages.RECORDING_STOP_FAILED) from e
608
+
609
+ # Store segment info
610
+ f = segment_filter().session_id.eq(self.session.id)
611
+ last_segment = await self.segment_repo.first(f.sequence.desc().build())
612
+ seq = 1 if last_segment is None else last_segment.sequence + 1
613
+
614
+ segment = Segment(
615
+ session_id=self.session.id,
616
+ audio_key=segment_data.key,
617
+ duration_ms=segment_data.duration_ms,
618
+ started_at=segment_data.started_at,
619
+ ended_at=segment_data.ended_at,
620
+ sequence=seq,
621
+ )
622
+
623
+ try:
624
+ seg_id = await self.segment_repo.create(segment)
625
+ self.logger.info(
626
+ f"Stored segment {seg_id} (seq={seq}) for session {self.session.id}, "
627
+ f"duration={segment_data.duration_ms}ms"
628
+ )
629
+ except Exception as e:
630
+ self.logger.error(f"Failed to store segment: {e}")
631
+
632
+ # Clear audio frames
633
+ self.recorder.clear_frames()
634
+
635
+ # Clean up utterance tracking
636
+ self._utterances.clear()
637
+ self._vpr_results.clear()
638
+
639
+ except RecordingError:
640
+ raise
641
+ except Exception as e:
642
+ self.logger.error(f"Failed to close session: {e}")
643
+ raise InternalSessionServiceError() from e
644
+
645
+ def receive(self) -> AsyncStream[Start | Delta | Done]:
646
+ """Receive transcription events."""
647
+ return AsyncStream(self._receive_iter())
648
+
649
+ async def _receive_iter(self) -> t.AsyncIterator[Start | Delta | Done]:
650
+ """Internal iterator for receiving transcription events."""
651
+ async for event in self.transcription_session.receive():
652
+ if isinstance(event, events.Start):
653
+ # Track new utterance with absolute start time
654
+ self._utterances[event.utterance_id] = {
655
+ "started_at": event.started_at,
656
+ "text": "",
657
+ "sequence": None,
658
+ }
659
+
660
+ yield Start(session_id=self.session.id)
661
+
662
+ elif isinstance(event, events.Delta):
663
+ # Update utterance tracking
664
+ if event.utterance_id not in self._utterances:
665
+ self.logger.warning(
666
+ f"Received Delta for unknown utterance: {event.utterance_id}"
667
+ )
668
+ continue
669
+
670
+ utt = self._utterances[event.utterance_id]
671
+
672
+ if not event.interim:
673
+ # Final delta - update text
674
+ utt["text"] = event.text
675
+
676
+ # Assign sequence number
677
+ self._utterance_sequence += 1
678
+ utt["sequence"] = self._utterance_sequence
679
+
680
+ # Calculate absolute timestamps (only for display, not for VPR)
681
+ started_at = utt["started_at"]
682
+ from_at = started_at + event.offset_begin
683
+ to_at = (
684
+ started_at + event.offset_end
685
+ if event.offset_end is not None
686
+ else started_at + event.offset_begin + 1.0
687
+ )
688
+
689
+ # Yield Delta with sequence
690
+ yield Delta(
691
+ session_id=self.session.id,
692
+ from_at=from_at,
693
+ to_at=to_at,
694
+ text=event.text,
695
+ interim=False,
696
+ sequence=self._utterance_sequence,
697
+ )
698
+ else:
699
+ # Interim result - calculate display timestamps
700
+ started_at = utt["started_at"]
701
+ from_at = started_at + event.offset_begin
702
+ to_at = (
703
+ started_at + event.offset_end
704
+ if event.offset_end is not None
705
+ else started_at + event.offset_begin + 1.0
706
+ )
707
+
708
+ yield Delta(
709
+ session_id=self.session.id,
710
+ from_at=from_at,
711
+ to_at=to_at,
712
+ text=event.text,
713
+ interim=True,
714
+ sequence=None,
715
+ )
716
+
717
+ elif isinstance(event, events.Done):
718
+ # Get utterance data
719
+ if event.utterance_id not in self._utterances:
720
+ self.logger.warning(
721
+ f"Received Done for unknown utterance: {event.utterance_id}"
722
+ )
723
+ continue
724
+
725
+ utt = self._utterances[event.utterance_id]
726
+ sequence = utt.get("sequence")
727
+ text = utt.get("text", "")
728
+
729
+ # Only queue for VPR if we have valid data
730
+ if sequence is not None and text:
731
+ # Queue VPR task using absolute timestamps from Start and Done events
732
+ await self._vpr_queue.put(
733
+ _VPRTask(
734
+ sequence=sequence,
735
+ text=text,
736
+ started_at=utt["started_at"], # From Start event
737
+ ended_at=event.ended_at, # From Done event
738
+ )
739
+ )
740
+
741
+ # Check VPR result (may not be ready yet)
742
+ is_doctor = self._vpr_results.get(sequence, False) if sequence else False
743
+
744
+ yield Done(
745
+ session_id=self.session.id,
746
+ is_doctor=is_doctor,
747
+ full_text=text,
748
+ sequence=sequence or 0,
749
+ )
750
+
751
+ # Clean up to prevent memory leak
752
+ del self._utterances[event.utterance_id]
753
+ if sequence in self._vpr_results:
754
+ del self._vpr_results[sequence]