要做什麼事
要做的事,是通過浏覽器相關 API ,在頁面上實時擷取麥克風的音頻資料,并把這些資訊傳遞到服務端。
簡單來想,要解決這些問題:
- 浏覽器的麥克風相關的 API 怎麼使用。
- 浏覽器擷取到的資料是什麼樣的。
- 浏覽器擷取的音頻資料如何編碼到通常的“音頻檔案”。
浏覽器 Stream API
如果直接搜尋 “浏覽器 audio” 相關的内容,一方面是講 audio 标簽的,另一個方面會講到 AudioContext ,其實這些都算是浏覽器的多媒體能力的一部分,并且在程式設計 API 層面,它們也是統一的。
audio 标簽,是“音頻”媒體的可選的一個輸入端,及輸出端。 AudioContext 整體處理風格,是管道式的,比如:
source = getAudioTag();
dest = getAnotherTag();
source.connect(dest);
如果你想使用浏覽器直接提供的音頻解析能力,或者其它處理能力,則在 AudioContext 下直接建立相關“中間層”:
source = getAudioTag();
dest = getAnotherTag();
processor = AudioContext.createScriptProcessor();
analyser = AudioContext.createAnalyser();
source.connect(analyser);
analyser.connect(processor);
processor.connect(dest);
每個中間層,都提供了一些音頻的預處理資訊,或者能力,或者事件。典型的,是在
processor
的
onaudioprocess
事件中,從
analyser
擷取音頻的采樣資料。
processor.onaudioprocess = function(e){
const amplitudeArray = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatTimeDomainData(amplitudeArray);
console.log(amplitudeArray);
}
當然,這裡
onaudioprocess
觸發的頻率,
amplitudeArray
的長度這些,就與音頻相關的參數有關了,後面會簡單介紹。
對于浏覽器的音頻 API 有了一點概念之後,再加上“麥克風”這個并不算太特殊的輸入源,事情就好辦多了:
- 通過
回調,擷取麥克風的實時輸入内容。navigator.mediaDevices.getUserMedia({audio: true, video: false}).then(stream => {})
-
建立一個包裝輸入的“中間層”。context.createMediaStreamSource(stream)
整體代碼大概像:
navigator.mediaDevices.getUserMedia({audio: true, video: false}).then(stream => {
const context = new AudioContext({ sampleRate: 48000 });
audioContext = context;
const input = context.createMediaStreamSource(stream)
const processor = context.createScriptProcessor(1024,1,1);
const analyser = context.createAnalyser();
const dest = context.destination;
input.connect(analyser);
analyser.connect(processor);
processor.connect(dest);
analyser.fftSize = 2048;
const amplitudeArray = new Float32Array(analyser.frequencyBinCount);
processor.onaudioprocess = function(e){
analyser.getFloatTimeDomainData(amplitudeArray);
console.log(amplitudeArray);
};
}).catch(err => {console.log(err)});
跑起來之後,能看到不斷變化的
amplitudeArray
,就說明成功了。
音頻參數,采樣率與樣本值表示
前面示例中,
amplitudeArray
中的内容是一串在
[-1, 1]
之間的小數,我們需要搞清楚這些數字的含義,才能友善後面對聲音資訊的處理。
聲音的實體形式,是“波”。存儲,還原聲音,是一種典型的模拟信号到數字信号的轉換。直覺地,這裡有兩個關鍵資訊需要先确定,采樣率和樣本值表示。
采樣率比較好了解,就像做拟合一樣,對于一段連續的曲線:
plot(sin(x))
(現實中的聲音頻率不是固定的,這裡
sin
隻作采樣的一個說明)
你在上面取的樣本點越多,越能原樣還原之前的曲線的樣子。采樣率 Sample Rate ,反映了機關時間内,擷取樣本的次數,要使結果更精确,這個值當然越大越好,但是采樣資料越多,存儲傳輸的成本也越大。
由于人耳能聽到的聲音是有一個頻率範圍的,是以,在這個特定場景下,采樣率有一個“足夠”的上限,是 48000Hz 。
采樣率對應到 API 中是:
new AudioContext({sampleRate: 48000})
但是注意,資料擷取了,與資料暴露出來,還不是一回事。
擷取資料的方式,是通過
onaudioprocess
的回調,換句話說,
onaudioprocess
每秒回調的次數,每次傳遞出來的資料多少,才真正決定我們能拿到多少采樣資料。
onaudioprocess
每次傳遞出來的資料多少,由
analyser.fftSize
決定,
amplitudeArray
的長度是
analyser.fftSize
的一半。
而
onaudioprocess
每秒回調次數,就比較詭異了。似乎與
sampleRate
及
context.createScriptProcessor
的第一個參數
bufferSize
有關。
在我自己的浏覽器上:
-
:2048,對應fftSize
就是 1024。analyser.frequencyBinCount
-
:48000sampleRate
-
:1024bufferSize
這種情況下,大概
onaudioprocess
每秒調用 48 次不到,每次 1024 個資料,算下來,每秒差不多 48000 個資料。
但是我把
bufferSize
改成 0 ,每秒調用就變成 24 了。
說完了采樣率,再說樣本值表示。
從實體層面想的話,模拟信号資訊的擷取,最初得到的“樣本值”隻是一組非直接的實體量,比如某個特征尺寸改變了,但是這并不等于在這個時刻,你聲音大,或者說聲音的強度大。我們這裡不讨論怎麼定義聲音的大小,隻是說明,數字化的表示模拟信号,對于“強度”的還原,也是需要在一定規則下作約定式的處理,才能有結果的。就 API 來說:
analyser.getByteTimeDomainData(amplitudeArray);
可以傳回一組
到
255
的整數,意味着在這組資料當中,強弱對比,最多 255 個級别,無法再有更細的辨識能力。
而:
analyser.getFloatTimeDomainData(amplitudeArray);
對是傳回一組
-1
1
之間的小數,其表示的強弱對比,就大之前的區區 255 個級别,要大得多了。
但是,無論是 255 個級别,還是遠多于 255 的級别,都隻是采樣層面的“中間結果”,當你要把這些資訊,以特定的格式(比如 PCM)存儲的時候,又面臨一些選擇,使用多大的空間來儲存這個“強弱對比”? 255 的級别,那麼使用 1 個位元組就夠了,遠多于 255 級别呢?用 2 個位元組? 3 個位元組?
這裡說的到底用多少個位元組,在音頻技術名額中,就是“分辯率 Resolution”的概念了。 API 以浮點數給出的結果,精度是有了,存儲時需要多麼細緻,用多少空間,就在于你自己選擇了。
基本的兩個重要概念介紹完之後,總結一下,就是浏覽器 API 通過
getFloatTimeDomainData
能給到一個相對資料,但這些資料怎麼存儲,怎麼格式化到通常的音頻格式,都是你自己要處理的事。
PCM 編碼和 WAVE 封裝
PCM 算是一種通用的存儲數字化采樣資訊的格式,事實上它都算不上什麼格式,隻是在音頻領域有一些約定。
拿“位元組”和“字元”的關系來類比的話, PCM 的地位,就像“位元組”,單獨看一個 PCM 片段,沒有意義,因為你無法解釋。
- 你不知道應該一個位元組一個位元組地看,還是兩個位元組兩個位元組的看。
- 你更不知道存儲了幾個聲道的資訊。
- 你還不知道應該以什麼速度還原(這對聲音很重要)。
比如随意一段:
0xAB 0x03 0x8F 0xD8
- 幾個位元組一起看,就是前面提的“分辯率”問題。用到位元組越多,強弱對比就越細緻。你平時看音頻檔案,16bit,32bit 說的就是這個。我們這裡,假設是 16bit ,兩個位元組的配置,是以,資料就解釋成:
和0xAB 0x03
,同時, PCM 規定使用的是有符号整數,你就還知道了單個數的上限和下限,相對的強弱關系就可以還原了,那兩個數就是0x8F 0xD8
939
。範圍在-10097
。[-32768, 32768)
- 聲道資訊就簡單了,它們是在單個 frame 中順序排列的。上面 2 x 2 個位元組,如果是雙聲道,則隻有 1 frame ,分别是第一聲道的資訊和第二聲道的資訊。如果是單聲道,則是單個聲道 2 frame 的資訊。這裡我們假設是單聲道。
- 以什麼速度還原,就是采樣率的問題。如果每秒采樣 1 個機關,那麼 2 frame 播放時間就是 2 秒。如果每秒采樣 2 個機關,則 2 frame 播放時間 1 秒。(我們代碼中用 48000Hz)
這幾點說清楚後,就能明白, PCM 就是位元組串,這些資料作為聲音解釋還原,所需要的其它資訊,不是 PCM 的事。而 WAVE 封裝,就是在其頭部補充了這些資訊。(當然,WAVE 還有其它功能,壓縮什麼的,同時, WAVE 中也不一定隻能是 PCM 格式)
是以,浏覽器的 API ,完成 PCM 的裸流即時往服務端送出,沒有問題。但是服務端如果要把接收到的這些資料,最後存成通常的“音頻檔案”格式,則還需要其它資訊補充,聲道數,采樣率,(分辯率在儲存時由服務端決定)。我們後面的代碼中,聲道數約定寫死,采樣率通過協定設計在流程中互動擷取。
整體實作
- 問題:為什麼不在浏覽器都做完,要加個服務端呢?
- 回答:1. 我不了解 wave 格式細節。2. 我對使用 js 處理二進制場景表示畏懼。3. Python 我都熟。
用戶端
-
實時擷取getUserMedia
的結果。getFloatTimeDomainData
-
的資料,通過 websocket 傳遞給服務端。getFloatTimeDomainData
- websocket 上通過自定義有狀态的協定,解決擷取資料,擷取采樣率,儲存等問題。
- 通過 audio 标簽實時回放。
- 外加一個簡單的聲音可視化效果。
<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>Audio</title>
<script type="text/javascript" src="https://s.zys.me/js/jq/jquery.min.js"></script>
</head>
<body>
<div>
<audio id="player" controls autoplay></audio>
</div>
<div id="wrapper" style="width: 512px; height: 256px; border: 1px solid red; margin: 10px 0;">
<canvas id="canvas" width="512" height="256"></canvas>
</div>
<div id="controls">
<div>
<input type="button" id="start" value="開始">
<input type="button" id="stop" value="停止">
</div>
</div>
<script type="text/javascript">
const requestAnimFrame = window.requestAnimationFrame;
const ctx = $('#canvas')[0].getContext('2d');
let canvasWidth = 512;
let canvasHeight = 256;
let audioContext = null;
let connection = null;
const sampleRate = 48000;
function setWidth(w){
$('#wrapper').css({width: w});
$('#canvas').attr('width', w);
canvasWidth = w;
}
function drawTimeDomain(data) {
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
for (var i = 0; i < data.length; i++) {
var value = data[i] - (-1) / 2;
var y = canvasHeight - (canvasHeight * value) - 1;
ctx.fillStyle = 'red';
ctx.fillRect(i, y, 2, 2);
}
}
function connect(){
const ws = new WebSocket('ws://' + location.host + '/stream');
let uuid = null;
connection = {
close: () => {
ws.send('CLOSE ' + uuid);
ws.close();
uuid = null;
},
send_data: data => {
ws.send('DATA ' + uuid + ' ' + data)
},
set_uuid: id => {
uuid = id;
console.log(uuid);
}
};
ws.addEventListener('open', function (event) {
ws.send('INIT ' + sampleRate);
});
ws.addEventListener('message', function (event) {
const msg = event.data;
const p = msg.split(' ');
const cmd = p[0];
const params = p.slice(1);
const cmdMap = {
'UUID': 'set_uuid'
}
if(cmdMap[cmd]){
connection[cmdMap[cmd]].apply(this, params);
}
});
}
function stop(){
if(audioContext){
audioContext.close();
audioContext = null;
}
if(connection){
connection.close();
connection = null;
}
$('#player')[0].pause();
$('#player')[0].currentTime = 0;
$('#player')[0].srcObject = null;
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.restore();
}
function run(){
navigator.mediaDevices.getUserMedia({audio: true, video: false}).then(stream => {
connect();
const context = new AudioContext({ sampleRate });
audioContext = context;
const input = context.createMediaStreamSource(stream)
const processor = context.createScriptProcessor(1024,1,1);
const analyser = context.createAnalyser();
const dest = context.destination;
input.connect(analyser);
analyser.connect(processor);
processor.connect(dest);
analyser.fftSize = 2048;
setWidth(analyser.fftSize / 2);
// getByteTimeDomainData 使用 Unit8 精度太低
//const amplitudeArray = new Uint8Array(analyser.frequencyBinCount);
const amplitudeArray = new Float32Array(analyser.frequencyBinCount);
let count = 0;
const start = new Date().getTime();
const _drawTimeDomain = () => drawTimeDomain(amplitudeArray);
processor.onaudioprocess = function(e){
count += 1;
const now = new Date().getTime();
//每秒調用統計
//console.log(count / ((now - start) / 1000));
//analyser.getByteTimeDomainData(amplitudeArray);
analyser.getFloatTimeDomainData(amplitudeArray);
if(connection){
connection.send_data(amplitudeArray.join('|'))
}
requestAnimFrame(_drawTimeDomain);
};
//回放
const player = $('#player')[0];
player.srcObject = stream;
player.onloadedmetadata = function(e) { player.play() };
}).catch(err => {console.log(err)});
}
$(() => {
$('#start').on('click', e => { run() });
$('#stop').on('click', e => { stop() });
});
</script>
</body>
</html>
服務端
- HTTP 和 websocket 服務。
-
傳回上面用戶端頁面内容。/
-
處理 websocket 服務。/stream
-
上連接配接關閉時,把已接收的資料,作為 wave 格式存儲到檔案系統。/stream
# -*- coding: utf-8 -*-
import uuid
from io import BytesIO
import struct
import wave
import tornado.web
import tornado.httpserver
import tornado.ioloop
import tornado.websocket
class Application(tornado.web.Application):
def __init__(self, handlers):
super(Application, self).__init__(handlers, '', None, debug=True)
class HTTPServer(tornado.httpserver.HTTPServer):
def __init__(self, app):
super(HTTPServer, self).__init__(app, xheaders=True)
class BaseHandler(tornado.web.RequestHandler):
pass
class IndexHandler(BaseHandler):
def get(self):
with open('./client.html', 'rb') as f:
data = f.read()
self.finish(data)
class StreamHandler(tornado.websocket.WebSocketHandler):
CONNECTION = {}
SAMPLE_LENGTH = 2
VALUE_MAX = 2 ** (2 * 8 - 1) - 1
def open(self):
self.uuid = uuid.uuid4()
self.rate = None
self.io = BytesIO()
def on_close(self):
pass
def do_init(self, rate):
self.rate = int(rate)
self.write_message('UUID {}'.format(self.uuid))
self.__class__.CONNECTION[self.uuid] = self
def do_close(self, uuid):
if uuid in self.__class__.CONNECTION:
del self.__class__.CONNECTION[uuid]
self.close()
with wave.open('{}.wav'.format(self.uuid), 'wb') as wavfile:
wavfile.setparams((1, self.__class__.SAMPLE_LENGTH, self.rate, 0, 'NONE', 'NONE'))
self.io.seek(0)
wavfile.writeframes(self.io.read())
self.io.close()
self.io = None
def do_data(self, uuid, data):
# 2個位元組帶符号
# 聲音太小,作增益
gain = 1
for x in data.split('|'):
v = int(float(x) * gain * self.__class__.VALUE_MAX)
if v > self.__class__.VALUE_MAX:
v = self.__class__.VALUE_MAX
if v < self.__class__.VALUE_MAX * -1:
v = self.__class__.VALUE_MAX * -1
try:
self.io.write(struct.pack('h', v))
except:
print(v)
raise Exception('')
def on_message(self, message):
cmd_map = {
'INIT': self.do_init,
'CLOSE': self.do_close,
'DATA': self.do_data
}
cmd, *params = message.split(' ')
if cmd in cmd_map:
cmd_map[cmd](*params)
else:
self.write_message('{} is a ERROR cmd'.format(cmd))
Handlers = [
('/', IndexHandler),
('/stream', StreamHandler),
]
def main():
application = Application(Handlers)
server = HTTPServer(application)
port = 8888
server.listen(port)
print('SERVER IS STARTING ON %s ...' % port)
tornado.ioloop.IOLoop.current().start()
if __name__ == '__main__':
main()