[AI] 佈署並呼叫地端Azure Speech to Text

  • 89
  • 0
  • AI
  • 2026-01-22

AI產的Code還是要人工不斷code review校正, 直接使用的話太危險

Azure佈署在地端可到Microsoft Artifact Registry拉取最新Image: 

mcr.microsoft.com/azure-cognitive-services/speechservices/speech-to-text:5.1.0-amd64-zh-tw

pod的yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: azure-stt
  namespace: independent
spec:
  replicas: 1
  selector:
    matchLabels:
      app: azure-stt
  template:
    metadata:
      labels:
        app: azure-stt
    spec:
      containers:
      - name: azure-stt
        image: mcr.microsoft.com/azure-cognitive-services/speechservices/speech-to-text:5.1.0-amd64-zh-tw
        ports:
        - containerPort: 5000
        env:
        - name: eula
          value: "accept"
        - name: apikey
          valueFrom:
            secretKeyRef:
              name: azure-tts-secret    #建一個secret存azure key
              key: KEY
        - name: billing
          valueFrom:
            secretKeyRef:
              name: azure-tts-secret    #建一個secret存azure url,同下hostnames
              key: URI
        resources:
          requests:
            cpu: "0.05"
            memory: "1Gi"
          limits:
            cpu: "8"
            memory: "8Gi"
      restartPolicy: Always
      hostAliases:
      - ip: "你azure的資源ip"
        hostnames:
        - "你azure的資源名.cognitiveservices.azure.com"

除了中英文以外, 可以理解少量常用台語、區分發言者、發言時間

呼叫方式可參考官網: 使用快速轉錄 API - 語音服務 - Foundry Tools | Microsoft Learn

由於只能上傳wav格式, 所以我寫了一支程式:

核心功能
1.    錄音功能
•    透過麥克風即時錄音
•    儲存為 WAV 格式
•    支援手動停止錄音
2.    語音轉文字 (STT)
•    使用 Azure Cognitive Services Speech SDK
•    支援多格式檔案:
•    音訊: WAV, MP3
•    影片: MP4, AVI, MOV, WMV, MKV, FLV, WebM
•    自動轉換非 WAV 格式為 WAV
3.    對話辨識與說話者識別
•    使用 ConversationTranscriber 進行多人對話辨識
•    標記不同說話者 (某1, 某2...)
•    輸出時間戳記 (開始~結束時間)
4.    智能重試機制
•    最多重試 10 次
•    發生錯誤時自動續傳
•    從中斷點切割音訊繼續處理
5.    使用者介面
•    即時顯示辨識結果
•    狀態訊息追蹤
•    支援取消分析
•    靜音超時設定

UI 畫面:

 

UI程式 Form1.Designer.cs:

namespace STT
{
    partial class Form1
    {
        /// <summary>
        /// 設計工具所需的變數。
        /// </summary>
        private System.ComponentModel.IContainer components = null;

        /// <summary>
        /// 清除任何使用中的資源。
        /// </summary>
        /// <param name="disposing">如果應該處置受控資源則為 true,否則為 false。</param>
        protected override void Dispose(bool disposing)
        {
            if (disposing && (components != null))
            {
                components.Dispose();
            }
            base.Dispose(disposing);
        }

        #region Windows Form 設計工具產生的程式碼

