跳到主要内容

· 阅读需 11 分钟

由于某些需要,我需要在前端页面进行录音,并传递到后端。使用前端进行音频录制时,会有一定的注意事项,因此在简要介绍使用前端录音的大概流程,并简要介绍需要注意的要点。

在浏览器上取得麦克风权限相关接口:MediaRecorder

其实,与很多人认知中的不同,浏览器其实能提供相当丰富的多媒体输入资源。但是与安装在设备的应用程序不同,每次使用这些多媒体设备,需要进行权限申请并得到用户授权,否则将无法使用那些接口。同时,浏览器的安全策略同样也可能会使得某些看似可用的功能在某些情况下不可用。

在这里,我使用原生的 MediaRecorder 进行音频录制。但是观察其构造函数,会注意到, 需要一个必选的stream 参数,这个就是将要用于录制的流。对于这个流,有多种取得方式

  1. navigator.mediaDevices.getUserMedia() 这个接口会返回一个 Promise,等待用户确认权限申请,并取得请求的MediaStream, 这个MediaStream可以作为MediaRecorder的构造函数中的stream
  2. DOM 元素 <canvas><audio><vedio>

而当 MediaRecorder 创建完毕后,接下来就比较容易了。 可以使用 start() 开始音频录制,使用 stop() 停止音频录制,然后提供事件 ondataavailable, onstop, onstart 等事件回调函数,看起来似乎一切其实并不复杂。

申请权限并录音

在这里,使用 vue + vuetify 作为框架,以能提供易于制作的前端页面。同时使用 typescript 编写,以保证能近似得到强类型语言的支持和安全感。

我使用的是 Vue 的组合式 API, 其中<template>部分如下

<div>
<v-container justify="center">
<v-col sm="6" md="4" cols="auto">
<v-btn @click="onQueryPermission">Request Audio Permission</v-btn>
</v-col>
<v-col sm="6" md="4" cols="auto">
<v-btn v-if="mediaRecorder != null" @click="onRecordSwitch">{{ inRecording ? "Stop" : "Start" }} Audio Recording</v-btn>
</v-col>
<audio v-if="audioURL != null" controls :src="audioURL"></audio>
</v-container>
</div>

其中,包含 2 个按钮和 1 个音频播放器。

  • 第一个按钮 Request Audio Permission 用于申请麦克风权限
  • 第二个按钮 Stop/Start Audio Recording 用于执行录音的开始和停止
  • 第三个播放器用于播放刚刚录制的音频内容

对于以上的 template 需要部分状态保持,如以下

import { ref } from "vue"

// 在未取得用户授权,没有相应的录音器实例存在
const mediaRecorder = ref<null | MediaRecorder>(null)
// 当前是否正在进行录音
const inRecording = ref(false)
// 录音结果的URL
const audioURL = ref<null | string>(null)
// 录音结果的原始Blob
const audioBlob = ref<null | Blob>(null)

最后就是各个按键的回调函数部分

  • onQueryPermission 向用户发起麦克风权限请求。如果用户许可,那就创建一个MediaRecorder 实例,以供后续使用。实现主要是为MediaRecorder实例添加 ondataavailable事件回调。在这个回调中,需要取得录音音频本体Blob 然后转换为audio/ogg 格式,最后生成 audioURL 供播放器使用
const onQueryPermission = () => {
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
let recorder = new MediaRecorder(stream)
recorder.ondataavailable = (ev) => {
const blob = new Blob([ev.data], { type: "audio/ogg; codecs=opus" })
var object_url = window.URL.createObjectURL(blob)
audioURL.value = object_url
audioBlob.value = blob

console.log(`audio record done URL ${audioURL.value}`)
}
mediaRecorder.value = recorder
})
console.log("media recorder create done")
}
  • onRecordSwitch 用户点击用于开始/停止录音。当没有在录音时,点击该按键会开始录音,并重置部分状态。当正在录音时,点击该按键会停止录音。
const onRecordSwitch = () => {
if (inRecording.value) {
mediaRecorder.value?.stop()
inRecording.value = false
} else {
audioBlob.value = null
audioURL.value = null
mediaRecorder.value?.start()
inRecording.value = true
}
console.log(`now record state is ${mediaRecorder.value?.state}`)
}

自此,基本版本的录音功能就已经完成了,但是还有些问题

Edge: 录音结果不含有音频长度信息

在将音频上传到后端进行进一步处理时会发现,当用户使用 Edge 浏览器时,音频文件不包含长度信息,使得 ffmepg 等工具无法进行处理。而当用户使用 FireFox 时,却不会出现 Edge 中的问题,因此需要对音频时长进行修复

