บทที่ 14mobile|13 KB

บทที่ 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)

ระบบรับเสียงมีสถานะที่ชัดเจนที่เราต้องจัดการ:

  1. Uninitialized: ยังไม่พร้อมใช้งาน (ต้อง init ก่อน)
  2. Listening: กำลังฟังอยู่ (ไมค์เปิด)
  3. Not Listening: หยุดฟังแล้ว (ไมค์ปิด)
  4. 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

สิ่งที่ต้องทำ:

  1. ศึกษา SpeechRecognitionResult ว่ามีค่า confidence ไหม
  2. ถ้ามั่นใจต่ำ (< 0.5) ให้แสดง text สีจางๆ หรือขึ้นเตือนว่า "ไม่ค่อยแน่ใจ"

โจทย์ที่ 2: Auto Stop & Submit

สิ่งที่ต้องทำ:

  1. เมื่อหยุดพูดครบ 3 วินาที (speech status = done/notListening)
  2. ให้ส่งข้อมูลไป API (NotesApi.create) อัตโนมัติ
  3. เปลี่ยนหน้าไปหน้ารายละเอียดทันที

โจทย์ที่ 3: Command Recognition

สิ่งที่ต้องทำ:

  1. ถ้าผู้ใช้พูดคำว่า "ลบ", "เคลียร์", "ยกเลิก"
  2. ให้ล้างข้อความใน _textController ทิ้งทั้งหมด

📌 สรุป

Speech-to-Text เป็นฟีเจอร์ที่ว้าวกว่าการพิมพ์มาก แต่ก็มีความท้าทาย:

  • Environment: ที่เสียงดังจะทำงานแย่
  • Permission: ถ้า User ปฏิเสธไมค์ ฟีเจอร์นี้ดับทันที (ต้อง handle case นี้ด้วย)
  • Feedback: ต้องบอก User ตลอดเวลาว่า "ฟังอยู่" หรือ "หยุดแล้ว"

ไฟล์ที่เกี่ยวข้อง


🔜 บทถัดไป

บทที่ 15: Image Handling & Export เมื่อได้ข้อความแล้ว เราจะเอามันไปทำเป็นรูปภาพสวยๆ เพื่อแชร์ลง Social Media กัน!