charness 3.6.1 → 3.7.0

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 (578) hide show
  1. package/CHANGELOG.md +267 -0
  2. package/DEVELOPMENT.md +31 -5
  3. package/MIGRATION_RENAME_TO_CHARNESS.md +3 -2
  4. package/README.md +84 -782
  5. package/SETUP.md +37 -10
  6. package/agent-plugin/.claude-plugin/plugin.json +7 -0
  7. package/agent-plugin/README.md +35 -0
  8. package/agent-plugin/skills/charness-delegate-claude/SKILL.md +85 -0
  9. package/agent-plugin/skills/charness-delegate-claude/summarize-events.py +90 -0
  10. package/agent-plugin/skills/charness-delegate-opencode/SKILL.md +68 -0
  11. package/agent-plugin/skills/charness-manager/SKILL.md +152 -0
  12. package/dist/adapters/adapterNames.d.ts +6 -0
  13. package/dist/adapters/adapterNames.d.ts.map +1 -1
  14. package/dist/adapters/adapterNames.js +8 -2
  15. package/dist/adapters/adapterNames.js.map +1 -1
  16. package/dist/adapters/claudeCliAdapter.d.ts +8 -0
  17. package/dist/adapters/claudeCliAdapter.d.ts.map +1 -1
  18. package/dist/adapters/claudeCliAdapter.js +42 -5
  19. package/dist/adapters/claudeCliAdapter.js.map +1 -1
  20. package/dist/adapters/claudeJsonStreamAdapter.d.ts +10 -9
  21. package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -1
  22. package/dist/adapters/claudeJsonStreamAdapter.js +53 -55
  23. package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -1
  24. package/dist/adapters/createAdapter.d.ts +8 -0
  25. package/dist/adapters/createAdapter.d.ts.map +1 -1
  26. package/dist/adapters/createAdapter.js +14 -2
  27. package/dist/adapters/createAdapter.js.map +1 -1
  28. package/dist/adapters/openCodeAdapter.d.ts +43 -6
  29. package/dist/adapters/openCodeAdapter.d.ts.map +1 -1
  30. package/dist/adapters/openCodeAdapter.js +95 -6
  31. package/dist/adapters/openCodeAdapter.js.map +1 -1
  32. package/dist/agentLogin/agentLogin.d.ts +1 -1
  33. package/dist/agentLogin/agentLogin.d.ts.map +1 -1
  34. package/dist/apiRetryKick.d.ts +4 -3
  35. package/dist/apiRetryKick.d.ts.map +1 -1
  36. package/dist/apiRetryKick.js +16 -9
  37. package/dist/apiRetryKick.js.map +1 -1
  38. package/dist/bot.d.ts.map +1 -1
  39. package/dist/bot.js +1313 -381
  40. package/dist/bot.js.map +1 -1
  41. package/dist/cli/claudeProfiles.d.ts +11 -6
  42. package/dist/cli/claudeProfiles.d.ts.map +1 -1
  43. package/dist/cli/claudeProfiles.js +50 -22
  44. package/dist/cli/claudeProfiles.js.map +1 -1
  45. package/dist/cli/hot.d.ts +5 -0
  46. package/dist/cli/hot.d.ts.map +1 -1
  47. package/dist/cli/hot.js +71 -25
  48. package/dist/cli/hot.js.map +1 -1
  49. package/dist/cli/tscWatchGuard.d.ts +95 -0
  50. package/dist/cli/tscWatchGuard.d.ts.map +1 -0
  51. package/dist/cli/tscWatchGuard.js +295 -0
  52. package/dist/cli/tscWatchGuard.js.map +1 -0
  53. package/dist/cli.js +12 -1
  54. package/dist/cli.js.map +1 -1
  55. package/dist/connectors/jira/answerSink.d.ts +10 -0
  56. package/dist/connectors/jira/answerSink.d.ts.map +1 -1
  57. package/dist/connectors/jira/answerSink.js +13 -0
  58. package/dist/connectors/jira/answerSink.js.map +1 -1
  59. package/dist/connectors/jira/config.d.ts +2 -0
  60. package/dist/connectors/jira/config.d.ts.map +1 -1
  61. package/dist/connectors/jira/config.js +9 -1
  62. package/dist/connectors/jira/config.js.map +1 -1
  63. package/dist/connectors/jira/connector.d.ts +11 -1
  64. package/dist/connectors/jira/connector.d.ts.map +1 -1
  65. package/dist/connectors/jira/connector.js +7 -0
  66. package/dist/connectors/jira/connector.js.map +1 -1
  67. package/dist/connectors/jira/inbound.d.ts +27 -4
  68. package/dist/connectors/jira/inbound.d.ts.map +1 -1
  69. package/dist/connectors/jira/inbound.js +69 -8
  70. package/dist/connectors/jira/inbound.js.map +1 -1
  71. package/dist/connectors/jira/issueBlocks.d.ts +5 -0
  72. package/dist/connectors/jira/issueBlocks.d.ts.map +1 -1
  73. package/dist/connectors/jira/issueBlocks.js +11 -0
  74. package/dist/connectors/jira/issueBlocks.js.map +1 -1
  75. package/dist/connectors/telegram/bindPicker.d.ts +5 -0
  76. package/dist/connectors/telegram/bindPicker.d.ts.map +1 -1
  77. package/dist/connectors/telegram/bindPicker.js +18 -1
  78. package/dist/connectors/telegram/bindPicker.js.map +1 -1
  79. package/dist/connectors/telegram/bindPickerClaims.d.ts +46 -0
  80. package/dist/connectors/telegram/bindPickerClaims.d.ts.map +1 -0
  81. package/dist/connectors/telegram/bindPickerClaims.js +83 -0
  82. package/dist/connectors/telegram/bindPickerClaims.js.map +1 -0
  83. package/dist/connectors/telegram/bindSweep.d.ts +32 -0
  84. package/dist/connectors/telegram/bindSweep.d.ts.map +1 -0
  85. package/dist/connectors/telegram/bindSweep.js +47 -0
  86. package/dist/connectors/telegram/bindSweep.js.map +1 -0
  87. package/dist/connectors/telegram/bindingWelcome.d.ts +23 -0
  88. package/dist/connectors/telegram/bindingWelcome.d.ts.map +1 -0
  89. package/dist/connectors/telegram/bindingWelcome.js +26 -0
  90. package/dist/connectors/telegram/bindingWelcome.js.map +1 -0
  91. package/dist/connectors/telegram/botApiConnection.d.ts +74 -0
  92. package/dist/connectors/telegram/botApiConnection.d.ts.map +1 -0
  93. package/dist/connectors/telegram/botApiConnection.js +201 -0
  94. package/dist/connectors/telegram/botApiConnection.js.map +1 -0
  95. package/dist/connectors/telegram/commands/botCore.d.ts +8 -2
  96. package/dist/connectors/telegram/commands/botCore.d.ts.map +1 -1
  97. package/dist/connectors/telegram/commands/botSkills.d.ts +21 -0
  98. package/dist/connectors/telegram/commands/botSkills.d.ts.map +1 -0
  99. package/dist/connectors/telegram/commands/botSkills.js +133 -0
  100. package/dist/connectors/telegram/commands/botSkills.js.map +1 -0
  101. package/dist/connectors/telegram/commands/callbackMessage.d.ts +5 -0
  102. package/dist/connectors/telegram/commands/callbackMessage.d.ts.map +1 -1
  103. package/dist/connectors/telegram/commands/callbackMessage.js +9 -0
  104. package/dist/connectors/telegram/commands/callbackMessage.js.map +1 -1
  105. package/dist/connectors/telegram/commands/compaction.d.ts +65 -3
  106. package/dist/connectors/telegram/commands/compaction.d.ts.map +1 -1
  107. package/dist/connectors/telegram/commands/compaction.js +512 -94
  108. package/dist/connectors/telegram/commands/compaction.js.map +1 -1
  109. package/dist/connectors/telegram/commands/displayModes.d.ts +1 -1
  110. package/dist/connectors/telegram/commands/displayModes.d.ts.map +1 -1
  111. package/dist/connectors/telegram/commands/displayModes.js +6 -18
  112. package/dist/connectors/telegram/commands/displayModes.js.map +1 -1
  113. package/dist/connectors/telegram/commands/fallbackChain.js +5 -5
  114. package/dist/connectors/telegram/commands/fallbackChain.js.map +1 -1
  115. package/dist/connectors/telegram/commands/reminders.d.ts +1 -1
  116. package/dist/connectors/telegram/commands/reminders.d.ts.map +1 -1
  117. package/dist/connectors/telegram/commands/reminders.js +4 -15
  118. package/dist/connectors/telegram/commands/reminders.js.map +1 -1
  119. package/dist/connectors/telegram/commands/selfUpdate.d.ts.map +1 -1
  120. package/dist/connectors/telegram/commands/selfUpdate.js +1 -11
  121. package/dist/connectors/telegram/commands/selfUpdate.js.map +1 -1
  122. package/dist/connectors/telegram/commands/taskAgents.d.ts +22 -0
  123. package/dist/connectors/telegram/commands/taskAgents.d.ts.map +1 -0
  124. package/dist/connectors/telegram/commands/taskAgents.js +104 -0
  125. package/dist/connectors/telegram/commands/taskAgents.js.map +1 -0
  126. package/dist/connectors/telegram/commands/todo.d.ts +82 -0
  127. package/dist/connectors/telegram/commands/todo.d.ts.map +1 -0
  128. package/dist/connectors/telegram/commands/todo.js +384 -0
  129. package/dist/connectors/telegram/commands/todo.js.map +1 -0
  130. package/dist/connectors/telegram/commands/topicCommand.d.ts +6 -2
  131. package/dist/connectors/telegram/commands/topicCommand.d.ts.map +1 -1
  132. package/dist/connectors/telegram/commands/topicCommand.js +6 -9
  133. package/dist/connectors/telegram/commands/topicCommand.js.map +1 -1
  134. package/dist/connectors/telegram/openBindMessages.d.ts +45 -0
  135. package/dist/connectors/telegram/openBindMessages.d.ts.map +1 -0
  136. package/dist/connectors/telegram/openBindMessages.js +67 -0
  137. package/dist/connectors/telegram/openBindMessages.js.map +1 -0
  138. package/dist/connectors/telegram/outputFlushPlan.d.ts +49 -0
  139. package/dist/connectors/telegram/outputFlushPlan.d.ts.map +1 -1
  140. package/dist/connectors/telegram/outputFlushPlan.js +46 -0
  141. package/dist/connectors/telegram/outputFlushPlan.js.map +1 -1
  142. package/dist/connectors/telegram/pendingTopicName.d.ts +26 -0
  143. package/dist/connectors/telegram/pendingTopicName.d.ts.map +1 -0
  144. package/dist/connectors/telegram/pendingTopicName.js +27 -0
  145. package/dist/connectors/telegram/pendingTopicName.js.map +1 -0
  146. package/dist/connectors/test/connector.d.ts.map +1 -1
  147. package/dist/connectors/test/connector.js +6 -1
  148. package/dist/connectors/test/connector.js.map +1 -1
  149. package/dist/i18n/de.d.ts.map +1 -1
  150. package/dist/i18n/de.js +90 -7
  151. package/dist/i18n/de.js.map +1 -1
  152. package/dist/i18n/en.d.ts.map +1 -1
  153. package/dist/i18n/en.js +90 -7
  154. package/dist/i18n/en.js.map +1 -1
  155. package/dist/i18n/es.d.ts.map +1 -1
  156. package/dist/i18n/es.js +90 -7
  157. package/dist/i18n/es.js.map +1 -1
  158. package/dist/i18n/fr.d.ts.map +1 -1
  159. package/dist/i18n/fr.js +90 -7
  160. package/dist/i18n/fr.js.map +1 -1
  161. package/dist/i18n/hi.d.ts.map +1 -1
  162. package/dist/i18n/hi.js +90 -7
  163. package/dist/i18n/hi.js.map +1 -1
  164. package/dist/i18n/ja.d.ts.map +1 -1
  165. package/dist/i18n/ja.js +90 -7
  166. package/dist/i18n/ja.js.map +1 -1
  167. package/dist/i18n/ka.d.ts.map +1 -1
  168. package/dist/i18n/ka.js +90 -7
  169. package/dist/i18n/ka.js.map +1 -1
  170. package/dist/i18n/pt.d.ts.map +1 -1
  171. package/dist/i18n/pt.js +90 -7
  172. package/dist/i18n/pt.js.map +1 -1
  173. package/dist/i18n/ru.d.ts.map +1 -1
  174. package/dist/i18n/ru.js +90 -7
  175. package/dist/i18n/ru.js.map +1 -1
  176. package/dist/i18n/uk.d.ts.map +1 -1
  177. package/dist/i18n/uk.js +90 -7
  178. package/dist/i18n/uk.js.map +1 -1
  179. package/dist/i18n/uz.d.ts.map +1 -1
  180. package/dist/i18n/uz.js +90 -7
  181. package/dist/i18n/uz.js.map +1 -1
  182. package/dist/i18n/zh.d.ts.map +1 -1
  183. package/dist/i18n/zh.js +90 -7
  184. package/dist/i18n/zh.js.map +1 -1
  185. package/dist/i18n.d.ts +1 -1
  186. package/dist/i18n.js +1 -1
  187. package/dist/installManager.d.ts +13 -0
  188. package/dist/installManager.d.ts.map +1 -1
  189. package/dist/installManager.js +38 -4
  190. package/dist/installManager.js.map +1 -1
  191. package/dist/instanceSettings.d.ts +40 -1
  192. package/dist/instanceSettings.d.ts.map +1 -1
  193. package/dist/instanceSettings.js +105 -26
  194. package/dist/instanceSettings.js.map +1 -1
  195. package/dist/openCodeQuestionFlow.d.ts +30 -0
  196. package/dist/openCodeQuestionFlow.d.ts.map +1 -1
  197. package/dist/openCodeQuestionFlow.js +43 -0
  198. package/dist/openCodeQuestionFlow.js.map +1 -1
  199. package/dist/postToSession.d.ts +20 -5
  200. package/dist/postToSession.d.ts.map +1 -1
  201. package/dist/postToSession.js +38 -7
  202. package/dist/postToSession.js.map +1 -1
  203. package/dist/remoteRun/cliArgs.d.ts +73 -0
  204. package/dist/remoteRun/cliArgs.d.ts.map +1 -0
  205. package/dist/remoteRun/cliArgs.js +197 -0
  206. package/dist/remoteRun/cliArgs.js.map +1 -0
  207. package/dist/remoteRun/commands.d.ts +56 -0
  208. package/dist/remoteRun/commands.d.ts.map +1 -0
  209. package/dist/remoteRun/commands.js +306 -0
  210. package/dist/remoteRun/commands.js.map +1 -0
  211. package/dist/remoteRun/depsPlan.d.ts +16 -0
  212. package/dist/remoteRun/depsPlan.d.ts.map +1 -0
  213. package/dist/remoteRun/depsPlan.js +82 -0
  214. package/dist/remoteRun/depsPlan.js.map +1 -0
  215. package/dist/remoteRun/exitCodes.d.ts +34 -0
  216. package/dist/remoteRun/exitCodes.d.ts.map +1 -0
  217. package/dist/remoteRun/exitCodes.js +38 -0
  218. package/dist/remoteRun/exitCodes.js.map +1 -0
  219. package/dist/remoteRun/foreground.d.ts +29 -0
  220. package/dist/remoteRun/foreground.d.ts.map +1 -0
  221. package/dist/remoteRun/foreground.js +159 -0
  222. package/dist/remoteRun/foreground.js.map +1 -0
  223. package/dist/remoteRun/interrupts.d.ts +17 -0
  224. package/dist/remoteRun/interrupts.d.ts.map +1 -0
  225. package/dist/remoteRun/interrupts.js +41 -0
  226. package/dist/remoteRun/interrupts.js.map +1 -0
  227. package/dist/remoteRun/jobFiles.d.ts +31 -0
  228. package/dist/remoteRun/jobFiles.d.ts.map +1 -0
  229. package/dist/remoteRun/jobFiles.js +84 -0
  230. package/dist/remoteRun/jobFiles.js.map +1 -0
  231. package/dist/remoteRun/jobFormat.d.ts +16 -0
  232. package/dist/remoteRun/jobFormat.d.ts.map +1 -0
  233. package/dist/remoteRun/jobFormat.js +130 -0
  234. package/dist/remoteRun/jobFormat.js.map +1 -0
  235. package/dist/remoteRun/jobId.d.ts +15 -0
  236. package/dist/remoteRun/jobId.d.ts.map +1 -0
  237. package/dist/remoteRun/jobId.js +85 -0
  238. package/dist/remoteRun/jobId.js.map +1 -0
  239. package/dist/remoteRun/jobMeta.d.ts +18 -0
  240. package/dist/remoteRun/jobMeta.d.ts.map +1 -0
  241. package/dist/remoteRun/jobMeta.js +33 -0
  242. package/dist/remoteRun/jobMeta.js.map +1 -0
  243. package/dist/remoteRun/jobQuery.d.ts +12 -0
  244. package/dist/remoteRun/jobQuery.d.ts.map +1 -0
  245. package/dist/remoteRun/jobQuery.js +22 -0
  246. package/dist/remoteRun/jobQuery.js.map +1 -0
  247. package/dist/remoteRun/jobRecords.d.ts +22 -0
  248. package/dist/remoteRun/jobRecords.d.ts.map +1 -0
  249. package/dist/remoteRun/jobRecords.js +145 -0
  250. package/dist/remoteRun/jobRecords.js.map +1 -0
  251. package/dist/remoteRun/jobState.d.ts +62 -0
  252. package/dist/remoteRun/jobState.d.ts.map +1 -0
  253. package/dist/remoteRun/jobState.js +132 -0
  254. package/dist/remoteRun/jobState.js.map +1 -0
  255. package/dist/remoteRun/leaseTiming.d.ts +18 -0
  256. package/dist/remoteRun/leaseTiming.d.ts.map +1 -0
  257. package/dist/remoteRun/leaseTiming.js +43 -0
  258. package/dist/remoteRun/leaseTiming.js.map +1 -0
  259. package/dist/remoteRun/output.d.ts +35 -0
  260. package/dist/remoteRun/output.d.ts.map +1 -0
  261. package/dist/remoteRun/output.js +82 -0
  262. package/dist/remoteRun/output.js.map +1 -0
  263. package/dist/remoteRun/pathPatterns.d.ts +33 -0
  264. package/dist/remoteRun/pathPatterns.d.ts.map +1 -0
  265. package/dist/remoteRun/pathPatterns.js +115 -0
  266. package/dist/remoteRun/pathPatterns.js.map +1 -0
  267. package/dist/remoteRun/probe.d.ts +56 -0
  268. package/dist/remoteRun/probe.d.ts.map +1 -0
  269. package/dist/remoteRun/probe.js +123 -0
  270. package/dist/remoteRun/probe.js.map +1 -0
  271. package/dist/remoteRun/project.d.ts +26 -0
  272. package/dist/remoteRun/project.d.ts.map +1 -0
  273. package/dist/remoteRun/project.js +159 -0
  274. package/dist/remoteRun/project.js.map +1 -0
  275. package/dist/remoteRun/projectConfig.d.ts +38 -0
  276. package/dist/remoteRun/projectConfig.d.ts.map +1 -0
  277. package/dist/remoteRun/projectConfig.js +87 -0
  278. package/dist/remoteRun/projectConfig.js.map +1 -0
  279. package/dist/remoteRun/remoteRunCli.d.ts +15 -0
  280. package/dist/remoteRun/remoteRunCli.d.ts.map +1 -0
  281. package/dist/remoteRun/remoteRunCli.js +144 -0
  282. package/dist/remoteRun/remoteRunCli.js.map +1 -0
  283. package/dist/remoteRun/remoteRunError.d.ts +14 -0
  284. package/dist/remoteRun/remoteRunError.d.ts.map +1 -0
  285. package/dist/remoteRun/remoteRunError.js +28 -0
  286. package/dist/remoteRun/remoteRunError.js.map +1 -0
  287. package/dist/remoteRun/rsync.d.ts +17 -0
  288. package/dist/remoteRun/rsync.d.ts.map +1 -0
  289. package/dist/remoteRun/rsync.js +39 -0
  290. package/dist/remoteRun/rsync.js.map +1 -0
  291. package/dist/remoteRun/runnerClient.d.ts +50 -0
  292. package/dist/remoteRun/runnerClient.d.ts.map +1 -0
  293. package/dist/remoteRun/runnerClient.js +139 -0
  294. package/dist/remoteRun/runnerClient.js.map +1 -0
  295. package/dist/remoteRun/runners.d.ts +57 -0
  296. package/dist/remoteRun/runners.d.ts.map +1 -0
  297. package/dist/remoteRun/runners.js +181 -0
  298. package/dist/remoteRun/runners.js.map +1 -0
  299. package/dist/remoteRun/runnersConfig.d.ts +44 -0
  300. package/dist/remoteRun/runnersConfig.d.ts.map +1 -0
  301. package/dist/remoteRun/runnersConfig.js +107 -0
  302. package/dist/remoteRun/runnersConfig.js.map +1 -0
  303. package/dist/remoteRun/safeCopy.d.ts +31 -0
  304. package/dist/remoteRun/safeCopy.d.ts.map +1 -0
  305. package/dist/remoteRun/safeCopy.js +137 -0
  306. package/dist/remoteRun/safeCopy.js.map +1 -0
  307. package/dist/remoteRun/sendPlan.d.ts +67 -0
  308. package/dist/remoteRun/sendPlan.d.ts.map +1 -0
  309. package/dist/remoteRun/sendPlan.js +139 -0
  310. package/dist/remoteRun/sendPlan.js.map +1 -0
  311. package/dist/remoteRun/ssh.d.ts +62 -0
  312. package/dist/remoteRun/ssh.d.ts.map +1 -0
  313. package/dist/remoteRun/ssh.js +202 -0
  314. package/dist/remoteRun/ssh.js.map +1 -0
  315. package/dist/remoteRun/submit.d.ts +29 -0
  316. package/dist/remoteRun/submit.d.ts.map +1 -0
  317. package/dist/remoteRun/submit.js +142 -0
  318. package/dist/remoteRun/submit.js.map +1 -0
  319. package/dist/requests/answerRequest.d.ts +5 -0
  320. package/dist/requests/answerRequest.d.ts.map +1 -1
  321. package/dist/requests/answerRequest.js +16 -1
  322. package/dist/requests/answerRequest.js.map +1 -1
  323. package/dist/requests/requestLedger.d.ts +13 -0
  324. package/dist/requests/requestLedger.d.ts.map +1 -1
  325. package/dist/requests/requestLedger.js +9 -0
  326. package/dist/requests/requestLedger.js.map +1 -1
  327. package/dist/requests/topicRequestSettle.d.ts +30 -0
  328. package/dist/requests/topicRequestSettle.d.ts.map +1 -0
  329. package/dist/requests/topicRequestSettle.js +23 -0
  330. package/dist/requests/topicRequestSettle.js.map +1 -0
  331. package/dist/requests/wakeUpEngine.d.ts +4 -0
  332. package/dist/requests/wakeUpEngine.d.ts.map +1 -1
  333. package/dist/requests/wakeUpEngine.js +5 -1
  334. package/dist/requests/wakeUpEngine.js.map +1 -1
  335. package/dist/scheduler/delivery.d.ts +2 -0
  336. package/dist/scheduler/delivery.d.ts.map +1 -1
  337. package/dist/scheduler/delivery.js +8 -4
  338. package/dist/scheduler/delivery.js.map +1 -1
  339. package/dist/scheduler/mcpSurface.d.ts +59 -10
  340. package/dist/scheduler/mcpSurface.d.ts.map +1 -1
  341. package/dist/scheduler/mcpSurface.js +276 -79
  342. package/dist/scheduler/mcpSurface.js.map +1 -1
  343. package/dist/scheduler/recurrence.d.ts +5 -0
  344. package/dist/scheduler/recurrence.d.ts.map +1 -1
  345. package/dist/scheduler/recurrence.js +12 -2
  346. package/dist/scheduler/recurrence.js.map +1 -1
  347. package/dist/scheduler/strictToolInput.d.ts +45 -0
  348. package/dist/scheduler/strictToolInput.d.ts.map +1 -0
  349. package/dist/scheduler/strictToolInput.js +155 -0
  350. package/dist/scheduler/strictToolInput.js.map +1 -0
  351. package/dist/scheduler/types.d.ts +5 -0
  352. package/dist/scheduler/types.d.ts.map +1 -1
  353. package/dist/scheduler/workerWatch.d.ts +23 -0
  354. package/dist/scheduler/workerWatch.d.ts.map +1 -0
  355. package/dist/scheduler/workerWatch.js +26 -0
  356. package/dist/scheduler/workerWatch.js.map +1 -0
  357. package/dist/selfUpdate/emittedSource.d.ts +11 -0
  358. package/dist/selfUpdate/emittedSource.d.ts.map +1 -0
  359. package/dist/selfUpdate/emittedSource.js +24 -0
  360. package/dist/selfUpdate/emittedSource.js.map +1 -0
  361. package/dist/selfUpdate/gitSource.d.ts +0 -6
  362. package/dist/selfUpdate/gitSource.d.ts.map +1 -1
  363. package/dist/selfUpdate/gitSource.js +2 -18
  364. package/dist/selfUpdate/gitSource.js.map +1 -1
  365. package/dist/selfUpdate/updatePending.d.ts +1 -1
  366. package/dist/sendErrorClassifier.d.ts +10 -0
  367. package/dist/sendErrorClassifier.d.ts.map +1 -1
  368. package/dist/sendErrorClassifier.js +22 -0
  369. package/dist/sendErrorClassifier.js.map +1 -1
  370. package/dist/state.d.ts +78 -7
  371. package/dist/state.d.ts.map +1 -1
  372. package/dist/state.js +125 -8
  373. package/dist/state.js.map +1 -1
  374. package/dist/stateFile.d.ts +9 -0
  375. package/dist/stateFile.d.ts.map +1 -0
  376. package/dist/stateFile.js +57 -0
  377. package/dist/stateFile.js.map +1 -0
  378. package/dist/stt/sttInstall.d.ts +7 -3
  379. package/dist/stt/sttInstall.d.ts.map +1 -1
  380. package/dist/stt/sttInstall.js +8 -4
  381. package/dist/stt/sttInstall.js.map +1 -1
  382. package/dist/stt/sttSettings.d.ts +2 -1
  383. package/dist/stt/sttSettings.d.ts.map +1 -1
  384. package/dist/stt/sttSettings.js +3 -2
  385. package/dist/stt/sttSettings.js.map +1 -1
  386. package/dist/stt/transcribeChain.js +2 -0
  387. package/dist/stt/transcribeChain.js.map +1 -1
  388. package/dist/testTimings.d.ts +13 -3
  389. package/dist/testTimings.d.ts.map +1 -1
  390. package/dist/testTimings.js +26 -2
  391. package/dist/testTimings.js.map +1 -1
  392. package/dist/todos/board.d.ts +83 -0
  393. package/dist/todos/board.d.ts.map +1 -0
  394. package/dist/todos/board.js +227 -0
  395. package/dist/todos/board.js.map +1 -0
  396. package/dist/todos/format.d.ts +33 -0
  397. package/dist/todos/format.d.ts.map +1 -0
  398. package/dist/todos/format.js +90 -0
  399. package/dist/todos/format.js.map +1 -0
  400. package/dist/todos/merge.d.ts +46 -0
  401. package/dist/todos/merge.d.ts.map +1 -0
  402. package/dist/todos/merge.js +137 -0
  403. package/dist/todos/merge.js.map +1 -0
  404. package/dist/todos/midTurn.d.ts +75 -0
  405. package/dist/todos/midTurn.d.ts.map +1 -0
  406. package/dist/todos/midTurn.js +214 -0
  407. package/dist/todos/midTurn.js.map +1 -0
  408. package/dist/todos/nag.d.ts +85 -0
  409. package/dist/todos/nag.d.ts.map +1 -0
  410. package/dist/todos/nag.js +103 -0
  411. package/dist/todos/nag.js.map +1 -0
  412. package/dist/todos/order.d.ts +25 -0
  413. package/dist/todos/order.d.ts.map +1 -0
  414. package/dist/todos/order.js +61 -0
  415. package/dist/todos/order.js.map +1 -0
  416. package/dist/todos/projects.d.ts +102 -0
  417. package/dist/todos/projects.d.ts.map +1 -0
  418. package/dist/todos/projects.js +342 -0
  419. package/dist/todos/projects.js.map +1 -0
  420. package/dist/todos/registry.d.ts +64 -0
  421. package/dist/todos/registry.d.ts.map +1 -0
  422. package/dist/todos/registry.js +116 -0
  423. package/dist/todos/registry.js.map +1 -0
  424. package/dist/todos/reminderEngine.d.ts +78 -0
  425. package/dist/todos/reminderEngine.d.ts.map +1 -0
  426. package/dist/todos/reminderEngine.js +162 -0
  427. package/dist/todos/reminderEngine.js.map +1 -0
  428. package/dist/todos/store.d.ts +34 -0
  429. package/dist/todos/store.d.ts.map +1 -0
  430. package/dist/todos/store.js +208 -0
  431. package/dist/todos/store.js.map +1 -0
  432. package/dist/todos/tools.d.ts +26 -0
  433. package/dist/todos/tools.d.ts.map +1 -0
  434. package/dist/todos/tools.js +25 -0
  435. package/dist/todos/tools.js.map +1 -0
  436. package/dist/todos/types.d.ts +85 -0
  437. package/dist/todos/types.d.ts.map +1 -0
  438. package/dist/todos/types.js +30 -0
  439. package/dist/todos/types.js.map +1 -0
  440. package/dist/trackers/taskAgentLimit.d.ts +58 -0
  441. package/dist/trackers/taskAgentLimit.d.ts.map +1 -0
  442. package/dist/trackers/taskAgentLimit.js +62 -0
  443. package/dist/trackers/taskAgentLimit.js.map +1 -0
  444. package/dist/trackers/taskAgentSlots.d.ts +109 -0
  445. package/dist/trackers/taskAgentSlots.d.ts.map +1 -0
  446. package/dist/trackers/taskAgentSlots.js +227 -0
  447. package/dist/trackers/taskAgentSlots.js.map +1 -0
  448. package/dist/trackers/types.d.ts +14 -0
  449. package/dist/trackers/types.d.ts.map +1 -0
  450. package/dist/trackers/types.js +3 -0
  451. package/dist/trackers/types.js.map +1 -0
  452. package/dist/types.d.ts +80 -4
  453. package/dist/types.d.ts.map +1 -1
  454. package/dist/utils/adoptReplayGate.d.ts +16 -0
  455. package/dist/utils/adoptReplayGate.d.ts.map +1 -0
  456. package/dist/utils/adoptReplayGate.js +35 -0
  457. package/dist/utils/adoptReplayGate.js.map +1 -0
  458. package/dist/utils/agentPlugin.d.ts +113 -0
  459. package/dist/utils/agentPlugin.d.ts.map +1 -0
  460. package/dist/utils/agentPlugin.js +192 -0
  461. package/dist/utils/agentPlugin.js.map +1 -0
  462. package/dist/utils/autoContinueOnLimit.d.ts +7 -4
  463. package/dist/utils/autoContinueOnLimit.d.ts.map +1 -1
  464. package/dist/utils/autoContinueOnLimit.js +8 -5
  465. package/dist/utils/autoContinueOnLimit.js.map +1 -1
  466. package/dist/utils/botSkills.d.ts +62 -0
  467. package/dist/utils/botSkills.d.ts.map +1 -0
  468. package/dist/utils/botSkills.js +53 -0
  469. package/dist/utils/botSkills.js.map +1 -0
  470. package/dist/utils/botSkillsSetting.d.ts +7 -0
  471. package/dist/utils/botSkillsSetting.d.ts.map +1 -0
  472. package/dist/utils/botSkillsSetting.js +9 -0
  473. package/dist/utils/botSkillsSetting.js.map +1 -0
  474. package/dist/utils/claudeCodeVersion.d.ts +7 -0
  475. package/dist/utils/claudeCodeVersion.d.ts.map +1 -1
  476. package/dist/utils/claudeCodeVersion.js +14 -1
  477. package/dist/utils/claudeCodeVersion.js.map +1 -1
  478. package/dist/utils/claudeCompactHook.d.ts +33 -15
  479. package/dist/utils/claudeCompactHook.d.ts.map +1 -1
  480. package/dist/utils/claudeCompactHook.js +36 -17
  481. package/dist/utils/claudeCompactHook.js.map +1 -1
  482. package/dist/utils/claudeCompactSummary.d.ts +15 -0
  483. package/dist/utils/claudeCompactSummary.d.ts.map +1 -0
  484. package/dist/utils/claudeCompactSummary.js +96 -0
  485. package/dist/utils/claudeCompactSummary.js.map +1 -0
  486. package/dist/utils/claudeLimitSwitch.d.ts +11 -9
  487. package/dist/utils/claudeLimitSwitch.d.ts.map +1 -1
  488. package/dist/utils/claudeLimitSwitch.js +18 -16
  489. package/dist/utils/claudeLimitSwitch.js.map +1 -1
  490. package/dist/utils/claudeProfiles.d.ts +5 -0
  491. package/dist/utils/claudeProfiles.d.ts.map +1 -1
  492. package/dist/utils/claudeProfiles.js +14 -0
  493. package/dist/utils/claudeProfiles.js.map +1 -1
  494. package/dist/utils/claudeSessionTitle.d.ts +2 -1
  495. package/dist/utils/claudeSessionTitle.d.ts.map +1 -1
  496. package/dist/utils/claudeSessionTitle.js +14 -14
  497. package/dist/utils/claudeSessionTitle.js.map +1 -1
  498. package/dist/utils/claudeUsage.d.ts +95 -15
  499. package/dist/utils/claudeUsage.d.ts.map +1 -1
  500. package/dist/utils/claudeUsage.js +241 -41
  501. package/dist/utils/claudeUsage.js.map +1 -1
  502. package/dist/utils/claudeUsageRefresher.d.ts +10 -0
  503. package/dist/utils/claudeUsageRefresher.d.ts.map +1 -1
  504. package/dist/utils/claudeUsageRefresher.js +11 -0
  505. package/dist/utils/claudeUsageRefresher.js.map +1 -1
  506. package/dist/utils/claudeUserEntry.d.ts +50 -0
  507. package/dist/utils/claudeUserEntry.d.ts.map +1 -0
  508. package/dist/utils/claudeUserEntry.js +120 -0
  509. package/dist/utils/claudeUserEntry.js.map +1 -0
  510. package/dist/utils/clearThreadOutputQueues.d.ts +4 -3
  511. package/dist/utils/clearThreadOutputQueues.d.ts.map +1 -1
  512. package/dist/utils/clearThreadOutputQueues.js +1 -1
  513. package/dist/utils/clearThreadOutputQueues.js.map +1 -1
  514. package/dist/utils/compactOnIdle.d.ts +33 -13
  515. package/dist/utils/compactOnIdle.d.ts.map +1 -1
  516. package/dist/utils/compactOnIdle.js +35 -15
  517. package/dist/utils/compactOnIdle.js.map +1 -1
  518. package/dist/utils/fallbackBridge.d.ts +5 -0
  519. package/dist/utils/fallbackBridge.d.ts.map +1 -1
  520. package/dist/utils/fallbackBridge.js +19 -3
  521. package/dist/utils/fallbackBridge.js.map +1 -1
  522. package/dist/utils/fallbackChain.d.ts +9 -3
  523. package/dist/utils/fallbackChain.d.ts.map +1 -1
  524. package/dist/utils/fallbackChain.js +21 -3
  525. package/dist/utils/fallbackChain.js.map +1 -1
  526. package/dist/utils/fileSendPlan.d.ts +6 -0
  527. package/dist/utils/fileSendPlan.d.ts.map +1 -1
  528. package/dist/utils/fileSendPlan.js +11 -0
  529. package/dist/utils/fileSendPlan.js.map +1 -1
  530. package/dist/utils/fileSendService.d.ts +7 -1
  531. package/dist/utils/fileSendService.d.ts.map +1 -1
  532. package/dist/utils/fileSendService.js +6 -2
  533. package/dist/utils/fileSendService.js.map +1 -1
  534. package/dist/utils/groupFinalizePlan.d.ts +8 -10
  535. package/dist/utils/groupFinalizePlan.d.ts.map +1 -1
  536. package/dist/utils/groupFinalizePlan.js +8 -12
  537. package/dist/utils/groupFinalizePlan.js.map +1 -1
  538. package/dist/utils/jsonStreamHost.d.ts +13 -0
  539. package/dist/utils/jsonStreamHost.d.ts.map +1 -1
  540. package/dist/utils/jsonStreamHost.js +13 -0
  541. package/dist/utils/jsonStreamHost.js.map +1 -1
  542. package/dist/utils/messageSendService.d.ts +5 -3
  543. package/dist/utils/messageSendService.d.ts.map +1 -1
  544. package/dist/utils/messageSendService.js +12 -5
  545. package/dist/utils/messageSendService.js.map +1 -1
  546. package/dist/utils/openCodeCompactPlugin.d.ts +39 -14
  547. package/dist/utils/openCodeCompactPlugin.d.ts.map +1 -1
  548. package/dist/utils/openCodeCompactPlugin.js +106 -38
  549. package/dist/utils/openCodeCompactPlugin.js.map +1 -1
  550. package/dist/utils/promptDelivery.d.ts +29 -12
  551. package/dist/utils/promptDelivery.d.ts.map +1 -1
  552. package/dist/utils/promptDelivery.js +13 -5
  553. package/dist/utils/promptDelivery.js.map +1 -1
  554. package/dist/utils/sentKindsSummary.d.ts +10 -0
  555. package/dist/utils/sentKindsSummary.d.ts.map +1 -0
  556. package/dist/utils/sentKindsSummary.js +18 -0
  557. package/dist/utils/sentKindsSummary.js.map +1 -0
  558. package/dist/utils/telegramError.d.ts +6 -0
  559. package/dist/utils/telegramError.d.ts.map +1 -1
  560. package/dist/utils/telegramError.js +12 -0
  561. package/dist/utils/telegramError.js.map +1 -1
  562. package/dist/utils/topicView.d.ts +1 -1
  563. package/dist/utils/topicView.js +1 -1
  564. package/dist/utils/transcribeAudio.d.ts +10 -0
  565. package/dist/utils/transcribeAudio.d.ts.map +1 -1
  566. package/dist/utils/transcribeAudio.js +22 -3
  567. package/dist/utils/transcribeAudio.js.map +1 -1
  568. package/dist/utils/transcriptErrorTime.d.ts +8 -0
  569. package/dist/utils/transcriptErrorTime.d.ts.map +1 -0
  570. package/dist/utils/transcriptErrorTime.js +56 -0
  571. package/dist/utils/transcriptErrorTime.js.map +1 -0
  572. package/dist/utils/wedgeRecovery.d.ts +2 -0
  573. package/dist/utils/wedgeRecovery.d.ts.map +1 -1
  574. package/dist/utils/wedgeRecovery.js +1 -1
  575. package/dist/utils/wedgeRecovery.js.map +1 -1
  576. package/package.json +11 -3
  577. package/remoteRun/runner.sh +1242 -0
  578. package/stt/gigaam_sidecar.py +555 -50