好消息是已经有能够修复缺少音频长度的 package 可以直接使用了,那对原有代码进行修改即可对音频进行长度进行修复,那个 package 名为 fix-webm-duration

首先,现在我们的组件就需要添加一个记录录音开始时间的状态。并为MediaRecorder 提供onstartonstop 的事件回调函数并修改 ondataavailable 回调函数。

  • onstart: 记录开始录音时间
  • onstop: 记录停止录音时间,并对音频进行修复,然后生成相应的audioURL
  • ondataavailable: 将获得的音频Blob 放入 audioBlob 中,供后续处理
// 录音开始时间
const startAt = ref(Date.now());
...

recorder.onstart = () => {
startAt.value = Date.now();
};
recorder.onstop = async () => {
if (audioBlob.value != null) {
const now = Date.now();
const duration = now - startAt.value;
const blob = await fixWebmDuration(audioBlob.value, duration);
const blobOgg = new Blob([blob], { type: "audio/ogg; codecs=opus" });
audioURL.value = window.URL.createObjectURL(blob);
audioBlob.value = blobOgg;
console.log(`audio record done URL ${audioURL.value}`);
}
};
recorder.ondataavailable = (ev) => {
audioBlob.value = ev.data;
};

FireFox & Edge 当使用 HTTP 协议并且服务器 IP 不是本地回环时,媒体资源不可用

在跨设备调试中,常常会将服务部署到局域网的 IP 和端口上,便于其他设备进行连接查看效果。但是通常情况,在这样的调试方式下不会使用 SSL 证书,因此提供的只是 HTTP 协议的服务。但是不论是对于 Firefox 还是 Edge , 当与服务器之间不是使用 HTTPS 协议并且 服务器不是在 127.0.0.1 , 媒体资源将变得完全不可用。 虽然出于安全考虑,这是正确的,但是却对调试带来了一定的麻烦。

FireFox

当 Vue 项目在局域网内进行监听时,此时使用 FireFox 访问网站,试图请求麦克风权限时,会发现以下报错

Uncaught TypeError: navigator.mediaDevices is undefined

这是由于使用的是 HTTP 协议而不是 HTTPS,因此,媒体设备相关的权限被火狐限制了。

在火狐的地址栏输入 about:config 在确认后,进入配置界面。

  • 搜索并将以下配置项设置为true
    • media.devices.insecure.enabled
    • media.getusermedia.insecure.enabled
  • 搜索并将前端的服务地址(如 http://192.168.56.1:5173/)添加到如下配置项中
    • security.tls.insecure_fallback_hosts

执行完成以上操作后,重启浏览器。会发现可以申请麦克风权限了

Edge

当 Vue 项目在局域网内进行监听时,此时使用 Edge(或其他采用 Chromium 内核的浏览器) 访问网站,试图请求麦克风权限时,会发现以下报错

Uncaught TypeError: Cannot read properties of undefined (reading 'getUserMedia')

原因与火狐类似,这里提供解决方法

在 Edge 地址栏输入 edge://flags,进入 flags 配置界面

找到配置项 Insecure origins treated as secure

  • 将前端的服务地址(如 http://192.168.56.1:5173/)添加到文本框中
  • 将这个配置项从 disable 切换为 enable

完成后,Edge 会提醒重启浏览器。重启浏览器后,就能申请麦克风权限了

完整代码

<template>
<div>
<v-container justify="center">
<v-col sm="6" md="4" cols="auto">
<v-btn @click="onQueryPermission">Request Audio Permission</v-btn>
</v-col>
<v-col sm="6" md="4" cols="auto">
<v-btn :disabled="mediaRecorder == null" @click="onRecordSwitch">{{ inRecording ? "Stop" : "Start" }} Audio Recording</v-btn>
</v-col>
<v-col sm="6" md="4" cols="auto">
<v-btn :disabled="audioURL == null">Upload</v-btn>
</v-col>
<audio v-if="audioURL != null" controls :src="audioURL"></audio>
</v-container>
</div>
</template>

<script setup lang="ts">
import fixWebmDuration from "fix-webm-duration"
import { ref } from "vue"

// 在未取得用户授权,没有相应的录音器实例存在
const mediaRecorder = ref<null | MediaRecorder>(null)
// 当前是否正在进行录音
const inRecording = ref(false)
// 录音结果的URL
const audioURL = ref<null | string>(null)
// 录音结果的原始Blob
const audioBlob = ref<null | Blob>(null)
// 录音开始时间
const startAt = ref(Date.now())

const onQueryPermission = () => {
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
let recorder = new MediaRecorder(stream)

recorder.onstart = () => {
startAt.value = Date.now()
}
recorder.onstop = async () => {
if (audioBlob.value != null) {
const now = Date.now()
const duration = now - startAt.value
const blob = await fixWebmDuration(audioBlob.value, duration)
const blobOgg = new Blob([blob], { type: "audio/ogg; codecs=opus" })
audioURL.value = window.URL.createObjectURL(blob)
audioBlob.value = blobOgg
console.log(`audio record done URL ${audioURL.value}`)
}
}

recorder.ondataavailable = (ev) => {
audioBlob.value = ev.data
}
mediaRecorder.value = recorder
})
console.log("media recorder create done")
}

