头图

微软MVP研究实验室 | 基于Blazor打造实时字幕

微软技术栈
English

大家好,我是本期的实验室研究员——俞坤。今天我将通过实验和完整的操作过程,向大家介绍如何基于 Blazor 打造一个能针对语音和视频中的声音,自动生成实时字幕的字幕系统。接下来就让我们一起到实验室中一探究竟吧!

微软MVP实验室研究员

image.png

思路浅析

很多童鞋可能接触过类似的技术,例如我们在录制视频时,可以使用 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,然后不断的接收回调展示在页面上即可。

二、将内容展示在页面上

有了基本的项目结构和接口,便可以尝试将内容绑定到页面上。要将实时转换的内容展示到界面上需要进行一定的算法转换。

在此之前,我们需要确定一下页面展示的预期:

  • 在页面上展示至少两行文本
  • 当一句话超过一行文本的宽度时自动进行换行
  • 当一句话结束时,下一句话自动换行

例如,上面这句话进行连续阅读时,可能会出现如下效果:

主要需要注意的是,在判断是要更新当前行还是进行换行,这部分逻辑需要注意进行处理。

三、填充现实

  1. 通过 Azure SDK 提供的 SpeechRecognizer 对象来进行语音识别。
  2. 通过 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;
        }
    }
}
  1. 实现工厂的方式非常多,这里采用 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();
            }
        }
    }
}
  1. 对页面逻辑进行填充,完成效果:
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();
            }
        }
    }
}

通过以上核心代码,即可完成从识别到展示相关的内容。

下载安装

在尝试了解源码前,大家可以通过以下步骤来初步体验一下项目效果。

  1. 从 Release 页面下载和操作系统对应的版本。
    image.png
  2. 将这个软件包解压到预先创建好的文件夹。
    image.png
  3. 在 Azure 管理门户中创建一个 Cognitive Services。

提示:语音转文字每个月有 5 小时的免费额度,可以参见此处。此外,大家可以通过这里创建一个免费的 Azure 账号,新账号包含有 12 个月的免费大礼包。

  1. 将生成好的 Region 和 Key 填入到 appsettings.Production.json 中。
    image.png
  2. 修改 Language 选项,例如美式英语为 en-us,简体中文为 zh-cn。大家可以点击这里查看所有支持的语言。
    image.png
  3. 启动 Newbe.LiveCaptioning.exe,如果看到如下所示信息,就说明一切已经正常。

  1. 最后,使用浏览器打开 http://localhost:5000,并对着话筒说话,这样便可以实时产生字幕了。

在 OBS 中加入字幕

  1. 打开 OBS,并添加一个 Browser 组件。
    image.png
  2. 组件的 URL 中填入 http://localhost:5000,并设置一个合适的宽度和高度。
    image.png
  3. 对着话筒话说,字幕就出来了。

总结

这是一个非常简单的项目应用,开发者可以通过该项目初步了解 Blazor 的使用方法。如需获取本项目的源代码,请点击这里。

此外对于上述实验中涉及到的各类技术和服务,大家可以通过下列资源链接进一步了解。

AzureSpeech to Text

1:初步体验Azure Speech的语音识别效果

2:C#SDK的对接方案

BlazorServer

1:如何通过服务端来推送UI变化到前端

:2:如何在UI线程之外来触发UI变化(其实也是Winform再现)

.Netcore publish

1:如何将dotnet core程序发布为一个单文件应用

2:不同操作系统下发布使用的RID

GitHub

1:如何通过GitHub Action打包发布内容到Release中

对于本次实验所涉及的内容,大家有任何问题或想法,或者对于我们这个实验室的后续探索方向有什么建议,都欢迎通过评论来留言。

当然,如果你有任何有趣的想法并且已经顺利实现了,也欢迎给我们投稿,将你的作品分享给更多开发者,让我们一起玩转软件开发,共同提高和进步!


微软MVP项目介绍

微软最有价值专家是微软公司授予第三方技术专业人士的一个全球奖项。28年来,世界各地的技术社区领导者,因其在线上和线下的技术社区中分享专业知识和经验而获得此奖项。

MVP是经过严格挑选的专家团队,他们代表着技术最精湛且最具智慧的人,是对社区投入极大的热情并乐于助人的专家。MVP致力于通过演讲、论坛问答、创建网站、撰写博客、分享视频、开源项目、组织会议等方式来帮助他人,并最大程度地帮助微软技术社区用户使用Microsoft技术。
更多详情请登录官方网站:
https://mvp.microsoft.com/zh-cn


文内链接请参考

OBS-auto-subtitle:

https://github.com/summershri...

Release页面:

https://github.com/newbe36524...

此处:

https://azure.microsoft.com/z...

这里:

https://docs.microsoft.com/en...

点击这里:

https://docs.microsoft.com/zh...

请点击这里:

https://github.com/newbe36524...

初步体验AzureSpeech的语音识别效果:

https://azure.microsoft.com/e...

C#SDK的对接方案:

https://docs.microsoft.com/zh...

如何通过服务端来推送UI变化到前端:

https://swimburger.net/blog/d...

如何在UI线程之外来触发UI变化:

https://docs.microsoft.com/zh...

如何将dotnet core程序发布为一个单文件应用:

https://docs.microsoft.com/zh...

不同操作系统下发布使用的RID:

https://docs.microsoft.com/zh...

如何通过GitHub Action打包发布内容到Release中:

https://github.com/gittools/g...


扫码关注微软MSDN,获取更多微软一手技术信息和官方学习资料!
image.png

阅读 564

微软技术生态官方平台。予力众生,成就不凡!微软致力于用技术改变世界,助力企业实现数字化转型。

176 声望
891 粉丝
0 条评论

微软技术生态官方平台。予力众生,成就不凡!微软致力于用技术改变世界,助力企业实现数字化转型。

176 声望
891 粉丝
文章目录
宣传栏