UnityのUI実装をしていると、つい Update() の中に「入力チェック」「アニメーション制御」「サウンド再生」「UI更新」など、あらゆる処理を詰め込んでしまいがちですよね。
動けば一旦満足なのですが、時間が経つと以下のような問題が出てきます。

  • テキストの変更とサウンド再生の処理が混ざり、どこで何をしているか分からない
  • アクセシビリティ機能(読み上げなど)を後から追加しようとすると、巨大なスクリプトをさらに改造するハメになる
  • UIのプレハブを別シーンで使い回したいのに、特定シーンのロジックにベッタリ依存している

こういった「神クラス化」を防ぐには、機能ごとに小さなコンポーネントに分割しておくのが有効です。
この記事では、UIテキストをOSのテキスト読み上げ機能(Text To Speech / TTS)に渡すための、単機能コンポーネントを作ってみましょう。

目的はシンプルです。

  • 「テキストの読み上げ」という責務だけを持つ TextToSpeech コンポーネントを用意する
  • UIテキストの変更ロジックとは疎結合にして、どのUIにも簡単に後付けできるようにする
  • アクセシビリティ対応をプレハブ単位でオンオフできるようにする

【Unity】UIテキストをしゃべらせよう!「TextToSpeech」コンポーネント

ここでは以下を満たす実装を紹介します。

  • Unity6 / C#
  • UIの TextMeshProUGUI または Text の内容を読み上げる
  • Windows / macOS の OS 組み込みTTS(System.Speech / say コマンド)を利用
  • コンポーネント単体で完結(他スクリプトへの依存なし)

※注意: OSのTTS機能を使うため、エディタ上やPCビルドでの利用を想定しています。モバイルやWebGLでは動作しません。

フルコード


using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using UnityEngine;
using TMPro;
using UnityEngine.UI;

#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
// Windows 用: System.Speech を使うための using
using System.Speech.Synthesis;
#endif

/// <summary>
/// UIテキストをOS標準のTTSで読み上げるコンポーネント。
/// - TextMeshProUGUI / Text に対応
/// - テキスト変更時に自動で読み上げ
/// - 任意タイミングでの読み上げAPIも提供
/// 
/// 責務は「文字列を受け取りOSのTTSに渡す」だけに限定しています。
/// </summary>
[DisallowMultipleComponent]
public class TextToSpeech : MonoBehaviour
{
    // 対象テキスト(TextMeshProUGUI)
    [SerializeField] private TextMeshProUGUI targetTMP;

    // 対象テキスト(旧UGUI Text)
    [SerializeField] private Text targetText;

    // 有効なプラットフォームかどうか(エディタから確認できるようにする)
    [SerializeField] private bool isPlatformSupported = true;

    // テキストが変わったときに自動で読み上げるか
    [SerializeField] private bool speakOnTextChanged = true;

    // 同じテキストを連続で読み上げないようにするためのキャッシュ
    private string _lastSpokenText = string.Empty;

#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
    // Windows用のSpeechSynthesizer。1つだけ使い回す。
    private SpeechSynthesizer _synthesizer;
#endif

    private void Awake()
    {
        // プラットフォーム判定
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN || UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
        isPlatformSupported = true;
#else
        isPlatformSupported = false;
#endif

        // どちらのテキストも設定されていない場合は、同じGameObjectから自動取得を試みる
        if (targetTMP == null)
        {
            targetTMP = GetComponent();
        }

        if (targetText == null)
        {
            targetText = GetComponent();
        }

        // Windowsの場合のみ SpeechSynthesizer を初期化
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
        if (isPlatformSupported)
        {
            _synthesizer = new SpeechSynthesizer();
            // OSの既定デバイス(スピーカー)に出力
            _synthesizer.SetOutputToDefaultAudioDevice();
        }
#endif
    }

    private void OnDestroy()
    {
        // Windows用のリソース解放
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
        if (_synthesizer != null)
        {
            _synthesizer.Dispose();
            _synthesizer = null;
        }
#endif
    }

    private void OnEnable()
    {
        // 有効化時に現在のテキストを読み上げたい場合はここから呼んでも良い
        if (speakOnTextChanged)
        {
            SpeakCurrentText();
        }
    }

