Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ without creating an unreviewed second source of truth.
- [Practice Lab](development/practice-lab.md)
- [Chart Creator foundation](development/chart-creator.md)
- [Performance error map](development/performance-error-map.md)
- [Auto Tempo Coach](development/auto-tempo-coach.md)
- [Demo-song vertical slice](development/demo-song-vertical-slice.md)
- [Keyboard hit matching](development/keyboard-hit-matching.md)
- [Pad visuals](development/pad-visuals.md)
Expand Down
28 changes: 28 additions & 0 deletions docs/development/auto-tempo-coach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Auto Tempo Coach

Auto Tempo Coach turns a completed lesson or Song Library attempt into a safe
next-speed recommendation. It uses the existing score and hit-matching results;
it does not introduce a second scoring model.

## Progression

Song Library sessions follow the already supported song speeds. Lessons follow
the existing study progression (`0.5x`, `0.75x`, `1.0x`). A step unlocks when:

- accuracy is at least 85%;
- misses are no more than 10% of chart notes;
- unmatched hits are no more than 10% of chart notes.

Below those guardrails the coach asks the player to repeat the current speed.
At the final supported speed it reports the target as mastered.

## Applying a recommendation

The results screen displays the recommendation. The player explicitly confirms
the next speed; the gameplay scene then reloads the same lesson or song with a
new immutable `GameplaySessionDefinition`. Original BPM is recovered from the
current effective BPM and multiplier, then chart timing, audio pitch, count-in
and display metadata are rebuilt together.

This avoids changing playback speed halfway through a scored attempt and keeps
the DSP clock, chart timeline and external audio on one timing contract.
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using HitTheKit.Unity.Matching;

