1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-20 20:20:28 +08:00
Files
osu-lazer/osu.Game/Screens/Edit/EditorBeatmapSkin.cs
T
Bartłomiej Dach de97660fa4 Fix hitsounds becoming loud in editor after entering setup section (#36512)
Probably closes https://github.com/ppy/osu/issues/36492.

This is dumb but it's in large part my stupidity.

To begin with, the immediate direct offending call that causes the
observed symptoms is


https://github.com/ppy/osu/blob/a401c7d5e9d6d8b05b2ec293145ad308dfe9d6d0/osu.Game/Screens/Edit/Components/FormSampleSet.cs#L296

The reason why this "invalidation" affects sample volume is that in the
framework implementation, the call [removes the relevant sample factory
from the sample store which is an audio
component](https://github.com/ppy/osu-framework/blob/5b716dcbef6f99e03188a7a7706361fa8445c754/osu.Framework/Audio/Sample/SampleStore.cs#L65-L72).
In the process it also [unbinds audio
adjustments](https://github.com/ppy/osu-framework/blob/5b716dcbef6f99e03188a7a7706361fa8445c754/osu.Framework/Audio/AudioCollectionManager.cs#L37-L38),
which *would* have the effect of resetting the sample volume to 100%,
effectively (and I've pretty much confirmed that that's what happens).

Now for why this call sometimes does the right thing and sometimes
doesn't: Sometimes the call is made in response to an *actual* change to
the beatmap skin, which is correct and expected, if very indirect, but
sometimes it is made *over-eagerly* when there is no reason to recycle
anything yet.

One such circumstance is entering the setup screen, which will still
"invalidate" (read: remove) the samples, but the compose tab hasn't seen
any change to the beatmap skin, so when it is returned to, it has no
reason to retrieve the sample again, and as such it will try to play
samples which are for better or worse in a completely undefined state
because they're not supposed to be *in use* anymore.

Therefore, the right thing here would seem to be to take the
responsibility of invalidation from a random component, and move it to a
place that's *actually* correlated to every other component needing to
recycle samples, e.g. `EditorBeatmapSkin` responding to changes in the
beatmap resources via raising `BeatmapSkinChanged`.

Unfortunately, because of the structure of everything, this recycle
needs to go from targeted to individual samples, to nuking the entire
store. The reason for this is that `RealmBackedResourceStore` does not
provide information as to *what* resources changed, it just says that
*the set of them* did.

For the recycle to be smarter, `EditorBeatmapSkin` would need to know
not only which samples were added or replaced, but also which ones were
*removed*, so that users don't hear phantom samples that no longer exist
in the editor later. That would however be a lot of hassle for nothing,
so I just recycle everything here and hope it won't matter.

As to why I could only reproduce this on this one beatmap - I'm not
super sure. The failure does not seem to be specific to beatmaps, but it
may be exacerbated by certain patterns of accessing samples which means
that beatmaps with high BPM like the one I managed to reproduce this on
may just be more susceptible to this.

As a final note, I do realise that this is not fundamentally improving
the surrounding systems and it's still a pretty rickety thing to do.
It's still on the consumers to know and respond to the sample store
recycle and this is likely to fail if a consumer ever doesn't. That
said, I have no brighter ideas at this point in time that won't involve
me spending a week refactoring audio.
2026-01-29 19:10:48 +09:00

176 lines
6.6 KiB
C#

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Textures;
using osu.Game.Audio;
using osu.Game.Skinning;
using osuTK.Graphics;
namespace osu.Game.Screens.Edit
{
/// <summary>
/// A beatmap skin which is being edited.
/// </summary>
public class EditorBeatmapSkin : ISkin, IDisposable
{
/// <summary>
/// Invoked when the beatmap skin changes.
/// This event is not locally scheduled to update thread or otherwise marshalled
/// in a way that would prevent invocation of a callback registered by a potentially-now-disposed caller.
/// Callers are expected to schedule locally as required.
/// </summary>
public event Action? BeatmapSkinChanged;
/// <summary>
/// The underlying beatmap skin.
/// </summary>
protected internal readonly LegacyBeatmapSkin Skin;
/// <summary>
/// The combo colours of this skin.
/// If empty, the default combo colours will be used.
/// </summary>
public BindableList<Colour4> ComboColours { get; }
private readonly EditorBeatmap editorBeatmap;
public EditorBeatmapSkin(EditorBeatmap editorBeatmap, LegacyBeatmapSkin skin)
{
this.editorBeatmap = editorBeatmap;
Skin = skin;
ComboColours = new BindableList<Colour4>();
if (Skin.Configuration.ComboColours is IReadOnlyList<Color4> comboColours)
{
// due to the foibles of how `IHasComboInformation` / `ComboIndexWithOffsets` work,
// the actual effective first combo colour that will be used on the beatmap is the one with index 1, not 0.
// see also: `IHasComboInformation.UpdateComboInformation`,
// https://github.com/peppy/osu-stable-reference/blob/46cd3a10af7cc6cc96f4eba92ef1812dc8c3a27e/osu!/GameModes/Edit/Forms/SongSetup.cs#L233-L234.
for (int i = 0; i < comboColours.Count; ++i)
ComboColours.Add(comboColours[(i + 1) % comboColours.Count]);
}
ComboColours.BindCollectionChanged((_, _) => updateColours());
if (skin.BeatmapSetResources != null)
skin.BeatmapSetResources.CacheInvalidated += beatmapResourcesInvalidated;
}
private void beatmapResourcesInvalidated()
{
Skin.RecycleSamples();
InvokeSkinChanged();
}
public void InvokeSkinChanged() => BeatmapSkinChanged?.Invoke();
#region Combo colours
private void updateColours()
{
// performs the inverse of the index rotation operation described in the ctor.
Skin.Configuration.CustomComboColours.Clear();
for (int i = 0; i < ComboColours.Count; ++i)
Skin.Configuration.CustomComboColours.Add(ComboColours[(ComboColours.Count + i - 1) % ComboColours.Count]);
InvokeSkinChanged();
editorBeatmap.SaveState();
}
#endregion
#region Sample sets
public record SampleSet(int SampleSetIndex, string Name)
{
public SampleSet(int sampleSetIndex)
: this(sampleSetIndex, $@"Custom #{sampleSetIndex}")
{
}
public override string ToString() => Name;
public HashSet<string> Filenames = [];
public string? FindSampleIfExists(string sampleName, string bankName)
=> Filenames.SingleOrDefault(f => f.StartsWith($@"{bankName}-{sampleName}{(SampleSetIndex > 1 ? SampleSetIndex : null)}", StringComparison.Ordinal));
public virtual bool Equals(SampleSet? other) => SampleSetIndex == other?.SampleSetIndex;
public override int GetHashCode() => SampleSetIndex;
}
public IEnumerable<SampleSet> GetAvailableSampleSets()
{
string[] possibleSounds = HitSampleInfo.ALL_ADDITIONS.Prepend(HitSampleInfo.HIT_NORMAL).ToArray();
string[] possibleBanks = HitSampleInfo.ALL_BANKS;
string[] possiblePrefixes = possibleSounds.SelectMany(sound => possibleBanks.Select(bank => $@"{bank}-{sound}")).ToArray();
Dictionary<int, SampleSet> sampleSets = new Dictionary<int, SampleSet>
{
[1] = new SampleSet(1),
};
if (Skin.Samples != null)
{
foreach (string sample in Skin.Samples.GetAvailableResources())
{
foreach (string possiblePrefix in possiblePrefixes)
{
if (!sample.StartsWith(possiblePrefix, StringComparison.Ordinal))
continue;
string indexString = Path.GetFileNameWithoutExtension(sample)[possiblePrefix.Length..];
int? index = null;
if (string.IsNullOrEmpty(indexString))
index = 1;
if (int.TryParse(indexString, out int parsed) && parsed >= 2)
index = parsed;
if (!index.HasValue)
continue;
SampleSet? sampleSet;
if (!sampleSets.TryGetValue(index.Value, out sampleSet))
sampleSet = sampleSets[index.Value] = new SampleSet(index.Value);
sampleSet.Filenames.Add(sample);
}
}
}
return sampleSets.OrderBy(i => i.Key).Select(i => i.Value);
}
#endregion
public void Dispose()
{
if (Skin.BeatmapSetResources != null)
Skin.BeatmapSetResources.CacheInvalidated -= beatmapResourcesInvalidated;
Skin.Dispose();
}
#region Delegated ISkin implementation
public Drawable? GetDrawableComponent(ISkinComponentLookup lookup) => Skin.GetDrawableComponent(lookup);
public Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Skin.GetTexture(componentName, wrapModeS, wrapModeT);
public ISample? GetSample(ISampleInfo sampleInfo) => Skin.GetSample(sampleInfo);
public IBindable<TValue>? GetConfig<TLookup, TValue>(TLookup lookup)
where TLookup : notnull
where TValue : notnull
=> Skin.GetConfig<TLookup, TValue>(lookup);
#endregion
}
}