vscode语音注释, 让信息更丰富 (下)
前言
这个系列的最后一篇, 主要讲述录制音频&音频文件存储相关知识, 当时因为录音有bug搞得我一周没心情吃饭(voice-annotation)。
一、MP3
文件储存位置
"语音注释"使用场景
- 单个项目使用"语音注释"。
- 多个项目使用"语音注释"。
- "语音注释"生成的
mp3
文件都放在自己项目中。 - "语音注释"生成的
mp3
文件统一存放在全局的某处。 - "语音注释"生成的
mp3
一部分存在项目中一部分使用全局路径。
vscode 工作区
具体音频储存在哪里肯定要读取用户的配置, 但如果用户只在全局配置了一个路径, 那么这个路径无法满足每个项目存放音频文件的位置不同的场景, 这时候就引出了vscode 工作区
的概念。
假如我们每个工程的eslint
规则各不相同, 此时我们只在全局配置eslint
规则就无法满足这个场景了, 此时我们需要在项目中新建一个.vscode
文件夹, 在其中建立settings.json
文件, 在这个文件内编写的配置就是针对当前项目的个性化配置了。
配置工作区 (绝对路径 or
相对路径)
虽然懂了工作区的概念, 但是还不能解决实际上的问题, 比如我们在工作区配置音频文件的绝对路径
, 那么.vscode > settings.json
文件是要上传到代码仓库的, 所以配置会被所有人拉到, 每个开发者的电脑系统可能不一样, 存放项目的文件夹位置也不一样, 所以在工作区定义绝对路径
不能解决团队协作问题。
假若用户配置了相对路径
, 并且这个路径是相对于当前的settings.json
文件自身的, 那么问题变成了如何知道settings.json
文件到底在哪? vscode插件
内部虽然可以读取到工作区的配置信息, 但是读不到settings.json
文件的位置。
settings.json
文件寻踪
我最开始想过每次录音结束后, 让用户手动选择一个存放音频文件的位置, 但显然这个方式在操作上不够简洁, 在一次跑步的时候我突然想到, 其实用户想要录制音频的时候肯定要点击某处触发录音功能, vscode
内提供了方法去获取用户触发命令时所在文件的位置。
那我就以用户触发命令的文件位置为启点, 进行逐级的搜寻.vscode
文件, 比如获取到用户在/xxx1/xxx2/xxx3.js
文件内部点击了录制音频注释
, 则我就先判断/xxx1/xxx2/.vscode
是否为文件夹, 如果不是则判断/xxx1/.vscode
是否为文件夹, 依次类推直到找到.vscode
文件夹的位置, 如果没找到则报错。
音频文件夹路径的校验
使用settings.json
文件的位置加上用户配置的相对路径
, 则可得出真正的音频储存位置, 此时也不能松懈需要检验一下得到的文件夹路径是否真的有文件夹, 这里并不会主动为用户创建文件夹。
此时还有可能出问题, 如果当前有个a项目
内部套了个b项目
, 但是想要在b项目
里录制音频, 可是b项目
内未设置.vscode 工作区文件夹
, 但是a项目里有.vscode > settings.json
, 那么此时会导致将b项目
的录音文件储存到a项目
中。
上述问题没法准确的检验出用户的真实目标路径, 那我想到的办法是录制音频页面内预展示出将要保存到的路径, 让用户来做最后的守门人:
当前插件简易用户配置:
{
"voiceAnnotation": {
"dirPath": "../mp3"
}
}
二、配置的定义
如果用户不想把音频文件储存在项目内, 怕自己的项目变大起来, 那我们支持单独做一个音频存放的项目, 此时就需要在全局配置一个绝对路径
, 因为全局的配置不会同步给其他开发者, 当我们获取不到用户在vscode工作区
定义的音频路径时, 我们就取全局路径的值, 下面我们就一起配置一下全局的属性:
package.json
新增全局配置设定:
"contributes":
"configuration": {
"type": "object",
"title": "语音注释配置",
"properties": {
"voiceAnnotation.globalDirPath": {
"type": "string",
"default": "",
"description": "语音注释文件的'绝对路径' (优先级低于工作空间的voiceAnnotation.dirPath)。"
},
"voiceAnnotation.serverProt": {
"type": "number",
"default": 8830,
"description": "默认值为8830"
}
}
}
},
具体每个属性的意义可以参考配置后的效果图:
三、获取音频文件夹位置的方法
util/index.ts
(下面有具体的方法解析):
export function getVoiceAnnotationDirPath() {
const activeFilePath: string = vscode.window.activeTextEditor?.document?.fileName ?? "";
const voiceAnnotationDirPath: string = vscode.workspace.getConfiguration().get("voiceAnnotation.dirPath") || "";
const workspaceFilePathArr = activeFilePath.split(path.sep)
let targetPath = "";
for (let i = workspaceFilePathArr.length - 1; i > 0; i--) {
try {
const itemPath = `${path.sep}${workspaceFilePathArr.slice(1, i).join(path.sep)}${path.sep}.vscode`;
fs.statSync(itemPath).isDirectory();
targetPath = itemPath;
break
} catch (_) { }
}
if (voiceAnnotationDirPath && targetPath) {
return path.resolve(targetPath, voiceAnnotationDirPath)
} else {
const globalDirPath = vscode.workspace
.getConfiguration()
.get("voiceAnnotation.globalDirPath");
if (globalDirPath) {
return globalDirPath as string
} else {
getVoiceAnnotationDirPathErr()
}
}
}
function getVoiceAnnotationDirPathErr() {
vscode.window.showErrorMessage(`请于 .vscode/setting.json 内设置
"voiceAnnotation": {
"dirPath": "音频文件夹的相对路径"
}`)
}
逐句解析
1: 获取激活位置
vscode.window.activeTextEditor?.document?.fileName
上述方法可以获取到你当前触发命令所在的文件位置, 例如你在a.js
内部点击右键, 在菜单中点击了某个选项, 此时使用上述方法就会获取到a.js
文件的绝对路径
, 当然不只是操作菜单, 所有命令包括hover
某段文字都可以调用这个方法获取文件位置。
2: 获取配置项
vscode.workspace.getConfiguration().get("voiceAnnotation.dirPath") || "";
vscode.workspace.getConfiguration().get("voiceAnnotation.globalDirPath");
上述方法不仅可以获取项目中.vscode > settings.json
文件的配置, 并且也是获取全局配置的方法, 所以我们要做好区分才能去使用哪个, 所以这里我命名为dirPath
与globalDirPath
。
3: 文件路径分割符
/xxx/xx/x.js
其中的 "/" 就是path.sep
, 因为mac或者window等系统里面是有差异的, 这里使用path.sep
是为了兼容其他系统的用户。
4: 报错
相对路径与绝对路径都获取不到就抛出报错:
vscode.window.showErrorMessage(错误信息)
5: 使用
第一是用在server保存音频时, 第二是打开web页面时会传递给前端用户显示保存路径。
四、录音初始知识
没使用过录音功能的同学你可能没见过navigator.mediaDevices
这个方法, 返回一个MediaDevices
对象,该对象可提供对相机和麦克风等媒体输入设备的连接访问,也包括屏幕共享。
录制音频需要先获取用户的许可, navigator.mediaDevices.getUserMedia
就是在获取用户许可成功并且设备可用时走成功回调。
navigator.mediaDevices.getUserMedia({audio:true})
.then((stream)=>{
// 因为我们输入的是{audio:true}, 则stream是音频的内容流
})
.carch((err)=>{
})
五、初始化录音设备与配置
下面展示的是定义播放标签以及环境的'初始化', 老样子先上代码, 然你后逐句解释:
<header>
<audio id="audio" controls></audio>
<audio id="replayAudio" controls></audio>
</header>
let audioCtx = {}
let processor;
let userMediStream;
navigator.mediaDevices.getUserMedia({ audio: true })
.then(function (stream) {
userMediStream = stream;
audio.srcObject = stream;
audio.onloadedmetadata = function (e) {
audio.muted = true;
};
})
.catch(function (err) {
console.log(err);
});
1: 发现有趣的事, 直接用id获取元素
2: 保存音频的内容流
这里将媒体源保存在全局变量上, 方便后续重播声音:
userMediStream = stream;
srcObject
属性指定<audio>标签
关联的'媒体源':
audio.srcObject = stream;
3: 监听数据变化
当载入完成时设置 audio.muted = true;
, 将设备静音处理, 录制音频为啥还要静音? 其实是因为录音的时候不需要同时播放我们的声音, 这会导致"回音"很重, 所以这里需要静音。
audio.onloadedmetadata = function (e) {
audio.muted = true;
};
六、开始录音
先为'开始录制'按钮添加点击事件:
const oAudio = document.getElementById("audio");
let buffer = [];
oStartBt.addEventListener("click", function () {
oAudio.srcObject = userMediStream;
oAudio.play();
buffer = [];
const options = {
mimeType: "audio/webm"
};
mediaRecorder = new MediaRecorder(userMediStream, options);
mediaRecorder.ondataavailable = handleDataAvailable;
mediaRecorder.start(10);
});
处理获取到的音频数据
function handleDataAvailable(e) {
if (e && e.data && e.data.size > 0) {
buffer.push(e.data);
}
}
oAudio.srcObject
定义了播放标签的'媒体源'。oAudio.play();
开始播放, 这里由于我们设置了muted = true
静音, 所以这里就是开始录音。buffer
是用来储存音频数据的, 每次录制需要清空一下上次的残留。new MediaRecorder
创建了一个对指定的 MediaStream 进行录制的 MediaRecorder 对象, 也就是说这个方法就是为了录制功能而存在的, 它的第二个参数可以输入指定的mimeType
类型, 具体的类型我在MDN上查了一下。mediaRecorder.ondataavailable
定义了针对每段音频数据的具体处理逻辑。mediaRecorder.start(10);
对音频进行10毫秒一切片, 音频信息是储存在Blob里的, 这里的配置我理解是每10毫秒生成一个Blob对象。
此时数组buffer
里面就可以持续不断的收集到我们的音频信息了, 至此我们完成了录音功能, 接下来我们要丰富它的功能了。
七、结束, 重播, 重录
1: 结束录音
录音当然要有个尽头了, 有同学提出是否需要限制音频的长短或大小? 但我感觉具体的限制规则还是每个团队自己来定制吧, 这一版我这边只提供核心功能。
const oEndBt = document.getElementById("endBt");
oEndBt.addEventListener("click", function () {
oAudio.pause();
oAudio.srcObject = null;
});
- 点击
录制结束
按钮,oAudio.pause()
停止标签播放。 oAudio.srcObject = null;
切断媒体源, 这样这个标签无法继续获得音频数据了。
2: 重播录音
录好的音频当然也要会听一遍效果才行啦:
const oReplayBt = document.getElementById("replayBt");
const oReplayAudio = document.getElementById("replayAudio");
oReplayBt.addEventListener("click", function () {
let blob = new Blob(buffer, { type: "audio/webm" });
oReplayAudio.src = window.URL.createObjectURL(blob);
oReplayAudio.play();
});
Blob
一种数据的储存形式, 我们实现纯前端生成excel
就是使用了blob
, 可以简单理解为第一个参数是文件的数据, 第二个参数可以定义文件的类型。window.URL.createObjectURL
参数是'资源数据', 此方法生成一串url
, 通过url
可以访问到传入的'资源数据', 需要注意生成的url
是短暂的就会失效无法访问。oReplayAudio.src
为播放器指定播放地址, 由于不用录音所以就不用指定srcObject
了。oReplayAudio.play();
开始播放。
3: 重新录制音频
录制的不好当然要重新录制了, 最早我还想兼容暂停与续录, 但是感觉这些能力有些片离核心, 预计应该很少出现很长的语音注释, 这里就直接暴力刷页面了。
const oResetBt = document.getElementById("resetBt");
oResetBt.addEventListener("click", function () {
location.reload();
});
八、转换格式
获取到的音频文件直接使用node
进行播放可能是播放失败的, 虽然这种单纯的音频数据流文件可以被浏览器识别, 为了消除不同浏览器与不同操作系统的差异,保险起见我们需要将其转换成标准的mp3音频格式。
MP3是一种有损音乐格式,而WAV则是一种无损音乐格式。其实两者的区别非常明显,前者是以牺牲音乐的质量来换取更小的文件体积,后者却是尽最大限度保证音乐的质量。这也就导致两者的用途不同,MP3一般是用于我们普通用户听歌,而WAV文件通常用于录音室录音和专业音频项目。
这里我选择的是lamejs
这款插件, 插件的 github地址在这里。
lamejs是一个用JS重写的mp3编码器, 简单理解就是它可以产出标准的mp3
编码格式。
在初始化逻辑里面新增一些初始逻辑:
let audioCtx = {};
let processor;
let source;
let userMediStream;
navigator.mediaDevices
.getUserMedia({ audio: true })
.then(function (stream) {
userMediStream = stream;
audio.srcObject = stream;
audio.onloadedmetadata = function (e) {
audio.muted = true;
};
audioCtx = new AudioContext(); // 新增
source = audioCtx.createMediaStreamSource(stream); // 新增
processor = audioCtx.createScriptProcessor(0, 1, 1); // 新增
processor.onaudioprocess = function (e) { // 新增
const array = e.inputBuffer.getChannelData(0);
encode(array);
};
})
.catch(function (err) {
console.log(err);
});
new AudioContext()
音频处理的上下文, 对音频的操作基本都会在这个类型里面进行。audioCtx.createMediaStreamSource(stream)
创建音频接口有点抽象。audioCtx.createScriptProcessor(0, 1, 1)
这里创建了一个用于JavaScript直接处理音频的对象, 也就是创建了这个才能用js操作音频数据,三个参数分别为'缓冲区大小','输入声道数','输出声道数'。processor.onaudioprocess
监听新数据的处理方法。encode
处理音频并返回一个float32Array
数组。
下面代码是参考网上其他人的代码, 具体效果就是完成了lamejs
的转换工作:
let mp3Encoder,
maxSamples = 1152,
samplesMono,
lame,
config,
dataBuffer;
const clearBuffer = function () {
dataBuffer = [];
};
const appendToBuffer = function (mp3Buf) {
dataBuffer.push(new Int8Array(mp3Buf));
};
const init = function (prefConfig) {
config = prefConfig || {};
lame = new lamejs();
mp3Encoder = new lame.Mp3Encoder(
1,
config.sampleRate || 44100,
config.bitRate || 128
);
clearBuffer();
};
init();
const floatTo16BitPCM = function (input, output) {
for (let i = 0; i < input.length; i++) {
let s = Math.max(-1, Math.min(1, input[i]));
output[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
};
const convertBuffer = function (arrayBuffer) {
let data = new Float32Array(arrayBuffer);
let out = new Int16Array(arrayBuffer.length);
floatTo16BitPCM(data, out);
return out;
};
const encode = function (arrayBuffer) {
samplesMono = convertBuffer(arrayBuffer);
let remaining = samplesMono.length;
for (let i = 0; remaining >= 0; i += maxSamples) {
let left = samplesMono.subarray(i, i + maxSamples);
let mp3buf = mp3Encoder.encodeBuffer(left);
appendToBuffer(mp3buf);
remaining -= maxSamples;
}
};
相应的开始录音要新增一些逻辑
oStartBt.addEventListener("click", function () {
clearBuffer();
oAudio.srcObject = userMediStream;
oAudio.play();
buffer = [];
const options = {
mimeType: "audio/webm",
};
mediaRecorder = new MediaRecorder(userMediStream, options);
mediaRecorder.ondataavailable = handleDataAvailable;
mediaRecorder.start(10);
source.connect(processor); // 新增
processor.connect(audioCtx.destination); // 新增
});
source.connect(processor)
别慌,source
是上面说过的createMediaStreamSource
返回的,processor
是createScriptProcessor
返回的, 这里是把他们两个联系起来, 所以相当于开始使用js
处理音频数据。audioCtx.destination
音频图形在特定情况下的最终输出地址, 通常是扬声器。processor.connect
形成链接, 也就是开始执行processor
的监听。
相应的结束录音新增一些逻辑
oEndBt.addEventListener("click", function () {
oAudio.pause();
oAudio.srcObject = null;
mediaRecorder.stop(); // 新增
processor.disconnect(); // 新增
});
mediaRecorder.stop
停止音频(用于回放录音)processor.disconnect()
停止处理音频数据(转换成mp3后的)。
九、 录制好的音频文件发送给server
弄好的数据要以FormData
的形式传递给后端。
const oSubmitBt = document.getElementById("submitBt");
oSubmitBt.addEventListener("click", function () {
var blob = new Blob(dataBuffer, { type: "audio/mp3" });
const formData = new FormData();
formData.append("file", blob);
fetch("/create_voice", {
method: "POST",
body: formData,
})
.then((res) => res.json())
.catch((err) => console.log(err))
.then((res) => {
copy(res.voiceId);
alert(`已保到剪切板: ${res.voiceId}`);
window.opener = null;
window.open("", "_self");
window.close();
});
});
- 这里我们成功传递音频文件后就关闭当前页面了, 因为要录制的语音注释也确实不会很多。
十、未来展望
在vscode
插件商店也没有找到类似的插件, 并且github
上也没找到类似的插件, 说明这个问题点并没有很痛, 但并不是说明这些问题就放任不管, 行动起来真的去做一些事来改善准没错。
对于开发者这个"语音注释"插件可想而知, 只在文字无法描述清楚的情况下才会去使用, 所以平时录音功能的使用应该是很低频的, 正因如此音频文件也当然不会'多', 所以项目多出的体积可能也并不会造成很大的困扰。
后续如果大家用起来了, 我计划是增加一个"一键删除未使用的注释", 随着项目的发展肯定有些注释会被淘汰, 手动清理肯定说不过去。
播放的时候显示是谁的录音, 录制的具体时间的展示。
除了语音注释, 用户也可以添加文字+图片, 也就是做一个以注释为核心的插件。
end
这次就是这样, 希望与你一起进步。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。