iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
AI Engineering

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

Day 14:開發踩坑週記/語音切片時遇到的音訊變形難題

  • 分享至 

  • xImage
  •  

一、前言

切出來的音訊播放起來像花栗鼠(聲音變尖、變快)

  • 聲音聽起來像機器人(雜音、斷斷續續)
  • OpenAI / Whisper 回傳整段空白或產生字詞幻覺

今天就來拆解這些「音訊變形」與「PCM 標頭缺失」的問題,並附上完整解法。


二、今日任務

1.聲音變成花栗鼠(Pitch & Speed 異常)

災情描述:將前端切片下來的 PCM 音訊傳給後端或播放器時,聲音變得極高昂且語速變快兩倍,或是反過來變得極低沉粗糙。

根本原因:採樣率與通道數

音訊資料本質上只是一串位元組(Bytes)。如果錄音時用的是 16,000 Hz,但後端或播放器預設用 32,000 Hz 或 44,100 Hz 去解碼,播放器就會在半秒內把一秒的資料播完,導致「花栗鼠效應」。

通道數同樣會出錯:

  • 雙聲道當單聲道讀:資料量少一半,語速變慢、音調變低
  • 單聲道當雙聲道讀:語速變快兩倍、音調變高

解決方案

在 AudioRecorderService 中,訂定統一生態系中的音訊參數

// 務必與 Whisper / Gemini / 後端 API 要求的規格 100% 對齊
const config = RecordConfig(
encoder: AudioEncoder.pcm16bits, // 16-bit PCM 格式
sampleRate: 16000,               // 16kHz 採樣率(Whisper 最愛的規格)
numChannels: 1,                  // Mono 單聲道
);

2.聲音出現「滋滋滋」雜音

災情描述:音訊聽得懂,但每當 Chunk 切片交界處,就會發出「喀嗒(Click / Pop)」的爆音,或連續播放時聽起來像機器人斷斷續續。

根本原因:非 Zero-Crossing 切割與 Endianness 錯亂

  • 位元組對齊問題(Byte Alignment):我們使用的是 16-bit(2 Bytes)PCM。如果 Chunk 長度剛好被切在奇數(例如 1023 Bytes),一個完整的採樣點(Sample)就會被硬生生剖成兩半,下一個 Chunk 讀取時所有 Sample 都會錯位(High Byte 變成 Low Byte)。
  • Little-Endian vs Big-Endian:iOS / Android 預設大多是 Little-Endian,若 API 接收端預設用 Big-Endian,出來的聲音會是純粹的白噪音。

解決方案:防錯位安全切片器

在 lib/core/utils/pcm_audio_utils.dart 建立工具類別,確保所有音訊 Chunk 都強制 2-Byte(16-bit):

import 'dart:typed_data';

class PcmAudioUtils {
  /// 確保 PCM 16-bit 資料長度必為偶數 (Byte Alignment)
  static Uint8List alignPcm16Bit(Uint8List chunk) {
    if (chunk.length % 2 != 0) {
      // 若長度為奇數,截掉最後一個無效 Byte,防止 16-bit 採樣點被剖半錯位
      return chunk.sublist(0, chunk.length - 1);
    }
    return chunk;
  }

  /// 檢查 PCM 是否全為靜音 (Silence Detection)
  /// 防止發送過多的靜音 Chunk 浪費 API 費用與網路頻寬
  static bool isSilent(Uint8List chunk, {int threshold = 500}) {
    final bytes = alignPcm16Bit(chunk);
    final buffer = bytes.buffer.asByteData(bytes.offsetInBytes, bytes.lengthInBytes);

    int maxAmplitude = 0;
    for (int i = 0; i < bytes.length; i += 2) {
      // 以 Little-Endian 讀取 16-bit 有符號整數 (Int16)
      int sample = buffer.getInt16(i, Endian.little).abs();
      if (sample > maxAmplitude) {
        maxAmplitude = sample;
      }
    }
    // 若最大振幅低於閥值,判定為靜音
    return maxAmplitude < threshold;
  }
}

