บทที่ 14: Speech-to-Text Integration
สารบัญเนื้อหา
บทที่ 14: Speech-to-Text Integration
🎯 เป้าหมายการเรียนรู้
หลังจากเรียนบทนี้แล้ว คุณจะสามารถ:
- สร้างฟีเจอร์แปลงเสียงเป็นข้อความแบบ Real-time
- จัดการ Permission (ขออนุญาตใช้ไมโครโฟน) ทั้งบน iOS และ Android
- สร้าง UI ที่ตอบสนองต่อสถานะการฟัง (Listening State)
- จัดการ Timeout และ Error ต่างๆ ของระบบรับเสียง
📚 ทฤษฎีและแนวคิด
1. Speech Recognition ทำงานอย่างไร?
การแปลงเสียงเป็นข้อความในมือถือ ไม่ได้ใช้พลังของแอปเราเอง แต่เป็นการเรียกใช้ System Service:
- iOS: ใช้ Apple Speech Framework (Siri engine)
- Android: ใช้ Google Speech Services
ดังนั้นสิ่งสำคัญที่สุดคือ Permission และ Internet Connection (ในบางรุ่นต้องใช้เน็ตในการประมวลผล)
2. สถานะการทำงาน (States)
ระบบรับเสียงมีสถานะที่ชัดเจนที่เราต้องจัดการ:
- Uninitialized: ยังไม่พร้อมใช้งาน (ต้อง init ก่อน)
- Listening: กำลังฟังอยู่ (ไมค์เปิด)
- Not Listening: หยุดฟังแล้ว (ไมค์ปิด)
- Error: เกิดข้อผิดพลาด (เช่น พูดเบาไป, ไมค์พัง)
💻 การเขียนโค้ด (Implementation)
1. ตั้งค่า Permission (สำคัญมาก!)
iOS (ios/Runner/Info.plist)
ต้องบอก Apple ว่าจะขอใช้ไมค์และระบบแปลงเสียงไปทำไม
<key>NSMicrophoneUsageDescription</key>
<string>ขออนุญาตใช้ไมโครโฟนเพื่อบันทึกเสียงของคุณ</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>ขออนุญาตใช้ระบบแปลงเสียงเพื่อเปลี่ยนคำพูดเป็นข้อความ</string>
Android (android/app/src/main/AndroidManifest.xml)
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
2. ติดตั้ง Package
dependencies:
speech_to_text: ^6.6.0
permission_handler: ^11.3.0
3. สร้าง Speech Service
ไฟล์: mobile/lib/services/speech_service.dart
เราจะสร้าง Wrapper Class เพื่อห่อหุ้ม library ไว้อีกชั้น ทำให้เรียกใช้ง่ายขึ้น
import 'package:speech_to_text/speech_to_text.dart';
import 'package:permission_handler/permission_handler.dart';
class SpeechService {
อธิบายทีละบรรทัด (Imports):
-
import 'package:speech_to_text/speech_to_text.dart'- Plugin หลักที่เชื่อมต่อกับ Speech Recognition Engine ของ OS (iOS/Android)
-
import 'package:permission_handler/permission_handler.dart'- Plugin สำหรับขออนุญาตผู้ใช้ (สำคัญมาก! ถ้าไม่ขอ App จะ Crash)
อธิบาย:
**อธิบายทีละบรรทัด (Imports):**
- `import 'package:speech_to_text/speech_to_text.dart'`
- Plugin หลักที่เชื่อมต่อกับ Speech Recognition Engine ของ OS (iOS/Android)
- `import 'package:permission_handler/permission_handler.dart'`
- Plugin สำหรับขออนุญาตผู้ใช้ (สำคัญมาก! ถ้าไม่ขอ App จะ Crash)
**อธิบาย:**
final SpeechToText _speech = SpeechToText();
bool _isInitialized = false;
// --- 1. Init (เตรียมพร้อม) ---
Future<bool> init() async {
if (_isInitialized) return true;
// ขอ Permission ก่อนเสมอ
var status = await Permission.microphone.request();
if (status != PermissionStatus.granted) {
return false; // ถ้า user ไม่ให้ ก็จบข่าว
}
try {
// สั่ง initialize ตัว engine
_isInitialized = await _speech.initialize(
onError: (e) => print('Speech Error: $e'),
onStatus: (s) => print('Speech Status: $s'),
);
} catch (e) {
print("Init failed: $e");
return false;
}
return _isInitialized;
}
// --- 2. Start Listening (เริ่มฟัง) ---
// onResult: callback function ที่จะถูกเรียกทุกครั้งที่ได้คำใหม่ๆ มา
Future<void> startListening(Function(String) onResult) async {
if (!_isInitialized) {
bool ready = await init();
if (!ready) return;
}
await _speech.listen(
onResult: (result) {
// ส่งเฉพาะ recognizedWords (คำที่มั่นใจแล้ว + คำที่กำลังเดา) กลับไป
onResult(result.recognizedWords);
},
localeId: "th_TH", // ระบุภาษาไทย
listenFor: const Duration(seconds: 30), // ฟังนานสุด 30 วิ
pauseFor: const Duration(seconds: 3), // หยุดพูด 3 วิ จะตัดจบ
cancelOnError: true,
listenMode: ListenMode.dictation, // โหมดจดตามคำบอก (ต่อเนื่อง)
);
}
// --- 3. Stop (หยุดฟัง) ---
Future<void> stopListening() async {
await _speech.stop();
}
// เช็คสถานะปัจจุบัน
bool get isListening => _speech.isListening;
}
4. สร้างหน้า Record Screen
ไฟล์: mobile/lib/screens/record_screen.dart
หน้าจอนี้จะซับซ้อนหน่อยเพราะต้องจัดการ Animation ไมโครโฟน
import 'package:flutter/material.dart';
import 'package:avatar_glow/avatar_glow.dart'; // แนะนำใช้เพื่อความสวยงาม
import '../services/speech_service.dart';
class RecordScreen extends StatefulWidget {
const RecordScreen({super.key});
@override
State<RecordScreen> createState() => _RecordScreenState();
}
class _RecordScreenState extends State<RecordScreen> {
final _speechService = SpeechService();
final _textController = TextEditingController();
bool _isListening = false;
double _confidence = 1.0;
@override
void initState() {
super.initState();
_initSpeech();
}
void _initSpeech() async {
// เตรียมพร้อมเงียบๆ
await _speechService.init();
}
void _toggleListening() async {
if (_isListening) {
// ถ้าฟังอยู่ -> สั่งหยุด
await _speechService.stopListening();
setState(() => _isListening = false);
} else {
// ถ้าหยุดอยู่ -> สั่งฟัง
setState(() => _isListening = true);
await _speechService.startListening((text) {
// อัปเดตข้อความในกล่องทันทีที่พูด
setState(() {
_textController.text = text;
});
});
// อัปเดตสถานะตอนจบ (เช่น หยุดพูดนานเกิน)
// หมายเหตุ: ในโค้ดจริงอาจต้องใช้ listener ของ status เพิ่มเติม
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("บันทึกเสียง")),
// ปุ่มลอยตรงกลางล่าง
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: AvatarGlow(
animate: _isListening, // กระพริบเมื่อฟังอยู่
glowColor: Colors.red,
endRadius: 75.0,
duration: const Duration(milliseconds: 2000),
repeat: true,
showTwoGlows: true,
child: FloatingActionButton(
onPressed: _toggleListening,
backgroundColor: _isListening ? Colors.red : Colors.blue,
child: Icon(_isListening ? Icons.mic : Icons.mic_none, size: 36),
),
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
Text(
_isListening ? "กำลังฟัง..." : "กดปุ่มไมค์เพื่อเริ่มพูด",
style: TextStyle(fontSize: 16, color: Colors.grey),
),
SizedBox(height: 24),
// กล่องข้อความแสดงผล
Expanded(
child: Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[300]!),
),
child: TextField(
controller: _textController,
maxLines: null, // พิมพ์ได้หลายบรรทัด
decoration: InputDecoration.collapsed(
hintText: "ข้อความที่คุณพูดจะปรากฏที่นี่...",
),
style: TextStyle(fontSize: 24, height: 1.5),
),
),
),
],
),
),
);
}
}
5. เทคนิคการจัดการ UI (UI Tricks)
Avatar Glow
ในโค้ดข้างบนมีการใช้ AvatarGlow (ต้องลง package เพิ่ม avatar_glow) เพราะ:
- ผู้ใช้ไม่รู้ว่าไมค์ทำงานอยู่ไหม ถ้าแค่เปลี่ยนสีปุ่มมันสังเกตยาก
- การมี Animation วูบวาบ ช่วยบอกว่า "ฉันกำลังทำงานอยู่นะ"
Real-time Text Update
_textController.text = text;
การใส่ข้อความลงใน Controller ตรงๆ ทำให้ผู้ใช้เห็นคำที่ตัวเองพูดขึ้นมาทันทีแบบ Real-time ซึ่งให้ความรู้สึกดีกว่ารอพูดจบแล้วค่อยขึ้นทีเดียว
📝 แบบฝึกหัด
โจทย์ที่ 1: Confidence Level
สิ่งที่ต้องทำ:
- ศึกษา
SpeechRecognitionResultว่ามีค่าconfidenceไหม - ถ้ามั่นใจต่ำ (< 0.5) ให้แสดง text สีจางๆ หรือขึ้นเตือนว่า "ไม่ค่อยแน่ใจ"
โจทย์ที่ 2: Auto Stop & Submit
สิ่งที่ต้องทำ:
- เมื่อหยุดพูดครบ 3 วินาที (speech status = done/notListening)
- ให้ส่งข้อมูลไป API (
NotesApi.create) อัตโนมัติ - เปลี่ยนหน้าไปหน้ารายละเอียดทันที
โจทย์ที่ 3: Command Recognition
สิ่งที่ต้องทำ:
- ถ้าผู้ใช้พูดคำว่า "ลบ", "เคลียร์", "ยกเลิก"
- ให้ล้างข้อความใน
_textControllerทิ้งทั้งหมด
📌 สรุป
Speech-to-Text เป็นฟีเจอร์ที่ว้าวกว่าการพิมพ์มาก แต่ก็มีความท้าทาย:
- Environment: ที่เสียงดังจะทำงานแย่
- Permission: ถ้า User ปฏิเสธไมค์ ฟีเจอร์นี้ดับทันที (ต้อง handle case นี้ด้วย)
- Feedback: ต้องบอก User ตลอดเวลาว่า "ฟังอยู่" หรือ "หยุดแล้ว"
ไฟล์ที่เกี่ยวข้อง
🔜 บทถัดไป
บทที่ 15: Image Handling & Export เมื่อได้ข้อความแล้ว เราจะเอามันไปทำเป็นรูปภาพสวยๆ เพื่อแชร์ลง Social Media กัน!