👂

【Flutter】Flutter_ttsを使ってテキスト読み上げアプリを作る【全文あり・コピペOK】

2021/12/15に公開

この記事は、【可茂IT塾 Advent Calendar 2021】の16日目の記事です。

あいさつ

こんにちは、いもりん(@imorin_basson)です。

以前Flutter_ttsのサンプルで遊んでいたものがあったので紹介します。
コードを全文載せているので、気になった方はコピペしてお試しください。

準備

「flutter_tts」パッケージと「flutter_localizations」をインストールしておきます。
https://pub.dev/packages/flutter_tts/install

pubspec.yaml
dependencies:
  flutter_tts:
  flutter_localizations:
    sdk: flutter

AndroidではminSdkVersionを21に指定します。

android/app/build.gradle
defaultConfig {
    // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
    applicationId "com.example.camera_app"
    minSdkVersion 21
    targetSdkVersion 29
    versionCode flutterVersionCode.toInteger()
    versionName flutterVersionName
}

これで準備完了です。

コード全文

main.dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_tts/flutter_tts.dart';

void main() => runApp(MyApp());

class MyApp extends StatefulWidget {
  
  _MyAppState createState() => _MyAppState();
}

enum TtsState { playing, stopped, paused, continued }

class _MyAppState extends State<MyApp> {
  FlutterTts flutterTts;
  String language;
  String engine;
  double volume = 0.5;
  double pitch = 1.0;
  double rate = 0.5;
  bool isCurrentLanguageInstalled = false;

  String _newVoiceText;
  var _controller = TextEditingController();

  TtsState ttsState = TtsState.stopped;

  get isPlaying => ttsState == TtsState.playing;

  get isStopped => ttsState == TtsState.stopped;

  get isPaused => ttsState == TtsState.paused;

  get isContinued => ttsState == TtsState.continued;

  
  initState() {
    super.initState();
    initTts();
  }

  initTts() {
    flutterTts = FlutterTts();

    flutterTts.setStartHandler(() {
      setState(() {
        ttsState = TtsState.playing;
      });
    });

    flutterTts.setCompletionHandler(() {
      setState(() {
        ttsState = TtsState.stopped;
      });
    });

    flutterTts.setCancelHandler(() {
      setState(() {
        ttsState = TtsState.stopped;
      });
    });

    flutterTts.setErrorHandler((msg) {
      setState(() {
        ttsState = TtsState.stopped;
      });
    });
  }

  Future<dynamic> _getLanguages() => flutterTts.getLanguages;

  Future _speak() async {
    await flutterTts.setVolume(volume);
    await flutterTts.setSpeechRate(rate);
    await flutterTts.setPitch(pitch);

    if (_newVoiceText != null) {
      if (_newVoiceText.isNotEmpty) {
        await flutterTts.awaitSpeakCompletion(true);
        await flutterTts.speak(_newVoiceText);
      }
    }
  }

  Future _stop() async {
    var result = await flutterTts.stop();
    if (result == 1) setState(() => ttsState = TtsState.stopped);
  }

  Future _pause() async {
    var result = await flutterTts.pause();
    if (result == 1) setState(() => ttsState = TtsState.paused);
  }

  
  void dispose() {
    super.dispose();
    flutterTts.stop();
  }

  List<DropdownMenuItem<String>> getEnginesDropDownMenuItems(dynamic engines) {
    var items = <DropdownMenuItem<String>>[];
    for (dynamic type in engines) {
      items.add(
          DropdownMenuItem(value: type as String, child: Text(type as String)));
    }
    return items;
  }

  void changedEnginesDropDownItem(String selectedEngine) {
    flutterTts.setEngine(selectedEngine);
    language = null;
    setState(() {
      engine = selectedEngine;
    });
  }

