diff --git a/assets b/assets index 946cf0082..cb7a863e4 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 946cf00829416b879881427d4e2fe09a09cb79ce +Subproject commit cb7a863e4ab5a563828436be63c9f8cbbf09b4c5 diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx index d557bd39c..9340e46c9 100644 --- a/source/funkin/data/song/SongData.hx +++ b/source/funkin/data/song/SongData.hx @@ -4,6 +4,7 @@ import flixel.util.typeLimit.OneOfTwo; import funkin.data.song.SongRegistry; import thx.semver.Version; +@:nullSafety class SongMetadata { /** @@ -42,7 +43,7 @@ class SongMetadata public var timeChanges:Array<SongTimeChange>; /** - * Defaults to `default` or `''`. Populated later. + * Defaults to `Constants.DEFAULT_VARIATION`. Populated later. */ @:jignored public var variation:String; @@ -228,10 +229,10 @@ class SongMusicData public var timeChanges:Array<SongTimeChange>; /** - * Defaults to `default` or `''`. Populated later. + * Defaults to `Constants.DEFAULT_VARIATION`. Populated later. */ @:jignored - public var variation:String = Constants.DEFAULT_VARIATION; + public var variation:String; public function new(songName:String, artist:String, variation:String = 'default') { @@ -375,6 +376,9 @@ class SongChartData @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) public var generatedBy:String; + /** + * Defaults to `Constants.DEFAULT_VARIATION`. Populated later. + */ @:jignored public var variation:String; diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx index cf2da14f7..889fca707 100644 --- a/source/funkin/data/song/SongRegistry.hx +++ b/source/funkin/data/song/SongRegistry.hx @@ -156,7 +156,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> return cleanMetadata(parser.value, variation); } - public function parseEntryMetadataWithMigration(id:String, ?variation:String, version:thx.semver.Version):Null<SongMetadata> + public function parseEntryMetadataWithMigration(id:String, variation:String, version:thx.semver.Version):Null<SongMetadata> { variation = variation == null ? Constants.DEFAULT_VARIATION : variation; @@ -192,7 +192,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> } } - function parseEntryMetadata_v2_0_0(id:String, variation:String = ""):Null<SongMetadata> + function parseEntryMetadata_v2_0_0(id:String, ?variation:String):Null<SongMetadata> { variation = variation == null ? Constants.DEFAULT_VARIATION : variation; diff --git a/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx index e852dff0a..b5a6f36be 100644 --- a/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx @@ -1,11 +1,14 @@ package funkin.ui.debug.charting; -import openfl.utils.Assets; import flixel.system.FlxAssets.FlxSoundAsset; import flixel.system.FlxSound; -import funkin.play.character.BaseCharacter.CharacterType; import flixel.system.FlxSound; +import funkin.audio.VoicesGroup; +import funkin.play.character.BaseCharacter.CharacterType; +import funkin.util.FileUtil; +import haxe.io.Bytes; import haxe.io.Path; +import openfl.utils.Assets; /** * Functions for loading audio for the chart editor. @@ -17,16 +20,18 @@ import haxe.io.Path; class ChartEditorAudioHandler { /** - * Loads a vocal track from an absolute file path. + * Loads and stores byte data for a vocal track from an absolute file path + * * @param path The absolute path to the audio file. - * @param charKey The character to load the vocal track for. + * @param charId The character this vocal track will be for. + * @param instId The instrumental this vocal track will be for. * @return Success or failure. */ - static function loadVocalsFromPath(state:ChartEditorState, path:Path, charKey:String = 'default'):Bool + static function loadVocalsFromPath(state:ChartEditorState, path:Path, charId:String, instId:String = ''):Bool { #if sys - var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString()); - return loadVocalsFromBytes(state, fileBytes, charKey); + var fileBytes:Bytes = sys.io.File.getBytes(path.toString()); + return loadVocalsFromBytes(state, fileBytes, charId, instId); #else trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); return false; @@ -34,137 +39,235 @@ class ChartEditorAudioHandler } /** - * Load a vocal track for a given song and character and add it to the voices group. + * Loads and stores byte data for a vocal track from an asset * - * @param path ID of the asset. - * @param charKey Character to load the vocal track for. + * @param path The path to the asset. Use `Paths` to build this. + * @param charId The character this vocal track will be for. + * @param instId The instrumental this vocal track will be for. * @return Success or failure. */ - static function loadVocalsFromAsset(state:ChartEditorState, path:String, charType:CharacterType = OTHER):Bool + static function loadVocalsFromAsset(state:ChartEditorState, path:String, charId:String, instId:String = ''):Bool { - var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false); + var trackData:Null<Bytes> = Assets.getBytes(path); + if (trackData != null) + { + return loadVocalsFromBytes(state, trackData, charId, instId); + } + return false; + } + + /** + * Loads and stores byte data for a vocal track + * + * @param bytes The audio byte data. + * @param charId The character this vocal track will be for. + * @param instId The instrumental this vocal track will be for. + */ + static function loadVocalsFromBytes(state:ChartEditorState, bytes:Bytes, charId:String, instId:String = ''):Bool + { + var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}'; + state.audioVocalTrackData.set(trackId, bytes); + return true; + } + + /** + * Loads and stores byte data for an instrumental track from an absolute file path + * + * @param path The absolute path to the audio file. + * @param instId The instrumental this vocal track will be for. + * @return Success or failure. + */ + static function loadInstFromPath(state:ChartEditorState, path:Path, instId:String = ''):Bool + { + #if sys + var fileBytes:Bytes = sys.io.File.getBytes(path.toString()); + return loadInstFromBytes(state, fileBytes, instId); + #else + trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); + return false; + #end + } + + /** + * Loads and stores byte data for an instrumental track from an asset + * + * @param path The path to the asset. Use `Paths` to build this. + * @param instId The instrumental this vocal track will be for. + * @return Success or failure. + */ + static function loadInstFromAsset(state:ChartEditorState, path:String, instId:String = ''):Bool + { + var trackData:Null<Bytes> = Assets.getBytes(path); + if (trackData != null) + { + return loadInstFromBytes(state, trackData, instId); + } + return false; + } + + /** + * Loads and stores byte data for a vocal track + * + * @param bytes The audio byte data. + * @param charId The character this vocal track will be for. + * @param instId The instrumental this vocal track will be for. + */ + static function loadInstFromBytes(state:ChartEditorState, bytes:Bytes, instId:String = ''):Bool + { + if (instId == '') instId = 'default'; + state.audioInstTrackData.set(instId, bytes); + return true; + } + + public static function switchToInstrumental(state:ChartEditorState, instId:String = '', playerId:String, opponentId:String):Bool + { + var result:Bool = playInstrumental(state, instId); + if (!result) return false; + + stopExistingVocals(state); + result = playVocals(state, BF, playerId, instId); + if (!result) return false; + result = playVocals(state, DAD, opponentId, instId); + if (!result) return false; + + return true; + } + + /** + * Tell the Chart Editor to select a specific instrumental track, that is already loaded. + */ + static function playInstrumental(state:ChartEditorState, instId:String = ''):Bool + { + if (instId == '') instId = 'default'; + var instTrackData:Null<Bytes> = state.audioInstTrackData.get(instId); + var instTrack:Null<FlxSound> = buildFlxSoundFromBytes(instTrackData); + if (instTrack == null) return false; + + stopExistingInstrumental(state); + state.audioInstTrack = instTrack; + state.postLoadInstrumental(); + return true; + } + + static function stopExistingInstrumental(state:ChartEditorState):Void + { + if (state.audioInstTrack != null) + { + state.audioInstTrack.stop(); + state.audioInstTrack.destroy(); + state.audioInstTrack = null; + } + } + + /** + * Tell the Chart Editor to select a specific vocal track, that is already loaded. + */ + static function playVocals(state:ChartEditorState, charType:CharacterType, charId:String, instId:String = ''):Bool + { + var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}'; + var vocalTrackData:Null<Bytes> = state.audioVocalTrackData.get(trackId); + var vocalTrack:Null<FlxSound> = buildFlxSoundFromBytes(vocalTrackData); + + if (state.audioVocalTrackGroup == null) state.audioVocalTrackGroup = new VoicesGroup(); + if (vocalTrack != null) { switch (charType) { - case CharacterType.BF: - if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.addPlayerVoice(vocalTrack); - state.audioVocalTrackData.set(state.currentSongCharacterPlayer, Assets.getBytes(path)); - case CharacterType.DAD: - if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.addOpponentVoice(vocalTrack); - state.audioVocalTrackData.set(state.currentSongCharacterOpponent, Assets.getBytes(path)); + case BF: + state.audioVocalTrackGroup.addPlayerVoice(vocalTrack); + return true; + case DAD: + state.audioVocalTrackGroup.addOpponentVoice(vocalTrack); + return true; + case OTHER: + state.audioVocalTrackGroup.add(vocalTrack); + return true; default: - if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.add(vocalTrack); - state.audioVocalTrackData.set('default', Assets.getBytes(path)); + // Do nothing. } - - return true; } return false; } - /** - * Loads a vocal track from audio byte data. - */ - static function loadVocalsFromBytes(state:ChartEditorState, bytes:haxe.io.Bytes, charKey:String = ''):Bool + static function stopExistingVocals(state:ChartEditorState):Void { - var openflSound:openfl.media.Sound = new openfl.media.Sound(); - openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length); - var vocalTrack:FlxSound = FlxG.sound.load(openflSound, 1.0, false); - if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.add(vocalTrack); - state.audioVocalTrackData.set(charKey, bytes); - return true; - } - - /** - * Loads an instrumental from an absolute file path, replacing the current instrumental. - * - * @param path The absolute path to the audio file. - * - * @return Success or failure. - */ - static function loadInstrumentalFromPath(state:ChartEditorState, path:Path):Bool - { - #if sys - // Validate file extension. - if (path.ext != null && !ChartEditorState.SUPPORTED_MUSIC_FORMATS.contains(path.ext)) + if (state.audioVocalTrackGroup != null) { - return false; + state.audioVocalTrackGroup.clear(); } - - var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString()); - return loadInstrumentalFromBytes(state, fileBytes, '${path.file}.${path.ext}'); - #else - trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); - return false; - #end - } - - /** - * Loads an instrumental from audio byte data, replacing the current instrumental. - * @param bytes The audio byte data. - * @param fileName The name of the file, if available. Used for notifications. - * @return Success or failure. - */ - static function loadInstrumentalFromBytes(state:ChartEditorState, bytes:haxe.io.Bytes, fileName:String = null):Bool - { - if (bytes == null) - { - return false; - } - - var openflSound:openfl.media.Sound = new openfl.media.Sound(); - openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length); - state.audioInstTrack = FlxG.sound.load(openflSound, 1.0, false); - state.audioInstTrack.autoDestroy = false; - state.audioInstTrack.pause(); - - state.audioInstTrackData = bytes; - - state.postLoadInstrumental(); - - return true; - } - - /** - * Loads an instrumental from an OpenFL asset, replacing the current instrumental. - * @param path The path to the asset. Use `Paths` to build this. - * @return Success or failure. - */ - static function loadInstrumentalFromAsset(state:ChartEditorState, path:String):Bool - { - var instTrack:FlxSound = FlxG.sound.load(path, 1.0, false); - if (instTrack != null) - { - state.audioInstTrack = instTrack; - - state.audioInstTrackData = Assets.getBytes(path); - - state.postLoadInstrumental(); - return true; - } - - return false; } /** * Play a sound effect. * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance. + * @param path The path to the sound effect. Use `Paths` to build this. */ public static function playSound(path:String):Void { var snd:FlxSound = FlxG.sound.list.recycle(FlxSound) ?? new FlxSound(); - var asset:Null<FlxSoundAsset> = FlxG.sound.cache(path); if (asset == null) { trace('WARN: Failed to play sound $path, asset not found.'); return; } - snd.loadEmbedded(asset); snd.autoDestroy = true; FlxG.sound.list.add(snd); snd.play(); } + + /** + * Convert byte data into a playable sound. + * + * @param input The byte data. + * @return The playable sound, or `null` if loading failed. + */ + public static function buildFlxSoundFromBytes(input:Null<Bytes>):Null<FlxSound> + { + if (input == null) return null; + + var openflSound:openfl.media.Sound = new openfl.media.Sound(); + openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(input), input.length); + var output:FlxSound = FlxG.sound.load(openflSound, 1.0, false); + return output; + } + + static function makeZIPEntriesFromInstrumentals(state:ChartEditorState):Array<haxe.zip.Entry> + { + var zipEntries = []; + + for (key in state.audioInstTrackData.keys()) + { + if (key == 'default') + { + var data:Null<Bytes> = state.audioInstTrackData.get('default'); + if (data == null) continue; + zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst.ogg', data)); + } + else + { + var data:Null<Bytes> = state.audioInstTrackData.get(key); + if (data == null) continue; + zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst-${key}.ogg', data)); + } + } + + return zipEntries; + } + + static function makeZIPEntriesFromVocals(state:ChartEditorState):Array<haxe.zip.Entry> + { + var zipEntries = []; + + for (key in state.audioVocalTrackData.keys()) + { + var data:Null<Bytes> = state.audioVocalTrackData.get(key); + if (data == null) continue; + zipEntries.push(FileUtil.makeZIPEntryFromBytes('Vocals-${key}.ogg', data)); + } + + return zipEntries; + } } diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx index 736851d16..30f0381c6 100644 --- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx @@ -83,7 +83,7 @@ class ChartEditorDialogHandler var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_WELCOME_LAYOUT, true, closable); if (dialog == null) throw 'Could not locate Welcome dialog'; - // Add handlers to the "Create From Song" section. + // Create New Song "Easy/Normal/Hard" var linkCreateBasic:Null<Link> = dialog.findComponent('splashCreateFromSongBasic', Link); if (linkCreateBasic == null) throw 'Could not locate splashCreateFromSongBasic link in Welcome dialog'; linkCreateBasic.onClick = function(_event) { @@ -94,7 +94,20 @@ class ChartEditorDialogHandler // // Create Song Wizard // - openCreateSongWizard(state, false); + openCreateSongWizardBasic(state, false); + } + + // Create New Song "Erect/Nightmare" + var linkCreateErect:Null<Link> = dialog.findComponent('splashCreateFromSongErect', Link); + if (linkCreateErect == null) throw 'Could not locate splashCreateFromSongErect link in Welcome dialog'; + linkCreateErect.onClick = function(_event) { + // Hide the welcome dialog + dialog.hideDialog(DialogButton.CANCEL); + + // + // Create Song Wizard + // + openCreateSongWizardErect(state, false); } var linkImportChartLegacy:Null<Link> = dialog.findComponent('splashImportChartLegacy', Link); @@ -237,34 +250,112 @@ class ChartEditorDialogHandler }; } - public static function openCreateSongWizard(state:ChartEditorState, closable:Bool):Void + public static function openCreateSongWizardBasic(state:ChartEditorState, closable:Bool):Void { - // Step 1. Upload Instrumental - var uploadInstDialog:Dialog = openUploadInstDialog(state, closable); - uploadInstDialog.onDialogClosed = function(_event) { + // Step 1. Song Metadata + var songMetadataDialog:Dialog = openSongMetadataDialog(state); + songMetadataDialog.onDialogClosed = function(_event) { state.isHaxeUIDialogOpen = false; if (_event.button == DialogButton.APPLY) { - // Step 2. Song Metadata - var songMetadataDialog:Dialog = openSongMetadataDialog(state); - songMetadataDialog.onDialogClosed = function(_event) { + // Step 2. Upload Instrumental + var uploadInstDialog:Dialog = openUploadInstDialog(state, closable); + uploadInstDialog.onDialogClosed = function(_event) { state.isHaxeUIDialogOpen = false; if (_event.button == DialogButton.APPLY) { // Step 3. Upload Vocals // NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard. - openUploadVocalsDialog(state, false); // var uploadVocalsDialog:Dialog + var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog + uploadVocalsDialog.onDialogClosed = function(_event) { + state.isHaxeUIDialogOpen = false; + state.switchToCurrentInstrumental(); + state.postLoadInstrumental(); + } } else { - // User cancelled the wizard! Back to the welcome dialog. + // User cancelled the wizard at Step 2! Back to the welcome dialog. openWelcomeDialog(state); } }; } else { - // User cancelled the wizard! Back to the welcome dialog. + // User cancelled the wizard at Step 1! Back to the welcome dialog. + openWelcomeDialog(state); + } + }; + } + + public static function openCreateSongWizardErect(state:ChartEditorState, closable:Bool):Void + { + // Step 1. Song Metadata + var songMetadataDialog:Dialog = openSongMetadataDialog(state); + songMetadataDialog.onDialogClosed = function(_event) { + state.isHaxeUIDialogOpen = false; + if (_event.button == DialogButton.APPLY) + { + // Step 2. Upload Instrumental + var uploadInstDialog:Dialog = openUploadInstDialog(state, closable); + uploadInstDialog.onDialogClosed = function(_event) { + state.isHaxeUIDialogOpen = false; + if (_event.button == DialogButton.APPLY) + { + // Step 3. Upload Vocals + // NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard. + var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog + uploadVocalsDialog.onDialogClosed = function(_event) { + state.switchToCurrentInstrumental(); + // Step 4. Song Metadata (Erect) + var songMetadataDialogErect:Dialog = openSongMetadataDialog(state, 'erect'); + songMetadataDialogErect.onDialogClosed = function(_event) { + state.isHaxeUIDialogOpen = false; + if (_event.button == DialogButton.APPLY) + { + // Switch to the Erect variation so uploading the instrumental applies properly. + state.selectedVariation = 'erect'; + + // Step 5. Upload Instrumental (Erect) + var uploadInstDialogErect:Dialog = openUploadInstDialog(state, closable); + uploadInstDialogErect.onDialogClosed = function(_event) { + state.isHaxeUIDialogOpen = false; + if (_event.button == DialogButton.APPLY) + { + // Step 6. Upload Vocals (Erect) + // NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard. + var uploadVocalsDialogErect:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog + uploadVocalsDialogErect.onDialogClosed = function(_event) { + state.isHaxeUIDialogOpen = false; + state.switchToCurrentInstrumental(); + state.postLoadInstrumental(); + } + } + else + { + // User cancelled the wizard at Step 5! Back to the welcome dialog. + openWelcomeDialog(state); + } + }; + } + else + { + // User cancelled the wizard at Step 4! Back to the welcome dialog. + openWelcomeDialog(state); + } + } + } + } + else + { + // User cancelled the wizard at Step 2! Back to the welcome dialog. + openWelcomeDialog(state); + } + }; + } + else + { + // User cancelled the wizard at Step 1! Back to the welcome dialog. openWelcomeDialog(state); } }; @@ -302,6 +393,8 @@ class ChartEditorDialogHandler Cursor.cursorMode = Default; } + var instId:String = state.currentInstrumentalId; + var onDropFile:String->Void; instrumentalBox.onClick = function(_event) { @@ -309,14 +402,14 @@ class ChartEditorDialogHandler {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile:SelectedFileInfo) { if (selectedFile != null && selectedFile.bytes != null) { - if (ChartEditorAudioHandler.loadInstrumentalFromBytes(state, selectedFile.bytes)) + if (ChartEditorAudioHandler.loadInstFromBytes(state, selectedFile.bytes, instId)) { trace('Selected file: ' + selectedFile.fullPath); #if !mac NotificationManager.instance.addNotification( { title: 'Success', - body: 'Loaded instrumental track (${selectedFile.name})', + body: 'Loaded instrumental track (${selectedFile.name}) for variation (${state.selectedVariation})', type: NotificationType.Success, expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME }); @@ -333,7 +426,7 @@ class ChartEditorDialogHandler NotificationManager.instance.addNotification( { title: 'Failure', - body: 'Failed to load instrumental track (${selectedFile.name})', + body: 'Failed to load instrumental track (${selectedFile.name}) for variation (${state.selectedVariation})', type: NotificationType.Error, expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME }); @@ -346,14 +439,14 @@ class ChartEditorDialogHandler onDropFile = function(pathStr:String) { var path:Path = new Path(pathStr); trace('Dropped file (${path})'); - if (ChartEditorAudioHandler.loadInstrumentalFromPath(state, path)) + if (ChartEditorAudioHandler.loadInstFromPath(state, path, instId)) { // Tell the user the load was successful. #if !mac NotificationManager.instance.addNotification( { title: 'Success', - body: 'Loaded instrumental track (${path.file}.${path.ext})', + body: 'Loaded instrumental track (${path.file}.${path.ext}) for variation (${state.selectedVariation})', type: NotificationType.Success, expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME }); @@ -370,7 +463,7 @@ class ChartEditorDialogHandler } else { - 'Failed to load instrumental track (${path.file}.${path.ext})'; + 'Failed to load instrumental track (${path.file}.${path.ext}) for variation (${state.selectedVariation})'; } // Tell the user the load was successful. @@ -457,11 +550,18 @@ class ChartEditorDialogHandler * @return The dialog to open. */ @:haxe.warning("-WVarInit") - public static function openSongMetadataDialog(state:ChartEditorState):Dialog + public static function openSongMetadataDialog(state:ChartEditorState, ?targetVariation:String):Dialog { + if (targetVariation == null) targetVariation = Constants.DEFAULT_VARIATION; + var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT, true, false); if (dialog == null) throw 'Could not locate Song Metadata dialog'; + if (targetVariation != Constants.DEFAULT_VARIATION) + { + dialog.title = 'New Chart - Provide Song Metadata (${targetVariation.toTitleCase()})'; + } + var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button); if (buttonCancel == null) throw 'Could not locate dialogCancel button in Song Metadata dialog'; buttonCancel.onClick = function(_event) { @@ -574,7 +674,11 @@ class ChartEditorDialogHandler var dialogContinue:Null<Button> = dialog.findComponent('dialogContinue', Button); if (dialogContinue == null) throw 'Could not locate dialogContinue button in Song Metadata dialog'; - dialogContinue.onClick = (_event) -> dialog.hideDialog(DialogButton.APPLY); + dialogContinue.onClick = (_event) -> { + state.songMetadata.set(targetVariation, newSongMetadata); + + dialog.hideDialog(DialogButton.APPLY); + } return dialog; } @@ -587,6 +691,7 @@ class ChartEditorDialogHandler */ public static function openUploadVocalsDialog(state:ChartEditorState, closable:Bool = true):Dialog { + var instId:String = state.currentInstrumentalId; var charIdsForVocals:Array<String> = []; var charData:SongCharacterData = state.currentSongMetadata.playData.characters; @@ -633,14 +738,14 @@ class ChartEditorDialogHandler trace('Selected file: $pathStr'); var path:Path = new Path(pathStr); - if (ChartEditorAudioHandler.loadVocalsFromPath(state, path, charKey)) + if (ChartEditorAudioHandler.loadVocalsFromPath(state, path, charKey, instId)) { // Tell the user the load was successful. #if !mac NotificationManager.instance.addNotification( { title: 'Success', - body: 'Loaded vocal track for $charName (${path.file}.${path.ext})', + body: 'Loaded vocals for $charName (${path.file}.${path.ext}), variation ${state.selectedVariation}', type: NotificationType.Success, expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME }); @@ -656,21 +761,14 @@ class ChartEditorDialogHandler } else { - var message:String = if (!ChartEditorState.SUPPORTED_MUSIC_FORMATS.contains(path.ext ?? '')) - { - 'File format (${path.ext}) not supported for vocal track (${path.file}.${path.ext})'; - } - else - { - 'Failed to load vocal track (${path.file}.${path.ext})'; - } + trace('Failed to load vocal track (${path.file}.${path.ext})'); // Vocals failed to load. #if !mac NotificationManager.instance.addNotification( { title: 'Failure', - body: message, + body: 'Failed to load vocal track (${path.file}.${path.ext}) for variation (${state.selectedVariation})', type: NotificationType.Error, expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME }); @@ -690,14 +788,46 @@ class ChartEditorDialogHandler if (selectedFile != null && selectedFile.bytes != null) { trace('Selected file: ' + selectedFile.name); - #if FILE_DROP_SUPPORTED - vocalsEntryLabel.text = 'Vocals for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}'; - #else - vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${selectedFile.name}'; - #end - ChartEditorAudioHandler.loadVocalsFromBytes(state, selectedFile.bytes, charKey); - dialogNoVocals.hidden = true; - removeDropHandler(onDropFile); + if (ChartEditorAudioHandler.loadVocalsFromBytes(state, selectedFile.bytes, charKey, instId)) + { + // Tell the user the load was successful. + #if !mac + NotificationManager.instance.addNotification( + { + title: 'Success', + body: 'Loaded vocals for $charName (${selectedFile.name}), variation ${state.selectedVariation}', + type: NotificationType.Success, + expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME + }); + #end + #if FILE_DROP_SUPPORTED + vocalsEntryLabel.text = 'Vocals for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}'; + #else + vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${selectedFile.name}'; + #end + + dialogNoVocals.hidden = true; + } + else + { + trace('Failed to load vocal track (${selectedFile.fullPath})'); + + #if !mac + NotificationManager.instance.addNotification( + { + title: 'Failure', + body: 'Failed to load vocal track (${selectedFile.name}) for variation (${state.selectedVariation})', + type: NotificationType.Error, + expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME + }); + #end + + #if FILE_DROP_SUPPORTED + vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.'; + #else + vocalsEntryLabel.text = 'Click to browse for vocals for $charName.'; + #end + } } }); } diff --git a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx index f116ad3f1..4d8ff18cb 100644 --- a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx @@ -36,7 +36,7 @@ class ChartEditorImportExportHandler for (metadata in rawSongMetadata) { if (metadata == null) continue; - var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation; + var variation = (metadata.variation == null || metadata.variation == '') ? Constants.DEFAULT_VARIATION : metadata.variation; // Clone to prevent modifying the original. var metadataClone:SongMetadata = metadata.clone(variation); @@ -52,23 +52,44 @@ class ChartEditorImportExportHandler state.clearVocals(); - ChartEditorAudioHandler.loadInstrumentalFromAsset(state, Paths.inst(songId)); - - var diff:Null<SongDifficulty> = song.getDifficulty(state.selectedDifficulty); - var voiceList:Array<String> = diff != null ? diff.buildVoiceList() : []; - if (voiceList.length == 2) + var variations:Array<String> = state.availableVariations; + for (variation in variations) { - ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[0], BF); - ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[1], DAD); - } - else - { - for (voicePath in voiceList) + if (variation == Constants.DEFAULT_VARIATION) { - ChartEditorAudioHandler.loadVocalsFromAsset(state, voicePath); + ChartEditorAudioHandler.loadInstFromAsset(state, Paths.inst(songId)); + } + else + { + ChartEditorAudioHandler.loadInstFromAsset(state, Paths.inst(songId, '-$variation'), variation); } } + for (difficultyId in song.listDifficulties()) + { + var diff:Null<SongDifficulty> = song.getDifficulty(difficultyId); + if (diff == null) continue; + + var instId:String = diff.variation == Constants.DEFAULT_VARIATION ? '' : diff.variation; + var voiceList:Array<String> = diff.buildVoiceList(); // SongDifficulty accounts for variation already. + + if (voiceList.length == 2) + { + ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[0], diff.characters.player, instId); + ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[1], diff.characters.opponent, instId); + } + else if (voiceList.length == 1) + { + ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[0], diff.characters.player, instId); + } + else + { + trace('[WARN] Strange quantity of voice paths for difficulty ${difficultyId}: ${voiceList.length}'); + } + } + + state.switchToCurrentInstrumental(); + state.refreshMetadataToolbox(); #if !mac @@ -148,13 +169,8 @@ class ChartEditorImportExportHandler } } - if (state.audioInstTrackData != null) zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst.ogg', state.audioInstTrackData)); - for (charId in state.audioVocalTrackData.keys()) - { - var entryData = state.audioVocalTrackData.get(charId); - if (entryData == null) continue; - zipEntries.push(FileUtil.makeZIPEntryFromBytes('Vocals-$charId.ogg', entryData)); - } + if (state.audioInstTrackData != null) zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromInstrumentals(state)); + if (state.audioVocalTrackData != null) zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromVocals(state)); trace('Exporting ${zipEntries.length} files to ZIP...'); diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index 5e4dded91..2f5222cd5 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -461,6 +461,8 @@ class ChartEditorState extends HaxeUIState notePreviewDirty = true; notePreviewViewportBoundsDirty = true; this.scrollPositionInPixels = this.scrollPositionInPixels; + // Characters have probably changed too. + healthIconsDirty = true; return isViewDownscroll; } @@ -519,8 +521,14 @@ class ChartEditorState extends HaxeUIState */ var selectedVariation(default, set):String = Constants.DEFAULT_VARIATION; + /** + * Setter called when we are switching variations. + * We will likely need to switch instrumentals as well. + */ function set_selectedVariation(value:String):String { + // Don't update if we're already on the variation. + if (selectedVariation == value) return selectedVariation; selectedVariation = value; // Make sure view is updated when the variation changes. @@ -528,6 +536,8 @@ class ChartEditorState extends HaxeUIState notePreviewDirty = true; notePreviewViewportBoundsDirty = true; + switchToCurrentInstrumental(); + return selectedVariation; } @@ -548,6 +558,23 @@ class ChartEditorState extends HaxeUIState return selectedDifficulty; } + /** + * The instrumental ID which is currently selected. + */ + var currentInstrumentalId(get, set):String; + + function get_currentInstrumentalId():String + { + var instId:Null<String> = currentSongMetadata.playData.characters.instrumental; + if (instId == null || instId == '') instId = (selectedVariation == Constants.DEFAULT_VARIATION) ? '' : selectedVariation; + return instId; + } + + function set_currentInstrumentalId(value:String):String + { + return currentSongMetadata.playData.characters.instrumental = value; + } + /** * The character ID for the character which is currently selected. */ @@ -592,6 +619,11 @@ class ChartEditorState extends HaxeUIState */ var noteDisplayDirty:Bool = true; + /** + * Whether the selected charactesr have been modified and the health icons need to be updated. + */ + var healthIconsDirty:Bool = true; + /** * Whether the note preview graphic needs to be FULLY rebuilt. */ @@ -773,28 +805,29 @@ class ChartEditorState extends HaxeUIState /** * The audio track for the instrumental. + * Replaced when switching instrumentals. * `null` until an instrumental track is loaded. */ var audioInstTrack:Null<FlxSound> = null; /** - * The raw byte data for the instrumental audio track. + * The raw byte data for the instrumental audio tracks. + * Key is the instrumental name. * `null` until an instrumental track is loaded. */ - var audioInstTrackData:Null<Bytes> = null; + var audioInstTrackData:Map<String, Bytes> = []; /** * The audio track for the vocals. * `null` until vocal track(s) are loaded. + * When switching characters, the elements of the VoicesGroup will be swapped to match the new character. */ var audioVocalTrackGroup:Null<VoicesGroup> = null; /** * A map of the audio tracks for each character's vocals. - * - Keys are the character IDs. - * - Values are the FlxSound objects to play that character's vocals. - * - * When switching characters, the elements of the VoicesGroup will be swapped to match the new character. + * - Keys are `characterId-variation` (with `characterId` being the default variation). + * - Values are the byte data for the audio track. */ var audioVocalTrackData:Map<String, Bytes> = []; @@ -1045,30 +1078,6 @@ class ChartEditorState extends HaxeUIState return currentSongMetadata.artist = value; } - var currentSongCharacterPlayer(get, set):String; - - function get_currentSongCharacterPlayer():String - { - return currentSongMetadata.playData.characters.player; - } - - function set_currentSongCharacterPlayer(value:String):String - { - return currentSongMetadata.playData.characters.player = value; - } - - var currentSongCharacterOpponent(get, set):String; - - function get_currentSongCharacterOpponent():String - { - return currentSongMetadata.playData.characters.opponent; - } - - function set_currentSongCharacterOpponent(value:String):String - { - return currentSongMetadata.playData.characters.opponent = value; - } - /** * SIGNALS */ @@ -1379,7 +1388,7 @@ class ChartEditorState extends HaxeUIState gridPlayhead.add(playheadBlock); // Character icons. - healthIconDad = new HealthIcon(currentSongCharacterOpponent); + healthIconDad = new HealthIcon(currentSongMetadata.playData.characters.opponent); healthIconDad.autoUpdate = false; healthIconDad.size.set(0.5, 0.5); healthIconDad.x = gridTiledSprite.x - 15 - (HealthIcon.HEALTH_ICON_SIZE * 0.5); @@ -1387,7 +1396,7 @@ class ChartEditorState extends HaxeUIState add(healthIconDad); healthIconDad.zIndex = 30; - healthIconBF = new HealthIcon(currentSongCharacterPlayer); + healthIconBF = new HealthIcon(currentSongMetadata.playData.characters.player); healthIconBF.autoUpdate = false; healthIconBF.size.set(0.5, 0.5); healthIconBF.x = gridTiledSprite.x + gridTiledSprite.width + 15; @@ -1484,6 +1493,12 @@ class ChartEditorState extends HaxeUIState return bounds; } + public function switchToCurrentInstrumental():Void + { + ChartEditorAudioHandler.switchToInstrumental(this, currentInstrumentalId, currentSongMetadata.playData.characters.player, + currentSongMetadata.playData.characters.opponent); + } + function setNotePreviewViewportBounds(bounds:FlxRect = null):Void { if (notePreviewViewport == null) @@ -1691,6 +1706,7 @@ class ChartEditorState extends HaxeUIState }); addUIClickListener('menubarItemAbout', _ -> ChartEditorDialogHandler.openAboutDialog(this)); + addUIClickListener('menubarItemWelcomeDialog', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true)); addUIClickListener('menubarItemUserGuide', _ -> ChartEditorDialogHandler.openUserGuideDialog(this)); @@ -1713,6 +1729,11 @@ class ChartEditorState extends HaxeUIState }); setUICheckboxSelected('menuBarItemThemeDark', currentTheme == ChartEditorTheme.Dark); + addUIClickListener('menubarItemPlayPause', _ -> toggleAudioPlayback()); + + addUIClickListener('menubarItemLoadInstrumental', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true)); + addUIClickListener('menubarItemLoadVocals', _ -> ChartEditorDialogHandler.openUploadVocalsDialog(this, true)); + addUIChangeListener('menubarItemMetronomeEnabled', event -> isMetronomeEnabled = event.value); setUICheckboxSelected('menubarItemMetronomeEnabled', isMetronomeEnabled); @@ -1726,7 +1747,7 @@ class ChartEditorState extends HaxeUIState if (instVolumeLabel != null) { addUIChangeListener('menubarItemVolumeInstrumental', function(event:UIEvent) { - var volume:Float = event?.value ?? 0 / 100.0; + var volume:Float = (event?.value ?? 0) / 100.0; if (audioInstTrack != null) audioInstTrack.volume = volume; instVolumeLabel.text = 'Instrumental - ${Std.int(event.value)}%'; }); @@ -1736,7 +1757,7 @@ class ChartEditorState extends HaxeUIState if (vocalsVolumeLabel != null) { addUIChangeListener('menubarItemVolumeVocals', function(event:UIEvent) { - var volume:Float = event?.value ?? 0 / 100.0; + var volume:Float = (event?.value ?? 0) / 100.0; if (audioVocalTrackGroup != null) audioVocalTrackGroup.volume = volume; vocalsVolumeLabel.text = 'Vocals - ${Std.int(event.value)}%'; }); @@ -2986,6 +3007,12 @@ class ChartEditorState extends HaxeUIState */ function handleHealthIcons():Void { + if (healthIconsDirty) + { + if (healthIconBF != null) healthIconBF.characterId = currentSongMetadata.playData.characters.player; + if (healthIconDad != null) healthIconDad.characterId = currentSongMetadata.playData.characters.opponent; + } + // Right align the BF health icon. if (healthIconBF != null) { @@ -3413,11 +3440,11 @@ class ChartEditorState extends HaxeUIState { playerPreviewDirty = false; - if (currentSongCharacterPlayer != charPlayer.charId) + if (currentSongMetadata.playData.characters.player != charPlayer.charId) { - if (healthIconBF != null) healthIconBF.characterId = currentSongCharacterPlayer; + if (healthIconBF != null) healthIconBF.characterId = currentSongMetadata.playData.characters.player; - charPlayer.loadCharacter(currentSongCharacterPlayer); + charPlayer.loadCharacter(currentSongMetadata.playData.characters.player); charPlayer.characterType = CharacterType.BF; charPlayer.flip = true; charPlayer.targetScale = 0.5; @@ -3449,11 +3476,11 @@ class ChartEditorState extends HaxeUIState { opponentPreviewDirty = false; - if (currentSongCharacterOpponent != charPlayer.charId) + if (currentSongMetadata.playData.characters.opponent != charPlayer.charId) { - if (healthIconDad != null) healthIconDad.characterId = currentSongCharacterOpponent; + if (healthIconDad != null) healthIconDad.characterId = currentSongMetadata.playData.characters.opponent; - charPlayer.loadCharacter(currentSongCharacterOpponent); + charPlayer.loadCharacter(currentSongMetadata.playData.characters.opponent); charPlayer.characterType = CharacterType.DAD; charPlayer.flip = false; charPlayer.targetScale = 0.5; @@ -3833,9 +3860,9 @@ class ChartEditorState extends HaxeUIState switch (noteData.getStrumlineIndex()) { case 0: // Player - if (hitsoundsEnabledPlayer) ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-09')); + if (hitsoundsEnabledPlayer) ChartEditorAudioHandler.playSound(Paths.sound('ui/chart-editor/playerHitsound')); case 1: // Opponent - if (hitsoundsEnabledOpponent) ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-010')); + if (hitsoundsEnabledOpponent) ChartEditorAudioHandler.playSound(Paths.sound('ui/chart-editor/opponentHitsound')); } } } @@ -4072,12 +4099,18 @@ class ChartEditorState extends HaxeUIState buildSpectrogram(audioInstTrack); } + else + { + trace('[WARN] Instrumental track was null!'); + } + // Pretty much everything is going to need to be reset. scrollPositionInPixels = 0; playheadPositionInPixels = 0; notePreviewDirty = true; notePreviewViewportBoundsDirty = true; noteDisplayDirty = true; + healthIconsDirty = true; moveSongToScrollPosition(); }