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,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stem Builder - Creates .stem.m4a files with embedded stem data
|
|
3
|
+
*
|
|
4
|
+
* The .stem.m4a format embeds multiple audio stems in a single M4A container
|
|
5
|
+
* using custom atoms/boxes. This is compatible with Native Instruments Stems.
|
|
6
|
+
*
|
|
7
|
+
* Structure:
|
|
8
|
+
* - ftyp (file type)
|
|
9
|
+
* - moov (movie header with metadata)
|
|
10
|
+
* - udta/stem (NI Stems metadata for DJ software)
|
|
11
|
+
* - udta/meta/ilst/kara (karaoke data: lyrics, timing, word-level timing)
|
|
12
|
+
* - mdat (media data with stems)
|
|
13
|
+
*
|
|
14
|
+
* Note: CREPE pitch detection is used only for key detection during creation.
|
|
15
|
+
* Vocal pitch tracking for auto-tune/scoring is done at runtime.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'child_process';
|
|
19
|
+
import { getFFmpegPath } from './systemChecker.js';
|
|
20
|
+
import { Atoms as M4AAtoms } from 'm4a-stems';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a .stem.m4a file from individual stem files
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} options - Build options
|
|
26
|
+
* @param {string} options.outputPath - Output .stem.m4a path
|
|
27
|
+
* @param {Object} options.stems - Map of stem name to path
|
|
28
|
+
* @param {Object} options.metadata - Song metadata (title, artist, duration)
|
|
29
|
+
* @param {Object} options.lyrics - Whisper transcription result with word timestamps
|
|
30
|
+
* @param {Object} options.pitch - CREPE pitch detection result
|
|
31
|
+
* @param {string[]} options.tags - Tags array for filtering (e.g., ['ai_corrected'])
|
|
32
|
+
* @returns {Promise<void>}
|
|
33
|
+
*/
|
|
34
|
+
export async function buildStemM4a(options) {
|
|
35
|
+
const { outputPath, stems, metadata, lyrics, pitch, llmCorrections, tags } = options;
|
|
36
|
+
|
|
37
|
+
// For now, use ffmpeg to mux stems into a single file
|
|
38
|
+
// The stem.m4a format requires custom atom injection
|
|
39
|
+
// We'll use the first stem as the main track and embed others as metadata
|
|
40
|
+
|
|
41
|
+
const ffmpegPath = getFFmpegPath();
|
|
42
|
+
|
|
43
|
+
// Build ffmpeg command to combine stems
|
|
44
|
+
// Using -map to include multiple audio streams
|
|
45
|
+
const args = [];
|
|
46
|
+
|
|
47
|
+
// NI Stems track order: master, drums, bass, other, vocals
|
|
48
|
+
const niStemOrder = ['master', 'drums', 'bass', 'other', 'vocals'];
|
|
49
|
+
const stemNames = niStemOrder.filter((name) => stems[name]);
|
|
50
|
+
|
|
51
|
+
// Add input files in correct order
|
|
52
|
+
for (const name of stemNames) {
|
|
53
|
+
args.push('-i', stems[name]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Map all inputs to output
|
|
57
|
+
for (let i = 0; i < stemNames.length; i++) {
|
|
58
|
+
args.push('-map', `${i}:a`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Set metadata - copy ALL original ID3 tags
|
|
62
|
+
const id3Tags = metadata.tags || {};
|
|
63
|
+
|
|
64
|
+
// Standard ID3 tags to preserve
|
|
65
|
+
const tagMapping = {
|
|
66
|
+
title: metadata.title || id3Tags.title,
|
|
67
|
+
artist: metadata.artist || id3Tags.artist,
|
|
68
|
+
album: id3Tags.album,
|
|
69
|
+
album_artist: id3Tags.album_artist || id3Tags.albumartist,
|
|
70
|
+
composer: id3Tags.composer,
|
|
71
|
+
genre: id3Tags.genre,
|
|
72
|
+
date: id3Tags.date || id3Tags.year,
|
|
73
|
+
track: id3Tags.track || id3Tags.tracknumber,
|
|
74
|
+
disc: id3Tags.disc || id3Tags.discnumber,
|
|
75
|
+
comment: id3Tags.comment,
|
|
76
|
+
copyright: id3Tags.copyright,
|
|
77
|
+
publisher: id3Tags.publisher,
|
|
78
|
+
encoded_by: id3Tags.encoded_by,
|
|
79
|
+
language: id3Tags.language,
|
|
80
|
+
lyrics: id3Tags.lyrics || id3Tags.unsyncedlyrics,
|
|
81
|
+
bpm: id3Tags.bpm || id3Tags.tbpm,
|
|
82
|
+
initialkey: pitch?.detected_key?.key || id3Tags.initialkey || id3Tags.key,
|
|
83
|
+
isrc: id3Tags.isrc,
|
|
84
|
+
barcode: id3Tags.barcode,
|
|
85
|
+
catalog: id3Tags.catalog,
|
|
86
|
+
compilation: id3Tags.compilation,
|
|
87
|
+
grouping: id3Tags.grouping,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Add all non-empty tags
|
|
91
|
+
for (const [key, value] of Object.entries(tagMapping)) {
|
|
92
|
+
if (value) {
|
|
93
|
+
args.push('-metadata', `${key}=${value}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Also pass through any additional ID3 tags we might have missed
|
|
98
|
+
for (const [key, value] of Object.entries(id3Tags)) {
|
|
99
|
+
const lowerKey = key.toLowerCase();
|
|
100
|
+
// Skip if already handled above
|
|
101
|
+
if (!tagMapping[lowerKey] && value) {
|
|
102
|
+
args.push('-metadata', `${key}=${value}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
args.push('-metadata', 'encoder=Loukai Creator');
|
|
107
|
+
|
|
108
|
+
// Log key if detected
|
|
109
|
+
if (pitch?.detected_key?.key) {
|
|
110
|
+
console.log(`šµ Writing key to metadata: ${pitch.detected_key.key}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Copy codecs (stems are already AAC)
|
|
114
|
+
args.push('-c', 'copy');
|
|
115
|
+
|
|
116
|
+
// Add stream labels for stems
|
|
117
|
+
for (let i = 0; i < stemNames.length; i++) {
|
|
118
|
+
const stemName = stemNames[i];
|
|
119
|
+
// Use metadata to label streams
|
|
120
|
+
args.push(`-metadata:s:a:${i}`, `title=${stemName}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Per NI Stems spec: Track 1 (master) should be "enabled"/default,
|
|
124
|
+
// Tracks 2-5 (stems) should be "disabled" so normal players only play master
|
|
125
|
+
args.push('-disposition:a:0', 'default'); // Master track is default
|
|
126
|
+
for (let i = 1; i < stemNames.length; i++) {
|
|
127
|
+
args.push(`-disposition:a:${i}`, '0'); // Clear disposition flags for stem tracks
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Output format
|
|
131
|
+
args.push('-f', 'mp4');
|
|
132
|
+
args.push('-y'); // Overwrite output
|
|
133
|
+
args.push(outputPath);
|
|
134
|
+
|
|
135
|
+
// Run ffmpeg
|
|
136
|
+
await new Promise((resolve, reject) => {
|
|
137
|
+
const proc = spawn(ffmpegPath, args, {
|
|
138
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
let stderr = '';
|
|
142
|
+
proc.stderr.on('data', (data) => {
|
|
143
|
+
stderr += data.toString();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
proc.on('close', (code) => {
|
|
147
|
+
if (code === 0) {
|
|
148
|
+
resolve();
|
|
149
|
+
} else {
|
|
150
|
+
reject(new Error(`FFmpeg failed (code ${code}): ${stderr.slice(-500)}`));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
proc.on('error', (err) => {
|
|
155
|
+
reject(new Error(`Failed to run FFmpeg: ${err.message}`));
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Add NI Stems metadata so Mixxx/Traktor recognize this as a stem file
|
|
160
|
+
// Per NI Stems spec, stems array should have exactly 4 entries (NOT including master)
|
|
161
|
+
// Track order: drums, bass, other, vocals (corresponding to tracks 2-5)
|
|
162
|
+
const stemPartsOnly = stemNames.filter((name) => name !== 'master');
|
|
163
|
+
console.log(
|
|
164
|
+
`šļø Writing NI Stems metadata for ${stemPartsOnly.length} stem parts: ${stemPartsOnly.join(', ')}`
|
|
165
|
+
);
|
|
166
|
+
await M4AAtoms.addNiStemsMetadata(outputPath, stemPartsOnly);
|
|
167
|
+
|
|
168
|
+
// Verify stem atom was written (debug)
|
|
169
|
+
const { stat } = await import('fs/promises');
|
|
170
|
+
const afterStemSize = (await stat(outputPath)).size;
|
|
171
|
+
console.log(`š File size after stem atom: ${afterStemSize} bytes`);
|
|
172
|
+
|
|
173
|
+
// Now inject kara atom for karaoke data using m4a-stems library
|
|
174
|
+
await injectKaraokeAtoms(outputPath, {
|
|
175
|
+
lyrics,
|
|
176
|
+
pitch,
|
|
177
|
+
metadata,
|
|
178
|
+
stems: stemNames,
|
|
179
|
+
llmCorrections,
|
|
180
|
+
tags,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Inject karaoke atoms into an M4A file using m4a-stems library
|
|
186
|
+
*
|
|
187
|
+
* @param {string} filePath - Path to M4A file
|
|
188
|
+
* @param {Object} data - Karaoke data to embed
|
|
189
|
+
*/
|
|
190
|
+
async function injectKaraokeAtoms(filePath, data) {
|
|
191
|
+
const { lyrics, llmCorrections, tags } = data;
|
|
192
|
+
|
|
193
|
+
// Convert lyrics segments to lines format expected by kara atom
|
|
194
|
+
// Include word-level timing if available from Whisper
|
|
195
|
+
const lines = [];
|
|
196
|
+
if (lyrics && lyrics.lines && lyrics.lines.length > 0) {
|
|
197
|
+
const words = lyrics.words || [];
|
|
198
|
+
|
|
199
|
+
for (const line of lyrics.lines) {
|
|
200
|
+
const lineData = {
|
|
201
|
+
start: line.start,
|
|
202
|
+
end: line.end,
|
|
203
|
+
text: line.text,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Find words that fall within this line's time range
|
|
207
|
+
const lineWords = words.filter((w) => w.start >= line.start && w.start < line.end);
|
|
208
|
+
|
|
209
|
+
if (lineWords.length > 0) {
|
|
210
|
+
// Compute relative timings: [startOffset, endOffset] from line.start
|
|
211
|
+
// Round to 3 decimal places for reasonable precision
|
|
212
|
+
const timings = lineWords.map((w) => [
|
|
213
|
+
Math.round((w.start - line.start) * 1000) / 1000,
|
|
214
|
+
Math.round(((w.end || w.start + 0.1) - line.start) * 1000) / 1000,
|
|
215
|
+
]);
|
|
216
|
+
lineData.words = { timings };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
lines.push(lineData);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Build kara data structure for m4a-stems
|
|
224
|
+
// Note: Audio sources are read from the NI Stems 'stem' atom, not stored in kara
|
|
225
|
+
const karaData = {
|
|
226
|
+
// Timing information
|
|
227
|
+
timing: {
|
|
228
|
+
offset_sec: 0,
|
|
229
|
+
encoder_delay_samples: 0,
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
// Tags for filtering (e.g., 'edited', 'ai_corrected')
|
|
233
|
+
tags: tags || [],
|
|
234
|
+
|
|
235
|
+
// Lyrics (lines)
|
|
236
|
+
lines: lines,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Add LLM corrections metadata if available
|
|
240
|
+
// Uses same structure as KAI format for consistency with SongEditor
|
|
241
|
+
if (
|
|
242
|
+
llmCorrections &&
|
|
243
|
+
(llmCorrections.corrections?.length > 0 || llmCorrections.missing_lines?.length > 0)
|
|
244
|
+
) {
|
|
245
|
+
karaData.meta = {
|
|
246
|
+
corrections: {
|
|
247
|
+
// Applied corrections (for reference/audit)
|
|
248
|
+
applied: (llmCorrections.corrections || []).map((c) => ({
|
|
249
|
+
line: c.line_num,
|
|
250
|
+
start: c.start_time,
|
|
251
|
+
end: c.end_time,
|
|
252
|
+
old: c.old_text,
|
|
253
|
+
new: c.new_text,
|
|
254
|
+
reason: c.reason,
|
|
255
|
+
word_retention: c.retention_rate,
|
|
256
|
+
})),
|
|
257
|
+
// Suggested missing lines (user can review/add in editor)
|
|
258
|
+
missing_lines_suggested: (llmCorrections.missing_lines || []).map((s) => ({
|
|
259
|
+
suggested_text: s.suggested_text,
|
|
260
|
+
start: s.start_time,
|
|
261
|
+
end: s.end_time,
|
|
262
|
+
confidence: s.confidence,
|
|
263
|
+
reason: s.reason,
|
|
264
|
+
})),
|
|
265
|
+
// Stats
|
|
266
|
+
provider: llmCorrections.provider,
|
|
267
|
+
model: llmCorrections.model,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Write kara atom using m4a-stems library
|
|
273
|
+
console.log(`š¾ Writing kara atom: ${lines.length} lines`);
|
|
274
|
+
await M4AAtoms.writeKaraAtom(filePath, karaData);
|
|
275
|
+
|
|
276
|
+
// Verify final file size (debug)
|
|
277
|
+
const { stat } = await import('fs/promises');
|
|
278
|
+
const finalSize = (await stat(filePath)).size;
|
|
279
|
+
console.log(`š Final file size after kara atom: ${finalSize} bytes`);
|
|
280
|
+
|
|
281
|
+
// Note: Vocal pitch tracking is done at runtime, not stored in file.
|
|
282
|
+
// CREPE output is used only for key detection (stored in standard metadata).
|
|
283
|
+
|
|
284
|
+
console.log('ā
Karaoke atoms written successfully');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Inject lyrics into an existing .stem.m4a file
|
|
289
|
+
* Used for "lyrics only" mode when stems already exist
|
|
290
|
+
*
|
|
291
|
+
* @param {Object} options - Injection options
|
|
292
|
+
* @param {string} options.filePath - Path to existing .stem.m4a file
|
|
293
|
+
* @param {Object} options.lyrics - Whisper transcription result with word timestamps
|
|
294
|
+
* @param {Object} options.llmCorrections - LLM correction stats
|
|
295
|
+
* @param {string[]} options.tags - Tags array for filtering
|
|
296
|
+
* @returns {Promise<void>}
|
|
297
|
+
*/
|
|
298
|
+
export async function injectLyricsIntoStemFile(options) {
|
|
299
|
+
const { filePath, lyrics, llmCorrections, tags } = options;
|
|
300
|
+
|
|
301
|
+
console.log(`š¤ Injecting lyrics into existing stem file: ${filePath}`);
|
|
302
|
+
|
|
303
|
+
// Read existing kara atom to preserve timing/tags
|
|
304
|
+
let existingKara = null;
|
|
305
|
+
try {
|
|
306
|
+
existingKara = await M4AAtoms.readKaraAtom(filePath);
|
|
307
|
+
} catch {
|
|
308
|
+
// No existing kara atom - that's fine
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Build kara data structure with word-level timing if available
|
|
312
|
+
const lines = [];
|
|
313
|
+
if (lyrics && lyrics.lines && lyrics.lines.length > 0) {
|
|
314
|
+
const words = lyrics.words || [];
|
|
315
|
+
|
|
316
|
+
for (const line of lyrics.lines) {
|
|
317
|
+
const lineData = {
|
|
318
|
+
start: line.start,
|
|
319
|
+
end: line.end,
|
|
320
|
+
text: line.text,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Find words that fall within this line's time range
|
|
324
|
+
const lineWords = words.filter((w) => w.start >= line.start && w.start < line.end);
|
|
325
|
+
|
|
326
|
+
if (lineWords.length > 0) {
|
|
327
|
+
// Compute relative timings: [startOffset, endOffset] from line.start
|
|
328
|
+
const timings = lineWords.map((w) => [
|
|
329
|
+
Math.round((w.start - line.start) * 1000) / 1000,
|
|
330
|
+
Math.round(((w.end || w.start + 0.1) - line.start) * 1000) / 1000,
|
|
331
|
+
]);
|
|
332
|
+
lineData.words = { timings };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
lines.push(lineData);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Note: Audio sources are read from the NI Stems 'stem' atom, not stored in kara
|
|
340
|
+
const karaData = {
|
|
341
|
+
timing: {
|
|
342
|
+
offset_sec: existingKara?.timing?.offset_sec || 0,
|
|
343
|
+
encoder_delay_samples: existingKara?.timing?.encoder_delay_samples || 0,
|
|
344
|
+
},
|
|
345
|
+
tags: tags || [],
|
|
346
|
+
lines: lines,
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// Add LLM corrections metadata if available
|
|
350
|
+
if (
|
|
351
|
+
llmCorrections &&
|
|
352
|
+
(llmCorrections.corrections?.length > 0 || llmCorrections.missing_lines?.length > 0)
|
|
353
|
+
) {
|
|
354
|
+
karaData.meta = {
|
|
355
|
+
corrections: {
|
|
356
|
+
applied: (llmCorrections.corrections || []).map((c) => ({
|
|
357
|
+
line: c.line_num,
|
|
358
|
+
start: c.start_time,
|
|
359
|
+
end: c.end_time,
|
|
360
|
+
old: c.old_text,
|
|
361
|
+
new: c.new_text,
|
|
362
|
+
reason: c.reason,
|
|
363
|
+
word_retention: c.retention_rate,
|
|
364
|
+
})),
|
|
365
|
+
missing_lines_suggested: (llmCorrections.missing_lines || []).map((s) => ({
|
|
366
|
+
suggested_text: s.suggested_text,
|
|
367
|
+
start: s.start_time,
|
|
368
|
+
end: s.end_time,
|
|
369
|
+
confidence: s.confidence,
|
|
370
|
+
reason: s.reason,
|
|
371
|
+
})),
|
|
372
|
+
provider: llmCorrections.provider,
|
|
373
|
+
model: llmCorrections.model,
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Write kara atom
|
|
379
|
+
console.log(`š¾ Writing kara atom: ${lines.length} lines`);
|
|
380
|
+
await M4AAtoms.writeKaraAtom(filePath, karaData);
|
|
381
|
+
|
|
382
|
+
console.log('ā
Lyrics injected successfully');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Repair an existing .stem.m4a file to fix NI Stems metadata
|
|
387
|
+
* This fixes files created before the spec-compliant stem atom was implemented
|
|
388
|
+
*
|
|
389
|
+
* @param {string} filePath - Path to existing .stem.m4a file
|
|
390
|
+
* @param {Object} options - Repair options
|
|
391
|
+
* @param {boolean} options.force - Force rewrite even if metadata exists
|
|
392
|
+
* @returns {Promise<Object>} Repair result
|
|
393
|
+
*/
|
|
394
|
+
export async function repairStemFile(filePath, options = {}) {
|
|
395
|
+
console.log(`š§ Checking stem file: ${filePath}`);
|
|
396
|
+
|
|
397
|
+
// Default NI Stems order (excluding master, which is track 0)
|
|
398
|
+
const stemPartsOnly = ['drums', 'bass', 'other', 'vocals'];
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
// Check if NI Stems metadata already exists
|
|
402
|
+
let existingMetadata = null;
|
|
403
|
+
try {
|
|
404
|
+
existingMetadata = await M4AAtoms.readNiStemsMetadata(filePath);
|
|
405
|
+
} catch {
|
|
406
|
+
// No existing metadata
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (existingMetadata && existingMetadata.stems && !options.force) {
|
|
410
|
+
const existingStems = existingMetadata.stems.map((s) => s.name).join(', ');
|
|
411
|
+
console.log(`ā
File already has valid NI Stems metadata: ${existingStems}`);
|
|
412
|
+
console.log(' Use --force to rewrite anyway.');
|
|
413
|
+
return {
|
|
414
|
+
success: true,
|
|
415
|
+
filePath,
|
|
416
|
+
alreadyValid: true,
|
|
417
|
+
existingStems: existingMetadata.stems.map((s) => s.name),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Write the stem atom with correct 4-stem metadata
|
|
422
|
+
if (existingMetadata) {
|
|
423
|
+
console.log(`š Force rewriting NI Stems metadata for ${stemPartsOnly.length} stem parts`);
|
|
424
|
+
} else {
|
|
425
|
+
console.log(`šļø Adding NI Stems metadata for ${stemPartsOnly.length} stem parts`);
|
|
426
|
+
}
|
|
427
|
+
await M4AAtoms.addNiStemsMetadata(filePath, stemPartsOnly);
|
|
428
|
+
|
|
429
|
+
console.log('ā
Stem file repaired successfully');
|
|
430
|
+
console.log('ā ļø Note: Track disposition flags cannot be fixed without re-encoding.');
|
|
431
|
+
console.log(' File should work in Mixxx/Traktor but may play wrong track in some players.');
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
success: true,
|
|
435
|
+
filePath,
|
|
436
|
+
stemsFixed: stemPartsOnly,
|
|
437
|
+
};
|
|
438
|
+
} catch (error) {
|
|
439
|
+
console.error('ā Failed to repair stem file:', error.message);
|
|
440
|
+
return {
|
|
441
|
+
success: false,
|
|
442
|
+
error: error.message,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Batch repair multiple stem files
|
|
449
|
+
* @param {string[]} filePaths - Array of paths to .stem.m4a files
|
|
450
|
+
* @param {Object} options - Repair options (passed to each repairStemFile call)
|
|
451
|
+
* @returns {Promise<Object>} Batch repair results
|
|
452
|
+
*/
|
|
453
|
+
export async function repairStemFiles(filePaths, options = {}) {
|
|
454
|
+
console.log(`š§ Batch checking ${filePaths.length} stem files...`);
|
|
455
|
+
|
|
456
|
+
const results = {
|
|
457
|
+
total: filePaths.length,
|
|
458
|
+
success: 0,
|
|
459
|
+
failed: 0,
|
|
460
|
+
alreadyValid: 0,
|
|
461
|
+
repaired: 0,
|
|
462
|
+
files: [],
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
for (const filePath of filePaths) {
|
|
466
|
+
const result = await repairStemFile(filePath, options);
|
|
467
|
+
results.files.push(result);
|
|
468
|
+
if (result.success) {
|
|
469
|
+
results.success++;
|
|
470
|
+
if (result.alreadyValid) {
|
|
471
|
+
results.alreadyValid++;
|
|
472
|
+
} else {
|
|
473
|
+
results.repaired++;
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
results.failed++;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
console.log(
|
|
481
|
+
`\nš Complete: ${results.alreadyValid} already valid, ${results.repaired} repaired, ${results.failed} failed`
|
|
482
|
+
);
|
|
483
|
+
return results;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export default {
|
|
487
|
+
buildStemM4a,
|
|
488
|
+
injectLyricsIntoStemFile,
|
|
489
|
+
repairStemFile,
|
|
490
|
+
repairStemFiles,
|
|
491
|
+
};
|