From 01d062c585d514dff36804315cafc5c1db1f7384 Mon Sep 17 00:00:00 2001 From: maotovisk Date: Fri, 6 Mar 2026 10:07:09 -0300 Subject: [PATCH] feat: add export capabilities to hitsound visualizer --- .../HitsoundService/HitSoundService.cs | 315 ++++++++++++++++++ .../HitsoundService/IHitSoundService.cs | 3 + .../ViewModels/HitSoundVisualizerViewModel.cs | 114 ++++++- .../HitSoundVisualizerView.axaml | 41 ++- 4 files changed, 465 insertions(+), 8 deletions(-) diff --git a/MapWizard.Desktop/Services/HitsoundService/HitSoundService.cs b/MapWizard.Desktop/Services/HitsoundService/HitSoundService.cs index cc1b7e8..05ffb45 100644 --- a/MapWizard.Desktop/Services/HitsoundService/HitSoundService.cs +++ b/MapWizard.Desktop/Services/HitsoundService/HitSoundService.cs @@ -2,12 +2,16 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Numerics; using BeatmapParser; using BeatmapParser.Enums; +using BeatmapParser.HitObjects; +using BeatmapParser.HitObjects.HitSounds; using BeatmapParser.TimingPoints; using MapWizard.Desktop.Models.HitSoundVisualizer; using MapWizard.Tools.HelperExtensions; using MapWizard.Tools.HitSounds.Copier; +using MapWizard.Tools.HitSounds.Event; using MapWizard.Tools.HitSounds.Extensions; using MapWizard.Tools.HitSounds.Timeline; using MapWizard.Tools.MapCleaner.Snapping; @@ -18,10 +22,12 @@ public class HitSoundService : IHitSoundService { private const double RedlineTimeToleranceMs = 0.5d; private const double BeatLengthToleranceMs = 0.001d; + private const int VisualizerExportLeniencyMs = 5; // Guards against invalid/near-zero intervals when generating snap ticks. private const double SnapTickStepToleranceMs = 0.00001d; // Inclusive segment-end tolerance to absorb floating-point drift at boundaries. private const double SnapTickSegmentEndToleranceMs = 0.0001d; + private static readonly Vector2 HitsoundDiffCirclePosition = new(256, 192); private static readonly IReadOnlyList VisualizerDivisors = Enumerable.Range(1, 16).Select(x => new SnapDivisor(1, x)).ToList(); @@ -100,6 +106,88 @@ public IReadOnlyList BuildHitsoundVisualizerSnapTick return BuildSnapTicks(beatmap, Math.Max(1000, endTimeMs)); } + public bool ApplyVisualizerTimelineToBeatmap(string targetBeatmapPath, HitSoundTimeline timeline, out string errorMessage) + { + errorMessage = string.Empty; + + try + { + if (string.IsNullOrWhiteSpace(targetBeatmapPath) || !File.Exists(targetBeatmapPath)) + { + errorMessage = "Target beatmap file was not found."; + return false; + } + + var normalizedTimeline = NormalizeTimelineForBeatmapEncoding(timeline); + var targetBeatmap = Beatmap.Decode(File.ReadAllText(targetBeatmapPath)); + var options = BuildVisualizerExportOptions(); + + targetBeatmap.ApplyNonDraggableHitSounds(normalizedTimeline.NonDraggableSoundTimeline, options); + targetBeatmap.ApplySampleTimeline(normalizedTimeline.SampleSetTimeline, options); + targetBeatmap.TimingPoints?.RemoveRedundantGreenLines(); + + BeatmapBackupHelper.CreateBackupCopy(targetBeatmapPath); + WriteBeatmap(targetBeatmapPath, targetBeatmap); + return true; + } + catch (Exception ex) + { + MapWizardLogger.LogException(ex); + errorMessage = ex.Message; + return false; + } + } + + public bool ExportVisualizerHitsoundDiff( + string sourceBeatmapPath, + HitSoundTimeline timeline, + out string exportedBeatmapPath, + out string errorMessage) + { + exportedBeatmapPath = string.Empty; + errorMessage = string.Empty; + + try + { + if (string.IsNullOrWhiteSpace(sourceBeatmapPath) || !File.Exists(sourceBeatmapPath)) + { + errorMessage = "Source beatmap file was not found."; + return false; + } + + var sourceBeatmap = Beatmap.Decode(File.ReadAllText(sourceBeatmapPath)); + var normalizedTimeline = NormalizeTimelineForBeatmapEncoding(timeline); + if (normalizedTimeline.NonDraggableSoundTimeline.SoundEvents.Count == 0) + { + errorMessage = "There are no hitsound points to export."; + return false; + } + + // Clone through encode/decode so we can keep all map metadata while replacing hitobjects. + var exportedBeatmap = Beatmap.Decode(sourceBeatmap.Encode()); + exportedBeatmap.GeneralSection.Mode = Ruleset.Osu; + exportedBeatmap.GeneralSection.StackLeniency = 0.0d; + + var versionName = BuildHitsoundDiffVersionName(sourceBeatmap.MetadataSection.Version); + exportedBeatmap.MetadataSection.Version = versionName; + exportedBeatmap.HitObjects.Objects = BuildCircleOnlyHitObjects(normalizedTimeline.NonDraggableSoundTimeline); + + var options = BuildVisualizerExportOptions(); + exportedBeatmap.ApplySampleTimeline(normalizedTimeline.SampleSetTimeline, options); + exportedBeatmap.TimingPoints?.RemoveRedundantGreenLines(); + + exportedBeatmapPath = BuildUniqueHitsoundDiffPath(sourceBeatmapPath, versionName); + WriteBeatmap(exportedBeatmapPath, exportedBeatmap); + return true; + } + catch (Exception ex) + { + MapWizardLogger.LogException(ex); + errorMessage = ex.Message; + return false; + } + } + private static HitSoundTimingCompatibilityTargetResult CompareTiming(Beatmap source, Beatmap target, string targetPath) { var sourceRedlines = GetRedlines(source); @@ -512,4 +600,231 @@ private static int NormalizeSampleVolume(double rawVolume) var rounded = (int)Math.Round(rawVolume); return rounded <= 0 ? 100 : Math.Clamp(rounded, 1, 100); } + + private static HitSoundTimeline NormalizeTimelineForBeatmapEncoding(HitSoundTimeline source) + { + var sourceTimeline = source ?? new HitSoundTimeline(); + + var nonDraggable = sourceTimeline.NonDraggableSoundTimeline?.SoundEvents + ?.OrderBy(x => x.Time.TotalMilliseconds) + .Select(NormalizeSoundEventForBeatmapEncoding) + .ToList() + ?? []; + var draggable = sourceTimeline.DraggableSoundTimeline?.SoundEvents + ?.OrderBy(x => x.Time.TotalMilliseconds) + .Select(NormalizeSoundEventForBeatmapEncoding) + .ToList() + ?? []; + var sampleChanges = sourceTimeline.SampleSetTimeline?.HitSamples + ?.OrderBy(x => x.Time) + .Select(change => new SampleSetEvent( + change.Time, + NormalizeSampleSet(change.Sample, SampleSet.Normal), + NormalizeSampleIndex(change.Index), + NormalizeSampleVolume(change.Volume))) + .ToList() + ?? []; + + return new HitSoundTimeline + { + NonDraggableSoundTimeline = new SoundTimeline(nonDraggable), + DraggableSoundTimeline = new SoundTimeline(draggable), + SampleSetTimeline = new SampleSetTimeline + { + HitSamples = sampleChanges + } + }; + } + + private static SoundEvent NormalizeSoundEventForBeatmapEncoding(SoundEvent source) + { + var timeMs = Math.Max(0, source.Time.TotalMilliseconds); + var normalSample = NormalizeSampleSet(source.NormalSample, SampleSet.Normal); + var additionSample = NormalizeSampleSet(source.AdditionSample, normalSample); + var fileName = string.IsNullOrWhiteSpace(source.FileName) + ? string.Empty + : Path.GetFileName(source.FileName.Trim().Trim('"')); + + return new SoundEvent( + time: TimeSpan.FromMilliseconds(timeMs), + hitSounds: NormalizeHitSoundsForBeatmapEncoding(source.HitSounds), + normalSample: normalSample, + additionSample: additionSample, + fileName: fileName, + sampleIndexOverride: NormalizeOptionalSampleIndex(source.SampleIndexOverride), + sampleVolumeOverride: NormalizeOptionalSampleVolume(source.SampleVolumeOverride)); + } + + private static List NormalizeHitSoundsForBeatmapEncoding(IEnumerable? sourceHitSounds) + { + var combinedFlags = 0; + if (sourceHitSounds is not null) + { + foreach (var hitSound in sourceHitSounds) + { + // The visualizer uses HitSound.Normal for lane identity. + // Beatmap encoding expects "no additions" to be represented by HitSound.None. + if (hitSound is HitSound.None or HitSound.Normal) + { + continue; + } + + combinedFlags |= (int)hitSound; + } + } + + var normalized = new List(); + if ((combinedFlags & (int)HitSound.Whistle) == (int)HitSound.Whistle) + { + normalized.Add(HitSound.Whistle); + } + + if ((combinedFlags & (int)HitSound.Finish) == (int)HitSound.Finish) + { + normalized.Add(HitSound.Finish); + } + + if ((combinedFlags & (int)HitSound.Clap) == (int)HitSound.Clap) + { + normalized.Add(HitSound.Clap); + } + + if (normalized.Count == 0) + { + normalized.Add(HitSound.None); + } + + return normalized; + } + + private static int? NormalizeOptionalSampleIndex(int? sampleIndexOverride) + { + if (sampleIndexOverride is not > 0) + { + return null; + } + + return Math.Clamp(sampleIndexOverride.Value, 1, 99); + } + + private static int? NormalizeOptionalSampleVolume(int? sampleVolumeOverride) + { + if (sampleVolumeOverride is not > 0) + { + return null; + } + + return Math.Clamp(sampleVolumeOverride.Value, 1, 100); + } + + private static List BuildCircleOnlyHitObjects(SoundTimeline soundTimeline) + { + var circles = new List(); + var sortedEvents = soundTimeline.SoundEvents + .OrderBy(x => x.Time.TotalMilliseconds) + .ToList(); + + for (var i = 0; i < sortedEvents.Count; i++) + { + var soundEvent = sortedEvents[i]; + var hitSample = new HitSample( + normalSet: NormalizeSampleSet(soundEvent.NormalSample, SampleSet.Normal), + additionSet: NormalizeSampleSet(soundEvent.AdditionSample, NormalizeSampleSet(soundEvent.NormalSample, SampleSet.Normal)), + fileName: string.IsNullOrWhiteSpace(soundEvent.FileName) + ? string.Empty + : Path.GetFileName(soundEvent.FileName.Trim().Trim('"')), + index: ToOptionalUnsignedInt(NormalizeOptionalSampleIndex(soundEvent.SampleIndexOverride)), + volume: ToOptionalUnsignedInt(NormalizeOptionalSampleVolume(soundEvent.SampleVolumeOverride))); + var hitSounds = NormalizeHitSoundsForBeatmapEncoding(soundEvent.HitSounds); + var isNewCombo = i == 0; + + circles.Add(new Circle( + coordinates: HitsoundDiffCirclePosition, + time: soundEvent.Time, + type: HitObjectType.Circle, + hitSounds: (hitSample, hitSounds), + newCombo: isNewCombo, + comboOffset: 0)); + } + + return circles; + } + + private static HitSoundCopierOptions BuildVisualizerExportOptions() + { + return new HitSoundCopierOptions + { + Leniency = VisualizerExportLeniencyMs, + OverwriteEverything = true, + OverwriteMuting = true, + CopySliderBodySounds = false, + CopySampleAndVolumeChanges = true + }; + } + + private static uint? ToOptionalUnsignedInt(int? value) + { + return value is > 0 ? (uint)value.Value : null; + } + + private static string BuildHitsoundDiffVersionName(string? sourceVersion) + { + var trimmed = string.IsNullOrWhiteSpace(sourceVersion) + ? "Hitsound" + : sourceVersion.Trim(); + + if (trimmed.Contains("hitsound", StringComparison.OrdinalIgnoreCase)) + { + return trimmed; + } + + return $"{trimmed} (Hitsound)"; + } + + private static string BuildUniqueHitsoundDiffPath(string sourceBeatmapPath, string versionName) + { + var sourceDirectory = Path.GetDirectoryName(sourceBeatmapPath) ?? string.Empty; + var sourceFileNameWithoutExtension = Path.GetFileNameWithoutExtension(sourceBeatmapPath); + var sanitizedVersionName = SanitizeFileNamePart(versionName); + + var bracketStartIndex = sourceFileNameWithoutExtension.LastIndexOf('['); + var bracketEndIndex = sourceFileNameWithoutExtension.LastIndexOf(']'); + var baseFileName = bracketStartIndex >= 0 && bracketEndIndex > bracketStartIndex + ? $"{sourceFileNameWithoutExtension[..(bracketStartIndex + 1)]}{sanitizedVersionName}{sourceFileNameWithoutExtension[bracketEndIndex..]}" + : $"{sourceFileNameWithoutExtension} [{sanitizedVersionName}]"; + + var candidatePath = Path.Combine(sourceDirectory, $"{baseFileName}.osu"); + if (!File.Exists(candidatePath)) + { + return candidatePath; + } + + var suffix = 1; + while (true) + { + var suffixedPath = Path.Combine(sourceDirectory, $"{baseFileName} ({suffix}).osu"); + if (!File.Exists(suffixedPath)) + { + return suffixedPath; + } + + suffix++; + } + } + + private static string SanitizeFileNamePart(string value) + { + var sanitized = value; + foreach (var invalidCharacter in Path.GetInvalidFileNameChars()) + { + sanitized = sanitized.Replace(invalidCharacter, '_'); + } + + return string.IsNullOrWhiteSpace(sanitized) ? "Hitsound" : sanitized.Trim(); + } + + private static void WriteBeatmap(string path, Beatmap beatmap) + { + File.WriteAllText(path, beatmap.Encode().Replace("\r\n", "\n").Replace("\n", "\r\n")); + } } diff --git a/MapWizard.Desktop/Services/HitsoundService/IHitSoundService.cs b/MapWizard.Desktop/Services/HitsoundService/IHitSoundService.cs index bcc5c86..5bca286 100644 --- a/MapWizard.Desktop/Services/HitsoundService/IHitSoundService.cs +++ b/MapWizard.Desktop/Services/HitsoundService/IHitSoundService.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using MapWizard.Desktop.Models.HitSoundVisualizer; +using MapWizard.Tools.HitSounds.Timeline; using MapWizard.Tools.HitSounds.Copier; namespace MapWizard.Desktop.Services.HitsoundService; @@ -10,4 +11,6 @@ public interface IHitSoundService public bool CopyHitsounds(string sourcePath, string[] targetPaths, HitSoundCopierOptions options); public HitSoundVisualizerDocument LoadHitsoundVisualizerDocument(string beatmapPath); public IReadOnlyList BuildHitsoundVisualizerSnapTicks(string beatmapPath, double endTimeMs); + public bool ApplyVisualizerTimelineToBeatmap(string targetBeatmapPath, HitSoundTimeline timeline, out string errorMessage); + public bool ExportVisualizerHitsoundDiff(string sourceBeatmapPath, HitSoundTimeline timeline, out string exportedBeatmapPath, out string errorMessage); } diff --git a/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs b/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs index 4c7cadb..065b457 100644 --- a/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs +++ b/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs @@ -179,6 +179,7 @@ public ObservableCollection AdditionalBeatmaps public bool ShowSamplePointContextPopup => IsSampleRowContextActive; public bool ShowHitsoundContextPopup => !IsSampleRowContextActive; public bool CanEditHeaderBanks => HasLoadedMap; + public bool CanExportTimeline => HasLoadedMap && Points.Count > 0; public bool HasAnyHsSelectorAudioTypeEnabled => HsSelectorIncludeHitNormal || HsSelectorIncludeWhistle || HsSelectorIncludeFinish || HsSelectorIncludeClap; public bool IsHitNormalSelected @@ -257,6 +258,7 @@ partial void OnPointsChanged(ObservableCollection value { RebuildPointTimeCache(value); OnPropertyChanged(nameof(VisiblePointCount)); + OnPropertyChanged(nameof(CanExportTimeline)); } partial void OnCursorTimeMsChanged(int value) @@ -285,6 +287,7 @@ partial void OnSelectedPointIdsChanged(ObservableCollection value) partial void OnHasLoadedMapChanged(bool value) { OnPropertyChanged(nameof(CanEditHeaderBanks)); + OnPropertyChanged(nameof(CanExportTimeline)); OnPropertyChanged(nameof(ShowPlaybackTimeline)); OnPropertyChanged(nameof(ShowPlaybackTimelineSection)); } @@ -492,6 +495,80 @@ private Task LoadTimeline() return LoadTimelineCore(preservePlaybackPosition: false); } + [RelayCommand] + private async Task ExportApplyToTargetDiff(CancellationToken token) + { + if (!CanExportTimeline) + { + toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Load a beatmap with hitsound points first."); + return; + } + + try + { + var selectedPaths = await ShowSongSelectDialogAsync( + allowMultiple: false, + token: token, + preferredMapsetDirectoryPath: BeatmapPathUtils.TryGetMapsetDirectoryPath(OriginBeatmap.Path)); + if (token.IsCancellationRequested || selectedPaths is null || selectedPaths.Count == 0) + { + return; + } + + var targetPath = selectedPaths[0]; + if (!hitSoundService.ApplyVisualizerTimelineToBeatmap(targetPath, _workingTimeline, out var errorMessage)) + { + toastManager.ShowToast( + NotificationType.Error, + "Hitsound Visualizer", + string.IsNullOrWhiteSpace(errorMessage) ? "Failed to apply hitsounds to target diff." : errorMessage); + return; + } + + toastManager.ShowToast( + NotificationType.Success, + "Hitsound Visualizer", + $"Applied hitsounds to {Path.GetFileName(targetPath)}."); + } + catch (Exception ex) + { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", ex.Message); + } + } + + [RelayCommand] + private void ExportHitsoundDiff() + { + if (!CanExportTimeline) + { + toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Load a beatmap with hitsound points first."); + return; + } + + try + { + if (!hitSoundService.ExportVisualizerHitsoundDiff(OriginBeatmap.Path, _workingTimeline, out var exportedPath, out var errorMessage)) + { + toastManager.ShowToast( + NotificationType.Error, + "Hitsound Visualizer", + string.IsNullOrWhiteSpace(errorMessage) ? "Failed to export hitsound diff." : errorMessage); + return; + } + + toastManager.ShowToast( + NotificationType.Success, + "Hitsound Visualizer", + $"Exported hitsound diff: {Path.GetFileName(exportedPath)}"); + } + catch (Exception ex) + { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", ex.Message); + } + } + private async Task LoadTimelineCore(bool preservePlaybackPosition) { if (string.IsNullOrWhiteSpace(OriginBeatmap.Path)) @@ -2898,13 +2975,26 @@ private bool TryPlayPointSample(HitSoundVisualizerPoint point, IReadOnlyList ResolveSampleFilePathFromDirectoryUncached(directoryPath, sampleSet, hitSound, index)); + _ => ResolveSampleFilePathFromDirectoryUncached(directoryPath, sampleSet, hitSound, index, allowIndexOneFallback)); } - private string ResolveSampleFilePathFromDirectoryUncached(string directoryPath, SampleSet sampleSet, HitSound hitSound, int index) + private string ResolveSampleFilePathFromDirectoryUncached( + string directoryPath, + SampleSet sampleSet, + HitSound hitSound, + int index, + bool allowIndexOneFallback) { if (string.IsNullOrWhiteSpace(directoryPath) || !Directory.Exists(directoryPath)) { @@ -3114,7 +3214,7 @@ private string ResolveSampleFilePathFromDirectoryUncached(string directoryPath, } } - if (index <= 1) + if (!allowIndexOneFallback || index <= 1) { return string.Empty; } diff --git a/MapWizard.Desktop/Views/HitSoundVisualizer/HitSoundVisualizerView.axaml b/MapWizard.Desktop/Views/HitSoundVisualizer/HitSoundVisualizerView.axaml index 04c6933..f79bf62 100644 --- a/MapWizard.Desktop/Views/HitSoundVisualizer/HitSoundVisualizerView.axaml +++ b/MapWizard.Desktop/Views/HitSoundVisualizer/HitSoundVisualizerView.axaml @@ -168,11 +168,50 @@ - + + + + + + + +