@@ -5,8 +5,10 @@ ONE process: it loads the model at start, binds 127.0.0.1 on an OS-chosen port a
5
5
  `POST /v1/audio/transcriptions` plus `GET /health`. The bot starts it on demand, adopts it across hot reloads,
6
6
  and it takes itself down when it has been idle (`--idle-minutes`) or when its owner process is gone.
7
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.
8
+ Every voice is cut at the centre of each pause of at least 500 ms that Silero VAD finds (the model drops a whole
9
+ phrase that follows a long pause when it decodes several phrases in one pass); no audio is ever discarded, and a
10
+ piece longer than 25 s is cut again at its quietest 50 ms. The pieces are recognised in parallel on a pool of
11
+ threads that share ONE onnxruntime session, strictly in arrival order (a short voice never overtakes an earlier one).
10
12
 
11
13
  It claims the bot's record before it loads the model (an exclusive create, so a second start of the same engine
12
14
  exits instead of becoming an unrecorded orphan) and completes it with its port once it listens.
@@ -21,19 +23,22 @@ Protocol and layout: `src/stt/README.md`.
21
23
  from __future__ import annotations
22
24
 
23
25
  import argparse
26
+ import collections
27
+ import itertools
24
28
  import contextlib
29
+ import dataclasses
25
30
  import datetime