        /// <summary>
        /// 此為設計工具支援所需的方法 - 請勿使用程式碼編輯器修改
        /// 這個方法的內容。
        /// </summary>
        private void InitializeComponent()
        {
            this.btnStart = new System.Windows.Forms.Button();
            this.txtStatus = new System.Windows.Forms.TextBox();
            this.txtPath = new System.Windows.Forms.TextBox();
            this.btnPath = new System.Windows.Forms.Button();
            this.btnSTT = new System.Windows.Forms.Button();
            this.txtResult = new System.Windows.Forms.TextBox();
            this.panel1 = new System.Windows.Forms.Panel();
            this.lblEndSilenceTimeout = new System.Windows.Forms.Label();
            this.numSilenceTimeout = new System.Windows.Forms.NumericUpDown();
            this.lblInitialSilenceTimeout = new System.Windows.Forms.Label();
            this.splitContainer1 = new System.Windows.Forms.SplitContainer();
            this.btnCancel = new System.Windows.Forms.Button();
            this.panel1.SuspendLayout();
            ((System.ComponentModel.ISupportInitialize)(this.numSilenceTimeout)).BeginInit();
            ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).BeginInit();
            this.splitContainer1.Panel1.SuspendLayout();
            this.splitContainer1.Panel2.SuspendLayout();
            this.splitContainer1.SuspendLayout();
            this.SuspendLayout();
            // 
            // btnStart
            // 
            this.btnStart.Location = new System.Drawing.Point(12, 12);
            this.btnStart.Name = "btnStart";
            this.btnStart.Size = new System.Drawing.Size(105, 32);
            this.btnStart.TabIndex = 0;
            this.btnStart.Text = "進行錄音";
            this.btnStart.UseVisualStyleBackColor = true;
            this.btnStart.Click += new System.EventHandler(this.btnStart_Click);
            // 
            // txtStatus
            // 
            this.txtStatus.Dock = System.Windows.Forms.DockStyle.Fill;
            this.txtStatus.Location = new System.Drawing.Point(0, 0);
            this.txtStatus.Multiline = true;
            this.txtStatus.Name = "txtStatus";
            this.txtStatus.ScrollBars = System.Windows.Forms.ScrollBars.Vertical;
            this.txtStatus.Size = new System.Drawing.Size(477, 673);
            this.txtStatus.TabIndex = 0;
            // 
            // txtPath
            // 
            this.txtPath.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) 
            | System.Windows.Forms.AnchorStyles.Right)));
            this.txtPath.Location = new System.Drawing.Point(235, 17);
            this.txtPath.Name = "txtPath";
            this.txtPath.Size = new System.Drawing.Size(899, 29);
            this.txtPath.TabIndex = 2;
            // 
            // btnPath
            // 
            this.btnPath.Location = new System.Drawing.Point(123, 13);
            this.btnPath.Name = "btnPath";
            this.btnPath.Size = new System.Drawing.Size(106, 32);
            this.btnPath.TabIndex = 1;
            this.btnPath.Text = "音檔路徑";
            this.btnPath.UseVisualStyleBackColor = true;
            this.btnPath.Click += new System.EventHandler(this.btnPath_Click);
            // 
            // btnSTT
            // 
            this.btnSTT.BackColor = System.Drawing.Color.SpringGreen;
            this.btnSTT.Location = new System.Drawing.Point(12, 50);
            this.btnSTT.Name = "btnSTT";
            this.btnSTT.Size = new System.Drawing.Size(217, 32);
            this.btnSTT.TabIndex = 3;
            this.btnSTT.Text = "轉文字";
            this.btnSTT.UseVisualStyleBackColor = false;
            this.btnSTT.Click += new System.EventHandler(this.btnSTT_Click);
            // 
            // txtResult
            // 
            this.txtResult.Dock = System.Windows.Forms.DockStyle.Fill;
            this.txtResult.Location = new System.Drawing.Point(0, 0);
            this.txtResult.Multiline = true;
            this.txtResult.Name = "txtResult";
            this.txtResult.ReadOnly = true;
            this.txtResult.ScrollBars = System.Windows.Forms.ScrollBars.Vertical;
            this.txtResult.Size = new System.Drawing.Size(665, 673);
            this.txtResult.TabIndex = 0;
            // 
            // panel1
            // 
            this.panel1.Controls.Add(this.lblEndSilenceTimeout);
            this.panel1.Controls.Add(this.numSilenceTimeout);
            this.panel1.Controls.Add(this.btnStart);
            this.panel1.Controls.Add(this.lblInitialSilenceTimeout);
            this.panel1.Controls.Add(this.btnSTT);
            this.panel1.Controls.Add(this.txtPath);
            this.panel1.Controls.Add(this.btnPath);
            this.panel1.Dock = System.Windows.Forms.DockStyle.Top;
            this.panel1.Location = new System.Drawing.Point(0, 0);
            this.panel1.Name = "panel1";
            this.panel1.Size = new System.Drawing.Size(1146, 92);
            this.panel1.TabIndex = 1;
            // 
            // lblEndSilenceTimeout
            // 
            this.lblEndSilenceTimeout.AutoSize = true;
            this.lblEndSilenceTimeout.Location = new System.Drawing.Point(391, 58);
            this.lblEndSilenceTimeout.Name = "lblEndSilenceTimeout";
            this.lblEndSilenceTimeout.Size = new System.Drawing.Size(121, 20);
            this.lblEndSilenceTimeout.TabIndex = 2;
            this.lblEndSilenceTimeout.Text = "秒則終止轉文字";
            // 
            // numSilenceTimeout
            // 
            this.numSilenceTimeout.Location = new System.Drawing.Point(328, 55);
            this.numSilenceTimeout.Maximum = new decimal(new int[] {
            1200,
            0,
            0,
            0});
            this.numSilenceTimeout.Minimum = new decimal(new int[] {
            5,
            0,
            0,
            0});
            this.numSilenceTimeout.Name = "numSilenceTimeout";
            this.numSilenceTimeout.Size = new System.Drawing.Size(57, 29);
            this.numSilenceTimeout.TabIndex = 4;
            this.numSilenceTimeout.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
            this.numSilenceTimeout.Value = new decimal(new int[] {
            60,
            0,
            0,
            0});
            // 
            // lblInitialSilenceTimeout
            // 
            this.lblInitialSilenceTimeout.AutoSize = true;
            this.lblInitialSilenceTimeout.Location = new System.Drawing.Point(235, 58);
            this.lblInitialSilenceTimeout.Name = "lblInitialSilenceTimeout";
            this.lblInitialSilenceTimeout.Size = new System.Drawing.Size(89, 20);
            this.lblInitialSilenceTimeout.TabIndex = 0;
            this.lblInitialSilenceTimeout.Text = "未發言超過";
            // 
            // splitContainer1
            // 
            this.splitContainer1.Dock = System.Windows.Forms.DockStyle.Fill;
            this.splitContainer1.Location = new System.Drawing.Point(0, 92);
            this.splitContainer1.Name = "splitContainer1";
            // 
            // splitContainer1.Panel1
            // 
            this.splitContainer1.Panel1.Controls.Add(this.txtResult);
            // 
            // splitContainer1.Panel2
            // 
            this.splitContainer1.Panel2.Controls.Add(this.txtStatus);
            this.splitContainer1.Size = new System.Drawing.Size(1146, 673);
            this.splitContainer1.SplitterDistance = 665;
            this.splitContainer1.TabIndex = 5;
            // 
            // btnCancel
            // 
            this.btnCancel.BackColor = System.Drawing.Color.LightPink;
            this.btnCancel.Location = new System.Drawing.Point(12, 50);
            this.btnCancel.Name = "btnCancel";
            this.btnCancel.Size = new System.Drawing.Size(217, 32);
            this.btnCancel.TabIndex = 0;
            this.btnCancel.Text = "取消轉文字";
            this.btnCancel.UseVisualStyleBackColor = false;
            this.btnCancel.Visible = false;
            this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click);
            // 
            // Form1
            // 
            this.AutoScaleDimensions = new System.Drawing.SizeF(10F, 20F);
            this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
            this.ClientSize = new System.Drawing.Size(1146, 765);
            this.Controls.Add(this.btnCancel);
            this.Controls.Add(this.splitContainer1);
            this.Controls.Add(this.panel1);
            this.Font = new System.Drawing.Font("微軟正黑體", 12F);
            this.Name = "Form1";
            this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
            this.Text = "語音轉文字";
            this.Load += new System.EventHandler(this.Form1_Load);
            this.panel1.ResumeLayout(false);
            this.panel1.PerformLayout();
            ((System.ComponentModel.ISupportInitialize)(this.numSilenceTimeout)).EndInit();
            this.splitContainer1.Panel1.ResumeLayout(false);
            this.splitContainer1.Panel1.PerformLayout();
            this.splitContainer1.Panel2.ResumeLayout(false);
            this.splitContainer1.Panel2.PerformLayout();
            ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).EndInit();
            this.splitContainer1.ResumeLayout(false);
            this.ResumeLayout(false);

        }

        #endregion

        private System.Windows.Forms.Button btnStart;
        private System.Windows.Forms.TextBox txtStatus;
        private System.Windows.Forms.TextBox txtPath;
        private System.Windows.Forms.Button btnPath;
        private System.Windows.Forms.Button btnSTT;
        private System.Windows.Forms.TextBox txtResult;
        private System.Windows.Forms.Panel panel1;
        private System.Windows.Forms.NumericUpDown numSilenceTimeout;
        private System.Windows.Forms.Label lblInitialSilenceTimeout;
        private System.Windows.Forms.Label lblEndSilenceTimeout;
        private System.Windows.Forms.SplitContainer splitContainer1;
        private System.Windows.Forms.Button btnCancel;
    }
}

