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 @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions docs/development/practice-lab.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,22 @@ public IReadOnlyList<TimelineNote> GetElapsed(double songPositionSeconds)
return result.AsReadOnly();
}

public IReadOnlyList<TimelineNote> 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<TimelineNote>();
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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,23 @@ private void Start()
public IReadOnlyList<ChartNote> 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<ChartNote> 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<ChartNote> CreateMatchingNotes(IReadOnlyList<TimelineNote> 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);
Expand Down
Loading
Loading