const onRecordSwitch = () => {
if (inRecording.value) {
mediaRecorder.value?.stop()
inRecording.value = false
} else {
audioBlob.value = null
audioURL.value = null
mediaRecorder.value?.start()
inRecording.value = true
}
console.log(`now record state is ${mediaRecorder.value?.state}`)
}
</script>

· 阅读需 3 分钟
洛梧藤

安装kubectl(k8s客户端)

提示

如果已经安装,直接跳转到连接线上k8s

Windows安装与配置

  • 下载kubectl

    • curl.exe -LO "https://dl.k8s.io/release/v1.27.3/bin/windows/amd64/kubectl.exe"
    • 或者点击 kubectl v1.27.3下载
      提示

      可通过链接 版本列表 查看最新版本kubectl下载

  • 配置kubectl

    • 通过打开文件资源管理器 -> 右键此电脑 -> 属性 -> 高级系统设置 -> 环境变量打开环境变量
    • 点击新建,变量名输入KUBECTL_HOME,变量值添加kubectl下载路径的父文件夹 新建环境变量
    • 添加%KUBECTL_HOME%Path环境变量中 创建kubectl home环境变量
    • 打开Terminal\Powershell\CMD, 输入kubectl version检查是否配置成功 检查kubectl-version配置

连接线上k8s

  • C:\Users\<username>文件夹中,添加.kube文件夹
  • 添加线上k8s连接配置的yaml文件到文件夹下,如果有多个k8s服务,可在.kube文件夹中分文件夹,再将yaml文件存于其底下 添加k8s连接配置
  • 如上述所说,再次进入环境变量
  • 点击新建,变量名输入KUBECONFIG,变量值输入yaml文件完整路径 创建KUBECONFIG环境变量
  • 打开Terminal\Powershell\CMD, 输入kubectl get svc检查是否配置成功 检查连接配置

利用端口转发,连接线上服务

  • 打开终端

  • 输入kubectl get namespace, 获取所有的namespace 获取k8s中namespace

  • 利用前一个步骤获取的namespace, 输入kubectl get svc -n <namespace>,获取指定namespace下的服务的名字与端口 获取k8s服务中namespace中名字与端口

  • 输入kubectl port-forward -n <namespace> svc/<service-name> <loacl-port>:<online-port>将其转发到本地端口上

    • 如成功,将看到类似效果

      Forwarding from 127.0.0.1:7000 -> 6379
      Forwarding from [::1]:7000 -> 6379
  • 这时候你就可以通过127.0.0.1:<local-port>来访问服务了

· 阅读需 1 分钟
洛梧藤

github生成token

  • 点击 github token 进入token界面
  • 点击 Generate new token -> Generate new token(classic) 创建新的token 生成新token
  • 根据图片配置生成token github token配置 生成github token
  • 点击复制新生成的token 复制token

将token添加到npm配置

  • 获取npm配置文件位置 npm配置路径

  • 添加token到npm配置

    //npm.pkg.github.com/:_authToken=<TOKEN>
    <组织名称,如@enraged-dun-cookie-development-team>:registry=https://npm.pkg.github.com

    添加token到npm配置

引入npm包

  • 在项目package.json文件中引入npm包

· 阅读需 10 分钟

在实践中,由于某些异常(比如死锁)会使得异步任务长时间处于挂起状态。因此,让异步任务能够在每隔固定时间报告一次当前任务累计运行时间,以迅速找到可能的出现异常的异步任务位置的功能支持就变得似乎很有必要。

因此,本次的目标就是希望制作一个 Future 类型,能够支持在指定时间之后定时报告任务用时或者其他信息。即 TimeUsageRecordFuture

功能分析

