昨天,我們已將逐字稿透過LLM翻譯,還加入了追星人常用的飯圈用語,但可能還會遇到一個問題點就是會斷章取義,導致翻譯出來的句子會讓人誤會。
今天我們要實作上下文感知滑動視窗翻譯機制!
TranslationService:將「對話歷史 + 當前句子」一起打包丟給 LLM 進行上下文感知翻譯。在 lib/features/live_translate/data/models/ 下建立 chat_context_data.dart
import 'dart:collection';
class ChatContextMessage {
final String rawText; // 原始 STT 逐字稿
final String translatedText; // 翻譯後的文字
ChatContextMessage({
required this.rawText,
required this.translatedText,
});
}
class ChatContextManager {
final int maxHistory; // 最多保留幾句上下文
final Queue<ChatContextMessage> _history = Queue<ChatContextMessage>();
ChatContextManager({this.maxHistory = 3});
/// 新增對話紀錄
void addMessage(String raw, String translated) {
if (_history.length >= maxHistory) {
_history.removeFirst(); // 滿了就丟棄最舊的一句
}
_history.add(ChatContextMessage(rawText: raw, translatedText: translated));
}
/// 格式化為 LLM 看得懂的歷史 Prompt
String getFormattedHistory() {
if (_history.isEmpty) return '(無前文紀錄)';
final StringBuffer buffer = StringBuffer();
int index = 1;
for (var msg in _history) {
buffer.writeln('第 $index 句 [原文]: ${msg.rawText} | [譯文]: ${msg.translatedText}');
index++;
}
return buffer.toString().trim();
}
/// 清空歷史紀錄(切換話題或重置時使用)
void clear() {
_history.clear();
}
}
TranslationService 支援 Context-Aware更新 lib/features/live_translate/data/services/translation_service.dart,加入 contextHistory 參數
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
class TranslationService {
final String _openAiUrl = 'https://api.openai.com/v1/chat/completions';
/// 支援上下文感知的翻譯請求
Future<String?> translateWithContext({
required String rawText,
required String targetLanguage,
required String contextHistory, // 傳入上一步驟生成的上下文歷史 Prompt
}) async {
if (rawText.trim().isEmpty) return '';
final apiKey = dotenv.env['OPENAI_API_KEY'];
if (apiKey == null || apiKey.isEmpty) return null;
try {
final response = await http.post(
Uri.parse(_openAiUrl),
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Authorization': 'Bearer $apiKey',
},
body: jsonEncode({
'model': 'gpt-4o-mini',
'temperature': 0.3,
'messages': [
{
'role': 'system',
'content': '''你是一位同聲傳譯專家。請參考【前文對話歷史】,將【當前輸入】翻譯為 $targetLanguage。
【翻譯原則】:
1. 利用【前文對話歷史】推斷當前句子的主詞、代名詞(如:他/他們/這件事)以及主題背景。
2. 避免斷章取義,確保譯文前後語氣、時態與專有名詞統一樣式。
3. 剔除語音贅字(如:呃、那個),補充標點符號。
4. 僅回傳【當前輸入】的最終翻譯結果,嚴禁輸出任何解釋或標籤。'''
},
{
'role': 'user',
'content': '''
【前文對話歷史】:
$contextHistory
【當前輸入】:
$rawText
'''
}
],
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
return data['choices'][0]['message']['content']?.toString().trim();
} else {
print('翻譯 API 錯誤: ${response.body}');
return null;
}
} catch (e) {
print('請求異常: $e');
return null;
}
}
}
multi_lang_page.dart 中串接對話記憶庫更新 UI 控制邏輯,讓每次翻譯前都會先帶入上下文,翻譯完畢後自動記錄
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import '../../models/audio_config.dart';
import '../../../../services/audio_recorder_service.dart';
import '../../../../services/whisper_service.dart';
import '../../data/models/chat_context_data.dart';
import '../../data/services/translation_service.dart';
class MultiLangAudioPage extends StatefulWidget {
const MultiLangAudioPage({super.key});
@override
State<MultiLangAudioPage> createState() => _MultiLangAudioPageState();
}
class _MultiLangAudioPageState extends State<MultiLangAudioPage> {
final AudioRecorderService _recorder = AudioRecorderService();
final WhisperService _whisper = WhisperService();
final TranslationService _translationService = TranslationService();
final ChatContextManager _contextManager = ChatContextManager(maxHistory: 3);
SupportedLanguage _selectedLang = SupportedLanguage.ja; // 預設測試日語
bool _isRecording = false;
bool _isLoading = false;
String _rawTranscript = ''; // 原始 STT 逐字稿
String _translatedText = ''; // LLM 修飾後的最終翻譯
String? _audioPath;
// 依語言決定要載入哪一份飯圈字典(其他語言不加字典)
String? get _fandomCategory {
switch (_selectedLang.code) {
case 'ko':
return 'kpop';
case 'ja':
return 'jpop';
default:
return null;
}
}
void _toggleRecording() async {
if (_isRecording) {
await _recorder.stopRecording();
setState(() {
_isRecording = false;
});
if (_audioPath != null) {
await _processAudioAndTranslate(File(_audioPath!));
}
} else {
final tempDir = await getTemporaryDirectory();
_audioPath = '${tempDir.path}/test_${_selectedLang.code}.m4a';
await _recorder.startFileRecording(_audioPath!);
setState(() {
_isRecording = true;
_rawTranscript = '';
_translatedText = '';
});
}
}
// 處理語音流程:先 STT 取得逐字稿,再送 LLM 翻譯修飾
Future<void> _processAudioAndTranslate(File audioFile) async {
setState(() => _isLoading = true);
// 1. 先用 Whisper 取得原始逐字稿 (STT)
final rawResult = await _whisper.transcribeAudio(
audioFile,
language: _selectedLang,
category: _fandomCategory,
);
if (rawResult != null && rawResult.trim().isNotEmpty) {
// 2. 取得最近 3 句的歷史紀錄 Prompt
final historyPrompt = _contextManager.getFormattedHistory();
// 3. 上下文感知翻譯
final translated = await _translationService.translateWithContext(
rawText: rawResult,
targetLanguage: '繁體中文', // 設定你想翻譯成的目標語言
contextHistory: historyPrompt,
);
// 4. 翻譯成功才存入記憶庫,供下一句使用
if (translated != null && translated.isNotEmpty) {
_contextManager.addMessage(rawResult, translated);
}
setState(() {
_rawTranscript = rawResult;
_translatedText = translated ?? '翻譯失敗';
_isLoading = false;
});
} else {
setState(() {
_rawTranscript = '未辨識到聲音';
_translatedText = '';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final currentConfig = LanguageAudioConfig.forLanguage(_selectedLang);
return Scaffold(
appBar: AppBar(title: const Text('Day 12 - 多國語音聲學調校')),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
// 語言切換選單
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('選擇目標語言:', style: TextStyle(fontSize: 16)),
DropdownButton<SupportedLanguage>(
value: _selectedLang,
items: SupportedLanguage.values.map((lang) {
return DropdownMenuItem(
value: lang,
child: Text(lang.label),
);
}).toList(),
onChanged: (val) {
if (val != null) {
_contextManager.clear(); // 換語言就清掉前文,避免混入
setState(() => _selectedLang = val);
}
},
),
],
),
const SizedBox(height: 10),
// 顯示該語言的聲學調校參數
Card(
color: Colors.blueGrey[50],
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
children: [
Text('聲學配置: SampleRate ${currentConfig.sampleRate}Hz | Chunk: ${currentConfig.chunkIntervalMs}ms'),
const SizedBox(height: 5),
Text('Prompt 補全: "${currentConfig.language.initialPrompt}"', style: const TextStyle(fontSize: 12, color: Colors.grey)),
if (_fandomCategory != null) ...[
const SizedBox(height: 5),
Text('飯圈字典: ${_fandomCategory!}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
],
),
),
),
const SizedBox(height: 30),
// 錄音按鈕
ElevatedButton.icon(
onPressed: _isLoading ? null : _toggleRecording,
icon: Icon(_isRecording ? Icons.stop : Icons.mic),
label: Text(_isRecording ? '停止並分析' : '開始測試發音 (${_selectedLang.code.toUpperCase()})'),
style: ElevatedButton.styleFrom(
backgroundColor: _isRecording ? Colors.red : Colors.indigo,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
),
),
const SizedBox(height: 30),
if (_isLoading) const CircularProgressIndicator(),
if (_rawTranscript.isNotEmpty) ...[
const Text('原始逐字稿:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.grey[200], borderRadius: BorderRadius.circular(8)),
child: Text(_rawTranscript, style: const TextStyle(fontSize: 16)),
),
const SizedBox(height: 20),
],
if (_translatedText.isNotEmpty) ...[
const Text('翻譯結果:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.indigo[50], borderRadius: BorderRadius.circular(8)),
child: Text(_translatedText, style: const TextStyle(fontSize: 18)),
),
],
],
),
),
);
}
}
嘗試說兩句話(不帶代名詞):
「昨日、SEVENTEENのコンサートに行きました。」 (昨天去了 SEVENTEEN 的演唱會),此時記憶庫記錄了第 1 句
「本当にカッコよかった!」 (真的超級帥!)