26
31
  import email.parser
27
32
  import email.policy
28
33
  import hmac
29
34
  import http.server
30
35
  import io
31
- import itertools
32
36
  import json
33
37
  import logging
34
38
  import logging.handlers
35
39
  import os
36
- import queue
40
+ import select
41
+ import socket
37
42
  import signal
38
43
  import subprocess
39
44
  import sys
@@ -44,7 +49,7 @@ import traceback
44
49
  import numpy as np
45
50
 
46
51
  # Bumped with every change of the sidecar's behaviour; the bot hashes it into the adoption check.
47
- SCRIPT_VERSION = "1"
52
+ SCRIPT_VERSION = "3"
48
53
  MODEL_NAME = "gigaam-v3-e2e-rnnt"
49
54
  MODEL_QUANTIZATION = "int8"
50
55
  TOKEN_ENV_NAME = "CHARNESS_STT_SIDECAR_TOKEN"
@@ -56,12 +61,46 @@ EXIT_USAGE = 2
56
61
  EXIT_DUPLICATE = 4
57
62
 
58
63
  MAX_CHUNK_SECONDS = 25.0
64
+ # The rate the model and the VAD work at: the upload is resampled to it once.
65
+ SPEECH_SAMPLE_RATE = 16000
66
+ VAD_MODEL_NAME = "silero"
67
+ # A gap this long between two stretches of speech the VAD detects ends a piece. Measured on real speech (919 phrases,
68
+ # pauses of 0.3-2 s): 11 phrases lost against 10 when decoding each phrase alone (39 in one pass), and it splits 56 of 356
69
+ # continuous sentences with no loss of accuracy (4.91 % -> 4.86 % WER).
70
+ MIN_PAUSE_MS = 500
71
+ VAD_SPEECH_PAD_MS = 100
72
+ # onnx-asr compares the raw gap with `min_silence_duration_ms + 2 * speech_pad_ms`: given this, a gap of
73
+ # `MIN_PAUSE_MS` is what ends a piece.
74
+ VAD_MIN_SILENCE_MS = MIN_PAUSE_MS - 2 * VAD_SPEECH_PAD_MS
75
+ # The upload is resampled to 16 kHz window by window: onnxruntime keeps the largest buffer a session ever needed, and
76
+ # a 5-minute voice resampled at once raised the peak by ~240 MB (profiled; 30 s windows: ~66 MB). The margin on each
77
+ # side is trimmed away again; with it the joined windows are identical to a one-pass resample on every sample, except
78
+ # that the last one may be dropped (the trims round, the model rounds up: at most 63 µs; measured at every supported
79
+ # rate). Both are whole seconds, so their length at 16 kHz is a whole number of samples for every supported rate.
80
+ RESAMPLE_WINDOW_SECONDS = 30
81
+ RESAMPLE_MARGIN_SECONDS = 1
82
+ # The install check's 1 s of silence is at Telegram's Opus rate, so it runs the resampler too, and a VAD that fails
83
+ # shows as pieces left at this rate (the fallback keeps the file's own rate).
84
+ CHECK_SAMPLE_RATE = 48000
59
85
  SILENCE_SEARCH_SECONDS = 8.0
