2026年2月15日 星期日

將語音檔案進行批次轉錄成為文字

將語音檔案進行批次轉錄成為文字

因為工作的關係,經常會與客戶進行開會,討論軟體或者系統該如何開發或者修正的需求,又或者在內部進行開會,因此,無論是會議錄音、訪談內容、醫療紀錄,或是客服通話內容,只要能夠轉換為可搜尋、可分析的文字資料,如此,就可以將這些錄音檔案轉換出來文字稿,使用 ChatGPT 這個 LLM 大語言模型,整理出來一份會議紀錄,可以把該會議的內容分類歸納出來,做為日後執行與追蹤之用。這時候,Azure Speech to Text 便成為一項極具價值的雲端服務。它隸屬於 Microsoft Azure,透過深度學習模型將語音轉換為高品質文字,支援即時串流與離線批次轉錄。

當我們手上有錄音筆或手機錄製的語音檔案,例如 MP3、WAV 或 M4A 檔案,其實整個轉換流程並不複雜。第一步通常是在 Azure 入口網站中建立 Speech 資源。建立完成後,系統會提供金鑰(Key)與服務端點(Endpoint),這兩個資訊就是後續呼叫 API 的核心憑證。

接著,使用者可以選擇透過 SDK 或 REST API 進行語音轉換。如果是單一檔案測試,開發者可以透過簡單的程式碼將音訊檔上傳至服務端點,系統會回傳轉換後的文字內容。這種方式適合小型專案或即時處理需求,例如在 Web 應用程式中即時顯示逐字稿。

在技術實作層面上,開發流程大致包含三個核心動作。首先是音訊格式的確認與優化,例如建議使用單聲道、16kHz 或 44.1kHz 的標準音訊格式,以提升辨識準確率。接著是透過 SDK 初始化 SpeechConfig,設定語言(例如 zh-TW 或 en-US)與金鑰資訊。最後將音訊串流傳入 SpeechRecognizer 物件,等待服務回傳辨識結果。整個過程可以在幾十行程式碼內完成。

從架構角度來看,實務上常見的做法是建立一條完整的語音處理流程。音訊由前端裝置上傳至雲端儲存空間,再由後端服務呼叫 Speech API 進行轉錄,轉換完成後將結果寫入資料庫,最後透過搜尋或 AI 分析系統進行後續應用。這樣的設計能讓語音資料真正成為企業可再利用的數位資產,而不是一次性的錄音檔案。

整體而言,Azure Speech to Text 的導入並不困難,但其價值並不只在於「轉成文字」本身,而是在於它打開了語音資料數位化、結構化與智慧化應用的大門。當語音可以被搜尋、索引、分析與整合時,它就不再只是聲音,而是企業知識的一部分。

建立 主控台應用程式 專案

  • 開啟 Visual Studio 2026
  • 選擇「建立新專案」
  • 在 [建立新專案] 視窗中,在右方清單內,找到並選擇 [主控台應用程式] 項目
  • 然後點擊右下方「下一步」按鈕
  • 此時將會看到 [設定新的專案] 對話窗
  • 在該對話窗的 [專案名稱] 欄位中,輸入專案名稱,例如 "csSpeechToTextBatch"
  • 然後點擊右下方「下一步」按鈕
  • 接著會看到 [其他資訊] 對話窗
  • 在這個對話窗內,確認使用底下的選項
    • 架構:.NET 10.0 (或更新版本)
    • 勾選 不要使用最上層陳述式 (這是我的個人習慣)
  • 然後點擊右下方「建立」按鈕
  • 現在,已經完成了這個 主控台應用程式 專案的建立

安裝 Newtonsoft.Json 套件