Form1.cs:

using Microsoft.CognitiveServices.Speech;
using Microsoft.CognitiveServices.Speech.Audio;
using NAudio.Wave;
using System;
using System.IO;
using System.Threading;
using System.Windows.Forms;

namespace STT
{
    public partial class Form1 : Form
    {
        private const string url = @"www.自己url.com";
        private WaveInEvent waveIn;
        private WaveFileWriter writer;
        private string outputPath = string.Empty;
        private string lastStatusMessage = string.Empty;
        private CancellationTokenSource cancellationTokenSource;
        private double offset = 0.0;

        // 重試設定
        private const int MAX_RETRY_COUNT = 10;
        private const int RETRY_DELAY_MS = 3000;

        public Form1()
        {
            InitializeComponent();
        }

        private void btnCancel_Click(object sender, EventArgs e)
        {
            if (cancellationTokenSource != null && !cancellationTokenSource.IsCancellationRequested)
            {
                if (MessageBox.Show("是否要終止分析?", "確認終止", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes)
                {
                    AppendStatusText("使用者取消分析");
                    cancellationTokenSource?.Cancel();
                }
            }
        }

        private void Form1_Load(object sender, EventArgs e)
        {
            txtStatus.Text = "";
            lastStatusMessage = "";
        }

        private void btnStart_Click(object sender, EventArgs e)
        {
            try
            {
                using (var sfd = new SaveFileDialog()
                {
                    Filter = "WAV 檔 (*.wav)|*.wav",
                    Title = "選擇錄音輸出檔案",
                    FileName = $"record_{DateTime.Now:yyyyMMdd_HHmmss}.wav",
                    AddExtension = true
                })
                {
                    if (sfd.ShowDialog() != DialogResult.OK)
                        return;

                    outputPath = sfd.FileName;
                    txtPath.Text = outputPath;
                }

                // 設定錄音參數
                waveIn = new WaveInEvent
                {
                    DeviceNumber = 0,                           // 預設裝置,可根據需要改成其他索引
                    WaveFormat = new WaveFormat(16000, 16, 1)   // 常見設定
                };

                waveIn.DataAvailable += OnDataAvailable;
                waveIn.RecordingStopped += OnRecordingStopped;

                writer = new WaveFileWriter(outputPath, waveIn.WaveFormat);

                waveIn.StartRecording();
                if (MessageBox.Show("錄音中,按確定停止錄音", "錄音中", MessageBoxButtons.OK, MessageBoxIcon.Information) == DialogResult.OK)
                {
                    waveIn?.StopRecording();
                    Cleanup();
                }
            }
            catch (Exception ex)
            {
                AppendStatusText($"啟動錄音失敗: {ex.ToString()}");
                MessageBox.Show(ex.Message, "錯誤", MessageBoxButtons.OK, MessageBoxIcon.Error);
                Cleanup();
            }
        }

        private void OnDataAvailable(object sender, WaveInEventArgs e)
        {
            try
            {
                // 將 buffer 寫入 WAV
                writer?.Write(e.Buffer, 0, e.BytesRecorded);
                writer?.Flush();
            }
            catch (Exception ex)
            {
                AppendStatusText($"寫入音訊失敗,停止錄音");
                AppendStatusText(ex.ToString());
                MessageBox.Show(ex.Message, "寫檔錯誤", MessageBoxButtons.OK, MessageBoxIcon.Error);
                waveIn?.StopRecording();
            }
        }

        private void OnRecordingStopped(object sender, StoppedEventArgs e)
        {
            Cleanup();
            if (e.Exception != null)
            {
                AppendStatusText("錄音停止(有例外)");
                AppendStatusText(e.Exception.ToString());
                MessageBox.Show(e.Exception.Message, "錄音例外", MessageBoxButtons.OK, MessageBoxIcon.Warning);
            }
            else
            {
                AppendStatusText("錄音完成");
            }
        }

        private void Cleanup()
        {
            if (waveIn != null)
            {
                waveIn.DataAvailable -= OnDataAvailable;
                waveIn.RecordingStopped -= OnRecordingStopped;
                waveIn.Dispose();
                waveIn = null;
            }

            if (writer != null)
            {
                writer.Dispose();
                writer = null;
            }
        }

        private void btnPath_Click(object sender, EventArgs e)
        {
            using (var ofd = new OpenFileDialog())
            {
                ofd.Filter = "所有支援的檔案|*.wav;*.mp3;*.mp4;*.avi;*.mov;*.wmv;*.mkv;*.flv;*.webm|音訊檔|*.wav;*.mp3|影片檔|*.mp4;*.avi;*.mov;*.wmv;*.mkv;*.flv;*.webm|WAV 檔|*.wav|MP3 檔|*.mp3|MP4 檔|*.mp4|所有檔案|*.*";
                if (ofd.ShowDialog() == DialogResult.OK)
                {
                    txtPath.Text = ofd.FileName;
                }
            }
        }

        private async void btnSTT_Click(object sender, EventArgs e)
        {
            txtResult.Text = string.Empty;
            if (txtPath.Text.Length == 0)
            {
                btnPath_Click(sender, e);
                if (txtPath.Text.Length == 0) return;
            }

            if (!File.Exists(txtPath.Text))
            {
                MessageBox.Show("找不到指定的檔案", "錯誤", MessageBoxButtons.OK, MessageBoxIcon.Error);
                return;
            }

            string wavPath = txtPath.Text;
            string tempFile = null;

            string fileExt = Path.GetExtension(txtPath.Text).ToLower();

            // 判斷是否需要轉換
            if (fileExt != ".wav")
            {
                try
                {
                    tempFile = Path.Combine(Path.GetTempPath(), $"temp_{Guid.NewGuid()}.wav");

                    if (fileExt == ".mp3")
                    {
                        ConvertMp3ToWav(txtPath.Text, tempFile);
                    }
                    else if (IsVideoFile(fileExt))
                    {
                        ConvertVideoToWav(txtPath.Text, tempFile);
                    }
                    else
                    {
                        MessageBox.Show($"不支援的檔案格式: {fileExt}", "錯誤", MessageBoxButtons.OK, MessageBoxIcon.Error);
                        return;
                    }

                    wavPath = tempFile;
                }
                catch (Exception ex)
                {
                    MessageBox.Show($"轉換失敗: {ex.Message}\r\n\r\n請確認系統是否支援該格式的解碼。", "錯誤", MessageBoxButtons.OK, MessageBoxIcon.Error);
                    AppendStatusText(ex.ToString());
                    if (tempFile != null && File.Exists(tempFile))
                    {
                        try { File.Delete(tempFile); } catch { }
                    }
                    return;
                }
            }

            // 檢查音訊長度
            try
            {
                using (var reader = new WaveFileReader(wavPath))
                {
                    double durationSeconds = reader.TotalTime.TotalSeconds;
                    AppendStatusText($"音訊長度 {FormatTime(durationSeconds)}");
                }
            }
            catch (Exception ex)
            {
                MessageBox.Show($"讀取音訊失敗: {ex.Message}", "錯誤", MessageBoxButtons.OK, MessageBoxIcon.Error);
                AppendStatusText(ex.ToString());
                if (tempFile != null && File.Exists(tempFile))
                {
                    try { File.Delete(tempFile); } catch { }
                }
                return;
            }

            try
            {
                panel1.Enabled = false;
                btnCancel.Visible = true;
                this.Cursor = Cursors.WaitCursor;
                AppendStatusText($"**如果連續 {numSilenceTimeout.Value} 秒沒有發言, 將出現錯誤訊息並停止分析");

                cancellationTokenSource = new CancellationTokenSource();
                await ProcessAudioWithResume(wavPath, cancellationTokenSource.Token);

                if (!cancellationTokenSource.Token.IsCancellationRequested)
                    AppendStatusText("辨識完成");
            }
            catch (OperationCanceledException)
            {
                AppendStatusText("分析已被取消");
            }
            catch (Exception ex)
            {
                AppendStatusText(ex.ToString().Replace(url, "端點網址"));
                MessageBox.Show($"解析失敗:\n{ex.Message}", "錯誤", MessageBoxButtons.OK, MessageBoxIcon.Error);
            }
            finally
            {
                AppendStatusText($"==========================={Environment.NewLine}");
                cancellationTokenSource?.Dispose();
                cancellationTokenSource = null;
                // 清理臨時檔案
                if (tempFile != null && File.Exists(tempFile))
                {
                    try { File.Delete(tempFile); } catch { }
                }
                panel1.Enabled = true;
                btnCancel.Visible = false;
                this.Cursor = Cursors.Default;
            }
        }

        /// <summary>
        /// 處理音訊辨識(支援智能續傳)
        /// </summary>
        private async System.Threading.Tasks.Task ProcessAudioWithResume(string wavPath, CancellationToken cancellationToken)
        {
            int retryCount = 0;
            double startOffsetForCut = 0;
            offset = 0;

            while (retryCount <= MAX_RETRY_COUNT)
            {
                if (cancellationToken.IsCancellationRequested)
                    throw new OperationCanceledException(cancellationToken);

                try
                {
                    string processWavPath = wavPath;
                    string tempResumeFile = null;

                    // 如果是續傳模式,切割音訊從 startOffsetForCut 開始
                    if (startOffsetForCut > 0)
                    {
                        tempResumeFile = Path.Combine(Path.GetTempPath(), $"resume_{Guid.NewGuid()}.wav");
                        CutWavFile(wavPath, tempResumeFile, startOffsetForCut);
                        processWavPath = tempResumeFile;
                        AppendStatusText($"從 {FormatTime(startOffsetForCut)} 處續傳");
                    }

                    await ProcessAudioInternal(processWavPath, startOffsetForCut, cancellationToken);

                    // 清理臨時切割檔案
                    if (tempResumeFile != null && File.Exists(tempResumeFile))
                        try { File.Delete(tempResumeFile); } catch { }
                    return;
                }
                catch (OperationCanceledException)
                {
                    throw;
                }
                catch (Exception ex)
                {
                    retryCount++;
                    if (retryCount <= MAX_RETRY_COUNT)
                    {
                        AppendStatusText($"發生錯誤: {ex.Message.Replace(url, "端點網址")}");
                        // 將當前的 offset 設為下次的切割起點
                        startOffsetForCut = offset;
                        AppendStatusText($"等待 {RETRY_DELAY_MS / 1000} 秒後進行第 {retryCount} 次重試");
                        await System.Threading.Tasks.Task.Delay(RETRY_DELAY_MS, cancellationToken);
                    }
                    else
                    {
                        AppendStatusText($"重試 {MAX_RETRY_COUNT} 次後仍失敗");
                        throw;
                    }
                }
            }
        }

        /// <summary>
        /// 從指定時間點切割 WAV 檔案
        /// </summary>
        private void CutWavFile(string inputWavPath, string outputWavPath, double startSeconds)
        {
            using (var reader = new WaveFileReader(inputWavPath))
            {
                var waveFormat = reader.WaveFormat;
                int bytesPerSecond = waveFormat.AverageBytesPerSecond;
                long startByte = (long)(bytesPerSecond * startSeconds);
                // 確保對齊到 block
                long blockAlign = waveFormat.BlockAlign;
                startByte = (startByte / blockAlign) * blockAlign;
                if (startByte >= reader.Length)
                    throw new ArgumentException($"起始時間 {startSeconds} 秒超過音訊長度");

                reader.Position = startByte;
                using (var writer = new WaveFileWriter(outputWavPath, waveFormat))
                {
                    byte[] buffer = new byte[bytesPerSecond]; // 1秒的緩衝區
                    int bytesRead;
                    while ((bytesRead = reader.Read(buffer, 0, buffer.Length)) > 0)
                        writer.Write(buffer, 0, bytesRead);
                }
            }
        }

        /// <summary>
        /// 實際處理音訊的內部方法
        /// </summary>
        private async System.Threading.Tasks.Task ProcessAudioInternal(string wavPath, double startOffsetForCut, CancellationToken cancellationToken)
        {
            var speechConfig = SpeechConfig.FromHost(new Uri("http://" + url));
            speechConfig.SpeechRecognitionLanguage = "zh-TW";
            speechConfig.OutputFormat = OutputFormat.Detailed;
            //speechConfig.SetProperty(PropertyId.Speech_LogFilename, "../speech-sdk.log");
            speechConfig.SetProperty(PropertyId.SpeechServiceConnection_LanguageIdMode, "Continuous");
            var timeout = numSilenceTimeout.Value.ToString() + "000";
            speechConfig.SetProperty(PropertyId.SpeechServiceConnection_InitialSilenceTimeoutMs, timeout);
            speechConfig.SetProperty(PropertyId.SpeechServiceConnection_EndSilenceTimeoutMs, timeout);
            speechConfig.SetProperty(PropertyId.Speech_SegmentationStrategy, "Semantic");

            using (var audioConfig = AudioConfig.FromWavFileInput(wavPath))
            {
                var conversationTranscriber = new Microsoft.CognitiveServices.Speech.Transcription.ConversationTranscriber(speechConfig, audioConfig);
                var stopTranscription = new System.Threading.Tasks.TaskCompletionSource<int>();

                conversationTranscriber.Transcribed += (s, e1) =>
                {
                    if (cancellationToken.IsCancellationRequested)
                    {
                        stopTranscription.TrySetResult(0);
                        return;
                    }

                    if (e1.Result.Reason == ResultReason.RecognizedSpeech && !string.IsNullOrEmpty(e1.Result.Text))
                    {
                        var speakerId = e1.Result.SpeakerId;
                        // 計算實際時間 = 切割檔案中的位置 + 切割起點
                        offset = e1.Result.OffsetInTicks / 10000000.0 + startOffsetForCut;

                        var duration = e1.Result.Duration.TotalSeconds;
                        var endTime = offset + duration;
                        var timeInfo = $"{FormatTime(offset)}~{FormatTime(endTime)}";
                        var speakerInfo = string.IsNullOrEmpty(speakerId) ? "?" : speakerId.Replace("Guest-", "某").Replace("Unknown", "?");
                        var resultText = $"{timeInfo} {speakerInfo}: {e1.Result.Text}{Environment.NewLine}";

                        // 直接輸出結果
                        this.Invoke(new Action(() =>
                        {
                            txtResult.AppendText(resultText);
                        }));

                        // 更新當前位置為結束時間
                        offset = endTime;
                    }
                };

                conversationTranscriber.Transcribing += (s, e1) =>
                {
                    if (cancellationToken.IsCancellationRequested)
                        stopTranscription.TrySetResult(0);
                };

                conversationTranscriber.Canceled += (s, e1) =>
                {
                    if (e1.Reason == CancellationReason.Error)
                    {
                        var errorMsg = $"{FormatTime(offset)}: {e1.ErrorDetails}";
                        stopTranscription.TrySetException(new Exception(errorMsg));
                        return;
                    }
                    stopTranscription.TrySetResult(0);
                };

                conversationTranscriber.SessionStopped += (s, e1) =>
                {
                    stopTranscription.TrySetResult(0);
                };

                await conversationTranscriber.StartTranscribingAsync();

                using (cancellationToken.Register(() => stopTranscription.TrySetResult(0)))
                    await stopTranscription.Task;

                await conversationTranscriber.StopTranscribingAsync();

                if (cancellationToken.IsCancellationRequested)
                    throw new OperationCanceledException(cancellationToken);
            }
        }

        /// <summary>
        /// 格式化時間為 HH:mm:ss 格式
        /// </summary>
        private string FormatTime(double seconds)
        {
            var timeSpan = TimeSpan.FromSeconds(seconds);
            return $"{(int)timeSpan.TotalHours:D2}:{timeSpan.Minutes:D2}:{timeSpan.Seconds:D2}";
        }

        /// <summary>
        /// 判斷是否為影片檔案
        /// </summary>
        private bool IsVideoFile(string extension)
        {
            string[] videoExtensions = { ".mp4", ".avi", ".mov", ".wmv", ".mkv", ".flv", ".webm" };
            return Array.Exists(videoExtensions, ext => ext.Equals(extension, StringComparison.OrdinalIgnoreCase));
        }

        /// <summary>
        /// 將 MP3 轉換為 WAV 格式
        /// </summary>
        private void ConvertMp3ToWav(string mp3Path, string wavPath)
        {
            using (var reader = new Mp3FileReader(mp3Path))
            using (var writer = new WaveFileWriter(wavPath, reader.WaveFormat))
            {
                reader.CopyTo(writer);
            }
        }

        /// <summary>
        /// 將影片檔案轉換為 WAV 格式(提取音訊)
        /// </summary>
        private void ConvertVideoToWav(string videoPath, string wavPath)
        {
            try
            {
                using (var reader = new MediaFoundationReader(videoPath))
                using (var writer = new WaveFileWriter(wavPath, reader.WaveFormat))
                {
                    reader.CopyTo(writer);
                }
            }
            catch (Exception ex)
            {
                throw new Exception($"無法從影片提取音訊: {ex.Message}", ex);
            }
        }

        /// <summary>
        /// 附加訊息至 txtStatus,如果與上次訊息相同則略過
        /// </summary>
        private void AppendStatusText(string message)
        {
            if (string.IsNullOrEmpty(lastStatusMessage) || !message.StartsWith(lastStatusMessage))
            {
                txtStatus.AppendText($"{DateTime.Now:HH:mm:ss} {message}{Environment.NewLine}");
                lastStatusMessage = message;
            }
        }
    }
}

Taiwan is a country. 臺灣是我的國家