60
86
  SILENCE_WINDOW_SECONDS = 0.05
61
87
  # The encoder fails on fewer than ~25 ms of audio; a piece this short holds no word anyway.
62
88
  MIN_CHUNK_SECONDS = 0.1
63
89
  # The sample rates onnx-asr resamples to 16 kHz itself.
64
90
  SUPPORTED_SAMPLE_RATES = (8000, 11025, 16000, 22050, 24000, 32000, 44100, 48000)
91
+ # The decoders the install check wants in the `av` package (ffmpeg): AAC (M4A), MP3, Vorbis and Opus.
92
+ FFMPEG_REQUIRED_DECODERS = ("aac", "mp3", "vorbis", "opus")
93
+ # The upload's bytes choose ffmpeg's demuxer, and a few of them open further inputs: a text file in SDP form makes it open
94
+ # an RTP socket and wait for 20 s. A file handed over as bytes needs no protocol, so only the two that read nothing from
95
+ # outside are allowed.
96
+ FFMPEG_PROTOCOL_WHITELIST = "crypto,data"
97
+ # Ogg page header: "OggS", version, a flags byte (bit 1 = first page of a stream), granule position, serial, sequence,
98
+ # checksum, then the number of lace values, which are followed by that many bytes of lace values.
99
+ OGG_CAPTURE_PATTERN = b"OggS"
100
+ OGG_PAGE_HEADER_BYTES = 27
101
+ OGG_FLAGS_OFFSET = 5
102
+ OGG_LACE_COUNT_OFFSET = 26
103
+ OGG_FIRST_PAGE_FLAG = 0x02
65
104
 