套件 [Newtonsoft.Json] 是一個流行的 JSON 處理庫,提供了方便的序列化和反序列化功能。透過這個套件,我們可以輕鬆地在 C# 程式碼中處理 JSON 資料,例如將物件轉換為 JSON 字串,或將 JSON 字串轉換為物件。這裡將會列出這個套件提供的功能清單:

  • 將物件序列化為 JSON 字串

  • 將 JSON 字串反序列化為物件

  • 支援自訂序列化設定

  • 支援 LINQ to JSON 操作

  • 在 Visual Studio 的「方案總管」視窗中,右鍵點擊專案名稱

  • 從右鍵選單中,選擇「管理 NuGet 套件」

  • 在 NuGet 套件管理器視窗中,切換到「瀏覽」標籤頁

  • 在搜尋框中,輸入 "Newtonsoft.Json" 並按下 Enter 鍵

  • 從搜尋結果中,找到 "Newtonsoft.Json" 套件 並點擊它

  • 在這裡的範例中,使用該套件的版本為 13.0.3

  • 在右側的詳細資訊面板中,點擊「安裝」按鈕

撰寫程式碼

  • 打開 Program.cs 檔案,並將其內容替換為以下程式碼:
using Newtonsoft.Json;
using System.Net.Http.Headers;

namespace csSpeechToTextBatch;

class TranscriptionJson
{
    [JsonProperty("combinedRecognizedPhrases")]
    public CombinedPhrase[] CombinedRecognizedPhrases { get; set; }
}
class CombinedPhrase
{
    [JsonProperty("display")]
    public string Display { get; set; }
}
internal class Program
{
    static async Task Main(string[] args)
    {
        string SubscriptionKey = Environment.GetEnvironmentVariable("AzureSpeechServiceSubscriptionKey");
        string ServiceRegion = Environment.GetEnvironmentVariable("AzureSpeechServiceRegion");
        string AudioFileSasUri = "https://blogstoragekh.blob.core.windows.net/audio-files/250501_0814.mp3?sv=2025-05-05&se=2025-05-01T10%3A53%3A53Z&sr=b&sp=r&sig=HVG%2Bs3hxD5cmv%2FrVOs5HZbekqmIBJujOGJWnsRLTjUQ%3D";

        var client = new HttpClient();
        client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", SubscriptionKey);
        var createUrl = $"https://{ServiceRegion}.api.cognitive.microsoft.com/speechtotext/v3.2/transcriptions";
        var createBody = new
        {
            contentUrls = new[] { AudioFileSasUri },
            locale = "zh-TW",
            displayName = "My Batch Transcription",
            properties = new
            {
                diarizationEnabled = false,
                wordLevelTimestampsEnabled = true,
                punctuationMode = "DictatedAndAutomatic"
            }
        };

        var jsonContent = new StringContent(JsonConvert.SerializeObject(createBody));
        jsonContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
        var createResponse = await client.PostAsync(createUrl, jsonContent);
        createResponse.EnsureSuccessStatusCode();

        var createResult = await createResponse.Content.ReadAsStringAsync();
        Console.WriteLine("已建立批次轉錄工作:");
        Console.WriteLine(createResult);

        dynamic createJson = JsonConvert.DeserializeObject(createResult);
        string transcriptionUrl = createJson.self;

        Console.WriteLine("開始輪詢轉錄狀態…");
        TimeSpan elapsedTime;
        DateTime startTime = DateTime.Now;
        while (true)
        {
            elapsedTime = DateTime.Now - startTime;
            Console.WriteLine($"已經花費時間:{elapsedTime.Hours:D2}:{elapsedTime.Minutes:D2}:{elapsedTime.Seconds:D2}");

            var statusResponse = await client.GetAsync(transcriptionUrl);
            statusResponse.EnsureSuccessStatusCode();

            var statusJson = await statusResponse.Content.ReadAsStringAsync();
            dynamic statusObj = JsonConvert.DeserializeObject(statusJson);
            string status = statusObj.status;
            Console.WriteLine($"目前狀態:{status}");

            if (status == "Succeeded")
            {
                string filesUrl = statusObj.links.files;
                var filesResponse = await client.GetAsync(filesUrl);
                filesResponse.EnsureSuccessStatusCode();

                var filesJson = await filesResponse.Content.ReadAsStringAsync();
                dynamic filesObj = JsonConvert.DeserializeObject(filesJson);

                foreach (var file in filesObj.values)
                {
                    if ((string)file.kind == "Transcription")
                    {
                        var fileUrl = (string)file.links.contentUrl;
                        var transcriptionResult = await client.GetStringAsync(fileUrl);
                        var resultObj = JsonConvert.DeserializeObject<TranscriptionJson>(transcriptionResult);

                        string fullText = string.Join(" ",
                            resultObj.CombinedRecognizedPhrases
                                     .Select(p => p.Display?.Trim())
                                     .Where(s => !string.IsNullOrEmpty(s))
                        );
                        Console.WriteLine("---- 完整轉錄文字 ----");
                        Console.WriteLine(fullText);
                    }
                }
                break;
            }
            else if (status == "Failed")
            {
                Console.WriteLine("轉錄失敗");
                break;
            }

            await Task.Delay(TimeSpan.FromSeconds(5));
        }
    }
}