3.Whisper API 回報 "Invalid File Format" 或 400 錯誤

災情描述:把從 record 套件拿到的原始位元組(Raw PCM Chunk)直接轉檔存成 .wav 發給 OpenAI Whisper REST API,結果 API 報錯退件。

根本原因:Raw PCM 缺少 44-Byte 的 WAV Header

Raw PCM 是「純音訊數據」,沒有標頭檔。一般播放器或 HTTP API 拿到這串 Byte 時,根本不知道它的採樣率是 16k 還是 44.1k,也不知道是單聲道還是雙聲道。必須為這段 PCM 數據加上標準的 44 位元組 WAV Header,它才是一個合法的 .wav 檔案。

解決方案:手動合成 WAV 檔頭

在 lib/core/utils/wav_header_builder.dart 實作標頭補全器:

import 'dart:typed_data';

class WavHeaderBuilder {
  /// 為原始 PCM 數據前綴加上標準的 44-Byte WAV Header
  static Uint8List addWavHeader(
    Uint8List pcmData, {
    int sampleRate = 16000,
    int numChannels = 1,
    int bitsPerSample = 16,
  }) {
    final int byteRate = sampleRate * numChannels * (bitsPerSample ~/ 8);
    final int blockAlign = numChannels * (bitsPerSample ~/ 8);
    final int dataSize = pcmData.length;
    final int chunkSize = 36 + dataSize;

    final builder = BytesBuilder();

    // 1. RIFF Header
    builder.add([0x52, 0x49, 0x46, 0x46]); // "RIFF"
    builder.add(_int32ToBytes(chunkSize));
    builder.add([0x57, 0x41, 0x56, 0x45]); // "WAVE"

    // 2. fmt Sub-chunk
    builder.add([0x66, 0x6D, 0x74, 0x20]); // "fmt "
    builder.add(_int32ToBytes(16));        // Subchunk1Size (16 for PCM)
    builder.add(_int16ToBytes(1));         // AudioFormat (1 for PCM)
    builder.add(_int16ToBytes(numChannels));
    builder.add(_int32ToBytes(sampleRate));
    builder.add(_int32ToBytes(byteRate));
    builder.add(_int16ToBytes(blockAlign));
    builder.add(_int16ToBytes(bitsPerSample));

    // 3. data Sub-chunk
    builder.add([0x64, 0x61, 0x74, 0x61]); // "data"
    builder.add(_int32ToBytes(dataSize));

    // 4. 音訊原始數據
    builder.add(pcmData);

    return builder.toBytes();
  }

  static Uint8List _int16ToBytes(int value) {
    return Uint8List(2)..buffer.asByteData().setInt16(0, value, Endian.little);
  }

  static Uint8List _int32ToBytes(int value) {
    return Uint8List(4)..buffer.asByteData().setInt32(0, value, Endian.little);
  }
}

本週排坑檢核表

排查項目 檢查標準 踩坑預防效果
1. 採樣率對齊 全專案固定使用 16,000 Hz Mono 解決花栗鼠 / 低沉變音
2. 偶數 Byte 對齊 送出 Chunk 前呼叫 alignPcm16Bit() 消除爆音與滋滋雜音
3. WAV Header 上傳 REST API 前先添加 44-Byte 檔頭 避免 API 格式退件
4. 靜音過濾(VAD) 傳送前過濾無聲 Chunk 省下 30% 以上 API 費用

小結

今天整理了語音串流開發中最常見的三大音訊變形陷阱,並提供對應的解法,明天我們將繼續深入即時語音串流的其他實戰,敬請期待!


上一篇
Day 13:語音串流斷線重連與邊緣端備援機制實作
下一篇
Day 15:從逐字稿到流暢語意/串接 LLM(GPT-4o-mini / Gemini)進行翻譯
系列文
從行程到應援:30 天做出追星實用工具箱 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言