iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Mobile Development

Flutter :30天打造念佛App,跨平台應用從Mobile到VR,讓極樂世界在眼前實現!系列 第 13

[ Day 13 ] Flutter 語音辨識 實戰應用篇— 生活在地球的勇者啊,你聽過阿彌陀佛嗎(4) #雲端語音轉文字 #Google Cloud Speech to Text

  • 分享至 

  • xImage
  •  

2025 iThome鐵人賽
「 Flutter :30天打造念佛App,跨平台從Mobile到VR,讓極樂世界在眼前實現 ! 」

Day 13
「 Flutter 語音辨識 實戰應用篇 — 生活在地球的勇者啊,你聽過阿彌陀佛嗎(4) 」


前言

昨天我們已經認識雲端語音轉文字與供應商差異,
也初步認識「Google Cloud Speech to Text」,
今天就讓我們來把它實作出來!

觀看實作影片(20250928更新)
Chirp模型官方文件
Chirp2模型官方文件

模型 支援繁體中文台灣 適用即時串流 google_speech(5.3.0)
Chirp
Chirp2

Day13 文章目錄:
一、google_speech
二、API與金鑰
三、實作步驟


一、google_speech

1.簡介

這次的實作使用開源套件 google_speech ,
它讓我們可以在Flutter App端直接呼叫Google Cloud Speech-to-Text API。

google_speech 使用gRPC 連線與 Google Cloud 溝通,
有支援多語言、串流辨識、長語音轉錄等。

備註:
一般而言,由後端與Google Cloud 溝通。
App 端只把音訊送後端,後端呼叫 Google STT 後,回傳結果給App端。

2. 提醒

套件將地區預寫為global,這部分我們對照Google官方文件進行調整

(1) viaServiceAccount 的 cloudSpeechEndpoint

Future<void> _initV2() async {
    if (_stt != null) return;
    final saJson = await rootBundle.loadString('assets/sa.json');
    final sa = ServiceAccount.fromString(saJson);
    _stt = SpeechToTextV2.viaServiceAccount(
      sa,
      projectId: 'namoamitabha',
      cloudSpeechEndpoint: 'asia-southeast1-speech.googleapis.com',
    );
  }

(2) streamingRecognize 的 location

final responses = _stt!.streamingRecognize(
      streamingCfg,
      _audioCtrl!.stream,
      location: 'asia-southeast1',
    );

(3) responses 的 location

final responses = _stt!.streamingRecognize(
      streamingCfg,
      _audioCtrl!.stream,
      location: 'asia-southeast1',
);

目前(google_speech 5.3.0)繁體中文台灣只有Chirp模型可以使用。
chirp 不適用即時串流的語音辨識
此次實作嘗試streamingRecognize,有收到轉錄結果,但沒有即時串流。


二、API與金鑰

1. Google Cloud 控制台,打開專案挑選器

2. 新增專案

3. 點擊新增的專案

4. 開啟導覽選單

5. 點選 已啟用的API與服務

6. 點選 +啟用API和服務

7. 輸入API名稱搜尋

8. 點選 Cloud Speech-to-Text API

9. 點選啟用

10. 點選 導覽選單 ⮕ API和服務 ⮕ 憑證

11. 點選 +建立憑證 ⮕ 服務帳戶

12. 設置權限

雖然控制台寫非必要,但如果我們沒有給對應權限,呼叫Speech-to-Text 會直接失敗。

13. 點擊服務帳戶

14.新增金鑰

15.建立JSON

要保管好!金鑰如果外流,任何人都可以呼叫這隻API,可能造成不小的費用。
除此之外,這也關係著專案與資料的安全風險。
此次為了快速演示flutter實作Google STT,所以暫時將金鑰放於專案中,
這是在不提交任何歷史記錄與確定演示的App不會上架的情況。


三、實作步驟

1.安裝套件

dependencies:
  flutter:
    sdk: flutter
    
  google_speech: ^5.3.0                  //STT 
  flutter_audio_capture: ^1.1.11         //取得沒壓縮的原始音訊
  permission_handler: ^11.3.1            //申請麥克風權限