在這個檔案內,宣告了 [TranscriptionJson] 與 [CombinedPhrase] 兩個類別,這些類別用來對應從 Azure Speech to Text API 回傳的 JSON 結構。

在程式進入點,宣告了三個字串 [SubscriptionKey]、[ServiceRegion] 與 [AudioFileSasUri],分別用來存放 Azure Speech 服務的金鑰、區域與要轉錄的音訊檔案的 SAS URI。其中,關於 [SAS URI] 部分,想要取得這個服務端點,可以參考 如何使用與存取 Azure Blob Storage Service 這篇文章內的說明。

接著,程式碼中使用了 [HttpClient] 類別來與 Azure Speech API 進行 HTTP 通訊。首先,建立了一個新的 HttpClient 實例,並在其標頭中加入了 [Ocp-Apim-Subscription-Key] 的訂閱金鑰。宣告了 [createUrl] 這個服務端點字串,作為呼叫 Speech To Text 的 API 端點,其中,[ServiceRegion] 是前面宣告的區域字串,這個值會根據你在 Azure 入口網站中建立 Speech 資源時所選擇的區域而有所不同。接著要產生一個 [createBody] 的物件,這個物件包含了轉錄工作所需的參數,例如使用 [contentUrls] 做為要轉錄的音訊檔案 URL、[locale] 設定語言,這裡指定了 [zh-tw] 也就是要產生出繁體中文的文稿、[displayName] 是這個轉錄工作的顯示名稱,還有一些屬性設定,例如 [diarizationEnabled] 是否啟用說話人分離、[wordLevelTimestampsEnabled] 是否啟用逐字時間戳記、[punctuationMode] 標點符號的處理方式等等。然後,將這個物件序列化為 JSON 字串,並包裝成一個 [StringContent] 物件,最後呼叫 [PostAsync] 方法來發送 HTTP POST 請求以建立轉錄工作。

一旦這次的 API 呼叫成功,便可以透過 [Content.ReadAsStringAsync()] 方法來取得 API 回傳的結果,這個結果會包含一些關於轉錄工作的資訊,例如轉錄工作的 ID、狀態、以及一個 [self] URL,這個 URL 就是用來輪詢轉錄狀態的端點。透過 [Json.NET] 將取得的回應 JSON 內容,反序列化成為 [createJson] 這個型別為 [dynamic] 的物件,然後從中取出 [self] URL,並將它存放在 [transcriptionUrl] 這個字串變數中。這個 [transcriptionUrl] 就是後續用來輪詢轉錄狀態的 API 端點,透過不斷的呼叫這個 API,確認此次將語音文字轉換成為文字稿的過程是否已經完成。

這裡同樣的是使用 [HttpClient] 來發送 HTTP GET 請求,並在每次呼叫後,解析回傳的 JSON 內容,經過反序列化之後,取得這個 JSON 物件 [statusObj],透過該物件從中取出 [status] 欄位的值,來確認目前轉錄工作的狀態。如果狀態是 "Succeeded",就代表轉錄已經完成,可以進一步取得轉錄結果;如果狀態是 "Failed",則代表轉錄過程中發生了錯誤,需要進行錯誤處理。為了避免過於頻繁地呼叫 API,這裡使用了 [Task.Delay] 方法來讓程式在每次輪詢之間暫停幾秒鐘。

