vscode語音註釋, 讓資訊更豐富(下)

lulu_up發表於2022-02-24

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

     這次就是這樣, 希望與你一起進步。

相關文章