66
105
  MAX_BODY_BYTES = 25 * 1024 * 1024
67
106
  MAX_HEADER_LINE_BYTES = 8192
@@ -81,10 +120,20 @@ CLAIM_ATTEMPTS = 3
81
120
  SECONDS_PER_MINUTE = 60.0
82
121
  BEARER_PREFIX = "Bearer "
83
122
  TRANSCRIPTIONS_PATH = "/v1/audio/transcriptions"
123
+ # Once a voice is in line the sidecar answers an interim 102 naming the seconds of audio queued ahead of it, so the
124
+ # bot's timeout can count the wait in line (voices are recognised strictly in arrival order).
125
+ QUEUE_REPORT_STATUS = 102
126
+ QUEUED_AUDIO_SECONDS_HEADER = "X-Charness-Queued-Audio-Seconds"
127
+ # How often a waiting handler checks that its client is still connected, and whether the queue ahead grew.
128
+ CLIENT_CHECK_SECONDS = 0.5
129
+ # A waiting voice is told the queue ahead again when it has grown by more than this: a safeguard for a voice ahead whose
130
+ # length was still unknown when this one was reported (see `SIZING_WAIT_SECONDS`).
131
+ QUEUE_REPORT_GROWTH_SECONDS = 1.0
132
+ # A voice takes its place in line before its length is read (reading takes a few milliseconds, more for a long file with
133
+ # no length header), so the voices behind it wait this long at most for that length before they report their queue.
134
+ SIZING_WAIT_SECONDS = 2.0
84
135
  HEALTH_PATH = "/health"
85
136
  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
137
 
89
138
  logger = logging.getLogger("gigaam_sidecar")
90
139
 
@@ -102,6 +151,10 @@ class RecognitionTimeout(Exception):
102
151
  """A request ran past `--max-seconds`."""
103
152
 
104
153
 
154
+ class ClientGone(Exception):
155
+ """The client hung up while its voice waited (it timed out, or the bot moved on): nobody would read the text."""
156
+
157
+
105
158
  # ──────────────────────────────── audio ────────────────────────────────
106
159
 
107
160
 
