iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
AI Engineering

從行程到應援:30 天做出追星實用工具箱系列 第 9 篇

Day 9:Flutter 音訊處理實作/錄音與語音低延遲切片

  • 分享至 

  • xImage
  •  

一、前言

昨天已完成錄音功能的基本架構,今天進入語音功能的實作。
我希望這個功能不只是「錄完一段語音,再上傳檔案進行翻譯」,而是能夠在使用者說話的過程中,就逐步將音訊送出並進行後續處理。本日的重點會放在 Flutter 錄音、音訊切片,以及低延遲傳輸。

錄音部分使用 record 套件,針對 iOS 與 Android 的麥克風權限及平台設定進行處理。錄音資料則會切成約 100~500ms 的小片段,不是等整段錄音結束後才產生完整的 .wav 或 .mp3 檔案,讓後續的語音辨識能夠更即時地進行。最後再搭配 Flutter UI 顯示目前的錄音狀態,完成基本的錄音流程。

二、今日任務

Step 1: 安裝與權限設定

1. 安裝record套件

在 pubspec.yaml 加入:

dependencies:
  flutter:
    sdk: flutter
  record: ^5.1.2 # 音訊錄製與 Stream 串流套件
  permission_handler: ^11.3.1 # 麥克風權限管理

執行 flutter pub get

2. Android 權限設定 (android/app/src/main/AndroidManifest.xml)

<uses-permission android:name="android.permission.RECORD_AUDIO" />

3. iOS 權限設定 (ios/Runner/Info.plist)

<key>NSMicrophoneUsageDescription</key>
<string>需要麥克風權限以進行語音輸入與 AI 對話</string>

Step 2: 核心邏輯實作

建立實作即時語音與串流監聽:

import 'dart:async';
import 'dart:typed_data';
import 'package:record/record.dart';
import 'package:permission_handler/permission_handler.dart';

class AudioRecorderService {
  final AudioRecorder _audioRecorder = AudioRecorder();
  StreamSubscription<Uint8List>? _audioStreamSubscription;

  // 1. 檢查並請求麥克風權限
  Future<bool> requestPermission() async {
    final status = await Permission.microphone.request();
    return status.isGranted;
  }

  // 2. 開始錄音並進行 Chunking 串流切片
  Future<void> startChunkedRecording(Function(Uint8List chunk) onAudioChunkReceived) async {
    final hasPermission = await requestPermission();
    if (!hasPermission) {
      print('❌ 麥克風權限被拒絕');
      return;
    }

    // 設定錄音規格(低延遲通常使用 PCM 16bit 16kHz Mono)
    const config = RecordConfig(
      encoder: AudioEncoder.pcm16bits, // 原始音訊格式,適合即時 AI 處理
      sampleRate: 16000,               // 16kHz 採樣率(Whisper / 語音模型常用)
      numChannels: 1,                  // 單聲道
    );

    // 取得即時音訊數據串流 (Audio Stream)
    final Stream<Uint8List> audioStream = await _audioRecorder.startStream(config);

    // 監聽串流,每當產生新的 Audio Chunk 就回傳
    _audioStreamSubscription = audioStream.listen((Uint8List chunk) {
      print('🎙️ 收到 Audio Chunk 大小: ${chunk.length} bytes');
      onAudioChunkReceived(chunk); // 將 chunk 送給外部(如 WebSocket)
    });
  }

  // 3. 停止錄音
  Future<void> stopRecording() async {
    await _audioStreamSubscription?.cancel();
    _audioStreamSubscription = null;
    await _audioRecorder.stop();
    print('🛑 錄音已停止');
  }

  // 4. 釋放資源
  void dispose() {
    _audioRecorder.dispose();
  }
}

Step 3: UI 介面實作

import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'services/audio_recorder_service.dart';

class AudioRecorderPage extends StatefulWidget {
  const AudioRecorderPage({super.key});

  @override
  State<AudioRecorderPage> createState() => _AudioRecorderPageState();
}

class _AudioRecorderPageState extends State<AudioRecorderPage> {
  final AudioRecorderService _recorderService = AudioRecorderService();
  bool _isRecording = false;
  int _totalBytesReceived = 0;

  void _toggleRecording() async {
    if (_isRecording) {
      await _recorderService.stopRecording();
      setState(() {
        _isRecording = false;
      });
    } else {
      setState(() {
        _totalBytesReceived = 0;
        _isRecording = true;
      });

      await _recorderService.startChunkedRecording((Uint8List chunk) {
        // 收到新的音訊切片(Chunk)
        setState(() {
          _totalBytesReceived += chunk.length;
        });
      });
    }
  }

  @override
  void dispose() {
    _recorderService.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Day 9 - 音訊切片實作')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              _isRecording ? Icons.mic : Icons.mic_none,
              size: 80,
              color: _isRecording ? Colors.red : Colors.grey,
            ),
            const SizedBox(height: 20),
            Text(
              _isRecording ? '正在即時錄音與切片中...' : '點擊下方按鈕開始錄音',
              style: const TextStyle(fontSize: 18),
            ),
            const SizedBox(height: 10),
            Text(
              '累計接收音訊資料: $_totalBytesReceived bytes',
              style: const TextStyle(fontSize: 14, color: Colors.grey),
            ),
            const SizedBox(height: 40),
            ElevatedButton(
              onPressed: _toggleRecording,
              style: ElevatedButton.styleFrom(
                backgroundColor: _isRecording ? Colors.red : Colors.blue,
                padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
              ),
              child: Text(
                _isRecording ? '停止錄音' : '開始錄音',
                style: const TextStyle(color: Colors.white, fontSize: 16),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

三、今日檢查與觀念連結

完成後,要在Console(偵錯主控台)看到以下輸出:

🎙️ 收到 Audio Chunk 大小: 1280 bytes

螢幕擷取畫面 2026-09-15 155455
看到有以上截圖內容代表成功了!

為什麼不存成 .wav 或 .mp3 檔案再傳?
如果存成檔案才上傳,使用者必須講完話、停止錄音後才能發送,延遲會長達數秒,所以我們採用串流(Stream)方式傳送音訊資料,將錄音過程中取得的音訊資料分段即時傳送給後端/Whisper API,讓語音辨識與翻譯可以在使用者說話的同時進行,降低整體延遲,實現接近即時的翻譯效果。


上一篇
Day 8:直播即時翻譯架構設計
下一篇
Day 10:OpenAI Whisper API 串接與 HTTP/WebSocket 傳輸機制
系列文
從行程到應援:30 天做出追星實用工具箱 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言