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.
- package/README.md +558 -0
- package/bin/loukai.js +32 -0
- package/package.json +243 -0
- package/src/main/appState.js +250 -0
- package/src/main/audioEngine.js +478 -0
- package/src/main/creator/conversionService.js +503 -0
- package/src/main/creator/downloadManager.js +1128 -0
- package/src/main/creator/ffmpegService.js +487 -0
- package/src/main/creator/installLogger.js +51 -0
- package/src/main/creator/keyDetection.js +212 -0
- package/src/main/creator/llmService.js +370 -0
- package/src/main/creator/lrclibService.js +340 -0
- package/src/main/creator/python/crepe_runner.py +189 -0
- package/src/main/creator/python/demucs_runner.py +158 -0
- package/src/main/creator/python/whisper_runner.py +172 -0
- package/src/main/creator/pythonRunner.js +268 -0
- package/src/main/creator/stemBuilder.js +491 -0
- package/src/main/creator/systemChecker.js +474 -0
- package/src/main/handlers/appHandlers.js +45 -0
- package/src/main/handlers/audioHandlers.js +33 -0
- package/src/main/handlers/autotuneHandlers.js +28 -0
- package/src/main/handlers/canvasHandlers.js +84 -0
- package/src/main/handlers/creatorHandlers.js +159 -0
- package/src/main/handlers/editorHandlers.js +98 -0
- package/src/main/handlers/effectsHandlers.js +100 -0
- package/src/main/handlers/fileHandlers.js +45 -0
- package/src/main/handlers/index.js +78 -0
- package/src/main/handlers/libraryHandlers.js +96 -0
- package/src/main/handlers/mixerHandlers.js +64 -0
- package/src/main/handlers/playerHandlers.js +39 -0
- package/src/main/handlers/preferencesHandlers.js +46 -0
- package/src/main/handlers/queueHandlers.js +81 -0
- package/src/main/handlers/rendererHandlers.js +63 -0
- package/src/main/handlers/settingsHandlers.js +42 -0
- package/src/main/handlers/webServerHandlers.js +105 -0
- package/src/main/main.js +2351 -0
- package/src/main/preload.js +252 -0
- package/src/main/settingsManager.js +139 -0
- package/src/main/statePersistence.js +193 -0
- package/src/main/utils/pathValidator.js +112 -0
- package/src/main/webServer.js +2535 -0
- package/src/native/autotune.js +417 -0
- package/src/renderer/adapters/ElectronBridge.js +677 -0
- package/src/renderer/canvas.html +80 -0
- package/src/renderer/components/App.jsx +303 -0
- package/src/renderer/components/AppRoot.jsx +37 -0
- package/src/renderer/components/AudioDeviceSettings.jsx +145 -0
- package/src/renderer/components/EffectsPanelWrapper.jsx +267 -0
- package/src/renderer/components/MixerTab.jsx +233 -0
- package/src/renderer/components/MixerTabWrapper.jsx +31 -0
- package/src/renderer/components/PortalSelect.jsx +239 -0
- package/src/renderer/components/QueueTab.jsx +116 -0
- package/src/renderer/components/RequestsListWrapper.jsx +78 -0
- package/src/renderer/components/ServerTab.jsx +472 -0
- package/src/renderer/components/SongInfoBarWrapper.jsx +77 -0
- package/src/renderer/components/StatusBar.jsx +92 -0
- package/src/renderer/components/TabNavigation.jsx +77 -0
- package/src/renderer/components/TransportControlsWrapper.jsx +69 -0
- package/src/renderer/components/creator/CreateTab.jsx +1236 -0
- package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js +2 -0
- package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js.map +1 -0
- package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js +2 -0
- package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js.map +1 -0
- package/src/renderer/dist/assets/player-DVrqp7N5.js +3 -0
- package/src/renderer/dist/assets/player-DVrqp7N5.js.map +1 -0
- package/src/renderer/dist/assets/songLoaders-BaTgGib4.js +2 -0
- package/src/renderer/dist/assets/songLoaders-BaTgGib4.js.map +1 -0
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js +2 -0
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js.map +1 -0
- package/src/renderer/dist/js/autoTuneWorklet.js +224 -0
- package/src/renderer/dist/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/dist/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/dist/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/dist/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/dist/renderer.css +1 -0
- package/src/renderer/dist/renderer.js +62 -0
- package/src/renderer/dist/renderer.js.map +1 -0
- package/src/renderer/dist/renderer.woff2 +0 -0
- package/src/renderer/hooks/useKeyboardShortcuts.js +154 -0
- package/src/renderer/index.html +24 -0
- package/src/renderer/index.html.backup +372 -0
- package/src/renderer/js/PlayerInterface.js +267 -0
- package/src/renderer/js/autoTuneWorklet.js +224 -0
- package/src/renderer/js/butterchurnVerify.js +46 -0
- package/src/renderer/js/canvas-app.js +114 -0
- package/src/renderer/js/cdgPlayer.js +685 -0
- package/src/renderer/js/kaiPlayer.js +1200 -0
- package/src/renderer/js/karaokeRenderer.js +3392 -0
- package/src/renderer/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/js/microphoneEngine.js +656 -0
- package/src/renderer/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/js/player.js +232 -0
- package/src/renderer/js/referencePitchTracker.js +130 -0
- package/src/renderer/js/songLoaders.js +334 -0
- package/src/renderer/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/js/webrtcManager.js +511 -0
- package/src/renderer/lib/butterchurn.min.js +6739 -0
- package/src/renderer/lib/butterchurnPresets.min.js +1 -0
- package/src/renderer/lib/cdgraphics-wrapper.js +16 -0
- package/src/renderer/lib/cdgraphics.js +299 -0
- package/src/renderer/public/js/autoTuneWorklet.js +224 -0
- package/src/renderer/public/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/public/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/public/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/public/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/react-entry.jsx +44 -0
- package/src/renderer/styles/tailwind.css +106 -0
- package/src/renderer/utils/qrCodeGenerator.js +98 -0
- package/src/renderer/vite.config.js +31 -0
- package/src/shared/adapters/BridgeInterface.js +195 -0
- package/src/shared/components/EffectsPanel.jsx +177 -0
- package/src/shared/components/LibraryPanel.jsx +701 -0
- package/src/shared/components/LineDetailCanvas.jsx +167 -0
- package/src/shared/components/LyricLine.jsx +505 -0
- package/src/shared/components/LyricRejection.jsx +84 -0
- package/src/shared/components/LyricSuggestion.jsx +80 -0
- package/src/shared/components/LyricsEditorCanvas.jsx +271 -0
- package/src/shared/components/MixerPanel.jsx +94 -0
- package/src/shared/components/PlayerControls.jsx +206 -0
- package/src/shared/components/PortalSelect.jsx +239 -0
- package/src/shared/components/QueueList.jsx +365 -0
- package/src/shared/components/QuickSearch.jsx +126 -0
- package/src/shared/components/RequestsList.jsx +121 -0
- package/src/shared/components/SongEditor.jsx +1362 -0
- package/src/shared/components/SongInfoBar.jsx +81 -0
- package/src/shared/components/ThemeToggle.jsx +106 -0
- package/src/shared/components/Toast.jsx +30 -0
- package/src/shared/components/VisualizationSettings.jsx +243 -0
- package/src/shared/constants.js +95 -0
- package/src/shared/context/BridgeContext.jsx +32 -0
- package/src/shared/contexts/AudioContext.jsx +37 -0
- package/src/shared/contexts/PlayerContext.jsx +66 -0
- package/src/shared/contexts/SettingsContext.jsx +50 -0
- package/src/shared/defaults.js +158 -0
- package/src/shared/formatUtils.js +59 -0
- package/src/shared/formatUtils.test.js +207 -0
- package/src/shared/hooks/useAppState.js +97 -0
- package/src/shared/hooks/useAudioEngine.js +264 -0
- package/src/shared/hooks/usePlayer.js +89 -0
- package/src/shared/hooks/useSettingsPersistence.js +74 -0
- package/src/shared/hooks/useWebRTC.js +118 -0
- package/src/shared/ipcContracts.js +299 -0
- package/src/shared/package.json +3 -0
- package/src/shared/services/creatorService.js +373 -0
- package/src/shared/services/creatorService.test.js +413 -0
- package/src/shared/services/editorService.js +213 -0
- package/src/shared/services/editorService.test.js +219 -0
- package/src/shared/services/effectsService.js +271 -0
- package/src/shared/services/effectsService.test.js +418 -0
- package/src/shared/services/libraryService.js +438 -0
- package/src/shared/services/libraryService.test.js +474 -0
- package/src/shared/services/mixerService.js +172 -0
- package/src/shared/services/mixerService.test.js +399 -0
- package/src/shared/services/playerService.js +221 -0
- package/src/shared/services/playerService.test.js +357 -0
- package/src/shared/services/preferencesService.js +219 -0
- package/src/shared/services/queueService.js +226 -0
- package/src/shared/services/queueService.test.js +430 -0
- package/src/shared/services/requestsService.js +155 -0
- package/src/shared/services/requestsService.test.js +362 -0
- package/src/shared/services/serverSettingsService.js +151 -0
- package/src/shared/services/settingsService.js +257 -0
- package/src/shared/services/settingsService.test.js +295 -0
- package/src/shared/state/StateManager.js +263 -0
- package/src/shared/utils/audio.js +42 -0
- package/src/shared/utils/format.js +32 -0
- package/src/shared/utils/lyricsUtils.js +162 -0
- package/src/test/setup.js +40 -0
- package/src/utils/cdgLoader.js +180 -0
- package/src/utils/m4aLoader.js +333 -0
- package/src/web/App.jsx +578 -0
- package/src/web/adapters/WebBridge.js +428 -0
- package/src/web/components/PlayerSettingsPanel.jsx +231 -0
- package/src/web/components/SongSearch.jsx +180 -0
- package/src/web/dist/assets/index-0H-RnRrV.js +51 -0
- package/src/web/dist/assets/index-0H-RnRrV.js.map +1 -0
- package/src/web/dist/assets/index-DYW2zB0u.css +1 -0
- package/src/web/dist/index.html +15 -0
- package/src/web/index.html +14 -0
- package/src/web/main.jsx +10 -0
- package/src/web/package-lock.json +1765 -0
- package/src/web/pages/SongRequestPage.jsx +619 -0
- package/src/web/styles/tailwind.css +68 -0
- package/src/web/vite.config.js +27 -0
- package/static/fonts/material-icons.woff2 +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Potion of Spirits.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Songflower _Moss Posy_.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Storm of the Eye _Thunder_ - mash0000 - quasi pseudo meta concentrics.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi _ Geiss - Airhandler _Kali Mix_ - Canvas Mix.png +0 -0
- 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
- package/static/images/butterchurn-screenshots/Cope - The Neverending Explosion of Red Liquid Fire.png +0 -0
- proton lights __Krash_s beat code_ _Phat_remix02b.png +0 -0
- package/static/images/butterchurn-screenshots/Eo_S_ _ Phat - cubetrace - v2.png +0 -0
- package/static/images/butterchurn-screenshots/Eo_S_ _ Zylot - skylight _Stained Glass Majesty mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - alien fish pond.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - area 51.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - infused with the spiral.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - mindblob _shiny mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - mindblob mix.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - predator-prey-spirals.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - smashing fractals _acid etching mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - truly soft piece of software - this is generic texturing _Jelly_ .png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ Martin - astral projection.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ Martin - cascading decay swing.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ amandio c - piercing 05 - Kopie _2_ - Kopie.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ stahlregen - jelly showoff parade.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi_ fishbrain_ Geiss _ Martin - tokamak witchery.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi_ martin _ geiss - dedicated to the sherwin maxawow.png +0 -0
- package/static/images/butterchurn-screenshots/Fumbling_Foo _ Flexi_ Martin_ Orb_ Unchained - Star Nova v7b.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Cauldron - painterly 2 _saturation remix_.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Reaction Diffusion 2.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Spiral Artifact.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Thumb Drum.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss _ Flexi _ Martin - disconnected.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss_ Flexi _ Stahlregen - Thumbdrum Tokamak _crossfiring aftermath jelly mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/Goody - The Wild Vort.png +0 -0
- package/static/images/butterchurn-screenshots/Idiot - Star Of Annon.png +0 -0
- package/static/images/butterchurn-screenshots/Krash _ Illusion - Spiral Movement.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - QBikal - Surface Turbulence IIb.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - acid wiring.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - charisma.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - liquid arrows.png +0 -0
- package/static/images/butterchurn-screenshots/Milk Artist At our Best - FED - SlowFast Ft AdamFX n Martin - HD CosmoFX.png +0 -0
- package/static/images/butterchurn-screenshots/ORB - Waaa.png +0 -0
- package/static/images/butterchurn-screenshots/Phat_fiShbRaiN_Eo_S_Mandala_Chasers_remix.png +0 -0
- package/static/images/butterchurn-screenshots/Rovastar - Oozing Resistance.png +0 -0
- package/static/images/butterchurn-screenshots/Rovastar _ Loadus _ Geiss - FractalDrop _Triple Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/TonyMilkdrop - Leonardo Da Vinci_s Balloon _Flexi - merry-go-round _ techstyle_.png +0 -0
- package/static/images/butterchurn-screenshots/TonyMilkdrop - Magellan_s Nebula _Flexi - you enter first _ multiverse_.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained - Rewop.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained - Unified Drag 2.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained _ Rovastar - Wormhole Pillars _Hall of Shadows mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - Paint Spill _Music Reactive Paint Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - Star Ornament.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - True Visionary _Final Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/_Aderrasi - Wanderer in Curved Space - mash0000 - faclempt kibitzing meshuggana schmaltz _Geiss color mix_.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - Artifact 01.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - Desert Rose 2.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - untitled.png +0 -0
- package/static/images/butterchurn-screenshots/_Mig_049.png +0 -0
- package/static/images/butterchurn-screenshots/_Mig_085.png +0 -0
- package/static/images/butterchurn-screenshots/_Rovastar _ Geiss - Hurricane Nightmare _Posterize Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _197_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _220_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _431_.png +0 -0
- package/static/images/butterchurn-screenshots/cope _ martin - mother-of-pearl.png +0 -0
- package/static/images/butterchurn-screenshots/fiShbRaiN _ Flexi - witchcraft 2_0.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - bouncing balls _double mindblob neon mix_.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - mom_ why the sky looks different today.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - patternton_ district of media_ capitol of the united abstractions of fractopia.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - swing out on the spiral.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - what is the matrix.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ amandio c - organic _random mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ amandio c - organic12-3d-2_milk.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ fishbrain - neon mindblob grafitti.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ geiss - pogo cubes vs_ tokamak vs_ game of life _stahls jelly 4_5 finish_.png +0 -0
- package/static/images/butterchurn-screenshots/high-altitude basket unraveling - singh grooves nitrogen argon nz_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - The Bridge of Khazad-Dum.png +0 -0
- package/static/images/butterchurn-screenshots/martin - angel flight.png +0 -0
- package/static/images/butterchurn-screenshots/martin - another kind of groove.png +0 -0
- package/static/images/butterchurn-screenshots/martin - bombyx mori.png +0 -0
- package/static/images/butterchurn-screenshots/martin - castle in the air.png +0 -0
- package/static/images/butterchurn-screenshots/martin - chain breaker.png +0 -0
- package/static/images/butterchurn-screenshots/martin - disco mix 4.png +0 -0
- package/static/images/butterchurn-screenshots/martin - extreme heat.png +0 -0
- package/static/images/butterchurn-screenshots/martin - frosty caves 2.png +0 -0
- package/static/images/butterchurn-screenshots/martin - fruit machine.png +0 -0
- package/static/images/butterchurn-screenshots/martin - ghost city.png +0 -0
- package/static/images/butterchurn-screenshots/martin - glass corridor.png +0 -0
- package/static/images/butterchurn-screenshots/martin - infinity _2010 update_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - mandelbox explorer - high speed demo version.png +0 -0
- package/static/images/butterchurn-screenshots/martin - mucus cervix.png +0 -0
- package/static/images/butterchurn-screenshots/martin - reflections on black tiles.png +0 -0
- package/static/images/butterchurn-screenshots/martin - stormy sea _2010 update_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - witchcraft reloaded.png +0 -0
- package/static/images/butterchurn-screenshots/martin _ flexi - diamond cutter _prismaticvortex_com_ - camille - i wish i wish i wish i was constrained.png +0 -0
- package/static/images/butterchurn-screenshots/martin _shadow harlequins shape code_ - fata morgana.png +0 -0
- package/static/images/butterchurn-screenshots/martin_ flexi_ fishbrain _ sto - enterstate _random mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/sawtooth grin roam.png +0 -0
- package/static/images/butterchurn-screenshots/shifter - dark tides bdrv mix 2.png +0 -0
- 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
- package/static/images/butterchurn-screenshots/suksma - heretical crosscut playpen.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - uninitialized variabowl _hydroponic chronic_.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - vector exp 1 - couldn_t not.png +0 -0
- package/static/images/butterchurn-screenshots/yin - 191 - Temporal singularities.png +0 -0
- package/static/images/logo-512.png +0 -0
- package/static/images/logo.png +0 -0
- package/static/loukai-logo.png +0 -0
- package/static/screenshot-generator.html +610 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LRCLIB Service - Lyrics lookup from lrclib.net
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Lyrics search by title/artist
|
|
6
|
+
* - Vocabulary extraction for Whisper hints
|
|
7
|
+
* - Synced lyrics (LRC format) when available
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const LRCLIB_API_BASE = 'https://lrclib.net/api';
|
|
11
|
+
|
|
12
|
+
// Common words to filter out of vocabulary hints
|
|
13
|
+
const COMMON_WORDS = new Set([
|
|
14
|
+
'this',
|
|
15
|
+
'that',
|
|
16
|
+
'with',
|
|
17
|
+
'will',
|
|
18
|
+
'were',
|
|
19
|
+
'when',
|
|
20
|
+
'where',
|
|
21
|
+
'what',
|
|
22
|
+
'they',
|
|
23
|
+
'them',
|
|
24
|
+
'then',
|
|
25
|
+
'than',
|
|
26
|
+
'like',
|
|
27
|
+
'just',
|
|
28
|
+
'have',
|
|
29
|
+
'from',
|
|
30
|
+
'been',
|
|
31
|
+
'your',
|
|
32
|
+
'come',
|
|
33
|
+
'said',
|
|
34
|
+
'would',
|
|
35
|
+
'could',
|
|
36
|
+
'should',
|
|
37
|
+
'there',
|
|
38
|
+
'their',
|
|
39
|
+
'these',
|
|
40
|
+
'those',
|
|
41
|
+
'through',
|
|
42
|
+
'before',
|
|
43
|
+
'after',
|
|
44
|
+
'about',
|
|
45
|
+
'dont',
|
|
46
|
+
'cant',
|
|
47
|
+
'wont',
|
|
48
|
+
'isnt',
|
|
49
|
+
'arent',
|
|
50
|
+
'wasnt',
|
|
51
|
+
'werent',
|
|
52
|
+
'doesnt',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Search LRCLIB for lyrics
|
|
57
|
+
* @param {string} title - Song title
|
|
58
|
+
* @param {string} artist - Artist name
|
|
59
|
+
* @returns {Promise<Object|null>} Lyrics result or null
|
|
60
|
+
*/
|
|
61
|
+
export async function searchLyrics(title, artist) {
|
|
62
|
+
if (!title) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const params = new URLSearchParams({
|
|
68
|
+
track_name: title,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (artist) {
|
|
72
|
+
params.set('artist_name', artist);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const url = `${LRCLIB_API_BASE}/search?${params}`;
|
|
76
|
+
console.log(`Searching LRCLIB for: ${title} by ${artist || 'unknown'}`);
|
|
77
|
+
|
|
78
|
+
const response = await fetch(url, {
|
|
79
|
+
headers: {
|
|
80
|
+
'User-Agent': 'Loukai/1.0',
|
|
81
|
+
},
|
|
82
|
+
signal: AbortSignal.timeout(10000),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
console.warn(`LRCLIB search failed: ${response.status}`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const results = await response.json();
|
|
91
|
+
|
|
92
|
+
if (!results || results.length === 0) {
|
|
93
|
+
console.warn('No lyrics found on LRCLIB');
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Find first non-instrumental result with plain lyrics
|
|
98
|
+
for (const result of results) {
|
|
99
|
+
if (!result.instrumental && result.plainLyrics) {
|
|
100
|
+
console.log(
|
|
101
|
+
`Found lyrics: ${result.name || 'Unknown'} from ${result.albumName || 'Unknown'}`
|
|
102
|
+
);
|
|
103
|
+
return {
|
|
104
|
+
id: result.id,
|
|
105
|
+
name: result.name,
|
|
106
|
+
artist: result.artistName,
|
|
107
|
+
album: result.albumName,
|
|
108
|
+
duration: result.duration,
|
|
109
|
+
plainLyrics: result.plainLyrics,
|
|
110
|
+
syncedLyrics: result.syncedLyrics || null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.warn('No suitable lyrics found (all instrumental or missing plainLyrics)');
|
|
116
|
+
return null;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error('Failed to fetch lyrics from LRCLIB:', error.message);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get lyrics by LRCLIB ID
|
|
125
|
+
* @param {number} id - LRCLIB track ID
|
|
126
|
+
* @returns {Promise<Object|null>} Lyrics result or null
|
|
127
|
+
*/
|
|
128
|
+
export async function getLyricsById(id) {
|
|
129
|
+
try {
|
|
130
|
+
const url = `${LRCLIB_API_BASE}/get/${id}`;
|
|
131
|
+
console.log(`Fetching LRCLIB track: ${id}`);
|
|
132
|
+
|
|
133
|
+
const response = await fetch(url, {
|
|
134
|
+
headers: {
|
|
135
|
+
'User-Agent': 'Loukai/1.0',
|
|
136
|
+
},
|
|
137
|
+
signal: AbortSignal.timeout(10000),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
console.warn(`LRCLIB get failed: ${response.status}`);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const result = await response.json();
|
|
146
|
+
|
|
147
|
+
if (result.instrumental) {
|
|
148
|
+
console.warn('Track is marked as instrumental');
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!result.plainLyrics) {
|
|
153
|
+
console.warn('No plain lyrics in response');
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
id: result.id,
|
|
159
|
+
name: result.name,
|
|
160
|
+
artist: result.artistName,
|
|
161
|
+
album: result.albumName,
|
|
162
|
+
duration: result.duration,
|
|
163
|
+
plainLyrics: result.plainLyrics,
|
|
164
|
+
syncedLyrics: result.syncedLyrics || null,
|
|
165
|
+
};
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error('Failed to fetch lyrics by ID:', error.message);
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Extract vocabulary hints from lyrics for Whisper context
|
|
174
|
+
*
|
|
175
|
+
* @param {string} lyrics - Full lyrics text
|
|
176
|
+
* @param {number} maxTokens - Maximum tokens for vocabulary hints (default 150)
|
|
177
|
+
* @returns {string} Comma-separated list of vocabulary words
|
|
178
|
+
*/
|
|
179
|
+
export function extractVocabularyHints(lyrics, maxTokens = 150) {
|
|
180
|
+
if (!lyrics) {
|
|
181
|
+
return '';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Keep only letters (English + common accented characters)
|
|
185
|
+
const wordsOnly = lyrics.replace(/[^a-zA-ZáéíóúñüÁÉÍÓÚÑÜ\s]/g, ' ');
|
|
186
|
+
|
|
187
|
+
// Split into words, filter meaningful ones (> 3 chars)
|
|
188
|
+
const words = wordsOnly
|
|
189
|
+
.split(/\s+/)
|
|
190
|
+
.map((w) => w.toLowerCase())
|
|
191
|
+
.filter((w) => w.length > 3);
|
|
192
|
+
|
|
193
|
+
// Count word frequency with boost for opening words
|
|
194
|
+
const wordCounts = new Map();
|
|
195
|
+
words.forEach((word, i) => {
|
|
196
|
+
if (!COMMON_WORDS.has(word)) {
|
|
197
|
+
let count = (wordCounts.get(word) || 0) + 1;
|
|
198
|
+
|
|
199
|
+
// Boost first 3 meaningful words
|
|
200
|
+
if (i < 3) {
|
|
201
|
+
count += 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
wordCounts.set(word, count);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Get words with at least 2 occurrences (frequent)
|
|
209
|
+
const frequentWords = [...wordCounts.entries()]
|
|
210
|
+
.filter(([_word, count]) => count >= 2)
|
|
211
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
212
|
+
.map(([word]) => word);
|
|
213
|
+
|
|
214
|
+
// Build candidate list
|
|
215
|
+
const candidates = [...frequentWords];
|
|
216
|
+
|
|
217
|
+
// Add single-occurrence words if we have room
|
|
218
|
+
if (frequentWords.length < 15) {
|
|
219
|
+
const singleWords = [...wordCounts.entries()]
|
|
220
|
+
.filter(([_word, count]) => count === 1)
|
|
221
|
+
.map(([word]) => word)
|
|
222
|
+
.sort();
|
|
223
|
+
|
|
224
|
+
const remaining = 15 - candidates.length;
|
|
225
|
+
candidates.push(...singleWords.slice(0, remaining));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Build vocabulary list respecting token budget
|
|
229
|
+
const selectedWords = [];
|
|
230
|
+
let estimatedTokens = 0;
|
|
231
|
+
|
|
232
|
+
for (const word of candidates) {
|
|
233
|
+
// Rough estimate: 1 token per 4 characters + 1 for separator
|
|
234
|
+
const wordTokens = Math.ceil(word.length / 4) + 1;
|
|
235
|
+
|
|
236
|
+
if (estimatedTokens + wordTokens <= maxTokens) {
|
|
237
|
+
selectedWords.push(word);
|
|
238
|
+
estimatedTokens += wordTokens;
|
|
239
|
+
} else {
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return selectedWords.join(', ');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Prepare Whisper context with LRCLIB vocabulary enhancement
|
|
249
|
+
*
|
|
250
|
+
* @param {string} title - Song title
|
|
251
|
+
* @param {string} artist - Artist name
|
|
252
|
+
* @param {string} existingLyrics - Optional pre-fetched lyrics
|
|
253
|
+
* @returns {Promise<Object>} Object with initialPrompt and lyrics
|
|
254
|
+
*/
|
|
255
|
+
export async function prepareWhisperContext(title, artist, existingLyrics = null) {
|
|
256
|
+
let lyrics = existingLyrics;
|
|
257
|
+
|
|
258
|
+
// Fetch lyrics if not provided
|
|
259
|
+
if (!lyrics) {
|
|
260
|
+
const result = await searchLyrics(title, artist);
|
|
261
|
+
lyrics = result?.plainLyrics || null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Build initial prompt
|
|
265
|
+
let initialPrompt = null;
|
|
266
|
+
|
|
267
|
+
if (lyrics) {
|
|
268
|
+
// Calculate available tokens for vocabulary hints
|
|
269
|
+
// Whisper limit: 224 tokens total
|
|
270
|
+
// Reserve 30 tokens for safety buffer
|
|
271
|
+
const basePrompt = title ? `${title}. ` : '';
|
|
272
|
+
const baseTokens = Math.ceil(basePrompt.length / 4) + 2; // +2 for safety
|
|
273
|
+
const safetyBuffer = 30;
|
|
274
|
+
const maxVocabTokens = 224 - baseTokens - safetyBuffer;
|
|
275
|
+
|
|
276
|
+
// Extract vocabulary hints
|
|
277
|
+
const vocabularyHints = extractVocabularyHints(lyrics, maxVocabTokens);
|
|
278
|
+
|
|
279
|
+
if (vocabularyHints) {
|
|
280
|
+
initialPrompt = `${title}. ${vocabularyHints}`;
|
|
281
|
+
console.log(`Whisper initial prompt: ${initialPrompt.substring(0, 100)}...`);
|
|
282
|
+
} else {
|
|
283
|
+
initialPrompt = title;
|
|
284
|
+
}
|
|
285
|
+
} else if (title) {
|
|
286
|
+
initialPrompt = title;
|
|
287
|
+
} else if (artist) {
|
|
288
|
+
initialPrompt = artist;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
initialPrompt,
|
|
293
|
+
lyrics,
|
|
294
|
+
hasLyrics: Boolean(lyrics),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Parse synced lyrics (LRC format) into timed segments
|
|
300
|
+
*
|
|
301
|
+
* @param {string} syncedLyrics - LRC format lyrics
|
|
302
|
+
* @returns {Array<{time: number, text: string}>} Array of timed lyrics
|
|
303
|
+
*/
|
|
304
|
+
export function parseSyncedLyrics(syncedLyrics) {
|
|
305
|
+
if (!syncedLyrics) {
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const lines = syncedLyrics.split('\n');
|
|
310
|
+
const result = [];
|
|
311
|
+
|
|
312
|
+
// LRC format: [mm:ss.xx]lyrics
|
|
313
|
+
const timeRegex = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/;
|
|
314
|
+
|
|
315
|
+
for (const line of lines) {
|
|
316
|
+
const match = line.match(timeRegex);
|
|
317
|
+
if (match) {
|
|
318
|
+
const minutes = parseInt(match[1], 10);
|
|
319
|
+
const seconds = parseInt(match[2], 10);
|
|
320
|
+
const hundredths = parseInt(match[3].padEnd(3, '0').slice(0, 3), 10);
|
|
321
|
+
|
|
322
|
+
const time = minutes * 60 + seconds + hundredths / 1000;
|
|
323
|
+
const text = line.replace(timeRegex, '').trim();
|
|
324
|
+
|
|
325
|
+
if (text) {
|
|
326
|
+
result.push({ time, text });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return result.sort((a, b) => a.time - b.time);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export default {
|
|
335
|
+
searchLyrics,
|
|
336
|
+
getLyricsById,
|
|
337
|
+
extractVocabularyHints,
|
|
338
|
+
prepareWhisperContext,
|
|
339
|
+
parseSyncedLyrics,
|
|
340
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CREPE Runner - Pitch detection for Loukai Creator
|
|
4
|
+
|
|
5
|
+
Usage: python crepe_runner.py '{"input": "path/to/vocals.wav", "output": "path/to/pitch.json"}'
|
|
6
|
+
|
|
7
|
+
Detects pitch (F0) from vocal audio for karaoke scoring.
|
|
8
|
+
Outputs pitch data as JSON to stdout.
|
|
9
|
+
Progress updates are sent to stderr in format: PROGRESS:percent:message
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
def progress(percent, message):
|
|
17
|
+
"""Send progress update to stderr"""
|
|
18
|
+
print(f"PROGRESS:{percent}:{message}", file=sys.stderr, flush=True)
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
if len(sys.argv) < 2:
|
|
22
|
+
print(json.dumps({"error": "Missing arguments"}))
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
args = json.loads(sys.argv[1])
|
|
27
|
+
except json.JSONDecodeError as e:
|
|
28
|
+
print(json.dumps({"error": f"Invalid JSON arguments: {e}"}))
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
input_path = args.get("input")
|
|
32
|
+
output_path = args.get("output")
|
|
33
|
+
hop_length = args.get("hop_length", 512) # ~11.6ms at 44100 Hz
|
|
34
|
+
model_capacity = args.get("model", "tiny") # 'tiny', 'small', 'medium', 'large', 'full' - tiny is fast and accurate enough
|
|
35
|
+
|
|
36
|
+
if not input_path:
|
|
37
|
+
print(json.dumps({"error": "Missing input path"}))
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import torch
|
|
42
|
+
import torchaudio
|
|
43
|
+
import torchcrepe
|
|
44
|
+
import numpy as np
|
|
45
|
+
|
|
46
|
+
# Detect device (CREPE has issues with MPS viterbi decoder, use CPU)
|
|
47
|
+
if torch.cuda.is_available():
|
|
48
|
+
device = "cuda"
|
|
49
|
+
device_name = torch.cuda.get_device_name(0)
|
|
50
|
+
else:
|
|
51
|
+
# Force CPU even on Apple Silicon (CREPE's viterbi decoder hangs on MPS)
|
|
52
|
+
device = "cpu"
|
|
53
|
+
device_name = "CPU"
|
|
54
|
+
|
|
55
|
+
progress(0, f"Loading vocal audio on {device_name}")
|
|
56
|
+
|
|
57
|
+
# Load audio using soundfile (avoids torchcodec requirement)
|
|
58
|
+
import soundfile as sf
|
|
59
|
+
audio_np, sample_rate = sf.read(input_path, always_2d=True)
|
|
60
|
+
# Convert to torch tensor and transpose to [channels, samples]
|
|
61
|
+
audio = torch.from_numpy(audio_np.T).float()
|
|
62
|
+
duration = audio.shape[1] / sample_rate
|
|
63
|
+
|
|
64
|
+
progress(5, f"Loaded {duration:.1f}s of audio")
|
|
65
|
+
|
|
66
|
+
# Convert to mono if stereo
|
|
67
|
+
if audio.shape[0] > 1:
|
|
68
|
+
audio = audio.mean(dim=0, keepdim=True)
|
|
69
|
+
progress(8, "Converted to mono")
|
|
70
|
+
|
|
71
|
+
# Resample to 16kHz (CREPE's expected sample rate)
|
|
72
|
+
if sample_rate != 16000:
|
|
73
|
+
progress(10, f"Resampling from {sample_rate}Hz to 16kHz")
|
|
74
|
+
import torchaudio.functional
|
|
75
|
+
# Resample on CPU to avoid MPS float64 issues
|
|
76
|
+
audio = torchaudio.functional.resample(audio, sample_rate, 16000)
|
|
77
|
+
sample_rate = 16000
|
|
78
|
+
|
|
79
|
+
audio = audio.to(device)
|
|
80
|
+
|
|
81
|
+
progress(15, f"🎵 Detecting pitch ({model_capacity} model)...")
|
|
82
|
+
|
|
83
|
+
# Run CREPE
|
|
84
|
+
# Returns: (pitch, periodicity) - periodicity is confidence-like (0-1)
|
|
85
|
+
import time
|
|
86
|
+
start_time = time.time()
|
|
87
|
+
|
|
88
|
+
frequency, periodicity = torchcrepe.predict(
|
|
89
|
+
audio,
|
|
90
|
+
sample_rate,
|
|
91
|
+
hop_length=hop_length,
|
|
92
|
+
model=model_capacity,
|
|
93
|
+
device=device,
|
|
94
|
+
return_periodicity=True,
|
|
95
|
+
batch_size=2048,
|
|
96
|
+
decoder=torchcrepe.decode.argmax # Use argmax instead of viterbi (viterbi hangs on MPS)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
elapsed_time = time.time() - start_time
|
|
100
|
+
progress(75, f"Processing pitch data (CREPE took {elapsed_time:.1f}s for {duration:.1f}s audio)")
|
|
101
|
+
|
|
102
|
+
print(f"⏱️ CREPE timing: {elapsed_time:.1f}s for {duration:.1f}s of audio ({elapsed_time/duration:.2f}x realtime)", file=sys.stderr, flush=True)
|
|
103
|
+
|
|
104
|
+
# Convert to numpy
|
|
105
|
+
frequency = frequency.cpu().numpy().flatten()
|
|
106
|
+
confidence = periodicity.cpu().numpy().flatten() # periodicity is the confidence
|
|
107
|
+
|
|
108
|
+
# Compute time array from hop_length
|
|
109
|
+
num_frames = len(frequency)
|
|
110
|
+
time = np.arange(num_frames) * hop_length / sample_rate
|
|
111
|
+
|
|
112
|
+
# Calculate stats
|
|
113
|
+
valid_frames = (frequency > 0) & (confidence > 0.5)
|
|
114
|
+
voiced_percent = (valid_frames.sum() / len(frequency)) * 100
|
|
115
|
+
avg_confidence = confidence[valid_frames].mean() if valid_frames.any() else 0
|
|
116
|
+
|
|
117
|
+
progress(80, f"Found pitch in {voiced_percent:.0f}% of frames")
|
|
118
|
+
|
|
119
|
+
# Filter out low confidence predictions
|
|
120
|
+
# Set frequency to 0 where confidence is low
|
|
121
|
+
frequency[confidence < 0.5] = 0
|
|
122
|
+
|
|
123
|
+
# Convert frequency to MIDI note numbers for easier use
|
|
124
|
+
# MIDI = 69 + 12 * log2(f/440)
|
|
125
|
+
midi = np.zeros_like(frequency)
|
|
126
|
+
valid = frequency > 0
|
|
127
|
+
midi[valid] = 69 + 12 * np.log2(frequency[valid] / 440.0)
|
|
128
|
+
|
|
129
|
+
# Calculate vocal range
|
|
130
|
+
if valid.any():
|
|
131
|
+
min_midi = midi[valid].min()
|
|
132
|
+
max_midi = midi[valid].max()
|
|
133
|
+
range_semitones = max_midi - min_midi
|
|
134
|
+
progress(85, f"Vocal range: {range_semitones:.0f} semitones")
|
|
135
|
+
else:
|
|
136
|
+
progress(85, "No pitched vocals detected")
|
|
137
|
+
|
|
138
|
+
# Downsample for storage efficiency (keep every Nth point)
|
|
139
|
+
# Original is ~86 fps, downsample to ~20 fps
|
|
140
|
+
downsample_factor = 4
|
|
141
|
+
time_ds = time[::downsample_factor].tolist()
|
|
142
|
+
frequency_ds = frequency[::downsample_factor].tolist()
|
|
143
|
+
midi_ds = midi[::downsample_factor].tolist()
|
|
144
|
+
confidence_ds = confidence[::downsample_factor].tolist()
|
|
145
|
+
|
|
146
|
+
progress(90, f"Downsampled to {len(time_ds)} points")
|
|
147
|
+
|
|
148
|
+
# Build output
|
|
149
|
+
pitch_data = {
|
|
150
|
+
"time": [round(t, 4) for t in time_ds],
|
|
151
|
+
"frequency": [round(f, 2) if f > 0 else 0 for f in frequency_ds],
|
|
152
|
+
"midi": [round(m, 2) if m > 0 else 0 for m in midi_ds],
|
|
153
|
+
"confidence": [round(c, 3) for c in confidence_ds],
|
|
154
|
+
"sample_rate": sample_rate,
|
|
155
|
+
"hop_length": hop_length * downsample_factor,
|
|
156
|
+
"model": model_capacity
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# Save to file if output path specified
|
|
160
|
+
if output_path:
|
|
161
|
+
progress(95, "Saving pitch data")
|
|
162
|
+
with open(output_path, 'w') as f:
|
|
163
|
+
json.dump(pitch_data, f)
|
|
164
|
+
|
|
165
|
+
progress(100, f"✓ Pitch detection complete ({len(time_ds)} points)")
|
|
166
|
+
|
|
167
|
+
# Output result
|
|
168
|
+
result = {
|
|
169
|
+
"success": True,
|
|
170
|
+
"num_frames": len(time_ds),
|
|
171
|
+
"duration": float(time[-1]) if len(time) > 0 else 0,
|
|
172
|
+
"device": device,
|
|
173
|
+
"voiced_percent": round(voiced_percent, 1),
|
|
174
|
+
"avg_confidence": round(float(avg_confidence), 3),
|
|
175
|
+
"pitch_data": pitch_data if not output_path else None,
|
|
176
|
+
"output_file": output_path
|
|
177
|
+
}
|
|
178
|
+
print(json.dumps(result))
|
|
179
|
+
|
|
180
|
+
except Exception as e:
|
|
181
|
+
import traceback
|
|
182
|
+
print(json.dumps({
|
|
183
|
+
"error": str(e),
|
|
184
|
+
"traceback": traceback.format_exc()
|
|
185
|
+
}))
|
|
186
|
+
sys.exit(1)
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
main()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Demucs Runner - Stem separation for Loukai Creator
|
|
4
|
+
|
|
5
|
+
Usage: python demucs_runner.py '{"input": "path/to/audio.wav", "output_dir": "path/to/output", "model": "htdemucs_ft"}'
|
|
6
|
+
|
|
7
|
+
Outputs stems as WAV files and prints JSON result to stdout.
|
|
8
|
+
Progress updates are sent to stderr in format: PROGRESS:percent:message
|
|
9
|
+
tqdm progress bars are also output to stderr and parsed by Node.js
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
def progress(percent, message):
|
|
17
|
+
"""Send progress update to stderr"""
|
|
18
|
+
print(f"PROGRESS:{percent}:{message}", file=sys.stderr, flush=True)
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
if len(sys.argv) < 2:
|
|
22
|
+
print(json.dumps({"error": "Missing arguments"}))
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
args = json.loads(sys.argv[1])
|
|
27
|
+
except json.JSONDecodeError as e:
|
|
28
|
+
print(json.dumps({"error": f"Invalid JSON arguments: {e}"}))
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
input_path = args.get("input")
|
|
32
|
+
output_dir = args.get("output_dir")
|
|
33
|
+
model_name = args.get("model", "htdemucs_ft")
|
|
34
|
+
num_stems = args.get("num_stems", 4)
|
|
35
|
+
|
|
36
|
+
if not input_path or not output_dir:
|
|
37
|
+
print(json.dumps({"error": "Missing input or output_dir"}))
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import torch
|
|
42
|
+
import torchaudio
|
|
43
|
+
from demucs.pretrained import get_model
|
|
44
|
+
from demucs.apply import apply_model
|
|
45
|
+
from demucs.audio import convert_audio
|
|
46
|
+
|
|
47
|
+
# Detect device
|
|
48
|
+
if torch.cuda.is_available():
|
|
49
|
+
device = "cuda"
|
|
50
|
+
device_name = torch.cuda.get_device_name(0)
|
|
51
|
+
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
|
52
|
+
device = "mps"
|
|
53
|
+
device_name = "Apple Silicon GPU"
|
|
54
|
+
else:
|
|
55
|
+
device = "cpu"
|
|
56
|
+
device_name = "CPU"
|
|
57
|
+
|
|
58
|
+
progress(0, f"Loading model on {device_name}")
|
|
59
|
+
|
|
60
|
+
# Load model
|
|
61
|
+
model = get_model(model_name)
|
|
62
|
+
model.to(device)
|
|
63
|
+
model.eval()
|
|
64
|
+
|
|
65
|
+
source_names = model.sources
|
|
66
|
+
stem_labels = {
|
|
67
|
+
'drums': '🥁 Drums',
|
|
68
|
+
'bass': '🎸 Bass',
|
|
69
|
+
'other': '🎹 Other',
|
|
70
|
+
'vocals': '🎤 Vocals',
|
|
71
|
+
'no_vocals': '🎵 Instrumental',
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
progress(5, "Loading audio file")
|
|
75
|
+
|
|
76
|
+
# Load audio using soundfile (avoids torchcodec requirement)
|
|
77
|
+
import soundfile as sf
|
|
78
|
+
audio_np, sample_rate = sf.read(input_path, always_2d=True)
|
|
79
|
+
# Convert to torch tensor and transpose to [channels, samples]
|
|
80
|
+
audio = torch.from_numpy(audio_np.T).float()
|
|
81
|
+
duration = audio.shape[1] / sample_rate
|
|
82
|
+
|
|
83
|
+
progress(8, f"Loaded {duration:.1f}s audio")
|
|
84
|
+
|
|
85
|
+
# Convert to model format and move to device
|
|
86
|
+
audio = convert_audio(
|
|
87
|
+
audio.unsqueeze(0),
|
|
88
|
+
sample_rate,
|
|
89
|
+
model.samplerate,
|
|
90
|
+
model.audio_channels
|
|
91
|
+
).to(device)
|
|
92
|
+
|
|
93
|
+
stems_str = " + ".join(stem_labels.get(s, s) for s in source_names)
|
|
94
|
+
progress(10, f"Separating {stems_str}")
|
|
95
|
+
|
|
96
|
+
# Run separation with tqdm progress (parsed by Node.js)
|
|
97
|
+
with torch.no_grad():
|
|
98
|
+
sources = apply_model(
|
|
99
|
+
model,
|
|
100
|
+
audio,
|
|
101
|
+
device=device,
|
|
102
|
+
shifts=1,
|
|
103
|
+
split=True,
|
|
104
|
+
overlap=0.25,
|
|
105
|
+
progress=True # tqdm output goes to stderr
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
progress(82, "Separation complete!")
|
|
109
|
+
|
|
110
|
+
# Resample if needed
|
|
111
|
+
if model.samplerate != sample_rate:
|
|
112
|
+
progress(83, f"Resampling to {sample_rate}Hz")
|
|
113
|
+
import torchaudio.functional
|
|
114
|
+
sources = torchaudio.functional.resample(
|
|
115
|
+
sources.squeeze(0),
|
|
116
|
+
model.samplerate,
|
|
117
|
+
sample_rate
|
|
118
|
+
).unsqueeze(0)
|
|
119
|
+
|
|
120
|
+
# Save stems
|
|
121
|
+
output_path = Path(output_dir)
|
|
122
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
stem_files = {}
|
|
125
|
+
num_sources = len(source_names)
|
|
126
|
+
|
|
127
|
+
for i, name in enumerate(source_names):
|
|
128
|
+
stem_progress = int(85 + (i / num_sources) * 14)
|
|
129
|
+
label = stem_labels.get(name, name.capitalize())
|
|
130
|
+
progress(stem_progress, f"Saving {label}")
|
|
131
|
+
|
|
132
|
+
stem_audio = sources[0, i].cpu()
|
|
133
|
+
stem_path = output_path / f"{name}.wav"
|
|
134
|
+
# Save using soundfile (avoids torchcodec requirement)
|
|
135
|
+
sf.write(str(stem_path), stem_audio.numpy().T, sample_rate)
|
|
136
|
+
stem_files[name] = str(stem_path)
|
|
137
|
+
|
|
138
|
+
progress(100, f"✓ Saved {num_sources} stems")
|
|
139
|
+
|
|
140
|
+
print(json.dumps({
|
|
141
|
+
"success": True,
|
|
142
|
+
"stems": stem_files,
|
|
143
|
+
"model": model_name,
|
|
144
|
+
"device": device,
|
|
145
|
+
"sample_rate": sample_rate,
|
|
146
|
+
"duration": duration
|
|
147
|
+
}))
|
|
148
|
+
|
|
149
|
+
except Exception as e:
|
|
150
|
+
import traceback
|
|
151
|
+
print(json.dumps({
|
|
152
|
+
"error": str(e),
|
|
153
|
+
"traceback": traceback.format_exc()
|
|
154
|
+
}))
|
|
155
|
+
sys.exit(1)
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
main()
|