@@ -132,8 +185,173 @@ def split_at_silences(
132
185
  return pieces
133
186
 
134
187
 
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."""
188
+ def split_at_pauses(audio: np.ndarray, speech_segments: list[tuple[int, int]], sample_rate: int) -> list[np.ndarray]:
189
+ """Cut `audio` at the centre of the gap between every two consecutive speech segments (sample offsets, in
190
+ order), then cut any piece longer than `MAX_CHUNK_SECONDS` with `split_at_silences`. The segments only choose
191
+ where to cut: the pieces joined give back `audio` exactly, so speech the detector missed is still recognised."""
192
+ cuts = [0]
193
+ for (_, previous_end), (next_start, _) in zip(speech_segments, speech_segments[1:]):
194
+ cut = (previous_end + next_start) // 2
195
+ if cuts[-1] < cut < len(audio):
196
+ cuts.append(cut)
197
+ cuts.append(len(audio))
198
+ pieces: list[np.ndarray] = []
199
+ for start, end in zip(cuts, cuts[1:]):
200
+ pieces.extend(split_at_silences(audio[start:end], sample_rate))
201
+ return pieces
202
+
203
+
204
+ def split_without_pauses(samples: np.ndarray, sample_rate: int) -> tuple[list[np.ndarray], int]:
205
+ """The splitter without a VAD: only the 25 s cuts, at the file's own rate."""
206
+ return split_at_silences(samples, sample_rate), sample_rate
207
+
208
+
209
+ def make_pause_splitter(resample, find_speech):
210
+ """The request splitter: `resample(samples, rate)` to `SPEECH_SAMPLE_RATE`, then `find_speech(audio)` → speech
211
+ segments, then `split_at_pauses`. Returns the pieces and their rate. A failing resample or VAD falls back to
212
+ `split_without_pauses`: the voice is still transcribed, only cut the old way."""
213
+
214
+ def split(samples: np.ndarray, sample_rate: int) -> tuple[list[np.ndarray], int]:
215
+ try:
216
+ audio = resample(samples, sample_rate)
217
+ speech_segments = find_speech(audio)
218
+ except Exception:
219
+ logger.error("pause detection failed, cutting at %.0f s only: %s", MAX_CHUNK_SECONDS, traceback.format_exc())
220
+ return split_without_pauses(samples, sample_rate)
221
+ return split_at_pauses(audio, speech_segments, SPEECH_SAMPLE_RATE), SPEECH_SAMPLE_RATE
222
+
223
+ return split
224
+
225
+
226
+ def resample_in_windows(resample_once, samples: np.ndarray, sample_rate: int) -> np.ndarray:
227
+ """`resample_once(samples, rate)` → 16 kHz, applied to `RESAMPLE_WINDOW_SECONDS` windows that each read
228
+ `RESAMPLE_MARGIN_SECONDS` beyond both ends; the margins are cut off the output again and the windows joined."""
229
+ if sample_rate == SPEECH_SAMPLE_RATE:
230
+ return samples
231
+ ratio = SPEECH_SAMPLE_RATE / sample_rate
232
+ window = RESAMPLE_WINDOW_SECONDS * sample_rate
233
+ margin = RESAMPLE_MARGIN_SECONDS * sample_rate
234
+ parts = []
235
+ for start in range(0, len(samples), window):
236
+ end = min(start + window, len(samples))
237
+ read_from = max(0, start - margin)
238
+ resampled = resample_once(samples[read_from : min(len(samples), end + margin)], sample_rate)
239
+ keep_from = round((start - read_from) * ratio)
240
+ parts.append(resampled[keep_from : keep_from + round((end - start) * ratio)])
241
+ return np.concatenate(parts) if parts else samples[:0]
242
+
243
+
244
+ def make_model_pause_splitter(model, vad):
245
+ """`make_pause_splitter` over the loaded model's own resampler and the Silero VAD."""
246
+
247
+ def resample_once(samples: np.ndarray, sample_rate: int) -> np.ndarray:
248
+ waveforms, lengths = model.resampler(samples[None], np.array([len(samples)], dtype=np.int64), sample_rate)
249
+ return np.ascontiguousarray(waveforms[0][: int(lengths[0])], dtype=np.float32)
250
+
251
+ def resample(samples: np.ndarray, sample_rate: int) -> np.ndarray:
252
+ return resample_in_windows(resample_once, samples, sample_rate)
253
+
254
+ def find_speech(audio: np.ndarray) -> list[tuple[int, int]]:
255
+ (segments,) = vad.segment_batch(
256
+ audio[None],
257
+ np.array([len(audio)], dtype=np.int64),
258
+ SPEECH_SAMPLE_RATE,
259
+ min_silence_duration_ms=VAD_MIN_SILENCE_MS,
260
+ speech_pad_ms=VAD_SPEECH_PAD_MS,
261
+ # Never let the VAD cut a long stretch of speech itself: `split_at_pauses` caps the pieces at a quiet spot.
262
+ max_speech_duration_s=len(audio) / SPEECH_SAMPLE_RATE + 1,
263
+ )
264
+ return [(int(start), int(end)) for start, end in segments]
265
+
266
+ return make_pause_splitter(resample, find_speech)
267
+
268
+
269
+ def find_ogg_stream_starts(data: bytes) -> list[int]:
270
+ """The offsets where each logical stream of an Ogg file begins: more than one means chained streams (two files one
271
+ after the other). Only the page headers are walked (a packet that happens to hold "OggS" is not read), so the cost
272
+ is a few bytes per page. Streams multiplexed side by side open with consecutive first pages and count once. Empty
273
+ when the data does not start with an Ogg page; a damaged page ends the walk, the rest counts as the last stream."""
274
+ starts: list[int] = []
275
+ is_in_opening_pages = False
276
+ offset = 0
277
+ while offset + OGG_PAGE_HEADER_BYTES <= len(data) and data.startswith(OGG_CAPTURE_PATTERN, offset):
278
+ lace_end = offset + OGG_PAGE_HEADER_BYTES + data[offset + OGG_LACE_COUNT_OFFSET]
279
+ if lace_end > len(data):
280
+ break
281
+ is_first_page = bool(data[offset + OGG_FLAGS_OFFSET] & OGG_FIRST_PAGE_FLAG)
282
+ if is_first_page and not is_in_opening_pages:
283
+ starts.append(offset)
284
+ is_in_opening_pages = is_first_page
285
+ offset = lace_end + sum(data[offset + OGG_PAGE_HEADER_BYTES : lace_end])
286
+ return starts
287
+
288
+
289
+ def split_ogg_streams(data: bytes) -> list[bytes]:
290
+ """The streams of a chained Ogg file as separate files (the file itself when it holds one or is not Ogg)."""
291
+ starts = find_ogg_stream_starts(data)
292
+ if len(starts) < 2:
293
+ return [data]
294
+ return [data[start:end] for start, end in zip(starts, starts[1:] + [len(data)])]
295
+
296
+
297
+ @dataclasses.dataclass(frozen=True)
298
+ class AudioUpload:
299
+ """An uploaded file and the route it takes, decided ONCE by `inspect_audio` and handed to the length read and the
300
+ decode: `header_seconds` is libsndfile's own length when libsndfile reads the file whole, None when ffmpeg decodes it;
301
+ `streams` are what ffmpeg is given (the file, or the streams of a chained Ogg apart)."""
302
+
303
+ data: bytes
304
+ header_seconds: float | None
305
+ streams: list[bytes]
306
+
307
+
308
+ def inspect_audio(data: bytes) -> AudioUpload:
309
+ """Decide how an upload is read. libsndfile reads as many frames as the file's header says, which is exact for WAV,
310
+ FLAC and one Ogg stream. It is not for an MP3 without a Xing / Info / VBRI frame (the length is an estimate from
311
+ the first frame's bitrate, and the read stops there: a variable-bitrate file lost 33 s of 40) nor for chained Ogg
312
+ streams (only the first is read, and a chained Vorbis even reports an absurd length); and it cannot open M4A / AAC
313
+ at all. Those go to ffmpeg."""
314
+ import soundfile
315
+
316
+ streams = split_ogg_streams(data)
317
+ try:
318
+ info = soundfile.info(io.BytesIO(data))
319
+ except Exception: # soundfile raises its own LibsndfileError / RuntimeError
320
+ return AudioUpload(data, None, streams)
321
+ if info.format == "MP3" or len(streams) > 1:
322
+ return AudioUpload(data, None, streams)
323
+ return AudioUpload(data, float(info.duration), streams)
324
+
325
+
326
+ def open_ffmpeg_container(data: bytes):
327
+ """The uploaded bytes as an ffmpeg container that cannot reach beyond them (see `FFMPEG_PROTOCOL_WHITELIST`)."""
328
+ import av
329
+
330
+ return av.open(io.BytesIO(data), options={"protocol_whitelist": FFMPEG_PROTOCOL_WHITELIST})
331
+
332
+
333
+ def read_ffmpeg_stream_seconds(data: bytes) -> float:
334
+ """The length of one stream from its packets' durations: exact for MP3 without a length header and for M4A, no
335
+ decoding (the container's own duration is an estimate there)."""
336
+ with open_ffmpeg_container(data) as container:
337
+ stream = container.streams.audio[0]
338
+ return sum((float(packet.duration * packet.time_base) for packet in container.demux(stream) if packet.duration), 0.0)
339
+
340
+
341
+ def read_audio_seconds(upload: AudioUpload) -> float:
342
+ """The upload's length from the file (a few milliseconds, a 5-minute decode takes seconds), so the voices behind it
343
+ know their wait before it is decoded; 0 when it cannot be read (the decode then refuses it). What ffmpeg decodes is
344
+ demuxed here without decoding and once more by the decode: the early, cheap pass is the point."""
345
+ if upload.header_seconds is not None:
346
+ return upload.header_seconds
347
+ try:
348
+ return sum(read_ffmpeg_stream_seconds(stream) for stream in upload.streams)
349
+ except Exception: # av raises its own FFmpegError subclasses
350
+ return 0.0
351
+
352
+
353
+ def decode_with_libsndfile(data: bytes) -> tuple[np.ndarray, int]:
354
+ """Mono float32 samples and the sample rate of a file libsndfile reads whole (Telegram's OGG/Opus, WAV, FLAC)."""
137
355
  import soundfile
138
356
 
139
357
  try:
@@ -147,6 +365,59 @@ def decode_audio(data: bytes) -> tuple[np.ndarray, int]:
147
365
  return np.ascontiguousarray(samples, dtype=np.float32), int(sample_rate)
148
366
 
149
367
 
368
+ def get_mono_samples(frames) -> list[np.ndarray]:
369
+ """The channels of resampled planar float frames averaged into one, as for libsndfile (ffmpeg's own downmix is
370
+ 3 dB louder)."""
371
+ return [frame.to_ndarray().mean(axis=0, dtype=np.float32) for frame in frames]
372
+
373
+
374
+ def decode_ffmpeg_stream(data: bytes) -> list[np.ndarray]:
375
+ """The first audio stream of one file as mono float32 arrays at `SPEECH_SAMPLE_RATE` (ffmpeg resamples). A resampler
376
+ is set up for the format, layout and rate of the frames in front of it and replaced when they change: it refuses a
377
+ frame that does not match its setup, and passes a frame through untouched when its first one needed no conversion."""
378
+ import av
379
+
380
+ pieces: list[np.ndarray] = []
381
+ with open_ffmpeg_container(data) as container:
382
+ if not container.streams.audio:
383
+ raise RequestError(400, "the file has no audio")
384
+ resampler = None
385
+ setup = None
386
+ for frame in container.decode(audio=0):
387
+ frame_setup = (frame.format.name, frame.layout.name, frame.sample_rate)
388
+ if frame_setup != setup:
389
+ if resampler is not None:
390
+ pieces.extend(get_mono_samples(resampler.resample(None))) # the old setup's delay, before the new part
391
+ resampler = av.AudioResampler(format="fltp", layout=frame.layout.name, rate=SPEECH_SAMPLE_RATE)
392
+ setup = frame_setup
393
+ pieces.extend(get_mono_samples(resampler.resample(frame)))
394
+ if resampler is not None:
395
+ pieces.extend(get_mono_samples(resampler.resample(None))) # the resampler's delay
396
+ return pieces
397
+
398
+
399
+ def decode_with_ffmpeg(streams: list[bytes]) -> tuple[np.ndarray, int]:
400
+ """Decode with the ffmpeg libraries of the `av` package what libsndfile does not read whole (see `inspect_audio`).
401
+ The streams of a chained Ogg are decoded one by one and joined, so streams that differ in codec or rate still make one
402
+ voice. Always mono float32 at `SPEECH_SAMPLE_RATE`."""
403
+ try:
404
+ pieces = [piece for stream in streams for piece in decode_ffmpeg_stream(stream)]
405
+ except RequestError:
406
+ raise
407
+ except Exception as error: # av raises its own FFmpegError subclasses
408
+ raise RequestError(400, "the audio file could not be decoded") from error
409
+ if not pieces:
410
+ raise RequestError(400, "the audio file could not be decoded")
411
+ return np.ascontiguousarray(np.concatenate(pieces), dtype=np.float32), SPEECH_SAMPLE_RATE
412
+
413
+
414
+ def decode_audio(upload: AudioUpload) -> tuple[np.ndarray, int]:
415
+ """Decode an uploaded file (Telegram sends OGG/Opus) to mono float32 samples and their sample rate."""
416
+ if upload.header_seconds is not None:
417
+ return decode_with_libsndfile(upload.data)
418
+ return decode_with_ffmpeg(upload.streams)
419
+
420
+
150
421
  def make_recognizer(model):
151
422
  """The pool's recognise callback over a loaded model. A piece too short to hold a word is empty text, not
152
423
  an error: the encoder cannot take it."""
@@ -163,12 +434,20 @@ def make_recognizer(model):
163
434
 
164
435
 
165
436
  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
437
+ """One upload in the pool: its place in the arrival order, then its pieces once they are cut. Collects their
438
+ texts in order and tells the waiting handler when all are in."""
439
+
440
+ def __init__(self, on_finished):
441
+ self.sample_rate = 0
442
+ self._on_finished = on_finished
443
+ self._chunks: list[np.ndarray] | None = None
444
+ self._next_chunk = 0
445
+ # The voice's length once read from the file, then once decoded: what it counts for the requests behind it while
446
+ # it is still being cut. `_is_sized` is set when the first of them is known (or the request ended).
447
+ self._audio_seconds = 0.0
448
+ self._is_sized = threading.Event()
449
+ self._texts: list[str | None] = []
450
+ self._remaining = 0
172
451
  self._error: Exception | None = None
173
452
  self._lock = threading.Lock()
174
453
  self._finished = threading.Event()
@@ -176,41 +455,109 @@ class PoolRequest:
176
455
  def is_finished(self) -> bool:
177
456
  return self._finished.is_set()
178
457
 
458
+ def attach_chunks(self, chunks: list[np.ndarray], sample_rate: int) -> None:
459
+ """The pieces are cut: from now on the pool hands them out. No pieces is empty text at once."""
460
+ with self._lock:
461
+ self.sample_rate = sample_rate
462
+ self._texts = [None] * len(chunks)
463
+ self._remaining = len(chunks)
464
+ self._chunks = list(chunks)
465
+ is_empty = not chunks
466
+ if is_empty:
467
+ self._finished.set()
468
+ if is_empty:
469
+ self._on_finished()
470
+
471
+ def note_audio_seconds(self, seconds: float) -> None:
472
+ self._audio_seconds = seconds
473
+ self._is_sized.set()
474
+
475
+ def wait_until_sized(self, timeout_seconds: float) -> None:
476
+ """Block until the length is noted or the request ended — at most `timeout_seconds`."""
477
+ self._is_sized.wait(timeout_seconds)
478
+
479
+ def is_cut(self) -> bool:
480
+ return self._chunks is not None
481
+
482
+ def get_remaining_audio_seconds(self) -> float:
483
+ """Audio still to recognise: the pieces not finished (the running ones included), or the decoded length while
484
+ the voice is still being cut."""
485
+ with self._lock:
486
+ if self._finished.is_set():
487
+ return 0.0
488
+ if self._chunks is None:
489
+ return self._audio_seconds
490
+ samples = sum(len(chunk) for chunk, text in zip(self._chunks, self._texts) if text is None)
491
+ return samples / self.sample_rate if self.sample_rate else 0.0
492
+
493
+ def has_unclaimed_chunk(self) -> bool:
494
+ return self._chunks is not None and self._next_chunk < len(self._chunks)
495
+
496
+ def count_unclaimed_chunks(self) -> int:
497
+ return len(self._chunks) - self._next_chunk if self._chunks is not None else 0
498
+
499
+ def claim_next_chunk(self) -> tuple[int, np.ndarray]:
500
+ """The next piece for a worker (the pool calls this under its own lock, so the pieces go out in order)."""
501
+ index = self._next_chunk
502
+ self._next_chunk += 1
503
+ return index, self._chunks[index]
504
+
179
505
  def complete_chunk(self, index: int, text: str) -> None:
180
506
  with self._lock:
181
507
  if self._finished.is_set():
182
508
  return
183
509
  self._texts[index] = text
184
510
  self._remaining -= 1
185
- if self._remaining == 0:
511
+ is_last = self._remaining == 0
512
+ if is_last:
186
513
  self._finished.set()
514
+ if is_last:
515
+ self._on_finished()
187
516
 
188
517
  def fail(self, error: Exception) -> None:
189
- """Fail the whole request; the pool drops its chunks that are still queued."""
518
+ """Fail the whole request; the pool drops its pieces that were not handed out yet."""
190
519
  with self._lock:
191
- if not self._finished.is_set():
520
+ is_first_end = not self._finished.is_set()
521
+ if is_first_end:
192
522
  self._error = error
193
523
  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())