  List<DropdownMenuItem<String>> getLanguageDropDownMenuItems(
      dynamic languages) {
    var items = <DropdownMenuItem<String>>[];
    for (dynamic type in languages) {
      items.add(
        DropdownMenuItem(
          value: type as String,
          child: Text(type as String),
        ),
      );
    }
    return items;
  }

  void changedLanguageDropDownItem(String selectedType) {
    setState(() {
      language = selectedType;
      flutterTts.setLanguage(language);
    });
  }

  void _onChange(String text) {
    setState(() {
      _newVoiceText = text;
    });
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      supportedLocales: [Locale('ja', 'JP')],
      locale: Locale('ja', 'JP'),
      localizationsDelegates: [
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        appBar: AppBar(
          title: Text(
            'TextToSpeech',
            style: TextStyle(color: Colors.black),
          ),
          backgroundColor: Colors.white,
        ),
        body: GestureDetector(
          onTap: () {
            FocusScope.of(context).requestFocus(new FocusNode());
          },
          child: SingleChildScrollView(
            scrollDirection: Axis.vertical,
            child: Column(
              children: [
                _inputSection(),
                _btnSection(),
                _futureBuilder(),
                _buildSliders(),
              ],
            ),
          ),
        ),
      ),
    );
  }

  Widget _futureBuilder() => FutureBuilder<dynamic>(
      future: _getLanguages(),
      builder: (BuildContext context, AsyncSnapshot<dynamic> snapshot) {
        if (snapshot.hasData) {
          return _languageDropDownSection(snapshot.data);
        } else if (snapshot.hasError) {
          return Text('言語の読み込みに失敗');
        } else
          return Text('読み込み中');
      });

  Widget _inputSection() => Container(
      alignment: Alignment.topCenter,
      padding: EdgeInsets.only(top: 25.0, left: 25.0, right: 25.0),
      child: TextField(
        controller: _controller,
        enabled: true,
        keyboardType: TextInputType.multiline,
        maxLines: null,
        minLines: 3,
        decoration: InputDecoration(
          suffixIcon: IconButton(
            onPressed: () => _controller.clear(),
            icon: Icon(
              Icons.clear,
              color: Colors.grey,
            ),
          ),
        ),
        onChanged: (String value) {
          _onChange(value);
        },
      ));

  Widget _btnSection() {
    return Container(
      padding: EdgeInsets.only(top: 50.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          _buildButtonColumn(
            Colors.green,
            Colors.greenAccent,
            Icons.play_arrow,
            '再生',
            _speak,
          ),
          _buildButtonColumn(
            Colors.red,
            Colors.redAccent,
            Icons.stop,
            'リセット',
            _stop,
          ),
          _buildButtonColumn(
            Colors.blue,
            Colors.blueAccent,
            Icons.pause,
            '停止',
            _pause,
          ),
        ],
      ),
    );
  }

  Widget _languageDropDownSection(dynamic languages) {
    return Container(
      padding: EdgeInsets.only(top: 10.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          DropdownButton(
            value: language,
            items: getLanguageDropDownMenuItems(languages),
            onChanged: changedLanguageDropDownItem,
          ),
        ],
      ),
    );
  }

  Column _buildButtonColumn(
      Color color,
      Color splashColor,
      IconData icon,
      String label,
      Function func,
      ) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        IconButton(
          icon: Icon(icon),
          color: color,
          splashColor: splashColor,
          onPressed: () => func(),
        ),
        Container(
          margin: const EdgeInsets.only(top: 8.0),
          child: Text(
            label,
            style: TextStyle(
              fontSize: 12.0,
              fontWeight: FontWeight.w400,
              color: color,
            ),
          ),
        ),
      ],
    );
  }

  Widget _buildSliders() {
    return Column(
      children: [_volume(), _pitch(), _rate()],
    );
  }

  Widget _volume() {
    return Slider(
        value: volume,
        onChanged: (newVolume) {
          setState(() => volume = newVolume);
        },
        min: 0.0,
        max: 1.0,
        divisions: 10,
        label: "音量: $volume");
  }

  Widget _pitch() {
    return Slider(
      value: pitch,
      onChanged: (newPitch) {
        setState(() => pitch = newPitch);
      },
      min: 0.5,
      max: 2.0,
      divisions: 15,
      label: "声の高さ: $pitch",
      activeColor: Colors.red,
    );
  }

  Widget _rate() {
    return Slider(
      value: rate,
      onChanged: (newRate) {
        setState(() => rate = newRate);
      },
      min: 0.0,
      max: 1.0,
      divisions: 10,
      label: "スピード: $rate",
      activeColor: Colors.green,
    );
  }
}


