charness 3.2.0 → 3.3.1

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 (313) hide show
  1. package/.env.example +3 -6
  2. package/DEVELOPMENT.md +15 -0
  3. package/README.md +111 -38
  4. package/SETUP.md +121 -16
  5. package/dist/adapters/adapterEventRouting.d.ts +2 -1
  6. package/dist/adapters/adapterEventRouting.d.ts.map +1 -1
  7. package/dist/adapters/adapterEventRouting.js +3 -0
  8. package/dist/adapters/adapterEventRouting.js.map +1 -1
  9. package/dist/adapters/adapterNames.d.ts +13 -0
  10. package/dist/adapters/adapterNames.d.ts.map +1 -1
  11. package/dist/adapters/adapterNames.js +16 -0
  12. package/dist/adapters/adapterNames.js.map +1 -1
  13. package/dist/adapters/backendUnavailableError.d.ts +11 -0
  14. package/dist/adapters/backendUnavailableError.d.ts.map +1 -0
  15. package/dist/adapters/backendUnavailableError.js +19 -0
  16. package/dist/adapters/backendUnavailableError.js.map +1 -0
  17. package/dist/adapters/claudeCliAdapter.d.ts +29 -6
  18. package/dist/adapters/claudeCliAdapter.d.ts.map +1 -1
  19. package/dist/adapters/claudeCliAdapter.js +70 -16
  20. package/dist/adapters/claudeCliAdapter.js.map +1 -1
  21. package/dist/adapters/claudeJsonStreamAdapter.d.ts +36 -3
  22. package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -1
  23. package/dist/adapters/claudeJsonStreamAdapter.js +93 -30
  24. package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -1
  25. package/dist/adapters/createAdapter.d.ts +3 -14
  26. package/dist/adapters/createAdapter.d.ts.map +1 -1
  27. package/dist/adapters/createAdapter.js +9 -19
  28. package/dist/adapters/createAdapter.js.map +1 -1
  29. package/dist/adapters/openCodeAdapter.d.ts +140 -12
  30. package/dist/adapters/openCodeAdapter.d.ts.map +1 -1
  31. package/dist/adapters/openCodeAdapter.js +385 -119
  32. package/dist/adapters/openCodeAdapter.js.map +1 -1
  33. package/dist/apiErrorRetry.d.ts +70 -6
  34. package/dist/apiErrorRetry.d.ts.map +1 -1
  35. package/dist/apiErrorRetry.js +312 -34
  36. package/dist/apiErrorRetry.js.map +1 -1
  37. package/dist/apiRetryKick.d.ts +26 -1
  38. package/dist/apiRetryKick.d.ts.map +1 -1
  39. package/dist/apiRetryKick.js +41 -2
  40. package/dist/apiRetryKick.js.map +1 -1
  41. package/dist/bot.d.ts +2 -1
  42. package/dist/bot.d.ts.map +1 -1
  43. package/dist/bot.js +736 -184
  44. package/dist/bot.js.map +1 -1
  45. package/dist/cli/claudeProfiles.d.ts +1 -1
  46. package/dist/cli/claudeProfiles.js +1 -1
  47. package/dist/cli/hot.d.ts +7 -1
  48. package/dist/cli/hot.d.ts.map +1 -1
  49. package/dist/cli/hot.js +29 -3
  50. package/dist/cli/hot.js.map +1 -1
  51. package/dist/configDirFile.d.ts +73 -0
  52. package/dist/configDirFile.d.ts.map +1 -0
  53. package/dist/configDirFile.js +216 -0
  54. package/dist/configDirFile.js.map +1 -0
  55. package/dist/connectors/telegram/commands/autoContinueLimits.d.ts +2 -2
  56. package/dist/connectors/telegram/commands/autoContinueLimits.d.ts.map +1 -1
  57. package/dist/connectors/telegram/commands/autoContinueLimits.js +4 -4
  58. package/dist/connectors/telegram/commands/autoContinueLimits.js.map +1 -1
  59. package/dist/connectors/telegram/commands/backendSwitch.d.ts +25 -0
  60. package/dist/connectors/telegram/commands/backendSwitch.d.ts.map +1 -0
  61. package/dist/connectors/telegram/commands/backendSwitch.js +104 -0
  62. package/dist/connectors/telegram/commands/backendSwitch.js.map +1 -0
  63. package/dist/connectors/telegram/commands/callbackMessage.d.ts +12 -0
  64. package/dist/connectors/telegram/commands/callbackMessage.d.ts.map +1 -1
  65. package/dist/connectors/telegram/commands/callbackMessage.js +24 -0
  66. package/dist/connectors/telegram/commands/callbackMessage.js.map +1 -1
  67. package/dist/connectors/telegram/commands/callbackToast.d.ts +36 -0
  68. package/dist/connectors/telegram/commands/callbackToast.d.ts.map +1 -0
  69. package/dist/connectors/telegram/commands/callbackToast.js +83 -0
  70. package/dist/connectors/telegram/commands/callbackToast.js.map +1 -0
  71. package/dist/connectors/telegram/commands/claudeAccounts.d.ts +18 -0
  72. package/dist/connectors/telegram/commands/claudeAccounts.d.ts.map +1 -1
  73. package/dist/connectors/telegram/commands/claudeAccounts.js +220 -66
  74. package/dist/connectors/telegram/commands/claudeAccounts.js.map +1 -1
  75. package/dist/connectors/telegram/commands/displayModes.d.ts.map +1 -1
  76. package/dist/connectors/telegram/commands/displayModes.js +4 -2
  77. package/dist/connectors/telegram/commands/displayModes.js.map +1 -1
  78. package/dist/connectors/telegram/commands/fallbackChain.d.ts +5 -8
  79. package/dist/connectors/telegram/commands/fallbackChain.d.ts.map +1 -1
  80. package/dist/connectors/telegram/commands/fallbackChain.js +183 -193
  81. package/dist/connectors/telegram/commands/fallbackChain.js.map +1 -1
  82. package/dist/connectors/telegram/commands/limitsBoard.d.ts +40 -0
  83. package/dist/connectors/telegram/commands/limitsBoard.d.ts.map +1 -0
  84. package/dist/connectors/telegram/commands/limitsBoard.js +281 -0
  85. package/dist/connectors/telegram/commands/limitsBoard.js.map +1 -0
  86. package/dist/connectors/telegram/commands/modelPage.d.ts +130 -0
  87. package/dist/connectors/telegram/commands/modelPage.d.ts.map +1 -0
  88. package/dist/connectors/telegram/commands/modelPage.js +120 -0
  89. package/dist/connectors/telegram/commands/modelPage.js.map +1 -0
  90. package/dist/connectors/telegram/commands/modelProviders.d.ts +9 -1
  91. package/dist/connectors/telegram/commands/modelProviders.d.ts.map +1 -1
  92. package/dist/connectors/telegram/commands/modelProviders.js +203 -111
  93. package/dist/connectors/telegram/commands/modelProviders.js.map +1 -1
  94. package/dist/connectors/telegram/commands/stt.d.ts +32 -0
  95. package/dist/connectors/telegram/commands/stt.d.ts.map +1 -0
  96. package/dist/connectors/telegram/commands/stt.js +460 -0
  97. package/dist/connectors/telegram/commands/stt.js.map +1 -0
  98. package/dist/effortLevels.d.ts +57 -7
  99. package/dist/effortLevels.d.ts.map +1 -1
  100. package/dist/effortLevels.js +65 -9
  101. package/dist/effortLevels.js.map +1 -1
  102. package/dist/i18n/de.d.ts.map +1 -1
  103. package/dist/i18n/de.js +160 -17
  104. package/dist/i18n/de.js.map +1 -1
  105. package/dist/i18n/en.d.ts.map +1 -1
  106. package/dist/i18n/en.js +160 -17
  107. package/dist/i18n/en.js.map +1 -1
  108. package/dist/i18n/es.d.ts.map +1 -1
  109. package/dist/i18n/es.js +160 -17
  110. package/dist/i18n/es.js.map +1 -1
  111. package/dist/i18n/fr.d.ts.map +1 -1
  112. package/dist/i18n/fr.js +160 -17
  113. package/dist/i18n/fr.js.map +1 -1
  114. package/dist/i18n/hi.d.ts.map +1 -1
  115. package/dist/i18n/hi.js +160 -17
  116. package/dist/i18n/hi.js.map +1 -1
  117. package/dist/i18n/ja.d.ts.map +1 -1
  118. package/dist/i18n/ja.js +160 -17
  119. package/dist/i18n/ja.js.map +1 -1
  120. package/dist/i18n/ka.d.ts.map +1 -1
  121. package/dist/i18n/ka.js +160 -17
  122. package/dist/i18n/ka.js.map +1 -1
  123. package/dist/i18n/pt.d.ts.map +1 -1
  124. package/dist/i18n/pt.js +160 -17
  125. package/dist/i18n/pt.js.map +1 -1
  126. package/dist/i18n/ru.d.ts.map +1 -1
  127. package/dist/i18n/ru.js +160 -17
  128. package/dist/i18n/ru.js.map +1 -1
  129. package/dist/i18n/uk.d.ts.map +1 -1
  130. package/dist/i18n/uk.js +160 -17
  131. package/dist/i18n/uk.js.map +1 -1
  132. package/dist/i18n/uz.d.ts.map +1 -1
  133. package/dist/i18n/uz.js +160 -17
  134. package/dist/i18n/uz.js.map +1 -1
  135. package/dist/i18n/zh.d.ts.map +1 -1
  136. package/dist/i18n/zh.js +160 -17
  137. package/dist/i18n/zh.js.map +1 -1
  138. package/dist/installManager.d.ts +20 -4
  139. package/dist/installManager.d.ts.map +1 -1
  140. package/dist/installManager.js +82 -14
  141. package/dist/installManager.js.map +1 -1
  142. package/dist/instanceSettings.d.ts +52 -0
  143. package/dist/instanceSettings.d.ts.map +1 -0
  144. package/dist/instanceSettings.js +136 -0
  145. package/dist/instanceSettings.js.map +1 -0
  146. package/dist/pinnedStatus.d.ts +7 -1
  147. package/dist/pinnedStatus.d.ts.map +1 -1
  148. package/dist/pinnedStatus.js +1 -1
  149. package/dist/pinnedStatus.js.map +1 -1
  150. package/dist/requests/requestLedger.d.ts +8 -0
  151. package/dist/requests/requestLedger.d.ts.map +1 -1
  152. package/dist/requests/requestLedger.js +19 -1
  153. package/dist/requests/requestLedger.js.map +1 -1
  154. package/dist/scheduler/mcpSurface.js +4 -4
  155. package/dist/scheduler/mcpSurface.js.map +1 -1
  156. package/dist/shutdown.d.ts +14 -0
  157. package/dist/shutdown.d.ts.map +1 -1
  158. package/dist/shutdown.js +16 -0
  159. package/dist/shutdown.js.map +1 -1
  160. package/dist/state.d.ts +54 -2
  161. package/dist/state.d.ts.map +1 -1
  162. package/dist/state.js +62 -1
  163. package/dist/state.js.map +1 -1
  164. package/dist/stt/sttAutoInstall.d.ts +39 -0
  165. package/dist/stt/sttAutoInstall.d.ts.map +1 -0
  166. package/dist/stt/sttAutoInstall.js +158 -0
  167. package/dist/stt/sttAutoInstall.js.map +1 -0
  168. package/dist/stt/sttChildEnv.d.ts +11 -0
  169. package/dist/stt/sttChildEnv.d.ts.map +1 -0
  170. package/dist/stt/sttChildEnv.js +30 -0
  171. package/dist/stt/sttChildEnv.js.map +1 -0
  172. package/dist/stt/sttConfigEdit.d.ts +71 -0
  173. package/dist/stt/sttConfigEdit.d.ts.map +1 -0
  174. package/dist/stt/sttConfigEdit.js +144 -0
  175. package/dist/stt/sttConfigEdit.js.map +1 -0
  176. package/dist/stt/sttInstall.d.ts +147 -0
  177. package/dist/stt/sttInstall.d.ts.map +1 -0
  178. package/dist/stt/sttInstall.js +491 -0
  179. package/dist/stt/sttInstall.js.map +1 -0
  180. package/dist/stt/sttInstallEntry.d.ts +2 -0
  181. package/dist/stt/sttInstallEntry.d.ts.map +1 -0
  182. package/dist/stt/sttInstallEntry.js +31 -0
  183. package/dist/stt/sttInstallEntry.js.map +1 -0
  184. package/dist/stt/sttInstallReporter.d.ts +45 -0
  185. package/dist/stt/sttInstallReporter.d.ts.map +1 -0
  186. package/dist/stt/sttInstallReporter.js +57 -0
  187. package/dist/stt/sttInstallReporter.js.map +1 -0
  188. package/dist/stt/sttRuntime.d.ts +35 -0
  189. package/dist/stt/sttRuntime.d.ts.map +1 -0
  190. package/dist/stt/sttRuntime.js +73 -0
  191. package/dist/stt/sttRuntime.js.map +1 -0
  192. package/dist/stt/sttScreenPlan.d.ts +193 -0
  193. package/dist/stt/sttScreenPlan.d.ts.map +1 -0
  194. package/dist/stt/sttScreenPlan.js +541 -0
  195. package/dist/stt/sttScreenPlan.js.map +1 -0
  196. package/dist/stt/sttSettings.d.ts +201 -0
  197. package/dist/stt/sttSettings.d.ts.map +1 -0
  198. package/dist/stt/sttSettings.js +387 -0
  199. package/dist/stt/sttSettings.js.map +1 -0
  200. package/dist/stt/sttSidecarOwner.d.ts +115 -0
  201. package/dist/stt/sttSidecarOwner.d.ts.map +1 -0
  202. package/dist/stt/sttSidecarOwner.js +475 -0
  203. package/dist/stt/sttSidecarOwner.js.map +1 -0
  204. package/dist/stt/transcribeChain.d.ts +36 -0
  205. package/dist/stt/transcribeChain.d.ts.map +1 -0
  206. package/dist/stt/transcribeChain.js +42 -0
  207. package/dist/stt/transcribeChain.js.map +1 -0
  208. package/dist/types.d.ts +77 -4
  209. package/dist/types.d.ts.map +1 -1
  210. package/dist/utils/adapterSwitchSessionIds.d.ts +32 -0
  211. package/dist/utils/adapterSwitchSessionIds.d.ts.map +1 -0
  212. package/dist/utils/adapterSwitchSessionIds.js +44 -0
  213. package/dist/utils/adapterSwitchSessionIds.js.map +1 -0
  214. package/dist/utils/backendSwitchPlan.d.ts +63 -0
  215. package/dist/utils/backendSwitchPlan.d.ts.map +1 -0
  216. package/dist/utils/backendSwitchPlan.js +73 -0
  217. package/dist/utils/backendSwitchPlan.js.map +1 -0
  218. package/dist/utils/claudeAccountPickerPlan.d.ts +12 -1
  219. package/dist/utils/claudeAccountPickerPlan.d.ts.map +1 -1
  220. package/dist/utils/claudeAccountPickerPlan.js +20 -1
  221. package/dist/utils/claudeAccountPickerPlan.js.map +1 -1
  222. package/dist/utils/claudeTranscriptContent.d.ts +24 -0
  223. package/dist/utils/claudeTranscriptContent.d.ts.map +1 -0
  224. package/dist/utils/claudeTranscriptContent.js +107 -0
  225. package/dist/utils/claudeTranscriptContent.js.map +1 -0
  226. package/dist/utils/claudeUsage.d.ts +47 -8
  227. package/dist/utils/claudeUsage.d.ts.map +1 -1
  228. package/dist/utils/claudeUsage.js +76 -14
  229. package/dist/utils/claudeUsage.js.map +1 -1
  230. package/dist/utils/claudeUsageRefresher.d.ts +8 -1
  231. package/dist/utils/claudeUsageRefresher.d.ts.map +1 -1
  232. package/dist/utils/claudeUsageRefresher.js +9 -4
  233. package/dist/utils/claudeUsageRefresher.js.map +1 -1
  234. package/dist/utils/effortPickerPlan.d.ts +54 -0
  235. package/dist/utils/effortPickerPlan.d.ts.map +1 -0
  236. package/dist/utils/effortPickerPlan.js +56 -0
  237. package/dist/utils/effortPickerPlan.js.map +1 -0
  238. package/dist/utils/ensureSessionPlan.d.ts +20 -4
  239. package/dist/utils/ensureSessionPlan.d.ts.map +1 -1
  240. package/dist/utils/ensureSessionPlan.js +17 -4
  241. package/dist/utils/ensureSessionPlan.js.map +1 -1
  242. package/dist/utils/fallbackBridge.d.ts +6 -2
  243. package/dist/utils/fallbackBridge.d.ts.map +1 -1
  244. package/dist/utils/fallbackBridge.js +10 -3
  245. package/dist/utils/fallbackBridge.js.map +1 -1
  246. package/dist/utils/fallbackChain.d.ts +36 -0
  247. package/dist/utils/fallbackChain.d.ts.map +1 -1
  248. package/dist/utils/fallbackChain.js +65 -1
  249. package/dist/utils/fallbackChain.js.map +1 -1
  250. package/dist/utils/fallbackSwitchNotice.d.ts +41 -0
  251. package/dist/utils/fallbackSwitchNotice.d.ts.map +1 -0
  252. package/dist/utils/fallbackSwitchNotice.js +75 -0
  253. package/dist/utils/fallbackSwitchNotice.js.map +1 -0
  254. package/dist/utils/fileSendService.d.ts +33 -10
  255. package/dist/utils/fileSendService.d.ts.map +1 -1
  256. package/dist/utils/fileSendService.js +78 -27
  257. package/dist/utils/fileSendService.js.map +1 -1
  258. package/dist/utils/jsonStreamHost.d.ts +39 -0
  259. package/dist/utils/jsonStreamHost.d.ts.map +1 -1
  260. package/dist/utils/jsonStreamHost.js +60 -1
  261. package/dist/utils/jsonStreamHost.js.map +1 -1
  262. package/dist/utils/limitEpisodeRecovery.d.ts +16 -2
  263. package/dist/utils/limitEpisodeRecovery.d.ts.map +1 -1
  264. package/dist/utils/limitEpisodeRecovery.js +44 -3
  265. package/dist/utils/limitEpisodeRecovery.js.map +1 -1
  266. package/dist/utils/limitSettings.d.ts +25 -0
  267. package/dist/utils/limitSettings.d.ts.map +1 -0
  268. package/dist/utils/limitSettings.js +31 -0
  269. package/dist/utils/limitSettings.js.map +1 -0
  270. package/dist/utils/limitsBoardPlan.d.ts +52 -0
  271. package/dist/utils/limitsBoardPlan.d.ts.map +1 -0
  272. package/dist/utils/limitsBoardPlan.js +93 -0
  273. package/dist/utils/limitsBoardPlan.js.map +1 -0
  274. package/dist/utils/openCodeUsage.d.ts +93 -0
  275. package/dist/utils/openCodeUsage.d.ts.map +1 -0
  276. package/dist/utils/openCodeUsage.js +248 -0
  277. package/dist/utils/openCodeUsage.js.map +1 -0
  278. package/dist/utils/operatorSwitchPlan.d.ts +61 -0
  279. package/dist/utils/operatorSwitchPlan.d.ts.map +1 -0
  280. package/dist/utils/operatorSwitchPlan.js +32 -0
  281. package/dist/utils/operatorSwitchPlan.js.map +1 -0
  282. package/dist/utils/promptDelivery.d.ts +40 -0
  283. package/dist/utils/promptDelivery.d.ts.map +1 -1
  284. package/dist/utils/promptDelivery.js +31 -0
  285. package/dist/utils/promptDelivery.js.map +1 -1
  286. package/dist/utils/recvPreviewRedaction.d.ts +14 -4
  287. package/dist/utils/recvPreviewRedaction.d.ts.map +1 -1
  288. package/dist/utils/recvPreviewRedaction.js +19 -7
  289. package/dist/utils/recvPreviewRedaction.js.map +1 -1
  290. package/dist/utils/startupReadiness.d.ts +3 -3
  291. package/dist/utils/startupReadiness.d.ts.map +1 -1
  292. package/dist/utils/startupReadiness.js +2 -2
  293. package/dist/utils/startupReadiness.js.map +1 -1
  294. package/dist/utils/threadStatusReport.d.ts +3 -1
  295. package/dist/utils/threadStatusReport.d.ts.map +1 -1
  296. package/dist/utils/threadStatusReport.js +4 -3
  297. package/dist/utils/threadStatusReport.js.map +1 -1
  298. package/dist/utils/timezone.d.ts +23 -0
  299. package/dist/utils/timezone.d.ts.map +1 -1
  300. package/dist/utils/timezone.js +38 -0
  301. package/dist/utils/timezone.js.map +1 -1
  302. package/dist/utils/transcribeAudio.d.ts +25 -12
  303. package/dist/utils/transcribeAudio.d.ts.map +1 -1
  304. package/dist/utils/transcribeAudio.js +39 -21
  305. package/dist/utils/transcribeAudio.js.map +1 -1
  306. package/dist/utils/usageRefreshBudget.d.ts +9 -0
  307. package/dist/utils/usageRefreshBudget.d.ts.map +1 -0
  308. package/dist/utils/usageRefreshBudget.js +27 -0
  309. package/dist/utils/usageRefreshBudget.js.map +1 -0
  310. package/examples/.env.example +7 -3
  311. package/examples/docker-compose.yml +9 -7
  312. package/package.json +5 -4
  313. package/stt/gigaam_sidecar.py +800 -0
@@ -0,0 +1,800 @@
1
+ #!/usr/bin/env python3
2
+ """GigaAM v3 speech-recognition sidecar for Charness.
3
+
4
+ ONE process: it loads the model at start, binds 127.0.0.1 on an OS-chosen port and serves an OpenAI-compatible
5
+ `POST /v1/audio/transcriptions` plus `GET /health`. The bot starts it on demand, adopts it across hot reloads,
6
+ and it takes itself down when it has been idle (`--idle-minutes`) or when its owner process is gone.
7
+
8
+ Long audio is cut at the quietest 50 ms inside the last 8 s before every 25 s boundary; the chunks are
9
+ recognised in parallel on a pool of threads that share ONE onnxruntime session, shortest request first.
10
+
11
+ It claims the bot's record before it loads the model (an exclusive create, so a second start of the same engine
12
+ exits instead of becoming an unrecorded orphan) and completes it with its port once it listens.
13
+
14
+ Exit codes: 0 clean (SIGTERM, idle, owner gone), 1 the model, the socket, the record or the ready file could not be set up, 2 bad usage
15
+ (including a missing token), 4 another live sidecar already holds the record. Everything it has to say goes to `--log-file`: it is externally parented, so its
16
+ stdio may point at a dead pipe.
17
+
18
+ Protocol and layout: `src/stt/README.md`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import contextlib
25
+ import datetime
26
+ import email.parser
27
+ import email.policy
28
+ import hmac
29
+ import http.server
30
+ import io
31
+ import itertools
32
+ import json
33
+ import logging
34
+ import logging.handlers
35
+ import os
36
+ import queue
37
+ import signal
38
+ import subprocess
39
+ import sys
40
+ import threading
41
+ import time
42
+ import traceback
43
+
44
+ import numpy as np
45
+
46
+ # Bumped with every change of the sidecar's behaviour; the bot hashes it into the adoption check.
47
+ SCRIPT_VERSION = "1"
48
+ MODEL_NAME = "gigaam-v3-e2e-rnnt"
49
+ MODEL_QUANTIZATION = "int8"
50
+ TOKEN_ENV_NAME = "CHARNESS_STT_SIDECAR_TOKEN"
51
+
52
+ EXIT_OK = 0
53
+ EXIT_SETUP_FAILED = 1
54
+ EXIT_USAGE = 2
55
+ # Another live sidecar already holds the bot's record: this process was a second start of the same engine.
56
+ EXIT_DUPLICATE = 4
57
+
58
+ MAX_CHUNK_SECONDS = 25.0
59
+ SILENCE_SEARCH_SECONDS = 8.0
60
+ SILENCE_WINDOW_SECONDS = 0.05
61
+ # The encoder fails on fewer than ~25 ms of audio; a piece this short holds no word anyway.
62
+ MIN_CHUNK_SECONDS = 0.1
63
+ # The sample rates onnx-asr resamples to 16 kHz itself.
64
+ SUPPORTED_SAMPLE_RATES = (8000, 11025, 16000, 22050, 24000, 32000, 44100, 48000)
65
+
66
+ MAX_BODY_BYTES = 25 * 1024 * 1024
67
+ MAX_HEADER_LINE_BYTES = 8192
68
+ DEFAULT_MAX_SECONDS = 600.0
69
+ SOCKET_TIMEOUT_SECONDS = 120.0
70
+ REQUEST_QUEUE_SIZE = 16
71
+ IDLE_CHECK_SECONDS = 5.0
72
+ OWNER_CHECK_SECONDS = 2.0
73
+ PS_TIMEOUT_SECONDS = 3.0
74
+ SERVE_POLL_SECONDS = 0.5
75
+ LOG_MAX_BYTES = 5 * 1024 * 1024
76
+ LOG_BACKUP_COUNT = 1
77
+ FILE_MODE = 0o600
78
+ # The record's port while the model is still loading: the process is there, but nothing listens yet.
79
+ STARTING_PORT = 0
80
+ CLAIM_ATTEMPTS = 3
81
+ SECONDS_PER_MINUTE = 60.0
82
+ BEARER_PREFIX = "Bearer "
83
+ TRANSCRIPTIONS_PATH = "/v1/audio/transcriptions"
84
+ HEALTH_PATH = "/health"
85
+ RESPONSE_FORMATS = ("json", "text")
86
+ # The pool queue's last position: a stop sentinel sorts after every real request.
87
+ STOP_PRIORITY = sys.maxsize
88
+
89
+ logger = logging.getLogger("gigaam_sidecar")
90
+
91
+
92
+ class RequestError(Exception):
93
+ """A failure that maps to an HTTP status and a message safe to show the client."""
94
+
95
+ def __init__(self, status: int, message: str):
96
+ super().__init__(message)
97
+ self.status = status
98
+ self.message = message
99
+
100
+
101
+ class RecognitionTimeout(Exception):
102
+ """A request ran past `--max-seconds`."""
103
+
104
+
105
+ # ──────────────────────────────── audio ────────────────────────────────
106
+
107
+
108
+ def split_at_silences(
109
+ audio: np.ndarray,
110
+ sample_rate: int,
111
+ max_seconds: float = MAX_CHUNK_SECONDS,
112
+ search_seconds: float = SILENCE_SEARCH_SECONDS,
113
+ window_seconds: float = SILENCE_WINDOW_SECONDS,
114
+ ) -> list[np.ndarray]:
115
+ """Cut `audio` into pieces of at most `max_seconds`, each cut at the centre of the lowest-energy window
116
+ inside the last `search_seconds` before the boundary — usually a pause between words. Audio that already
117
+ fits is one piece. The pieces joined give back `audio` exactly."""
118
+ window = max(1, int(window_seconds * sample_rate))
119
+ max_samples = max_seconds * sample_rate
120
+ pieces: list[np.ndarray] = []
121
+ start = 0
122
+ while len(audio) - start > max_samples:
123
+ search_start = start + int((max_seconds - search_seconds) * sample_rate)
124
+ search_end = start + int(max_seconds * sample_rate)
125
+ frames = audio[search_start:search_end]
126
+ frames = frames[: len(frames) // window * window].reshape(-1, window)
127
+ quietest = int(np.argmin((frames ** 2).mean(axis=1)))
128
+ cut = search_start + quietest * window + window // 2
129
+ pieces.append(audio[start:cut])
130
+ start = cut
131
+ pieces.append(audio[start:])
132
+ return pieces
133
+
134
+
135
+ def decode_audio(data: bytes) -> tuple[np.ndarray, int]:
136
+ """Decode an uploaded file (Telegram sends OGG/Opus) to mono float32 samples and their sample rate."""
137
+ import soundfile
138
+
139
+ try:
140
+ samples, sample_rate = soundfile.read(io.BytesIO(data), dtype="float32")
141
+ except Exception as error: # soundfile raises its own LibsndfileError / RuntimeError
142
+ raise RequestError(400, "the audio file could not be decoded") from error
143
+ if samples.ndim > 1:
144
+ samples = samples.mean(axis=1, dtype=np.float32)
145
+ if sample_rate not in SUPPORTED_SAMPLE_RATES:
146
+ raise RequestError(400, f"unsupported sample rate {sample_rate}")
147
+ return np.ascontiguousarray(samples, dtype=np.float32), int(sample_rate)
148
+
149
+
150
+ def make_recognizer(model):
151
+ """The pool's recognise callback over a loaded model. A piece too short to hold a word is empty text, not
152
+ an error: the encoder cannot take it."""
153
+
154
+ def recognize(samples: np.ndarray, sample_rate: int) -> str:
155
+ if len(samples) < MIN_CHUNK_SECONDS * sample_rate:
156
+ return ""
157
+ return model.recognize(samples, sample_rate=sample_rate)
158
+
159
+ return recognize
160
+
161
+
162
+ # ──────────────────────────────── pool ────────────────────────────────
163
+
164
+
165
+ class PoolRequest:
166
+ """One upload's chunks: collects their texts in order and tells the waiting handler when all are in."""
167
+
168
+ def __init__(self, chunk_count: int, sample_rate: int):
169
+ self.sample_rate = sample_rate
170
+ self._texts: list[str | None] = [None] * chunk_count
171
+ self._remaining = chunk_count
172
+ self._error: Exception | None = None
173
+ self._lock = threading.Lock()
174
+ self._finished = threading.Event()
175
+
176
+ def is_finished(self) -> bool:
177
+ return self._finished.is_set()
178
+
179
+ def complete_chunk(self, index: int, text: str) -> None:
180
+ with self._lock:
181
+ if self._finished.is_set():
182
+ return
183
+ self._texts[index] = text
184
+ self._remaining -= 1
185
+ if self._remaining == 0:
186
+ self._finished.set()
187
+
188
+ def fail(self, error: Exception) -> None:
189
+ """Fail the whole request; the pool drops its chunks that are still queued."""
190
+ with self._lock:
191
+ if not self._finished.is_set():
192
+ self._error = error
193
+ self._finished.set()
194
+
195
+ def wait(self, timeout_seconds: float) -> str:
196
+ """The chunks' texts joined in order with single spaces; raises the first chunk error, or
197
+ `RecognitionTimeout` after `timeout_seconds` (the pending chunks are then dropped)."""
198
+ if not self._finished.wait(timeout_seconds):
199
+ self.fail(RecognitionTimeout())
200
+ if self._error is not None:
201
+ raise self._error
202
+ return " ".join(text.strip() for text in self._texts if text and text.strip())
203
+
204
+
205
+ class RecognitionPool:
206
+ """W threads over one priority queue. A request's priority is its total chunk count, so a short voice
207
+ overtakes the rest of a long one; equal priorities keep arrival order."""
208
+
209
+ def __init__(self, recognize, workers: int):
210
+ self._recognize = recognize
211
+ self._workers = workers
212
+ self._queue: queue.PriorityQueue = queue.PriorityQueue()
213
+ self._sequence = itertools.count()
214
+ self._threads: list[threading.Thread] = []
215
+
216
+ def start(self) -> None:
217
+ for number in range(self._workers):
218
+ thread = threading.Thread(target=self._work, name=f"stt-worker-{number}", daemon=True)
219
+ thread.start()
220
+ self._threads.append(thread)
221
+
222
+ def stop(self) -> None:
223
+ for _ in self._threads:
224
+ self._queue.put((STOP_PRIORITY, next(self._sequence), None, 0, None))
225
+
226
+ def queued_chunks(self) -> int:
227
+ """Chunks waiting for a worker (approximate: the queue moves while it is read)."""
228
+ return self._queue.qsize()
229
+
230
+ def submit(self, chunks: list[np.ndarray], sample_rate: int) -> PoolRequest:
231
+ request = PoolRequest(len(chunks), sample_rate)
232
+ for index, chunk in enumerate(chunks):
233
+ self._queue.put((len(chunks), next(self._sequence), request, index, chunk))
234
+ return request
235
+
236
+ def _work(self) -> None:
237
+ while True:
238
+ _, _, request, index, samples = self._queue.get()
239
+ if request is None:
240
+ return
241
+ if request.is_finished():
242
+ continue
243
+ try:
244
+ text = self._recognize(samples, request.sample_rate)
245
+ except Exception as error: # one broken chunk fails its request only
246
+ logger.error("chunk %d failed: %s", index, traceback.format_exc())
247
+ request.fail(error)
248
+ continue
249
+ request.complete_chunk(index, text)
250
+
251
+
252
+ # ──────────────────────────────── HTTP ────────────────────────────────
253
+
254
+
255
+ def parse_multipart(content_type: str, body: bytes) -> tuple[dict[str, str], bytes | None]:
256
+ """The text fields and the `file` part of a multipart/form-data body (`cgi` is gone from Python 3.13)."""
257
+ header = f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode("latin-1", "replace")
258
+ message = email.parser.BytesParser(policy=email.policy.HTTP).parsebytes(header + body)
259
+ if message.get_content_type() != "multipart/form-data" or not message.is_multipart():
260
+ raise RequestError(400, "expected a multipart/form-data body")
261
+ fields: dict[str, str] = {}
262
+ file_bytes: bytes | None = None
263
+ for part in message.iter_parts():
264
+ name = part.get_param("name", header="content-disposition")
265
+ if not isinstance(name, str):
266
+ continue
267
+ payload = part.get_payload(decode=True) or b""
268
+ if name == "file":
269
+ file_bytes = payload
270
+ else:
271
+ fields[name] = payload.decode("utf-8", "replace")
272
+ return fields, file_bytes
273
+
274
+
275
+ def read_exact(stream, size: int) -> bytes:
276
+ data = stream.read(size)
277
+ if len(data) != size:
278
+ raise RequestError(400, "the request body ended early")
279
+ return data
280
+
281
+
282
+ def read_chunked_body(stream, limit: int) -> bytes:
283
+ """Decode `Transfer-Encoding: chunked` — Node's form-data uploads without a Content-Length."""
284
+ body = bytearray()
285
+ while True:
286
+ size_line = stream.readline(MAX_HEADER_LINE_BYTES)
287
+ try:
288
+ size = int(size_line.split(b";", 1)[0].strip(), 16)
289
+ except ValueError:
290
+ raise RequestError(400, "malformed chunked body") from None
291
+ if size == 0:
292
+ break
293
+ if len(body) + size > limit:
294
+ raise RequestError(413, "the audio file is too large")
295
+ body += read_exact(stream, size)
296
+ read_exact(stream, 2) # the CRLF that ends the chunk
297
+ while stream.readline(MAX_HEADER_LINE_BYTES) not in (b"\r\n", b"\n", b""):
298
+ pass # trailers
299
+ return bytes(body)
300
+
301
+
302
+ def read_request_body(handler: http.server.BaseHTTPRequestHandler, limit: int) -> bytes:
303
+ if handler.headers.get("Transfer-Encoding", "").lower() == "chunked":
304
+ return read_chunked_body(handler.rfile, limit)
305
+ try:
306
+ length = int(handler.headers.get("Content-Length", "0"))
307
+ except ValueError:
308
+ raise RequestError(400, "malformed Content-Length") from None
309
+ if length < 0:
310
+ raise RequestError(400, "malformed Content-Length")
311
+ if length > limit:
312
+ raise RequestError(413, "the audio file is too large")
313
+ return read_exact(handler.rfile, length)
314
+
315
+
316
+ class SidecarContext:
317
+ """What the handlers and the monitors share."""
318
+
319
+ def __init__(
320
+ self, token: str, pool: RecognitionPool, workers: int, idle_seconds: float, max_seconds: float, clock=time.monotonic
321
+ ):
322
+ self.token = token
323
+ self.pool = pool
324
+ self.workers = workers
325
+ self.idle_seconds = idle_seconds
326
+ self.max_seconds = max_seconds
327
+ self.clock = clock
328
+ self.lock = threading.Lock()
329
+ self.busy_requests = 0
330
+ self.last_activity = clock()
331
+ self.is_exiting = False
332
+
333
+ def enter_request(self) -> bool:
334
+ """Count a transcription in flight; false once the idle exit is decided (the client falls back)."""
335
+ with self.lock:
336
+ if self.is_exiting:
337
+ return False
338
+ self.busy_requests += 1
339
+ return True
340
+
341
+ def leave_request(self) -> None:
342
+ with self.lock:
343
+ self.busy_requests -= 1
344
+ self.last_activity = self.clock()
345
+
346
+ def is_idle(self) -> bool:
347
+ """No transcription in flight, and none for `idle_seconds`. Never true when the idle exit is off (0)."""
348
+ with self.lock:
349
+ return self._is_idle_locked()
350
+
351
+ def _is_idle_locked(self) -> bool:
352
+ return self.idle_seconds > 0 and self.busy_requests == 0 and self.clock() - self.last_activity >= self.idle_seconds
353
+
354
+ def decide_idle_exit(self, stop_accepting, resume_accepting) -> bool:
355
+ """The idle exit's two-step decision. Stop taking connections first, then re-check under the lock: a request
356
+ that slipped in meanwhile keeps the process alive (accepting resumes), otherwise the exit is final and any
357
+ later request is refused with 503 so its client falls back. True when the process should exit."""
358
+ if not self.is_idle():
359
+ return False
360
+ stop_accepting()
361
+ with self.lock:
362
+ self.is_exiting = self._is_idle_locked()
363
+ is_exit_decided = self.is_exiting
364
+ if not is_exit_decided:
365
+ resume_accepting()
366
+ return is_exit_decided
367
+
368
+
369
+ class SidecarServer(http.server.ThreadingHTTPServer):
370
+ daemon_threads = True
371
+ request_queue_size = REQUEST_QUEUE_SIZE
372
+
373
+ def __init__(self, address, context: SidecarContext):
374
+ super().__init__(address, SidecarHandler)
375
+ self.context = context
376
+
377
+
378
+ class SidecarHandler(http.server.BaseHTTPRequestHandler):
379
+ server_version = "CharnessSttSidecar"
380
+ protocol_version = "HTTP/1.1"
381
+ timeout = SOCKET_TIMEOUT_SECONDS
382
+
383
+ def log_message(self, format: str, *args) -> None: # noqa: A002 - the stdlib signature
384
+ logger.info("%s %s", self.address_string(), format % args)
385
+
386
+ def do_GET(self) -> None:
387
+ if self.path != HEALTH_PATH:
388
+ self._reply_error(404, "not found")
389
+ elif not self._check_token():
390
+ self._reply_error(401, "missing or wrong token")
391
+ else:
392
+ context = self.server.context
393
+ self._reply_json(200, {
394
+ "status": "ok",
395
+ "busyRequests": context.busy_requests,
396
+ "queuedChunks": context.pool.queued_chunks(),
397
+ "workers": context.workers,
398
+ "idleMinutes": context.idle_seconds / SECONDS_PER_MINUTE,
399
+ "version": SCRIPT_VERSION,
400
+ "pid": os.getpid(),
401
+ })
402
+
403
+ def do_POST(self) -> None:
404
+ if self.path != TRANSCRIPTIONS_PATH:
405
+ self._reply_error(404, "not found")
406
+ return
407
+ if not self._check_token():
408
+ self._reply_error(401, "missing or wrong token")
409
+ return
410
+ context = self.server.context
411
+ if not context.enter_request():
412
+ self._reply_error(503, "the sidecar is shutting down")
413
+ return
414
+ try:
415
+ self._transcribe(context)
416
+ except RequestError as error:
417
+ self._reply_error(error.status, error.message)
418
+ except RecognitionTimeout:
419
+ self._reply_error(504, "recognition took too long")
420
+ except Exception as error:
421
+ logger.error("request failed: %s", traceback.format_exc())
422
+ self._reply_error(500, f"recognition failed: {type(error).__name__}")
423
+ finally:
424
+ context.leave_request()
425
+
426
+ def _check_token(self) -> bool:
427
+ header = self.headers.get("Authorization", "")
428
+ if not header.startswith(BEARER_PREFIX):
429
+ return False
430
+ return hmac.compare_digest(header[len(BEARER_PREFIX):].encode(), self.server.context.token.encode())
431
+
432
+ def _transcribe(self, context: SidecarContext) -> None:
433
+ started = time.monotonic()
434
+ body = read_request_body(self, MAX_BODY_BYTES)
435
+ fields, file_bytes = parse_multipart(self.headers.get("Content-Type", ""), body)
436
+ if not file_bytes:
437
+ raise RequestError(400, "the file field is required")
438
+ response_format = fields.get("response_format", "json")
439
+ if response_format not in RESPONSE_FORMATS:
440
+ raise RequestError(400, f"response_format must be one of {', '.join(RESPONSE_FORMATS)}")
441
+ samples, sample_rate = decode_audio(file_bytes)
442
+ chunks = split_at_silences(samples, sample_rate)
443
+ request = context.pool.submit(chunks, sample_rate)
444
+ text = request.wait(context.max_seconds)
445
+ logger.info(
446
+ "transcribed %.1fs of audio in %d chunk(s), %.2fs", len(samples) / sample_rate, len(chunks), time.monotonic() - started
447
+ )
448
+ if response_format == "text":
449
+ self._reply(200, text.encode("utf-8"), "text/plain; charset=utf-8")
450
+ else:
451
+ self._reply_json(200, {"text": text})
452
+
453
+ def _reply_json(self, status: int, payload: dict) -> None:
454
+ self._reply(status, json.dumps(payload, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
455
+
456
+ def _reply_error(self, status: int, message: str) -> None:
457
+ self._reply_json(status, {"error": {"message": message}})
458
+
459
+ def _reply(self, status: int, payload: bytes, content_type: str) -> None:
460
+ self.send_response(status)
461
+ self.send_header("Content-Type", content_type)
462
+ self.send_header("Content-Length", str(len(payload)))
463
+ # One request per connection: an error may leave an unread body behind.
464
+ self.send_header("Connection", "close")
465
+ self.end_headers()
466
+ self.wfile.write(payload)
467
+ self.close_connection = True
468
+
469
+
470
+ # ──────────────────────────────── process ────────────────────────────────
471
+
472
+
473
+ def write_json_atomic(path: str, payload: dict) -> None:
474
+ temporary = f"{path}.{os.getpid()}.tmp"
475
+ with open(os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, FILE_MODE), "w", encoding="utf-8") as stream:
476
+ json.dump(payload, stream)
477
+ os.replace(temporary, path)
478
+
479
+
480
+ def remove_record_if_owned(record_path: str, own_pid: int) -> bool:
481
+ """Delete the bot's record of a sidecar only when it names THIS process — a newer generation's record
482
+ stays. A missing or unreadable record is not an error."""
483
+ try:
484
+ with open(record_path, encoding="utf-8") as stream:
485
+ record_pid = json.load(stream).get("pid")
486
+ if record_pid != own_pid:
487
+ return False
488
+ os.unlink(record_path)
489
+ return True
490
+ except (OSError, ValueError, AttributeError):
491
+ return False
492
+
493
+
494
+ def read_process_start_token(pid: int) -> str | None:
495
+ """The token the bot records to tell a process from a reused pid: field 22 of `/proc/<pid>/stat` on Linux
496
+ (None when gone or a zombie), elsewhere the `ps` text the bot reads (`lstart` + `command`)."""
497
+ if not sys.platform.startswith("linux"):
498
+ try:
499
+ result = subprocess.run(
500
+ ["ps", "-o", "lstart=", "-o", "command=", "-p", str(pid)],
501
+ capture_output=True, text=True, timeout=PS_TIMEOUT_SECONDS, check=False,
502
+ )
503
+ except (OSError, subprocess.SubprocessError):
504
+ return None
505
+ return result.stdout.strip() or None
506
+ try:
507
+ with open(f"/proc/{pid}/stat", encoding="utf-8") as stream:
508
+ stat = stream.read()
509
+ except OSError:
510
+ return None
511
+ fields = stat[stat.rfind(")") + 1:].split()
512
+ if not fields or fields[0] == "Z" or len(fields) < 20:
513
+ return None
514
+ return fields[19]
515
+
516
+
517
+ def build_record(args: argparse.Namespace, port: int, token: str, start_token: str) -> dict:
518
+ """What the bot adopts this process from: the sidecar writes it ITSELF, so a bot worker that is replaced while it
519
+ waits for the ready file leaves a process the next worker can find (the token is only in this process and in this
520
+ file). `port` is STARTING_PORT until the server listens."""
521
+ return {
522
+ "pid": os.getpid(),
523
+ "startToken": start_token,
524
+ "port": port,
525
+ "token": token,
526
+ "configHash": args.config_hash,
527
+ "ownerPid": args.owner_pid,
528
+ "ownerStartToken": args.owner_start_token or "",
529
+ "startedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
530
+ }
531
+
532
+
533
+ def read_record(record_path: str) -> dict | None:
534
+ try:
535
+ with open(record_path, encoding="utf-8") as stream:
536
+ record = json.load(stream)
537
+ except (OSError, ValueError):
538
+ return None
539
+ return record if isinstance(record, dict) else None
540
+
541
+
542
+ def is_other_sidecar_alive(record: dict | None) -> bool:
543
+ """The record names a process that is not this one and is still the process it was (the start token tells a
544
+ reused pid)."""
545
+ if record is None:
546
+ return False
547
+ pid = record.get("pid")
548
+ if not isinstance(pid, int) or pid == os.getpid():
549
+ return False
550
+ return read_process_start_token(pid) == record.get("startToken")
551
+
552
+
553
+ def claim_record(args: argparse.Namespace, token: str, start_token: str) -> bool:
554
+ """Take the bot's record BEFORE the slow model load, with an exclusive create (a hard link of a finished file, so
555
+ a reader never sees half a record): of two sidecars started at once — a bot worker replaced mid-start asks again
556
+ — only the first is ever recorded, and the second learns it here instead of becoming an unrecorded orphan.
557
+ False: another live sidecar holds the record. A record of a dead process is replaced."""
558
+ temporary = f"{args.record_file}.{os.getpid()}.claim"
559
+ with open(os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, FILE_MODE), "w", encoding="utf-8") as stream:
560
+ json.dump(build_record(args, STARTING_PORT, token, start_token), stream)
561
+ try:
562
+ for _ in range(CLAIM_ATTEMPTS):
563
+ try:
564
+ os.link(temporary, args.record_file)
565
+ return True
566
+ except FileExistsError:
567
+ if is_other_sidecar_alive(read_record(args.record_file)):
568
+ return False
569
+ with contextlib.suppress(FileNotFoundError):
570
+ os.unlink(args.record_file)
571
+ raise OSError(f"could not claim {args.record_file}")
572
+ finally:
573
+ with contextlib.suppress(OSError):
574
+ os.unlink(temporary)
575
+
576
+
577
+ def publish_port(args: argparse.Namespace, port: int, token: str, start_token: str) -> bool:
578
+ """Put the listening port into the record, while the record is still this process's (the bot may have stopped
579
+ this start and taken it away)."""
580
+ current = read_record(args.record_file)
581
+ if current is None or current.get("pid") != os.getpid():
582
+ return False
583
+ write_json_atomic(args.record_file, build_record(args, port, token, start_token))
584
+ return True
585
+
586
+
587
+ def is_owner_alive(owner_pid: int, owner_start_token: str | None) -> bool:
588
+ try:
589
+ os.kill(owner_pid, 0)
590
+ except ProcessLookupError:
591
+ return False
592
+ except PermissionError:
593
+ pass # alive, but another user's
594
+ if owner_start_token is None or not sys.platform.startswith("linux"):
595
+ return True
596
+ return read_process_start_token(owner_pid) == owner_start_token
597
+
598
+
599
+ def setup_logging(log_file: str) -> None:
600
+ handler = logging.handlers.RotatingFileHandler(
601
+ log_file, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT, encoding="utf-8"
602
+ )
603
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
604
+ logger.addHandler(handler)
605
+ logger.setLevel(logging.INFO)
606
+ threading.excepthook = lambda args: logger.error(
607
+ "thread %s crashed: %s", args.thread.name if args.thread else "?", "".join(traceback.format_exception(*args[:3]))
608
+ )
609
+
610
+
611
+ def detach_stdio() -> None:
612
+ """The parent may be gone and its pipes with it: point the standard streams at /dev/null."""
613
+ devnull = os.open(os.devnull, os.O_RDWR)
614
+ for descriptor in (0, 1, 2):
615
+ os.dup2(devnull, descriptor)
616
+
617
+
618
+ def load_model(threads: int):
619
+ import onnx_asr
620
+ import onnxruntime
621
+
622
+ options = onnxruntime.SessionOptions()
623
+ options.intra_op_num_threads = threads
624
+ options.inter_op_num_threads = 1
625
+ return onnx_asr.load_model(MODEL_NAME, quantization=MODEL_QUANTIZATION, sess_options=options)
626
+
627
+
628
+ def run_check(args: argparse.Namespace) -> int:
629
+ """Load the model, recognise 1 s of silence and print the versions — the installer's verify step."""
630
+ from importlib import metadata
631
+ import platform
632
+
633
+ try:
634
+ import soundfile
635
+
636
+ model = load_model(args.threads)
637
+ silence = np.zeros(16000, dtype=np.float32)
638
+ text = make_recognizer(model)(silence, 16000)
639
+ is_opus_readable = "OPUS" in soundfile.available_subtypes("OGG")
640
+ if not is_opus_readable:
641
+ raise RuntimeError("libsndfile cannot read OGG/Opus")
642
+ report = {
643
+ "ok": True,
644
+ "scriptVersion": SCRIPT_VERSION,
645
+ "model": MODEL_NAME,
646
+ "python": platform.python_version(),
647
+ "onnxAsr": metadata.version("onnx-asr"),
648
+ "onnxruntime": metadata.version("onnxruntime"),
649
+ "soundfile": metadata.version("soundfile"),
650
+ "libsndfile": soundfile.__libsndfile_version__,
651
+ "numpy": metadata.version("numpy"),
652
+ "silenceText": text,
653
+ }
654
+ print(json.dumps(report))
655
+ return EXIT_OK
656
+ except Exception as error:
657
+ print(json.dumps({"ok": False, "error": f"{type(error).__name__}: {error}"}))
658
+ return EXIT_SETUP_FAILED
659
+
660
+
661
+ def run_serve(args: argparse.Namespace, token: str) -> int:
662
+ setup_logging(args.log_file)
663
+ detach_stdio()
664
+ logger.info(
665
+ "starting pid=%d workers=%d threads=%d idle=%ss owner=%d", os.getpid(), args.workers, args.threads, args.idle_seconds, args.owner_pid
666
+ )
667
+ start_token = read_process_start_token(os.getpid())
668
+ if start_token is None:
669
+ logger.error("could not read the own start token")
670
+ return EXIT_SETUP_FAILED
671
+ try:
672
+ is_claimed = claim_record(args, token, start_token)
673
+ except OSError:
674
+ logger.error("could not write the record: %s", traceback.format_exc())
675
+ return EXIT_SETUP_FAILED
676
+ if not is_claimed:
677
+ logger.info("another sidecar already holds the record; exiting")
678
+ return EXIT_DUPLICATE
679
+ loading_started = time.monotonic()
680
+ try:
681
+ model = load_model(args.threads)
682
+ except Exception:
683
+ logger.error("model load failed: %s", traceback.format_exc())
684
+ return EXIT_SETUP_FAILED
685
+ logger.info("model loaded in %.2fs", time.monotonic() - loading_started)
686
+
687
+ pool = RecognitionPool(make_recognizer(model), args.workers)
688
+ pool.start()
689
+ context = SidecarContext(token, pool, args.workers, args.idle_seconds, args.max_seconds)
690
+ try:
691
+ server = SidecarServer(("127.0.0.1", 0), context)
692
+ except OSError:
693
+ logger.error("could not bind: %s", traceback.format_exc())
694
+ return EXIT_SETUP_FAILED
695
+ port = server.server_address[1]
696
+
697
+ stop_event = threading.Event()
698
+ exit_code = [EXIT_OK]
699
+ is_serving = threading.Event()
700
+
701
+ def start_serving() -> None:
702
+ def serve() -> None:
703
+ is_serving.set()
704
+ try:
705
+ server.serve_forever(poll_interval=SERVE_POLL_SECONDS)
706
+ finally:
707
+ is_serving.clear()
708
+
709
+ threading.Thread(target=serve, name="stt-http", daemon=True).start()
710
+
711
+ def request_exit(code: int) -> None:
712
+ exit_code[0] = code
713
+ stop_event.set()
714
+
715
+ def monitor_idle() -> None:
716
+ while not stop_event.wait(IDLE_CHECK_SECONDS):
717
+ if not context.decide_idle_exit(server.shutdown, start_serving):
718
+ continue
719
+ logger.info("idle for %.0fs; exiting", context.idle_seconds)
720
+ remove_record_if_owned(args.record_file, os.getpid())
721
+ request_exit(EXIT_OK)
722
+ return
723
+
724
+ def monitor_owner() -> None:
725
+ while not stop_event.wait(OWNER_CHECK_SECONDS):
726
+ if is_owner_alive(args.owner_pid, args.owner_start_token):
727
+ continue
728
+ logger.info("owner %d is gone; exiting", args.owner_pid)
729
+ remove_record_if_owned(args.record_file, os.getpid())
730
+ logging.shutdown()
731
+ os._exit(EXIT_OK)
732
+
733
+ for handled in (signal.SIGTERM, signal.SIGINT):
734
+ signal.signal(handled, lambda *_: request_exit(EXIT_OK))
735
+
736
+ try:
737
+ if not publish_port(args, port, token, start_token):
738
+ logger.error("the record was taken away while the model loaded; exiting")
739
+ return EXIT_SETUP_FAILED
740
+ write_json_atomic(args.ready_file, {"pid": os.getpid(), "port": port, "version": SCRIPT_VERSION})
741
+ except OSError:
742
+ logger.error("could not write the record or the ready file: %s", traceback.format_exc())
743
+ return EXIT_SETUP_FAILED
744
+ logger.info("ready on 127.0.0.1:%d", port)
745
+ start_serving()
746
+ threading.Thread(target=monitor_owner, name="stt-owner", daemon=True).start()
747
+ if args.idle_seconds > 0:
748
+ threading.Thread(target=monitor_idle, name="stt-idle", daemon=True).start()
749
+
750
+ while not stop_event.wait(1.0):
751
+ pass
752
+ if is_serving.is_set():
753
+ server.shutdown()
754
+ server.server_close()
755
+ pool.stop()
756
+ logger.info("stopped (exit %d)", exit_code[0])
757
+ logging.shutdown()
758
+ os._exit(exit_code[0])
759
+
760
+
761
+ def parse_args(argv: list[str]) -> argparse.Namespace:
762
+ parser = argparse.ArgumentParser(description="Charness GigaAM speech-recognition sidecar")
763
+ parser.add_argument("--check", action="store_true", help="load the model, recognise 1 s of silence, print versions")
764
+ parser.add_argument("--ready-file", help="written atomically with {pid, port, version} once the server listens")
765
+ parser.add_argument("--record-file", help="claimed by this process before it loads the model, completed with the port once it listens (the bot adopts it from this file); deleted on an idle exit if it names this pid")
766
+ parser.add_argument("--config-hash", help="the bot's hash of the knobs this process was started with, recorded as is")
767
+ parser.add_argument("--log-file", help="where every log line goes")
768
+ parser.add_argument("--owner-pid", type=int, help="exit within ~2 s of this process dying")
769
+ parser.add_argument("--owner-start-token", help="the owner's /proc start time (field 22), to tell a reused pid")
770
+ parser.add_argument("--idle-minutes", type=float, default=15.0, help="exit after this long with no request; 0 = never")
771
+ parser.add_argument("--idle-seconds", type=float, default=None, help=argparse.SUPPRESS)
772
+ parser.add_argument("--workers", type=int, default=4)
773
+ parser.add_argument("--threads", type=int, default=2, help="onnxruntime intra-op threads per worker")
774
+ parser.add_argument("--max-seconds", type=float, default=DEFAULT_MAX_SECONDS, help="give up on one request after this long")
775
+ args = parser.parse_args(argv)
776
+ if not args.check:
777
+ for name in ("ready_file", "record_file", "log_file", "owner_pid", "config_hash"):
778
+ if getattr(args, name) is None:
779
+ parser.error(f"--{name.replace('_', '-')} is required")
780
+ if args.idle_seconds is None:
781
+ args.idle_seconds = args.idle_minutes * SECONDS_PER_MINUTE
782
+ return args
783
+
784
+
785
+ def main(argv: list[str]) -> int:
786
+ args = parse_args(argv)
787
+ if args.check:
788
+ return run_check(args)
789
+ token = os.environ.get(TOKEN_ENV_NAME)
790
+ if not token:
791
+ try:
792
+ sys.stderr.write(f"{TOKEN_ENV_NAME} is required\n")
793
+ except OSError:
794
+ pass
795
+ return EXIT_USAGE
796
+ return run_serve(args, token)
797
+
798
+
799
+ if __name__ == "__main__":
800
+ sys.exit(main(sys.argv[1:]))