2.Android、iOS 設定

(1) iOS

  • 最低部署版本,ios/Podfile( flutter_audio_capture 需求)
platform :ios, '13.6'
  • 終端機執行
flutter clean
flutter pub get
cd ios
pod repo update
pod install
cd ..
  • 權限設置(ios/Runner/Info.plist):
<key>NSMicrophoneUsageDescription</key>
<string>此 App 需要麥克風以進行語音辨識</string>

(2)Android

  • 權限設置(android/app/src/main/AndroidManifest.xml):
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>

3.核心程式

(1) 匯入

import 'package:flutter_audio_capture/flutter_audio_capture.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:google_speech/google_speech.dart';
import 'package:grpc/grpc.dart' show GrpcError;
import 'package:google_speech/generated/google/cloud/speech/v2/cloud_speech.pb.dart' as _cs;

(2) 資料模型:每一句結果+結束時間

class LineItem {
  final String text;
  final Duration? endAt; // 句子的結束時間
  LineItem(this.text, this.endAt);
}

// State 內
final List<LineItem> _items = [];

(3) 重要成員與常數

final _capture = FlutterAudioCapture();   // 麥克風
SpeechToTextV2? _stt;                     // gRPC 
StreamController<List<int>>? _audioCtrl;  // PCM16 給 STT
bool _isListening = false;
String _interim = '';
static const int kSampleRate = 44100;

(4) 初始化錄音裝置

@override
void initState() { super.initState(); _initCapture(); }

Future<void> _initCapture() async {
  try {
    await _capture.init();           
    debugPrint('FlutterAudioCapture 初始化完成');
  } catch (e) {
    debugPrint('初始化失敗: $e');
  }
}

(5) 初始化 Speech-to-Text v2

Future<void> _initV2() async {
  if (_stt != null) return;
  final saJson = await rootBundle.loadString('assets/sa.json');
  final sa = ServiceAccount.fromString(saJson);
  _stt = SpeechToTextV2.viaServiceAccount(
    sa,
    projectId: 'namoamitabha',
    cloudSpeechEndpoint: 'asia-southeast1-speech.googleapis.com', 
  );
}

(6) v2 設定

RecognitionConfigV2 _buildConfigV2() {
  return RecognitionConfigV2(
    languageCodes:(6) v2 設定 const ['cmn-Hant-TW'],      // 台灣繁中
    model: RecognitionModelV2.chirp,           // v2 支援繁體中文的模型
    features: _cs.RecognitionFeatures(
      enableWordTimeOffsets: true,             // ← 時間戳記
    ),
    explicitDecodingConfig: _cs.ExplicitDecodingConfig(
      encoding: _cs.ExplicitDecodingConfig_AudioEncoding.LINEAR16,
      sampleRateHertz: kSampleRate,
      audioChannelCount: 1,
    ),
  );
}

(7) 音訊格式轉換

Uint8List _float32ToInt16LE(Float32List data) {
  final out = BytesBuilder();
  for (final f in data) {
    var s = (f.clamp(-1.0, 1.0) * 32767.0).round().clamp(-32768, 32767);
    out.add([s & 0xFF, (s >> 8) & 0xFF]); 
  }
  return out.toBytes();
}

(8) 權限處理

Future<bool> ensureMicPermission(BuildContext context) async {
    var status = await Permission.microphone.status;

    if (status.isGranted) return true;

    if (status.isDenied || status.isRestricted) {
      final req = await Permission.microphone.request();
      if (req.isGranted) return true;
      if (mounted) {
        ScaffoldMessenger.of(
          context,
        ).showSnackBar(const SnackBar(content: Text('需要麥克風權限才能辨識語音')));
      }
      return false;
    }

    if (status.isPermanentlyDenied) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('請到「設定 > 麥克風」開啟本 App 的權限')),
        );
      }
      await openAppSettings(); 
      return false;
    }

    final req = await Permission.microphone.request();
    return req.isGranted;
  }

(9) 開始錄音 ⮕ Google STT

