loukai-app 0.3.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 (290) hide show
  1. package/README.md +558 -0
  2. package/bin/loukai.js +32 -0
  3. package/package.json +243 -0
  4. package/src/main/appState.js +250 -0
  5. package/src/main/audioEngine.js +478 -0
  6. package/src/main/creator/conversionService.js +503 -0
  7. package/src/main/creator/downloadManager.js +1128 -0
  8. package/src/main/creator/ffmpegService.js +487 -0
  9. package/src/main/creator/installLogger.js +51 -0
  10. package/src/main/creator/keyDetection.js +212 -0
  11. package/src/main/creator/llmService.js +370 -0
  12. package/src/main/creator/lrclibService.js +340 -0
  13. package/src/main/creator/python/crepe_runner.py +189 -0
  14. package/src/main/creator/python/demucs_runner.py +158 -0
  15. package/src/main/creator/python/whisper_runner.py +172 -0
  16. package/src/main/creator/pythonRunner.js +268 -0
  17. package/src/main/creator/stemBuilder.js +491 -0
  18. package/src/main/creator/systemChecker.js +474 -0
  19. package/src/main/handlers/appHandlers.js +45 -0
  20. package/src/main/handlers/audioHandlers.js +33 -0
  21. package/src/main/handlers/autotuneHandlers.js +28 -0
  22. package/src/main/handlers/canvasHandlers.js +84 -0
  23. package/src/main/handlers/creatorHandlers.js +159 -0
  24. package/src/main/handlers/editorHandlers.js +98 -0
  25. package/src/main/handlers/effectsHandlers.js +100 -0
  26. package/src/main/handlers/fileHandlers.js +45 -0
  27. package/src/main/handlers/index.js +78 -0
  28. package/src/main/handlers/libraryHandlers.js +96 -0
  29. package/src/main/handlers/mixerHandlers.js +64 -0
  30. package/src/main/handlers/playerHandlers.js +39 -0
  31. package/src/main/handlers/preferencesHandlers.js +46 -0
  32. package/src/main/handlers/queueHandlers.js +81 -0
  33. package/src/main/handlers/rendererHandlers.js +63 -0
  34. package/src/main/handlers/settingsHandlers.js +42 -0
  35. package/src/main/handlers/webServerHandlers.js +105 -0
  36. package/src/main/main.js +2351 -0
  37. package/src/main/preload.js +252 -0
  38. package/src/main/settingsManager.js +139 -0
  39. package/src/main/statePersistence.js +193 -0
  40. package/src/main/utils/pathValidator.js +112 -0
  41. package/src/main/webServer.js +2535 -0
  42. package/src/native/autotune.js +417 -0
  43. package/src/renderer/adapters/ElectronBridge.js +677 -0
  44. package/src/renderer/canvas.html +80 -0
  45. package/src/renderer/components/App.jsx +303 -0
  46. package/src/renderer/components/AppRoot.jsx +37 -0
  47. package/src/renderer/components/AudioDeviceSettings.jsx +145 -0
  48. package/src/renderer/components/EffectsPanelWrapper.jsx +267 -0
  49. package/src/renderer/components/MixerTab.jsx +233 -0
  50. package/src/renderer/components/MixerTabWrapper.jsx +31 -0
  51. package/src/renderer/components/PortalSelect.jsx +239 -0
  52. package/src/renderer/components/QueueTab.jsx +116 -0
  53. package/src/renderer/components/RequestsListWrapper.jsx +78 -0
  54. package/src/renderer/components/ServerTab.jsx +472 -0
  55. package/src/renderer/components/SongInfoBarWrapper.jsx +77 -0
  56. package/src/renderer/components/StatusBar.jsx +92 -0
  57. package/src/renderer/components/TabNavigation.jsx +77 -0
  58. package/src/renderer/components/TransportControlsWrapper.jsx +69 -0
  59. package/src/renderer/components/creator/CreateTab.jsx +1236 -0
  60. package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js +2 -0
  61. package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js.map +1 -0
  62. package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js +2 -0
  63. package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js.map +1 -0
  64. package/src/renderer/dist/assets/player-DVrqp7N5.js +3 -0
  65. package/src/renderer/dist/assets/player-DVrqp7N5.js.map +1 -0
  66. package/src/renderer/dist/assets/songLoaders-BaTgGib4.js +2 -0
  67. package/src/renderer/dist/assets/songLoaders-BaTgGib4.js.map +1 -0
  68. package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js +2 -0
  69. package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js.map +1 -0
  70. package/src/renderer/dist/js/autoTuneWorklet.js +224 -0
  71. package/src/renderer/dist/js/micPitchDetectorWorklet.js +137 -0
  72. package/src/renderer/dist/js/musicAnalysisWorklet.js +216 -0
  73. package/src/renderer/dist/js/phaseVocoderWorklet.js +341 -0
  74. package/src/renderer/dist/js/soundtouch-worklet.js +1395 -0
  75. package/src/renderer/dist/renderer.css +1 -0
  76. package/src/renderer/dist/renderer.js +62 -0
  77. package/src/renderer/dist/renderer.js.map +1 -0
  78. package/src/renderer/dist/renderer.woff2 +0 -0
  79. package/src/renderer/hooks/useKeyboardShortcuts.js +154 -0
  80. package/src/renderer/index.html +24 -0
  81. package/src/renderer/index.html.backup +372 -0
  82. package/src/renderer/js/PlayerInterface.js +267 -0
  83. package/src/renderer/js/autoTuneWorklet.js +224 -0
  84. package/src/renderer/js/butterchurnVerify.js +46 -0
  85. package/src/renderer/js/canvas-app.js +114 -0
  86. package/src/renderer/js/cdgPlayer.js +685 -0
  87. package/src/renderer/js/kaiPlayer.js +1200 -0
  88. package/src/renderer/js/karaokeRenderer.js +3392 -0
  89. package/src/renderer/js/micPitchDetectorWorklet.js +137 -0
  90. package/src/renderer/js/microphoneEngine.js +656 -0
  91. package/src/renderer/js/musicAnalysisWorklet.js +216 -0
  92. package/src/renderer/js/phaseVocoderWorklet.js +341 -0
  93. package/src/renderer/js/player.js +232 -0
  94. package/src/renderer/js/referencePitchTracker.js +130 -0
  95. package/src/renderer/js/songLoaders.js +334 -0
  96. package/src/renderer/js/soundtouch-worklet.js +1395 -0
  97. package/src/renderer/js/webrtcManager.js +511 -0
  98. package/src/renderer/lib/butterchurn.min.js +6739 -0
  99. package/src/renderer/lib/butterchurnPresets.min.js +1 -0
  100. package/src/renderer/lib/cdgraphics-wrapper.js +16 -0
  101. package/src/renderer/lib/cdgraphics.js +299 -0
  102. package/src/renderer/public/js/autoTuneWorklet.js +224 -0
  103. package/src/renderer/public/js/micPitchDetectorWorklet.js +137 -0
  104. package/src/renderer/public/js/musicAnalysisWorklet.js +216 -0
  105. package/src/renderer/public/js/phaseVocoderWorklet.js +341 -0
  106. package/src/renderer/public/js/soundtouch-worklet.js +1395 -0
  107. package/src/renderer/react-entry.jsx +44 -0
  108. package/src/renderer/styles/tailwind.css +106 -0
  109. package/src/renderer/utils/qrCodeGenerator.js +98 -0
  110. package/src/renderer/vite.config.js +31 -0
  111. package/src/shared/adapters/BridgeInterface.js +195 -0
  112. package/src/shared/components/EffectsPanel.jsx +177 -0
  113. package/src/shared/components/LibraryPanel.jsx +701 -0
  114. package/src/shared/components/LineDetailCanvas.jsx +167 -0
  115. package/src/shared/components/LyricLine.jsx +505 -0
  116. package/src/shared/components/LyricRejection.jsx +84 -0
  117. package/src/shared/components/LyricSuggestion.jsx +80 -0
  118. package/src/shared/components/LyricsEditorCanvas.jsx +271 -0
  119. package/src/shared/components/MixerPanel.jsx +94 -0
  120. package/src/shared/components/PlayerControls.jsx +206 -0
  121. package/src/shared/components/PortalSelect.jsx +239 -0
  122. package/src/shared/components/QueueList.jsx +365 -0
  123. package/src/shared/components/QuickSearch.jsx +126 -0
  124. package/src/shared/components/RequestsList.jsx +121 -0
  125. package/src/shared/components/SongEditor.jsx +1362 -0
  126. package/src/shared/components/SongInfoBar.jsx +81 -0
  127. package/src/shared/components/ThemeToggle.jsx +106 -0
  128. package/src/shared/components/Toast.jsx +30 -0
  129. package/src/shared/components/VisualizationSettings.jsx +243 -0
  130. package/src/shared/constants.js +95 -0
  131. package/src/shared/context/BridgeContext.jsx +32 -0
  132. package/src/shared/contexts/AudioContext.jsx +37 -0
  133. package/src/shared/contexts/PlayerContext.jsx +66 -0
  134. package/src/shared/contexts/SettingsContext.jsx +50 -0
  135. package/src/shared/defaults.js +158 -0
  136. package/src/shared/formatUtils.js +59 -0
  137. package/src/shared/formatUtils.test.js +207 -0
  138. package/src/shared/hooks/useAppState.js +97 -0
  139. package/src/shared/hooks/useAudioEngine.js +264 -0
  140. package/src/shared/hooks/usePlayer.js +89 -0
  141. package/src/shared/hooks/useSettingsPersistence.js +74 -0
  142. package/src/shared/hooks/useWebRTC.js +118 -0
  143. package/src/shared/ipcContracts.js +299 -0
  144. package/src/shared/package.json +3 -0
  145. package/src/shared/services/creatorService.js +373 -0
  146. package/src/shared/services/creatorService.test.js +413 -0
  147. package/src/shared/services/editorService.js +213 -0
  148. package/src/shared/services/editorService.test.js +219 -0
  149. package/src/shared/services/effectsService.js +271 -0
  150. package/src/shared/services/effectsService.test.js +418 -0
  151. package/src/shared/services/libraryService.js +438 -0
  152. package/src/shared/services/libraryService.test.js +474 -0
  153. package/src/shared/services/mixerService.js +172 -0
  154. package/src/shared/services/mixerService.test.js +399 -0
  155. package/src/shared/services/playerService.js +221 -0
  156. package/src/shared/services/playerService.test.js +357 -0
  157. package/src/shared/services/preferencesService.js +219 -0
  158. package/src/shared/services/queueService.js +226 -0
  159. package/src/shared/services/queueService.test.js +430 -0
  160. package/src/shared/services/requestsService.js +155 -0
  161. package/src/shared/services/requestsService.test.js +362 -0
  162. package/src/shared/services/serverSettingsService.js +151 -0
  163. package/src/shared/services/settingsService.js +257 -0
  164. package/src/shared/services/settingsService.test.js +295 -0
  165. package/src/shared/state/StateManager.js +263 -0
  166. package/src/shared/utils/audio.js +42 -0
  167. package/src/shared/utils/format.js +32 -0
  168. package/src/shared/utils/lyricsUtils.js +162 -0
  169. package/src/test/setup.js +40 -0
  170. package/src/utils/cdgLoader.js +180 -0
  171. package/src/utils/m4aLoader.js +333 -0
  172. package/src/web/App.jsx +578 -0
  173. package/src/web/adapters/WebBridge.js +428 -0
  174. package/src/web/components/PlayerSettingsPanel.jsx +231 -0
  175. package/src/web/components/SongSearch.jsx +180 -0
  176. package/src/web/dist/assets/index-0H-RnRrV.js +51 -0
  177. package/src/web/dist/assets/index-0H-RnRrV.js.map +1 -0
  178. package/src/web/dist/assets/index-DYW2zB0u.css +1 -0
  179. package/src/web/dist/index.html +15 -0
  180. package/src/web/index.html +14 -0
  181. package/src/web/main.jsx +10 -0
  182. package/src/web/package-lock.json +1765 -0
  183. package/src/web/pages/SongRequestPage.jsx +619 -0
  184. package/src/web/styles/tailwind.css +68 -0
  185. package/src/web/vite.config.js +27 -0
  186. package/static/fonts/material-icons.woff2 +0 -0
  187. package/static/images/butterchurn-screenshots/Aderrasi - Potion of Spirits.png +0 -0
  188. package/static/images/butterchurn-screenshots/Aderrasi - Songflower _Moss Posy_.png +0 -0
  189. package/static/images/butterchurn-screenshots/Aderrasi - Storm of the Eye _Thunder_ - mash0000 - quasi pseudo meta concentrics.png +0 -0
  190. package/static/images/butterchurn-screenshots/Aderrasi _ Geiss - Airhandler _Kali Mix_ - Canvas Mix.png +0 -0
  191. package/static/images/butterchurn-screenshots/An AdamFX n Martin Infusion 2 flexi - Why The Sky Looks Diffrent Today - AdamFx n Martin Infusion - Tack Tile Disfunction B.png +0 -0
  192. package/static/images/butterchurn-screenshots/Cope - The Neverending Explosion of Red Liquid Fire.png +0 -0
  193. proton lights __Krash_s beat code_ _Phat_remix02b.png +0 -0
  194. package/static/images/butterchurn-screenshots/Eo_S_ _ Phat - cubetrace - v2.png +0 -0
  195. package/static/images/butterchurn-screenshots/Eo_S_ _ Zylot - skylight _Stained Glass Majesty mix_.png +0 -0
  196. package/static/images/butterchurn-screenshots/Flexi - alien fish pond.png +0 -0
  197. package/static/images/butterchurn-screenshots/Flexi - area 51.png +0 -0
  198. package/static/images/butterchurn-screenshots/Flexi - infused with the spiral.png +0 -0
  199. package/static/images/butterchurn-screenshots/Flexi - mindblob _shiny mix_.png +0 -0
  200. package/static/images/butterchurn-screenshots/Flexi - mindblob mix.png +0 -0
  201. package/static/images/butterchurn-screenshots/Flexi - predator-prey-spirals.png +0 -0
  202. package/static/images/butterchurn-screenshots/Flexi - smashing fractals _acid etching mix_.png +0 -0
  203. package/static/images/butterchurn-screenshots/Flexi - truly soft piece of software - this is generic texturing _Jelly_ .png +0 -0
  204. package/static/images/butterchurn-screenshots/Flexi _ Martin - astral projection.png +0 -0
  205. package/static/images/butterchurn-screenshots/Flexi _ Martin - cascading decay swing.png +0 -0
  206. package/static/images/butterchurn-screenshots/Flexi _ amandio c - piercing 05 - Kopie _2_ - Kopie.png +0 -0
  207. package/static/images/butterchurn-screenshots/Flexi _ stahlregen - jelly showoff parade.png +0 -0
  208. package/static/images/butterchurn-screenshots/Flexi_ fishbrain_ Geiss _ Martin - tokamak witchery.png +0 -0
  209. package/static/images/butterchurn-screenshots/Flexi_ martin _ geiss - dedicated to the sherwin maxawow.png +0 -0
  210. package/static/images/butterchurn-screenshots/Fumbling_Foo _ Flexi_ Martin_ Orb_ Unchained - Star Nova v7b.png +0 -0
  211. package/static/images/butterchurn-screenshots/Geiss - Cauldron - painterly 2 _saturation remix_.png +0 -0
  212. package/static/images/butterchurn-screenshots/Geiss - Reaction Diffusion 2.png +0 -0
  213. package/static/images/butterchurn-screenshots/Geiss - Spiral Artifact.png +0 -0
  214. package/static/images/butterchurn-screenshots/Geiss - Thumb Drum.png +0 -0
  215. package/static/images/butterchurn-screenshots/Geiss _ Flexi _ Martin - disconnected.png +0 -0
  216. package/static/images/butterchurn-screenshots/Geiss_ Flexi _ Stahlregen - Thumbdrum Tokamak _crossfiring aftermath jelly mashup_.png +0 -0
  217. package/static/images/butterchurn-screenshots/Goody - The Wild Vort.png +0 -0
  218. package/static/images/butterchurn-screenshots/Idiot - Star Of Annon.png +0 -0
  219. package/static/images/butterchurn-screenshots/Krash _ Illusion - Spiral Movement.png +0 -0
  220. package/static/images/butterchurn-screenshots/Martin - QBikal - Surface Turbulence IIb.png +0 -0
  221. package/static/images/butterchurn-screenshots/Martin - acid wiring.png +0 -0
  222. package/static/images/butterchurn-screenshots/Martin - charisma.png +0 -0
  223. package/static/images/butterchurn-screenshots/Martin - liquid arrows.png +0 -0
  224. package/static/images/butterchurn-screenshots/Milk Artist At our Best - FED - SlowFast Ft AdamFX n Martin - HD CosmoFX.png +0 -0
  225. package/static/images/butterchurn-screenshots/ORB - Waaa.png +0 -0
  226. package/static/images/butterchurn-screenshots/Phat_fiShbRaiN_Eo_S_Mandala_Chasers_remix.png +0 -0
  227. package/static/images/butterchurn-screenshots/Rovastar - Oozing Resistance.png +0 -0
  228. package/static/images/butterchurn-screenshots/Rovastar _ Loadus _ Geiss - FractalDrop _Triple Mix_.png +0 -0
  229. package/static/images/butterchurn-screenshots/TonyMilkdrop - Leonardo Da Vinci_s Balloon _Flexi - merry-go-round _ techstyle_.png +0 -0
  230. package/static/images/butterchurn-screenshots/TonyMilkdrop - Magellan_s Nebula _Flexi - you enter first _ multiverse_.png +0 -0
  231. package/static/images/butterchurn-screenshots/Unchained - Rewop.png +0 -0
  232. package/static/images/butterchurn-screenshots/Unchained - Unified Drag 2.png +0 -0
  233. package/static/images/butterchurn-screenshots/Unchained _ Rovastar - Wormhole Pillars _Hall of Shadows mix_.png +0 -0
  234. package/static/images/butterchurn-screenshots/Zylot - Paint Spill _Music Reactive Paint Mix_.png +0 -0
  235. package/static/images/butterchurn-screenshots/Zylot - Star Ornament.png +0 -0
  236. package/static/images/butterchurn-screenshots/Zylot - True Visionary _Final Mix_.png +0 -0
  237. package/static/images/butterchurn-screenshots/_Aderrasi - Wanderer in Curved Space - mash0000 - faclempt kibitzing meshuggana schmaltz _Geiss color mix_.png +0 -0
  238. package/static/images/butterchurn-screenshots/_Geiss - Artifact 01.png +0 -0
  239. package/static/images/butterchurn-screenshots/_Geiss - Desert Rose 2.png +0 -0
  240. package/static/images/butterchurn-screenshots/_Geiss - untitled.png +0 -0
  241. package/static/images/butterchurn-screenshots/_Mig_049.png +0 -0
  242. package/static/images/butterchurn-screenshots/_Mig_085.png +0 -0
  243. package/static/images/butterchurn-screenshots/_Rovastar _ Geiss - Hurricane Nightmare _Posterize Mix_.png +0 -0
  244. package/static/images/butterchurn-screenshots/___ Royal - Mashup _197_.png +0 -0
  245. package/static/images/butterchurn-screenshots/___ Royal - Mashup _220_.png +0 -0
  246. package/static/images/butterchurn-screenshots/___ Royal - Mashup _431_.png +0 -0
  247. package/static/images/butterchurn-screenshots/cope _ martin - mother-of-pearl.png +0 -0
  248. package/static/images/butterchurn-screenshots/fiShbRaiN _ Flexi - witchcraft 2_0.png +0 -0
  249. package/static/images/butterchurn-screenshots/flexi - bouncing balls _double mindblob neon mix_.png +0 -0
  250. package/static/images/butterchurn-screenshots/flexi - mom_ why the sky looks different today.png +0 -0
  251. package/static/images/butterchurn-screenshots/flexi - patternton_ district of media_ capitol of the united abstractions of fractopia.png +0 -0
  252. package/static/images/butterchurn-screenshots/flexi - swing out on the spiral.png +0 -0
  253. package/static/images/butterchurn-screenshots/flexi - what is the matrix.png +0 -0
  254. package/static/images/butterchurn-screenshots/flexi _ amandio c - organic _random mashup_.png +0 -0
  255. package/static/images/butterchurn-screenshots/flexi _ amandio c - organic12-3d-2_milk.png +0 -0
  256. package/static/images/butterchurn-screenshots/flexi _ fishbrain - neon mindblob grafitti.png +0 -0
  257. package/static/images/butterchurn-screenshots/flexi _ geiss - pogo cubes vs_ tokamak vs_ game of life _stahls jelly 4_5 finish_.png +0 -0
  258. package/static/images/butterchurn-screenshots/high-altitude basket unraveling - singh grooves nitrogen argon nz_.png +0 -0
  259. package/static/images/butterchurn-screenshots/martin - The Bridge of Khazad-Dum.png +0 -0
  260. package/static/images/butterchurn-screenshots/martin - angel flight.png +0 -0
  261. package/static/images/butterchurn-screenshots/martin - another kind of groove.png +0 -0
  262. package/static/images/butterchurn-screenshots/martin - bombyx mori.png +0 -0
  263. package/static/images/butterchurn-screenshots/martin - castle in the air.png +0 -0
  264. package/static/images/butterchurn-screenshots/martin - chain breaker.png +0 -0
  265. package/static/images/butterchurn-screenshots/martin - disco mix 4.png +0 -0
  266. package/static/images/butterchurn-screenshots/martin - extreme heat.png +0 -0
  267. package/static/images/butterchurn-screenshots/martin - frosty caves 2.png +0 -0
  268. package/static/images/butterchurn-screenshots/martin - fruit machine.png +0 -0
  269. package/static/images/butterchurn-screenshots/martin - ghost city.png +0 -0
  270. package/static/images/butterchurn-screenshots/martin - glass corridor.png +0 -0
  271. package/static/images/butterchurn-screenshots/martin - infinity _2010 update_.png +0 -0
  272. package/static/images/butterchurn-screenshots/martin - mandelbox explorer - high speed demo version.png +0 -0
  273. package/static/images/butterchurn-screenshots/martin - mucus cervix.png +0 -0
  274. package/static/images/butterchurn-screenshots/martin - reflections on black tiles.png +0 -0
  275. package/static/images/butterchurn-screenshots/martin - stormy sea _2010 update_.png +0 -0
  276. package/static/images/butterchurn-screenshots/martin - witchcraft reloaded.png +0 -0
  277. package/static/images/butterchurn-screenshots/martin _ flexi - diamond cutter _prismaticvortex_com_ - camille - i wish i wish i wish i was constrained.png +0 -0
  278. package/static/images/butterchurn-screenshots/martin _shadow harlequins shape code_ - fata morgana.png +0 -0
  279. package/static/images/butterchurn-screenshots/martin_ flexi_ fishbrain _ sto - enterstate _random mashup_.png +0 -0
  280. package/static/images/butterchurn-screenshots/sawtooth grin roam.png +0 -0
  281. package/static/images/butterchurn-screenshots/shifter - dark tides bdrv mix 2.png +0 -0
  282. package/static/images/butterchurn-screenshots/suksma - Rovastar - Sunflower Passion _Enlightment Mix__Phat_edit _ flexi und martin shaders - circumflex in character classes in regular expression.png +0 -0
  283. package/static/images/butterchurn-screenshots/suksma - heretical crosscut playpen.png +0 -0
  284. package/static/images/butterchurn-screenshots/suksma - uninitialized variabowl _hydroponic chronic_.png +0 -0
  285. package/static/images/butterchurn-screenshots/suksma - vector exp 1 - couldn_t not.png +0 -0
  286. package/static/images/butterchurn-screenshots/yin - 191 - Temporal singularities.png +0 -0
  287. package/static/images/logo-512.png +0 -0
  288. package/static/images/logo.png +0 -0
  289. package/static/loukai-logo.png +0 -0
  290. package/static/screenshot-generator.html +610 -0
