iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
AI Engineering

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

Day 13:語音串流斷線重連與邊緣端備援機制實作

  • 分享至 

  • xImage
  •  

一、今日目標

在行動裝置環境裡,4G/5G 切換、電梯或地下室收訊不好,是我們日常生活常見的情況。如果 WebSocket 一斷線就讓錄音跟著中斷、資料跟著不見,會使使用者的體驗會差很多。

今天要處理的是語音串流的高可用性,WebSocket 斷線後自動重連,斷線期間先把音訊暫存在本地佇列,等連線恢復後再把積壓的資料補發回伺服器。

名詞補充:

  • 本地佇列備援:網路斷線時,先把 Audio Chunk 暫存在邊緣端的記憶體 Buffer 裡,避免封包遺失。
  • 指數退避自動重連:斷線後不會馬上狂發重連請求,而是拉長間隔慢慢重試。
  • 區塊補發:重連成功後,把 Buffer 裡積壓的 Audio Chunk 依序補發給伺服器。

二、今日任務

Step 1: 實作可斷線重連與備援的 WebSocket 服務

建立 lib/services/resilient_websocket_service.dart

import 'dart:async';
import 'dart:collection';
import 'dart:math';
import 'dart:typed_data';
import 'package:web_socket_channel/web_socket_channel.dart';

class ResilientWebSocketService {
  WebSocketChannel? _channel;
  StreamSubscription? _subscription;
  
  // 1. 本地邊緣端備援佇列(Ring Buffer 機制)
  final Queue<Uint8List> _audioBuffer = Queue<Uint8List>();
  final int _maxBufferSize = 100; // 最多暫存約 10 秒的語音 Chunk,防止記憶體溢出

  bool _isConnected = false;
  bool _isReconnecting = false;
  int _retryCount = 0;
  
  String? _currentUrl;
  Function(String)? _onTextReceived;

  bool get isConnected => _isConnected;

  /// 開始連線
  Future<void> connect(String url, Function(String textChunk) onTextReceived) async {
    _currentUrl = url;
    _onTextReceived = onTextReceived;
    await _initWebSocket();
  }

  /// 初始化 WebSocket 與監聽器
  Future<void> _initWebSocket() async {
    if (_currentUrl == null) return;

    try {
      _channel = WebSocketChannel.connect(Uri.parse(_currentUrl!));
      _isConnected = true;
      _isReconnecting = false;
      _retryCount = 0;
      print('WebSocket 連線成功!');

      // 重連成功後,優先清空並補發本地 Buffer 中的語音 Chunk
      _flushBuffer();

      _subscription = _channel!.stream.listen(
        (data) {
          if (_onTextReceived != null) _onTextReceived!(data.toString());
        },
        onError: (error) {
          print('WebSocket 發生錯誤: $error');
          _handleDisconnect();
        },
        onDone: () {
          print('WebSocket 連線關閉');
          _handleDisconnect();
        },
      );
    } catch (e) {
      print('連線建立失敗: $error');
      _handleDisconnect();
    }
  }

  /// 處理斷線與指數退避重連 (Exponential Backoff)
  void _handleDisconnect() {
    _isConnected = false;
    _subscription?.cancel();
    _channel?.sink.close();

    if (_isReconnecting) return;
    _isReconnecting = true;

    // 計算重連延遲時間
    _retryCount++;
    final backoffSeconds = min(pow(2, _retryCount).toInt(), 30); // 上限 30 秒
    print('網路斷線!將於 $backoffSeconds 秒後進行第 $_retryCount 次自動重連...');

    Timer(Duration(seconds: backoffSeconds), () async {
      if (_isReconnecting) {
        await _initWebSocket();
      }
    });
  }

  /// 2. 發送 Audio Chunk(含邊緣端備援邏輯)
  void sendAudioChunk(Uint8List chunk) {
    if (_isConnected && _channel != null) {
      try {
        _channel!.sink.add(chunk);
      } catch (e) {
        print('傳送失敗,存入本地 Buffer');
        _enqueueBuffer(chunk);
        _handleDisconnect();
      }
    } else {
      // 斷線狀態下,把 Chunk 暫存在邊緣端 Buffer
      _enqueueBuffer(chunk);
    }
  }

  /// 將 Chunk 加入 Buffer
  void _enqueueBuffer(Uint8List chunk) {
    if (_audioBuffer.length >= _maxBufferSize) {
      _audioBuffer.removeFirst(); // 若 Buffer 滿了,丟棄最舊的 Chunk(確保記憶體安全)
    }
    _audioBuffer.add(chunk);
    print('邊緣端暫存 Chunk,當前 Buffer 積壓數: ${_audioBuffer.length}');
  }

