diff --git a/docs/README.md b/docs/README.md index 2d41d62..270aca9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,7 @@ without creating an unreviewed second source of truth. - [Chart Creator foundation](development/chart-creator.md) - [Performance error map](development/performance-error-map.md) - [Auto Tempo Coach](development/auto-tempo-coach.md) +- [Audio and latency sound check](development/audio-latency-sound-check.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) diff --git a/docs/development/audio-latency-sound-check.md b/docs/development/audio-latency-sound-check.md new file mode 100644 index 0000000..0cce5a0 --- /dev/null +++ b/docs/development/audio-latency-sound-check.md @@ -0,0 +1,25 @@ +# Audio and latency sound check + +The guided sound check lives in **Settings → Input calibration** and reuses the +same local timing-offset preferences consumed by gameplay. + +## Flow + +1. **Test audio** plays an original generated click through Unity's active + output device. HitTheKit does not choose the hardware output; macOS or the + drum module must route it to the desired headphones/speakers. +2. Select **Keyboard** or **MIDI**. The two recommendations never share samples. +3. Start the check, listen to four count-in clicks, then strike with the next + twelve clicks. +4. At least eight matched strikes are required. The existing + `TimingCalibrationAdvisor` calculates a median and rejects out-of-window + hits. Applying the recommendation updates only the selected input offset. + +The generated track is scheduled against Unity DSP time. Target timestamps use +the corresponding unscaled-time origin, so the measurement does not depend on +frame-by-frame accumulated time. All samples remain in memory and local to the +running app; there is no telemetry, upload, or raw-hit persistence. + +This is a guided user calibration, not laboratory round-trip latency +measurement: human timing variation is reduced by the median and the minimum +sample gate, not claimed to be eliminated. diff --git a/src/HitTheKit.Core/GuidedLatencySoundCheck.cs b/src/HitTheKit.Core/GuidedLatencySoundCheck.cs new file mode 100644 index 0000000..4cc358c --- /dev/null +++ b/src/HitTheKit.Core/GuidedLatencySoundCheck.cs @@ -0,0 +1,153 @@ +using System; + +namespace HitTheKit.Core +{ + public enum GuidedSoundCheckState + { + Idle, + Running, + Complete + } + + public enum GuidedSoundCheckInput + { + Keyboard, + Midi + } + + public sealed class GuidedSoundCheckSnapshot + { + internal GuidedSoundCheckSnapshot( + GuidedSoundCheckState state, + GuidedSoundCheckInput source, + int targetCount, + int acceptedCount, + int missedCount, + double? nextTargetTimeSeconds, + TimingCalibrationSnapshot calibration) + { + State = state; + Source = source; + TargetCount = targetCount; + AcceptedCount = acceptedCount; + MissedCount = missedCount; + NextTargetTimeSeconds = nextTargetTimeSeconds; + Calibration = calibration ?? throw new ArgumentNullException(nameof(calibration)); + } + + public GuidedSoundCheckState State { get; } + public GuidedSoundCheckInput Source { get; } + public int TargetCount { get; } + public int AcceptedCount { get; } + public int MissedCount { get; } + public int ResolvedCount => AcceptedCount + MissedCount; + public double? NextTargetTimeSeconds { get; } + public TimingCalibrationSnapshot Calibration { get; } + public bool CanApplyRecommendation => State == GuidedSoundCheckState.Complete && Calibration.HasRecommendation; + } + + public sealed class GuidedLatencySoundCheck + { + public const int DefaultTargetCount = 12; + public const double DefaultIntervalSeconds = 0.5; + + private readonly TimingCalibrationAdvisor advisor = new TimingCalibrationAdvisor(); + private readonly int targetCount; + private GuidedSoundCheckState state; + private GuidedSoundCheckInput source; + private double firstTargetTimeSeconds; + private double intervalSeconds; + private int targetIndex; + private int acceptedCount; + private int missedCount; + + public GuidedLatencySoundCheck(int targetCount = DefaultTargetCount) + { + if (targetCount < TimingCalibrationAdvisor.MinimumSamples) + throw new ArgumentOutOfRangeException(nameof(targetCount)); + this.targetCount = targetCount; + } + + public GuidedSoundCheckSnapshot Snapshot => new GuidedSoundCheckSnapshot( + state, + source, + targetCount, + acceptedCount, + missedCount, + state == GuidedSoundCheckState.Running ? TargetTime(targetIndex) : (double?)null, + advisor.Snapshot); + + public void Begin(GuidedSoundCheckInput source, double firstTargetTimeSeconds, double intervalSeconds = DefaultIntervalSeconds) + { + if (source != GuidedSoundCheckInput.Keyboard && source != GuidedSoundCheckInput.Midi) + throw new ArgumentOutOfRangeException(nameof(source)); + if (!IsFinite(firstTargetTimeSeconds) || firstTargetTimeSeconds < 0) + throw new ArgumentOutOfRangeException(nameof(firstTargetTimeSeconds)); + if (!IsFinite(intervalSeconds) || intervalSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(intervalSeconds)); + + this.source = source; + this.firstTargetTimeSeconds = firstTargetTimeSeconds; + this.intervalSeconds = intervalSeconds; + targetIndex = 0; + acceptedCount = 0; + missedCount = 0; + advisor.Reset(); + state = GuidedSoundCheckState.Running; + } + + public bool TryRecord(GuidedSoundCheckInput inputSource, double hitTimeSeconds) + { + if (!IsFinite(hitTimeSeconds)) throw new ArgumentOutOfRangeException(nameof(hitTimeSeconds)); + if (state != GuidedSoundCheckState.Running || inputSource != source) return false; + Advance(hitTimeSeconds); + if (state != GuidedSoundCheckState.Running) return false; + + double delta = hitTimeSeconds - TargetTime(targetIndex); + if (Math.Abs(delta) > TimingWindows.Default.HitSeconds) return false; + advisor.Add(delta); + acceptedCount++; + targetIndex++; + CompleteIfResolved(); + return true; + } + + public void Advance(double currentTimeSeconds) + { + if (!IsFinite(currentTimeSeconds)) throw new ArgumentOutOfRangeException(nameof(currentTimeSeconds)); + if (state != GuidedSoundCheckState.Running) return; + while (targetIndex < targetCount && + currentTimeSeconds > TargetTime(targetIndex) + TimingWindows.Default.HitSeconds) + { + missedCount++; + targetIndex++; + } + CompleteIfResolved(); + } + + public double RecommendOffsetSeconds(double currentOffsetSeconds) + { + if (!Snapshot.CanApplyRecommendation) + throw new InvalidOperationException("The sound check does not have enough accepted hits."); + return advisor.RecommendOffsetSeconds(currentOffsetSeconds); + } + + public void Reset() + { + state = GuidedSoundCheckState.Idle; + targetIndex = 0; + acceptedCount = 0; + missedCount = 0; + advisor.Reset(); + } + + private double TargetTime(int index) => firstTargetTimeSeconds + index * intervalSeconds; + + private void CompleteIfResolved() + { + if (targetIndex >= targetCount) state = GuidedSoundCheckState.Complete; + } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/MainMenu/MainMenuController.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/MainMenu/MainMenuController.cs index caa80bb..3250001 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/MainMenu/MainMenuController.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/MainMenu/MainMenuController.cs @@ -6,6 +6,7 @@ using UnityEngine.SceneManagement; using UnityEngine.UIElements; using HitTheKit.Core; +using HitTheKit.Unity.Audio; using HitTheKit.Unity.Input; using HitTheKit.Unity.Gameplay; using HitTheKit.Unity.DeviceSetup; @@ -128,6 +129,15 @@ public sealed class MainMenuController : MonoBehaviour private Button midiOffsetResetButton; private Label midiOffsetValue; private Label calibrationHelp; + private Button soundCheckAudioButton; + private Button soundCheckKeyboardButton; + private Button soundCheckMidiButton; + private Button soundCheckStartButton; + private Button soundCheckApplyButton; + private Button soundCheckCancelButton; + private Label soundCheckTitle; + private Label soundCheckInstructions; + private Label soundCheckStatus; private Button bindingKickButton; private Button bindingSnareButton; private Button bindingHiHatButton; @@ -181,6 +191,12 @@ public sealed class MainMenuController : MonoBehaviour private DrumPad? pendingKeyBinding; private bool resetConfirmationPending; private bool resetDataConfirmationPending; + private readonly GuidedLatencySoundCheck soundCheck = new GuidedLatencySoundCheck(); + private DrumInputSource selectedSoundCheckSource = DrumInputSource.Keyboard; + private AudioSource soundCheckAudioSource; + private AudioClip soundCheckPreviewClip; + private AudioClip soundCheckRunClip; + private bool soundCheckRecommendationApplied; private static readonly Vector2Int[] WindowSizes = { new Vector2Int(1280, 720), @@ -214,6 +230,7 @@ public sealed class MainMenuController : MonoBehaviour public string PendingChartAudioPath => pendingChartAudioPath; public bool IsSongAudioBindingVisible => songAudioBindingOverlay != null && songAudioBindingOverlay.resolvedStyle.display != DisplayStyle.None; + public GuidedSoundCheckSnapshot SoundCheckSnapshot => soundCheck.Snapshot; private void Awake() { @@ -245,6 +262,19 @@ private void Update() { if (!isBound || IsNavigationPending) return; if (onboardingVisible) return; + if (soundCheck.Snapshot.State == GuidedSoundCheckState.Running) + { + if (UnityEngine.Input.GetKeyDown(KeyCode.Escape)) + { + CancelSoundCheck(); + return; + } + HandleSoundCheckKeyboardInput(); + soundCheck.Advance(Time.unscaledTimeAsDouble); + RenderSoundCheck(); + RefreshInputStatus(); + return; + } if (UnityEngine.Input.GetKeyDown(KeyCode.UpArrow)) { if (playVisible) MoveSongSelection(-1); @@ -266,9 +296,15 @@ private void Update() private void OnDisable() { + CancelSoundCheck(); Unbind(); } + private void OnDestroy() + { + ReleaseSoundCheckAudio(); + } + public void Configure( PanelSettings panelSettings, Texture2D backgroundTexture, @@ -510,6 +546,15 @@ private void TryBind() midiOffsetResetButton = Required