namespace HitTheKit.Unity.Gameplay
{
public enum GameplayAutoTempoStatus
{
Unavailable,
Repeat,
Advance,
Mastered
}

public sealed class GameplayAutoTempoRecommendation
{
internal GameplayAutoTempoRecommendation(
GameplayAutoTempoStatus status,
double currentSpeed,
double nextSpeed,
string message)
{
Status = status;
CurrentSpeed = currentSpeed;
NextSpeed = nextSpeed;
Message = message ?? throw new ArgumentNullException(nameof(message));
}

public GameplayAutoTempoStatus Status { get; }
public double CurrentSpeed { get; }
public double NextSpeed { get; }
public string Message { get; }
public bool CanAdvance => Status == GameplayAutoTempoStatus.Advance;
}

public static class GameplayAutoTempoCoach
{
public const double MinimumAccuracy = 85.0;
public const double MaximumMissRatio = 0.10;
public const double MaximumNoMatchRatio = 0.10;

public static GameplayAutoTempoRecommendation Evaluate(
GameplaySessionDefinition session,
GameplayScoreSnapshot score,
HitMatchingSnapshot matching)
{
if (score == null) throw new ArgumentNullException(nameof(score));
if (matching == null) throw new ArgumentNullException(nameof(matching));
return Evaluate(
session,
score.Accuracy,
matching.MissCount,
matching.NoMatchCount,
matching.TotalNoteCount);
}

public static GameplayAutoTempoRecommendation Evaluate(
GameplaySessionDefinition session,
double accuracy,
int misses,
int noMatches,
int totalNotes)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (double.IsNaN(accuracy) || double.IsInfinity(accuracy) || accuracy < 0 || accuracy > 100)
throw new ArgumentOutOfRangeException(nameof(accuracy));
if (misses < 0) throw new ArgumentOutOfRangeException(nameof(misses));
if (noMatches < 0) throw new ArgumentOutOfRangeException(nameof(noMatches));
if (totalNotes <= 0) throw new ArgumentOutOfRangeException(nameof(totalNotes));

IReadOnlyList<double> speeds = SpeedsFor(session);
if (speeds == null)
{
return new GameplayAutoTempoRecommendation(
GameplayAutoTempoStatus.Unavailable,
session.SpeedMultiplier,
session.SpeedMultiplier,
"AUTO TEMPO DISPONIBILE PER LEZIONI E BRANI DELLA LIBRERIA");
}

int currentIndex = FindSpeed(speeds, session.SpeedMultiplier);
if (currentIndex < 0)
throw new InvalidOperationException("The current session speed is outside the Auto Tempo progression.");

double missRatio = misses / (double)totalNotes;
double noMatchRatio = noMatches / (double)totalNotes;
bool passed = accuracy >= MinimumAccuracy &&
missRatio <= MaximumMissRatio &&
noMatchRatio <= MaximumNoMatchRatio;
if (!passed)
{
return new GameplayAutoTempoRecommendation(
GameplayAutoTempoStatus.Repeat,
session.SpeedMultiplier,
session.SpeedMultiplier,
$"RIPETI {session.SpeedMultiplier:0.##}× · SERVONO {MinimumAccuracy:0}% E POCHI ERRORI");
}

if (currentIndex == speeds.Count - 1)
{
return new GameplayAutoTempoRecommendation(
GameplayAutoTempoStatus.Mastered,
session.SpeedMultiplier,
session.SpeedMultiplier,
"TEMPO OBIETTIVO RAGGIUNTO · 100%");
}

double next = speeds[currentIndex + 1];
return new GameplayAutoTempoRecommendation(
GameplayAutoTempoStatus.Advance,
session.SpeedMultiplier,
next,
$"PRONTO PER {next:0.##}× · PRECISIONE {accuracy:0.0}%");
}

private static IReadOnlyList<double> SpeedsFor(GameplaySessionDefinition session)
{
if (session.IsChartCreator) return null;
if (session.Kind == GameplaySessionKind.Lesson) return GameplayStudySpeeds.All;
if (!string.IsNullOrWhiteSpace(session.SongId)) return GameplaySongSpeeds.All;
return null;
}

private static int FindSpeed(IReadOnlyList<double> speeds, double value)
{
for (int index = 0; index < speeds.Count; index++)
if (Math.Abs(speeds[index] - value) < 0.0001) return index;
return -1;
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public sealed class GameplayHighwayController : MonoBehaviour
private Button resultMenuButton;
private Button resultApplyCalibrationButton;
private Button resultPracticeWeakestButton;
private Button autoTempoAdvanceButton;
private Button practicePreviousSectionButton;
private Button practiceNextSectionButton;
private Button practiceLoopSectionButton;
Expand All @@ -86,6 +87,7 @@ public sealed class GameplayHighwayController : MonoBehaviour
private Label resultPracticeLabel;
private Label resultCalibrationLabel;
private Label resultErrorMapLabel;
private Label autoTempoStatusLabel;
private Label practiceSectionLabel;
private Label practiceStatusLabel;
private VisualElement resultPerformancePanel;
Expand Down Expand Up @@ -135,6 +137,8 @@ public sealed class GameplayHighwayController : MonoBehaviour
private bool metronomeScheduled;
private bool metronomeSeekedWhilePaused;
private bool resultRecorded;
private GameplayAutoTempoRecommendation autoTempoRecommendation;
private bool isChangingTempo;
private readonly GameplayPracticeLoop practiceLoop = new GameplayPracticeLoop();
private IReadOnlyList<GameplayPracticeRange> practiceSections = Array.Empty<GameplayPracticeRange>();
private int selectedPracticeSectionIndex;
Expand Down Expand Up @@ -313,6 +317,7 @@ private void BindView()
resultMenuButton = root.Q<Button>("result-menu-button");
resultApplyCalibrationButton = root.Q<Button>("result-apply-calibration");
resultPracticeWeakestButton = root.Q<Button>("result-practice-weakest");
autoTempoAdvanceButton = root.Q<Button>("auto-tempo-advance");
practicePreviousSectionButton = root.Q<Button>("practice-previous-section");
practiceNextSectionButton = root.Q<Button>("practice-next-section");
practiceLoopSectionButton = root.Q<Button>("practice-loop-section");
Expand All @@ -331,6 +336,7 @@ private void BindView()
resultPracticeLabel = root.Q<Label>("result-practice");
resultCalibrationLabel = root.Q<Label>("result-calibration");
resultErrorMapLabel = root.Q<Label>("result-error-map");
autoTempoStatusLabel = root.Q<Label>("auto-tempo-status");
practiceSectionLabel = root.Q<Label>("practice-section-label");
practiceStatusLabel = root.Q<Label>("practice-status");
resultPerformancePanel = root.Q<VisualElement>("result-performance-panel");
Expand Down Expand Up @@ -866,6 +872,7 @@ private void ShowResults(HitMatchingSnapshot matchingSnapshot)
$"EARLY/LATE {matchingSnapshot.EarlyCount + matchingSnapshot.LateCount} MISS {matchingSnapshot.MissCount}";
RenderPracticeRecommendation();
RenderErrorMap();
RenderAutoTempoRecommendation(matchingSnapshot, score);
RenderCalibrationRecommendation();
SetDisplayed(chartCreatorResults, false);
}
Expand Down Expand Up @@ -1091,6 +1098,7 @@ private void BindRunControls()
if (resultMenuButton != null) resultMenuButton.clicked += ReturnToMainMenu;
if (resultApplyCalibrationButton != null) resultApplyCalibrationButton.clicked += ApplyCalibrationRecommendation;
if (resultPracticeWeakestButton != null) resultPracticeWeakestButton.clicked += PracticeWeakestArea;
if (autoTempoAdvanceButton != null) autoTempoAdvanceButton.clicked += ApplyAutoTempoRecommendation;
if (practicePreviousSectionButton != null) practicePreviousSectionButton.clicked += SelectPreviousPracticeSection;
if (practiceNextSectionButton != null) practiceNextSectionButton.clicked += SelectNextPracticeSection;
if (practiceLoopSectionButton != null) practiceLoopSectionButton.clicked += LoopSelectedPracticeSection;
Expand Down Expand Up @@ -1119,6 +1127,7 @@ private void UnbindRunControls()
if (resultMenuButton != null) resultMenuButton.clicked -= ReturnToMainMenu;
if (resultApplyCalibrationButton != null) resultApplyCalibrationButton.clicked -= ApplyCalibrationRecommendation;
if (resultPracticeWeakestButton != null) resultPracticeWeakestButton.clicked -= PracticeWeakestArea;
if (autoTempoAdvanceButton != null) autoTempoAdvanceButton.clicked -= ApplyAutoTempoRecommendation;
if (practicePreviousSectionButton != null) practicePreviousSectionButton.clicked -= SelectPreviousPracticeSection;
if (practiceNextSectionButton != null) practiceNextSectionButton.clicked -= SelectNextPracticeSection;
if (practiceLoopSectionButton != null) practiceLoopSectionButton.clicked -= LoopSelectedPracticeSection;
Expand Down Expand Up @@ -1513,6 +1522,33 @@ public void PracticeWeakestArea()
LoopSelectedPracticeSection();
}

