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 @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions docs/development/audio-latency-sound-check.md
Original file line number Diff line number Diff line change
@@ -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.
153 changes: 153 additions & 0 deletions src/HitTheKit.Core/GuidedLatencySoundCheck.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading