342 lines
13 KiB
C#
342 lines
13 KiB
C#
using System.Collections.Generic;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.InputSystem;
|
|
using UnityEngine.SceneManagement;
|
|
using UnityEngine.UI;
|
|
using UnityEngine.XR;
|
|
using XRCommonUsages = UnityEngine.XR.CommonUsages;
|
|
using XRInputDevice = UnityEngine.XR.InputDevice;
|
|
|
|
public class SyncCalibrationOverlay : MonoBehaviour
|
|
{
|
|
private const float TickInterval = 1.0f;
|
|
private const int MaxSamples = 8;
|
|
|
|
private static Scene pendingReturnScene;
|
|
|
|
private readonly List<float> samples = new();
|
|
private readonly List<(Canvas canvas, bool enabled)> hiddenCanvases = new();
|
|
|
|
private AudioSource audioSource = null;
|
|
private AudioClip tickClip = null;
|
|
private RectTransform sweepDot = null;
|
|
private RectTransform visualPulse = null;
|
|
private TextMeshProUGUI offsetText = null;
|
|
private TextMeshProUGUI sampleText = null;
|
|
private TextMeshProUGUI guideText = null;
|
|
|
|
private float lastTickTime = 0.0f;
|
|
private float nextTickTime = 0.0f;
|
|
private float pendingVisualPulseTime = -1.0f;
|
|
private float visualPulseTimer = 0.0f;
|
|
private bool previousPrimary = false;
|
|
private bool previousSecondary = false;
|
|
private Scene returnScene;
|
|
private Scene syncScene;
|
|
private bool canvasesRestored = false;
|
|
|
|
public static void Open()
|
|
{
|
|
if (FindFirstObjectByType<SyncCalibrationOverlay>() != null)
|
|
return;
|
|
|
|
pendingReturnScene = SceneManager.GetActiveScene();
|
|
Scene calibrationScene = SceneManager.CreateScene("SyncCalibration");
|
|
SceneManager.SetActiveScene(calibrationScene);
|
|
|
|
GameObject overlay = new GameObject("[SyncCalibrationOverlay]");
|
|
overlay.AddComponent<SyncCalibrationOverlay>();
|
|
}
|
|
|
|
private void Awake()
|
|
{
|
|
syncScene = gameObject.scene;
|
|
returnScene = pendingReturnScene;
|
|
|
|
HideExistingCanvases();
|
|
BuildView();
|
|
tickClip = CreateTickClip();
|
|
audioSource = gameObject.AddComponent<AudioSource>();
|
|
audioSource.playOnAwake = false;
|
|
audioSource.spatialBlend = 0.0f;
|
|
|
|
float now = Time.unscaledTime;
|
|
lastTickTime = now;
|
|
nextTickTime = now + 0.8f;
|
|
UpdateTexts();
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
RestoreCanvases();
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
float now = Time.unscaledTime;
|
|
|
|
if (now >= nextTickTime)
|
|
{
|
|
lastTickTime = nextTickTime;
|
|
nextTickTime += TickInterval;
|
|
audioSource.PlayOneShot(tickClip);
|
|
pendingVisualPulseTime = lastTickTime + GlobalSyncSettings.AudioOffsetSeconds;
|
|
}
|
|
|
|
if (pendingVisualPulseTime > 0.0f && now >= pendingVisualPulseTime)
|
|
{
|
|
visualPulseTimer = 0.18f;
|
|
pendingVisualPulseTime = -1.0f;
|
|
}
|
|
|
|
UpdateMetronomeVisual(now);
|
|
HandleInput(now);
|
|
}
|
|
|
|
private void HandleInput(float now)
|
|
{
|
|
bool primary = GetRightButton(XRCommonUsages.primaryButton) || IsKeyboardPressed(Key.Space);
|
|
bool secondary = GetRightButton(XRCommonUsages.secondaryButton) || IsKeyboardPressed(Key.Escape);
|
|
|
|
if (primary && !previousPrimary)
|
|
CaptureSample(now);
|
|
if (secondary && !previousSecondary)
|
|
Close();
|
|
|
|
previousPrimary = primary;
|
|
previousSecondary = secondary;
|
|
}
|
|
|
|
private void CaptureSample(float now)
|
|
{
|
|
float nearestTick = Mathf.Abs(now - lastTickTime) <= Mathf.Abs(now - nextTickTime)
|
|
? lastTickTime
|
|
: nextTickTime;
|
|
|
|
float offsetMs = Mathf.Clamp((now - nearestTick) * 1000.0f, -300.0f, 300.0f);
|
|
samples.Add(offsetMs);
|
|
if (samples.Count > MaxSamples)
|
|
samples.RemoveAt(0);
|
|
|
|
float sum = 0.0f;
|
|
for (int i = 0; i < samples.Count; i++)
|
|
sum += samples[i];
|
|
|
|
GlobalSyncSettings.AudioOffsetMs = sum / samples.Count;
|
|
UpdateTexts();
|
|
}
|
|
|
|
private void AdjustOffset(float deltaMs)
|
|
{
|
|
GlobalSyncSettings.AudioOffsetMs += deltaMs;
|
|
samples.Clear();
|
|
UpdateTexts();
|
|
}
|
|
|
|
private void ResetOffset()
|
|
{
|
|
GlobalSyncSettings.Reset();
|
|
samples.Clear();
|
|
UpdateTexts();
|
|
}
|
|
|
|
private void Close()
|
|
{
|
|
RestoreCanvases();
|
|
|
|
if (returnScene.IsValid() && returnScene.isLoaded)
|
|
SceneManager.SetActiveScene(returnScene);
|
|
|
|
if (syncScene.IsValid() && syncScene.isLoaded)
|
|
SceneManager.UnloadSceneAsync(syncScene);
|
|
else
|
|
Destroy(gameObject);
|
|
}
|
|
|
|
private void BuildView()
|
|
{
|
|
Camera camera = Camera.main ?? FindFirstObjectByType<Camera>();
|
|
|
|
GameObject canvasObject = new GameObject("SyncCalibrationCanvas");
|
|
canvasObject.transform.SetParent(transform, false);
|
|
Canvas canvas = canvasObject.AddComponent<Canvas>();
|
|
canvas.renderMode = RenderMode.WorldSpace;
|
|
canvas.sortingOrder = 400;
|
|
canvas.worldCamera = camera;
|
|
canvasObject.AddComponent<GraphicRaycaster>();
|
|
|
|
RectTransform canvasRect = canvasObject.GetComponent<RectTransform>();
|
|
canvasRect.sizeDelta = new Vector2(1080.0f, 660.0f);
|
|
canvasObject.transform.localScale = Vector3.one * 0.0028f;
|
|
|
|
if (camera != null)
|
|
{
|
|
Transform camTransform = camera.transform;
|
|
canvasObject.transform.position = camTransform.position + camTransform.forward * 1.9f;
|
|
canvasObject.transform.rotation = Quaternion.LookRotation(canvasObject.transform.position - camTransform.position, camTransform.up);
|
|
}
|
|
|
|
Image background = CreateImage("Panel", canvasRect, new Vector2(0, 0), new Vector2(1020, 620), new Color(0.02f, 0.06f, 0.09f, 0.92f));
|
|
background.raycastTarget = false;
|
|
|
|
TextMeshProUGUI title = CreateText("Title", canvasRect, "SYNC CALIBRATION", new Vector2(0, 260), new Vector2(920, 58), 42, Color.white, TextAlignmentOptions.Center);
|
|
title.fontStyle = FontStyles.Bold;
|
|
guideText = CreateText("Guide", canvasRect, "", new Vector2(0, 180), new Vector2(920, 92), 26, new Color(0.77f, 0.9f, 1.0f, 1.0f), TextAlignmentOptions.Center);
|
|
|
|
Image bar = CreateImage("BeatBar", canvasRect, new Vector2(0, 82), new Vector2(760, 10), new Color(0.25f, 0.85f, 1.0f, 0.28f));
|
|
bar.raycastTarget = false;
|
|
sweepDot = CreateImage("BeatDot", canvasRect, new Vector2(-380, 82), new Vector2(34, 34), new Color(0.35f, 0.95f, 1.0f, 1.0f)).rectTransform;
|
|
visualPulse = CreateImage("VisualPulse", canvasRect, new Vector2(0, 82), new Vector2(140, 140), new Color(0.35f, 0.95f, 1.0f, 0.0f)).rectTransform;
|
|
|
|
offsetText = CreateText("Offset", canvasRect, "", new Vector2(0, 0), new Vector2(720, 74), 54, Color.white, TextAlignmentOptions.Center);
|
|
offsetText.fontStyle = FontStyles.Bold;
|
|
sampleText = CreateText("Samples", canvasRect, "", new Vector2(0, -72), new Vector2(760, 44), 24, new Color(0.65f, 0.78f, 0.84f, 1.0f), TextAlignmentOptions.Center);
|
|
|
|
CreateButton(canvasRect, "-10ms", new Vector2(-300, -165), () => AdjustOffset(-10.0f));
|
|
CreateButton(canvasRect, "+10ms", new Vector2(-100, -165), () => AdjustOffset(10.0f));
|
|
CreateButton(canvasRect, "RESET", new Vector2(100, -165), ResetOffset);
|
|
CreateButton(canvasRect, "BACK", new Vector2(300, -165), Close);
|
|
|
|
CreateText("Footer", canvasRect, "A / Space: capture beat B / Esc: back", new Vector2(0, -270), new Vector2(860, 40), 22, new Color(0.58f, 0.7f, 0.75f, 1.0f), TextAlignmentOptions.Center);
|
|
}
|
|
|
|
private void UpdateMetronomeVisual(float now)
|
|
{
|
|
float phase = Mathf.InverseLerp(lastTickTime, nextTickTime, now);
|
|
if (sweepDot != null)
|
|
sweepDot.anchoredPosition = new Vector2(Mathf.Lerp(-380.0f, 380.0f, phase), 82.0f);
|
|
|
|
if (visualPulse == null)
|
|
return;
|
|
|
|
visualPulseTimer = Mathf.Max(0.0f, visualPulseTimer - Time.unscaledDeltaTime);
|
|
float alpha = visualPulseTimer / 0.18f;
|
|
visualPulse.sizeDelta = Vector2.one * Mathf.Lerp(190.0f, 80.0f, alpha);
|
|
Image image = visualPulse.GetComponent<Image>();
|
|
if (image != null)
|
|
image.color = new Color(0.35f, 0.95f, 1.0f, alpha * 0.52f);
|
|
}
|
|
|
|
private void UpdateTexts()
|
|
{
|
|
float offset = GlobalSyncSettings.AudioOffsetMs;
|
|
if (offsetText != null)
|
|
offsetText.text = $"{offset:+0;-0;0} ms";
|
|
if (sampleText != null)
|
|
sampleText.text = $"samples {samples.Count}/{MaxSamples} global offset saved";
|
|
if (guideText != null)
|
|
guideText.text = "Tick 소리가 들리는 순간 A / Space를 누르세요.\n파란 원이 박자와 겹치면 보정이 맞습니다.";
|
|
}
|
|
|
|
private void HideExistingCanvases()
|
|
{
|
|
hiddenCanvases.Clear();
|
|
foreach (Canvas canvas in FindObjectsByType<Canvas>(FindObjectsSortMode.None))
|
|
{
|
|
hiddenCanvases.Add((canvas, canvas.enabled));
|
|
canvas.enabled = false;
|
|
}
|
|
}
|
|
|
|
private void RestoreCanvases()
|
|
{
|
|
if (canvasesRestored)
|
|
return;
|
|
|
|
canvasesRestored = true;
|
|
foreach ((Canvas canvas, bool enabled) in hiddenCanvases)
|
|
{
|
|
if (canvas != null)
|
|
canvas.enabled = enabled;
|
|
}
|
|
}
|
|
|
|
private static TextMeshProUGUI CreateText(string name, RectTransform parent, string value, Vector2 position, Vector2 size, int fontSize, Color color, TextAlignmentOptions alignment)
|
|
{
|
|
GameObject go = new GameObject(name);
|
|
go.transform.SetParent(parent, false);
|
|
RectTransform rect = go.AddComponent<RectTransform>();
|
|
rect.anchoredPosition = position;
|
|
rect.sizeDelta = size;
|
|
|
|
TextMeshProUGUI text = go.AddComponent<TextMeshProUGUI>();
|
|
text.text = value;
|
|
text.fontSize = fontSize;
|
|
text.color = color;
|
|
text.alignment = alignment;
|
|
text.textWrappingMode = TextWrappingModes.Normal;
|
|
text.overflowMode = TextOverflowModes.Overflow;
|
|
text.raycastTarget = false;
|
|
return text;
|
|
}
|
|
|
|
private static Image CreateImage(string name, RectTransform parent, Vector2 position, Vector2 size, Color color)
|
|
{
|
|
GameObject go = new GameObject(name);
|
|
go.transform.SetParent(parent, false);
|
|
RectTransform rect = go.AddComponent<RectTransform>();
|
|
rect.anchoredPosition = position;
|
|
rect.sizeDelta = size;
|
|
|
|
Image image = go.AddComponent<Image>();
|
|
image.color = color;
|
|
return image;
|
|
}
|
|
|
|
private static void CreateButton(RectTransform parent, string label, Vector2 position, UnityEngine.Events.UnityAction action)
|
|
{
|
|
Image image = CreateImage(label + "Button", parent, position, new Vector2(168, 58), new Color(0.07f, 0.18f, 0.24f, 0.96f));
|
|
Button button = image.gameObject.AddComponent<Button>();
|
|
button.onClick.AddListener(action);
|
|
|
|
ColorBlock colors = button.colors;
|
|
colors.normalColor = new Color(0.07f, 0.18f, 0.24f, 0.96f);
|
|
colors.highlightedColor = new Color(0.13f, 0.38f, 0.48f, 1.0f);
|
|
colors.pressedColor = new Color(0.08f, 0.72f, 0.85f, 1.0f);
|
|
colors.selectedColor = colors.highlightedColor;
|
|
button.colors = colors;
|
|
|
|
TextMeshProUGUI labelText = CreateText(label + "Text", image.rectTransform, label, Vector2.zero, new Vector2(154, 48), 24, Color.white, TextAlignmentOptions.Center);
|
|
labelText.fontStyle = FontStyles.Bold;
|
|
}
|
|
|
|
private static bool GetRightButton(InputFeatureUsage<bool> usage)
|
|
{
|
|
var devices = new List<XRInputDevice>();
|
|
InputDevices.GetDevicesWithCharacteristics(InputDeviceCharacteristics.Controller | InputDeviceCharacteristics.Right, devices);
|
|
if (devices.Count == 0)
|
|
return false;
|
|
|
|
devices[0].TryGetFeatureValue(usage, out bool pressed);
|
|
return pressed;
|
|
}
|
|
|
|
private static bool IsKeyboardPressed(Key key)
|
|
{
|
|
Keyboard keyboard = Keyboard.current;
|
|
return keyboard != null && keyboard[key].isPressed;
|
|
}
|
|
|
|
private static AudioClip CreateTickClip()
|
|
{
|
|
const int sampleRate = 48000;
|
|
const float duration = 0.055f;
|
|
int sampleCount = Mathf.CeilToInt(sampleRate * duration);
|
|
float[] data = new float[sampleCount];
|
|
|
|
for (int i = 0; i < sampleCount; i++)
|
|
{
|
|
float t = (float)i / sampleRate;
|
|
float envelope = Mathf.Exp(-t * 62.0f);
|
|
float high = Mathf.Sin(2.0f * Mathf.PI * 1760.0f * t);
|
|
float click = i < 80 ? 1.0f - (float)i / 80.0f : 0.0f;
|
|
data[i] = Mathf.Clamp((high * 0.75f + click * 0.35f) * envelope, -1.0f, 1.0f);
|
|
}
|
|
|
|
AudioClip clip = AudioClip.Create("SyncTick", sampleCount, 1, sampleRate, false);
|
|
clip.SetData(data, 0);
|
|
return clip;
|
|
}
|
|
}
|