11
头图

vscode语音注释, 让信息更丰富 (下)

前言

     这个系列的最后一篇, 主要讲述录制音频&音频文件存储相关知识, 当时因为录音有bug搞得我一周没心情吃饭(voice-annotation)。
image.png

一、MP3 文件储存位置

"语音注释"使用场景
  1. 单个项目使用"语音注释"。
  2. 多个项目使用"语音注释"。
  3. "语音注释"生成的 mp3 文件都放在自己项目中。
  4. "语音注释"生成的 mp3 文件统一存放在全局的某处。
  5. "语音注释"生成的 mp3 一部分存在项目中一部分使用全局路径。
vscode 工作区

     具体音频储存在哪里肯定要读取用户的配置, 但如果用户只在全局配置了一个路径, 那么这个路径无法满足每个项目存放音频文件的位置不同的场景, 这时候就引出了vscode 工作区的概念。

     假如我们每个工程的eslint规则各不相同, 此时我们只在全局配置eslint规则就无法满足这个场景了, 此时我们需要在项目中新建一个.vscode文件夹, 在其中建立settings.json文件, 在这个文件内编写的配置就是针对当前项目的个性化配置了。

image.png

配置工作区 (绝对路径 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项目中。

     上述问题没法准确的检验出用户的真实目标路径, 那我想到的办法是录制音频页面内预展示出将要保存到的路径, 让用户来做最后的守门人:

image.png

     当前插件简易用户配置:

{
    "voiceAnnotation": {
        "dirPath": "../mp3"
    }
}

image.png

二、配置的定义

     如果用户不想把音频文件储存在项目内, 怕自己的项目变大起来, 那我们支持单独做一个音频存放的项目, 此时就需要在全局配置一个绝对路径, 因为全局的配置不会同步给其他开发者, 当我们获取不到用户在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"
                }
            }
        }
    },

具体每个属性的意义可以参考配置后的效果图:

image.png

三、获取音频文件夹位置的方法

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文件的配置, 并且也是获取全局配置的方法, 所以我们要做好区分才能去使用哪个, 所以这里我命名为dirPathglobalDirPath

3: 文件路径分割符

     /xxx/xx/x.js其中的 "/" 就是path.sep, 因为mac或者window等系统里面是有差异的, 这里使用path.sep是为了兼容其他系统的用户。

4: 报错

     相对路径与绝对路径都获取不到就抛出报错:

 vscode.window.showErrorMessage(错误信息)

image.png

5: 使用

     第一是用在server保存音频时, 第二是打开web页面时会传递给前端用户显示保存路径。

四、录音初始知识

     没使用过录音功能的同学你可能没见过navigator.mediaDevices这个方法, 返回一个MediaDevices对象,该对象可提供对相机和麦克风等媒体输入设备的连接访问,也包括屏幕共享。
image.png

     录制音频需要先获取用户的许可, navigator.mediaDevices.getUserMedia就是在获取用户许可成功并且设备可用时走成功回调。

image.png

navigator.mediaDevices.getUserMedia({audio:true})
.then((stream)=>{
  // 因为我们输入的是{audio:true}, 则stream是音频的内容流
})
.carch((err)=>{

})

image.png

五、初始化录音设备与配置

下面展示的是定义播放标签以及环境的'初始化', 老样子先上代码, 然你后逐句解释:

  <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获取元素

image.png

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);
    }
  }
  1. oAudio.srcObject定义了播放标签的'媒体源'。
  2. oAudio.play();开始播放, 这里由于我们设置了muted = true静音, 所以这里就是开始录音。
  3. buffer是用来储存音频数据的, 每次录制需要清空一下上次的残留。
  4. new MediaRecorder 创建了一个对指定的 MediaStream 进行录制的 MediaRecorder 对象, 也就是说这个方法就是为了录制功能而存在的, 它的第二个参数可以输入指定的mimeType类型, 具体的类型我在MDN上查了一下。
    image.png
  5. mediaRecorder.ondataavailable定义了针对每段音频数据的具体处理逻辑。
  6. mediaRecorder.start(10); 对音频进行10毫秒一切片, 音频信息是储存在Blob里的, 这里的配置我理解是每10毫秒生成一个Blob对象。

     此时数组buffer里面就可以持续不断的收集到我们的音频信息了, 至此我们完成了录音功能, 接下来我们要丰富它的功能了。