524
+ self._is_sized.set()
525
+ if is_first_end:
526
+ self._on_finished()
527
+
528
+ def wait(self, timeout_seconds: float, is_abandoned=lambda: False, on_tick=lambda: None) -> str:
529
+ """The chunks' texts joined in order with single spaces; raises the first chunk error, `RecognitionTimeout`
530
+ after `timeout_seconds`, or `ClientGone` once `is_abandoned()` says the client hung up (checked every
531
+ `CLIENT_CHECK_SECONDS`, when `on_tick()` also runs); in both cases the pieces not handed out yet are dropped."""
532
+ deadline = time.monotonic() + timeout_seconds
533
+ while not self._finished.wait(max(0.0, min(CLIENT_CHECK_SECONDS, deadline - time.monotonic()))):
534
+ on_tick()
535
+ if is_abandoned():
536
+ self.fail(ClientGone())
537
+ elif time.monotonic() >= deadline:
538
+ self.fail(RecognitionTimeout())
200
539
  if self._error is not None:
201
540
  raise self._error
202
541
  return " ".join(text.strip() for text in self._texts if text and text.strip())
203
542
 
204
543
 
544
+ class RequestCancelled(Exception):
545
+ """The upload never got its pieces (it could not be decoded): its place in the order is given up."""
546
+
547
+
205
548
  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."""
549
+ """W threads over the requests in strict arrival order (FIFO). A request takes its place when it arrives
550
+ (`open_request`, before its audio is decoded and cut), so a voice that is quicker to cut never overtakes one that
551
+ came earlier; the workers hand out the pieces of the first request with pieces left, in order, before any piece of
552
+ the next one, and wait while a request ahead is still being cut. A request stays listed until it is finished, so
553
+ the audio queued ahead of a voice counts the pieces still running too."""
208
554
 
209
555
  def __init__(self, recognize, workers: int):
210
556
  self._recognize = recognize
211
557
  self._workers = workers
212
- self._queue: queue.PriorityQueue = queue.PriorityQueue()
213
- self._sequence = itertools.count()
558
+ self._condition = threading.Condition()
559
+ self._requests: collections.deque[PoolRequest] = collections.deque()
560
+ self._is_stopping = False
214
561
  self._threads: list[threading.Thread] = []
215
562
 
216
563
  def start(self) -> None:
@@ -220,24 +567,76 @@ class RecognitionPool:
220
567
  self._threads.append(thread)
221
568
 
222
569
  def stop(self) -> None:
223
- for _ in self._threads:
224
- self._queue.put((STOP_PRIORITY, next(self._sequence), None, 0, None))
570
+ """The workers finish the piece they hold and leave; pieces not handed out yet are dropped."""
571
+ with self._condition:
572
+ self._is_stopping = True
573
+ self._condition.notify_all()
225
574
 
226
575
  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
576
+ """Pieces waiting for a worker."""
577
+ with self._condition:
578
+ return sum(request.count_unclaimed_chunks() for request in self._requests if not request.is_finished())
579
+
580
+ def count_cutting_requests(self) -> int:
581
+ """Requests in line whose audio is still being decoded and cut."""
582
+ with self._condition:
583
+ return sum(1 for request in self._requests if not request.is_finished() and not request.is_cut())
584
+
585
+ def get_audio_seconds_ahead(self, request: PoolRequest) -> float:
586
+ """Seconds of audio that will be recognised before `request`: everything unfinished that arrived earlier."""
587
+ with self._condition:
588
+ ahead = itertools.takewhile(lambda listed: listed is not request, self._requests)
589
+ return sum(listed.get_remaining_audio_seconds() for listed in ahead)
590
+
591
+ def wait_until_sized_ahead(self, request: PoolRequest, timeout_seconds: float) -> None:
592
+ """Wait (at most `timeout_seconds` in all) until every voice that arrived before `request` has its length
593
+ noted, so the queue reported to `request` counts it. A voice takes its place before its length is read."""
594
+ with self._condition:
595
+ ahead = list(itertools.takewhile(lambda listed: listed is not request, self._requests))
596
+ deadline = time.monotonic() + timeout_seconds
597
+ for listed in ahead:
598
+ listed.wait_until_sized(max(0.0, deadline - time.monotonic()))
599
+
600
+ def open_request(self) -> PoolRequest:
601
+ """Take the next place in the arrival order; `submit` or `cancel` must follow."""
602
+ with self._condition:
603
+ request = PoolRequest(self._wake_workers)
604
+ self._requests.append(request)
605
+ return request
606
+
607
+ def submit(self, request: PoolRequest, chunks: list[np.ndarray], sample_rate: int) -> None:
608
+ request.attach_chunks(chunks, sample_rate)
609
+ self._wake_workers()
610
+
611
+ def cancel(self, request: PoolRequest) -> None:
612
+ request.fail(RequestCancelled())
613
+
614
+ def _wake_workers(self) -> None:
615
+ with self._condition:
616
+ self._condition.notify_all()
617
+
618
+ def _take_next_chunk(self) -> tuple[PoolRequest, int, np.ndarray] | None:
619
+ with self._condition:
620
+ while not self._is_stopping:
621
+ while self._requests and self._requests[0].is_finished():
622
+ self._requests.popleft()
623
+ for listed in self._requests:
624
+ if listed.is_finished():
625
+ continue
626
+ if listed.has_unclaimed_chunk():
627
+ index, samples = listed.claim_next_chunk()
628
+ return listed, index, samples
629
+ if not listed.is_cut():
630
+ break # still being cut: nothing behind it may go first
631
+ self._condition.wait()
632
+ return None
235
633
 
236
634
  def _work(self) -> None:
237
635
  while True:
238
- _, _, request, index, samples = self._queue.get()
239
- if request is None:
636
+ taken = self._take_next_chunk()
637
+ if taken is None:
240
638
  return
639
+ request, index, samples = taken
241
640
  if request.is_finished():
242
641
  continue
243
642
  try:
@@ -317,10 +716,20 @@ class SidecarContext:
317
716
  """What the handlers and the monitors share."""
318
717
 
319
718
  def __init__(
320
- self, token: str, pool: RecognitionPool, workers: int, idle_seconds: float, max_seconds: float, clock=time.monotonic
719
+ self,
720
+ token: str,
721
+ pool: RecognitionPool,
722
+ split_audio,
723
+ workers: int,
724
+ idle_seconds: float,
725
+ max_seconds: float,
726
+ clock=time.monotonic,
321
727
  ):
728
+ """`split_audio(samples, sample_rate)` → (pieces, their sample rate): `make_model_pause_splitter`, or
729
+ `split_without_pauses` when the VAD could not be loaded."""
322
730
  self.token = token
323
731
  self.pool = pool
732
+ self.split_audio = split_audio
324
733
  self.workers = workers
325
734
  self.idle_seconds = idle_seconds
326
735
  self.max_seconds = max_seconds
@@ -394,6 +803,7 @@ class SidecarHandler(http.server.BaseHTTPRequestHandler):
394
803
  "status": "ok",
395
804
  "busyRequests": context.busy_requests,
396
805
  "queuedChunks": context.pool.queued_chunks(),
806
+ "cuttingRequests": context.pool.count_cutting_requests(),
397
807
  "workers": context.workers,
398
808
  "idleMinutes": context.idle_seconds / SECONDS_PER_MINUTE,
399
809
  "version": SCRIPT_VERSION,
@@ -417,6 +827,8 @@ class SidecarHandler(http.server.BaseHTTPRequestHandler):
417
827
  self._reply_error(error.status, error.message)
418
828
  except RecognitionTimeout:
419
829
  self._reply_error(504, "recognition took too long")
830
+ except ClientGone:
831
+ self.close_connection = True
420
832
  except Exception as error:
421
833
  logger.error("request failed: %s", traceback.format_exc())
422
834
  self._reply_error(500, f"recognition failed: {type(error).__name__}")
@@ -438,18 +850,68 @@ class SidecarHandler(http.server.BaseHTTPRequestHandler):
438
850
  response_format = fields.get("response_format", "json")
439
851
  if response_format not in RESPONSE_FORMATS:
440
852
  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)
853
+ # The upload is in: its place in the arrival order is taken before the decoding and the cutting.
854
+ request = context.pool.open_request()
855
+ try:
856
+ upload = inspect_audio(file_bytes)
857
+ request.note_audio_seconds(read_audio_seconds(upload))
858
+ samples, sample_rate = decode_audio(upload)
859
+ audio_seconds = len(samples) / sample_rate
860
+ request.note_audio_seconds(audio_seconds)
861
+ chunks, chunk_sample_rate = context.split_audio(samples, sample_rate)
862
+ # The pieces hold what is needed: the decoded file would only take memory while the voice waits.
863
+ del samples, file_bytes, body, upload
864
+ except BaseException:
865
+ context.pool.cancel(request)
866
+ raise
867
+ context.pool.submit(request, chunks, chunk_sample_rate)
868
+ context.pool.wait_until_sized_ahead(request, SIZING_WAIT_SECONDS)
869
+ reported_seconds_ahead = context.pool.get_audio_seconds_ahead(request)
870
+
871
+ def report_a_longer_queue() -> None:
872
+ nonlocal reported_seconds_ahead
873
+ seconds_ahead = context.pool.get_audio_seconds_ahead(request)
874
+ if seconds_ahead > reported_seconds_ahead + QUEUE_REPORT_GROWTH_SECONDS:
875
+ self._report_queue(seconds_ahead)
876
+ reported_seconds_ahead = seconds_ahead
877
+
878
+ try:
879
+ self._report_queue(reported_seconds_ahead)
880
+ # A failing piece re-raises here; an OSError of the recognition itself is OUR failure and goes on to the 500.
881
+ text = request.wait(context.max_seconds, self._is_client_gone, report_a_longer_queue)
882
+ except ClientGone:
883
+ request.fail(ClientGone()) # a report that could not be written ends the wait too (no-op once failed)
884
+ logger.info("the client left while %.1fs of audio waited; dropped", audio_seconds)
885
+ raise
445
886
  logger.info(
446
- "transcribed %.1fs of audio in %d chunk(s), %.2fs", len(samples) / sample_rate, len(chunks), time.monotonic() - started
887
+ "transcribed %.1fs of audio in %d chunk(s), %.2fs", audio_seconds, len(chunks), time.monotonic() - started
447
888
  )
448
889
  if response_format == "text":
449
890
  self._reply(200, text.encode("utf-8"), "text/plain; charset=utf-8")
450
891
  else:
451
892
  self._reply_json(200, {"text": text})
452
893
 
894
+ def _report_queue(self, seconds_ahead: float) -> None:
895
+ """The interim 102: the voice is in line, with this much audio before it. A report that cannot be written means
896
+ the client is gone (`ClientGone`)."""
897
+ try:
898
+ self._write_queue_report(seconds_ahead)
899
+ except OSError as error:
900
+ raise ClientGone() from error
901
+
902
+ def _write_queue_report(self, seconds_ahead: float) -> None:
903
+ self.send_response_only(QUEUE_REPORT_STATUS)
904
+ self.send_header(QUEUED_AUDIO_SECONDS_HEADER, f"{seconds_ahead:.1f}")
905
+ self.end_headers()
906
+
907
+ def _is_client_gone(self) -> bool:
908
+ """The client closed its end (its request is fully read, so a readable socket with nothing to read is EOF)."""
909
+ try:
910
+ readable, _, _ = select.select([self.connection], [], [], 0)
911
+ return bool(readable) and self.connection.recv(1, socket.MSG_PEEK) == b""
912
+ except OSError:
913
+ return True
914
+
453
915
  def _reply_json(self, status: int, payload: dict) -> None:
454
916
  self._reply(status, json.dumps(payload, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
455
917
 
@@ -625,8 +1087,41 @@ def load_model(threads: int):
625
1087
  return onnx_asr.load_model(MODEL_NAME, quantization=MODEL_QUANTIZATION, sess_options=options)
626
1088
 
627
1089
 
1090
+ def load_vad():
1091
+ """Silero VAD on one thread: it runs once per request, in that request's handler thread."""
1092
+ import onnx_asr
1093
+ import onnxruntime
1094
+
1095
+ options = onnxruntime.SessionOptions()
1096
+ options.intra_op_num_threads = 1
1097
+ options.inter_op_num_threads = 1
1098
+ return onnx_asr.load_vad(VAD_MODEL_NAME, sess_options=options)
1099
+
1100
+
1101
+ def create_audio_splitter(model, load_vad_model=load_vad):
1102
+ """The request splitter `run_serve` uses: the pause cuts, or — when the VAD cannot be loaded — the 25 s cuts alone,
1103
+ so the engine keeps working the old way instead of failing to start."""
1104
+ try:
1105
+ return make_model_pause_splitter(model, load_vad_model())
1106
+ except Exception:
1107
+ logger.error("the VAD could not be loaded, cutting at %.0f s only: %s", MAX_CHUNK_SECONDS, traceback.format_exc())
1108
+ return split_without_pauses
1109
+
1110
+
1111
+ def check_ffmpeg_decoder(name: str) -> bool:
1112
+ """Whether the ffmpeg inside the `av` wheel has the decoder `name`."""
1113
+ import av
1114
+
1115
+ try:
1116
+ av.Codec(name, "r")
1117
+ except av.codec.codec.UnknownCodecError:
1118
+ return False
1119
+ return True
1120
+
1121
+
628
1122
  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."""
1123
+ """Load the model and the VAD, recognise 1 s of silence through the pause splitter and print the versions —
1124
+ the installer's verify step (it also fetches both while the network is allowed)."""
630
1125
  from importlib import metadata
