using UnityEngine; using TMPro; using UnityEngine.UI; using System.Collections; #if UNITY_ANDROID using UnityEngine.Android; #endif [RequireComponent(typeof(AudioSource))] public class TunerController : MonoBehaviour { [Header("UI")] public TextMeshProUGUI freqText; public TextMeshProUGUI noteText; public TextMeshProUGUI centsText; public Image tuningNeedle; public TextMeshProUGUI statusText; // можно оставить пустым, если не нужен [Header("Microphone settings")] public int sampleRate = 16000; public int clipLengthSec = 1; public string microphoneDevice = ""; [Header("Detection settings")] public float minFreq = 80f; public float maxFreq = 1200f; public float rmsThreshold = 0.0015f; public float acfPeakThreshold = 0.25f; public int sampleWindow = 4096; AudioSource audioSource; AudioClip micClip; string micDevice; float uiInterval = 0.09f; float uiTimer = 0f; bool micStarted = false; bool permissionRequested = false; void Start() { audioSource = GetComponent(); if (statusText) statusText.text = "Проверяю доступ к микрофону..."; TryStartMicrophone(); } void OnApplicationFocus(bool hasFocus) { if (hasFocus && !micStarted) { TryStartMicrophone(); } } void TryStartMicrophone() { #if UNITY_ANDROID if (Permission.HasUserAuthorizedPermission(Permission.Microphone)) { StartCoroutine(StartMicRoutine()); return; } if (permissionRequested) return; permissionRequested = true; var callbacks = new PermissionCallbacks(); callbacks.PermissionGranted += OnPermissionGranted; callbacks.PermissionDenied += OnPermissionDenied; callbacks.PermissionDeniedAndDontAskAgain += OnPermissionDeniedDontAskAgain; Permission.RequestUserPermission(Permission.Microphone, callbacks); #else StartCoroutine(StartMicRoutine()); #endif } #if UNITY_ANDROID void OnPermissionGranted(string permissionName) { StartCoroutine(StartMicRoutine()); } void OnPermissionDenied(string permissionName) { if (statusText) statusText.text = "Нужен доступ к микрофону"; } void OnPermissionDeniedDontAskAgain(string permissionName) { if (statusText) statusText.text = "Разреши микрофон в настройках"; } #endif IEnumerator StartMicRoutine() { if (micStarted) yield break; if (Microphone.devices.Length == 0) { if (statusText) statusText.text = "Микрофон не найден"; yield break; } micDevice = string.IsNullOrEmpty(microphoneDevice) ? Microphone.devices[0] : microphoneDevice; // даём системе 1 кадр после окна разрешения yield return null; micClip = Microphone.Start(micDevice, true, clipLengthSec, sampleRate); if (micClip == null) { if (statusText) statusText.text = "Не удалось включить микрофон"; yield break; } // ждём, пока микрофон реально начнёт писать, но без вечного зависания float timeout = 2f; while (Microphone.GetPosition(micDevice) <= 0 && timeout > 0f) { timeout -= Time.unscaledDeltaTime; yield return null; } if (timeout <= 0f) { if (statusText) statusText.text = "Микрофон не запустился"; yield break; } audioSource.loop = true; audioSource.clip = micClip; audioSource.mute = true; audioSource.Play(); micStarted = true; if (statusText) statusText.text = "Микрофон включён"; } void Update() { if (!micStarted || micClip == null) return; uiTimer += Time.unscaledDeltaTime; if (uiTimer < uiInterval) return; uiTimer = 0f; float freq = DetectPitch(); if (freq > 0) { float midiFloat = FrequencyToMidi(freq); int octave; string note = MidiToNoteName(midiFloat, out octave); float cents = FrequencyToCents(freq, midiFloat); if (freqText) freqText.SetText("{0:F1} Hz", freq); if (noteText) noteText.text = $"{note}{octave}"; if (centsText) centsText.SetText("{0:+0.0;-0.0}¢", cents); if (tuningNeedle) { float angle = Mathf.Clamp(cents, -50f, 50f) / 50f * 45f; tuningNeedle.rectTransform.localRotation = Quaternion.Euler(0, 0, -angle); } } else { if (freqText) freqText.SetText("-- Hz"); if (noteText) noteText.SetText("-"); if (centsText) centsText.SetText(""); if (tuningNeedle) tuningNeedle.rectTransform.localRotation = Quaternion.Euler(0, 0, 0); } } void OnDestroy() { if (!string.IsNullOrEmpty(micDevice) && Microphone.IsRecording(micDevice)) Microphone.End(micDevice); } float DetectPitch() { if (micClip == null) return -1f; int micPos = Microphone.GetPosition(micDevice); if (micPos <= 0) return -1f; float[] samples = new float[sampleWindow]; int startPos = micPos - sampleWindow; if (startPos >= 0) { micClip.GetData(samples, startPos); } else { int part1 = sampleWindow + startPos; float[] tail = new float[part1]; float[] head = new float[sampleWindow - part1]; micClip.GetData(tail, micClip.samples - part1); micClip.GetData(head, 0); System.Array.Copy(tail, 0, samples, 0, part1); System.Array.Copy(head, 0, samples, part1, head.Length); } float mean = 0f; for (int i = 0; i < samples.Length; i++) mean += samples[i]; mean /= samples.Length; for (int i = 0; i < samples.Length; i++) samples[i] -= mean; float sumSq = 0f; for (int i = 0; i < samples.Length; i++) sumSq += samples[i] * samples[i]; float rms = Mathf.Sqrt(sumSq / samples.Length); if (rms < rmsThreshold) return -1f; ApplyHammingWindow(samples); int maxLag = Mathf.FloorToInt(sampleRate / minFreq); int minLag = Mathf.CeilToInt(sampleRate / maxFreq); if (maxLag >= samples.Length) maxLag = samples.Length - 1; if (minLag < 1) minLag = 1; float acf0 = 0f; for (int i = 0; i < samples.Length; i++) acf0 += samples[i] * samples[i]; if (acf0 <= 1e-9f) return -1f; float[] acf = new float[maxLag + 1]; for (int lag = minLag; lag <= maxLag; lag++) { float sum = 0f; for (int i = 0; i < samples.Length - lag; i++) sum += samples[i] * samples[i + lag]; acf[lag] = sum / acf0; } int bestLag = -1; float bestVal = 0f; for (int lag = minLag; lag <= maxLag; lag++) { if (acf[lag] > bestVal) { bestVal = acf[lag]; bestLag = lag; } } if (bestLag <= 0) return -1f; if (bestVal < acfPeakThreshold) return -1f; float refinedLag = ParabolicInterpolation(acf, bestLag); float frequency = sampleRate / refinedLag; return frequency; } static void ApplyHammingWindow(float[] data) { int N = data.Length; for (int i = 0; i < N; i++) { float w = 0.54f - 0.46f * Mathf.Cos(2f * Mathf.PI * i / (N - 1)); data[i] *= w; } } static float ParabolicInterpolation(float[] acf, int lag) { if (lag <= 0 || lag >= acf.Length - 1) return lag; float y0 = acf[lag - 1]; float y1 = acf[lag]; float y2 = acf[lag + 1]; float denom = (y0 - 2f * y1 + y2); if (Mathf.Abs(denom) < 1e-8f) return lag; float shift = 0.5f * (y0 - y2) / denom; return lag + shift; } static float FrequencyToMidi(float freq) { return 69f + 12f * Mathf.Log(freq / 440f, 2f); } static string MidiToNoteName(float midiFloat, out int octave) { int midi = Mathf.RoundToInt(midiFloat); string[] names = { "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" }; int noteIndex = midi % 12; octave = (midi / 12) - 1; return names[(noteIndex + 12) % 12]; } static float FrequencyToCents(float freq, float midiFloat) { float nearestMidi = Mathf.Round(midiFloat); return 1200f * Mathf.Log(freq / MidiToFrequency(nearestMidi), 2f); } static float MidiToFrequency(float midi) { return 440f * Mathf.Pow(2f, (midi - 69f) / 12f); } }