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與金鑰
三、實作步驟
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,有收到轉錄結果,但沒有即時串流。
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不會上架的情況。
dependencies:
flutter:
sdk: flutter
google_speech: ^5.3.0 //STT
flutter_audio_capture: ^1.1.11 //取得沒壓縮的原始音訊
permission_handler: ^11.3.1 //申請麥克風權限
(1) iOS
platform :ios, '13.6'
flutter clean
flutter pub get
cd ios
pod repo update
pod install
cd ..
<key>NSMicrophoneUsageDescription</key>
<string>此 App 需要麥克風以進行語音辨識</string>
(2)Android
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
(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();
}
重點 | 內容 |
---|---|
google_speech | App呼叫STT API的套件 |
API與金鑰 | 開啟API 取得金鑰 |
實作步驟 | 初始化錄音與STT → 同步音訊 → 監聽結果 |