1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-31 19:10:43 +08:00
Files
osu-lazer/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs
T
Hivie 107e875a02 Adjust CheckFewHitsounds verify check based on feedback (#37466)
commit 1 makes sure breaks are skipped in calculation to avoid false
positives ([reported
internally](https://discord.com/channels/90072389919997952/1259818301517725707/1491051558081925170))

commit 2 makes the check only available in osu! and osu!catch as it's
not relevant in:
- osu!taiko, as it usually only triggers on valid mono-mapping (section
with all dons for example).
- osu!mania, given hitsounding is optional there.
- ([brief internal
discussion](https://discord.com/channels/90072389919997952/1259818301517725707/1496265253061660793))
2026-05-11 09:03:31 +02:00

205 lines
8.7 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.Linq;
using osu.Game.Audio;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Rulesets.Edit.Checks
{
public abstract class CheckFewHitsounds : ICheck
{
/// <summary>
/// 2 measures (4/4) of 120 BPM, typically makes up a few patterns in the map.
/// This is almost always ok, but can still be useful for the mapper to make sure hitsounding coverage is good.
/// </summary>
private const int negligible_threshold_time = 4000;
/// <summary>
/// 4 measures (4/4) of 120 BPM, typically makes up a large portion of a section in the song.
/// This is ok if the section is a quiet intro, for example.
/// </summary>
private const int warning_threshold_time = 8000;
/// <summary>
/// 12 measures (4/4) of 120 BPM, typically makes up multiple sections in the song.
/// </summary>
private const int problem_threshold_time = 24000;
// Should pass at least this many objects without hitsounds to be considered an issue (should work for Easy diffs too).
private const int warning_threshold_objects = 4;
private const int problem_threshold_objects = 16;
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Few or no hitsounds");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateLongPeriodProblem(this),
new IssueTemplateLongPeriodWarning(this),
new IssueTemplateLongPeriodNegligible(this),
new IssueTemplateNoHitsounds(this)
};
private bool mapHasHitsounds;
private int objectsWithoutHitsounds;
private double lastHitsoundTime;
private IReadOnlyList<(double StartTime, double EndTime)> excludedTimeRanges = Array.Empty<(double StartTime, double EndTime)>();
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
if (!context.CurrentDifficulty.Playable.HitObjects.Any())
yield break;
excludedTimeRanges = context.CurrentDifficulty.Playable.Breaks
.Select(b => (b.StartTime, b.EndTime))
.Concat(context.CurrentDifficulty.Playable.HitObjects
.Where(IsExcludedFromHitsounding)
.Select(h => (h.StartTime, EndTime: h.GetEndTime())))
.Where(period => period.EndTime > period.StartTime)
.ToList();
mapHasHitsounds = false;
objectsWithoutHitsounds = 0;
lastHitsoundTime = context.CurrentDifficulty.Playable.HitObjects.First().StartTime;
var hitObjectsIncludingNested = new List<HitObject>();
foreach (var hitObject in context.CurrentDifficulty.Playable.HitObjects)
{
// Samples play on the end of objects. Some objects have nested objects to accomplish playing them elsewhere (e.g. slider head/repeat).
foreach (var nestedHitObject in hitObject.NestedHitObjects)
{
if (!IsExcludedFromHitsounding(nestedHitObject))
hitObjectsIncludingNested.Add(nestedHitObject);
}
if (!IsExcludedFromHitsounding(hitObject))
hitObjectsIncludingNested.Add(hitObject);
}
var hitObjectsByEndTime = hitObjectsIncludingNested.OrderBy(o => o.GetEndTime()).ToList();
int hitObjectCount = hitObjectsByEndTime.Count;
for (int i = 0; i < hitObjectCount; ++i)
{
var hitObject = hitObjectsByEndTime[i];
// This is used to perform an update at the end so that the period after the last hitsounded object can be an issue.
bool isLastObject = i == hitObjectCount - 1;
foreach (var issue in applyHitsoundUpdate(hitObject, isLastObject))
yield return issue;
}
if (!mapHasHitsounds)
yield return new IssueTemplateNoHitsounds(this).Create();
}
private IEnumerable<Issue> applyHitsoundUpdate(HitObject hitObject, bool isLastObject = false)
{
double time = hitObject.GetEndTime();
bool hasHitsound = hitObject.Samples.Any(isHitsound);
bool couldHaveHitsound = hitObject.Samples.Any(isHitnormal);
// Only generating issues on hitsounded or last objects ensures we get one issue per long period.
// If there are no hitsounds we let the "No hitsounds" template take precedence.
if (hasHitsound || (isLastObject && mapHasHitsounds))
{
double timeWithoutHitsounds = getTimeWithoutHitsounds(lastHitsoundTime, time);
if (timeWithoutHitsounds > problem_threshold_time && objectsWithoutHitsounds > problem_threshold_objects)
yield return new IssueTemplateLongPeriodProblem(this).Create(lastHitsoundTime, timeWithoutHitsounds);
else if (timeWithoutHitsounds > warning_threshold_time && objectsWithoutHitsounds > warning_threshold_objects)
yield return new IssueTemplateLongPeriodWarning(this).Create(lastHitsoundTime, timeWithoutHitsounds);
else if (timeWithoutHitsounds > negligible_threshold_time && objectsWithoutHitsounds > warning_threshold_objects)
yield return new IssueTemplateLongPeriodNegligible(this).Create(lastHitsoundTime, timeWithoutHitsounds);
}
if (hasHitsound)
{
mapHasHitsounds = true;
objectsWithoutHitsounds = 0;
lastHitsoundTime = time;
}
else if (couldHaveHitsound)
++objectsWithoutHitsounds;
}
/// <summary>
/// Milliseconds between <paramref name="start"/> and <paramref name="end"/> that are not covered by a <see cref="BreakPeriod"/> or excluded object duration.
/// </summary>
private double getTimeWithoutHitsounds(double start, double end)
{
if (end <= start)
return 0;
double duration = end - start;
foreach (var period in excludedTimeRanges)
{
double overlapStart = Math.Max(start, period.StartTime);
double overlapEnd = Math.Min(end, period.EndTime);
if (overlapEnd > overlapStart)
duration -= overlapEnd - overlapStart;
}
return Math.Max(0, duration);
}
// Intended to be overridden by ruleset-specific objects e.g. spinners, banana showers.
protected virtual bool IsExcludedFromHitsounding(HitObject hitObject) => false;
private bool isHitsound(HitSampleInfo sample) => HitSampleInfo.ALL_ADDITIONS.Any(sample.Name.Contains);
private bool isHitnormal(HitSampleInfo sample) => sample.Name.Contains(HitSampleInfo.HIT_NORMAL);
public abstract class IssueTemplateLongPeriod : IssueTemplate
{
protected IssueTemplateLongPeriod(ICheck check, IssueType type)
: base(check, type, "Long period without hitsounds ({0:F1} seconds).")
{
}
public Issue Create(double time, double duration) => new Issue(this, duration / 1000f) { Time = time };
}
public class IssueTemplateLongPeriodProblem : IssueTemplateLongPeriod
{
public IssueTemplateLongPeriodProblem(ICheck check)
: base(check, IssueType.Problem)
{
}
}
public class IssueTemplateLongPeriodWarning : IssueTemplateLongPeriod
{
public IssueTemplateLongPeriodWarning(ICheck check)
: base(check, IssueType.Warning)
{
}
}
public class IssueTemplateLongPeriodNegligible : IssueTemplateLongPeriod
{
public IssueTemplateLongPeriodNegligible(ICheck check)
: base(check, IssueType.Negligible)
{
}
}
public class IssueTemplateNoHitsounds : IssueTemplate
{
public IssueTemplateNoHitsounds(ICheck check)
: base(check, IssueType.Problem, "There are no hitsounds.")
{
}
public Issue Create() => new Issue(this);
}
}
}