private void RenderAutoTempoRecommendation(
HitMatchingSnapshot matchingSnapshot,
GameplayScoreSnapshot score)
{
if (autoTempoStatusLabel == null || autoTempoAdvanceButton == null) return;
autoTempoRecommendation = GameplayAutoTempoCoach.Evaluate(CurrentSession, score, matchingSnapshot);
autoTempoStatusLabel.text = autoTempoRecommendation.Message;
autoTempoAdvanceButton.SetEnabled(autoTempoRecommendation.CanAdvance);
if (autoTempoRecommendation.CanAdvance)
autoTempoAdvanceButton.text = $"PROSSIMO TEMPO · {autoTempoRecommendation.NextSpeed:0.##}×";
else if (autoTempoRecommendation.Status == GameplayAutoTempoStatus.Mastered)
autoTempoAdvanceButton.text = "TEMPO OBIETTIVO RAGGIUNTO";
else if (autoTempoRecommendation.Status == GameplayAutoTempoStatus.Unavailable)
autoTempoAdvanceButton.text = "AUTO TEMPO NON DISPONIBILE";
else
autoTempoAdvanceButton.text = "RIPETI PER SBLOCCARE";
}

public void ApplyAutoTempoRecommendation()
{
if (isChangingTempo || autoTempoRecommendation == null || !autoTempoRecommendation.CanAdvance) return;
isChangingTempo = true;
GameplaySessionContext.Select(
GameplaySessionFactory.AtSpeed(CurrentSession, autoTempoRecommendation.NextSpeed));
SceneManager.LoadSceneAsync(SceneManager.GetActiveScene().name, LoadSceneMode.Single);
}

private static string PadLabel(DrumPad pad)
{
switch (pad)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,50 @@ public static int SongCountInBeats(double effectiveBpm)
return Math.Max(CountInBeats, (int)Math.Ceiling(MinimumSongCountInSeconds * effectiveBpm / 60.0));
}

public static GameplaySessionDefinition AtSpeed(
GameplaySessionDefinition session,
double speedMultiplier)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (session.Kind == GameplaySessionKind.Lesson)
{
if (!session.LessonId.HasValue) throw new InvalidOperationException("Lesson session is missing its lesson ID.");
return Lesson(session.LessonId.Value, speedMultiplier, session.Theme);
}
if (string.IsNullOrWhiteSpace(session.SongId))
throw new InvalidOperationException("Auto Tempo requires a lesson or Song Library session.");
if (!GameplaySongSpeeds.IsSupported(speedMultiplier))
throw new ArgumentOutOfRangeException(nameof(speedMultiplier));

double originalBpm = session.Bpm / session.SpeedMultiplier;
double effectiveBpm = originalBpm * speedMultiplier;
string metadata = session.Metadata
.Replace($"{session.Bpm:0.#} BPM", $"{effectiveBpm:0.#} BPM")
.Replace($"{session.SpeedMultiplier:0.##}×", $"{speedMultiplier:0.##}×");
return new GameplaySessionDefinition(
session.Kind,
session.Chart,
session.Difficulty,
speedMultiplier,
effectiveBpm,
session.Bars,
session.BeatsPerBar,
SongCountInBeats(effectiveBpm),
session.UseGeneratedSong,
session.Theme,
session.ReturnTarget,
session.Title,
session.Subtitle,
metadata,
session.Kicker,
session.ReturnButtonLabel,
null,
session.SongId,
session.ChartFilePath,
session.AudioFilePath,
session.IsChartCreator);
}

private static bool ContainsDifficulty(IReadOnlyList<string> values, string selected)
{
if (values == null) return false;
Expand Down
Loading
Loading