631
1126
  import platform
632
1127
 
@@ -634,11 +1129,19 @@ def run_check(args: argparse.Namespace) -> int:
634
1129
  import soundfile
635
1130
 
636
1131
  model = load_model(args.threads)
637
- silence = np.zeros(16000, dtype=np.float32)
638
- text = make_recognizer(model)(silence, 16000)
1132
+ split = make_model_pause_splitter(model, load_vad())
1133
+ silence = np.zeros(CHECK_SAMPLE_RATE, dtype=np.float32)
1134
+ pieces, piece_sample_rate = split(silence, CHECK_SAMPLE_RATE)
1135
+ if piece_sample_rate != SPEECH_SAMPLE_RATE:
1136
+ raise RuntimeError("the pause detection failed (its traceback is above, on stderr)")
1137
+ recognize = make_recognizer(model)
1138
+ text = " ".join(recognize(piece, piece_sample_rate) for piece in pieces).strip()
639
1139
  is_opus_readable = "OPUS" in soundfile.available_subtypes("OGG")
640
1140
  if not is_opus_readable:
641
1141
  raise RuntimeError("libsndfile cannot read OGG/Opus")
1142
+ missing_decoders = [name for name in FFMPEG_REQUIRED_DECODERS if not check_ffmpeg_decoder(name)]
1143
+ if missing_decoders:
1144
+ raise RuntimeError(f"ffmpeg (the av package) lacks the decoders {', '.join(missing_decoders)}")
642
1145
  report = {
643
1146
  "ok": True,
644
1147
  "scriptVersion": SCRIPT_VERSION,
@@ -648,6 +1151,7 @@ def run_check(args: argparse.Namespace) -> int:
648
1151
  "onnxruntime": metadata.version("onnxruntime"),
649
1152
  "soundfile": metadata.version("soundfile"),
650
1153
  "libsndfile": soundfile.__libsndfile_version__,
1154
+ "av": metadata.version("av"),
651
1155
  "numpy": metadata.version("numpy"),
652
1156
  "silenceText": text,
653
1157
  }
@@ -683,10 +1187,11 @@ def run_serve(args: argparse.Namespace, token: str) -> int:
683
1187
  logger.error("model load failed: %s", traceback.format_exc())
684
1188
  return EXIT_SETUP_FAILED
685
1189
  logger.info("model loaded in %.2fs", time.monotonic() - loading_started)
1190
+ split_audio = create_audio_splitter(model)
686
1191
 
687
1192
  pool = RecognitionPool(make_recognizer(model), args.workers)
688
1193
  pool.start()
689
- context = SidecarContext(token, pool, args.workers, args.idle_seconds, args.max_seconds)
1194
+ context = SidecarContext(token, pool, split_audio, args.workers, args.idle_seconds, args.max_seconds)
690
1195
  try:
691
1196
  server = SidecarServer(("127.0.0.1", 0), context)
692
1197
  except OSError: