@erinjs/core 1.0.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 (389) hide show
  1. package/core/.changeset/README.md +8 -0
  2. package/core/.changeset/community-bootstrap-release.md +17 -0
  3. package/core/.changeset/config.json +11 -0
  4. package/core/.changeset/no-changelog.js +16 -0
  5. package/core/.changeset/pre.json +17 -0
  6. package/core/.editorconfig +13 -0
  7. package/core/.gitattributes +2 -0
  8. package/core/.github/CODE_OF_CONDUCT.md +23 -0
  9. package/core/.github/FUNDING.yml +7 -0
  10. package/core/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  11. package/core/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  12. package/core/.github/PULL_REQUEST_TEMPLATE.md +16 -0
  13. package/core/.github/dependabot.yml +16 -0
  14. package/core/.github/workflows/autoapp.yml +16 -0
  15. package/core/.github/workflows/ci.yml +187 -0
  16. package/core/.github/workflows/codeql.yml +30 -0
  17. package/core/.github/workflows/deploy-docs.yml +54 -0
  18. package/core/.github/workflows/publish.yml +43 -0
  19. package/core/.lintstagedrc.json +4 -0
  20. package/core/.nvmrc +1 -0
  21. package/core/.prettierignore +8 -0
  22. package/core/.prettierrc +11 -0
  23. package/core/CONTRIBUTING.md +70 -0
  24. package/core/LICENSE +203 -0
  25. package/core/README.md +61 -0
  26. package/core/SECURITY.md +21 -0
  27. package/core/apps/docs/index.html +28 -0
  28. package/core/apps/docs/middleware.ts +21 -0
  29. package/core/apps/docs/package.json +33 -0
  30. package/core/apps/docs/public/@flux.png +0 -0
  31. package/core/apps/docs/public/docs/latest/guides.json +1420 -0
  32. package/core/apps/docs/public/docs/latest/main.json +14981 -0
  33. package/core/apps/docs/public/docs/latest/rag-index.json +1 -0
  34. package/core/apps/docs/public/docs/v1.0.5/guides.json +226 -0
  35. package/core/apps/docs/public/docs/v1.0.5/main.json +7920 -0
  36. package/core/apps/docs/public/docs/v1.0.6/guides.json +226 -0
  37. package/core/apps/docs/public/docs/v1.0.6/main.json +7920 -0
  38. package/core/apps/docs/public/docs/v1.0.7/guides.json +259 -0
  39. package/core/apps/docs/public/docs/v1.0.7/main.json +8652 -0
  40. package/core/apps/docs/public/docs/v1.0.8/guides.json +313 -0
  41. package/core/apps/docs/public/docs/v1.0.8/main.json +9618 -0
  42. package/core/apps/docs/public/docs/v1.0.9/guides.json +319 -0
  43. package/core/apps/docs/public/docs/v1.0.9/main.json +10694 -0
  44. package/core/apps/docs/public/docs/v1.1.0/guides.json +589 -0
  45. package/core/apps/docs/public/docs/v1.1.0/main.json +12576 -0
  46. package/core/apps/docs/public/docs/v1.1.2/guides.json +650 -0
  47. package/core/apps/docs/public/docs/v1.1.2/main.json +13239 -0
  48. package/core/apps/docs/public/docs/v1.1.3/guides.json +650 -0
  49. package/core/apps/docs/public/docs/v1.1.3/main.json +13239 -0
  50. package/core/apps/docs/public/docs/v1.1.4/guides.json +708 -0
  51. package/core/apps/docs/public/docs/v1.1.4/main.json +13231 -0
  52. package/core/apps/docs/public/docs/v1.1.5/guides.json +1035 -0
  53. package/core/apps/docs/public/docs/v1.1.5/main.json +13838 -0
  54. package/core/apps/docs/public/docs/v1.1.6/guides.json +1041 -0
  55. package/core/apps/docs/public/docs/v1.1.6/main.json +14313 -0
  56. package/core/apps/docs/public/docs/v1.1.8/guides.json +1047 -0
  57. package/core/apps/docs/public/docs/v1.1.8/main.json +14421 -0
  58. package/core/apps/docs/public/docs/v1.1.9/guides.json +1047 -0
  59. package/core/apps/docs/public/docs/v1.1.9/main.json +14421 -0
  60. package/core/apps/docs/public/docs/v1.2.0/guides.json +1212 -0
  61. package/core/apps/docs/public/docs/v1.2.0/main.json +14663 -0
  62. package/core/apps/docs/public/docs/v1.2.1/guides.json +1293 -0
  63. package/core/apps/docs/public/docs/v1.2.1/main.json +14828 -0
  64. package/core/apps/docs/public/docs/v1.2.2/guides.json +1293 -0
  65. package/core/apps/docs/public/docs/v1.2.2/main.json +15025 -0
  66. package/core/apps/docs/public/docs/v1.2.3/guides.json +1420 -0
  67. package/core/apps/docs/public/docs/v1.2.3/main.json +14954 -0
  68. package/core/apps/docs/public/docs/v1.2.4/guides.json +1420 -0
  69. package/core/apps/docs/public/docs/v1.2.4/main.json +14981 -0
  70. package/core/apps/docs/public/docs/versions.json +24 -0
  71. package/core/apps/docs/public/flux.png +0 -0
  72. package/core/apps/docs/public/locales/en.json +50 -0
  73. package/core/apps/docs/public/locales/guides-en.json +512 -0
  74. package/core/apps/docs/public/robots.txt +4 -0
  75. package/core/apps/docs/public/sitemap.xml +33 -0
  76. package/core/apps/docs/src/App.vue +538 -0
  77. package/core/apps/docs/src/components/ApiCategorySection.vue +42 -0
  78. package/core/apps/docs/src/components/ApiDiscordCompat.vue +65 -0
  79. package/core/apps/docs/src/components/ApiEndpointCard.vue +313 -0
  80. package/core/apps/docs/src/components/ApiSchemaBlock.vue +131 -0
  81. package/core/apps/docs/src/components/CodeBlock.vue +177 -0
  82. package/core/apps/docs/src/components/CommunityCallout.vue +90 -0
  83. package/core/apps/docs/src/components/ConstructorSection.vue +82 -0
  84. package/core/apps/docs/src/components/DocDescription.vue +40 -0
  85. package/core/apps/docs/src/components/FluxerLogo.vue +3 -0
  86. package/core/apps/docs/src/components/Footer.vue +106 -0
  87. package/core/apps/docs/src/components/GuideCodeBlock.vue +102 -0
  88. package/core/apps/docs/src/components/GuideDiscordCompat.vue +77 -0
  89. package/core/apps/docs/src/components/GuideDiscordCompatCallout.vue +83 -0
  90. package/core/apps/docs/src/components/GuideTable.vue +77 -0
  91. package/core/apps/docs/src/components/GuideTip.vue +38 -0
  92. package/core/apps/docs/src/components/MethodsSection.vue +195 -0
  93. package/core/apps/docs/src/components/ParamsTable.vue +70 -0
  94. package/core/apps/docs/src/components/PropertiesSection.vue +143 -0
  95. package/core/apps/docs/src/components/SearchBar.vue +76 -0
  96. package/core/apps/docs/src/components/SearchModal.vue +361 -0
  97. package/core/apps/docs/src/components/SidebarNav.vue +225 -0
  98. package/core/apps/docs/src/components/SponsorBanner.vue +153 -0
  99. package/core/apps/docs/src/components/TypeSignature.vue +187 -0
  100. package/core/apps/docs/src/components/VersionPicker.vue +191 -0
  101. package/core/apps/docs/src/composables/useSearchIndex.ts +144 -0
  102. package/core/apps/docs/src/composables/useVersionedPath.ts +20 -0
  103. package/core/apps/docs/src/data/apiEndpoints.ts +1073 -0
  104. package/core/apps/docs/src/data/changelog.ts +717 -0
  105. package/core/apps/docs/src/data/guides.ts +2362 -0
  106. package/core/apps/docs/src/env.d.ts +7 -0
  107. package/core/apps/docs/src/locales/guides-en.json +512 -0
  108. package/core/apps/docs/src/main.ts +27 -0
  109. package/core/apps/docs/src/pages/ApiReferenceLayout.vue +175 -0
  110. package/core/apps/docs/src/pages/ApiReferencePage.vue +128 -0
  111. package/core/apps/docs/src/pages/Changelog.vue +288 -0
  112. package/core/apps/docs/src/pages/ClassPage.vue +319 -0
  113. package/core/apps/docs/src/pages/ClassesList.vue +100 -0
  114. package/core/apps/docs/src/pages/DocsLayout.vue +127 -0
  115. package/core/apps/docs/src/pages/GuidePage.vue +279 -0
  116. package/core/apps/docs/src/pages/GuidesIndex.vue +166 -0
  117. package/core/apps/docs/src/pages/GuidesLayout.vue +245 -0
  118. package/core/apps/docs/src/pages/Home.vue +125 -0
  119. package/core/apps/docs/src/pages/NotFound.vue +57 -0
  120. package/core/apps/docs/src/pages/TypedefPage.vue +230 -0
  121. package/core/apps/docs/src/pages/TypedefsList.vue +168 -0
  122. package/core/apps/docs/src/pages/VersionLayout.vue +15 -0
  123. package/core/apps/docs/src/router.ts +73 -0
  124. package/core/apps/docs/src/stores/docs.ts +54 -0
  125. package/core/apps/docs/src/stores/guides.ts +53 -0
  126. package/core/apps/docs/src/stores/version.ts +67 -0
  127. package/core/apps/docs/src/styles/main.css +278 -0
  128. package/core/apps/docs/src/styles/prism.css +95 -0
  129. package/core/apps/docs/src/types/doc-schema.ts +112 -0
  130. package/core/apps/docs/tsconfig.json +17 -0
  131. package/core/apps/docs/tsconfig.node.json +10 -0
  132. package/core/apps/docs/vite.config.d.ts +2 -0
  133. package/core/apps/docs/vite.config.js +26 -0
  134. package/core/apps/docs/vite.config.ts +28 -0
  135. package/core/apps/docs-vitepress/.vitepress/config.ts +141 -0
  136. package/core/apps/docs-vitepress/api-data/latest/main.json +15035 -0
  137. package/core/apps/docs-vitepress/api-data/v1.2.4/main.json +15035 -0
  138. package/core/apps/docs-vitepress/api-data/versions.json +6 -0
  139. package/core/apps/docs-vitepress/index.md +15 -0
  140. package/core/apps/docs-vitepress/package-lock.json +2924 -0
  141. package/core/apps/docs-vitepress/package.json +20 -0
  142. package/core/apps/docs-vitepress/public/CNAME +1 -0
  143. package/core/apps/docs-vitepress/scripts/generate-api.ts +243 -0
  144. package/core/apps/docs-vitepress/scripts/migrate-guides.ts +129 -0
  145. package/core/apps/docs-vitepress/tsconfig.json +11 -0
  146. package/core/apps/docs-vitepress/v/latest/guides/attachments-by-url.md +57 -0
  147. package/core/apps/docs-vitepress/v/latest/guides/attachments.md +62 -0
  148. package/core/apps/docs-vitepress/v/latest/guides/basic-bot.md +49 -0
  149. package/core/apps/docs-vitepress/v/latest/guides/channels.md +180 -0
  150. package/core/apps/docs-vitepress/v/latest/guides/deprecated-apis.md +58 -0
  151. package/core/apps/docs-vitepress/v/latest/guides/discord-js-compatibility.md +42 -0
  152. package/core/apps/docs-vitepress/v/latest/guides/editing-embeds.md +65 -0
  153. package/core/apps/docs-vitepress/v/latest/guides/embed-media.md +87 -0
  154. package/core/apps/docs-vitepress/v/latest/guides/embeds.md +166 -0
  155. package/core/apps/docs-vitepress/v/latest/guides/emojis.md +77 -0
  156. package/core/apps/docs-vitepress/v/latest/guides/events.md +202 -0
  157. package/core/apps/docs-vitepress/v/latest/guides/gifs.md +47 -0
  158. package/core/apps/docs-vitepress/v/latest/guides/installation.md +10 -0
  159. package/core/apps/docs-vitepress/v/latest/guides/moderation.md +89 -0
  160. package/core/apps/docs-vitepress/v/latest/guides/permissions.md +130 -0
  161. package/core/apps/docs-vitepress/v/latest/guides/prefix-commands.md +41 -0
  162. package/core/apps/docs-vitepress/v/latest/guides/profile-urls.md +58 -0
  163. package/core/apps/docs-vitepress/v/latest/guides/reactions.md +69 -0
  164. package/core/apps/docs-vitepress/v/latest/guides/roles.md +130 -0
  165. package/core/apps/docs-vitepress/v/latest/guides/sending-without-reply.md +172 -0
  166. package/core/apps/docs-vitepress/v/latest/guides/voice.md +109 -0
  167. package/core/apps/docs-vitepress/v/latest/guides/wait-for-guilds.md +37 -0
  168. package/core/apps/docs-vitepress/v/latest/guides/webhook-attachments-embeds.md +73 -0
  169. package/core/apps/docs-vitepress/v/latest/guides/webhooks.md +131 -0
  170. package/core/eslint.config.js +80 -0
  171. package/core/examples/.env.example +22 -0
  172. package/core/examples/README.md +68 -0
  173. package/core/examples/first-steps-bot.js +118 -0
  174. package/core/examples/minimal-bot.js +17 -0
  175. package/core/examples/moderation-bot.js +209 -0
  176. package/core/examples/package.json +14 -0
  177. package/core/examples/ping-bot.js +1146 -0
  178. package/core/examples/reaction-bot.js +70 -0
  179. package/core/examples/reaction-roles-bot.js +140 -0
  180. package/core/examples/webhook-bot.js +239 -0
  181. package/core/flux.png +0 -0
  182. package/core/package.json +78 -0
  183. package/core/packages/builders/package.json +51 -0
  184. package/core/packages/builders/src/index.ts +13 -0
  185. package/core/packages/builders/src/messages/AttachmentBuilder.test.ts +79 -0
  186. package/core/packages/builders/src/messages/AttachmentBuilder.ts +69 -0
  187. package/core/packages/builders/src/messages/EmbedBuilder.test.ts +266 -0
  188. package/core/packages/builders/src/messages/EmbedBuilder.ts +239 -0
  189. package/core/packages/builders/src/messages/MessagePayload.test.ts +118 -0
  190. package/core/packages/builders/src/messages/MessagePayload.ts +122 -0
  191. package/core/packages/builders/tsconfig.json +9 -0
  192. package/core/packages/builders/tsup.config.ts +9 -0
  193. package/core/packages/builders/vitest.config.ts +9 -0
  194. package/core/packages/collection/package.json +47 -0
  195. package/core/packages/collection/src/Collection.test.ts +232 -0
  196. package/core/packages/collection/src/Collection.ts +196 -0
  197. package/core/packages/collection/src/index.ts +1 -0
  198. package/core/packages/collection/tsconfig.json +9 -0
  199. package/core/packages/collection/tsup.config.ts +9 -0
  200. package/core/packages/collection/vitest.config.ts +9 -0
  201. package/core/packages/docgen/package.json +26 -0
  202. package/core/packages/docgen/src/extract.ts +262 -0
  203. package/core/packages/docgen/src/formatType.ts +24 -0
  204. package/core/packages/docgen/src/index.ts +103 -0
  205. package/core/packages/docgen/src/schema.ts +100 -0
  206. package/core/packages/docgen/src/visitor.ts +147 -0
  207. package/core/packages/docgen/tsconfig.json +9 -0
  208. package/core/packages/docgen/tsup.config.ts +9 -0
  209. package/core/packages/fluxer-core/README.md +26 -0
  210. package/core/packages/fluxer-core/package.json +60 -0
  211. package/core/packages/fluxer-core/src/client/ChannelManager.ts +143 -0
  212. package/core/packages/fluxer-core/src/client/Client.gateway.test.ts +84 -0
  213. package/core/packages/fluxer-core/src/client/Client.resolveEmoji.test.ts +45 -0
  214. package/core/packages/fluxer-core/src/client/Client.ts +558 -0
  215. package/core/packages/fluxer-core/src/client/ClientUser.ts +40 -0
  216. package/core/packages/fluxer-core/src/client/EventHandlerRegistry.ts +469 -0
  217. package/core/packages/fluxer-core/src/client/GuildManager.ts +79 -0
  218. package/core/packages/fluxer-core/src/client/GuildMemberManager.ts +91 -0
  219. package/core/packages/fluxer-core/src/client/MessageManager.ts +58 -0
  220. package/core/packages/fluxer-core/src/client/UsersManager.ts +122 -0
  221. package/core/packages/fluxer-core/src/errors/ErrorCodes.test.ts +19 -0
  222. package/core/packages/fluxer-core/src/errors/ErrorCodes.ts +12 -0
  223. package/core/packages/fluxer-core/src/errors/FluxerError.test.ts +32 -0
  224. package/core/packages/fluxer-core/src/errors/FluxerError.ts +15 -0
  225. package/core/packages/fluxer-core/src/index.ts +85 -0
  226. package/core/packages/fluxer-core/src/structures/Base.ts +7 -0
  227. package/core/packages/fluxer-core/src/structures/Channel.ts +508 -0
  228. package/core/packages/fluxer-core/src/structures/Guild.test.ts +189 -0
  229. package/core/packages/fluxer-core/src/structures/Guild.ts +734 -0
  230. package/core/packages/fluxer-core/src/structures/GuildBan.ts +35 -0
  231. package/core/packages/fluxer-core/src/structures/GuildEmoji.ts +57 -0
  232. package/core/packages/fluxer-core/src/structures/GuildMember.test.ts +203 -0
  233. package/core/packages/fluxer-core/src/structures/GuildMember.ts +213 -0
  234. package/core/packages/fluxer-core/src/structures/GuildMemberRoleManager.ts +121 -0
  235. package/core/packages/fluxer-core/src/structures/GuildSticker.ts +56 -0
  236. package/core/packages/fluxer-core/src/structures/Invite.test.ts +103 -0
  237. package/core/packages/fluxer-core/src/structures/Invite.ts +121 -0
  238. package/core/packages/fluxer-core/src/structures/Message.test.ts +109 -0
  239. package/core/packages/fluxer-core/src/structures/Message.ts +397 -0
  240. package/core/packages/fluxer-core/src/structures/MessageReaction.ts +72 -0
  241. package/core/packages/fluxer-core/src/structures/PartialMessage.ts +12 -0
  242. package/core/packages/fluxer-core/src/structures/Role.test.ts +77 -0
  243. package/core/packages/fluxer-core/src/structures/Role.ts +112 -0
  244. package/core/packages/fluxer-core/src/structures/User.test.ts +110 -0
  245. package/core/packages/fluxer-core/src/structures/User.ts +109 -0
  246. package/core/packages/fluxer-core/src/structures/Webhook.test.ts +109 -0
  247. package/core/packages/fluxer-core/src/structures/Webhook.ts +258 -0
  248. package/core/packages/fluxer-core/src/util/Constants.test.ts +16 -0
  249. package/core/packages/fluxer-core/src/util/Constants.ts +7 -0
  250. package/core/packages/fluxer-core/src/util/Events.ts +46 -0
  251. package/core/packages/fluxer-core/src/util/MessageCollector.ts +87 -0
  252. package/core/packages/fluxer-core/src/util/Options.ts +33 -0
  253. package/core/packages/fluxer-core/src/util/ReactionCollector.ts +116 -0
  254. package/core/packages/fluxer-core/src/util/cdn.test.ts +108 -0
  255. package/core/packages/fluxer-core/src/util/cdn.ts +130 -0
  256. package/core/packages/fluxer-core/src/util/guildUtils.ts +33 -0
  257. package/core/packages/fluxer-core/src/util/messageUtils.test.ts +74 -0
  258. package/core/packages/fluxer-core/src/util/messageUtils.ts +119 -0
  259. package/core/packages/fluxer-core/src/util/permissions.test.ts +95 -0
  260. package/core/packages/fluxer-core/src/util/permissions.ts +43 -0
  261. package/core/packages/fluxer-core/tsconfig.json +9 -0
  262. package/core/packages/fluxer-core/tsup.config.ts +9 -0
  263. package/core/packages/fluxer-core/vitest.config.ts +9 -0
  264. package/core/packages/rest/package.json +52 -0
  265. package/core/packages/rest/src/REST.test.ts +64 -0
  266. package/core/packages/rest/src/REST.ts +90 -0
  267. package/core/packages/rest/src/RateLimitManager.test.ts +71 -0
  268. package/core/packages/rest/src/RateLimitManager.ts +60 -0
  269. package/core/packages/rest/src/RequestManager.test.ts +87 -0
  270. package/core/packages/rest/src/RequestManager.ts +172 -0
  271. package/core/packages/rest/src/errors/FluxerAPIError.test.ts +57 -0
  272. package/core/packages/rest/src/errors/FluxerAPIError.ts +21 -0
  273. package/core/packages/rest/src/errors/HTTPError.test.ts +55 -0
  274. package/core/packages/rest/src/errors/HTTPError.ts +25 -0
  275. package/core/packages/rest/src/errors/RateLimitError.test.ts +41 -0
  276. package/core/packages/rest/src/errors/RateLimitError.ts +15 -0
  277. package/core/packages/rest/src/errors/index.ts +3 -0
  278. package/core/packages/rest/src/index.ts +6 -0
  279. package/core/packages/rest/src/utils/constants.test.ts +31 -0
  280. package/core/packages/rest/src/utils/constants.ts +5 -0
  281. package/core/packages/rest/src/utils/files.test.ts +37 -0
  282. package/core/packages/rest/src/utils/files.ts +75 -0
  283. package/core/packages/rest/tsconfig.json +9 -0
  284. package/core/packages/rest/tsup.config.ts +9 -0
  285. package/core/packages/rest/vitest.config.ts +9 -0
  286. package/core/packages/types/package.json +46 -0
  287. package/core/packages/types/src/api/ban.ts +8 -0
  288. package/core/packages/types/src/api/channel.ts +65 -0
  289. package/core/packages/types/src/api/embed.ts +82 -0
  290. package/core/packages/types/src/api/emoji.ts +12 -0
  291. package/core/packages/types/src/api/errors.ts +68 -0
  292. package/core/packages/types/src/api/gateway.ts +14 -0
  293. package/core/packages/types/src/api/guild.ts +123 -0
  294. package/core/packages/types/src/api/index.ts +15 -0
  295. package/core/packages/types/src/api/instance.ts +32 -0
  296. package/core/packages/types/src/api/interaction.ts +26 -0
  297. package/core/packages/types/src/api/invite.ts +28 -0
  298. package/core/packages/types/src/api/message.ts +140 -0
  299. package/core/packages/types/src/api/role.ts +41 -0
  300. package/core/packages/types/src/api/sticker.ts +14 -0
  301. package/core/packages/types/src/api/user.ts +79 -0
  302. package/core/packages/types/src/api/webhook.ts +41 -0
  303. package/core/packages/types/src/common/index.ts +1 -0
  304. package/core/packages/types/src/common/snowflake.test.ts +9 -0
  305. package/core/packages/types/src/common/snowflake.ts +8 -0
  306. package/core/packages/types/src/gateway/events.ts +189 -0
  307. package/core/packages/types/src/gateway/index.ts +3 -0
  308. package/core/packages/types/src/gateway/opcodes.ts +17 -0
  309. package/core/packages/types/src/gateway/payloads.ts +481 -0
  310. package/core/packages/types/src/index.ts +4 -0
  311. package/core/packages/types/src/rest/index.ts +1 -0
  312. package/core/packages/types/src/rest/routes.test.ts +169 -0
  313. package/core/packages/types/src/rest/routes.ts +109 -0
  314. package/core/packages/types/tsconfig.json +9 -0
  315. package/core/packages/types/tsup.config.ts +9 -0
  316. package/core/packages/types/vitest.config.ts +9 -0
  317. package/core/packages/util/package.json +51 -0
  318. package/core/packages/util/src/BitField.test.ts +96 -0
  319. package/core/packages/util/src/BitField.ts +105 -0
  320. package/core/packages/util/src/MessageFlagsBitField.test.ts +42 -0
  321. package/core/packages/util/src/MessageFlagsBitField.ts +20 -0
  322. package/core/packages/util/src/PermissionsBitField.test.ts +79 -0
  323. package/core/packages/util/src/PermissionsBitField.ts +97 -0
  324. package/core/packages/util/src/SnowflakeUtil.test.ts +69 -0
  325. package/core/packages/util/src/SnowflakeUtil.ts +65 -0
  326. package/core/packages/util/src/UserFlagsBitField.test.ts +39 -0
  327. package/core/packages/util/src/UserFlagsBitField.ts +48 -0
  328. package/core/packages/util/src/deprecation.test.ts +44 -0
  329. package/core/packages/util/src/deprecation.ts +28 -0
  330. package/core/packages/util/src/emojiShortcodes.generated.ts +5 -0
  331. package/core/packages/util/src/emojiShortcodes.test.ts +41 -0
  332. package/core/packages/util/src/emojiShortcodes.ts +22 -0
  333. package/core/packages/util/src/formatters.test.ts +65 -0
  334. package/core/packages/util/src/formatters.ts +35 -0
  335. package/core/packages/util/src/index.ts +34 -0
  336. package/core/packages/util/src/resolvers.test.ts +198 -0
  337. package/core/packages/util/src/resolvers.ts +127 -0
  338. package/core/packages/util/src/tenorUtils.test.ts +75 -0
  339. package/core/packages/util/src/tenorUtils.ts +86 -0
  340. package/core/packages/util/tsconfig.json +9 -0
  341. package/core/packages/util/tsup.config.ts +9 -0
  342. package/core/packages/util/vitest.config.ts +9 -0
  343. package/core/packages/voice/README.md +42 -0
  344. package/core/packages/voice/package.json +67 -0
  345. package/core/packages/voice/src/LiveKitRtcConnection.receive.test.ts +24 -0
  346. package/core/packages/voice/src/LiveKitRtcConnection.ts +1767 -0
  347. package/core/packages/voice/src/VoiceConnection.ts +413 -0
  348. package/core/packages/voice/src/VoiceManager.receive.test.ts +61 -0
  349. package/core/packages/voice/src/VoiceManager.test.ts +44 -0
  350. package/core/packages/voice/src/VoiceManager.ts +503 -0
  351. package/core/packages/voice/src/exports.test.ts +38 -0
  352. package/core/packages/voice/src/index.ts +51 -0
  353. package/core/packages/voice/src/livekit.test.ts +48 -0
  354. package/core/packages/voice/src/livekit.ts +33 -0
  355. package/core/packages/voice/src/mp4box.d.ts +32 -0
  356. package/core/packages/voice/src/opusUtils.test.ts +29 -0
  357. package/core/packages/voice/src/opusUtils.ts +86 -0
  358. package/core/packages/voice/src/streamPreviewPlaceholder.test.ts +16 -0
  359. package/core/packages/voice/src/streamPreviewPlaceholder.ts +8 -0
  360. package/core/packages/voice/src/ws.d.ts +1 -0
  361. package/core/packages/voice/tsconfig.json +5 -0
  362. package/core/packages/voice/tsup.config.ts +10 -0
  363. package/core/packages/voice/vitest.config.ts +9 -0
  364. package/core/packages/ws/package.json +52 -0
  365. package/core/packages/ws/src/WebSocketManager.ts +130 -0
  366. package/core/packages/ws/src/WebSocketShard.ts +296 -0
  367. package/core/packages/ws/src/index.ts +12 -0
  368. package/core/packages/ws/src/utils/constants.test.ts +46 -0
  369. package/core/packages/ws/src/utils/constants.ts +22 -0
  370. package/core/packages/ws/src/utils/getWebSocket.ts +55 -0
  371. package/core/packages/ws/src/ws.d.ts +10 -0
  372. package/core/packages/ws/tsconfig.json +9 -0
  373. package/core/packages/ws/tsup.config.ts +9 -0
  374. package/core/pnpm-lock.yaml +7033 -0
  375. package/core/pnpm-workspace.yaml +4 -0
  376. package/core/scripts/generate-ai-rag.ts +240 -0
  377. package/core/scripts/generate-docs.ts +143 -0
  378. package/core/scripts/generate-emoji-shortcodes.ts +58 -0
  379. package/core/scripts/generate-types.ts +6 -0
  380. package/core/scripts/publish-ordered.js +63 -0
  381. package/core/scripts/test-cjs-require.mjs +43 -0
  382. package/core/scripts/test-esm-imports.mjs +42 -0
  383. package/core/scripts/test-package-exports.mjs +98 -0
  384. package/core/scripts/test-smoke.mjs +103 -0
  385. package/core/tsconfig.json +18 -0
  386. package/core/turbo.json +30 -0
  387. package/core/vitest.config.ts +17 -0
  388. package/core/wrangler.jsonc +9 -0
  389. package/package.json +26 -0
@@ -0,0 +1,1767 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { EventEmitter } from 'events';
3
+ import { Client } from '@erinjs/core';
4
+ import { VoiceChannel } from '@erinjs/core';
5
+ import {
6
+ GatewayVoiceServerUpdateDispatchData,
7
+ GatewayVoiceStateUpdateDispatchData,
8
+ } from '@erinjs/types';
9
+ import {
10
+ AudioStream,
11
+ Room,
12
+ RoomEvent,
13
+ AudioSource,
14
+ AudioFrame,
15
+ LocalAudioTrack,
16
+ LocalVideoTrack,
17
+ TrackPublishOptions,
18
+ TrackSource,
19
+ VideoBufferType,
20
+ VideoFrame,
21
+ VideoSource,
22
+ type RemoteParticipant,
23
+ type RemoteTrack,
24
+ TrackKind,
25
+ } from '@livekit/rtc-node';
26
+ import { buildLiveKitUrlForRtcSdk } from './livekit.js';
27
+ import { parseOpusPacketBoundaries, concatUint8Arrays } from './opusUtils.js';
28
+ import { VoiceConnectionEvents } from './VoiceConnection.js';
29
+ import { Readable } from 'node:stream';
30
+ import { OpusDecoder } from 'opus-decoder';
31
+ import { opus } from 'prism-media';
32
+ import { promisify } from 'node:util';
33
+ import { createFile } from 'mp4box';
34
+ import type { VideoFrame as WebCodecsVideoFrame } from 'node-webcodecs';
35
+
36
+ const SAMPLE_RATE = 48000;
37
+ const CHANNELS = 1;
38
+ const RECEIVE_READ_TIMEOUT_MS = 100;
39
+
40
+ /** avcC box structure from mp4box (AVCConfigurationBox). */
41
+ interface AvcCBox {
42
+ configurationVersion: number;
43
+ AVCProfileIndication: number;
44
+ profile_compatibility: number;
45
+ AVCLevelIndication: number;
46
+ lengthSizeMinusOne: number;
47
+ SPS: Array<{ length: number; nalu: Uint8Array | number[] }>;
48
+ PPS: Array<{ length: number; nalu: Uint8Array | number[] }>;
49
+ ext?: Uint8Array | number[];
50
+ }
51
+
52
+ /** Get byte length of nalu (handles TypedArray, ArrayBuffer, number[]). */
53
+ function getNaluByteLength(nalu: Uint8Array | number[] | ArrayBuffer): number {
54
+ if (ArrayBuffer.isView(nalu)) return nalu.byteLength;
55
+ if (nalu instanceof ArrayBuffer) return nalu.byteLength;
56
+ if (Array.isArray(nalu)) return nalu.length;
57
+ return 0;
58
+ }
59
+
60
+ /** Convert nalu to Uint8Array for safe copying (handles TypedArray, ArrayBuffer, number[]). */
61
+ function toUint8Array(nalu: Uint8Array | number[] | ArrayBuffer): Uint8Array {
62
+ if (nalu instanceof Uint8Array) return nalu;
63
+ if (ArrayBuffer.isView(nalu))
64
+ return new Uint8Array(nalu.buffer, nalu.byteOffset, nalu.byteLength);
65
+ if (nalu instanceof ArrayBuffer) return new Uint8Array(nalu);
66
+ if (Array.isArray(nalu)) return new Uint8Array(nalu);
67
+ return new Uint8Array(0);
68
+ }
69
+
70
+ /** Extract AAC AudioSpecificConfig from mp4box mp4a sample entry (esds -> DecoderSpecificInfo). */
71
+ function _getAacDecoderDescription(mp4aEntry: {
72
+ esds?: {
73
+ esd?: {
74
+ findDescriptor: (tag: number) => { findDescriptor: (tag: number) => { data?: Uint8Array } };
75
+ };
76
+ };
77
+ }): ArrayBuffer | undefined {
78
+ try {
79
+ const esd = mp4aEntry.esds?.esd;
80
+ if (!esd) return undefined;
81
+ const dcd = esd.findDescriptor(0x04); // DecoderConfigDescriptor
82
+ if (!dcd) return undefined;
83
+ const dsi = dcd.findDescriptor(0x05); // DecoderSpecificInfo
84
+ if (!dsi?.data?.length) return undefined;
85
+ return dsi.data.buffer.slice(
86
+ dsi.data.byteOffset,
87
+ dsi.data.byteOffset + dsi.data.byteLength,
88
+ ) as ArrayBuffer;
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ /** Build AVCDecoderConfigurationRecord (ISO 14496-15) from mp4box avcC for WebCodecs VideoDecoder. */
95
+ function buildAvcDecoderConfig(avcC: AvcCBox): ArrayBuffer | undefined {
96
+ try {
97
+ let size = 6; // config version + profile + compat + level + (lengthSize|0xFC) + (nbSPS|0xE0)
98
+ for (const s of avcC.SPS) size += 2 + getNaluByteLength(s.nalu);
99
+ size += 1; // nb_PPS
100
+ for (const p of avcC.PPS) size += 2 + getNaluByteLength(p.nalu);
101
+ if (avcC.ext) size += getNaluByteLength(avcC.ext);
102
+
103
+ const buf = new ArrayBuffer(size);
104
+ const view = new DataView(buf);
105
+ const arr = new Uint8Array(buf);
106
+ let offset = 0;
107
+
108
+ view.setUint8(offset++, avcC.configurationVersion);
109
+ view.setUint8(offset++, avcC.AVCProfileIndication);
110
+ view.setUint8(offset++, avcC.profile_compatibility);
111
+ view.setUint8(offset++, avcC.AVCLevelIndication);
112
+ view.setUint8(offset++, (avcC.lengthSizeMinusOne & 0x3) | 0xfc);
113
+ view.setUint8(offset++, (avcC.SPS.length & 0x1f) | 0xe0);
114
+
115
+ for (const s of avcC.SPS) {
116
+ const naluBytes = toUint8Array(s.nalu);
117
+ const naluLen = naluBytes.byteLength;
118
+ if (offset + 2 + naluLen > size) return undefined;
119
+ view.setUint16(offset, naluLen, false);
120
+ offset += 2;
121
+ arr.set(naluBytes, offset);
122
+ offset += naluLen;
123
+ }
124
+ view.setUint8(offset++, avcC.PPS.length);
125
+ for (const p of avcC.PPS) {
126
+ const naluBytes = toUint8Array(p.nalu);
127
+ const naluLen = naluBytes.byteLength;
128
+ if (offset + 2 + naluLen > size) return undefined;
129
+ view.setUint16(offset, naluLen, false);
130
+ offset += 2;
131
+ arr.set(naluBytes, offset);
132
+ offset += naluLen;
133
+ }
134
+ if (avcC.ext) {
135
+ const extBytes = toUint8Array(avcC.ext);
136
+ if (offset + extBytes.byteLength > size) return undefined;
137
+ arr.set(extBytes, offset);
138
+ }
139
+ return buf;
140
+ } catch {
141
+ return undefined;
142
+ }
143
+ }
144
+ /** 10ms frames at 48kHz mono - matches typical Opus/voice. */
145
+ const FRAME_SAMPLES = 480;
146
+
147
+ function floatToInt16(float32: Float32Array): Int16Array {
148
+ const int16 = new Int16Array(float32.length);
149
+ for (let i = 0; i < float32.length; i++) {
150
+ let s = float32[i];
151
+ if (!Number.isFinite(s)) {
152
+ int16[i] = 0;
153
+ continue;
154
+ }
155
+ s = Math.max(-1, Math.min(1, s));
156
+ const scale = s < 0 ? 0x8000 : 0x7fff;
157
+ const dither = (Math.random() + Math.random() - 1) * 0.5;
158
+ const scaled = Math.round(s * scale + dither);
159
+ int16[i] = Math.max(-0x8000, Math.min(0x7fff, scaled));
160
+ }
161
+ return int16;
162
+ }
163
+
164
+ function applyVolumeToInt16(
165
+ samples: Int16Array,
166
+ volumePercent: number | null | undefined,
167
+ ): Int16Array {
168
+ const vol = (volumePercent ?? 100) / 100;
169
+ if (vol === 1) return samples;
170
+ const out = new Int16Array(samples.length);
171
+ for (let i = 0; i < samples.length; i++) {
172
+ out[i] = Math.max(-32768, Math.min(32767, Math.round(samples[i] * vol)));
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /** Enable verbose audio pipeline logging. Set VOICE_DEBUG=1 in env to enable. */
178
+ const VOICE_DEBUG = process.env.VOICE_DEBUG === '1' || process.env.VOICE_DEBUG === 'true';
179
+
180
+ /** LiveKit-specific: emitted when server sends leave (token expiry, server policy, etc.). Emitted before disconnect. */
181
+ export type LiveKitRtcConnectionEvents = VoiceConnectionEvents & {
182
+ serverLeave: [];
183
+ /** Emitted when voice state should be synced (self_stream/self_video). VoiceManager listens. */
184
+ requestVoiceStateSync: [payload: { self_stream?: boolean; self_video?: boolean }];
185
+ /** Emitted when a remote participant starts speaking. */
186
+ speakerStart: [payload: { participantId: string }];
187
+ /** Emitted when a remote participant stops speaking. */
188
+ speakerStop: [payload: { participantId: string }];
189
+ /** Emitted for each decoded inbound audio frame. */
190
+ audioFrame: [frame: LiveKitAudioFrame];
191
+ };
192
+
193
+ export type LiveKitAudioFrame = {
194
+ participantId: string;
195
+ trackSid?: string;
196
+ sampleRate: number;
197
+ channels: number;
198
+ samples: Int16Array;
199
+ };
200
+
201
+ export type LiveKitReceiveSubscription = {
202
+ participantId: string;
203
+ stop: () => void;
204
+ };
205
+
206
+ /**
207
+ * Options for video playback via {@link LiveKitRtcConnection.playVideo}.
208
+ *
209
+ * @property source - Track source hint sent to LiveKit. Use `'camera'` for typical video streams
210
+ * (default) or `'screenshare'` for screen-share-style content. Affects how clients may display the track.
211
+ * @property loop - When true (default), loops the video continuously to keep the stream live. Required for
212
+ * LiveKit: "stream it continuously for the benefit of participants joining after the initial frame."
213
+ * @property useFFmpeg - When true, use FFmpeg subprocess for decoding instead of node-webcodecs.
214
+ * Recommended when node-webcodecs causes libc++abi crashes on macOS. Requires ffmpeg in PATH.
215
+ * Also set via FLUXER_VIDEO_FFMPEG=1 env.
216
+ * @property videoBitrate - Max video bitrate in bps (default: 2_500_000). Higher values improve quality.
217
+ * @property maxFramerate - Max framerate for encoding (default: 60).
218
+ * @property width - Output width (default: source). FFmpeg path only.
219
+ * @property height - Output height (default: source). FFmpeg path only.
220
+ * @property resolution - Output resolution. When set, overrides width/height and maxFramerate. FFmpeg path only.
221
+ * 480p, 720p, 1080p, 1440p, 4k = @ 30fps.
222
+ */
223
+ export interface VideoPlayOptions {
224
+ /** Track source hint - camera or screenshare (default: camera). */
225
+ source?: 'camera' | 'screenshare';
226
+ /** Loop video to keep stream continuously live (default: true). */
227
+ loop?: boolean;
228
+ /** Use FFmpeg for decoding (avoids node-webcodecs; requires ffmpeg in PATH). */
229
+ useFFmpeg?: boolean;
230
+ /** Max video bitrate in bps for encoding (default: 2_500_000). */
231
+ videoBitrate?: number;
232
+ /** Max framerate for encoding (default: 60). */
233
+ maxFramerate?: number;
234
+ /** Output width for resolution override (FFmpeg path). */
235
+ width?: number;
236
+ /** Output height for resolution override (FFmpeg path). */
237
+ height?: number;
238
+ /** Output resolution. When set, overrides width/height and maxFramerate. FFmpeg path only. */
239
+ resolution?: '480p' | '720p' | '1080p' | '1440p' | '4k';
240
+ }
241
+
242
+ /**
243
+ * Voice connection using LiveKit RTC. Used when Fluxer routes voice to LiveKit.
244
+ *
245
+ * Supports both audio playback ({@link play}) and video streaming ({@link playVideo}) to voice channels.
246
+ * Video uses node-webcodecs for decoding (no ffmpeg subprocess). Audio uses prism-media WebM demuxer.
247
+ *
248
+ * @emits ready - When connected to the LiveKit room and ready for playback
249
+ * @emits disconnect - When disconnected from the room
250
+ * @emits serverLeave - When LiveKit server signals leave (e.g. token expiry), before disconnect
251
+ * @emits error - On connection, playback, or decoding errors
252
+ */
253
+ export class LiveKitRtcConnection extends EventEmitter {
254
+ readonly client: Client;
255
+ readonly channel: VoiceChannel;
256
+ readonly guildId: string;
257
+ private _volume = 100;
258
+ private _playing = false;
259
+ private _playingVideo = false;
260
+ private _destroyed = false;
261
+ private room: Room | null = null;
262
+ private audioSource: AudioSource | null = null;
263
+ private audioTrack: LocalAudioTrack | null = null;
264
+ private videoSource: VideoSource | null = null;
265
+ private videoTrack: LocalVideoTrack | null = null;
266
+ private currentStream: { destroy?: () => void } | null = null;
267
+ private currentVideoStream: { destroy?: () => void } | null = null;
268
+ private _videoCleanup: (() => void) | null = null;
269
+ private lastServerEndpoint: string | null = null;
270
+ private lastServerToken: string | null = null;
271
+ private _disconnectEmitted = false;
272
+ private readonly receiveSubscriptions = new Map<string, LiveKitReceiveSubscription>();
273
+ private readonly requestedSubscriptions = new Map<string, boolean>();
274
+ private readonly participantTrackSids = new Map<string, string>();
275
+ private readonly activeSpeakers = new Set<string>();
276
+
277
+ /**
278
+ * @param client - The Fluxer client instance
279
+ * @param channel - The voice channel to connect to
280
+ * @param _userId - The user ID (reserved for future use)
281
+ */
282
+ constructor(client: Client, channel: VoiceChannel, _userId: string) {
283
+ super();
284
+ this.client = client;
285
+ this.channel = channel;
286
+ this.guildId = channel.guildId;
287
+ }
288
+
289
+ /** Whether audio is currently playing. */
290
+ get playing(): boolean {
291
+ return this._playing;
292
+ }
293
+
294
+ private debug(msg: string, data?: object | string): void {
295
+ console.error('[voice LiveKitRtc]', msg, data ?? '');
296
+ }
297
+
298
+ private audioDebug(msg: string, data?: object): void {
299
+ if (VOICE_DEBUG) {
300
+ console.error('[voice LiveKitRtc audio]', msg, data ?? '');
301
+ }
302
+ }
303
+
304
+ private emitDisconnect(source: string): void {
305
+ if (this._disconnectEmitted) return;
306
+ this._disconnectEmitted = true;
307
+ this.debug('emitting disconnect', { source });
308
+ this.emit('disconnect');
309
+ }
310
+
311
+ /** Returns true if the LiveKit room is connected and not destroyed. */
312
+ isConnected(): boolean {
313
+ return !this._destroyed && this.room != null && this.room.isConnected;
314
+ }
315
+
316
+ /**
317
+ * Returns true if we're already connected to the given server (skip migration).
318
+ * @param endpoint - Voice server endpoint from the gateway
319
+ * @param token - Voice server token
320
+ */
321
+ isSameServer(endpoint: string | null, token: string): boolean {
322
+ const ep = (endpoint ?? '').trim();
323
+ return ep === (this.lastServerEndpoint ?? '') && token === (this.lastServerToken ?? '');
324
+ }
325
+
326
+ /** Set playback volume (0-200, 100 = normal). Affects current and future playback. */
327
+ setVolume(volumePercent: number): void {
328
+ this._volume = Math.max(0, Math.min(200, volumePercent ?? 100));
329
+ }
330
+
331
+ /** Get current volume (0-200). */
332
+ getVolume(): number {
333
+ return this._volume ?? 100;
334
+ }
335
+
336
+ private isAudioTrack(track: RemoteTrack): boolean {
337
+ return track.kind === TrackKind.KIND_AUDIO;
338
+ }
339
+
340
+ private getParticipantId(participant: RemoteParticipant): string {
341
+ return participant.identity;
342
+ }
343
+
344
+ private subscribeParticipantTrack(
345
+ participant: RemoteParticipant,
346
+ track: RemoteTrack,
347
+ options: { autoSubscribe?: boolean } = {},
348
+ ): void {
349
+ if (!this.isAudioTrack(track)) return;
350
+ const participantId = this.getParticipantId(participant);
351
+ if (!options.autoSubscribe && !this.requestedSubscriptions.has(participantId)) return;
352
+ const current = this.receiveSubscriptions.get(participantId);
353
+ if (current) current.stop();
354
+
355
+ const audioStream = new AudioStream(track, {
356
+ sampleRate: SAMPLE_RATE,
357
+ numChannels: CHANNELS,
358
+ frameSizeMs: 10,
359
+ });
360
+ let stopped = false;
361
+ let reader: ReturnType<AudioStream['getReader']> | null = null;
362
+
363
+ const pump = async () => {
364
+ try {
365
+ reader = audioStream.getReader();
366
+ while (!stopped) {
367
+ let readTimeout: NodeJS.Timeout | null = null;
368
+ const next = await Promise.race([
369
+ reader.read(),
370
+ new Promise<null>((resolve) => {
371
+ readTimeout = setTimeout(() => resolve(null), RECEIVE_READ_TIMEOUT_MS);
372
+ }),
373
+ ]);
374
+ if (readTimeout) clearTimeout(readTimeout);
375
+ if (next === null) continue;
376
+ const { done, value } = next;
377
+ if (done || !value) break;
378
+ this.emit('audioFrame', {
379
+ participantId,
380
+ trackSid: track.sid,
381
+ sampleRate: value.sampleRate,
382
+ channels: value.channels,
383
+ samples: value.data,
384
+ });
385
+ }
386
+ } catch (err) {
387
+ if (!stopped) {
388
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
389
+ }
390
+ } finally {
391
+ if (reader) {
392
+ try {
393
+ reader.releaseLock();
394
+ } catch {
395
+ // Reader may already be released.
396
+ }
397
+ reader = null;
398
+ }
399
+ }
400
+ };
401
+
402
+ const stop = () => {
403
+ if (stopped) return;
404
+ stopped = true;
405
+ if (reader) {
406
+ reader.cancel().catch(() => {});
407
+ try {
408
+ reader.releaseLock();
409
+ } catch {
410
+ // Reader may already be released.
411
+ }
412
+ }
413
+ audioStream.cancel().catch(() => {});
414
+ this.receiveSubscriptions.delete(participantId);
415
+ };
416
+
417
+ this.receiveSubscriptions.set(participantId, { participantId, stop });
418
+ this.participantTrackSids.set(participantId, track.sid ?? '');
419
+ void pump();
420
+ }
421
+
422
+ subscribeParticipantAudio(
423
+ participantId: string,
424
+ options: { autoResubscribe?: boolean } = {},
425
+ ): LiveKitReceiveSubscription {
426
+ const autoResubscribe = options.autoResubscribe === true;
427
+ const stop = () => {
428
+ this.receiveSubscriptions.get(participantId)?.stop();
429
+ this.receiveSubscriptions.delete(participantId);
430
+ this.participantTrackSids.delete(participantId);
431
+ this.requestedSubscriptions.delete(participantId);
432
+ };
433
+ this.requestedSubscriptions.set(participantId, autoResubscribe);
434
+
435
+ const room = this.room;
436
+ if (!room || !room.isConnected) return { participantId, stop };
437
+
438
+ const participant = room.remoteParticipants.get(participantId);
439
+ if (!participant) return { participantId, stop };
440
+
441
+ for (const pub of participant.trackPublications.values()) {
442
+ const maybeTrack = (pub as { track?: RemoteTrack }).track;
443
+ if (maybeTrack && this.isAudioTrack(maybeTrack)) {
444
+ this.subscribeParticipantTrack(participant, maybeTrack);
445
+ break;
446
+ }
447
+ }
448
+
449
+ return { participantId, stop };
450
+ }
451
+
452
+ private clearReceiveSubscriptions(): void {
453
+ for (const sub of this.receiveSubscriptions.values()) sub.stop();
454
+ this.receiveSubscriptions.clear();
455
+ this.requestedSubscriptions.clear();
456
+ this.participantTrackSids.clear();
457
+ this.activeSpeakers.clear();
458
+ }
459
+
460
+ playOpus(_stream: NodeJS.ReadableStream): void {
461
+ this.emit(
462
+ 'error',
463
+ new Error('LiveKit: playOpus not supported; use play(url) with a WebM/Opus URL'),
464
+ );
465
+ }
466
+
467
+ /**
468
+ * Connect to the LiveKit room using voice server and state from the gateway.
469
+ * Called internally by VoiceManager; typically not used directly.
470
+ *
471
+ * @param server - Voice server update data (endpoint, token)
472
+ * @param _state - Voice state update data (session, channel)
473
+ */
474
+ async connect(
475
+ server: GatewayVoiceServerUpdateDispatchData,
476
+ _state: GatewayVoiceStateUpdateDispatchData,
477
+ ): Promise<void> {
478
+ const raw = (server.endpoint ?? '').trim();
479
+ const token = server.token;
480
+ if (!raw || !token) {
481
+ this.emit('error', new Error('Missing voice server endpoint or token'));
482
+ return;
483
+ }
484
+
485
+ const url = buildLiveKitUrlForRtcSdk(raw);
486
+ this._disconnectEmitted = false;
487
+
488
+ try {
489
+ const room = new Room();
490
+ this.room = room;
491
+
492
+ room.on(RoomEvent.Disconnected, () => {
493
+ this.debug('Room disconnected');
494
+ this.lastServerEndpoint = null;
495
+ this.lastServerToken = null;
496
+ setImmediate(() => this.emit('serverLeave'));
497
+ this.emitDisconnect('room_disconnected');
498
+ });
499
+
500
+ room.on(RoomEvent.Reconnecting, () => {
501
+ this.debug('Room reconnecting');
502
+ });
503
+
504
+ room.on(RoomEvent.Reconnected, () => {
505
+ this.debug('Room reconnected');
506
+ });
507
+
508
+ room.on(RoomEvent.TrackSubscribed, (track, _publication, participant) => {
509
+ if (!this.isAudioTrack(track)) return;
510
+ this.subscribeParticipantTrack(participant, track);
511
+ });
512
+
513
+ room.on(RoomEvent.TrackUnsubscribed, (track, _publication, participant) => {
514
+ if (!this.isAudioTrack(track)) return;
515
+ const participantId = this.getParticipantId(participant);
516
+ this.receiveSubscriptions.get(participantId)?.stop();
517
+ this.receiveSubscriptions.delete(participantId);
518
+ if (this.requestedSubscriptions.get(participantId) !== true) {
519
+ this.requestedSubscriptions.delete(participantId);
520
+ }
521
+ this.participantTrackSids.delete(participantId);
522
+ });
523
+
524
+ room.on(RoomEvent.ParticipantDisconnected, (participant) => {
525
+ const participantId = this.getParticipantId(participant);
526
+ this.receiveSubscriptions.get(participantId)?.stop();
527
+ this.receiveSubscriptions.delete(participantId);
528
+ if (this.requestedSubscriptions.get(participantId) !== true) {
529
+ this.requestedSubscriptions.delete(participantId);
530
+ }
531
+ this.participantTrackSids.delete(participantId);
532
+ if (this.activeSpeakers.delete(participantId)) {
533
+ this.emit('speakerStop', { participantId });
534
+ }
535
+ });
536
+
537
+ room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
538
+ const next = new Set(speakers.map((speaker) => speaker.identity));
539
+ for (const participantId of next) {
540
+ if (!this.activeSpeakers.has(participantId)) {
541
+ this.emit('speakerStart', { participantId });
542
+ }
543
+ }
544
+ for (const participantId of this.activeSpeakers) {
545
+ if (!next.has(participantId)) {
546
+ this.emit('speakerStop', { participantId });
547
+ }
548
+ }
549
+ this.activeSpeakers.clear();
550
+ for (const participantId of next) this.activeSpeakers.add(participantId);
551
+ });
552
+
553
+ await room.connect(url, token, { autoSubscribe: true, dynacast: false });
554
+ this.lastServerEndpoint = raw;
555
+ this.lastServerToken = token;
556
+ this.debug('connected to room');
557
+ this.emit('ready');
558
+ } catch (e) {
559
+ this.room = null;
560
+ const err = e instanceof Error ? e : new Error(String(e));
561
+ this.emit('error', err);
562
+ throw err;
563
+ }
564
+ }
565
+
566
+ /** Whether a video track is currently playing in the voice channel. */
567
+ get playingVideo(): boolean {
568
+ return this._playingVideo;
569
+ }
570
+
571
+ /**
572
+ * Play video from an MP4 URL or buffer. Streams decoded frames to the LiveKit room as a video track.
573
+ * Uses node-webcodecs for decoding (no ffmpeg). Supports H.264 (avc1) and H.265 (hvc1/hev1) codecs.
574
+ *
575
+ * @param urlOrBuffer - Video source: HTTP(S) URL to an MP4 file, or raw ArrayBuffer/Uint8Array of MP4 data
576
+ * @param options - Optional playback options (see {@link VideoPlayOptions})
577
+ * @emits error - On fetch failure, missing video track, or decode errors
578
+ *
579
+ * @example
580
+ * ```ts
581
+ * const conn = await voiceManager.join(channel);
582
+ * if (conn instanceof LiveKitRtcConnection && conn.isConnected()) {
583
+ * await conn.playVideo('https://example.com/video.mp4', { source: 'camera' });
584
+ * }
585
+ * ```
586
+ */
587
+ async playVideo(
588
+ urlOrBuffer: string | ArrayBuffer | Uint8Array,
589
+ options?: VideoPlayOptions,
590
+ ): Promise<void> {
591
+ this.stopVideo();
592
+ if (!this.room || !this.room.isConnected) {
593
+ this.emit('error', new Error('LiveKit: not connected'));
594
+ return;
595
+ }
596
+
597
+ let useFFmpeg = options?.useFFmpeg ?? process.env.FLUXER_VIDEO_FFMPEG === '1';
598
+ if (options?.resolution) useFFmpeg = true; // resolution requires FFmpeg path
599
+ if (useFFmpeg && typeof urlOrBuffer === 'string') {
600
+ await this.playVideoFFmpeg(urlOrBuffer, options);
601
+ return;
602
+ }
603
+ if (useFFmpeg && (urlOrBuffer instanceof ArrayBuffer || urlOrBuffer instanceof Uint8Array)) {
604
+ this.emit('error', new Error('useFFmpeg requires a URL; buffer/ArrayBuffer not supported'));
605
+ return;
606
+ }
607
+
608
+ let VideoDecoder: typeof import('node-webcodecs').VideoDecoder;
609
+ let EncodedVideoChunk: typeof import('node-webcodecs').EncodedVideoChunk;
610
+ try {
611
+ const webcodecs = await import('node-webcodecs');
612
+ VideoDecoder = webcodecs.VideoDecoder;
613
+ EncodedVideoChunk = webcodecs.EncodedVideoChunk;
614
+ } catch {
615
+ this.emit(
616
+ 'error',
617
+ new Error(
618
+ 'node-webcodecs is not available (optional dependency failed to install). Use options.useFFmpeg with a URL, or install node-webcodecs.',
619
+ ),
620
+ );
621
+ return;
622
+ }
623
+
624
+ const videoUrl = typeof urlOrBuffer === 'string' ? urlOrBuffer : null;
625
+
626
+ let arrayBuffer: ArrayBuffer;
627
+ if (typeof urlOrBuffer === 'string') {
628
+ try {
629
+ const response = await fetch(urlOrBuffer);
630
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
631
+ const buf = await response.arrayBuffer();
632
+ arrayBuffer = buf;
633
+ } catch (e) {
634
+ this.emit('error', e instanceof Error ? e : new Error(String(e)));
635
+ return;
636
+ }
637
+ } else if (urlOrBuffer instanceof Uint8Array) {
638
+ arrayBuffer = urlOrBuffer.buffer.slice(
639
+ urlOrBuffer.byteOffset,
640
+ urlOrBuffer.byteOffset + urlOrBuffer.byteLength,
641
+ ) as ArrayBuffer;
642
+ } else {
643
+ arrayBuffer = urlOrBuffer;
644
+ }
645
+
646
+ const file = createFile();
647
+ const sourceOption = options?.source ?? 'camera';
648
+ const loop = options?.loop ?? true;
649
+
650
+ file.onError = (e: Error) => {
651
+ this._playingVideo = false;
652
+ this.emit('error', e);
653
+ };
654
+
655
+ file.onReady = (info: {
656
+ tracks?: Array<{
657
+ id: number;
658
+ type: string;
659
+ codec: string;
660
+ video?: { width: number; height: number };
661
+ audio?: { sample_rate: number; channel_count: number };
662
+ timescale?: number;
663
+ nb_samples?: number;
664
+ }>;
665
+ }) => {
666
+ if (!info.tracks?.length) {
667
+ this.emit('error', new Error('No tracks found in MP4 file'));
668
+ return;
669
+ }
670
+ const tracks = info.tracks;
671
+ const videoTrack = tracks.find((t: { type: string }) => t.type === 'video');
672
+ if (!videoTrack) {
673
+ this.emit('error', new Error('No video track in MP4'));
674
+ return;
675
+ }
676
+ const audioTrackInfo = tracks.find(
677
+ (t: { type: string; codec: string }) => t.type === 'audio' && t.codec.startsWith('mp4a'),
678
+ );
679
+ const width = videoTrack.video?.width ?? 640;
680
+ const height = videoTrack.video?.height ?? 480;
681
+ const totalSamples = videoTrack.nb_samples ?? Number.POSITIVE_INFINITY;
682
+
683
+ const source = new VideoSource(width, height);
684
+ this.videoSource = source;
685
+ const track = LocalVideoTrack.createVideoTrack('video', source);
686
+ this.videoTrack = track;
687
+
688
+ let audioSource: AudioSource | null = null;
689
+ let audioTrack: LocalAudioTrack | null = null;
690
+ let audioFfmpegProc: ReturnType<typeof spawn> | null = null;
691
+
692
+ const decoderCodec = videoTrack.codec.startsWith('avc1')
693
+ ? videoTrack.codec
694
+ : videoTrack.codec.startsWith('hvc1') || videoTrack.codec.startsWith('hev1')
695
+ ? videoTrack.codec
696
+ : 'avc1.42E01E';
697
+
698
+ // WebCodecs expects AVCDecoderConfigurationRecord when input is AVCC (MP4) format.
699
+ // Without description, the decoder assumes Annex B (start codes) and fails with "No start code is found".
700
+ let decoderDescription: ArrayBuffer | undefined;
701
+ if (videoTrack.codec.startsWith('avc1') || videoTrack.codec.startsWith('avc3')) {
702
+ type SampleEntry = { avcC?: AvcCBox };
703
+ type Trak = {
704
+ tkhd: { track_id: number };
705
+ mdia: { minf: { stbl: { stsd: { entries: SampleEntry[] } } } };
706
+ };
707
+ const isoFile = file as { moov?: { traks?: Trak[] } };
708
+ const trak = isoFile.moov?.traks?.find((t: Trak) => t.tkhd.track_id === videoTrack.id);
709
+ const sampleEntry = trak?.mdia?.minf?.stbl?.stsd?.entries?.[0];
710
+ const avcC = sampleEntry?.avcC;
711
+ if (avcC) {
712
+ decoderDescription = buildAvcDecoderConfig(avcC);
713
+ }
714
+ }
715
+
716
+ // Set up audio via FFmpeg->WebM/Opus (same pipeline as play()) when URL and MP4 has audio
717
+ if (videoUrl && audioTrackInfo) {
718
+ audioSource = new AudioSource(SAMPLE_RATE, CHANNELS);
719
+ this.audioSource = audioSource;
720
+ audioTrack = LocalAudioTrack.createAudioTrack('audio', audioSource);
721
+ this.audioTrack = audioTrack;
722
+ }
723
+
724
+ // Real-time pacing: queue frames and deliver via setInterval.
725
+ // Avoids hundreds of per-frame setTimeout calls which can trigger libc++abi crashes on macOS.
726
+ const frameQueue: Array<{
727
+ buffer: Uint8Array;
728
+ width: number;
729
+ height: number;
730
+ timestampMs: number;
731
+ }> = [];
732
+ let playbackStartMs: number | null = null;
733
+ const maxFps = options?.maxFramerate ?? 60;
734
+ const FRAME_INTERVAL_MS = Math.round(1000 / maxFps); // e.g. 17ms for 60fps
735
+ const MAX_QUEUED_FRAMES = 30; // ~1 second - drop excess to prevent lag accumulation
736
+ let pacingInterval: ReturnType<typeof setInterval> | null = null;
737
+
738
+ const decoder = new VideoDecoder({
739
+ output: async (frame: WebCodecsVideoFrame) => {
740
+ if (!this._playingVideo || !source) return;
741
+ const { codedWidth, codedHeight } = frame;
742
+ if (codedWidth <= 0 || codedHeight <= 0) {
743
+ frame.close();
744
+ if (VOICE_DEBUG)
745
+ this.audioDebug('video frame skipped (invalid dimensions)', {
746
+ codedWidth,
747
+ codedHeight,
748
+ });
749
+ return;
750
+ }
751
+ try {
752
+ if (playbackStartMs === null) playbackStartMs = Date.now();
753
+ const frameTimestampUs = (frame as { timestamp?: number }).timestamp ?? 0;
754
+ const frameTimeMs = frameTimestampUs / 1000;
755
+
756
+ const copyOptions = frame.format !== 'I420' ? { format: 'I420' as const } : undefined;
757
+ const size = frame.allocationSize(copyOptions);
758
+ const buffer = new Uint8Array(size);
759
+ await frame.copyTo(buffer, copyOptions);
760
+ frame.close();
761
+
762
+ const expectedI420Size = Math.ceil((codedWidth * codedHeight * 3) / 2);
763
+ if (buffer.byteLength < expectedI420Size) {
764
+ if (VOICE_DEBUG)
765
+ this.audioDebug('video frame skipped (buffer too small)', {
766
+ codedWidth,
767
+ codedHeight,
768
+ });
769
+ return;
770
+ }
771
+ // Drop oldest frames when queue is full to stay in sync and prevent memory spike
772
+ while (frameQueue.length >= MAX_QUEUED_FRAMES) {
773
+ frameQueue.shift();
774
+ }
775
+ frameQueue.push({
776
+ buffer,
777
+ width: codedWidth,
778
+ height: codedHeight,
779
+ timestampMs: frameTimeMs,
780
+ });
781
+ } catch (err) {
782
+ if (VOICE_DEBUG) this.audioDebug('video frame error', { error: String(err) });
783
+ }
784
+ },
785
+ error: (e: Error) => {
786
+ this.emit('error', e);
787
+ doCleanup();
788
+ },
789
+ });
790
+
791
+ decoder.configure({
792
+ codec: decoderCodec,
793
+ codedWidth: width,
794
+ codedHeight: height,
795
+ ...(decoderDescription && { description: decoderDescription }),
796
+ });
797
+
798
+ let samplesReceived = 0;
799
+ let cleanupCalled = false;
800
+ let currentFile: ReturnType<typeof createFile> = file;
801
+
802
+ const doCleanup = () => {
803
+ if (cleanupCalled) return;
804
+ cleanupCalled = true;
805
+ this._videoCleanup = null;
806
+ this._playingVideo = false;
807
+ if (pacingInterval) {
808
+ clearInterval(pacingInterval);
809
+ pacingInterval = null;
810
+ }
811
+ this.emit('requestVoiceStateSync', { self_stream: false, self_video: false });
812
+ const fileObj = currentFile as unknown as { stop?: () => void };
813
+ if (typeof fileObj.stop === 'function') {
814
+ fileObj.stop();
815
+ }
816
+ try {
817
+ decoder.close();
818
+ } catch {
819
+ /* decoder.close() may throw */
820
+ }
821
+ if (audioFfmpegProc && !audioFfmpegProc.killed) {
822
+ audioFfmpegProc.kill('SIGKILL');
823
+ audioFfmpegProc = null;
824
+ }
825
+ this.currentVideoStream = null;
826
+ if (this.videoTrack) {
827
+ this.videoTrack.close().catch(() => {});
828
+ this.videoTrack = null;
829
+ }
830
+ if (this.videoSource) {
831
+ this.videoSource.close().catch(() => {});
832
+ this.videoSource = null;
833
+ }
834
+ if (audioTrack) {
835
+ audioTrack.close().catch(() => {});
836
+ this.audioTrack = null;
837
+ }
838
+ if (audioSource) {
839
+ audioSource.close().catch(() => {});
840
+ this.audioSource = null;
841
+ }
842
+ };
843
+
844
+ const flushAndCleanup = () => {
845
+ decoder.flush().then(doCleanup).catch(doCleanup);
846
+ };
847
+
848
+ /** Restart extraction with a fresh mp4box file to loop. Keeps stream live per LiveKit docs. */
849
+ const scheduleLoop = (mp4File: ReturnType<typeof createFile>) => {
850
+ setImmediate(async () => {
851
+ if (!this._playingVideo || cleanupCalled) return;
852
+ try {
853
+ await decoder.flush();
854
+ decoder.reset();
855
+ decoder.configure({
856
+ codec: decoderCodec,
857
+ codedWidth: width,
858
+ codedHeight: height,
859
+ ...(decoderDescription && { description: decoderDescription }),
860
+ });
861
+ const fileObj = mp4File as unknown as { stop?: () => void };
862
+ if (typeof fileObj.stop === 'function') fileObj.stop();
863
+ } catch (e) {
864
+ if (VOICE_DEBUG) this.audioDebug('loop reset error', { error: String(e) });
865
+ }
866
+ if (!this._playingVideo || cleanupCalled) return;
867
+ playbackStartMs = null;
868
+ frameQueue.length = 0;
869
+ samplesReceived = 0;
870
+ const loopFile = createFile();
871
+ loopFile.onError = (e: Error) => {
872
+ this._playingVideo = false;
873
+ this.emit('error', e);
874
+ };
875
+ loopFile.onReady = (loopInfo: { tracks?: Array<{ id: number; type: string }> }) => {
876
+ const loopTracks = loopInfo.tracks ?? [];
877
+ const loopVt = loopTracks.find((t: { type: string }) => t.type === 'video');
878
+ if (!loopVt || loopVt.id !== videoTrack.id) return;
879
+ currentFile = loopFile;
880
+ this.currentVideoStream = loopFile as unknown as {
881
+ destroy?: () => void;
882
+ stop?: () => void;
883
+ };
884
+ loopFile.setExtractionOptions(loopVt.id, null, { nbSamples: 16 });
885
+ loopFile.onSamples = (
886
+ tid: number,
887
+ _u: unknown,
888
+ samp: Array<{
889
+ data: ArrayBuffer;
890
+ is_sync?: boolean;
891
+ is_rap?: boolean;
892
+ timescale: number;
893
+ dts: number;
894
+ duration: number;
895
+ }>,
896
+ ) => {
897
+ if (!this._playingVideo) return;
898
+ if (tid === videoTrack.id) {
899
+ try {
900
+ for (const sample of samp) {
901
+ const isKeyFrame =
902
+ sample.is_sync ?? (sample as { is_rap?: boolean }).is_rap ?? sample.dts === 0;
903
+ const chunk = new EncodedVideoChunk({
904
+ type: isKeyFrame ? 'key' : 'delta',
905
+ timestamp: Math.round((sample.dts / sample.timescale) * 1_000_000),
906
+ duration: Math.round((sample.duration / sample.timescale) * 1_000_000),
907
+ data: sample.data,
908
+ });
909
+ decoder.decode(chunk);
910
+ }
911
+ } catch (decodeErr) {
912
+ this.emit(
913
+ 'error',
914
+ decodeErr instanceof Error ? decodeErr : new Error(String(decodeErr)),
915
+ );
916
+ doCleanup();
917
+ return;
918
+ }
919
+ samplesReceived += samp.length;
920
+ if (samplesReceived >= totalSamples) {
921
+ if (loop) scheduleLoop(loopFile);
922
+ else flushAndCleanup();
923
+ }
924
+ }
925
+ };
926
+ loopFile.start();
927
+ };
928
+ (arrayBuffer as { fileStart?: number }).fileStart = 0;
929
+ loopFile.appendBuffer(arrayBuffer);
930
+ loopFile.flush();
931
+ });
932
+ };
933
+
934
+ this._videoCleanup = () => {
935
+ doCleanup();
936
+ };
937
+
938
+ file.onSamples = (
939
+ trackId: number,
940
+ _user: unknown,
941
+ samples: Array<{
942
+ data: ArrayBuffer;
943
+ is_sync?: boolean;
944
+ is_rap?: boolean;
945
+ timescale: number;
946
+ dts: number;
947
+ duration: number;
948
+ }>,
949
+ ) => {
950
+ if (!this._playingVideo) return;
951
+ if (trackId === videoTrack.id) {
952
+ try {
953
+ for (const sample of samples) {
954
+ const isKeyFrame =
955
+ sample.is_sync ?? (sample as { is_rap?: boolean }).is_rap ?? sample.dts === 0;
956
+ const chunk = new EncodedVideoChunk({
957
+ type: isKeyFrame ? 'key' : 'delta',
958
+ timestamp: Math.round((sample.dts / sample.timescale) * 1_000_000),
959
+ duration: Math.round((sample.duration / sample.timescale) * 1_000_000),
960
+ data: sample.data,
961
+ });
962
+ decoder.decode(chunk);
963
+ }
964
+ } catch (decodeErr) {
965
+ this.emit(
966
+ 'error',
967
+ decodeErr instanceof Error ? decodeErr : new Error(String(decodeErr)),
968
+ );
969
+ doCleanup();
970
+ return;
971
+ }
972
+ samplesReceived += samples.length;
973
+ if (samplesReceived >= totalSamples) {
974
+ if (loop) scheduleLoop(file);
975
+ else flushAndCleanup();
976
+ }
977
+ }
978
+ };
979
+
980
+ const participant = this.room?.localParticipant;
981
+ if (!participant) return;
982
+
983
+ const publishOptions = new TrackPublishOptions({
984
+ source:
985
+ sourceOption === 'screenshare'
986
+ ? TrackSource.SOURCE_SCREENSHARE
987
+ : TrackSource.SOURCE_CAMERA,
988
+ videoEncoding: {
989
+ maxBitrate: BigInt(options?.videoBitrate ?? 2_500_000),
990
+ maxFramerate: options?.maxFramerate ?? 60,
991
+ },
992
+ });
993
+
994
+ const publishVideo = participant.publishTrack(track, publishOptions);
995
+ const audioPublishOptions = new TrackPublishOptions();
996
+ audioPublishOptions.source = TrackSource.SOURCE_MICROPHONE;
997
+ const publishAudio = audioTrack
998
+ ? participant.publishTrack(audioTrack, audioPublishOptions)
999
+ : Promise.resolve();
1000
+
1001
+ Promise.all([publishVideo, publishAudio])
1002
+ .then(async () => {
1003
+ this._playingVideo = true;
1004
+ this.currentVideoStream = file as unknown as { destroy?: () => void; stop?: () => void };
1005
+ file.setExtractionOptions(videoTrack.id, null, { nbSamples: 16 });
1006
+ pacingInterval = setInterval(() => {
1007
+ if (!this._playingVideo || !source || playbackStartMs === null) return;
1008
+ const elapsed = Date.now() - playbackStartMs;
1009
+ // When behind (queue backing up), drop stale frames to catch up - adaptive frame dropping
1010
+ if (frameQueue.length > 10) {
1011
+ while (frameQueue.length > 1 && frameQueue[1]!.timestampMs <= elapsed) {
1012
+ frameQueue.shift();
1013
+ }
1014
+ }
1015
+ // Deliver exactly one frame per tick for smooth 30fps pacing (avoids burst/jumpy playback)
1016
+ if (frameQueue.length > 0 && frameQueue[0]!.timestampMs <= elapsed) {
1017
+ const f = frameQueue.shift()!;
1018
+ try {
1019
+ const livekitFrame = new VideoFrame(
1020
+ f.buffer,
1021
+ f.width,
1022
+ f.height,
1023
+ VideoBufferType.I420,
1024
+ );
1025
+ source.captureFrame(livekitFrame);
1026
+ } catch (captureErr) {
1027
+ if (VOICE_DEBUG)
1028
+ this.audioDebug('captureFrame error', { error: String(captureErr) });
1029
+ this.emit(
1030
+ 'error',
1031
+ captureErr instanceof Error ? captureErr : new Error(String(captureErr)),
1032
+ );
1033
+ }
1034
+ }
1035
+ }, FRAME_INTERVAL_MS);
1036
+ setImmediate(() => {
1037
+ if (!this._playingVideo) return;
1038
+ file.start();
1039
+ });
1040
+
1041
+ // Start FFmpeg audio pipeline (same as play()) when video has audio and we have URL
1042
+ if (videoUrl && audioSource && audioTrack) {
1043
+ const runAudioFfmpeg = async () => {
1044
+ if (!this._playingVideo || cleanupCalled || !audioSource) return;
1045
+ const audioProc = spawn(
1046
+ 'ffmpeg',
1047
+ [
1048
+ '-loglevel',
1049
+ 'warning',
1050
+ '-re',
1051
+ '-i',
1052
+ videoUrl,
1053
+ '-vn',
1054
+ '-c:a',
1055
+ 'libopus',
1056
+ '-f',
1057
+ 'webm',
1058
+ ...(loop ? ['-stream_loop', '-1'] : []),
1059
+ 'pipe:1',
1060
+ ],
1061
+ { stdio: ['ignore', 'pipe', 'pipe'] },
1062
+ );
1063
+ audioFfmpegProc = audioProc;
1064
+ const demuxer = new opus.WebmDemuxer();
1065
+ if (audioProc.stdout) audioProc.stdout.pipe(demuxer);
1066
+
1067
+ const decoder = new OpusDecoder({ sampleRate: SAMPLE_RATE, channels: CHANNELS });
1068
+ await decoder.ready;
1069
+
1070
+ let sampleBuffer = new Int16Array(0);
1071
+ let opusBuffer = new Uint8Array(0);
1072
+ let processing = false;
1073
+ const opusFrameQueue: Uint8Array[] = [];
1074
+
1075
+ const processOneOpusFrame = async (frame: Uint8Array) => {
1076
+ if (frame.length < 2 || !audioSource || !this._playingVideo) return;
1077
+ try {
1078
+ const result = decoder.decodeFrame(frame);
1079
+ if (!result?.channelData?.[0]?.length) return;
1080
+ const int16 = floatToInt16(result.channelData[0]);
1081
+ const newBuffer = new Int16Array(sampleBuffer.length + int16.length);
1082
+ newBuffer.set(sampleBuffer);
1083
+ newBuffer.set(int16, sampleBuffer.length);
1084
+ sampleBuffer = newBuffer;
1085
+ while (
1086
+ sampleBuffer.length >= FRAME_SAMPLES &&
1087
+ this._playingVideo &&
1088
+ audioSource
1089
+ ) {
1090
+ const rawSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1091
+ sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
1092
+ const outSamples = applyVolumeToInt16(rawSamples, this._volume);
1093
+ const audioFrame = new AudioFrame(
1094
+ outSamples,
1095
+ SAMPLE_RATE,
1096
+ CHANNELS,
1097
+ FRAME_SAMPLES,
1098
+ );
1099
+ if (audioSource.queuedDuration > 500) await audioSource.waitForPlayout();
1100
+ await audioSource.captureFrame(audioFrame);
1101
+ }
1102
+ } catch {
1103
+ /* decoder.close() may throw */
1104
+ }
1105
+ };
1106
+ const drainQueue = async () => {
1107
+ if (processing || opusFrameQueue.length === 0) return;
1108
+ processing = true;
1109
+ while (opusFrameQueue.length > 0 && this._playingVideo && audioSource) {
1110
+ const f = opusFrameQueue.shift()!;
1111
+ await processOneOpusFrame(f);
1112
+ }
1113
+ processing = false;
1114
+ };
1115
+
1116
+ demuxer.on('data', (chunk: Buffer) => {
1117
+ if (!this._playingVideo) return;
1118
+ opusBuffer = new Uint8Array(concatUint8Arrays(opusBuffer, new Uint8Array(chunk)));
1119
+ while (opusBuffer.length > 0) {
1120
+ const parsed = parseOpusPacketBoundaries(opusBuffer);
1121
+ if (!parsed) break;
1122
+ opusBuffer = new Uint8Array(opusBuffer.subarray(parsed.consumed));
1123
+ for (const frame of parsed.frames) opusFrameQueue.push(frame);
1124
+ }
1125
+ drainQueue().catch(() => {});
1126
+ });
1127
+
1128
+ audioProc.on('exit', (code) => {
1129
+ if (audioFfmpegProc === audioProc) audioFfmpegProc = null;
1130
+ if (loop && this._playingVideo && !cleanupCalled && (code === 0 || code === null)) {
1131
+ setImmediate(() => runAudioFfmpeg());
1132
+ }
1133
+ });
1134
+ };
1135
+ runAudioFfmpeg().catch((e) =>
1136
+ this.audioDebug('audio ffmpeg error', { error: String(e) }),
1137
+ );
1138
+ }
1139
+
1140
+ this.emit('requestVoiceStateSync', {
1141
+ self_stream: sourceOption === 'screenshare',
1142
+ self_video: sourceOption === 'camera',
1143
+ });
1144
+ })
1145
+ .catch((err) => {
1146
+ this._playingVideo = false;
1147
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
1148
+ });
1149
+ };
1150
+
1151
+ (arrayBuffer as { fileStart?: number }).fileStart = 0;
1152
+ file.appendBuffer(arrayBuffer);
1153
+ file.flush();
1154
+ }
1155
+
1156
+ /**
1157
+ * FFmpeg-based video playback. Bypasses node-webcodecs to avoid libc++abi crashes on macOS.
1158
+ * Requires ffmpeg and ffprobe in PATH. URL input only.
1159
+ */
1160
+ private async playVideoFFmpeg(url: string, options?: VideoPlayOptions): Promise<void> {
1161
+ const sourceOption = options?.source ?? 'camera';
1162
+ const loop = options?.loop ?? true;
1163
+
1164
+ let width = 640;
1165
+ let height = 480;
1166
+ let hasAudio = false;
1167
+ try {
1168
+ const exec = promisify(execFile);
1169
+ const { stdout } = await exec(
1170
+ 'ffprobe',
1171
+ [
1172
+ '-v',
1173
+ 'error',
1174
+ '-show_streams',
1175
+ '-show_entries',
1176
+ 'stream=codec_type,width,height',
1177
+ '-of',
1178
+ 'json',
1179
+ url,
1180
+ ],
1181
+ { encoding: 'utf8', timeout: 10000 },
1182
+ );
1183
+ const parsed = JSON.parse(stdout) as {
1184
+ streams?: Array<{ codec_type?: string; width?: number; height?: number }>;
1185
+ };
1186
+ const streams = parsed?.streams ?? [];
1187
+ for (const s of streams) {
1188
+ if (s.codec_type === 'video' && s.width != null && s.height != null) {
1189
+ width = s.width;
1190
+ height = s.height;
1191
+ break;
1192
+ }
1193
+ }
1194
+ for (const s of streams) {
1195
+ if (s.codec_type === 'audio') {
1196
+ hasAudio = true;
1197
+ break;
1198
+ }
1199
+ }
1200
+ } catch (probeErr) {
1201
+ this.emit(
1202
+ 'error',
1203
+ new Error(
1204
+ `ffprobe failed: ${probeErr instanceof Error ? probeErr.message : String(probeErr)}`,
1205
+ ),
1206
+ );
1207
+ return;
1208
+ }
1209
+ let maxFps = options?.maxFramerate ?? 60;
1210
+ const res = options?.resolution;
1211
+ if (res === '480p') {
1212
+ width = 854;
1213
+ height = 480;
1214
+ maxFps = 60;
1215
+ } else if (res === '720p') {
1216
+ width = 1280;
1217
+ height = 720;
1218
+ maxFps = 60;
1219
+ } else if (res === '1080p') {
1220
+ width = 1920;
1221
+ height = 1080;
1222
+ maxFps = 60;
1223
+ } else if (res === '1440p') {
1224
+ width = 2560;
1225
+ height = 1440;
1226
+ maxFps = 60;
1227
+ } else if (res === '4k') {
1228
+ width = 3840;
1229
+ height = 2160;
1230
+ maxFps = 60;
1231
+ } else if (options?.width != null && options?.height != null) {
1232
+ width = options.width;
1233
+ height = options.height;
1234
+ }
1235
+
1236
+ const source = new VideoSource(width, height);
1237
+ this.videoSource = source;
1238
+ const track = LocalVideoTrack.createVideoTrack('video', source);
1239
+ this.videoTrack = track;
1240
+
1241
+ const publishOptions = new TrackPublishOptions({
1242
+ source:
1243
+ sourceOption === 'screenshare' ? TrackSource.SOURCE_SCREENSHARE : TrackSource.SOURCE_CAMERA,
1244
+ videoEncoding: {
1245
+ maxBitrate: BigInt(options?.videoBitrate ?? 2_500_000),
1246
+ maxFramerate: maxFps,
1247
+ },
1248
+ });
1249
+
1250
+ const participant = this.room?.localParticipant;
1251
+ if (!participant) return;
1252
+
1253
+ try {
1254
+ await participant.publishTrack(track, publishOptions);
1255
+ } catch (err) {
1256
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
1257
+ return;
1258
+ }
1259
+
1260
+ let audioSource: AudioSource | null = null;
1261
+ let audioReady = false;
1262
+ if (hasAudio) {
1263
+ const src = new AudioSource(SAMPLE_RATE, CHANNELS);
1264
+ audioSource = src;
1265
+ this.audioSource = src;
1266
+ const track = LocalAudioTrack.createAudioTrack('audio', src);
1267
+ this.audioTrack = track;
1268
+ try {
1269
+ await participant.publishTrack(
1270
+ track,
1271
+ new TrackPublishOptions({ source: TrackSource.SOURCE_MICROPHONE }),
1272
+ );
1273
+ audioReady = true;
1274
+ } catch {
1275
+ track.close().catch(() => {});
1276
+ this.audioTrack = null;
1277
+ this.audioSource = null;
1278
+ }
1279
+ } else {
1280
+ this.audioSource = null;
1281
+ this.audioTrack = null;
1282
+ }
1283
+
1284
+ this._playingVideo = true;
1285
+ this.emit('requestVoiceStateSync', {
1286
+ self_stream: sourceOption === 'screenshare',
1287
+ self_video: sourceOption === 'camera',
1288
+ });
1289
+
1290
+ const frameSize = Math.ceil((width * height * 3) / 2);
1291
+ const FRAME_INTERVAL_MS = Math.round(1000 / maxFps);
1292
+ let pacingTimeout: ReturnType<typeof setTimeout> | null = null;
1293
+ let ffmpegProc: ReturnType<typeof spawn> | null = null;
1294
+ let cleanupCalled = false;
1295
+
1296
+ const doCleanup = () => {
1297
+ if (cleanupCalled) return;
1298
+ cleanupCalled = true;
1299
+ this._videoCleanup = null;
1300
+ this._playingVideo = false;
1301
+ if (pacingTimeout !== null) {
1302
+ clearTimeout(pacingTimeout);
1303
+ pacingTimeout = null;
1304
+ }
1305
+ if (ffmpegProc && !ffmpegProc.killed) {
1306
+ ffmpegProc.kill('SIGKILL');
1307
+ ffmpegProc = null;
1308
+ }
1309
+ this.emit('requestVoiceStateSync', { self_stream: false, self_video: false });
1310
+ this.currentVideoStream = null;
1311
+ if (this.audioTrack) {
1312
+ this.audioTrack.close().catch(() => {});
1313
+ this.audioTrack = null;
1314
+ }
1315
+ if (this.audioSource) {
1316
+ this.audioSource.close().catch(() => {});
1317
+ this.audioSource = null;
1318
+ }
1319
+ if (this.videoTrack) {
1320
+ this.videoTrack.close().catch(() => {});
1321
+ this.videoTrack = null;
1322
+ }
1323
+ if (this.videoSource) {
1324
+ this.videoSource.close().catch(() => {});
1325
+ this.videoSource = null;
1326
+ }
1327
+ };
1328
+
1329
+ this._videoCleanup = () => doCleanup();
1330
+
1331
+ const frameBuffer: Buffer[] = [];
1332
+ let frameBufferBytes = 0;
1333
+ const MAX_QUEUED_FRAMES = 60; // ~1 second at 60fps
1334
+ const FRAME_DURATION_US = BigInt(Math.round(1_000_000 / maxFps)); // per-frame duration in microseconds
1335
+ let frameIndex = 0n;
1336
+
1337
+ const pushFramesFromBuffer = () => {
1338
+ if (!this._playingVideo || !source || cleanupCalled) return;
1339
+ // Send exactly ONE frame per tick for smooth 30fps pacing
1340
+ if (frameBufferBytes < frameSize) return;
1341
+ if (frameBufferBytes > frameSize * MAX_QUEUED_FRAMES) {
1342
+ // Too far ahead - drop whole frames from the front to resync
1343
+ const framesToDrop = Math.floor((frameBufferBytes - frameSize * 2) / frameSize);
1344
+ let toDropBytes = framesToDrop * frameSize;
1345
+ while (toDropBytes > 0 && frameBuffer.length > 0) {
1346
+ const c = frameBuffer[0]!;
1347
+ if (c.length <= toDropBytes) {
1348
+ toDropBytes -= c.length;
1349
+ frameBufferBytes -= c.length;
1350
+ frameBuffer.shift();
1351
+ } else {
1352
+ frameBuffer[0] = c.subarray(toDropBytes);
1353
+ frameBufferBytes -= toDropBytes;
1354
+ toDropBytes = 0;
1355
+ }
1356
+ }
1357
+ }
1358
+
1359
+ let remaining = frameSize;
1360
+ const parts: Buffer[] = [];
1361
+ while (remaining > 0 && frameBuffer.length > 0) {
1362
+ const c = frameBuffer[0]!;
1363
+ const take = Math.min(remaining, c.length);
1364
+ parts.push(c.subarray(0, take));
1365
+ remaining -= take;
1366
+ if (take >= c.length) {
1367
+ frameBuffer.shift();
1368
+ } else {
1369
+ frameBuffer[0] = c.subarray(take);
1370
+ }
1371
+ }
1372
+ frameBufferBytes -= frameSize;
1373
+ const frameData = Buffer.concat(parts, frameSize);
1374
+ if (frameData.length !== frameSize) return;
1375
+ try {
1376
+ const frame = new VideoFrame(
1377
+ new Uint8Array(frameData.buffer, frameData.byteOffset, frameSize),
1378
+ width,
1379
+ height,
1380
+ VideoBufferType.I420,
1381
+ );
1382
+ const timestampUs = frameIndex * FRAME_DURATION_US;
1383
+ frameIndex += 1n;
1384
+ source.captureFrame(frame, timestampUs);
1385
+ } catch (e) {
1386
+ if (VOICE_DEBUG) this.audioDebug('captureFrame error', { error: String(e) });
1387
+ }
1388
+ };
1389
+
1390
+ const scheduleNextPacing = () => {
1391
+ if (!this._playingVideo || cleanupCalled) return;
1392
+ pushFramesFromBuffer();
1393
+ pacingTimeout = setTimeout(scheduleNextPacing, FRAME_INTERVAL_MS);
1394
+ };
1395
+ scheduleNextPacing();
1396
+
1397
+ const runFFmpeg = async () => {
1398
+ const ffmpegArgs = [
1399
+ '-loglevel',
1400
+ 'warning',
1401
+ '-re',
1402
+ ...(loop ? ['-stream_loop', '-1'] : []),
1403
+ '-i',
1404
+ url,
1405
+ '-map',
1406
+ '0:v',
1407
+ '-vf',
1408
+ `scale=${width}:${height}`,
1409
+ '-r',
1410
+ String(maxFps),
1411
+ '-f',
1412
+ 'rawvideo',
1413
+ '-pix_fmt',
1414
+ 'yuv420p',
1415
+ '-an',
1416
+ 'pipe:1',
1417
+ ...(hasAudio ? ['-map', '0:a', '-c:a', 'libopus', '-f', 'webm', '-vn', 'pipe:3'] : []),
1418
+ ];
1419
+ const stdioOpts: Array<'ignore' | 'pipe'> = hasAudio
1420
+ ? ['ignore', 'pipe', 'pipe', 'pipe']
1421
+ : ['ignore', 'pipe', 'pipe'];
1422
+ const proc = spawn('ffmpeg', ffmpegArgs, { stdio: stdioOpts });
1423
+ ffmpegProc = proc;
1424
+
1425
+ this.currentVideoStream = {
1426
+ destroy: () => {
1427
+ if (proc && !proc.killed) proc.kill('SIGKILL');
1428
+ },
1429
+ };
1430
+
1431
+ const stdout = proc.stdout;
1432
+ const stderr = proc.stderr;
1433
+ if (stdout) {
1434
+ stdout.on('data', (chunk: Buffer) => {
1435
+ if (!this._playingVideo || cleanupCalled) return;
1436
+ frameBuffer.push(chunk);
1437
+ frameBufferBytes += chunk.length;
1438
+ });
1439
+ }
1440
+ if (stderr) {
1441
+ stderr.on('data', (data: Buffer) => {
1442
+ const line = data.toString().trim();
1443
+ if (line && VOICE_DEBUG) this.audioDebug('ffmpeg stderr', { line: line.slice(0, 200) });
1444
+ });
1445
+ }
1446
+
1447
+ if (hasAudio && audioReady && audioSource && proc.stdio[3]) {
1448
+ const audioPipe = proc.stdio[3] as NodeJS.ReadableStream;
1449
+ const demuxer = new opus.WebmDemuxer();
1450
+ audioPipe.pipe(demuxer);
1451
+ const decoder = new OpusDecoder({ sampleRate: SAMPLE_RATE, channels: CHANNELS });
1452
+ await decoder.ready;
1453
+ let sampleBuffer = new Int16Array(0);
1454
+ let opusBuffer = new Uint8Array(0);
1455
+ let processing = false;
1456
+ const opusFrameQueue: Uint8Array[] = [];
1457
+ const processOneOpusFrame = async (frame: Uint8Array) => {
1458
+ if (frame.length < 2 || !audioSource || !this._playingVideo) return;
1459
+ try {
1460
+ const result = decoder.decodeFrame(frame);
1461
+ if (!result?.channelData?.[0]?.length) return;
1462
+ const int16 = floatToInt16(result.channelData[0]);
1463
+ const newBuffer = new Int16Array(sampleBuffer.length + int16.length);
1464
+ newBuffer.set(sampleBuffer);
1465
+ newBuffer.set(int16, sampleBuffer.length);
1466
+ sampleBuffer = newBuffer;
1467
+ while (sampleBuffer.length >= FRAME_SAMPLES && this._playingVideo && audioSource) {
1468
+ const rawSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1469
+ sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
1470
+ const outSamples = applyVolumeToInt16(rawSamples, this._volume);
1471
+ const audioFrame = new AudioFrame(outSamples, SAMPLE_RATE, CHANNELS, FRAME_SAMPLES);
1472
+ if (audioSource.queuedDuration > 500) await audioSource.waitForPlayout();
1473
+ await audioSource.captureFrame(audioFrame);
1474
+ }
1475
+ } catch {
1476
+ /* decoder.close() may throw */
1477
+ }
1478
+ };
1479
+ const drainQueue = async () => {
1480
+ if (processing || opusFrameQueue.length === 0) return;
1481
+ processing = true;
1482
+ while (opusFrameQueue.length > 0 && this._playingVideo && audioSource) {
1483
+ const f = opusFrameQueue.shift()!;
1484
+ await processOneOpusFrame(f);
1485
+ }
1486
+ processing = false;
1487
+ };
1488
+ demuxer.on('data', (chunk: Buffer) => {
1489
+ if (!this._playingVideo) return;
1490
+ opusBuffer = new Uint8Array(concatUint8Arrays(opusBuffer, new Uint8Array(chunk)));
1491
+ while (opusBuffer.length > 0) {
1492
+ const parsed = parseOpusPacketBoundaries(opusBuffer);
1493
+ if (!parsed) break;
1494
+ opusBuffer = new Uint8Array(opusBuffer.subarray(parsed.consumed));
1495
+ for (const frame of parsed.frames) opusFrameQueue.push(frame);
1496
+ }
1497
+ drainQueue().catch(() => {});
1498
+ });
1499
+ }
1500
+
1501
+ proc.on('error', (err) => {
1502
+ this.emit('error', err);
1503
+ doCleanup();
1504
+ });
1505
+
1506
+ proc.on('exit', (code) => {
1507
+ ffmpegProc = null;
1508
+ if (cleanupCalled || !this._playingVideo) return;
1509
+ if (loop && (code === 0 || code === null)) {
1510
+ frameBuffer.length = 0;
1511
+ frameBufferBytes = 0;
1512
+ frameIndex = 0n;
1513
+ setImmediate(() => runFFmpeg());
1514
+ } else {
1515
+ doCleanup();
1516
+ }
1517
+ });
1518
+ };
1519
+
1520
+ runFFmpeg().catch((e) => this.audioDebug('ffmpeg error', { error: String(e) }));
1521
+ }
1522
+
1523
+ /**
1524
+ * Play audio from a WebM/Opus URL or readable stream. Publishes to the LiveKit room as an audio track.
1525
+ *
1526
+ * @param urlOrStream - Audio source: HTTP(S) URL to a WebM/Opus file, or a Node.js ReadableStream
1527
+ * @emits error - On fetch failure or decode errors
1528
+ */
1529
+ async play(urlOrStream: string | NodeJS.ReadableStream): Promise<void> {
1530
+ this.stop();
1531
+ if (!this.room || !this.room.isConnected) {
1532
+ this.emit('error', new Error('LiveKit: not connected'));
1533
+ return;
1534
+ }
1535
+
1536
+ let inputStream: NodeJS.ReadableStream;
1537
+ if (typeof urlOrStream === 'string') {
1538
+ try {
1539
+ const response = await fetch(urlOrStream);
1540
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1541
+ if (!response.body) throw new Error('No response body');
1542
+ inputStream = Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]);
1543
+ } catch (e) {
1544
+ this.emit('error', e instanceof Error ? e : new Error(String(e)));
1545
+ return;
1546
+ }
1547
+ } else {
1548
+ inputStream = urlOrStream;
1549
+ }
1550
+
1551
+ const source = new AudioSource(SAMPLE_RATE, CHANNELS);
1552
+ this.audioSource = source;
1553
+ const track = LocalAudioTrack.createAudioTrack('audio', source);
1554
+ this.audioTrack = track;
1555
+
1556
+ const options = new TrackPublishOptions();
1557
+ options.source = TrackSource.SOURCE_MICROPHONE;
1558
+
1559
+ await this.room.localParticipant!.publishTrack(track, options);
1560
+
1561
+ const demuxer = new opus.WebmDemuxer();
1562
+ (inputStream as NodeJS.ReadableStream).pipe(demuxer);
1563
+ this.currentStream = demuxer;
1564
+
1565
+ const decoder = new OpusDecoder({ sampleRate: SAMPLE_RATE, channels: CHANNELS });
1566
+ await decoder.ready;
1567
+
1568
+ this._playing = true;
1569
+
1570
+ let sampleBuffer = new Int16Array(0);
1571
+ let opusBuffer: Uint8Array = new Uint8Array(0);
1572
+ let _streamEnded = false;
1573
+ let framesCaptured = 0;
1574
+
1575
+ const processOneOpusFrame = async (frame: Uint8Array): Promise<void> => {
1576
+ if (frame.length < 2) return;
1577
+ try {
1578
+ const result = decoder.decodeFrame(frame);
1579
+ if (!result?.channelData?.[0]?.length) return;
1580
+
1581
+ const int16 = floatToInt16(result.channelData[0]);
1582
+
1583
+ const newBuffer = new Int16Array(sampleBuffer.length + int16.length);
1584
+ newBuffer.set(sampleBuffer);
1585
+ newBuffer.set(int16, sampleBuffer.length);
1586
+ sampleBuffer = newBuffer;
1587
+
1588
+ while (sampleBuffer.length >= FRAME_SAMPLES && this._playing && source) {
1589
+ const rawSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1590
+ sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice(); // copy remainder
1591
+ const outSamples = applyVolumeToInt16(rawSamples, this._volume);
1592
+
1593
+ const audioFrame = new AudioFrame(outSamples, SAMPLE_RATE, CHANNELS, FRAME_SAMPLES);
1594
+
1595
+ if (source.queuedDuration > 500) {
1596
+ await source.waitForPlayout();
1597
+ }
1598
+ await source.captureFrame(audioFrame);
1599
+ framesCaptured++;
1600
+ }
1601
+ } catch (err) {
1602
+ if (VOICE_DEBUG) this.audioDebug('decode error', { error: String(err) });
1603
+ }
1604
+ };
1605
+
1606
+ let firstChunk = true;
1607
+ let processing = false;
1608
+ const opusFrameQueue: Uint8Array[] = [];
1609
+
1610
+ const drainOpusQueue = async () => {
1611
+ if (processing || opusFrameQueue.length === 0) return;
1612
+ processing = true;
1613
+ while (opusFrameQueue.length > 0 && this._playing && source) {
1614
+ const frame = opusFrameQueue.shift()!;
1615
+ await processOneOpusFrame(frame);
1616
+ }
1617
+ processing = false;
1618
+ };
1619
+
1620
+ demuxer.on('data', (chunk: Buffer) => {
1621
+ if (!this._playing) return;
1622
+ if (firstChunk) {
1623
+ this.audioDebug('first audio chunk received', { size: chunk.length });
1624
+ firstChunk = false;
1625
+ }
1626
+ opusBuffer = concatUint8Arrays(opusBuffer, new Uint8Array(chunk));
1627
+
1628
+ while (opusBuffer.length > 0) {
1629
+ const parsed = parseOpusPacketBoundaries(opusBuffer);
1630
+ if (!parsed) break;
1631
+ opusBuffer = opusBuffer.slice(parsed.consumed);
1632
+ for (const frame of parsed.frames) {
1633
+ opusFrameQueue.push(frame);
1634
+ }
1635
+ }
1636
+ drainOpusQueue().catch((e) => this.audioDebug('drainOpusQueue error', { error: String(e) }));
1637
+ });
1638
+
1639
+ demuxer.on('error', (err: Error) => {
1640
+ this.audioDebug('demuxer error', { error: err.message });
1641
+ this._playing = false;
1642
+ this.currentStream = null;
1643
+ this.emit('error', err);
1644
+ });
1645
+
1646
+ demuxer.on('end', async () => {
1647
+ _streamEnded = true;
1648
+ this.audioDebug('stream ended', { framesCaptured });
1649
+
1650
+ while (processing || opusFrameQueue.length > 0) {
1651
+ await drainOpusQueue();
1652
+ await new Promise((r) => setImmediate(r));
1653
+ }
1654
+
1655
+ while (sampleBuffer.length >= FRAME_SAMPLES && this._playing && source) {
1656
+ const rawSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1657
+ sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
1658
+ const outSamples = applyVolumeToInt16(rawSamples, this._volume);
1659
+ const audioFrame = new AudioFrame(outSamples, SAMPLE_RATE, CHANNELS, FRAME_SAMPLES);
1660
+ await source.captureFrame(audioFrame);
1661
+ framesCaptured++;
1662
+ }
1663
+
1664
+ if (sampleBuffer.length > 0 && this._playing && source) {
1665
+ const padded = new Int16Array(FRAME_SAMPLES);
1666
+ padded.set(sampleBuffer);
1667
+ const outSamples = applyVolumeToInt16(padded, this._volume);
1668
+ const audioFrame = new AudioFrame(outSamples, SAMPLE_RATE, CHANNELS, FRAME_SAMPLES);
1669
+ await source.captureFrame(audioFrame);
1670
+ framesCaptured++;
1671
+ }
1672
+
1673
+ this.audioDebug('playback complete', { framesCaptured });
1674
+ this._playing = false;
1675
+ this.currentStream = null;
1676
+ if (this.audioTrack) {
1677
+ await this.audioTrack.close();
1678
+ this.audioTrack = null;
1679
+ }
1680
+ if (this.audioSource) {
1681
+ await this.audioSource.close();
1682
+ this.audioSource = null;
1683
+ }
1684
+ });
1685
+ }
1686
+
1687
+ /**
1688
+ * Stop video playback and unpublish the video track from the LiveKit room.
1689
+ * Safe to call even when no video is playing.
1690
+ */
1691
+ private _videoCleaning = false;
1692
+
1693
+ stopVideo(): void {
1694
+ if (this._videoCleaning) return;
1695
+ if (this._videoCleanup) {
1696
+ this._videoCleaning = true;
1697
+ try {
1698
+ this._videoCleanup();
1699
+ } finally {
1700
+ this._videoCleaning = false;
1701
+ }
1702
+ this._videoCleanup = null;
1703
+ return;
1704
+ }
1705
+ this._playingVideo = false;
1706
+ this.emit('requestVoiceStateSync', { self_stream: false, self_video: false });
1707
+ if (this.currentVideoStream?.destroy) this.currentVideoStream.destroy();
1708
+ this.currentVideoStream = null;
1709
+ if (this.videoTrack) {
1710
+ this.videoTrack.close().catch(() => {});
1711
+ this.videoTrack = null;
1712
+ }
1713
+ if (this.videoSource) {
1714
+ this.videoSource.close().catch(() => {});
1715
+ this.videoSource = null;
1716
+ }
1717
+ }
1718
+
1719
+ /** Stop playback and clear both audio and video tracks. */
1720
+ stop(): void {
1721
+ this._playing = false;
1722
+ this.stopVideo();
1723
+ this.clearReceiveSubscriptions();
1724
+ if (this.currentStream?.destroy) this.currentStream.destroy();
1725
+ this.currentStream = null;
1726
+ if (this.audioTrack) {
1727
+ this.audioTrack.close().catch(() => {});
1728
+ this.audioTrack = null;
1729
+ }
1730
+ if (this.audioSource) {
1731
+ this.audioSource.close().catch(() => {});
1732
+ this.audioSource = null;
1733
+ }
1734
+ }
1735
+
1736
+ /** Disconnect from the LiveKit room and stop all playback. */
1737
+ disconnect(): void {
1738
+ this._destroyed = true;
1739
+ this.stop();
1740
+ if (this.room) {
1741
+ this.room.disconnect().catch(() => {});
1742
+ this.room = null;
1743
+ }
1744
+ this.lastServerEndpoint = null;
1745
+ this.lastServerToken = null;
1746
+ this.emit('disconnect');
1747
+ }
1748
+
1749
+ /** Disconnect from the room and remove all event listeners. */
1750
+ destroy(): void {
1751
+ this.disconnect();
1752
+ this.removeAllListeners();
1753
+ }
1754
+ }
1755
+
1756
+ declare module 'events' {
1757
+ interface LiveKitRtcConnection {
1758
+ on<E extends keyof LiveKitRtcConnectionEvents>(
1759
+ event: E,
1760
+ listener: (...args: LiveKitRtcConnectionEvents[E]) => void,
1761
+ ): this;
1762
+ emit<E extends keyof LiveKitRtcConnectionEvents>(
1763
+ event: E,
1764
+ ...args: LiveKitRtcConnectionEvents[E]
1765
+ ): boolean;
1766
+ }
1767
+ }