@@ -0,0 +1,1128 @@
1
+ /**
2
+ * Download Manager - Handles downloading and installing AI components for Creator
3
+ *
4
+ * Components:
5
+ * - Python (standalone build from python-build-standalone)
6
+ * - PyTorch (with MPS/CUDA/CPU support)
7
+ * - Demucs (stem separation)
8
+ * - Whisper (transcription)
9
+ * - torchcrepe (pitch detection)
10
+ * - FFmpeg (audio processing)
11
+ * - Models (Whisper large-v3-turbo, Demucs htdemucs_ft)
12
+ */
13
+
14
+ import https from 'https';
15
+ import http from 'http';
16
+ import { createWriteStream, existsSync, mkdirSync, rmSync, chmodSync } from 'fs';
17
+ import { join, dirname } from 'path';
18
+ import { execSync, spawn } from 'child_process';
19
+ import { getCacheDir, getPythonPath, getPythonEnv } from './systemChecker.js';
20
+ import yauzl from 'yauzl';
21
+
22
+ /**
23
+ * Extract a zip file using yauzl (cross-platform)
24
+ */
25
+ function extractZip(zipPath, destDir) {
26
+ return new Promise((resolve, reject) => {
27
+ yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
28
+ if (err) {
29
+ reject(err);
30
+ return;
31
+ }
32
+
33
+ zipfile.readEntry();
34
+
35
+ zipfile.on('entry', (entry) => {
36
+ const fullPath = join(destDir, entry.fileName);
37
+
38
+ if (/\/$/.test(entry.fileName)) {
39
+ // Directory entry
40
+ mkdirSync(fullPath, { recursive: true });
41
+ zipfile.readEntry();
42
+ } else {
43
+ // File entry
44
+ mkdirSync(dirname(fullPath), { recursive: true });
45
+ zipfile.openReadStream(entry, (err, readStream) => {
46
+ if (err) {
47
+ reject(err);
48
+ return;
49
+ }
50
+ const writeStream = createWriteStream(fullPath);
51
+ readStream.pipe(writeStream);
52
+ writeStream.on('close', () => {
53
+ zipfile.readEntry();
54
+ });
55
+ writeStream.on('error', reject);
56
+ });
57
+ }
58
+ });
59
+
60
+ zipfile.on('end', resolve);
61
+ zipfile.on('error', reject);
62
+ });
63
+ });
64
+ }
65
+
66
+ // Python standalone builds from indygreg/python-build-standalone
67
+ const PYTHON_BUILDS = {
68
+ darwin: {
69
+ x64: 'https://github.com/indygreg/python-build-standalone/releases/download/20241016/cpython-3.12.7+20241016-x86_64-apple-darwin-install_only.tar.gz',
70
+ arm64:
71
+ 'https://github.com/indygreg/python-build-standalone/releases/download/20241016/cpython-3.12.7+20241016-aarch64-apple-darwin-install_only.tar.gz',
72
+ },
73
+ win32: {
74
+ x64: 'https://github.com/indygreg/python-build-standalone/releases/download/20241016/cpython-3.12.7+20241016-x86_64-pc-windows-msvc-shared-install_only.tar.gz',
75
+ },
76
+ linux: {
77
+ x64: 'https://github.com/indygreg/python-build-standalone/releases/download/20241016/cpython-3.12.7+20241016-x86_64-unknown-linux-gnu-install_only.tar.gz',
78
+ arm64:
79
+ 'https://github.com/indygreg/python-build-standalone/releases/download/20241016/cpython-3.12.7+20241016-aarch64-unknown-linux-gnu-install_only.tar.gz',
80
+ },
81
+ };
82
+
83
+ /**
84
+ * Get Python build URL for current platform
85
+ */
86
+ function getPythonBuildUrl() {
87
+ const platform = process.platform;
88
+ const arch = process.arch;
89
+
90
+ const builds = PYTHON_BUILDS[platform];
91
+ if (!builds) {
92
+ throw new Error(`Unsupported platform: ${platform}`);
93
+ }
94
+
95
+ const url = builds[arch] || builds.x64;
96
+ if (!url) {
97
+ throw new Error(`Unsupported architecture: ${arch} on ${platform}`);
98
+ }
99
+
100
+ return url;
101
+ }
102
+
103
+ /**
104
+ * Download a file with progress tracking
105
+ */
106
+ // SECURITY FIX (#29): Add max redirects parameter to prevent infinite loops
107
+ function downloadFile(url, destPath, onProgress = null, maxRedirects = 5) {
108
+ return new Promise((resolve, reject) => {
109
+ console.log(`šŸ“„ Downloading: ${url}`);
110
+ console.log(` Destination: ${destPath}`);
111
+
112
+ let protocol;
113
+ try {
114
+ protocol = url.startsWith('https') ? https : http;
115
+ } catch (error) {
116
+ console.error('āŒ Invalid URL:', url);
117
+ console.error('Error:', error);
118
+ console.error('Stack:', error.stack);
119
+ reject(new Error(`Invalid URL: ${url} - ${error.message}`));
120
+ return;
121
+ }
122
+
123
+ // Ensure directory exists
124
+ const dir = dirname(destPath);
125
+ if (!existsSync(dir)) {
126
+ try {
127
+ mkdirSync(dir, { recursive: true });
128
+ console.log(`āœ… Created directory: ${dir}`);
129
+ } catch (error) {
130
+ console.error(`āŒ Failed to create directory: ${dir}`);
131
+ console.error('Error:', error);
132
+ console.error('Stack:', error.stack);
133
+ reject(new Error(`Failed to create directory ${dir}: ${error.message}`));
134
+ return;
135
+ }
136
+ }
137
+
138
+ const request = protocol.get(url, (response) => {
139
+ console.log(`šŸ“” Response status: ${response.statusCode} ${response.statusMessage}`);
140
+ console.log(` Headers:`, JSON.stringify(response.headers, null, 2));
141
+
142
+ // Handle redirects
143
+ if (response.statusCode === 301 || response.statusCode === 302) {
144
+ const location = response.headers.location;
145
+ console.log(`šŸ”€ Redirect to: ${location}`);
146
+
147
+ // SECURITY FIX (#29): Check redirect limit to prevent infinite loops/SSRF
148
+ if (maxRedirects <= 0) {
149
+ reject(new Error(`Too many redirects (max 5) when downloading ${url}`));
150
+ return;
151
+ }
152
+
153
+ try {
154
+ // Handle relative redirects by resolving against original URL
155
+ const redirectUrl = location.startsWith('http') ? location : new URL(location, url).href;
156
+ console.log(`šŸ”€ Resolved redirect URL: ${redirectUrl} (${maxRedirects - 1} redirects remaining)`);
157
+ downloadFile(redirectUrl, destPath, onProgress, maxRedirects - 1).then(resolve).catch(reject);
158
+ } catch (error) {
159
+ console.error(`āŒ Failed to resolve redirect URL`);
160
+ console.error('Original URL:', url);
161
+ console.error('Location header:', location);
162
+ console.error('Error:', error);
163
+ console.error('Stack:', error.stack);
164
+ reject(
165
+ new Error(`Failed to resolve redirect from ${url} to ${location}: ${error.message}`)
166
+ );
167
+ }
168
+ return;
169
+ }
170
+
171
+ if (response.statusCode !== 200) {
172
+ const errorMsg = `HTTP ${response.statusCode}: ${response.statusMessage} for ${url}`;
173
+ console.error(`āŒ ${errorMsg}`);
174
+ reject(new Error(errorMsg));
175
+ return;
176
+ }
177
+
178
+ const totalBytes = parseInt(response.headers['content-length'] || '0', 10);
179
+ console.log(`šŸ“¦ Content length: ${(totalBytes / 1024 / 1024).toFixed(2)} MB`);
180
+ let downloadedBytes = 0;
181
+ let lastLoggedPercent = -1;
182
+
183
+ const fileStream = createWriteStream(destPath);
184
+
185
+ let lastCallbackPercent = -1;
186
+
187
+ response.on('data', (chunk) => {
188
+ downloadedBytes += chunk.length;
189
+ if (onProgress && totalBytes > 0) {
190
+ const percent = Math.floor((downloadedBytes / totalBytes) * 100);
191
+
192
+ // Log progress every 10%
193
+ if (percent >= lastLoggedPercent + 10 || percent === 100) {
194
+ console.log(
195
+ ` Progress: ${percent}% (${(downloadedBytes / 1024 / 1024).toFixed(2)} / ${(totalBytes / 1024 / 1024).toFixed(2)} MB)`
196
+ );
197
+ lastLoggedPercent = percent;
198
+ }
199
+
200
+ // Only call onProgress when percent actually changes (avoid flooding IPC)
201
+ if (percent !== lastCallbackPercent) {
202
+ lastCallbackPercent = percent;
203
+ onProgress(percent, downloadedBytes, totalBytes);
204
+ }
205
+ }
206
+ });
207
+
208
+ response.pipe(fileStream);
209
+
210
+ fileStream.on('finish', () => {
211
+ fileStream.close();
212
+ console.log(`āœ… Download complete: ${destPath}`);
213
+ console.log(` Size: ${(downloadedBytes / 1024 / 1024).toFixed(2)} MB`);
214
+ resolve();
215
+ });
216
+
217
+ fileStream.on('error', (error) => {
218
+ fileStream.close();
219
+ console.error(`āŒ File stream error for ${destPath}`);
220
+ console.error('Error:', error);
221
+ console.error('Stack:', error.stack);
222
+ reject(new Error(`File write failed for ${destPath}: ${error.message}`));
223
+ });
224
+ });
225
+
226
+ request.on('error', (error) => {
227
+ console.error(`āŒ Request error for ${url}`);
228
+ console.error('Error:', error);
229
+ console.error('Stack:', error.stack);
230
+ reject(new Error(`Download failed for ${url}: ${error.message}`));
231
+ });
232
+
233
+ request.end();
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Run pip install command with progress tracking
239
+ */
240
+ function pipInstall(packages, onProgress = null) {
241
+ return new Promise((resolve, reject) => {
242
+ const pythonPath = getPythonPath();
243
+
244
+ if (!existsSync(pythonPath)) {
245
+ reject(new Error('Python not installed'));
246
+ return;
247
+ }
248
+
249
+ // Split packages string into args
250
+ const packageArgs = packages.split(/\s+/).filter((p) => p);
251
+ // Use --progress-bar on to ensure we get progress output
252
+ const args = ['-m', 'pip', 'install', ...packageArgs, '--no-cache-dir', '--progress-bar', 'on'];
253
+
254
+ const proc = spawn(pythonPath, args, {
255
+ env: {
256
+ ...getPythonEnv(),
257
+ // Force color output which includes progress bars
258
+ FORCE_COLOR: '1',
259
+ PIP_PROGRESS_BAR: 'on',
260
+ },
261
+ stdio: ['pipe', 'pipe', 'pipe'],
262
+ });
263
+
264
+ let stdout = '';
265
+ let stderr = '';
266
+ let currentPackage = '';
267
+ let lastProgressUpdate = 0;
268
+
269
+ proc.stdout.on('data', (data) => {
270
+ stdout += data.toString();
271
+ const text = data.toString();
272
+
273
+ if (onProgress) {
274
+ // Parse pip output for progress info
275
+ // Look for "Collecting package" or "Downloading package"
276
+ const collectMatch = text.match(/Collecting\s+(\S+)/);
277
+ if (collectMatch) {
278
+ currentPackage = collectMatch[1].split('[')[0].split('>')[0].split('<')[0].split('=')[0];
279
+ onProgress('collecting', `Collecting ${currentPackage}...`);
280
+ }
281
+
282
+ if (text.includes('Successfully installed')) {
283
+ onProgress('complete', 'Installation complete');
284
+ }
285
+ }
286
+ });
287
+
288
+ proc.stderr.on('data', (data) => {
289
+ stderr += data.toString();
290
+ const text = data.toString();
291
+
292
+ if (onProgress) {
293
+ // pip 23+ shows download progress in stderr with format like:
294
+ // "Downloading torch-2.0.0.whl (619.9 MB)"
295
+ // " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100.5/619.9 MB 15.2 MB/s eta 0:00:34"
296
+
297
+ // Match "Downloading package (size)"
298
+ const downloadMatch = text.match(/Downloading\s+(\S+)\s+\(([^)]+)\)/);
299
+ if (downloadMatch) {
300
+ currentPackage = downloadMatch[1].split('-')[0];
301
+ const totalSize = downloadMatch[2];
302
+ onProgress('downloading', `Downloading ${currentPackage} (${totalSize})...`);
303
+ }
304
+
305
+ // Match progress line with downloaded/total and speed
306
+ // Format: " ━━━━━━━━ 100.5/619.9 MB 15.2 MB/s eta 0:00:34"
307
+ const progressMatch = text.match(
308
+ /(\d+\.?\d*)\s*\/\s*(\d+\.?\d*)\s*(MB|GB|KB)\s+(\d+\.?\d*)\s*(MB|GB|KB)\/s/
309
+ );
310
+ if (progressMatch) {
311
+ const now = Date.now();
312
+ // Throttle updates to every 200ms to avoid flooding
313
+ if (now - lastProgressUpdate > 200) {
314
+ lastProgressUpdate = now;
315
+ const downloaded = parseFloat(progressMatch[1]);
316
+ const total = parseFloat(progressMatch[2]);
317
+ const unit = progressMatch[3];
318
+ const speed = progressMatch[4];
319
+ const speedUnit = progressMatch[5];
320
+
321
+ if (total > 0) {
322
+ const percent = Math.floor((downloaded / total) * 100);
323
+ const packageName = currentPackage || 'package';
324
+ onProgress(
325
+ 'downloading',
326
+ `Downloading ${packageName}: ${downloaded}/${total} ${unit} (${speed} ${speedUnit}/s) - ${percent}%`
327
+ );
328
+ }
329
+ }
330
+ }
331
+
332
+ // Also check for simpler progress format
333
+ // " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 619.9/619.9 MB"
334
+ const simpleProgressMatch = text.match(/(\d+\.?\d*)\s*\/\s*(\d+\.?\d*)\s*(MB|GB|KB)\s*$/);
335
+ if (simpleProgressMatch && !progressMatch) {
336
+ const now = Date.now();
337
+ if (now - lastProgressUpdate > 200) {
338
+ lastProgressUpdate = now;
339
+ const downloaded = parseFloat(simpleProgressMatch[1]);
340
+ const total = parseFloat(simpleProgressMatch[2]);
341
+ const unit = simpleProgressMatch[3];
342
+
343
+ if (total > 0) {
344
+ const percent = Math.floor((downloaded / total) * 100);
345
+ const packageName = currentPackage || 'package';
346
+ onProgress(
347
+ 'downloading',
348
+ `Downloading ${packageName}: ${downloaded}/${total} ${unit} - ${percent}%`
349
+ );
350
+ }
351
+ }
352
+ }
353
+ }
354
+ });
355
+
356
+ proc.on('close', (code) => {
357
+ if (code === 0) {
358
+ resolve({ success: true, stdout });
359
+ } else {
360
+ reject(new Error(`pip install failed (code ${code}): ${stderr.slice(-500)}`));
361
+ }
362
+ });
363
+
364
+ proc.on('error', (err) => {
365
+ reject(new Error(`Failed to run pip: ${err.message}`));
366
+ });
367
+ });
368
+ }
369
+
370
+ /**
371
+ * Detect GPU type for PyTorch variant selection
372
+ */
373
+ function detectGPU() {
374
+ const platform = process.platform;
375
+
376
+ // macOS: Check for Apple Silicon (MPS)
377
+ if (platform === 'darwin') {
378
+ return process.arch === 'arm64' ? 'mps' : 'cpu';
379
+ }
380
+
381
+ // Linux/Windows: Check for NVIDIA GPU
382
+ try {
383
+ execSync('nvidia-smi', { stdio: 'ignore' });
384
+ return 'cuda';
385
+ } catch {
386
+ return 'cpu';
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Download and install Python
392
+ */
393
+ export async function downloadPython(onProgress = null) {
394
+ console.log('šŸ Starting Python installation...');
395
+ const cacheDir = getCacheDir();
396
+ const pythonDir = join(cacheDir, 'python');
397
+ console.log(` Cache dir: ${cacheDir}`);
398
+ console.log(` Python dir: ${pythonDir}`);
399
+
400
+ // Check if already installed
401
+ const pythonPath = getPythonPath();
402
+ console.log(` Checking for existing Python: ${pythonPath}`);
403
+ if (existsSync(pythonPath)) {
404
+ console.log('āœ… Python already installed');
405
+ if (onProgress) onProgress('complete', 'Python already installed');
406
+ return { success: true, path: pythonPath };
407
+ }
408
+
409
+ try {
410
+ const url = getPythonBuildUrl();
411
+ console.log(`🌐 Python download URL: ${url}`);
412
+ const tarPath = join(cacheDir, 'python.tar.gz');
413
+
414
+ // Download
415
+ console.log('šŸ“„ Starting Python download...');
416
+ if (onProgress) onProgress('downloading', 'Downloading Python...');
417
+ await downloadFile(url, tarPath, (percent) => {
418
+ if (onProgress) onProgress('downloading', `Downloading Python... ${percent}%`);
419
+ });
420
+
421
+ // Extract
422
+ console.log('šŸ“¦ Extracting Python...');
423
+ if (onProgress) onProgress('extracting', 'Extracting Python...');
424
+
425
+ // Create python directory
426
+ if (!existsSync(pythonDir)) {
427
+ console.log(` Creating Python directory: ${pythonDir}`);
428
+ mkdirSync(pythonDir, { recursive: true });
429
+ }
430
+
431
+ // Use tar to extract (available on all platforms)
432
+ console.log(' Loading tar module...');
433
+ const tar = await import('tar');
434
+ console.log(' Extracting tarball...');
435
+ await tar.extract({
436
+ file: tarPath,
437
+ cwd: pythonDir,
438
+ strip: 1,
439
+ });
440
+ console.log('āœ… Extraction complete');
441
+
442
+ // Remove quarantine on macOS
443
+ if (process.platform === 'darwin') {
444
+ console.log('šŸŽ Removing macOS quarantine attributes...');
445
+ try {
446
+ execSync(`xattr -cr "${pythonDir}"`, { stdio: 'ignore' });
447
+ console.log('āœ… Quarantine removed');
448
+ } catch (error) {
449
+ console.warn('āš ļø Failed to remove quarantine (non-fatal):', error.message);
450
+ }
451
+ }
452
+
453
+ // Clean up tarball
454
+ console.log('🧹 Cleaning up tarball...');
455
+ rmSync(tarPath, { force: true });
456
+
457
+ // Upgrade pip and setuptools, fix common conflicts
458
+ if (onProgress) onProgress('configuring', 'Upgrading pip and setuptools...');
459
+ await pipInstall('--upgrade pip setuptools wheel');
460
+
461
+ // Fix coverage module conflict that can break installs
462
+ try {
463
+ await pipInstall('--upgrade coverage');
464
+ } catch {
465
+ // Non-fatal - coverage may not be installed
466
+ }
467
+
468
+ console.log('āœ… Python installation complete');
469
+ if (onProgress) onProgress('complete', 'Python installed successfully');
470
+ return { success: true, path: pythonPath };
471
+ } catch (error) {
472
+ console.error('āŒ Python installation failed');
473
+ console.error('Error:', error);
474
+ console.error('Stack:', error.stack);
475
+ return { success: false, error: error.message };
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Download and install PyTorch
481
+ */
482
+ export async function downloadPyTorch(variant = 'auto', onProgress = null) {
483
+ try {
484
+ // Detect variant if auto
485
+ if (variant === 'auto') {
486
+ const gpu = detectGPU();
487
+ variant = gpu === 'cuda' ? 'cuda' : gpu === 'mps' ? 'default' : 'cpu';
488
+ }
489
+
490
+ let packageSpec;
491
+ if (variant === 'cuda') {
492
+ packageSpec =
493
+ 'torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118';
494
+ } else if (variant === 'default' || process.platform === 'darwin') {
495
+ packageSpec = 'torch torchvision torchaudio';
496
+ } else {
497
+ packageSpec = 'torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu';
498
+ }
499
+
500
+ if (onProgress) onProgress('installing', 'Installing PyTorch...');
501
+ await pipInstall(packageSpec, (stage, msg) => {
502
+ if (onProgress) onProgress(stage, msg);
503
+ });
504
+
505
+ if (onProgress) onProgress('complete', 'PyTorch installed');
506
+ return { success: true, variant };
507
+ } catch (error) {
508
+ return { success: false, error: error.message };
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Download and install SoundFile (audio backend for torchaudio)
514
+ */
515
+ export async function downloadSoundFile(onProgress = null) {
516
+ try {
517
+ if (onProgress) onProgress('installing', 'Installing SoundFile...');
518
+ await pipInstall('soundfile', (stage, msg) => {
519
+ if (onProgress) onProgress(stage, msg);
520
+ });
521
+
522
+ if (onProgress) onProgress('complete', 'SoundFile installed');
523
+ return { success: true };
524
+ } catch (error) {
525
+ return { success: false, error: error.message };
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Download and install Demucs
531
+ */
532
+ export async function downloadDemucs(onProgress = null) {
533
+ try {
534
+ if (onProgress) onProgress('installing', 'Installing Demucs...');
535
+ await pipInstall('demucs', (stage, msg) => {
536
+ if (onProgress) onProgress(stage, msg);
537
+ });
538
+
539
+ if (onProgress) onProgress('complete', 'Demucs installed');
540
+ return { success: true };
541
+ } catch (error) {
542
+ return { success: false, error: error.message };
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Download and install Whisper
548
+ */
549
+ export async function downloadWhisper(onProgress = null) {
550
+ try {
551
+ if (onProgress) onProgress('installing', 'Installing Whisper...');
552
+ await pipInstall('openai-whisper', (stage, msg) => {
553
+ if (onProgress) onProgress(stage, msg);
554
+ });
555
+
556
+ if (onProgress) onProgress('complete', 'Whisper installed');
557
+ return { success: true };
558
+ } catch (error) {
559
+ return { success: false, error: error.message };
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Download and install torchcrepe (CREPE pitch detection)
565
+ */
566
+ export async function downloadCrepe(onProgress = null) {
567
+ try {
568
+ if (onProgress) onProgress('installing', 'Installing torchcrepe...');
569
+ await pipInstall('torchcrepe>=0.0.12', (stage, msg) => {
570
+ if (onProgress) onProgress(stage, msg);
571
+ });
572
+
573
+ if (onProgress) onProgress('complete', 'torchcrepe installed');
574
+ return { success: true };
575
+ } catch (error) {
576
+ return { success: false, error: error.message };
577
+ }
578
+ }
579
+
580
+ // Whisper model URLs from https://github.com/openai/whisper/blob/main/whisper/__init__.py
581
+ const WHISPER_MODELS = {
582
+ tiny: 'https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt',
583
+ base: 'https://openaipublic.azureedge.net/main/whisper/models/ed3a0b6b1c0edf879ad9b11b1af5a0e6ab5db9205f891f668f8b0e6c6326e34e/base.pt',
584
+ small:
585
+ 'https://openaipublic.azureedge.net/main/whisper/models/9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/small.pt',
586
+ medium:
587
+ 'https://openaipublic.azureedge.net/main/whisper/models/345ae4da62f9b3d59415adc60127b97c714f32e89e936602e85993674d08dcb1/medium.pt',
588
+ 'large-v1':
589
+ 'https://openaipublic.azureedge.net/main/whisper/models/e4b87e7e0bf463eb8e6956e646f1e277e901512310def2c24bf0e11bd3c28e9a/large-v1.pt',
590
+ 'large-v2':
591
+ 'https://openaipublic.azureedge.net/main/whisper/models/81f7c96c852ee8fc832187b0132e569d6c3065a3252ed18e56effd0b6a73e524/large-v2.pt',
592
+ 'large-v3':
593
+ 'https://openaipublic.azureedge.net/main/whisper/models/e5b1a55b89c1367dacf97e3e19bfd829a01529dbfdeefa8caeb59b3f1b81dadb/large-v3.pt',
594
+ 'large-v3-turbo':
595
+ 'https://openaipublic.azureedge.net/main/whisper/models/aff26ae408abcba5fbf8813c21e62b0941638c5f6eebfb145be0c9839262a19a/large-v3-turbo.pt',
596
+ };
597
+
598
+ // Model sizes for progress display
599
+ const WHISPER_MODEL_SIZES = {
600
+ tiny: '~75 MB',
601
+ base: '~145 MB',
602
+ small: '~465 MB',
603
+ medium: '~1.5 GB',
604
+ 'large-v1': '~3 GB',
605
+ 'large-v2': '~3 GB',
606
+ 'large-v3': '~3 GB',
607
+ 'large-v3-turbo': '~1.6 GB',
608
+ };
609
+
610
+ /**
611
+ * Download Whisper model directly with progress, then verify
612
+ */
613
+ export async function downloadWhisperModel(modelName = 'large-v3-turbo', onProgress = null) {
614
+ const pythonPath = getPythonPath();
615
+
616
+ if (!existsSync(pythonPath)) {
617
+ return { success: false, error: 'Python not installed' };
618
+ }
619
+
620
+ const modelUrl = WHISPER_MODELS[modelName];
621
+ if (!modelUrl) {
622
+ return { success: false, error: `Unknown model: ${modelName}` };
623
+ }
624
+
625
+ const modelSize = WHISPER_MODEL_SIZES[modelName] || 'unknown size';
626
+
627
+ // Whisper stores models in ~/.cache/whisper/ (we use XDG_CACHE_HOME from getPythonEnv)
628
+ const cacheDir = getCacheDir();
629
+ const whisperCacheDir = join(cacheDir, 'whisper');
630
+ const modelPath = join(whisperCacheDir, `${modelName}.pt`);
631
+
632
+ // Check if model already exists
633
+ if (existsSync(modelPath)) {
634
+ if (onProgress) onProgress('complete', `${modelName} model already downloaded`);
635
+ return { success: true, model: modelName, cached: true };
636
+ }
637
+
638
+ // Ensure whisper cache directory exists
639
+ if (!existsSync(whisperCacheDir)) {
640
+ mkdirSync(whisperCacheDir, { recursive: true });
641
+ }
642
+
643
+ try {
644
+ // Download with progress
645
+ if (onProgress)
646
+ onProgress('downloading', `Downloading ${modelName} model (${modelSize})... 0%`);
647
+
648
+ await downloadFile(modelUrl, modelPath, (percent) => {
649
+ if (onProgress) {
650
+ onProgress('downloading', `Downloading ${modelName} model (${modelSize})... ${percent}%`);
651
+ }
652
+ });
653
+
654
+ if (onProgress) onProgress('downloading', `Verifying ${modelName} model...`);
655
+
656
+ // Verify the model loads correctly
657
+ const verifyResult = await verifyWhisperModel(modelName);
658
+ if (!verifyResult.success) {
659
+ // Delete corrupted download
660
+ try {
661
+ rmSync(modelPath);
662
+ } catch {
663
+ // Ignore cleanup errors
664
+ }
665
+ return { success: false, error: verifyResult.error };
666
+ }
667
+
668
+ if (onProgress) onProgress('complete', `${modelName} model ready`);
669
+ return { success: true, model: modelName };
670
+ } catch (error) {
671
+ // Clean up partial download
672
+ try {
673
+ if (existsSync(modelPath)) {
674
+ rmSync(modelPath);
675
+ }
676
+ } catch {
677
+ // Ignore cleanup errors
678
+ }
679
+ return { success: false, error: error.message };
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Verify a Whisper model loads correctly
685
+ */
686
+ function verifyWhisperModel(modelName) {
687
+ const pythonPath = getPythonPath();
688
+
689
+ return new Promise((resolve) => {
690
+ const script = `
691
+ import sys
692
+ import json
693
+ try:
694
+ import whisper
695
+ model = whisper.load_model("${modelName}")
696
+ print(json.dumps({"success": True}))
697
+ except Exception as e:
698
+ print(json.dumps({"success": False, "error": str(e)}))
699
+ `;
700
+
701
+ const proc = spawn(pythonPath, ['-c', script], {
702
+ env: getPythonEnv(),
703
+ });
704
+
705
+ let stdout = '';
706
+
707
+ proc.stdout.on('data', (data) => {
708
+ stdout += data.toString();
709
+ });
710
+
711
+ proc.on('close', () => {
712
+ try {
713
+ const result = JSON.parse(stdout.trim());
714
+ resolve(result);
715
+ } catch {
716
+ resolve({ success: false, error: 'Failed to verify model' });
717
+ }
718
+ });
719
+
720
+ proc.on('error', (err) => {
721
+ resolve({ success: false, error: err.message });
722
+ });
723
+ });
724
+ }
725
+
726
+ /**
727
+ * Download Demucs model by running a test load
728
+ */
729
+ export function downloadDemucsModel(modelName = 'htdemucs_ft', onProgress = null) {
730
+ const pythonPath = getPythonPath();
731
+
732
+ if (!existsSync(pythonPath)) {
733
+ return Promise.resolve({ success: false, error: 'Python not installed' });
734
+ }
735
+
736
+ return new Promise((resolve) => {
737
+ if (onProgress) onProgress('downloading', `Downloading Demucs ${modelName} model (~300 MB)...`);
738
+
739
+ const script = `
740
+ import sys
741
+ import json
742
+ try:
743
+ old_stdout = sys.stdout
744
+ sys.stdout = sys.stderr
745
+ print("STATUS:Downloading model...", file=sys.stderr)
746
+ from demucs.pretrained import get_model
747
+ model = get_model("${modelName}")
748
+ print("STATUS:Model loaded successfully", file=sys.stderr)
749
+ sys.stdout = old_stdout
750
+ print(json.dumps({"success": True}))
751
+ except Exception as e:
752
+ sys.stdout = old_stdout if 'old_stdout' in locals() else sys.stdout
753
+ print(json.dumps({"success": False, "error": str(e)}))
754
+ `;
755
+
756
+ const proc = spawn(pythonPath, ['-c', script], {
757
+ env: getPythonEnv(),
758
+ });
759
+
760
+ let stdout = '';
761
+
762
+ proc.stdout.on('data', (data) => {
763
+ stdout += data.toString();
764
+ });
765
+
766
+ proc.stderr.on('data', (data) => {
767
+ const line = data.toString().trim();
768
+ if (!line) return;
769
+
770
+ if (onProgress) {
771
+ // Check for our custom status messages
772
+ if (line.startsWith('STATUS:')) {
773
+ onProgress('downloading', line.replace('STATUS:', ''));
774
+ } else if (line.includes('%|')) {
775
+ // tqdm progress bar - extract percentage
776
+ const match = line.match(/(\d+)%\|/);
777
+ if (match) {
778
+ onProgress('downloading', `Downloading model... ${match[1]}%`);
779
+ }
780
+ } else if (line.includes('Downloading') || line.includes('downloading')) {
781
+ onProgress('downloading', line.slice(0, 80));
782
+ }
783
+ }
784
+ });
785
+
786
+ proc.on('close', () => {
787
+ try {
788
+ const result = JSON.parse(stdout.trim());
789
+ if (result.success) {
790
+ if (onProgress) onProgress('complete', `${modelName} model ready`);
791
+ resolve({ success: true, model: modelName });
792
+ } else {
793
+ resolve({ success: false, error: result.error });
794
+ }
795
+ } catch {
796
+ resolve({ success: false, error: 'Failed to parse output' });
797
+ }
798
+ });
799
+
800
+ proc.on('error', (err) => {
801
+ resolve({ success: false, error: err.message });
802
+ });
803
+ });
804
+ }
805
+
806
+ /**
807
+ * Download FFmpeg binary
808
+ */
809
+ export async function downloadFFmpeg(onProgress = null) {
810
+ console.log('šŸŽ¬ Starting FFmpeg installation...');
811
+ const cacheDir = getCacheDir();
812
+ const binDir = join(cacheDir, 'bin');
813
+ console.log(` Binary dir: ${binDir}`);
814
+
815
+ if (!existsSync(binDir)) {
816
+ mkdirSync(binDir, { recursive: true });
817
+ console.log(` Created binary directory`);
818
+ }
819
+
820
+ const plat = process.platform;
821
+ const ffmpegName = plat === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
822
+ const ffprobeName = plat === 'win32' ? 'ffprobe.exe' : 'ffprobe';
823
+ const ffmpegPath = join(binDir, ffmpegName);
824
+ const ffprobePath = join(binDir, ffprobeName);
825
+ console.log(` Platform: ${plat}`);
826
+ console.log(` FFmpeg path: ${ffmpegPath}`);
827
+ console.log(` FFprobe path: ${ffprobePath}`);
828
+
829
+ // Check if both already exist
830
+ if (existsSync(ffmpegPath) && existsSync(ffprobePath)) {
831
+ console.log('āœ… FFmpeg and FFprobe already installed');
832
+ if (onProgress) onProgress('complete', 'FFmpeg already downloaded');
833
+ return { success: true, ffmpegPath, ffprobePath };
834
+ }
835
+
836
+ try {
837
+ // URLs for ffmpeg builds that include both ffmpeg and ffprobe
838
+ let url;
839
+ let ffprobeUrl = null; // macOS needs separate download for ffprobe
840
+ if (plat === 'darwin') {
841
+ // evermeet.cx provides separate downloads for ffmpeg and ffprobe on macOS
842
+ url = 'https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip';
843
+ ffprobeUrl = 'https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip';
844
+ } else if (plat === 'win32') {
845
+ // BtbN builds include both ffmpeg and ffprobe
846
+ url =
847
+ 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip';
848
+ } else {
849
+ // John Van Sickle builds include both ffmpeg and ffprobe
850
+ url = 'https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz';
851
+ }
852
+ console.log(`🌐 FFmpeg download URL: ${url}`);
853
+ if (ffprobeUrl) {
854
+ console.log(`🌐 FFprobe download URL: ${ffprobeUrl}`);
855
+ }
856
+
857
+ const archivePath = join(binDir, plat === 'linux' ? 'ffmpeg.tar.xz' : 'ffmpeg.zip');
858
+ console.log(` Archive path: ${archivePath}`);
859
+
860
+ // Download ffmpeg
861
+ console.log('šŸ“„ Starting FFmpeg download...');
862
+ if (onProgress) onProgress('downloading', 'Downloading FFmpeg...');
863
+ await downloadFile(url, archivePath, (percent) => {
864
+ if (onProgress) onProgress('downloading', `Downloading FFmpeg... ${percent}%`);
865
+ });
866
+
867
+ // Download ffprobe separately if needed (macOS)
868
+ let ffprobeArchivePath = null;
869
+ if (ffprobeUrl) {
870
+ ffprobeArchivePath = join(binDir, 'ffprobe.zip');
871
+ console.log('šŸ“„ Starting FFprobe download...');
872
+ if (onProgress) onProgress('downloading', 'Downloading FFprobe...');
873
+ await downloadFile(ffprobeUrl, ffprobeArchivePath, (percent) => {
874
+ if (onProgress) onProgress('downloading', `Downloading FFprobe... ${percent}%`);
875
+ });
876
+ }
877
+
878
+ // Extract
879
+ console.log('šŸ“¦ Extracting FFmpeg...');
880
+ if (onProgress) onProgress('extracting', 'Extracting FFmpeg...');
881
+
882
+ // Extract and find ffmpeg binary
883
+ const { mkdtempSync, readdirSync, statSync, copyFileSync } = await import('fs');
884
+ const { tmpdir } = await import('os');
885
+ const tempDir = mkdtempSync(join(tmpdir(), 'ffmpeg-'));
886
+
887
+ try {
888
+ console.log(` Extracting to temp dir: ${tempDir}`);
889
+ if (plat === 'linux') {
890
+ console.log(' Using tar to extract...');
891
+ execSync(`tar -xf "${archivePath}" -C "${tempDir}"`);
892
+ } else {
893
+ console.log(' Using yauzl to extract...');
894
+ await extractZip(archivePath, tempDir);
895
+ }
896
+ console.log(' Extraction complete, searching for binary...');
897
+
898
+ // Find binary recursively by name
899
+ const findBinary = (dir, name) => {
900
+ const files = readdirSync(dir);
901
+ for (const file of files) {
902
+ const fullPath = join(dir, file);
903
+ try {
904
+ if (statSync(fullPath).isDirectory()) {
905
+ const found = findBinary(fullPath, name);
906
+ if (found) return found;
907
+ } else if (file.toLowerCase() === name.toLowerCase()) {
908
+ return fullPath;
909
+ }
910
+ } catch {
911
+ continue;
912
+ }
913
+ }
914
+ return null;
915
+ };
916
+
917
+ // Extract both ffmpeg and ffprobe
918
+ const ffmpegName = plat === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
919
+ const ffprobeName = plat === 'win32' ? 'ffprobe.exe' : 'ffprobe';
920
+
921
+ const ffmpegFound = findBinary(tempDir, ffmpegName);
922
+ const ffprobeFound = findBinary(tempDir, ffprobeName);
923
+
924
+ if (ffmpegFound) {
925
+ const ffmpegDest = join(binDir, ffmpegName);
926
+ console.log(` Found ffmpeg: ${ffmpegFound}`);
927
+ console.log(` Copying to: ${ffmpegDest}`);
928
+ copyFileSync(ffmpegFound, ffmpegDest);
929
+ if (plat !== 'win32') {
930
+ chmodSync(ffmpegDest, 0o755);
931
+ }
932
+ console.log('āœ… ffmpeg binary installed');
933
+ } else {
934
+ console.error('āŒ ffmpeg binary not found in archive');
935
+ console.error(` Searched in: ${tempDir}`);
936
+ throw new Error('ffmpeg binary not found in archive');
937
+ }
938
+
939
+ if (ffprobeFound) {
940
+ console.log(` Found ffprobe: ${ffprobeFound}`);
941
+ console.log(` Copying to: ${ffprobePath}`);
942
+ copyFileSync(ffprobeFound, ffprobePath);
943
+ if (plat !== 'win32') {
944
+ chmodSync(ffprobePath, 0o755);
945
+ }
946
+ console.log('āœ… ffprobe binary installed');
947
+ } else if (ffprobeArchivePath) {
948
+ // macOS: Extract ffprobe from separate archive
949
+ console.log('šŸ“¦ Extracting FFprobe from separate archive...');
950
+ const ffprobeTempDir = mkdtempSync(join(tmpdir(), 'ffprobe-'));
951
+ try {
952
+ await extractZip(ffprobeArchivePath, ffprobeTempDir);
953
+ const ffprobeExtracted = findBinary(ffprobeTempDir, ffprobeName);
954
+ if (ffprobeExtracted) {
955
+ console.log(` Found ffprobe: ${ffprobeExtracted}`);
956
+ console.log(` Copying to: ${ffprobePath}`);
957
+ copyFileSync(ffprobeExtracted, ffprobePath);
958
+ chmodSync(ffprobePath, 0o755);
959
+ console.log('āœ… ffprobe binary installed');
960
+ } else {
961
+ console.warn('āš ļø ffprobe not found in separate archive');
962
+ }
963
+ rmSync(ffprobeTempDir, { recursive: true, force: true });
964
+ rmSync(ffprobeArchivePath, { force: true });
965
+ } catch (ffprobeError) {
966
+ console.warn('āš ļø Failed to extract ffprobe:', ffprobeError.message);
967
+ rmSync(ffprobeTempDir, { recursive: true, force: true });
968
+ }
969
+ } else {
970
+ console.warn('āš ļø ffprobe binary not found in archive');
971
+ }
972
+
973
+ // Clean up
974
+ console.log('🧹 Cleaning up temporary files...');
975
+ rmSync(tempDir, { recursive: true, force: true });
976
+ rmSync(archivePath, { force: true });
977
+
978
+ console.log('āœ… FFmpeg installation complete');
979
+ if (onProgress) onProgress('complete', 'FFmpeg installed');
980
+ return { success: true, ffmpegPath, ffprobePath };
981
+ } catch (extractError) {
982
+ console.error('āŒ FFmpeg extraction failed');
983
+ console.error('Error:', extractError);
984
+ console.error('Stack:', extractError.stack);
985
+ rmSync(tempDir, { recursive: true, force: true });
986
+ throw extractError;
987
+ }
988
+ } catch (error) {
989
+ console.error('āŒ FFmpeg installation failed');
990
+ console.error('Error:', error);
991
+ console.error('Stack:', error.stack);
992
+ return { success: false, error: error.message };
993
+ }
994
+ }
995
+
996
+ /**
997
+ * Install all components in order
998
+ */
999
+ export async function installAllComponents(onProgress = null) {
1000
+ console.log('šŸš€ Starting installation of all components...');
1001
+ console.log(` Platform: ${process.platform}`);
1002
+ console.log(` Architecture: ${process.arch}`);
1003
+
1004
+ const results = {};
1005
+
1006
+ // Define steps with human-readable labels and estimated sizes
1007
+ const steps = [
1008
+ { name: 'python', label: 'Python 3.12', fn: downloadPython, weight: 10, size: '~50 MB' },
1009
+ {
1010
+ name: 'pytorch',
1011
+ label: 'PyTorch',
1012
+ fn: () => downloadPyTorch('auto'),
1013
+ weight: 35,
1014
+ size: '~2 GB',
1015
+ },
1016
+ { name: 'soundfile', label: 'SoundFile', fn: downloadSoundFile, weight: 2, size: '~5 MB' },
1017
+ { name: 'demucs', label: 'Demucs', fn: downloadDemucs, weight: 8, size: '~100 MB' },
1018
+ { name: 'whisper', label: 'Whisper', fn: downloadWhisper, weight: 8, size: '~50 MB' },
1019
+ { name: 'crepe', label: 'CREPE', fn: downloadCrepe, weight: 4, size: '~20 MB' },
1020
+ { name: 'ffmpeg', label: 'FFmpeg', fn: downloadFFmpeg, weight: 5, size: '~80 MB' },
1021
+ {
1022
+ name: 'whisperModel',
1023
+ label: 'Whisper Model',
1024
+ action: 'Downloading', // Custom action word instead of "Installing"
1025
+ fn: () => downloadWhisperModel('large-v3-turbo'),
1026
+ weight: 15,
1027
+ size: '~1.5 GB',
1028
+ },
1029
+ {
1030
+ name: 'demucsModel',
1031
+ label: 'Demucs Model',
1032
+ action: 'Downloading', // Custom action word instead of "Installing"
1033
+ fn: () => downloadDemucsModel('htdemucs_ft'),
1034
+ weight: 15,
1035
+ size: '~300 MB',
1036
+ },
1037
+ ];
1038
+
1039
+ console.log(`šŸ“‹ Installation plan: ${steps.length} components`);
1040
+ steps.forEach((s, i) => {
1041
+ console.log(` ${i + 1}. ${s.label} (${s.size})`);
1042
+ });
1043
+
1044
+ let completedWeight = 0;
1045
+ const totalWeight = steps.reduce((sum, s) => sum + s.weight, 0);
1046
+
1047
+ for (let i = 0; i < steps.length; i++) {
1048
+ const step = steps[i];
1049
+ const stepNumber = i + 1;
1050
+ const totalSteps = steps.length;
1051
+ const action = step.action || 'Installing';
1052
+
1053
+ console.log(`\nšŸ“¦ [${stepNumber}/${totalSteps}] ${action} ${step.label}...`);
1054
+
1055
+ if (onProgress) {
1056
+ const percent = Math.floor((completedWeight / totalWeight) * 100);
1057
+ onProgress(
1058
+ percent,
1059
+ `[${stepNumber}/${totalSteps}] ${action} ${step.label} (${step.size})...`
1060
+ );
1061
+ }
1062
+
1063
+ const result = await step.fn((stage, msg) => {
1064
+ if (onProgress && stage !== 'complete') {
1065
+ // Calculate sub-progress within this step
1066
+ const basePercent = Math.floor((completedWeight / totalWeight) * 100);
1067
+
1068
+ // For download stages, try to extract percent from message
1069
+ let subProgress = 0;
1070
+ const percentMatch = msg.match(/(\d+)%/);
1071
+ if (percentMatch) {
1072
+ subProgress = parseInt(percentMatch[1], 10);
1073
+ }
1074
+
1075
+ // Add sub-progress contribution
1076
+ const stepContribution = Math.floor((step.weight / totalWeight) * subProgress);
1077
+ const totalPercent = Math.min(basePercent + stepContribution, 99);
1078
+
1079
+ onProgress(totalPercent, `[${stepNumber}/${totalSteps}] ${msg}`);
1080
+ }
1081
+ });
1082
+
1083
+ results[step.name] = result;
1084
+
1085
+ if (!result.success) {
1086
+ console.error(`āŒ [${stepNumber}/${totalSteps}] Failed to install ${step.label}`);
1087
+ console.error(' Error:', result.error);
1088
+ if (onProgress) {
1089
+ onProgress(
1090
+ Math.floor((completedWeight / totalWeight) * 100),
1091
+ `Failed to install ${step.label}: ${result.error}`
1092
+ );
1093
+ }
1094
+ return { success: false, failed: step.name, error: result.error, results };
1095
+ }
1096
+
1097
+ console.log(`āœ… [${stepNumber}/${totalSteps}] ${step.label} installed successfully`);
1098
+
1099
+ completedWeight += step.weight;
1100
+
1101
+ if (onProgress) {
1102
+ const percent = Math.floor((completedWeight / totalWeight) * 100);
1103
+ onProgress(percent, `[${stepNumber}/${totalSteps}] ${step.label} installed`);
1104
+ }
1105
+ }
1106
+
1107
+ console.log('\nšŸŽ‰ All components installed successfully!');
1108
+ console.log('Installation results:');
1109
+ Object.entries(results).forEach(([name, result]) => {
1110
+ console.log(` ${result.success ? 'āœ…' : 'āŒ'} ${name}`);
1111
+ });
1112
+
1113
+ if (onProgress) onProgress(100, 'All components installed successfully!');
1114
+ return { success: true, results };
1115
+ }
1116
+
1117
+ export default {
1118
+ downloadPython,
1119
+ downloadPyTorch,
1120
+ downloadSoundFile,
1121
+ downloadDemucs,
1122
+ downloadWhisper,
1123
+ downloadCrepe,
1124
+ downloadWhisperModel,
1125
+ downloadDemucsModel,
1126
+ downloadFFmpeg,
1127
+ installAllComponents,
1128
+ };