基於 Blazor 打造一款實時字幕

Newbe36524發表於2021-07-26

早先在錄製視訊的時候一直使用的是 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 displaylive 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

releaserelease

然後,將這個軟體包解壓到預先建立好的資料夾。

unzipunzip

接著,在 Azure Portal 中建立一個 Cognitive Services。

提示 1:語音轉文字每個月有 5 個小時的免費額度,可以參見

https://azure.microsoft.com/pricing/details/cognitive-services/speech-services/?WT.mc_id=DX-MVP-5003606

提示 2:你可以通過這個幫助來建立一個免費的 Azure 賬號,新賬號包含有 12 個月的免費大禮包,參見

https://docs.microsoft.com/en-us/dynamics-nav/how-to--sign-up-for-a-microsoft-azure-subscription?WT.mc_id=DX-MVP-5003606

create servicecreate service region and keyregion and key

隨後,將生成好的 region 和 key 填入到 appsettings.Production.json 中。

記得同時修改 Language 選項,例如美式英語為 en-us,簡體中文為 zh-cn。你可以通過以下連結來檢視所有支援的語言:

https://docs.microsoft.com/azure/cognitive-services/speech-service/language-support?WT.mc_id=DX-MVP-5003606

update appsettings.Production.jsonupdate appsettings.Production.json

繼而,啟動 Newbe.LiveCaptioning.exe,你可以看到如下這樣的提示資訊,就說明一切已經正常。

region and keyregion and key

最後,你可以使用瀏覽器開啟http://localhost:5000,並對著你的話筒說話,這樣便可以實時產生字幕了。

live captionlive caption

在 OBS 中加入字幕

首先,開啟你的 OBS,並新增一個 browser 元件。

add browseradd browser

在元件的 url 中填入 http://localhost:5000,並設定一個合適的寬度和高度。

add browseradd browser

對著你的話筒話說,字幕就出來了。

testtest

輔助資料

Azure Speech to Text

可以通過以下連結在初步體驗一下識別的效果:

https://azure.microsoft.com/services/cognitive-services/speech-to-text/?WT.mc_id=DX-MVP-5003606#overview

可以通過以下連結找到 C# SDK 的對接方案:

https://docs.microsoft.com/azure/cognitive-services/speech-service/get-started-speech-to-text?WT.mc_id=DX-MVP-5003606

Blazor server

可以通過以下連結來了解,如何通過服務端來推送 UI 變化到前端:

https://swimburger.net/blog/dotnet/pushing-ui-changes-from-blazor-server-to-browser-on-server-raised-events

可以通過以下連結來了解,如何在 UI 執行緒之外來出發 UI 變化(這不就是 winform 再現):

https://docs.microsoft.com/aspnet/core/blazor/components/rendering?view=aspnetcore-5.0&WT.mc_id=DX-MVP-5003606#receiving-a-call-from-something-external-to-the-blazor-rendering-and-event-handling-system

.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 的使用方法。你可以通過以下地址來獲取本專案的原始碼:

https://github.com/newbe36524/Newbe.LiveCaptioning

相關文章