解説

ほとんど「flutter_tts」のサンプル通りですが、iOS実機のテストに使えるようにところどころ手を加えています。

写真の上から
テキストを書き込む「_inputSection」

main.dart
Widget _inputSection() => Container(
      alignment: Alignment.topCenter,
      padding: EdgeInsets.only(top: 25.0, left: 25.0, right: 25.0),
      child: TextField(
        controller: _controller,
        enabled: true,
        keyboardType: TextInputType.multiline,
        maxLines: null,
        minLines: 3,
        decoration: InputDecoration(
          suffixIcon: IconButton(
            onPressed: () => _controller.clear(),
            icon: Icon(
              Icons.clear,
              color: Colors.grey,
            ),
          ),
        ),
        onChanged: (String value) {
          _onChange(value);
        },
      ));

テキストの読み上げ・読み上げ位置のリセット・一時停止をする「_btnSection」

main.dart
Widget _btnSection() {
    return Container(
      padding: EdgeInsets.only(top: 50.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          _buildButtonColumn(
            Colors.green,
            Colors.greenAccent,
            Icons.play_arrow,
            '再生',
            _speak,
          ),
          _buildButtonColumn(
            Colors.red,
            Colors.redAccent,
            Icons.stop,
            'リセット',
            _stop,
          ),
          _buildButtonColumn(
            Colors.blue,
            Colors.blueAccent,
            Icons.pause,
            '停止',
            _pause,
          ),
        ],
      ),
    );
  }

言語を切り替える「_futureBuilder」

main.dart
Widget _futureBuilder() => FutureBuilder<dynamic>(
      future: _getLanguages(),
      builder: (BuildContext context, AsyncSnapshot<dynamic> snapshot) {
        if (snapshot.hasData) {
          return _languageDropDownSection(snapshot.data);
        } else if (snapshot.hasError) {
          return Text('言語の読み込みに失敗');
        } else
          return Text('読み込み中');
      });

読み上げる音量、声の高さ、速度を調整する「_buildSliders」

main.dart
Widget _buildSliders() {
    return Column(
      children: [_volume(), _pitch(), _rate()],
    );
  }

  Widget _volume() {
    return Slider(
        value: volume,
        onChanged: (newVolume) {
          setState(() => volume = newVolume);
        },
        min: 0.0,
        max: 1.0,
        divisions: 10,
        label: "音量: $volume");
  }

  Widget _pitch() {
    return Slider(
      value: pitch,
      onChanged: (newPitch) {
        setState(() => pitch = newPitch);
      },
      min: 0.5,
      max: 2.0,
      divisions: 15,
      label: "声の高さ: $pitch",
      activeColor: Colors.red,
    );
  }

  Widget _rate() {
    return Slider(
      value: rate,
      onChanged: (newRate) {
        setState(() => rate = newRate);
      },
      min: 0.0,
      max: 1.0,
      divisions: 10,
      label: "スピード: $rate",
      activeColor: Colors.green,
    );
  }

大きく4つのWidgetで構成されています。

読み上げ言語はデフォルトでは英語が設定されているので、「_futureBuilder」で日本語を設定しましょう。
ただ日本語、特に漢字の読み上げがあまり安定しないので、実用性を求めるのであれば英語の読み上げでアプリを作るのが適当かと思いました。

Discussion