| | 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; |
| | } |
| | } |
| |
|