对于这个任务,其实功能需求非常简单。根据需求,就可以反推具体的数据结构

  1. 我们希望这个 Future 可以通用,即我们可以将任意的其他 Future 类型放入其中作为要报告用时的任务
    • 需要使用泛型参数,接受任意的 Future 类型
  2. 我们希望这个 Future 可以定时执行某些任务
    • 需要一个 定时器 , 以能够定时执行任务
    • 需要另一个泛型参数,以接受 执行什么任务? 信息
  3. 需要知道从任务开始后过了多长时间
    • 需要一个 时间 记录启动时间,并且是启动时才写入的

同样的,根据需求,也可以推导 Future 的实现。 可以很明显知道,Future 中有 3 种状态

  1. 被包裹的 Future 完成:此时,不需要理会定时器状态,直接响应 Ready(完成)
  2. 被包裹的 Future 正在进行,定时器到达定时点:此时,需要重置定时器状态,并执行指定任务,然后响应 Pending(正忙)
  3. 被包裹的 Future 和定时器 均正在进行:此时,直接响应 Pending(正忙)

基于以上的功能要求,我们就能制作出我们需要的 Future 类型了

依赖准备

根据功能分析,我们需要以下的额外内容

  • tokio 并开启 time features

为了方便实现 Future , 可以选择添加以下依赖

  • pin_project

    这个 crate 提供一个过程宏,以将较大粒度的 Pin (Pin 住整个结构体),转化为较小粒度的 Pin (只 Pin 某几个特定 field)

以下是依赖在 Cargo.toml 里面的样子

[dependencies]
pin-project = "1.1.0"
tokio = { version = "1.28.2", features = ["time"] }

代码实现

根据前置的功能分析,我们可以轻易定义出我们的 TimeUsageRecordFuture

#[pin_project]
pub struct TimeUsageRecordFuture<Fut, Recorder> {
#[pin]
fut: Fut,
timer: Interval,
recorder: Recorder,
start_at: Option<Instant>,
}

其中

  • fut: Fut 是被包裹的Future, 被标注为 pin, 也就是降低粒度后依然是被Pin 包裹的
  • timer: Interval 是 定时器
  • recorder: Recorder 是每次时间到后需要执行的任务
  • start_at: Option<Instant> 任务开始时间,当 None 时,任务未开始, Some 时任务已经开始

为什么没有在结构体声明中进行泛型约束?

在很多 Rust 最佳实践中,都是推荐在 impl 代码块中在需要的时候再添加泛型约束。这样可以一定程度添加灵活性。但社区同样也有人认为将约束添加在类型声明中,可以避免某些约束缺失带来的奇怪的编译器报错。

接下来,就是 Future 的实现了。基于前面的分析,我们的Future状态机需要维护 3 种状态的响应

