iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
AI Engineering

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

Day 12:處理多國語音/韓語與日語聲學辨識的參數調校

  • 分享至 

  • xImage
  •  

一、前言

做多語音的 AI 對話應用(韓語、日語、中文都要顧到)時,常碰到「AI 聽錯字」「語音辨識吃掉句尾」或「日韓語特有發音切不乾淨」的問題。大部分時候問題不在錄音程式本身,而是聲學參數和模型 、語系調校沒對上。

這篇來看韓語和日語在聲學辨識上到底差在哪,並動手做一個能依語言動態調校的多語言音訊服務

介紹:日韓語的「聲學坑」與調校

把 Audio Chunk 丟給 Whisper 或其他 STT 引擎之前,不同語言對音訊參數的敏感度差很多。

1.日語:長音、撥音、無聲化

日語的促音(っ)和長音很吃時間長度,切片切太短,比如 50ms,促音或長音會被硬生生截斷,Whisper 就直接辨識成別的字了。把 Chunk 切片拉到 150ms ~ 250ms 比較穩,採樣率維持 16kHz 16-bit PCM 。

另一個坑是無聲化,です(desu)、ます(masu)這種結尾的 u 音,日本人講話常常發得很輕,甚至幾乎沒聲音,麥克風的靜音偵測如果調太敏感,會把這種輕聲句尾當成靜音,直接切掉。

2.韓語:連音化、緊音與激音

韓語文字和實際發音差很多,例如 한국어 唸出來其實是 한구거。如果照字面單獨切割,Whisper 很容易產生幻覺,辨識出完全不相關的內容。這種情況可以靠 Whisper API 的 prompt 參數塞入韓文常見語境或標準敬語(像 안녕하세 以糾錯。

還有一個容易忽略的地方,韓語的 ㄱ/ㄲ/ㅋ(k/kk/kh)在高頻區段的聲波特徵其實不一樣。音訊如果壓縮過頭、採樣率降太低,這些高頻細節會先消失,辨識率也會跟著掉下來。

二、今日任務

Step 1: 建立動態語言參數設定檔

(audio_config.dart)

建立 lib/models/audio_config.dart,針對各語言配置最佳的音訊與 Prompt 參數

enum SupportedLanguage {
  zh('zh', '中文 (Mandarin)', '以下是中文對話。'),
  ja('ja', '日本語 (Japanese)', '以下は日本語の会話です。です、ます、よろしくお願いいたします。'),
  ko('ko', '한국어 (Korean)', '다음은 한국어 대화입니다. 안녕하세요, 감사합니다, ~습니다.');

  final String code;
  final String label;
  final String initialPrompt; // 用於提示 Whisper 模型的語境引導

  const SupportedLanguage(this.code, this.label, this.initialPrompt);
}

class LanguageAudioConfig {
  final SupportedLanguage language;
  final int sampleRate; // 採樣率 (Hz)
  final int chunkIntervalMs; // 切片間隔 (毫秒)

  LanguageAudioConfig({
    required this.language,
    this.sampleRate = 16000,
    required this.chunkIntervalMs,
  });

  // 取得特定語言的最佳優化設定檔
  factory LanguageAudioConfig.forLanguage(SupportedLanguage lang) {
    switch (lang) {
      case SupportedLanguage.ja:
        return LanguageAudioConfig(
          language: lang,
          sampleRate: 16000,
          chunkIntervalMs: 200, // 日語:適度放寬切片時間,保護促音與長音
        );
      case SupportedLanguage.ko:
        return LanguageAudioConfig(
          language: lang,
          sampleRate: 16000,
          chunkIntervalMs: 150, // 韓語:標準切片,主要依賴 initialPrompt 糾錯
        );
      case SupportedLanguage.zh:
      default:
        return LanguageAudioConfig(
          language: lang,
          sampleRate: 16000,
          chunkIntervalMs: 100, // 中文:低延遲 100ms
        );
    }
  }
}

Step 2: 整合進 Whisper Service (whisper_service.dart)

更新 Whisper REST API 呼叫邏輯,動態注入語言代碼 (language) 與語境提示 (prompt)

import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'dart:convert';
import '../models/audio_config.dart';

class MultiLangWhisperService {
  final String _baseUrl = 'https://api.openai.com/v1/audio/transcriptions';

  Future<String?> transcribe({
    required File audioFile,
    required SupportedLanguage config,
  }) async {
    final apiKey = dotenv.env['OPENAI_API_KEY'];
    if (apiKey == null || apiKey.isEmpty) return null;

    try {
      var request = http.MultipartRequest('POST', Uri.parse(_baseUrl));
      request.headers['Authorization'] = 'Bearer $apiKey';

      request.fields['model'] = 'whisper-1';
      // 動態傳入語言代碼(如 'ja', 'ko', 'zh')
      request.fields['language'] = config.code;
      // 傳入引導 Prompt,大幅提升日韓語辨識準確率與標點符號發布
      request.fields['prompt'] = config.initialPrompt;

      request.files.add(await http.MultipartFile.fromPath('file', audioFile.path));

      var streamedResponse = await request.send();
      var response = await http.Response.fromStream(streamedResponse);

      if (response.statusCode == 200) {
        var jsonResponse = jsonDecode(utf8.decode(response.bodyBytes));
        return jsonResponse['text'];
      } else {
        print('辨識失敗: ${response.body}');
        return null;
      }
    } catch (e) {
      print('錯誤: $error');
      return null;
    }
  }
}

Step 3: 多國語言聲學調校 UI 測試頁面 (multi_lang_page.dart)

打造一個可以讓使用者切換語言(日文 / 韓文 / 中文),並帶入對應聲学與模型設定的介面

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'models/audio_config.dart';
import 'services/audio_recorder_service.dart';
import 'services/whisper_service.dart';

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

  @override
  State<MultiLangAudioPage> createState() => _MultiLangAudioPageState();
}

class _MultiLangAudioPageState extends State<MultiLangAudioPage> {
  final AudioRecorderService _recorder = AudioRecorderService();
  final MultiLangWhisperService _whisper = MultiLangWhisperService();

  SupportedLanguage _selectedLang = SupportedLanguage.ja; // 預設測試日語
  bool _isRecording = false;
  bool _isLoading = false;
  String _resultText = '';
  String? _audioPath;

  void _toggleRecording() async {
    if (_isRecording) {
      await _recorder.stopRecording();
      setState(() {
        _isRecording = false;
        _isLoading = true;
      });

      if (_audioPath != null) {
        // 發送並帶入選定的語言調校設定
        final text = await _whisper.transcribe(
          audioFile: File(_audioPath!),
          config: _selectedLang,
        );
        setState(() {
          _resultText = text ?? '辨識失敗';
          _isLoading = false;
        });
      }
    } else {
      final tempDir = await getTemporaryDirectory();
      _audioPath = '${tempDir.path}/test_${_selectedLang.code}.m4a';

      await _recorder.startFileRecording(_audioPath!);
      setState(() {
        _isRecording = true;
        _resultText = '';
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    final currentConfig = LanguageAudioConfig.forLanguage(_selectedLang);

    return Scaffold(
      appBar: AppBar(title: const Text('Day 12 - 多國語音聲學調校')),
      body: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          children: [
            // 語言切換選單
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('選擇目標語言:', style: TextStyle(fontSize: 16)),
                DropdownButton<SupportedLanguage>(
                  value: _selectedLang,
                  items: SupportedLanguage.values.map((lang) {
                    return DropdownMenuItem(
                      value: lang,
                      child: Text(lang.label),
                    );
                  }).toList(),
                  onChanged: (val) {
                    if (val != null) setState(() => _selectedLang = val);
                  },
                ),
              ],
            ),
            const SizedBox(height: 10),

            // 顯示該語言的聲學調校參數
            Card(
              color: Colors.blueGrey[50],
              child: Padding(
                padding: const EdgeInsets.all(12.0),
                child: Column(
                  children: [
                    Text('聲學配置: SampleRate ${currentConfig.sampleRate}Hz | Chunk: ${currentConfig.chunkIntervalMs}ms'),
                    const SizedBox(height: 5),
                    Text('Prompt 補全: "${currentConfig.language.initialPrompt}"', style: const TextStyle(fontSize: 12, color: Colors.grey)),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 30),

            // 錄音按鈕
            ElevatedButton.icon(
              onPressed: _isLoading ? null : _toggleRecording,
              icon: Icon(_isRecording ? Icons.stop : Icons.mic),
              label: Text(_isRecording ? '停止並分析' : '開始測試發音 (${_selectedLang.code.toUpperCase()})'),
              style: ElevatedButton.styleFrom(
                backgroundColor: _isRecording ? Colors.red : Colors.indigo,
                padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
              ),
            ),
            const SizedBox(height: 30),

            if (_isLoading) const CircularProgressIndicator(),

            if (_resultText.isNotEmpty) ...[
              const Text('辨識結果:', style: TextStyle(fontWeight: FontWeight.bold)),
              const SizedBox(height: 10),
              Container(
                width: double.infinity,
                padding: const EdgeInsets.all(16),
                decoration: BoxDecoration(color: Colors.grey[200], borderRadius: BorderRadius.circular(8)),
                child: Text(_resultText, style: const TextStyle(fontSize: 18)),
              ),
            ]
          ],
        ),
      ),
    );
  }
}

檢查清單

  • [ ] 切換語言至 「日語」,嘗試對麥克風說一段包含長音與敬語的句子:
    • 範例:こんにちは、初めまして。どうぞよろしくお願いします。
    • 觀察是否有完整抓到 です/ます 句尾與連音。
  • [ ] 切換語言至 「韓語」,嘗試說:
    • 範例:안녕하세요. 반갑습니다.
    • 觀察Whisper是否能精準將連音轉回正確的韓文拼音文字。

上一篇
Day 11:如何將切片音訊流暢送出並取得逐字稿
下一篇
Day 13:語音串流斷線重連與邊緣端備援機制實作
系列文
從行程到應援:30 天做出追星實用工具箱 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言