一旦 [status] 的值變成 "Succeeded",就表示轉換過程已經完成了,接著就可以透過 [filesUrl] 這個 API 端點來取得轉錄結果的相關資訊。這裡同樣是使用 [HttpClient] 發送 HTTP GET 請求,並解析回傳的 JSON 內容,從中找到轉錄結果的 URL,然後再發送一次 HTTP GET 請求來下載轉錄結果的 JSON 內容。最後,將這個轉錄結果的 JSON 字串反序列化成為 [TranscriptionJson] 這個物件,從中取出所有的 [display] 欄位內容,並將它們串接起來形成完整的轉錄文字,最後印出來。 




如何使用與存取 Azure Blob Storage Service

如何使用與存取 Azure Blob Storage Service

因為需要設計一個將錄音聲音檔案,轉換成為文字文稿的系統,所以需要一個地方來儲存這些聲音檔案。當然,最簡單的做法就是把它們放在本地磁碟裡,但這樣就無法享受到雲端儲存的彈性與便利性了。於是,我們選擇了 Azure Blob Storage 來作為我們的儲存解決方案。另外一個採用 Azure Blob Storage 的原因是,有些時候語音檔案太大了,為了避免陸續上船語音內容到雲端上,讓 STT (Speech To Text) 可以完成與順暢,因此,會先把語音檔案上傳到 Azure Blob Storage,然後再讓 STT 直接從 Azure Blob Storage 上讀取檔案來進行轉錄,這樣就能大幅提升整體的效率。

一旦將與音檔案上傳到 Azure Blob Storage 之後,就會取得該 Blog 物件與 SAS URI,接著就可以把這些資訊傳遞給 STT 來進行轉錄了。當然,除了上傳檔案之外,我們也可以使用 Azure Blob Storage 的 SDK 來進行其他的操作,例如:下載檔案、刪除檔案、列出容器中的檔案等等。這些功能都可以透過 Azure Blob Storage 的 SDK 來輕鬆實現。

首先要先來了解關於 Azure Blob Storage 的一些基本概念與架構,才能更順利地在程式碼中使用它的 SDK 來進行上傳、下載、管理等操作。

在現代雲端架構中,「儲存」早已不只是把檔案放進硬碟那麼單純。當系統需要面對大量影像檔、備份資料、日誌紀錄、AI 訓練素材,甚至醫療系統中的 DICOM 或 FHIR 匯出資料時,我們真正需要的是一種可水平擴充、高可用、低維運負擔的儲存解決方案。這時候就會遇到一個關鍵服務 —— Azure Blob Storage。

Azure Blob Storage 是 Microsoft Azure 雲端平台提供的物件儲存(Object Storage)服務,專門用來儲存例如非結構化資料(Unstructured Data)、大型檔案、二進位資料(Binary Large Object, Blob)等各種類型的檔案內容。所謂 Blob,其實是「Binary Large Object」的縮寫,意思是「大型二進位物件」。這類資料通常無法用傳統資料庫表格方式儲存與查詢,因此更適合放在物件儲存系統中。

在傳統架構裡,我們可能會使用:本地磁碟、NAS 檔案伺服器、SAN 儲存設備。但在雲端環境中,更推薦使用物件儲存架構,而 Azure Blob Storage 就是這類架構的代表性服務。

Azure Blob Storage 採用三層邏輯架構:

Storage Account
    └── Container
            └── Blob

Storage Account 是最上層單位,可以視為一個儲存帳戶邊界。在這個層級中,你可以管理:存取金鑰、存取控制(RBAC)、網路限制、備援策略。它就像是一個獨立的儲存資源空間。

Container 類似資料夾概念,但本質更接近物件儲存中的 Bucket。你可以依用途建立不同的 Container,例如:fhir-export、dicom-images、ai-training-data、system-logs、backup-archives 等等,來邏輯分類與管理不同類型的資料。每個 Container 都有自己的存取權限設定,可以是公開讀取、私有存取,或是透過 SAS Token 進行細粒度授權。

