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.
- audex/__init__.py +9 -0
- audex/__main__.py +7 -0
- audex/cli/__init__.py +189 -0
- audex/cli/apis/__init__.py +12 -0
- audex/cli/apis/init/__init__.py +34 -0
- audex/cli/apis/init/gencfg.py +130 -0
- audex/cli/apis/init/setup.py +330 -0
- audex/cli/apis/init/vprgroup.py +125 -0
- audex/cli/apis/serve.py +141 -0
- audex/cli/args.py +356 -0
- audex/cli/exceptions.py +44 -0
- audex/cli/helper/__init__.py +0 -0
- audex/cli/helper/ansi.py +193 -0
- audex/cli/helper/display.py +288 -0
- audex/config/__init__.py +64 -0
- audex/config/core/__init__.py +30 -0
- audex/config/core/app.py +29 -0
- audex/config/core/audio.py +45 -0
- audex/config/core/logging.py +163 -0
- audex/config/core/session.py +11 -0
- audex/config/helper/__init__.py +1 -0
- audex/config/helper/client/__init__.py +1 -0
- audex/config/helper/client/http.py +28 -0
- audex/config/helper/client/websocket.py +21 -0
- audex/config/helper/provider/__init__.py +1 -0
- audex/config/helper/provider/dashscope.py +13 -0
- audex/config/helper/provider/unisound.py +18 -0
- audex/config/helper/provider/xfyun.py +23 -0
- audex/config/infrastructure/__init__.py +31 -0
- audex/config/infrastructure/cache.py +51 -0
- audex/config/infrastructure/database.py +48 -0
- audex/config/infrastructure/recorder.py +32 -0
- audex/config/infrastructure/store.py +19 -0
- audex/config/provider/__init__.py +18 -0
- audex/config/provider/transcription.py +109 -0
- audex/config/provider/vpr.py +99 -0
- audex/container.py +40 -0
- audex/entity/__init__.py +468 -0
- audex/entity/doctor.py +109 -0
- audex/entity/doctor.pyi +51 -0
- audex/entity/fields.py +401 -0
- audex/entity/segment.py +115 -0
- audex/entity/segment.pyi +38 -0
- audex/entity/session.py +133 -0
- audex/entity/session.pyi +47 -0
- audex/entity/utterance.py +142 -0
- audex/entity/utterance.pyi +48 -0
- audex/entity/vp.py +68 -0
- audex/entity/vp.pyi +35 -0
- audex/exceptions.py +157 -0
- audex/filters/__init__.py +692 -0
- audex/filters/generated/__init__.py +21 -0
- audex/filters/generated/doctor.py +987 -0
- audex/filters/generated/segment.py +723 -0
- audex/filters/generated/session.py +978 -0
- audex/filters/generated/utterance.py +939 -0
- audex/filters/generated/vp.py +815 -0
- audex/helper/__init__.py +1 -0
- audex/helper/hash.py +33 -0
- audex/helper/mixin.py +65 -0
- audex/helper/net.py +19 -0
- audex/helper/settings/__init__.py +830 -0
- audex/helper/settings/fields.py +317 -0
- audex/helper/stream.py +153 -0
- audex/injectors/__init__.py +1 -0
- audex/injectors/config.py +12 -0
- audex/injectors/lifespan.py +7 -0
- audex/lib/__init__.py +1 -0
- audex/lib/cache/__init__.py +383 -0
- audex/lib/cache/inmemory.py +513 -0
- audex/lib/database/__init__.py +83 -0
- audex/lib/database/sqlite.py +406 -0
- audex/lib/exporter.py +189 -0
- audex/lib/injectors/__init__.py +1 -0
- audex/lib/injectors/cache.py +25 -0
- audex/lib/injectors/container.py +47 -0
- audex/lib/injectors/exporter.py +26 -0
- audex/lib/injectors/recorder.py +33 -0
- audex/lib/injectors/server.py +17 -0
- audex/lib/injectors/session.py +18 -0
- audex/lib/injectors/sqlite.py +24 -0
- audex/lib/injectors/store.py +13 -0
- audex/lib/injectors/transcription.py +42 -0
- audex/lib/injectors/usb.py +12 -0
- audex/lib/injectors/vpr.py +65 -0
- audex/lib/injectors/wifi.py +7 -0
- audex/lib/recorder.py +844 -0
- audex/lib/repos/__init__.py +149 -0
- audex/lib/repos/container.py +23 -0
- audex/lib/repos/database/__init__.py +1 -0
- audex/lib/repos/database/sqlite.py +672 -0
- audex/lib/repos/decorators.py +74 -0
- audex/lib/repos/doctor.py +286 -0
- audex/lib/repos/segment.py +302 -0
- audex/lib/repos/session.py +285 -0
- audex/lib/repos/tables/__init__.py +70 -0
- audex/lib/repos/tables/doctor.py +137 -0
- audex/lib/repos/tables/segment.py +113 -0
- audex/lib/repos/tables/session.py +140 -0
- audex/lib/repos/tables/utterance.py +131 -0
- audex/lib/repos/tables/vp.py +102 -0
- audex/lib/repos/utterance.py +288 -0
- audex/lib/repos/vp.py +286 -0
- audex/lib/restful.py +251 -0
- audex/lib/server/__init__.py +97 -0
- audex/lib/server/auth.py +98 -0
- audex/lib/server/handlers.py +248 -0
- audex/lib/server/templates/index.html.j2 +226 -0
- audex/lib/server/templates/login.html.j2 +111 -0
- audex/lib/server/templates/static/script.js +68 -0
- audex/lib/server/templates/static/style.css +579 -0
- audex/lib/server/types.py +123 -0
- audex/lib/session.py +503 -0
- audex/lib/store/__init__.py +238 -0
- audex/lib/store/localfile.py +411 -0
- audex/lib/transcription/__init__.py +33 -0
- audex/lib/transcription/dashscope.py +525 -0
- audex/lib/transcription/events.py +62 -0
- audex/lib/usb.py +554 -0
- audex/lib/vpr/__init__.py +38 -0
- audex/lib/vpr/unisound/__init__.py +185 -0
- audex/lib/vpr/unisound/types.py +469 -0
- audex/lib/vpr/xfyun/__init__.py +483 -0
- audex/lib/vpr/xfyun/types.py +679 -0
- audex/lib/websocket/__init__.py +8 -0
- audex/lib/websocket/connection.py +485 -0
- audex/lib/websocket/pool.py +991 -0
- audex/lib/wifi.py +1146 -0
- audex/lifespan.py +75 -0
- audex/service/__init__.py +27 -0
- audex/service/decorators.py +73 -0
- audex/service/doctor/__init__.py +652 -0
- audex/service/doctor/const.py +36 -0
- audex/service/doctor/exceptions.py +96 -0
- audex/service/doctor/types.py +54 -0
- audex/service/export/__init__.py +236 -0
- audex/service/export/const.py +17 -0
- audex/service/export/exceptions.py +34 -0
- audex/service/export/types.py +21 -0
- audex/service/injectors/__init__.py +1 -0
- audex/service/injectors/container.py +53 -0
- audex/service/injectors/doctor.py +34 -0
- audex/service/injectors/export.py +27 -0
- audex/service/injectors/session.py +49 -0
- audex/service/session/__init__.py +754 -0
- audex/service/session/const.py +34 -0
- audex/service/session/exceptions.py +67 -0
- audex/service/session/types.py +91 -0
- audex/types.py +39 -0
- audex/utils.py +287 -0
- audex/valueobj/__init__.py +81 -0
- audex/valueobj/common/__init__.py +1 -0
- audex/valueobj/common/auth.py +84 -0
- audex/valueobj/common/email.py +16 -0
- audex/valueobj/common/ops.py +22 -0
- audex/valueobj/common/phone.py +84 -0
- audex/valueobj/common/version.py +72 -0
- audex/valueobj/session.py +19 -0
- audex/valueobj/utterance.py +15 -0
- audex/view/__init__.py +51 -0
- audex/view/container.py +17 -0
- audex/view/decorators.py +303 -0
- audex/view/pages/__init__.py +1 -0
- audex/view/pages/dashboard/__init__.py +286 -0
- audex/view/pages/dashboard/wifi.py +407 -0
- audex/view/pages/login.py +110 -0
- audex/view/pages/recording.py +348 -0
- audex/view/pages/register.py +202 -0
- audex/view/pages/sessions/__init__.py +196 -0
- audex/view/pages/sessions/details.py +224 -0
- audex/view/pages/sessions/export.py +443 -0
- audex/view/pages/settings.py +374 -0
- audex/view/pages/voiceprint/__init__.py +1 -0
- audex/view/pages/voiceprint/enroll.py +195 -0
- audex/view/pages/voiceprint/update.py +195 -0
- audex/view/static/css/dashboard.css +452 -0
- audex/view/static/css/glass.css +22 -0
- audex/view/static/css/global.css +541 -0
- audex/view/static/css/login.css +386 -0
- audex/view/static/css/recording.css +439 -0
- audex/view/static/css/register.css +293 -0
- audex/view/static/css/sessions/styles.css +501 -0
- audex/view/static/css/settings.css +186 -0
- audex/view/static/css/voiceprint/enroll.css +43 -0
- audex/view/static/css/voiceprint/styles.css +209 -0
- audex/view/static/css/voiceprint/update.css +44 -0
- audex/view/static/images/logo.svg +95 -0
- audex/view/static/js/recording.js +42 -0
- audex-1.0.7a3.dist-info/METADATA +361 -0
- audex-1.0.7a3.dist-info/RECORD +192 -0
- audex-1.0.7a3.dist-info/WHEEL +4 -0
- 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]
|