早先在錄製視訊的時候一直使用的是 obs-auto-subtitle 作為實時字幕展示功能。不過這個是以 OBS 外掛的形式存在,不管是語言和功能上都有一定的限制。故而使用 Blazor server 實現一個。
總體思路
- 實時字幕自然需要語音轉文字的功能。考察了一些服務之後,發現同時具備有一定免費額度和有 C# SDK 兩個條件的,就只有 Azure Cognitive Service 了。故而選擇了它。
- 使用 Blazor server 從服務端實時重新整理頁面到前端是非常簡單的事情。因此,渲染一個簡單的列表文字,然後通過 OBS 的 browser 元件接入畫面即可。
快樂編碼
有了基本的思路,我們就可以開始快樂的編碼了。
簡要設計
一般來說,語音轉文字服務是一個與服務端進行持續互動的過程。因此需要一個物件來保持和服務端之間的溝通。我們可以設計一個ILiveCaptioningProvider
來表示這種行為:
using System; using System.Threading.Tasks; namespace Newbe.LiveCaptioning.Services { public interface ILiveCaptioningProvider : IAsyncDisposable { Task StartAsync(); void AddCallBack(Func<CaptionItem, Task> captionCallBack); } }
為了擴充套件可能適配不同提供商的可能,我們同樣設計一個ILiveCaptioningProviderFactory
用於表現建立ILiveCaptioningProvider
的行為:
namespace Newbe.LiveCaptioning.Services { public interface ILiveCaptioningProviderFactory { ILiveCaptioningProvider Create(); } }
有了這樣兩個介面,在頁面上只要通過ILiveCaptioningProviderFactory
建立ILiveCaptioningProvider
,然後不斷的接收回撥展示在頁面上即可。
將內容展示在頁面上
有了基本的專案結構和介面,便可以嘗試將內容繫結到頁面上。要將實時轉換的內容展示到介面上需要進行一定的演算法轉換。
在此之前,我們需要確定一下頁面展示的預期:
- 在頁面上展示至少兩行文字
- 當一句話超過一行文字的寬度時自動進行換行
- 當一句話結束時,下一句話自動換行
例如,上面這句話進行連續閱讀時,可能會出現如下效果:
live caption display
主要需要注意的是,在判斷是要更新當前行還是進行換行,這部分邏輯需要注意進行處理。
填充實現
- 通過 Azure SDK 提供的
SpeechRecognizer
物件來進行語音識別 - 通過 Subject 將事件轉換為一個簡單的可觀測流,簡化業務回撥的處理
using System; using System.Collections.Generic; using System.Linq; using System.Reactive.Linq; using System.Reactive.Subjects; using System.Threading.Tasks; using Microsoft.CognitiveServices.Speech; using Microsoft.CognitiveServices.Speech.Audio; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Newbe.LiveCaptioning.Services { public class AzureLiveCaptioningProvider : ILiveCaptioningProvider { private readonly ILogger<AzureLiveCaptioningProvider> _logger; private readonly IOptions<LiveCaptionOptions> _options; private AudioConfig _audioConfig; private SpeechRecognizer _recognizer; private readonly List<Func<CaptionItem, Task>> _callbacks = new(); private Subject<CaptionItem> _sub; public AzureLiveCaptioningProvider( ILogger<AzureLiveCaptioningProvider> logger, IOptions<LiveCaptionOptions> options) { _logger = logger; _options = options; } public async Task StartAsync() { var azureProviderOptions = _options.Value.Azure; var speechConfig = SpeechConfig.FromSubscription(azureProviderOptions.Key, azureProviderOptions.Region); speechConfig.SpeechRecognitionLanguage = azureProviderOptions.Language; _audioConfig = AudioConfig.FromDefaultMicrophoneInput(); _recognizer = new SpeechRecognizer(speechConfig, _audioConfig); _sub = new Subject<CaptionItem>(); _sub .Select(item => Observable.FromAsync(async () => { try { await Task.WhenAll(_callbacks.Select(f => f.Invoke(item))); } catch (Exception e) { _logger.LogError(e, "failed to recognize"); } })) .Merge() .Subscribe(); _recognizer.Recognizing += (sender, args) => { _sub.OnNext(new CaptionItem { Text = args.Result.Text, LineEnd = false }); }; _recognizer.Recognized += (sender, args) => { _sub.OnNext(new CaptionItem { Text = args.Result.Text, LineEnd = true }); }; await _recognizer.StartContinuousRecognitionAsync(); } public void AddCallBack(Func<CaptionItem, Task> captionCallBack) { _callbacks.Add(captionCallBack); } public ValueTask DisposeAsync() { _recognizer?.Dispose(); _audioConfig?.Dispose(); _sub?.Dispose(); return ValueTask.CompletedTask; } } }
- 實現工廠的方式非常多,這裡採用 Autofac 來協助完成物件的建立
using Autofac; using Microsoft.Extensions.Options; namespace Newbe.LiveCaptioning.Services { public class LiveCaptioningProviderFactory : ILiveCaptioningProviderFactory { private readonly ILifetimeScope _lifetimeScope; private readonly IOptions<LiveCaptionOptions> _options; public LiveCaptioningProviderFactory( ILifetimeScope lifetimeScope, IOptions<LiveCaptionOptions> options) { _lifetimeScope = lifetimeScope; _options = options; } public ILiveCaptioningProvider Create() { var liveCaptionProviderType = _options.Value.Provider; switch (liveCaptionProviderType) { case LiveCaptionProviderType.Azure: var liveCaptioningProvider = _lifetimeScope.Resolve<AzureLiveCaptioningProvider>(); return liveCaptioningProvider; default: throw new ProviderNotFoundException(); } } } }
- 對頁面邏輯進行填充,完成效果
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging; using Newbe.LiveCaptioning.Services; namespace Newbe.LiveCaptioning.Pages { public partial class Index : IAsyncDisposable { [Inject] public ILiveCaptioningProviderFactory LiveCaptioningProviderFactory { get; set; } [Inject] public ILogger<Index> Logger { get; set; } private ILiveCaptioningProvider _liveCaptioningProvider; private readonly List<CaptionDisplayItem> _captionList = new(); protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); if (firstRender) { _liveCaptioningProvider = LiveCaptioningProviderFactory.Create(); _liveCaptioningProvider.AddCallBack(CaptionCallBack); await _liveCaptioningProvider.StartAsync(); } } private int maxCount = 20; private Task CaptionCallBack(CaptionItem arg) { return InvokeAsync(() => { Logger.LogDebug("Received: {Text}", arg.Text); var last = _captionList.FirstOrDefault(); var newLine = false; var text = arg.Text; var skipPage = 0; if (arg.Text.Length > maxCount) { skipPage = (int) Math.Floor(text.Length * 1.0 / maxCount); text = arg.Text[(skipPage * maxCount)..]; } if (last == null || skipPage > last.TagCount) { newLine = true; } if (newLine || _captionList.Count == 0) { _captionList.Insert(0, new CaptionDisplayItem { Text = text, TagCount = arg.LineEnd ? -1 : skipPage }); } else { _captionList[0].Text = text; if (arg.LineEnd) { _captionList[0].TagCount = -1; } } if (_captionList.Count > 4) { _captionList.RemoveRange(4, _captionList.Count - 4); } StateHasChanged(); }); } private record CaptionDisplayItem { public string Text { get; set; } public int TagCount { get; set; } } public async ValueTask DisposeAsync() { if (_liveCaptioningProvider != null) { await _liveCaptioningProvider.DisposeAsync(); } } } }
通過以上核心的程式碼,就可以完成從識別到展示相關的內容。
下載與安裝
在嘗試進行原始碼瞭解之前,你可以通過以下步驟來初步體驗一下專案的效果。
首先,你可以從 Release 頁面下載和你作業系統對應的版本:
https://github.com/newbe36524/Newbe.LiveCaptioning/releases
release
然後,將這個軟體包解壓到預先建立好的資料夾。
unzip
接著,在 Azure Portal 中建立一個 Cognitive Services。
提示 1:語音轉文字每個月有 5 個小時的免費額度,可以參見
提示 2:你可以通過這個幫助來建立一個免費的 Azure 賬號,新賬號包含有 12 個月的免費大禮包,參見
create service region and key
隨後,將生成好的 region 和 key 填入到 appsettings.Production.json
中。
記得同時修改 Language 選項,例如美式英語為 en-us,簡體中文為 zh-cn。你可以通過以下連結來檢視所有支援的語言:
update appsettings.Production.json
繼而,啟動 Newbe.LiveCaptioning.exe
,你可以看到如下這樣的提示資訊,就說明一切已經正常。
region and key
最後,你可以使用瀏覽器開啟http://localhost:5000
,並對著你的話筒說話,這樣便可以實時產生字幕了。
live caption
在 OBS 中加入字幕
首先,開啟你的 OBS,並新增一個 browser 元件。
add browser
在元件的 url 中填入 http://localhost:5000
,並設定一個合適的寬度和高度。
add browser
對著你的話筒話說,字幕就出來了。
test
輔助資料
Azure Speech to Text
可以通過以下連結在初步體驗一下識別的效果:
可以通過以下連結找到 C# SDK 的對接方案:
Blazor server
可以通過以下連結來了解,如何通過服務端來推送 UI 變化到前端:
可以通過以下連結來了解,如何在 UI 執行緒之外來出發 UI 變化(這不就是 winform 再現):
.Net core publish
通過這裡瞭解如何將 dotnet core 程式釋出為一個單檔案應用
https://docs.microsoft.com/dotnet/core/deploying/single-file?WT.mc_id=DX-MVP-5003606
瞭解不同作業系統下發布使用的 RID
https://docs.microsoft.com/dotnet/core/rid-catalog?WT.mc_id=DX-MVP-5003606
Github
瞭解如何通過 github action 打包釋出內容到 release 中:
https://github.com/gittools/gitreleasemanager
小結
這是一個非常簡單的專案應用,開發者可以通過該專案初步的瞭解 Blazor 的使用方法。你可以通過以下地址來獲取本專案的原始碼: