diff --git a/docs/README.md b/docs/README.md index 9068ea4..017e426 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,6 +44,7 @@ without creating an unreviewed second source of truth. - [Branching and release channels](development/branching-and-releases.md) - [DSP song clock](development/dsp-song-clock.md) - [Chart timeline](development/chart-timeline.md) +- [Practice Lab](development/practice-lab.md) - [Chart Creator foundation](development/chart-creator.md) - [Demo-song vertical slice](development/demo-song-vertical-slice.md) - [Keyboard hit matching](development/keyboard-hit-matching.md) diff --git a/docs/development/practice-lab.md b/docs/development/practice-lab.md new file mode 100644 index 0000000..731ccdb --- /dev/null +++ b/docs/development/practice-lab.md @@ -0,0 +1,37 @@ +# Practice Lab + +Practice Lab adds repeatable song sections and manual A–B loops to the existing +gameplay scene. It deliberately reuses the production DSP clock, chart timeline, +hit matcher, score tracker and audio sources. + +## Player flow + +Pause a song with `Esc` or `P`, then use the Practice Lab panel: + +- choose the previous or next four-bar section and select **Loop section**; +- select **Set A**, resume and pause later, then select **Set B** for a custom + range; +- select **Whole song** to leave practice mode and restart normally. + +Every repetition includes a two-beat preparation window before point A when the +range does not start at the beginning. Only notes inside `[A, B)` are sent to the +matcher. Score, combo and timing analysis restart for each pass, while recorded +practice time remains cumulative. + +## Timing contract + +`DspSongClock.Seek` re-anchors absolute song position to the current DSP time. +`DspSongClockPrototype.SeekPlayback` moves the audio playhead to the equivalent +clip position, including playback-speed conversion. The highway continues to +derive marker position from absolute chart time, so repeated loops do not +accumulate frame-based drift. + +Automatic sections are derived from verified session timing in groups of four +bars. The final section may contain fewer bars. A selected section is clamped to +the real audio duration before it can become active. + +## Boundaries + +This foundation does not persist loop selections and does not add a second +timeline, scoring engine or audio transport. Named musical sections can be added +later as optional chart metadata without changing the loop transport contract. diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClock.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClock.cs index 6e6f8c6..1aac2aa 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClock.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClock.cs @@ -75,6 +75,27 @@ public void Resume() IsPaused = false; } + public void Seek(double positionSeconds) + { + EnsureScheduled(); + EnsureFinite(positionSeconds, nameof(positionSeconds)); + if (positionSeconds < 0 || positionSeconds >= DurationSeconds) + { + throw new ArgumentOutOfRangeException( + nameof(positionSeconds), + "Song position must be within the scheduled duration."); + } + + if (IsPaused) + { + StartDspTime = pausedAtDspTime - positionSeconds; + } + else + { + StartDspTime = timeSource.Now - positionSeconds; + } + } + private static void EnsureFinite(double value, string parameterName) { if (double.IsNaN(value) || double.IsInfinity(value)) diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs index 9c6c4ed..dc24a2b 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs @@ -25,6 +25,7 @@ public sealed class DspSongClockPrototype : MonoBehaviour private string externalAudioPath; private double audioPlaybackSpeed = 1.0; private UnityWebRequest audioRequest; + private bool seekedWhilePaused; public DspSongClock Clock { get; private set; } public double StartDspTime => Clock != null && Clock.IsScheduled ? Clock.StartDspTime : double.NaN; @@ -142,7 +143,15 @@ public void ResumePlayback() { if (Clock == null || !Clock.IsPaused) return; Clock.Resume(); - audioSource.UnPause(); + if (seekedWhilePaused) + { + audioSource.Play(); + seekedWhilePaused = false; + } + else + { + audioSource.UnPause(); + } } public void RestartPlayback() @@ -154,6 +163,35 @@ public void RestartPlayback() completedLogged = false; } + public void SeekPlayback(double positionSeconds) + { + if (generatedClip == null || Clock == null || !Clock.IsScheduled) + throw new InvalidOperationException("Song playback must be scheduled before seeking."); + if (double.IsNaN(positionSeconds) || double.IsInfinity(positionSeconds) || + positionSeconds < 0 || positionSeconds >= Clock.DurationSeconds) + throw new ArgumentOutOfRangeException(nameof(positionSeconds)); + + bool wasPaused = Clock.IsPaused; + double clipPosition = Math.Min( + generatedClip.length - (1.0 / Math.Max(1, generatedClip.frequency)), + positionSeconds * audioPlaybackSpeed); + + audioSource.Stop(); + audioSource.time = (float)Math.Max(0, clipPosition); + Clock.Seek(positionSeconds); + if (wasPaused) + { + seekedWhilePaused = true; + } + else + { + audioSource.Play(); + seekedWhilePaused = false; + } + startedLogged = positionSeconds >= 0; + completedLogged = false; + } + public void PreviewFromSourceTime(double sourceTimeSeconds, double speed = 1.0) { if (generatedClip == null || audioSource == null) @@ -186,6 +224,7 @@ private void SchedulePlayback() Clock = new DspSongClock(timeSource); Clock.Schedule(startDspTime, generatedClip.length / audioPlaybackSpeed); audioSource.PlayScheduled(startDspTime); + seekedWhilePaused = false; } private void Update() diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimeline.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimeline.cs index 22b2bd7..e04042f 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimeline.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimeline.cs @@ -85,6 +85,22 @@ public IReadOnlyList GetElapsed(double songPositionSeconds) return result.AsReadOnly(); } + public IReadOnlyList GetRange(double startSeconds, double endSeconds) + { + ValidateFinite(startSeconds, nameof(startSeconds)); + ValidateFinite(endSeconds, nameof(endSeconds)); + if (startSeconds < 0 || endSeconds <= startSeconds) + throw new ArgumentOutOfRangeException(nameof(endSeconds), "Range end must be after a non-negative start."); + + var result = new List(); + foreach (TimelineNote note in notes) + { + if (note.EffectiveTimeSeconds >= startSeconds && note.EffectiveTimeSeconds < endSeconds) + result.Add(note); + } + return result.AsReadOnly(); + } + private static void ValidateFinite(double value, string parameterName) { if (!IsFinite(value)) diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs index f287acf..e66a68d 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs @@ -65,12 +65,23 @@ private void Start() public IReadOnlyList CreateMatchingNotes() { if (Timeline == null) throw new InvalidOperationException("The chart timeline has not started."); - var result = new ChartNote[Timeline.Notes.Count]; + return CreateMatchingNotes(Timeline.Notes); + } + + public IReadOnlyList CreateMatchingNotes(double startSeconds, double endSeconds) + { + if (Timeline == null) throw new InvalidOperationException("The chart timeline has not started."); + return CreateMatchingNotes(Timeline.GetRange(startSeconds, endSeconds)); + } + + private static IReadOnlyList CreateMatchingNotes(IReadOnlyList source) + { + var result = new ChartNote[source.Count]; for (int index = 0; index < result.Length; index++) { - ChartNote note = Timeline.Notes[index].Note; + ChartNote note = source[index].Note; result[index] = new ChartNote( - Timeline.Notes[index].EffectiveTimeSeconds, + source[index].EffectiveTimeSeconds, note.Pad, note.Velocity, note.Articulation); diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs index 46d353f..4e53baa 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs @@ -21,6 +21,7 @@ public sealed class GameplayHighwayController : MonoBehaviour private const double HighwayLookAheadSeconds = 4.0; private const double KitPreparationSeconds = 1.35; private const float PulseDurationSeconds = 0.18f; + private const int PracticeLeadInBeats = 2; [SerializeField] private UIDocument document; [SerializeField] private ChartTimelinePrototype chartTimeline; @@ -66,6 +67,12 @@ public sealed class GameplayHighwayController : MonoBehaviour private Button resultRestartButton; private Button resultMenuButton; private Button resultApplyCalibrationButton; + private Button practicePreviousSectionButton; + private Button practiceNextSectionButton; + private Button practiceLoopSectionButton; + private Button practiceSetAButton; + private Button practiceSetBButton; + private Button practiceClearButton; private VisualElement pauseOverlay; private VisualElement resultsOverlay; private VisualElement countdownOverlay; @@ -77,6 +84,8 @@ public sealed class GameplayHighwayController : MonoBehaviour private Label resultBreakdownLabel; private Label resultPracticeLabel; private Label resultCalibrationLabel; + private Label practiceSectionLabel; + private Label practiceStatusLabel; private VisualElement resultPerformancePanel; private VisualElement chartCreatorResults; private Label chartCreatorSummaryLabel; @@ -120,7 +129,11 @@ public sealed class GameplayHighwayController : MonoBehaviour private AudioSource metronomeSource; private AudioClip metronomeClip; private bool metronomeScheduled; + private bool metronomeSeekedWhilePaused; private bool resultRecorded; + private readonly GameplayPracticeLoop practiceLoop = new GameplayPracticeLoop(); + private IReadOnlyList practiceSections = Array.Empty(); + private int selectedPracticeSectionIndex; private ChartRecordingSession chartRecording; private ChartRecordingDraft chartDraft; private ChartDraftEditor chartDraftEditor; @@ -145,6 +158,8 @@ public sealed class GameplayHighwayController : MonoBehaviour public GameplaySessionDefinition CurrentSession => sessionCoordinator?.Session ?? GameplaySessionContext.Current; public double CurrentAttemptPracticeSeconds => practiceTimer.CurrentAttemptSeconds; + public GameplayPracticeRange ActivePracticeRange => practiceLoop.Range; + public IReadOnlyList PracticeSections => practiceSections; public int RecordedChartHitCount => chartRecording?.HitCount ?? chartDraft?.Hits.Count ?? 0; public string LastChartExportPath { get; private set; } public string LastChartPackagePath { get; private set; } @@ -176,6 +191,7 @@ private void Start() BindNavigation(); Subscribe(); InitializeAudioFeedback(); + InitializePracticeLab(); InitializeChartCreator(); SetTheme(CurrentSession.Theme); RefreshSessionCopy(); @@ -186,6 +202,7 @@ private void Update() HandleShortcuts(); TrackPracticeTime(); TryScheduleMetronome(); + UpdatePracticeLoop(); UpdatePulseState(); RefreshPresentation(); } @@ -290,6 +307,12 @@ private void BindView() resultRestartButton = root.Q