Blob 才是真正儲存的檔案內容,例如 report.pdf、image.png、dataset.ndjson、backup.zip 等。每一個 Blob 都是獨立的物件,透過 HTTP REST API 進行存取。Azure Blob Storage 提供三種 Blob 類型,依用途不同而選擇:Block Blob 是最常見的類型,適合儲存文件、影像、影片、備份檔與 FHIR 匯出資料等大型物件。大多數應用都會使用這種類型。、Append Blob 適合日誌寫入情境,例如系統 Log 需要持續追加資料。、Page Blob 則多用於虛擬機器磁碟(VHD),支援隨機讀寫操作。

Azure Blob Storage 是 Microsoft Azure 雲端平台提供的物件儲存服務,專門用來儲存例如非結構化資料(Unstructured Data)、大型檔案、二進位資料(Binary Large Object, Blob)等各類型內容,具備高擴充性、高可用性與彈性成本控制能力。如果用一句話總結:Azure Blob Storage 是雲端資料架構中的基礎儲存層,是現代 AI 與大型資料系統不可或缺的核心服務。

建立 主控台應用程式 專案

  • 開啟 Visual Studio 2026
  • 選擇「建立新專案」
  • 在 [建立新專案] 視窗中,在右方清單內,找到並選擇 [主控台應用程式] 項目
  • 然後點擊右下方「下一步」按鈕
  • 此時將會看到 [設定新的專案] 對話窗
  • 在該對話窗的 [專案名稱] 欄位中,輸入專案名稱,例如 "csAzureBlobStorage"
  • 然後點擊右下方「下一步」按鈕
  • 接著會看到 [其他資訊] 對話窗
  • 在這個對話窗內,確認使用底下的選項
    • 架構:.NET 10.0 (或更新版本)
    • 勾選 不要使用最上層陳述式 (這是我的個人習慣)
  • 然後點擊右下方「建立」按鈕
  • 現在,已經完成了這個 主控台應用程式 專案的建立

安裝 Azure.Storage.Blobs 套件

套件 [Azure.Storage.Blobs] 是 Microsoft 官方提供的用於與 Azure Blob Storage 進行互動的 SDK。透過這個套件,我們可以輕鬆地在 C# 程式碼中實現對 Azure Blob Storage 的各種操作,例如上傳、下載、刪除檔案,以及管理容器等。這裡將會列出這個套件提供的功能清單:

  • 上傳檔案到 Azure Blob Storage

  • 從 Azure Blob Storage 下載檔案

  • 刪除 Azure Blob Storage 中的檔案

  • 列出 Azure Blob Storage 中的檔案和容器

  • 管理 Azure Blob Storage 中的容器

  • 生成 Azure Blob Storage 的共享存取簽章 (SAS) URL

  • 在 Visual Studio 的「方案總管」視窗中,右鍵點擊專案名稱

  • 從右鍵選單中,選擇「管理 NuGet 套件」

  • 在 NuGet 套件管理器視窗中,切換到「瀏覽」標籤頁

  • 在搜尋框中,輸入 "Azure.Storage.Blobs" 並按下 Enter 鍵

  • 從搜尋結果中,找到 "Azure.Storage.Blobs" 套件 並點擊它

  • 在這裡的範例中,使用該套件的版本為 12.24.0

  • 在右側的詳細資訊面板中,點擊「安裝」按鈕

撰寫程式碼

  • 打開 Program.cs 檔案,並將其內容替換為以下程式碼:
using Azure.Storage.Blobs.Models;
using Azure.Storage.Blobs;
using Azure.Storage.Sas;

namespace csAzureBlobStorage;