  /// 3. 重連成功後補發暫存封包
  void _flushBuffer() {
    if (_audioBuffer.isEmpty) return;
    print('重連成功!開始補發 Buffer 中 ${_audioBuffer.length} 個音訊 Chunk...');

    while (_audioBuffer.isNotEmpty && _isConnected) {
      final chunk = _audioBuffer.removeFirst();
      _channel!.sink.add(chunk);
    }
  }

  /// 主動關閉連線
  Future<void> disconnect() async {
    _isReconnecting = false;
    _isConnected = false;
    _audioBuffer.clear();
    await _subscription?.cancel();
    await _channel?.sink.close();
    print('手動斷開連線,清空 Buffer');
  }
}

Step 2: 整合至 UI 畫面與連線狀態顯示

建立 lib/pages/resilient_stream_page.dart

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

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

  @override
  State<ResilientStreamPage> createState() => _ResilientStreamPageState();
}

class _ResilientStreamPageState extends State<ResilientStreamPage> {
  final AudioRecorderService _recorder = AudioRecorderService();
  final ResilientWebSocketService _wsService = ResilientWebSocketService();

  bool _isRecording = false;
  String _transcript = '';
  
  final String _testWsUrl = 'wss://echo.websocket.org';

  void _toggleStreaming() async {
    if (_isRecording) {
      await _recorder.stopRecording();
      await _wsService.disconnect();
      setState(() => _isRecording = false);
    } else {
      setState(() {
        _isRecording = true;
        _transcript = '';
      });

      // 1. 啟動防斷線 WebSocket 連線
      await _wsService.connect(_testWsUrl, (text) {
        setState(() {
          _transcript += '$text\n';
        });
      });

      // 2. 開始錄音並將切片丟入 Resilient Service 處理
      await _recorder.startChunkedRecording((Uint8List chunk) {
        _wsService.sendAudioChunk(chunk);
      });
    }
  }

  @override
  void dispose() {
    _recorder.dispose();
    _wsService.disconnect();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Day 13 - 語音串流斷線重連與備援')),
      body: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          children: [
            // 連線狀態指示燈
            Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: _wsService.isConnected ? Colors.green[100] : Colors.orange[100],
                borderRadius: BorderRadius.circular(8),
              ),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(
                    _wsService.isConnected ? Icons.wifi : Icons.wifi_off,
                    color: _wsService.isConnected ? Colors.green : Colors.orange,
                  ),
                  const SizedBox(width: 10),
                  Text(
                    _wsService.isConnected ? '網路狀態:正常連線中' : '網路狀態:斷線/重連中 (啟用邊緣端 Buffer)',
                    style: TextStyle(
                      color: _wsService.isConnected ? Colors.green[900] : Colors.orange[900],
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 30),

            ElevatedButton.icon(
              onPressed: _toggleStreaming,
              icon: Icon(_isRecording ? Icons.stop : Icons.mic),
              label: Text(_isRecording ? '停止錄音串流' : '開始高可用錄音串流'),
              style: ElevatedButton.styleFrom(
                backgroundColor: _isRecording ? Colors.red : Colors.blue,
                padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
              ),
            ),
            const SizedBox(height: 30),

            const Align(
              alignment: Alignment.centerLeft,
              child: Text('串流日誌 / 逐字稿:', style: TextStyle(fontWeight: FontWeight.bold)),
            ),
            const SizedBox(height: 10),

            Expanded(
              child: Container(
                width: double.infinity,
                padding: const EdgeInsets.all(12),
                color: Colors.black87,
                child: SingleChildScrollView(
                  child: Text(
                    _transcript.isEmpty ? '等待連線與語音輸入...' : _transcript,
                    style: const TextStyle(color: Colors.greenAccent, fontFamily: 'monospace'),
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

檢查清單

  • 錄音過程中,把手機或電腦的網路直接關掉(或切成飛行模式)。
  • 看一下 Console,這時候應該會印出「邊緣端暫存 Chunk,當前 Buffer 積壓數: ...」,錄音不會中斷,Audio Chunk 會持續堆疊在記憶體裡。
  • 把網路打開,系統會自動觸發重連,Console 依序印出「進行自動重連...」到「WebSocket 連線成功!」,接著是「重連成功!開始補發 Buffer 中 X 個音訊 Chunk...」

上一篇
Day 12:處理多國語音/韓語與日語聲學辨識的參數調校
下一篇
Day 14:開發踩坑週記/語音切片時遇到的音訊變形難題
系列文
從行程到應援:30 天做出追星實用工具箱 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言