moonshine-tiny-ko-unity / Assets /Scripts /ASRRuntimeController.cs
Sky-Kim's picture
Initial commit
6ac63e1
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;
}
}