Future<void> _start() async {
    //麥克風權限
    final isMicPermissionGranted = await ensureMicPermission(context);
    if (!isMicPermissionGranted) return;

    //建立 STT v2
    await _initV2();
    final cfg = _buildConfigV2();
    final streamingCfg = StreamingRecognitionConfigV2(config: cfg);

    //建立輸入音訊 
    _audioCtrl = StreamController<List<int>>();

    // 啟動 v2 
    final responses = _stt!.streamingRecognize(
      streamingCfg,
      _audioCtrl!.stream,
      location: 'asia-southeast1',
    );

    //監聽雲端回傳
    _respSub = responses.listen(
      (resp) {
        for (final r in resp.results) {
          final text = r.alternatives.isNotEmpty
              ? r.alternatives.first.transcript
              : '';
          if (r.isFinal) {
            if (text.trim().isEmpty) continue; //跳過空結果
            Duration? at;
            final alt = r.alternatives.isNotEmpty ? r.alternatives.first : null;
            // 詞級時間最後一個字的結束時間
            if (alt != null && alt.words.isNotEmpty) {
              final w = alt.words.last;
              at = Duration(
                seconds: w.endOffset.seconds.toInt(),
                microseconds: (w.endOffset.nanos / 1000).round(),
              );
            }

            //若詞級沒有,段落級的 resultEndOffset
            if (at == null && r.resultEndOffset.seconds != 0) {
              at = Duration(
                seconds: r.resultEndOffset.seconds.toInt(),
                microseconds: (r.resultEndOffset.nanos / 1000).round(),
              );
            }

            setState(() {
              _items.add(LineItem(text, at));
              _finals.add(text);
              _interim = '';
            });
          } else {
            setState(() => _interim = text);
          }
        }
      },
      onError: (e) {
        if (e is GrpcError) {
          debugPrint('gRPC code: ${e.code}  message: ${e.message}');
          debugPrint('gRPC details: ${e.details}');
          debugPrint('gRPC trailers: ${e.trailers}');
        } else {
          debugPrint('STT error: $e');
        }
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text(
                e is GrpcError ? (e.message ?? 'gRPC 錯誤') : e.toString(),
              ),
            ),
          );
        }
        setState(() => _isListening = false);
        _stop();
      },
      onDone: () {
        if (mounted) setState(() => _isListening = false);
      },
    );
    // 啟動麥克風:Float32 -> PCM16LE -> 丟進 _audioCtrl
    await _capture.start(
      (obj) {
        if (_audioCtrl?.isClosed ?? true) return;
        if (obj is Float32List) {
          _audioCtrl!.add(_float32ToInt16LE(obj)); // Float32 → PCM16 LE
        } else if (obj is Uint8List) {
          _audioCtrl!.add(obj as Uint8List);
        } else {
          debugPrint('unexpected audio buffer type: ${obj.runtimeType}');
        }
      },
      (err) {
        debugPrint('audio error: $err');
      },
      sampleRate: kSampleRate,
      bufferSize: 2048,
    );

    setState(() => _isListening = true);
  }

10. 停止錄音與釋放資源

Future<void> _stop() async {
    try { await _capture.stop(); } catch (_) {}
    await _audioCtrl?.close();
    await _respSub?.cancel();
    _audioCtrl = null;
    _respSub = null;
    setState(() => _isListening = false);
 }
 @override
  void dispose() {
    _stop();
    super.dispose();
  }

Day13 重點回顧

重點 內容
google_speech App呼叫STT API的套件
API與金鑰 開啟API 取得金鑰
實作步驟 初始化錄音與STT → 同步音訊 → 監聽結果

上一篇
[ Day 12 ] Flutter 語音辨識 實戰入門篇 — 生活在地球的勇者啊,你聽過阿彌陀佛嗎(3) #雲端語音轉文字
下一篇
[ Day 14 ] Flutter 語音辨識 實戰應用篇— 生活在地球的勇者啊,你聽過阿彌陀佛嗎(5) #地端語音轉文字
系列文
Flutter :30天打造念佛App,跨平台應用從Mobile到VR,讓極樂世界在眼前實現!14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言