天天看點

浏覽器音頻流擷取要做什麼事浏覽器 Stream API音頻參數,采樣率與樣本值表示PCM 編碼和 WAVE 封裝整體實作

要做什麼事

要做的事,是通過浏覽器相關 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

有關。

在我自己的浏覽器上:

  • fftSize

    :2048,對應

    analyser.frequencyBinCount

    就是 1024。
  • sampleRate

    :48000
  • bufferSize

    :1024

這種情況下,大概

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

    0x8F 0xD8

    ,同時, PCM 規定使用的是有符号整數,你就還知道了單個數的上限和下限,相對的強弱關系就可以還原了,那兩個數就是

    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

    的結果。
  • getFloatTimeDomainData

    的資料,通過 websocket 傳遞給服務端。
  • 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 服務。
  • /

    傳回上面用戶端頁面内容。
  • /stream

    處理 websocket 服務。
  • /stream

    上連接配接關閉時,把已接收的資料,作為 wave 格式存儲到檔案系統。
# -*- 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()
           

繼續閱讀