| 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<ASRManager>(); |
|
|
| if (m_ResultScrollRect == null && m_ContentRoot != null) |
| m_ResultScrollRect = m_ContentRoot.GetComponentInParent<ScrollRect>(); |
|
|
| 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<Text>(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<ScrollRect>(); |
|
|
| 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<Font>(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<Text>(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; |
| } |
| } |
|
|