七、结束, 重播, 重录

image.png

1: 结束录音

     录音当然要有个尽头了, 有同学提出是否需要限制音频的长短或大小? 但我感觉具体的限制规则还是每个团队自己来定制吧, 这一版我这边只提供核心功能。

  const oEndBt = document.getElementById("endBt");

  oEndBt.addEventListener("click", function () {
    oAudio.pause();
    oAudio.srcObject = null;
  });
  1. 点击录制结束按钮, oAudio.pause()停止标签播放。
  2. 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();
  });
  1. Blob 一种数据的储存形式, 我们实现纯前端生成excel就是使用了blob, 可以简单理解为第一个参数是文件的数据, 第二个参数可以定义文件的类型。
  2. window.URL.createObjectURL参数是'资源数据', 此方法生成一串url, 通过url可以访问到传入的'资源数据', 需要注意生成的url是短暂的就会失效无法访问。
  3. oReplayAudio.src 为播放器指定播放地址, 由于不用录音所以就不用指定srcObject了。
  4. 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);
        });
  1. new AudioContext()音频处理的上下文, 对音频的操作基本都会在这个类型里面进行。
  2. audioCtx.createMediaStreamSource(stream) 创建音频接口有点抽象。
  3. audioCtx.createScriptProcessor(0, 1, 1) 这里创建了一个用于JavaScript直接处理音频的对象, 也就是创建了这个才能用js操作音频数据,三个参数分别为'缓冲区大小','输入声道数','输出声道数'。
  4. processor.onaudioprocess 监听新数据的处理方法。
  5. 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); // 新增
      });
  1. source.connect(processor)别慌, source是上面说过的createMediaStreamSource返回的, processorcreateScriptProcessor返回的, 这里是把他们两个联系起来, 所以相当于开始使用js处理音频数据。
  2. audioCtx.destination 音频图形在特定情况下的最终输出地址, 通常是扬声器。
  3. processor.connect 形成链接, 也就是开始执行processor的监听。
相应的结束录音新增一些逻辑
      oEndBt.addEventListener("click", function () {
        oAudio.pause();
        oAudio.srcObject = null;
        mediaRecorder.stop(); // 新增
        processor.disconnect(); // 新增
      });
  1. mediaRecorder.stop 停止音频(用于回放录音)
  2. 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();
          });
      });
  1. 这里我们成功传递音频文件后就关闭当前页面了, 因为要录制的语音注释也确实不会很多。

十、未来展望

     在vscode插件商店也没有找到类似的插件, 并且github上也没找到类似的插件, 说明这个问题点并没有很痛, 但并不是说明这些问题就放任不管, 行动起来真的去做一些事来改善准没错。

     对于开发者这个"语音注释"插件可想而知, 只在文字无法描述清楚的情况下才会去使用, 所以平时录音功能的使用应该是很低频的, 正因如此音频文件也当然不会'多', 所以项目多出的体积可能也并不会造成很大的困扰。

     后续如果大家用起来了, 我计划是增加一个"一键删除未使用的注释", 随着项目的发展肯定有些注释会被淘汰, 手动清理肯定说不过去。

     播放的时候显示是谁的录音, 录制的具体时间的展示。

     除了语音注释, 用户也可以添加文字+图片, 也就是做一个以注释为核心的插件。

end

     这次就是这样, 希望与你一起进步。


lulu_up
5.7k 声望6.9k 粉丝

自信自律, 终身学习, 创业者