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
這次就是這樣, 希望與你一起進步。