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,1236 @@
1
+ /**
2
+ * CreateTab - Create karaoke files from audio
3
+ *
4
+ * Handles the full workflow:
5
+ * 1. Check/install Python dependencies
6
+ * 2. Select audio file
7
+ * 3. Configure options (stems, whisper model, etc.)
8
+ * 4. Run conversion pipeline
9
+ * 5. Output .stem.m4a file
10
+ */
11
+
12
+ import { useState, useEffect, useCallback, useRef } from 'react';
13
+ import { LLM_DEFAULTS, CREATOR_DEFAULTS } from '../../../shared/defaults.js';
14
+ import { PortalSelect } from '../PortalSelect.jsx';
15
+
16
+ // ============================================================================
17
+ // Shared Styles
18
+ // ============================================================================
19
+ const STYLES = {
20
+ input:
21
+ 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white',
22
+ select:
23
+ 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white',
24
+ btnPrimary:
25
+ 'px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors',
26
+ btnSecondary:
27
+ 'px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors',
28
+ btnSuccess:
29
+ 'px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors',
30
+ sectionTitle: 'text-lg font-semibold text-gray-900 dark:text-white mb-4',
31
+ card: 'bg-gray-100 dark:bg-gray-800 rounded-lg p-6',
32
+ };
33
+
34
+ // ============================================================================
35
+ // Helper Components
36
+ // ============================================================================
37
+
38
+ function Spinner({ message, size = 'md' }) {
39
+ const sizeClasses = {
40
+ sm: 'h-8 w-8',
41
+ md: 'h-12 w-12',
42
+ };
43
+ return (
44
+ <div className="text-center">
45
+ <div
46
+ className={`animate-spin rounded-full ${sizeClasses[size]} border-b-2 border-blue-500 mx-auto mb-3`}
47
+ />
48
+ {message && <p className="text-gray-600 dark:text-gray-400">{message}</p>}
49
+ </div>
50
+ );
51
+ }
52
+
53
+ function ErrorDisplay({ error, onDismiss }) {
54
+ if (!error) return null;
55
+ return (
56
+ <div className="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-400 px-4 py-3 rounded mb-6 select-text">
57
+ {onDismiss && (
58
+ <button
59
+ className="float-right text-red-700 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 text-xl leading-none"
60
+ onClick={onDismiss}
61
+ >
62
+ ×
63
+ </button>
64
+ )}
65
+ <div className="font-mono text-sm whitespace-pre-wrap overflow-x-auto max-h-96">{error}</div>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ function MissingLinesDetails({ missingLines }) {
71
+ if (!missingLines || missingLines.length === 0) return null;
72
+ return (
73
+ <details className="text-xs text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800 rounded p-2">
74
+ <summary className="cursor-pointer font-semibold">
75
+ 💡 {missingLines.length} missing line{missingLines.length !== 1 ? 's' : ''} suggested (not
76
+ applied)
77
+ </summary>
78
+ <ul className="mt-2 space-y-1 ml-4 list-disc max-h-40 overflow-y-auto">
79
+ {missingLines.map((line, i) => (
80
+ <li key={i}>
81
+ <span className="text-blue-600 dark:text-blue-400">"{line.suggested_text}"</span>{' '}
82
+ <span className="text-gray-500 dark:text-gray-400">
83
+ ({line.start?.toFixed(1)}s-{line.end?.toFixed(1)}s, {line.confidence} confidence)
84
+ </span>
85
+ {line.reason && (
86
+ <div className="text-gray-500 dark:text-gray-400 ml-2">→ {line.reason}</div>
87
+ )}
88
+ </li>
89
+ ))}
90
+ </ul>
91
+ </details>
92
+ );
93
+ }
94
+
95
+ function SongTitle({ artist, title }) {
96
+ return artist ? `${artist} - ${title}` : title;
97
+ }
98
+
99
+ // Format LLM provider name for display
100
+ function formatProviderName(provider) {
101
+ const names = {
102
+ anthropic: 'Anthropic Claude',
103
+ openai: 'OpenAI',
104
+ gemini: 'Google Gemini',
105
+ lmstudio: 'Local LLM Server',
106
+ };
107
+ return names[provider] || provider;
108
+ }
109
+
110
+ export function CreateTab({ bridge: _bridge }) {
111
+ const [status, setStatus] = useState('checking'); // checking, setup, ready, creating, complete, installing
112
+ const [components, setComponents] = useState(null);
113
+ const [installProgress, setInstallProgress] = useState(null);
114
+ const [error, setError] = useState(null);
115
+
116
+ // Sub-tab state: 'create' or 'settings'
117
+ const [activeSubTab, setActiveSubTab] = useState('create');
118
+
119
+ // File and conversion state
120
+ const [selectedFile, setSelectedFile] = useState(null);
121
+ const [fileLoading, setFileLoading] = useState(false); // Loading state for file selection
122
+ const [conversionProgress, setConversionProgress] = useState(null);
123
+ const [completedFile, setCompletedFile] = useState(null);
124
+ const [llmStats, setLlmStats] = useState(null);
125
+ const [songDuration, setSongDuration] = useState(null);
126
+ const [processingTime, setProcessingTime] = useState(null);
127
+ const [consoleLog, setConsoleLog] = useState([]);
128
+ const [isLyricsOnlyMode, setIsLyricsOnlyMode] = useState(false); // Track if we started in lyrics-only mode
129
+ const consoleEndRef = useRef(null);
130
+ const conversionStartTimeRef = useRef(null);
131
+
132
+ // Options
133
+ const [options, setOptions] = useState({
134
+ title: '',
135
+ artist: '',
136
+ numStems: 4, // Always 4 stems for .stem.m4a format
137
+ language: 'en',
138
+ referenceLyrics: '',
139
+ });
140
+
141
+ // LLM settings - uses unified defaults from shared/defaults.js
142
+ const [llmSettings, setLlmSettings] = useState({ ...LLM_DEFAULTS });
143
+ const [llmTestResult, setLlmTestResult] = useState(null);
144
+
145
+ // Output settings
146
+ const [outputToSongsFolder, setOutputToSongsFolder] = useState(false);
147
+ const [whisperModel, setWhisperModel] = useState(CREATOR_DEFAULTS.whisperModel);
148
+ const [enableCrepe, setEnableCrepe] = useState(CREATOR_DEFAULTS.enableCrepe);
149
+
150
+ const checkComponents = useCallback(async () => {
151
+ setStatus('checking');
152
+ setError(null);
153
+
154
+ try {
155
+ const result = await window.kaiAPI?.creator?.checkComponents();
156
+
157
+ if (result?.success) {
158
+ setComponents(result);
159
+
160
+ if (result.allInstalled) {
161
+ setStatus('ready');
162
+ } else {
163
+ setStatus('setup');
164
+ }
165
+ } else {
166
+ setError(result?.error || 'Failed to check components');
167
+ setStatus('setup');
168
+ }
169
+ } catch (err) {
170
+ console.error('Error checking components:', err);
171
+ setError(err.message);
172
+ setStatus('setup');
173
+ }
174
+ }, []);
175
+
176
+ useEffect(() => {
177
+ checkComponents();
178
+
179
+ // Load LLM settings
180
+ const loadLLMSettings = async () => {
181
+ try {
182
+ const settings = await window.kaiAPI?.creator?.getLLMSettings();
183
+ if (settings) {
184
+ setLlmSettings(settings);
185
+ }
186
+ } catch (err) {
187
+ console.error('Failed to load LLM settings:', err);
188
+ }
189
+ };
190
+ loadLLMSettings();
191
+
192
+ // Load output settings
193
+ const loadOutputSettings = async () => {
194
+ try {
195
+ const outputToSongs = await window.kaiAPI?.settings?.get(
196
+ 'creator.outputToSongsFolder',
197
+ false
198
+ );
199
+ setOutputToSongsFolder(outputToSongs);
200
+ const whisper = await window.kaiAPI?.settings?.get(
201
+ 'creator.whisperModel',
202
+ CREATOR_DEFAULTS.whisperModel
203
+ );
204
+ setWhisperModel(whisper);
205
+ const crepe = await window.kaiAPI?.settings?.get(
206
+ 'creator.enableCrepe',
207
+ CREATOR_DEFAULTS.enableCrepe
208
+ );
209
+ setEnableCrepe(crepe);
210
+ } catch (err) {
211
+ console.error('Failed to load output settings:', err);
212
+ }
213
+ };
214
+ loadOutputSettings();
215
+
216
+ // Listen for installation progress
217
+ const onInstallProgress = (_event, progress) => {
218
+ setInstallProgress(progress);
219
+ if (progress.step === 'complete') {
220
+ setStatus('checking');
221
+ checkComponents();
222
+ }
223
+ };
224
+
225
+ const onInstallError = (_event, err) => {
226
+ setError(err.error);
227
+ setStatus('setup');
228
+ };
229
+
230
+ // Listen for conversion progress
231
+ const onConversionProgress = (_event, progress) => {
232
+ setConversionProgress(progress);
233
+ };
234
+
235
+ const onConversionConsole = (_event, data) => {
236
+ const line = data.line;
237
+
238
+ setConsoleLog((prev) => {
239
+ // If line contains progress indicators (%, |, ━), replace last line
240
+ // This handles tqdm and pip progress bars that use \r
241
+ if (line.match(/\d+%|[│┃║▌▍▎▏█]|━|█/) && prev.length > 0) {
242
+ // Check if last line was also a progress line
243
+ const lastLine = prev[prev.length - 1];
244
+ if (lastLine.match(/\d+%|[│┃║▌▍▎▏█]|━|█/)) {
245
+ // Replace last line
246
+ return [...prev.slice(0, -1), line];
247
+ }
248
+ }
249
+
250
+ // Otherwise append new line
251
+ return [...prev, line];
252
+ });
253
+
254
+ // Auto-scroll to bottom
255
+ setTimeout(() => {
256
+ consoleEndRef.current?.scrollIntoView({ behavior: 'smooth' });
257
+ }, 100);
258
+ };
259
+
260
+ const onConversionComplete = async (_event, result) => {
261
+ const endTime = Date.now();
262
+ const elapsed = conversionStartTimeRef.current
263
+ ? (endTime - conversionStartTimeRef.current) / 1000
264
+ : null;
265
+
266
+ setCompletedFile(result.outputPath);
267
+ setLlmStats(result.llmStats);
268
+ setSongDuration(result.duration);
269
+ setProcessingTime(elapsed);
270
+ setStatus('complete');
271
+ setConversionProgress(null);
272
+
273
+ // If saved to songs folder, trigger a library sync to pick up the new file
274
+ if (result.savedToSongsFolder) {
275
+ try {
276
+ await window.kaiAPI?.library?.syncLibrary?.();
277
+ } catch (err) {
278
+ console.error('Failed to sync library after creation:', err);
279
+ }
280
+ }
281
+ };
282
+
283
+ const onConversionError = (_event, err) => {
284
+ setError(err.error);
285
+ setStatus('ready');
286
+ setConversionProgress(null);
287
+ };
288
+
289
+ window.kaiAPI?.creator?.onInstallProgress(onInstallProgress);
290
+ window.kaiAPI?.creator?.onInstallError(onInstallError);
291
+ window.kaiAPI?.creator?.onConversionProgress(onConversionProgress);
292
+ window.kaiAPI?.creator?.onConversionConsole(onConversionConsole);
293
+ window.kaiAPI?.creator?.onConversionComplete(onConversionComplete);
294
+ window.kaiAPI?.creator?.onConversionError(onConversionError);
295
+
296
+ return () => {
297
+ window.kaiAPI?.creator?.removeInstallProgressListener(onInstallProgress);
298
+ window.kaiAPI?.creator?.removeInstallErrorListener(onInstallError);
299
+ window.kaiAPI?.creator?.removeConversionProgressListener(onConversionProgress);
300
+ window.kaiAPI?.creator?.removeConversionConsoleListener(onConversionConsole);
301
+ window.kaiAPI?.creator?.removeConversionCompleteListener(onConversionComplete);
302
+ window.kaiAPI?.creator?.removeConversionErrorListener(onConversionError);
303
+ };
304
+ }, [checkComponents]);
305
+
306
+ const handleInstall = async () => {
307
+ setStatus('installing');
308
+ setInstallProgress({ step: 'starting', message: 'Starting installation...', progress: 0 });
309
+ setError(null);
310
+
311
+ try {
312
+ const result = await window.kaiAPI?.creator?.installComponents();
313
+ if (!result?.success) {
314
+ setError(result?.error || 'Installation failed');
315
+ setStatus('setup');
316
+ }
317
+ } catch (err) {
318
+ setError(err.message);
319
+ setStatus('setup');
320
+ }
321
+ };
322
+
323
+ const handleSelectFile = async () => {
324
+ try {
325
+ setFileLoading(true);
326
+ setError(null);
327
+ const result = await window.kaiAPI?.creator?.selectFile();
328
+
329
+ if (result?.cancelled) {
330
+ setFileLoading(false);
331
+ return;
332
+ }
333
+
334
+ if (result?.success && result.file) {
335
+ setSelectedFile(result.file);
336
+ setOptions((prev) => ({
337
+ ...prev,
338
+ title: result.file.title || prev.title,
339
+ artist: result.file.artist || prev.artist,
340
+ // Auto-populate lyrics if found (prefer plain text)
341
+ referenceLyrics: result.lyrics?.plainLyrics || prev.referenceLyrics,
342
+ }));
343
+ setError(null);
344
+ } else {
345
+ setError(result?.error || 'Failed to select file');
346
+ }
347
+ } catch (err) {
348
+ setError(err.message);
349
+ } finally {
350
+ setFileLoading(false);
351
+ }
352
+ };
353
+
354
+ const handleSearchLyrics = async () => {
355
+ if (!options.title) {
356
+ setError('Please enter a title to search for lyrics');
357
+ return;
358
+ }
359
+
360
+ try {
361
+ const result = await window.kaiAPI?.creator?.searchLyrics(options.title, options.artist);
362
+
363
+ if (result?.success) {
364
+ setOptions((prev) => ({
365
+ ...prev,
366
+ // Prefer plain lyrics (no timestamps) for Whisper reference
367
+ referenceLyrics: result.plainLyrics || '',
368
+ }));
369
+ setError(null);
370
+ } else {
371
+ setError(result?.error || 'No lyrics found');
372
+ }
373
+ } catch (err) {
374
+ setError(err.message);
375
+ }
376
+ };
377
+
378
+ const handleStartConversion = async () => {
379
+ if (!selectedFile) {
380
+ setError('Please select a file first');
381
+ return;
382
+ }
383
+
384
+ setStatus('creating');
385
+ setError(null);
386
+ setConsoleLog([]); // Clear console log
387
+ setConversionProgress({ step: 'starting', message: 'Starting conversion...', progress: 0 });
388
+ conversionStartTimeRef.current = Date.now();
389
+
390
+ try {
391
+ // Get output directory based on settings
392
+ let outputDir = undefined; // Default: same directory as source file
393
+ if (outputToSongsFolder) {
394
+ const songsFolder = await window.kaiAPI?.library?.getSongsFolder?.();
395
+ if (songsFolder) {
396
+ outputDir = songsFolder;
397
+ }
398
+ }
399
+
400
+ // Determine if this is a lyrics-only conversion (stem file without lyrics)
401
+ const lyricsOnlyMode = selectedFile.hasStems && !selectedFile.hasLyrics;
402
+ setIsLyricsOnlyMode(lyricsOnlyMode);
403
+
404
+ const result = await window.kaiAPI?.creator?.startConversion({
405
+ inputPath: selectedFile.path,
406
+ title: options.title || selectedFile.title,
407
+ artist: options.artist || selectedFile.artist,
408
+ tags: selectedFile.tags || {}, // Preserve all original ID3 tags
409
+ numStems: options.numStems,
410
+ whisperModel: whisperModel,
411
+ language: options.language,
412
+ enableCrepe: enableCrepe,
413
+ referenceLyrics: options.referenceLyrics,
414
+ outputDir,
415
+ // Lyrics-only mode options
416
+ lyricsOnlyMode,
417
+ vocalsTrackIndex: selectedFile.vocalsTrackIndex ?? 4,
418
+ });
419
+
420
+ if (!result?.success) {
421
+ setError(result?.error || 'Conversion failed');
422
+ setStatus('ready');
423
+ setConversionProgress(null);
424
+ }
425
+ } catch (err) {
426
+ setError(err.message);
427
+ setStatus('ready');
428
+ setConversionProgress(null);
429
+ }
430
+ };
431
+
432
+ const handleCancelConversion = async () => {
433
+ try {
434
+ await window.kaiAPI?.creator?.cancelConversion();
435
+ setStatus('ready');
436
+ setConversionProgress(null);
437
+ } catch (err) {
438
+ setError(err.message);
439
+ }
440
+ };
441
+
442
+ const handleCreateAnother = () => {
443
+ setSelectedFile(null);
444
+ setCompletedFile(null);
445
+ setLlmStats(null);
446
+ setSongDuration(null);
447
+ setProcessingTime(null);
448
+ setIsLyricsOnlyMode(false);
449
+ conversionStartTimeRef.current = null;
450
+ setOptions({
451
+ title: '',
452
+ artist: '',
453
+ numStems: 4,
454
+ language: 'en',
455
+ referenceLyrics: '',
456
+ });
457
+ setStatus('ready');
458
+ };
459
+
460
+ const handleOpenInEditor = async () => {
461
+ if (!completedFile) return;
462
+
463
+ try {
464
+ // Load the song into the editor
465
+ await window.kaiAPI?.editor?.loadKai?.(completedFile);
466
+
467
+ // Switch to the editor tab by manipulating DOM (same pattern as TabNavigation)
468
+ document.querySelectorAll('[id$="-tab"]').forEach((pane) => {
469
+ pane.classList.add('hidden');
470
+ pane.classList.remove('block', 'flex');
471
+ });
472
+ const editorPane = document.getElementById('editor-tab');
473
+ if (editorPane) {
474
+ editorPane.classList.remove('hidden');
475
+ editorPane.classList.add('block');
476
+ }
477
+ } catch (err) {
478
+ console.error('Failed to open in editor:', err);
479
+ setError(`Failed to open in editor: ${err.message}`);
480
+ }
481
+ };
482
+
483
+ const handleSaveLLMSettings = async () => {
484
+ try {
485
+ await window.kaiAPI?.creator?.saveLLMSettings(llmSettings);
486
+ setLlmTestResult({ success: true, message: 'Settings saved!' });
487
+ setTimeout(() => setLlmTestResult(null), 3000);
488
+ } catch (err) {
489
+ setLlmTestResult({ success: false, message: err.message });
490
+ }
491
+ };
492
+
493
+ const handleTestLLMConnection = async () => {
494
+ if (!llmSettings.apiKey && llmSettings.provider !== 'lmstudio') {
495
+ setLlmTestResult({ success: false, message: 'API key required' });
496
+ return;
497
+ }
498
+
499
+ setLlmTestResult({ testing: true, message: 'Testing connection...' });
500
+
501
+ try {
502
+ const result = await window.kaiAPI?.creator?.testLLMConnection(llmSettings);
503
+ setLlmTestResult(result);
504
+ setTimeout(() => setLlmTestResult(null), 3000);
505
+ } catch (err) {
506
+ setLlmTestResult({ success: false, message: err.message });
507
+ }
508
+ };
509
+
510
+ const formatDuration = (seconds) => {
511
+ if (!seconds) return '--:--';
512
+ const mins = Math.floor(seconds / 60);
513
+ const secs = Math.floor(seconds % 60);
514
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
515
+ };
516
+
517
+ if (status === 'checking') {
518
+ return (
519
+ <div className="flex items-center justify-center h-full">
520
+ <Spinner message="Checking AI tools..." />
521
+ </div>
522
+ );
523
+ }
524
+
525
+ // Component display configuration
526
+ const componentDisplay = [
527
+ { key: 'python', label: 'Python 3.10+' },
528
+ { key: 'pytorch', label: 'PyTorch' },
529
+ { key: 'soundfile', label: 'SoundFile (Audio)' },
530
+ { key: 'demucs', label: 'Demucs (Stems)' },
531
+ { key: 'whisper', label: 'Whisper (Lyrics)' },
532
+ { key: 'crepe', label: 'CREPE (Pitch)' },
533
+ { key: 'ffmpeg', label: 'FFmpeg' },
534
+ { key: 'whisperModel', label: 'Whisper Model' },
535
+ { key: 'demucsModel', label: 'Demucs Model' },
536
+ ];
537
+
538
+ if (status === 'installing') {
539
+ return (
540
+ <div className="flex items-center justify-center h-full p-8">
541
+ <div className="max-w-lg text-center">
542
+ <div className="text-6xl mb-6">⚡</div>
543
+ <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
544
+ Installing AI Tools
545
+ </h2>
546
+
547
+ <div className="mb-6">
548
+ <div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
549
+ <div
550
+ className="h-full bg-blue-600 transition-all duration-300"
551
+ style={{ width: `${installProgress?.progress || 0}%` }}
552
+ />
553
+ </div>
554
+ <p className="text-gray-600 dark:text-gray-400 mt-2 break-words">
555
+ {installProgress?.message || 'Starting...'}
556
+ </p>
557
+ </div>
558
+
559
+ <button
560
+ className={STYLES.btnSecondary}
561
+ onClick={() => window.kaiAPI?.creator?.cancelInstall()}
562
+ >
563
+ Cancel
564
+ </button>
565
+ </div>
566
+ </div>
567
+ );
568
+ }
569
+
570
+ if (status === 'setup') {
571
+ return (
572
+ <div className="flex items-center justify-center h-full p-8">
573
+ <div className="max-w-lg text-center">
574
+ <div className="text-6xl mb-6">⚡</div>
575
+ <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
576
+ AI Tools Setup Required
577
+ </h2>
578
+ <p className="text-gray-600 dark:text-gray-400 mb-6">
579
+ To create karaoke files, you need to install AI processing tools. This includes stem
580
+ separation (Demucs), lyrics transcription (Whisper), and pitch detection (CREPE).
581
+ </p>
582
+
583
+ <ErrorDisplay error={error} />
584
+
585
+ <div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 mb-6 text-left">
586
+ <div className="space-y-2">
587
+ {componentDisplay.map(({ key, label }) => {
588
+ const comp = components?.[key];
589
+ const isInstalled = comp?.installed;
590
+ const version = comp?.version;
591
+ const device = comp?.device;
592
+
593
+ return (
594
+ <div key={key} className="flex items-center justify-between">
595
+ <span className="text-gray-700 dark:text-gray-300">{label}</span>
596
+ <span className={isInstalled ? 'text-green-500' : 'text-gray-400'}>
597
+ {isInstalled
598
+ ? `✓ ${version || ''}${device ? ` (${device})` : ''}`.trim() ||
599
+ '✓ Installed'
600
+ : '○ Not installed'}
601
+ </span>
602
+ </div>
603
+ );
604
+ })}
605
+ </div>
606
+ </div>
607
+
608
+ <div className="text-sm text-gray-500 dark:text-gray-400 mb-6">
609
+ <p>Download size: ~2-4 GB</p>
610
+ <p>Disk space required: ~5 GB</p>
611
+ </div>
612
+
613
+ <button className={STYLES.btnPrimary} onClick={handleInstall}>
614
+ Install AI Tools
615
+ </button>
616
+ </div>
617
+ </div>
618
+ );
619
+ }
620
+
621
+ // Creating state - show progress
622
+ if (status === 'creating') {
623
+ return (
624
+ <div className="h-full flex flex-col p-8">
625
+ <div className="max-w-4xl mx-auto w-full">
626
+ <div className="text-center mb-6">
627
+ <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
628
+ {isLyricsOnlyMode
629
+ ? 'Adding Lyrics to Stem File 🎤'
630
+ : 'Creating Stems+Karaoke File ⚡'}
631
+ </h2>
632
+
633
+ <div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 mb-4">
634
+ <p className="text-gray-700 dark:text-gray-300 font-medium">
635
+ <SongTitle artist={options.artist} title={options.title} />
636
+ </p>
637
+ </div>
638
+
639
+ <div className="mb-6">
640
+ <div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
641
+ <div
642
+ className="h-full bg-blue-600 transition-all duration-300"
643
+ style={{ width: `${conversionProgress?.progress || 0}%` }}
644
+ />
645
+ </div>
646
+ <p className="text-gray-600 dark:text-gray-400 mt-3">
647
+ {conversionProgress?.message || 'Starting...'}
648
+ </p>
649
+ </div>
650
+
651
+ {/* Console Log Panel */}
652
+ {consoleLog.length > 0 && (
653
+ <div className="mb-6 bg-gray-900 dark:bg-black rounded-lg p-4 h-48 overflow-y-auto">
654
+ <div className="text-xs font-mono text-green-400 whitespace-pre-wrap select-text leading-tight">
655
+ {consoleLog.map((line, i) => (
656
+ <div key={i}>{line}</div>
657
+ ))}
658
+ <div ref={consoleEndRef} />
659
+ </div>
660
+ </div>
661
+ )}
662
+
663
+ <button className={STYLES.btnSecondary} onClick={handleCancelConversion}>
664
+ Cancel
665
+ </button>
666
+ </div>
667
+ </div>
668
+ </div>
669
+ );
670
+ }
671
+
672
+ // Complete state - show success
673
+ if (status === 'complete') {
674
+ return (
675
+ <div className="flex items-center justify-center h-full p-8">
676
+ <div className="max-w-lg w-full text-center">
677
+ <div className="text-6xl mb-6">✅</div>
678
+ <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
679
+ {isLyricsOnlyMode ? 'Lyrics Added!' : 'Karaoke File Created!'}
680
+ </h2>
681
+
682
+ <div className="bg-green-100 dark:bg-green-900/30 rounded-lg p-4 mb-6">
683
+ <p className="text-green-700 dark:text-green-400 font-medium">
684
+ <SongTitle artist={options.artist} title={options.title} />
685
+ </p>
686
+
687
+ {/* Processing Stats */}
688
+ <div className="text-sm text-gray-600 dark:text-gray-400 mt-2 space-y-1">
689
+ {songDuration && <p>🎵 Song length: {formatDuration(songDuration)}</p>}
690
+ {processingTime && (
691
+ <p>
692
+ ⏱️ Processing time: {formatDuration(processingTime)}
693
+ {songDuration && (
694
+ <span className="ml-2 text-xs">
695
+ ({(songDuration / processingTime).toFixed(1)}x realtime)
696
+ </span>
697
+ )}
698
+ </p>
699
+ )}
700
+ </div>
701
+
702
+ {/* LLM Stats */}
703
+ {llmStats?.failed ? (
704
+ <div className="mt-2">
705
+ <p className="text-sm text-yellow-600 dark:text-yellow-400">
706
+ ⚠️ AI correction failed ({formatProviderName(llmStats.provider)}):{' '}
707
+ {llmStats.error || 'Unknown error'}
708
+ </p>
709
+ </div>
710
+ ) : llmStats && llmStats.corrections_applied > 0 ? (
711
+ <div className="mt-2 space-y-2">
712
+ <p className="text-sm text-green-600 dark:text-green-400">
713
+ ✨ {formatProviderName(llmStats.provider)}: {llmStats.suggestions_made} suggestion
714
+ {llmStats.suggestions_made !== 1 ? 's' : ''} ({llmStats.corrections_applied}{' '}
715
+ applied
716
+ {llmStats.missing_lines_suggested > 0 &&
717
+ `, ${llmStats.missing_lines_suggested} for review`}
718
+ )
719
+ </p>
720
+ {llmStats.corrections && llmStats.corrections.length > 0 && (
721
+ <details className="text-xs text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800 rounded p-2">
722
+ <summary className="cursor-pointer font-semibold">
723
+ ✅ {llmStats.corrections.length} correction
724
+ {llmStats.corrections.length !== 1 ? 's' : ''} applied
725
+ </summary>
726
+ <ul className="mt-2 space-y-1 ml-4 list-disc max-h-40 overflow-y-auto">
727
+ {llmStats.corrections.map((corr, i) => (
728
+ <li key={i}>
729
+ Line #{corr.line_num}:{' '}
730
+ <span className="text-red-600 dark:text-red-400 line-through">
731
+ {corr.old_text}
732
+ </span>{' '}
733
+ →{' '}
734
+ <span className="text-green-600 dark:text-green-400">
735
+ {corr.new_text}
736
+ </span>
737
+ </li>
738
+ ))}
739
+ </ul>
740
+ </details>
741
+ )}
742
+ <MissingLinesDetails missingLines={llmStats.missing_lines} />
743
+ </div>
744
+ ) : llmStats && llmStats.corrections_applied === 0 ? (
745
+ <div className="mt-2">
746
+ <p className="text-sm text-gray-600 dark:text-gray-400">
747
+ ✓ {formatProviderName(llmStats.provider)}: No corrections applied
748
+ {llmStats.missing_lines_suggested > 0
749
+ ? `, ${llmStats.missing_lines_suggested} missing line${llmStats.missing_lines_suggested !== 1 ? 's' : ''} suggested`
750
+ : ''}
751
+ </p>
752
+ <MissingLinesDetails missingLines={llmStats.missing_lines} />
753
+ </div>
754
+ ) : (
755
+ <p className="text-sm text-gray-600 dark:text-gray-400 mt-2">
756
+ AI correction not used
757
+ </p>
758
+ )}
759
+
760
+ <p className="text-sm text-green-600 dark:text-green-500 mt-2 break-all">
761
+ {completedFile}
762
+ </p>
763
+ </div>
764
+
765
+ <div className="flex gap-4 justify-center">
766
+ {outputToSongsFolder && (
767
+ <button className={STYLES.btnPrimary} onClick={handleOpenInEditor}>
768
+ Open in Editor
769
+ </button>
770
+ )}
771
+ <button
772
+ className={outputToSongsFolder ? STYLES.btnSecondary : STYLES.btnPrimary}
773
+ onClick={handleCreateAnother}
774
+ >
775
+ Create Another
776
+ </button>
777
+ </div>
778
+ </div>
779
+ </div>
780
+ );
781
+ }
782
+
783
+ // Ready state - show create interface
784
+ return (
785
+ <div className="h-full overflow-y-auto p-6">
786
+ <div className="max-w-2xl mx-auto">
787
+ {/* Sub-tab navigation */}
788
+ <div className="flex border-b border-gray-300 dark:border-gray-600 mb-6">
789
+ <button
790
+ className={`px-4 py-2 font-medium transition-colors ${
791
+ activeSubTab === 'create'
792
+ ? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
793
+ : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
794
+ }`}
795
+ onClick={() => setActiveSubTab('create')}
796
+ >
797
+ Create
798
+ </button>
799
+ <button
800
+ className={`px-4 py-2 font-medium transition-colors ${
801
+ activeSubTab === 'settings'
802
+ ? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
803
+ : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
804
+ }`}
805
+ onClick={() => setActiveSubTab('settings')}
806
+ >
807
+ Settings
808
+ </button>
809
+ </div>
810
+
811
+ <ErrorDisplay error={error} onDismiss={() => setError(null)} />
812
+
813
+ {/* Settings Sub-tab */}
814
+ {activeSubTab === 'settings' && (
815
+ <div className="space-y-6">
816
+ <h2 className="text-2xl font-bold text-gray-900 dark:text-white">Creator Settings</h2>
817
+
818
+ {/* Output Location */}
819
+ <div className={STYLES.card}>
820
+ <h3 className={STYLES.sectionTitle}>Output Location</h3>
821
+ <label className="flex items-center cursor-pointer">
822
+ <input
823
+ type="checkbox"
824
+ className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
825
+ checked={outputToSongsFolder}
826
+ onChange={async (e) => {
827
+ const value = e.target.checked;
828
+ setOutputToSongsFolder(value);
829
+ await window.kaiAPI?.settings?.set('creator.outputToSongsFolder', value);
830
+ }}
831
+ />
832
+ <span className="ml-2 text-sm text-gray-700 dark:text-gray-300">
833
+ Save output files to karaoke songs folder
834
+ </span>
835
+ </label>
836
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
837
+ When enabled, created .stem.m4a files will be saved to your configured songs library
838
+ folder instead of next to the source file.
839
+ </p>
840
+ </div>
841
+
842
+ {/* Whisper Model */}
843
+ <div className={STYLES.card}>
844
+ <h3 className={STYLES.sectionTitle}>Whisper Model</h3>
845
+ <div className="w-64">
846
+ <PortalSelect
847
+ value={whisperModel}
848
+ onChange={async (e) => {
849
+ const value = e.target.value;
850
+ setWhisperModel(value);
851
+ await window.kaiAPI?.settings?.set('creator.whisperModel', value);
852
+ }}
853
+ options={[
854
+ { value: 'large-v3-turbo', label: 'Large V3 Turbo (recommended)' },
855
+ { value: 'large-v3', label: 'Large V3 (slower, slightly better)' },
856
+ { value: 'medium', label: 'Medium (faster)' },
857
+ { value: 'small', label: 'Small (fastest)' },
858
+ ]}
859
+ />
860
+ </div>
861
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
862
+ Larger models are more accurate but slower. Large V3 Turbo is recommended for most
863
+ users.
864
+ </p>
865
+ </div>
866
+
867
+ {/* Pitch Detection */}
868
+ <div className={STYLES.card}>
869
+ <h3 className={STYLES.sectionTitle}>Pitch Detection</h3>
870
+ <label className="flex items-center cursor-pointer">
871
+ <input
872
+ type="checkbox"
873
+ className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
874
+ checked={enableCrepe}
875
+ onChange={async (e) => {
876
+ const value = e.target.checked;
877
+ setEnableCrepe(value);
878
+ await window.kaiAPI?.settings?.set('creator.enableCrepe', value);
879
+ }}
880
+ />
881
+ <span className="ml-2 text-sm text-gray-700 dark:text-gray-300">
882
+ Enable pitch detection (CREPE)
883
+ </span>
884
+ </label>
885
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
886
+ Analyzes vocal pitch for karaoke scoring features. Adds processing time but enables
887
+ pitch visualization.
888
+ </p>
889
+ </div>
890
+
891
+ {/* LLM Settings */}
892
+ <div className={STYLES.card}>
893
+ <h3 className={STYLES.sectionTitle}>AI Lyrics Correction</h3>
894
+
895
+ <div className="space-y-4">
896
+ <label className="flex items-center cursor-pointer">
897
+ <input
898
+ type="checkbox"
899
+ className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
900
+ checked={llmSettings.enabled}
901
+ onChange={(e) =>
902
+ setLlmSettings((prev) => ({ ...prev, enabled: e.target.checked }))
903
+ }
904
+ />
905
+ <span className="ml-2 text-sm text-gray-700 dark:text-gray-300">
906
+ Use AI to improve lyrics accuracy (compares Whisper output to reference lyrics)
907
+ </span>
908
+ </label>
909
+
910
+ {llmSettings.enabled && (
911
+ <>
912
+ <div>
913
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
914
+ AI Provider
915
+ </label>
916
+ <PortalSelect
917
+ value={llmSettings.provider}
918
+ onChange={(e) =>
919
+ setLlmSettings((prev) => ({ ...prev, provider: e.target.value }))
920
+ }
921
+ options={[
922
+ {
923
+ value: 'lmstudio',
924
+ label: 'Local LLM Server (LM Studio, Ollama, etc.)',
925
+ },
926
+ { value: 'anthropic', label: 'Anthropic Claude' },
927
+ { value: 'openai', label: 'OpenAI' },
928
+ { value: 'gemini', label: 'Google Gemini' },
929
+ ]}
930
+ />
931
+ </div>
932
+
933
+ {llmSettings.provider !== 'lmstudio' && (
934
+ <div>
935
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
936
+ API Key
937
+ </label>
938
+ <input
939
+ type="password"
940
+ className={STYLES.input}
941
+ value={llmSettings.apiKey}
942
+ onChange={(e) =>
943
+ setLlmSettings((prev) => ({ ...prev, apiKey: e.target.value }))
944
+ }
945
+ placeholder={`Enter ${llmSettings.provider} API key...`}
946
+ />
947
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
948
+ {llmSettings.provider === 'anthropic' && (
949
+ <>
950
+ Get your key from{' '}
951
+ <a
952
+ href="https://console.anthropic.com/"
953
+ target="_blank"
954
+ rel="noopener noreferrer"
955
+ className="text-blue-600 dark:text-blue-400 hover:underline"
956
+ >
957
+ console.anthropic.com
958
+ </a>
959
+ </>
960
+ )}
961
+ {llmSettings.provider === 'openai' && (
962
+ <>
963
+ Get your key from{' '}
964
+ <a
965
+ href="https://platform.openai.com/api-keys"
966
+ target="_blank"
967
+ rel="noopener noreferrer"
968
+ className="text-blue-600 dark:text-blue-400 hover:underline"
969
+ >
970
+ platform.openai.com
971
+ </a>
972
+ </>
973
+ )}
974
+ {llmSettings.provider === 'gemini' && (
975
+ <>
976
+ Get your key from{' '}
977
+ <a
978
+ href="https://aistudio.google.com/app/apikey"
979
+ target="_blank"
980
+ rel="noopener noreferrer"
981
+ className="text-blue-600 dark:text-blue-400 hover:underline"
982
+ >
983
+ Google AI Studio
984
+ </a>
985
+ </>
986
+ )}
987
+ </p>
988
+ </div>
989
+ )}
990
+
991
+ {llmSettings.provider === 'lmstudio' && (
992
+ <div>
993
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
994
+ Server Base URL
995
+ </label>
996
+ <input
997
+ type="text"
998
+ className={STYLES.input}
999
+ value={llmSettings.baseUrl}
1000
+ onChange={(e) =>
1001
+ setLlmSettings((prev) => ({ ...prev, baseUrl: e.target.value }))
1002
+ }
1003
+ placeholder="http://localhost:1234/v1"
1004
+ />
1005
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1006
+ OpenAI-compatible API endpoint (LM Studio, Ollama, text-generation-webui,
1007
+ etc.)
1008
+ </p>
1009
+ </div>
1010
+ )}
1011
+
1012
+ <div className="flex gap-2">
1013
+ <button
1014
+ className={STYLES.btnSuccess}
1015
+ onClick={handleTestLLMConnection}
1016
+ disabled={llmTestResult?.testing}
1017
+ >
1018
+ {llmTestResult?.testing ? 'Testing...' : 'Test Connection'}
1019
+ </button>
1020
+ <button
1021
+ className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
1022
+ onClick={handleSaveLLMSettings}
1023
+ >
1024
+ Save Settings
1025
+ </button>
1026
+ </div>
1027
+
1028
+ {llmTestResult && !llmTestResult.testing && (
1029
+ <div
1030
+ className={`px-4 py-2 rounded-lg ${
1031
+ llmTestResult.success
1032
+ ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
1033
+ : 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
1034
+ }`}
1035
+ >
1036
+ {llmTestResult.success ? '✓' : '✗'}{' '}
1037
+ {llmTestResult.message || llmTestResult.error}
1038
+ </div>
1039
+ )}
1040
+ </>
1041
+ )}
1042
+ </div>
1043
+ </div>
1044
+ </div>
1045
+ )}
1046
+
1047
+ {/* Create Sub-tab */}
1048
+ {activeSubTab === 'create' && (
1049
+ <>
1050
+ <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
1051
+ Create Stems+Karaoke File
1052
+ </h2>
1053
+
1054
+ {/* File Selection */}
1055
+ <div className={`${STYLES.card} mb-6`}>
1056
+ <h3 className={STYLES.sectionTitle}>1. Select Audio File</h3>
1057
+
1058
+ {fileLoading ? (
1059
+ <div className="flex items-center justify-center py-8">
1060
+ <Spinner size="sm" message="Reading file info & searching lyrics..." />
1061
+ </div>
1062
+ ) : selectedFile ? (
1063
+ <div>
1064
+ <div className="flex items-center justify-between">
1065
+ <div className="flex-1 min-w-0">
1066
+ <p className="text-gray-900 dark:text-white font-medium truncate">
1067
+ {selectedFile.name}
1068
+ </p>
1069
+ <p className="text-sm text-gray-600 dark:text-gray-400">
1070
+ {formatDuration(selectedFile.duration)} •{' '}
1071
+ {selectedFile.codec?.toUpperCase() || 'Unknown'}{' '}
1072
+ {selectedFile.isVideo && '• Video'}
1073
+ </p>
1074
+ </div>
1075
+ <button
1076
+ className="ml-4 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors"
1077
+ onClick={handleSelectFile}
1078
+ >
1079
+ Change
1080
+ </button>
1081
+ </div>
1082
+ {/* Stem file detection indicator */}
1083
+ {selectedFile.hasStems && !selectedFile.hasLyrics && (
1084
+ <div className="mt-3 px-3 py-2 bg-purple-100 dark:bg-purple-900/30 border border-purple-300 dark:border-purple-700 rounded-lg">
1085
+ <p className="text-sm text-purple-700 dark:text-purple-300 font-medium">
1086
+ 🎛️ Stem file detected ({selectedFile.audioStreamCount} tracks)
1087
+ </p>
1088
+ <p className="text-xs text-purple-600 dark:text-purple-400 mt-1">
1089
+ Stems: {selectedFile.stemNames?.join(', ')} • Will add lyrics only (no stem
1090
+ separation needed)
1091
+ </p>
1092
+ </div>
1093
+ )}
1094
+ {selectedFile.hasStems && selectedFile.hasLyrics && (
1095
+ <div className="mt-3 px-3 py-2 bg-yellow-100 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded-lg">
1096
+ <p className="text-sm text-yellow-700 dark:text-yellow-300 font-medium">
1097
+ ⚠️ This file already has karaoke lyrics
1098
+ </p>
1099
+ <p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
1100
+ Processing will replace existing lyrics with new transcription
1101
+ </p>
1102
+ </div>
1103
+ )}
1104
+ </div>
1105
+ ) : (
1106
+ <button
1107
+ className="w-full px-6 py-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
1108
+ onClick={handleSelectFile}
1109
+ >
1110
+ <div className="text-gray-600 dark:text-gray-400">
1111
+ <div className="text-3xl mb-2">🎵</div>
1112
+ <p>Click to select an audio or video file</p>
1113
+ <p className="text-sm mt-1">
1114
+ MP3, WAV, FLAC, OGG, M4A, MP4, MKV, AVI, MOV, WEBM
1115
+ </p>
1116
+ </div>
1117
+ </button>
1118
+ )}
1119
+ </div>
1120
+
1121
+ {/* Song Info */}
1122
+ <div className={`${STYLES.card} mb-6`}>
1123
+ <h3 className={STYLES.sectionTitle}>2. Song Information</h3>
1124
+
1125
+ {fileLoading ? (
1126
+ <div className="flex items-center justify-center py-8">
1127
+ <Spinner size="sm" message="Loading song metadata..." />
1128
+ </div>
1129
+ ) : (
1130
+ <>
1131
+ <div className="grid grid-cols-3 gap-4 mb-4">
1132
+ <div>
1133
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1134
+ Title
1135
+ </label>
1136
+ <input
1137
+ type="text"
1138
+ className={STYLES.input}
1139
+ value={options.title}
1140
+ onChange={(e) => setOptions((prev) => ({ ...prev, title: e.target.value }))}
1141
+ placeholder="Song title"
1142
+ />
1143
+ </div>
1144
+ <div>
1145
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1146
+ Artist
1147
+ </label>
1148
+ <input
1149
+ type="text"
1150
+ className={STYLES.input}
1151
+ value={options.artist}
1152
+ onChange={(e) =>
1153
+ setOptions((prev) => ({ ...prev, artist: e.target.value }))
1154
+ }
1155
+ placeholder="Artist name"
1156
+ />
1157
+ </div>
1158
+ <div>
1159
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1160
+ Language
1161
+ </label>
1162
+ <PortalSelect
1163
+ value={options.language}
1164
+ onChange={(e) =>
1165
+ setOptions((prev) => ({ ...prev, language: e.target.value }))
1166
+ }
1167
+ options={[
1168
+ { value: 'en', label: 'English' },
1169
+ { value: 'es', label: 'Spanish' },
1170
+ { value: 'fr', label: 'French' },
1171
+ { value: 'de', label: 'German' },
1172
+ { value: 'it', label: 'Italian' },
1173
+ { value: 'pt', label: 'Portuguese' },
1174
+ { value: 'ja', label: 'Japanese' },
1175
+ { value: 'ko', label: 'Korean' },
1176
+ { value: 'zh', label: 'Chinese' },
1177
+ ]}
1178
+ />
1179
+ </div>
1180
+ </div>
1181
+
1182
+ <div>
1183
+ <div className="flex items-center justify-between mb-1">
1184
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
1185
+ Reference Lyrics (optional)
1186
+ </label>
1187
+ <button
1188
+ className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
1189
+ onClick={handleSearchLyrics}
1190
+ >
1191
+ Search LRCLIB
1192
+ </button>
1193
+ </div>
1194
+ <textarea
1195
+ className={`${STYLES.input} h-24 resize-none`}
1196
+ value={options.referenceLyrics}
1197
+ onChange={(e) =>
1198
+ setOptions((prev) => ({ ...prev, referenceLyrics: e.target.value }))
1199
+ }
1200
+ placeholder="Paste lyrics here to improve transcription accuracy..."
1201
+ />
1202
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1203
+ Reference lyrics help Whisper recognize song-specific vocabulary
1204
+ </p>
1205
+ </div>
1206
+ </>
1207
+ )}
1208
+ </div>
1209
+
1210
+ {/* Create Button */}
1211
+ <div className="text-center">
1212
+ <button
1213
+ className={`px-8 py-4 ${
1214
+ selectedFile?.hasStems && !selectedFile?.hasLyrics
1215
+ ? 'bg-purple-600 hover:bg-purple-700'
1216
+ : 'bg-blue-600 hover:bg-blue-700'
1217
+ } disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-bold text-lg rounded-lg transition-colors`}
1218
+ onClick={handleStartConversion}
1219
+ disabled={!selectedFile}
1220
+ >
1221
+ {selectedFile?.hasStems && !selectedFile?.hasLyrics
1222
+ ? 'Add Lyrics to Stem File'
1223
+ : 'Create Stems+Karaoke File'}
1224
+ </button>
1225
+ <p className="text-sm text-gray-500 dark:text-gray-400 mt-3">
1226
+ {selectedFile?.hasStems && !selectedFile?.hasLyrics
1227
+ ? 'Lyrics-only mode is much faster (typically under 1 minute)'
1228
+ : 'Processing time depends on song length and your hardware (typically 2-10 minutes)'}
1229
+ </p>
1230
+ </div>
1231
+ </>
1232
+ )}
1233
+ </div>
1234
+ </div>
1235
+ );
1236
+ }