    /// <summary>
    /// 現在のUIテキスト内容を取得して読み上げます。
    /// TextMeshProUGUI > Text の優先順で参照します。
    /// </summary>
    public void SpeakCurrentText()
    {
        if (!isPlatformSupported)
        {
            Debug.LogWarning("[TextToSpeech] このプラットフォームではTTSはサポートされていません。");
            return;
        }

        string text = GetCurrentText();
        if (string.IsNullOrWhiteSpace(text))
        {
            return;
        }

        // 直前と同じ内容はスキップ(必要なければこのチェックは削除してOK)
        if (text == _lastSpokenText)
        {
            return;
        }

        Speak(text);
        _lastSpokenText = text;
    }

    /// <summary>
    /// 任意の文字列を読み上げます。
    /// UIに表示していないテキストでもOKです。
    /// </summary>
    public void Speak(string message)
    {
        if (!isPlatformSupported)
        {
            Debug.LogWarning("[TextToSpeech] このプラットフォームではTTSはサポートされていません。 message=" + message);
            return;
        }

        if (string.IsNullOrWhiteSpace(message))
        {
            return;
        }

#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
        SpeakOnWindows(message);
#elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
        SpeakOnMac(message);
#else
        Debug.Log("[TextToSpeech] TTS未対応プラットフォームです: " + message);
#endif
    }

    /// <summary>
    /// TextMeshProUGUI または Text から現在のテキストを取得します。
    /// </summary>
    private string GetCurrentText()
    {
        if (targetTMP != null)
        {
            return targetTMP.text;
        }

        if (targetText != null)
        {
            return targetText.text;
        }

        Debug.LogWarning("[TextToSpeech] 対象テキストが設定されていません。TextMeshProUGUI または Text をアサインしてください。");
        return string.Empty;
    }

#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
    /// <summary>
    /// Windows の System.Speech を使って読み上げる処理。
    /// Unityのメインスレッド上から呼ばれる想定です。
    /// </summary>
    private void SpeakOnWindows(string message)
    {
        try
        {
            if (_synthesizer == null)
            {
                _synthesizer = new SpeechSynthesizer();
                _synthesizer.SetOutputToDefaultAudioDevice();
            }

            // 直前の読み上げを止めてから新しいテキストを再生
            _synthesizer.SpeakAsyncCancelAll();
            _synthesizer.SpeakAsync(message);
        }
        catch (Exception e)
        {
            Debug.LogError("[TextToSpeech] Windows TTS で例外が発生しました: " + e.Message);
        }
    }
#endif

#if UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
    /// <summary>
    /// macOS の 'say' コマンドを使って読み上げる処理。
    /// </summary>
    private void SpeakOnMac(string message)
    {
        try
        {
            // macOS の say コマンドを呼び出す
            // 引数のクォートに注意(ダブルクォートで囲む)
            var escaped = message.Replace("\"", "\\\"");

            ProcessStartInfo startInfo = new ProcessStartInfo
            {
                FileName = "/usr/bin/say",
                Arguments = "\"" + escaped + "\"",
                UseShellExecute = false,
                CreateNoWindow = true
            };

            Process.Start(startInfo);
        }
        catch (Exception e)
        {
            Debug.LogError("[TextToSpeech] macOS TTS で例外が発生しました: " + e.Message);
        }
    }
#endif

    /// <summary>
    /// UIテキストが変更されたときに呼び出してもらうためのヘルパー。
    /// 例えばボタンのOnClickから呼び出すなど、任意のタイミングで利用できます。
    /// </summary>
    public void NotifyTextChanged()
    {
        if (!speakOnTextChanged)
        {
            return;
        }

        SpeakCurrentText();
    }
}

使い方の手順

ここでは、メニューのボタン説明を読み上げるUI を例にして手順を説明します。

手順①:UIテキストを用意する

  • Hierarchy で UI > Text - TextMeshPro(または UI > Text)を作成します。
  • 例として、オブジェクト名を MenuDescriptionText にし、テキスト内容を「スタートボタンを押すとゲームが開始されます。」などにしておきます。

手順②:TextToSpeech コンポーネントをアタッチ

  • MenuDescriptionText を選択し、Inspector の Add Component ボタンから TextToSpeech を追加します。
  • 自動で TextMeshProUGUI(または Text)を取得しようとしますが、うまく入っていなければ手動でドラッグ&ドロップしてアサインしてください。
  • Speak On Text Changed をオンにしておくと、テキスト変更時に自動で読み上げるようになります。

手順③:ボタンから読み上げを呼び出す

例えば「スタート」ボタンにフォーカスが当たったときに説明文を読み上げたい場合、以下のようにします。

  1. StartButton(UI Button)を選択します。
  2. Inspector の On Click() イベント(または On Select() を扱えるイベントシステム)に、新しいイベントを追加します。
  3. オブジェクト欄に MenuDescriptionText をドラッグ&ドロップします。
  4. 関数のプルダウンから TextToSpeech -> SpeakCurrentText() を選択します。

これで、ボタンがクリックされたときに、説明文テキストがOSのTTSで読み上げられるようになります。

手順④:テキスト変更と連動させる(オプション)

例えば、プレイヤーがメニュー項目をカーソルで上下に移動したときに説明文を差し替えるようなスクリプトがあるとします。


using UnityEngine;
using TMPro;

public class MenuDescriptionController : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI descriptionText;
    [SerializeField] private TextToSpeech textToSpeech;

    public void OnSelectStart()
    {
        descriptionText.text = "スタートボタンを押すとゲームが開始されます。";
        textToSpeech.NotifyTextChanged();
    }

    public void OnSelectOptions()
    {
        descriptionText.text = "オプションでは音量や操作設定を変更できます。";
        textToSpeech.NotifyTextChanged();
    }
}

このように、テキストの変更ロジック(MenuDescriptionController) と、読み上げロジック(TextToSpeech) を分離しておくことで、どちらも小さくシンプルなコンポーネントとして保てます。

メリットと応用

この TextToSpeech コンポーネントを使うことで、以下のようなメリットがあります。

  • アクセシビリティ機能を後付けしやすい
    既存のUIプレハブに対して、「読み上げが必要なテキストにだけ TextToSpeech を追加する」という形で、最小限の改造で済みます。
  • 責務がはっきりしていてテストしやすい
    「文字列を受け取ってOSに渡す」だけに責務を絞っているため、他のゲームロジックと絡まずに動作確認できます。
  • プレハブの再利用性が上がる
    メニュー画面、ポーズ画面、設定画面など、どのシーンでも同じ TextToSpeech を使い回せます。
    アクセシビリティ対応済みのUIプレハブとしてプロジェクト内で統一できるのが嬉しいですね。
  • レベルデザイン/UIデザインが楽になる
    デザイナーやレベルデザイナーは、「このテキストは読み上げたい」と思ったオブジェクトに TextToSpeech をアタッチし、イベントから SpeakCurrentText() を呼ぶだけで済みます。
    スクリプトの中身を触る必要がないので、役割分担がしやすくなります。

改造案:読み上げ前に効果音を鳴らす

例えば、「今から読み上げが始まるよ」ということをユーザーに知らせるために、短い効果音を鳴らしてからTTSを再生する改造が考えられます。


[SerializeField] private AudioSource audioSource;
[SerializeField] private AudioClip cueClip;

/// <summary>
/// 効果音を鳴らしてからテキストを読み上げる例。
/// </summary>
public void PlayCueAndSpeakCurrentText()
{
    if (audioSource != null && cueClip != null)
    {
        audioSource.PlayOneShot(cueClip);
    }

    // 効果音と同時に読み上げを開始したくない場合は、
    // StartCoroutine で少し待ってから SpeakCurrentText() を呼ぶのもアリです。
    SpeakCurrentText();
}

このように、小さなコンポーネントに機能を分割しておけば、「読み上げ前にSE」「読み上げ中はボタン無効化」などの拡張も、別コンポーネントとして追加しやすくなります。
巨大な Update() に追記していくスタイルから卒業して、責務ごとに分割されたコンポーネント指向の設計を進めていきましょう。