Часы аудио: позиция, пауза и перемотка
Полный индикатор C# с учётом микширования, оценкой задержки и сбросом часов после перемотки.
Выберите источник времени
Для декоративной анимации интерфейса достаточно delta. Если движение должно следовать позиции музыки, суммирование delta создаёт отдельные часы: изменение TimeScale, пауза или перемотка расходятся с аудио. GetPlaybackPosition() относится к последнему голосу плеера, обновляется аудиомикшером и между смешиваниями меняется ступенчато.
Для одного потока со скоростью 1 используйте оценку: GetPlaybackPosition() + AudioServer.GetTimeSinceLastMix() - AudioServer.GetOutputLatency(). Первый член — позиция, второй интерполирует время между смешиваниями, третий оценивает задержку до вывода. Это не измерение момента, когда звук дошёл до уха: устройство, Bluetooth, ОС и экран добавляют свои задержки. Для ритм-игры предусмотрите пользовательскую калибровку.
Запустить готовые часы
Добавьте DemoTone.cs и MusicClockDemo.cs ниже. Прикрепите MusicClockDemo к корню Control, соберите и запустите F6. С начала запускает четырёхсекундный тестовый тон; число секунд идёт вперёд, пауза его замораживает, 1.5 s перемещает позицию. Число после 120 BPM — индекс условной доли, начинающийся с нуля; тон не является музыкальным треком с разметкой долей.
MusicClockDemo.cs
using System;
using Reckfell;
public partial class MusicClockDemo : Control
{
public AudioStreamPlayer Voice { get; private set; } = null!;
public Label Status { get; private set; } = null!;
public int FinishedCount { get; private set; }
private double _last;
private double _outputLatency;
public override void _Ready()
{
bool ru = TranslationServer.GetLocale().StartsWith("ru", StringComparison.OrdinalIgnoreCase);
Voice = new AudioStreamPlayer { Stream = DemoTone.Create(seconds: 4), MaxPolyphony = 1, PitchScale = 1, PlaybackType = AudioServer.PlaybackType.Stream };
AddChild(Voice); RefreshOutputLatency();
var layout = new VBoxContainer { Position = new Vector2(24, 24) }; AddChild(layout);
Status = new Label(); layout.AddChild(Status);
AddButton(layout, ru ? "С начала" : "Restart", Restart);
AddButton(layout, ru ? "Пауза / продолжить" : "Pause / resume", () => SetPaused(!Voice.StreamPaused));
AddButton(layout, "1.5 s", () => SeekTo(1.5));
AddButton(layout, ru ? "Остановить" : "Stop", StopClock);
Voice.Finished += () => { FinishedCount++; _last = Voice.Stream.GetLength(); };
}
private static void AddButton(VBoxContainer parent, string text, Action action)
{
var button = new Button { Text = text }; parent.AddChild(button); button.Pressed += action;
}
public void RefreshOutputLatency() => _outputLatency = Math.Max(0, AudioServer.GetOutputLatency());
public double EstimatedSeconds()
{
if (!Voice.IsPlaying() || Voice.StreamPaused) return _last;
double raw = Voice.GetPlaybackPosition() + AudioServer.GetTimeSinceLastMix() - _outputLatency;
_last = Math.Clamp(Math.Max(_last, raw), 0, Voice.Stream.GetLength());
return _last;
}
public void Restart() { Voice.Stop(); _last = 0; Voice.Play(); }
public void StopClock() { Voice.Stop(); _last = 0; }
public void SetPaused(bool paused)
{
if (!Voice.IsPlaying() && !Voice.StreamPaused) return;
_last = EstimatedSeconds(); Voice.StreamPaused = paused;
}
public bool SeekTo(double seconds)
{
if ((!Voice.IsPlaying() && !Voice.StreamPaused) || !double.IsFinite(seconds) || seconds < 0 || seconds >= Voice.Stream.GetLength()) return false;
bool paused = Voice.StreamPaused;
Voice.Stop(); Voice.Play((float)seconds); Voice.StreamPaused = paused; _last = seconds;
return true;
}
public override void _Process(double delta)
{
double seconds = EstimatedSeconds();
int beat = (int)Math.Floor(seconds * 120 / 60);
Status.Text = $"{seconds:0.000} s | 120 BPM: {beat}";
}
}Пример рассчитан на один незацикленный поток, PitchScale = 1 и глобальную скорость аудио 1. Он сохраняет последнее значение на паузе, ограничивает оценку длительностью и не даёт мелким колебаниям между потоками увести индикатор назад. При явной перемотке этот монотонный ограничитель сбрасывается к целевой позиции, поэтому перемотка назад работает. Не используйте вечный max(previous, now) без такого сброса.
AudioStreamPlayer.Seek() не выполняет перемотку на паузе. Метод SeekTo примера сохраняет паузу, останавливает единственный голос, запускает его с нужной позиции и возвращает паузу. Конечность числа и диапазон 0 <= seconds < length проверяются до изменения состояния. Stop сбрасывает часы; естественный конец оставляет полную длительность. При Loop потребуются отдельный счётчик кругов и явный выбор: показывать позицию внутри круга или общее время.
Что учесть в игре
| Условие | Решение |
|---|---|
| Изменён PitchScale или скорость аудиосервера | Пересчитайте интерполяцию под скорость; готовый пример рассчитан только на 1 |
| Меняется устройство/драйвер вывода | После смены обновите оценку через RefreshOutputLatency; не опрашивайте дорогую задержку каждый кадр |
| Несколько одновременно играющих голосов | Не считайте GetPlaybackPosition часами всех голосов; выделите отдельный музыкальный плеер |
| Плеер поставлен на паузу извне | Все изменения паузы/запуска/перемотки проводите через один контроллер часов |
| Interactive или составной музыкальный поток | Уточните семантику позиции его реализации; не переносите формулу слепо |
| Нужен звук на точной аудиогранице | _Process() и сигналы кадра не являются аудиопотоком; такой планировщик нужен отдельно |
Не вычисляйте попадание в ритм только по целому индексу доли из _Process: кадр может перескочить границу. Храните времена событий на шкале трека, проверяйте пересечение интервала предыдущей/новой позиции и отдельно обрабатывайте явные перемотки. Подтверждение пользовательского попадания должно учитывать настроенное временное окно, а не совпадение визуальных кадров.
Вызов Play тоже не означает немедленное появление звука на устройстве. GetTimeToNextMix() оценивает ожидание следующего смешивания; GetOutputLatency() — последующий вывод. Не прибавляйте обе оценки повторно к формуле позиции выше. Оценка от системного таймера после старта может быть полезна для короткого фрагмента, но это отдельные часы с возможным дрейфом; после паузы и перемотки их тоже нужно согласовывать.