internal class Program
{
    static async Task Main(string[] args)
    {
        string connectionString = Environment.GetEnvironmentVariable("AZURE_STORAGE_CONNECTION_STRING");
        string containerName = "audio-files";     // 要上傳到的 container
        string currentDirectory = Directory.GetCurrentDirectory();
        string localFilePath =Path.Combine( Directory.GetCurrentDirectory(), "250501_0814.mp3"); 
        string blobName = Path.GetFileName(localFilePath); // blob 名稱
        var blobServiceClient = new BlobServiceClient(connectionString);
        var containerClient = blobServiceClient.GetBlobContainerClient(containerName);
        await containerClient.CreateIfNotExistsAsync(PublicAccessType.None);
        var blobClient = containerClient.GetBlobClient(blobName);
        Console.WriteLine($"開始上傳 {localFilePath}{containerClient.Uri}/{blobName} ...");
        using FileStream uploadFileStream = File.OpenRead(localFilePath);
        var blobHttpHeaders = new BlobHttpHeaders { ContentType = "audio/mpeg" };
        await blobClient.UploadAsync( uploadFileStream, new BlobUploadOptions { HttpHeaders = blobHttpHeaders } );
        uploadFileStream.Close();
        Console.WriteLine("上傳完成!");
        var blobItemClient = containerClient.GetBlobClient(blobName);
        Uri blobUri = blobItemClient.Uri;
        Console.WriteLine(blobUri.ToString());
        var sasToken = blobItemClient.GenerateSasUri(BlobSasPermissions.Read, DateTimeOffset.UtcNow.AddHours(5));
        Console.WriteLine($"SAS URI: {sasToken}");
    }
}

在這個 Console 專案進入點方法中,首先宣告了五個字串,分別為 [connectionString] 其目的在為了儲存 Azure Blob Storage 的連線字串,這個連線字串會從環境變數中讀取,這樣就不需要把敏感資訊直接寫在程式碼裡面了。接著宣告了 [containerName] 這個字串變數,代表要上傳到 Azure Blob Storage 的哪一個 container。然後宣告了 [currentDirectory] 這個字串變數,透過 Directory.GetCurrentDirectory() 方法來取得當前的工作目錄路徑。接著宣告了 [localFilePath] 這個字串變數,將當前目錄與要上傳的檔案名稱結合成為完整的檔案路徑。最後宣告了 [blobName] 這個字串變數,從 localFilePath 中提取出檔案名稱作為 blob 的名稱。

接著,程式碼中建立了一個 [BlobServiceClient] 的實例,這個實例是用來與 Azure Blob Storage 進行互動的主要物件,這裡是透過這個 [BlobServiceClient]建構式與傳入 Azure Blog Storage 連線字串來建立此物件,接著使用了 [GetBlobContainerClient] 方法來取得一個 BlobContainerClient 的實例,這個實例代表了要操作的 container [containerClient]。然後呼叫了 [CreateIfNotExistsAsync] 方法,如果這個 container 不存在,則會自動建立一個新的 container。接著再透過 BlobContainerClient 來取得一個 BlobClient 的實例,這個實例代表了要操作的 blob。

之後,使用 [containerClient.GetBlobClient(blobName)] 來取得一個 BlobClient 的實例,這個實例代表了要操作的 blob。接著使用 [File.OpenRead(localFilePath)] 方法來開啟要上傳的檔案,並將它包裝成為一個 FileStream 的物件。然後建立了一個 [BlobHttpHeaders] 的物件,並設定 ContentType 為 "audio/mpeg",這樣在瀏覽器或其他客戶端讀取這個 blob 時,就能正確地識別它的內容類型。最後,呼叫了 [UploadAsync] 方法來將檔案上傳到 Azure Blob Storage 中,並傳入 BlobUploadOptions 來設定 HTTP 標頭。一旦上傳完成,就會關閉檔案流並印出上傳完成的訊息。

對於 [blobItemClient] 這個 BlobClient 實例,其主要的用途在於操作特定的 blob,例如取得其 URL 或生成 SAS URI。我們可以直接從它的 Uri 屬性來取得這個 blob 的 URL,然後印出來。接著,使用 [GenerateSasUri] 方法來生成一個帶有讀取權限的 SAS URI。

對於 SAS 這樣的物件,其目的在於提供一個 URI 可以讓其他人或服務在指定的時間內存取這個 blob,而不需要提供 Azure Blob Storage 的帳戶金鑰。最後,將生成的 SAS URI 印出來。