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

lulu_up -
vscode语音注释, 让信息更丰富(下)
vscode语音注释, 让信息更丰富 (下)前言

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

一、MP3 文件储存位置"语音注释"使用场景单个项目使用"语音注释"。多个项目使用"语音注释"。"语音注释"生成的 mp3 文件都放在自己项目中。"语音注释"生成的 mp3 文件统一存放在全局的某处。"语音注释"生成的 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);
    }
  }
oAudio.srcObject定义了播放标签的'媒体源'。oAudio.play();开始播放, 这里由于我们设置了muted = true静音, 所以这里就是开始录音。buffer是用来储存音频数据的, 每次录制需要清空一下上次的残留。new MediaRecorder 创建了一个对指定的 MediaStream 进行录制的 MediaRecorder 对象, 也就是说这个方法就是为了录制功能而存在的, 它的第二个参数可以输入指定的mimeType类型, 具体的类型我在MDN上查了一下。
image.pngmediaRecorder.ondataavailable定义了针对每段音频数据的具体处理逻辑。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;
  });
点击录制结束按钮, 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返回的, processorcreateScriptProcessor返回的, 这里是把他们两个联系起来, 所以相当于开始使用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

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

特别申明:本文内容来源网络,版权归原作者所有,如有侵权请立即与我们联系(cy198701067573@163.com),我们将及时处理。

Tags 标签

前端javascripthtml5node.jstypescript

扩展阅读

加个好友,技术交流

1628738909466805.jpg