Future::poll(Pin<&mut Self>, &mut Context<'_>) -> Poll<Future::Output>Future 的核心函数,在该异步任务开始后,异步运行时会执行该函数,如果该函数返回 Poll::Ready 那这个异步任务就完成了。否则,异步运行时会将该任务放入等待队列中。当这个异步任务准备好进入下一个状态时,会调用从 Context<'_> 中获得的 wake 函数,以告知异步运行时。此时异步运行时将会把对应的异步任务加入就绪队列,等待执行(调用poll

以下为具体代码

impl<Fut, Recorder> Future for TimeUsageRecordFuture<Fut, Recorder>
where
Fut: Future,
Recorder: FnMut(Duration),
{
type Output = Fut::Output;

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> std::task::Poll<Self::Output> {
let this = self.project();
match (this.fut.poll(cx), this.timer.poll_tick(cx)) {
(ret @ Poll::Ready(_), _) => ret,
(Poll::Pending, Poll::Ready(current_time)) => {
this.timer.reset();
if let Some(start_time) = *this.start_at {
let duration = current_time.duration_since(start_time);
(this.recorder)(duration)
} else {
let _ =this.start_at.insert(current_time);
}
Poll::Pending
}
(Poll::Pending, Poll::Pending) => Poll::Pending,
}
}
}

由于使用了pin-project 这里可以隐藏全部的unsafe代码,只要使用 self.project()就能获得降低粒度版本的this

而实现的主要内容,就是根据 Fut::pollInterval::poll_tick 的返回结果,来进行相应的操作。具体操作与先前功能分析一致

在计时器完成后,有一部分特殊的代码,这是为了用来处理第一次进入时,Interval 会立即返回(Ready)这样在任务开始时可以记录下开始的时间点(None -> Some(Instant)

当然别忘记,在实现 Future 时,我们需要要求 Fut 泛型参数实现 FutureRecorder 泛型参数实现 FnMut(Duration)

周边辅助

对于刚刚定义的 TimeUsageRecordFuture 显然我们不希望用户能够随意构造(如果用户构造时直接为 start_at 给定了具体时间点,将会产生错误的任务时长记录)。因此,我们可以提供一个 new 构造函数,以正确地初始化我们的 Future

impl<Fut, Recorder> TimeUsageRecordFuture<Fut, Recorder> {
pub fn new(fut: Fut, recorder: Recorder, period: Duration) -> Self
where
Fut: Future,
Recorder: FnMut(Duration),
{
Self {
fut,
timer: interval(period),
recorder,
start_at: None,
}
}
}

只要以上简单的代码,就能进行 TimeUsageRecordFuture , 并且能够避免用户错误的构造带来的错误行为。

但是,new 是关联函数,对于畅快的链式调用就像翠翠连续浓密的腿毛突然断开了,是相当不舒服的,是否能够将构造加入链式调用呢? 当然!

我们需要一个 trait 就叫 IntoTimeUsageRecordFuture吧 , 对于任意 Future 实现这个 trait, 这样就能在链式调用中直接使用其中的接口,进行如同顺着水水的毛吸猫一样舒适的链式调用。就像下面这样

pub trait IntoTimeUsageRecordFuture: Future + Sized {
fn time_usage_record<Func>(
self,
recorder: Func,
period: Duration,
) -> TimeUsageRecordFuture<Self, Func>
where
Func: FnMut(Duration),
{
TimeUsageRecordFuture::new(self, recorder, period)
}
}

impl<F> IntoTimeUsageRecordFuture for F where F: Future + Sized {}

简单测试

为了测试代码是否能够如预期运行,我简易编写了个单元测试, 如下

#[tokio::test]
async fn test() {
sleep(Duration::from_secs(1))
.time_usage_record(
|usage| println!("using time {}ms", usage.as_millis()),
Duration::from_millis(100),
)
.await
}

以下是单元测试的输出

running 1 test
using time 114ms
using time 222ms
using time 332ms
using time 442ms
using time 551ms
using time 660ms
using time 770ms
using time 879ms
using time 988ms
test test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.02s

虽然有一定误差,但是可以看到我们的代码正如我们预期运行!

单元测试需要启用 tokiotest-utilmacros features

完整代码

以下为完整的代码

use std::{
future::Future,
pin::Pin,
task::{Context, Poll},
time::Duration,
};

use pin_project::pin_project;
use tokio::time::{interval, sleep, Instant, Interval};

#[pin_project]
pub struct TimeUsageRecordFuture<Fut, Recorder> {
#[pin]
fut: Fut,
timer: Interval,
recorder: Recorder,
start_at: Option<Instant>,
}

impl<Fut, Recorder> TimeUsageRecordFuture<Fut, Recorder> {
pub fn new(fut: Fut, recorder: Recorder, period: Duration) -> Self
where
Fut: Future,
Recorder: FnMut(Duration),
{
Self {
fut,
timer: interval(period),
recorder,
start_at: None,
}
}
}

impl<Fut, Recorder> Future for TimeUsageRecordFuture<Fut, Recorder>
where
Fut: Future,
Recorder: FnMut(Duration),
{
type Output = Fut::Output;

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> std::task::Poll<Self::Output> {
let this = self.project();
match (this.fut.poll(cx), this.timer.poll_tick(cx)) {
(ret @ Poll::Ready(_), _) => ret,
(Poll::Pending, Poll::Ready(current_time)) => {
this.timer.reset();
if let Some(start_time) = *this.start_at {
let duration = current_time.duration_since(start_time);
(this.recorder)(duration)
} else {
let _ = this.start_at.insert(current_time);
}
Poll::Pending
}
(Poll::Pending, Poll::Pending) => Poll::Pending,
}
}
}

pub trait IntoTimeUsageRecordFuture: Future + Sized {
fn time_usage_record<Func>(
self,
recorder: Func,
period: Duration,
) -> TimeUsageRecordFuture<Self, Func>
where
Func: FnMut(Duration),
{
TimeUsageRecordFuture::new(self, recorder, period)
}
}

impl<F> IntoTimeUsageRecordFuture for F where F: Future + Sized {}

#[tokio::test]
async fn test() {
sleep(Duration::from_secs(1))
.time_usage_record(
|usage| println!("using time {}ms", usage.as_millis()),
Duration::from_millis(100),
)
.await
}