using System; using System.Collections; using System.Text; using UnityEngine; using UnityEngine.UI; public class ASRRuntimeController : MonoBehaviour { [Header("References")] [SerializeField] private ASRManager m_ASRManager; [SerializeField] private GameObject m_TextBlockPrefab; [SerializeField] private Transform m_ContentRoot; [SerializeField] private ScrollRect m_ResultScrollRect; [SerializeField] private GameObject m_StartButton; [SerializeField] private Dropdown m_BackendDropdown; private Font m_WebGlKoreanFont; private bool _missingBindingWarningLogged; private bool _hasPendingBackendSelection; private bool _startButtonDismissed; private ASRManager.InferenceBackend _pendingBackendSelection = ASRManager.InferenceBackend.GPUCompute; private Coroutine _scrollCoroutine; private GameObject _activeLineItem; private Text _activeLineText; private readonly StringBuilder _activeLineBuffer = new StringBuilder(256); private const string WebGlKoreanFontResourcePath = "NanumGothic-Regular"; private const string BackendOptionGpuCompute = "GPUCompute"; private const string BackendOptionCpu = "CPU"; private const bool STOP_AND_PROCESS_CURRENT_SEGMENT = true; private const int MAX_RESULT_ITEMS = 100; private void Awake() { if (m_ASRManager == null) m_ASRManager = FindFirstObjectByType(); if (m_ResultScrollRect == null && m_ContentRoot != null) m_ResultScrollRect = m_ContentRoot.GetComponentInParent(); ApplyWebGlKoreanFontIfNeeded(); InitializeBackendDropdown(); if (m_StartButton != null) m_StartButton.SetActive(false); } private void OnEnable() { if (m_ASRManager == null) return; m_ASRManager.OnStateChanged += OnStateChanged; m_ASRManager.OnSpeechTextReceived += OnSpeechTextReceived; } private void OnDisable() { if (_scrollCoroutine != null) { StopCoroutine(_scrollCoroutine); _scrollCoroutine = null; } if (m_ASRManager == null) return; m_ASRManager.OnStateChanged -= OnStateChanged; m_ASRManager.OnSpeechTextReceived -= OnSpeechTextReceived; } private void Start() { ApplyWebGlKoreanFontToExistingItems(); TryApplyPendingBackendSelection(); } public void ToggleListeningFromButton() { if (m_ASRManager == null) return; if (!_startButtonDismissed) { _startButtonDismissed = true; if (m_StartButton != null) m_StartButton.SetActive(false); } if (m_ASRManager.currentState == ASRManager.State.Listening || m_ASRManager.currentState == ASRManager.State.Speaking) { m_ASRManager.StopListening(STOP_AND_PROCESS_CURRENT_SEGMENT); return; } m_ASRManager.Listen(); } private void OnStateChanged(ASRManager.State state) { if (m_StartButton != null) { var isReady = !_startButtonDismissed && (state == ASRManager.State.Ready || state == ASRManager.State.Listening || state == ASRManager.State.Speaking || state == ASRManager.State.STTProcessing); m_StartButton.SetActive(isReady); } TryApplyPendingBackendSelection(); } private void OnSpeechTextReceived(string text) { if (string.IsNullOrWhiteSpace(text)) return; AppendStreamingText(text); } private bool TryCreateResultItem(string text, out GameObject item, out Text resultText) { item = null; resultText = null; if (m_TextBlockPrefab == null || m_ContentRoot == null) { if (!_missingBindingWarningLogged) { Debug.LogWarning("[ASRRuntimeController] Assign TextBlock prefab and Content transform."); _missingBindingWarningLogged = true; } return false; } item = Instantiate(m_TextBlockPrefab, m_ContentRoot); item.transform.SetAsLastSibling(); resultText = item.GetComponentInChildren(true); if (resultText == null) { Debug.LogWarning("[ASRRuntimeController] Text component not found in TextBlock prefab."); Destroy(item); item = null; return false; } ApplyWebGlKoreanFontToText(resultText); resultText.text = text; resultText.horizontalOverflow = HorizontalWrapMode.Overflow; resultText.resizeTextForBestFit = false; TrimOldItemsIfNeeded(); RequestScrollToBottom(); return true; } private void AppendStreamingText(string chunk) { EnsureActiveLine(); if (_activeLineText == null) return; for (var i = 0; i < chunk.Length; i++) { var ch = chunk[i]; if (ch == '\r') continue; if (ch == '\n') { MoveToNextLine(); continue; } _activeLineBuffer.Append(ch); if (WouldOverflowLine(_activeLineText, _activeLineBuffer) && _activeLineBuffer.Length > 1) { _activeLineBuffer.Length -= 1; ApplyActiveLineBuffer(); MoveToNextLine(); if (char.IsWhiteSpace(ch)) continue; _activeLineBuffer.Append(ch); } ApplyActiveLineBuffer(); } RequestScrollToBottom(); } private void EnsureActiveLine() { if (_activeLineText != null) return; if (!TryCreateResultItem(string.Empty, out _activeLineItem, out _activeLineText)) return; _activeLineBuffer.Clear(); SetLineStyle(_activeLineText, isActive: true); } private void MoveToNextLine() { if (_activeLineText != null) SetLineStyle(_activeLineText, isActive: false); _activeLineItem = null; _activeLineText = null; _activeLineBuffer.Clear(); EnsureActiveLine(); } private void ApplyActiveLineBuffer() { if (_activeLineText == null) return; _activeLineText.text = _activeLineBuffer.ToString(); } private bool WouldOverflowLine(Text textComponent, StringBuilder candidateBuffer) { if (textComponent == null || candidateBuffer == null) return false; var maxWidth = ResolveLineWidth(textComponent); if (maxWidth <= 0f) return false; var settings = textComponent.GetGenerationSettings(new Vector2(maxWidth, float.MaxValue)); var preferredWidth = textComponent.cachedTextGeneratorForLayout.GetPreferredWidth(candidateBuffer.ToString(), settings) / textComponent.pixelsPerUnit; return preferredWidth > maxWidth + 0.5f; } private float ResolveLineWidth(Text textComponent) { var textWidth = textComponent.rectTransform.rect.width; if (textWidth > 0f) return textWidth; if (textComponent.transform.parent is RectTransform parentRect && parentRect.rect.width > 0f) return parentRect.rect.width; if (m_ContentRoot is RectTransform contentRect && contentRect.rect.width > 0f) return contentRect.rect.width; return 0f; } private static void SetLineStyle(Text lineText, bool isActive) { if (lineText == null) return; lineText.fontStyle = isActive ? FontStyle.Bold : FontStyle.Normal; } private void RequestScrollToBottom() { if (_scrollCoroutine != null) return; _scrollCoroutine = StartCoroutine(ScrollToBottomCoroutine()); } private IEnumerator ScrollToBottomCoroutine() { yield return new WaitForEndOfFrame(); _scrollCoroutine = null; if (m_ResultScrollRect == null && m_ContentRoot != null) m_ResultScrollRect = m_ContentRoot.GetComponentInParent(); if (m_ResultScrollRect == null) yield break; if (m_ResultScrollRect.content != null) LayoutRebuilder.ForceRebuildLayoutImmediate(m_ResultScrollRect.content); Canvas.ForceUpdateCanvases(); m_ResultScrollRect.verticalNormalizedPosition = 0f; } private void TrimOldItemsIfNeeded() { if (m_ContentRoot == null) return; var excessCount = m_ContentRoot.childCount - MAX_RESULT_ITEMS; for (var i = 0; i < excessCount; i++) { var oldest = m_ContentRoot.GetChild(0); oldest.SetParent(null); Destroy(oldest.gameObject); } } private void ApplyWebGlKoreanFontIfNeeded() { #if UNITY_WEBGL && !UNITY_EDITOR if (m_WebGlKoreanFont == null) m_WebGlKoreanFont = Resources.Load(WebGlKoreanFontResourcePath); if (m_WebGlKoreanFont == null) Debug.LogWarning($"[ASRRuntimeController] WebGL Korean font not found at Resources/{WebGlKoreanFontResourcePath}."); #endif } private void ApplyWebGlKoreanFontToExistingItems() { if (m_ContentRoot == null) return; for (var i = 0; i < m_ContentRoot.childCount; i++) { var child = m_ContentRoot.GetChild(i); var text = child.GetComponentInChildren(true); ApplyWebGlKoreanFontToText(text); } } private void ApplyWebGlKoreanFontToText(Text textComponent) { #if UNITY_WEBGL && !UNITY_EDITOR if (textComponent == null || m_WebGlKoreanFont == null) return; textComponent.font = m_WebGlKoreanFont; #endif } private void InitializeBackendDropdown() { if (m_BackendDropdown == null) return; m_BackendDropdown.onValueChanged.RemoveListener(OnBackendDropdownValueChanged); m_BackendDropdown.onValueChanged.AddListener(OnBackendDropdownValueChanged); var defaultBackend = ASRManager.InferenceBackend.GPUCompute; var defaultIndex = GetDropdownIndexForBackend(defaultBackend); if (defaultIndex >= 0) m_BackendDropdown.SetValueWithoutNotify(defaultIndex); _pendingBackendSelection = defaultBackend; _hasPendingBackendSelection = true; } private void OnBackendDropdownValueChanged(int selectedIndex) { _startButtonDismissed = false; _pendingBackendSelection = GetBackendFromDropdown(selectedIndex); _hasPendingBackendSelection = true; TryApplyPendingBackendSelection(); } private void TryApplyPendingBackendSelection() { if (!_hasPendingBackendSelection || m_ASRManager == null) return; if (!m_ASRManager.TrySetInferenceBackend(_pendingBackendSelection)) return; _hasPendingBackendSelection = false; } private ASRManager.InferenceBackend GetBackendFromDropdown(int selectedIndex) { if (m_BackendDropdown == null || m_BackendDropdown.options == null || m_BackendDropdown.options.Count == 0) return ASRManager.InferenceBackend.CPU; var clamped = Mathf.Clamp(selectedIndex, 0, m_BackendDropdown.options.Count - 1); var option = m_BackendDropdown.options[clamped].text; if (IsOptionMatch(option, BackendOptionGpuCompute)) return ASRManager.InferenceBackend.GPUCompute; return ASRManager.InferenceBackend.CPU; } private int GetDropdownIndexForBackend(ASRManager.InferenceBackend backend) { if (m_BackendDropdown == null || m_BackendDropdown.options == null) return -1; var expected = backend == ASRManager.InferenceBackend.GPUCompute ? BackendOptionGpuCompute : BackendOptionCpu; for (var i = 0; i < m_BackendDropdown.options.Count; i++) { if (IsOptionMatch(m_BackendDropdown.options[i].text, expected)) return i; } return -1; } private static bool IsOptionMatch(string actual, string expected) { if (string.IsNullOrWhiteSpace(actual) || string.IsNullOrWhiteSpace(expected)) return false; return string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase) || actual.IndexOf(expected, StringComparison.OrdinalIgnoreCase) >= 0; } }