如上圖,要實現對FLV直播流中音頻的識別,並展示成一個音頻相關的動態頻譜。
一. 首先了解下什么是聲音?
能量波,有頻率有振幅,頻率高低就是音調,振幅大小就是音量;采樣率是對頻率采樣,采樣精度是對幅度采樣。
人耳能聽到的頻率范圍是200-20KHz
音頻數字化就是將模擬的(連續的)聲音波形數字化(離散化),以便利用數字計算機進行處理的過程,主要參數包括采樣頻率(Sample Rate)和采樣數位/采樣精度(Quantizing,也稱量化級)兩個方面,這二者決定了數字化音頻的質量。
二. 獲取音頻的可視化數據
音頻的可視化簡單來說可以通過反復收集當前音頻的時域數據, 並繪制為一個示波器風格的輸出(頻譜)。
時域(time domain)是描述數學函數或物理信號對時間的關系。例如一個信號的時域波形可以表達信號隨着時間的變化。
頻域(frequency domain)是指在對函數或信號進行分析時,分析其和頻率有關部分,而不是和時間有關的部分[1],和時域一詞相對。
一般來說,可視化是通過獲取各個時間上的音頻數據(通常是振幅或頻率),之后運用圖像技術將其處理為視覺輸出(例如一個圖像)來實現的。網頁音頻接口提供了一個不會改變輸入信號的音頻節點 AnalyserNode
,通過它可以獲取聲音數據並傳遞到像 <canvas>
等等一樣的可視化工具。
1. 什么是AnalyserNode?以及如何創建AnalyserNode?
nalyserNode?
AnalyserNode
賦予了節點可以提供實時頻率及時間域分析的信息。它使一個 AudioNode
通過音頻流不做修改的從輸入到輸出, 但允許你獲取生成的數據, 處理它並創建音頻可視化.
AnalyserNode是一個節點名稱,並不是html5的API,它可以通過
AudioContext
創建。
var audioCtx = new(window.AudioContext || window.webkitAudioContext)(); var analyser = audioCtx.createAnalyser();
AudioContext 即為本文實現方案的一個重點API,它是html5處理音頻的API,MDN中解釋如下:
AudioContext
接口表示由音頻模塊連接而成的音頻處理圖,每個模塊對應一個AudioNode
。AudioContext
可以控制它所包含的節點的創建,以及音頻處理、解碼操作的執行。做任何事情之前都要先創建AudioContext
對象,因為一切都發生在這個環境之中。
總結一下實現方案就是,AudioContext創建一個AnalyserNode節點,通過該節點拿到頻譜數據(可以理解為一定范圍內的數字),進行圖形化顯示。
2. 那如何通過AnalyserNode節點獲取頻譜數據呢?
先上代碼:
this.analyser = this.audioCtx.createAnalyser()
this.analyser.fftSize = 1024
...
let array = new Uint8Array(this.analyser.frequencyBinCount) // 創建frequencyBinCount長度的Uint8Array數組,用於存放音頻數據
this.analyser.getByteFrequencyData(array) // 將音頻數據填充到數組當中
這里的array值即為音頻的時域數據數組,數組中的每個數據的最大值為256。
為什么最大值為256?
音頻的每個數據占用一個字節,當音頻無數據時,array中的值均為0。每一個字節有8位,最大值為2的8次方,即256
再解釋幾個名詞:
1. fftSize(Fast Fourier Transfor 快速傅里葉變換)
AnalyserNode
接口的fftSize
屬性的值是一個無符號長整型的值, 表示(信號)樣本的窗口大小。fftSize 屬性的值必須是從32到32768范圍內的2的非零冪; 其默認值為2048。
2. frequencyBinCount
getByteFrequencyData
getByteFrequencyData()
方法將當前頻率數據復制到傳入的Uint8Array(無符號字節數組)中。
至此我們已經獲取到可以用於可視化的音頻數據數組!音頻數據已知,音頻數據的最大值已知,即可根據這些繪制出想要的可視化圖形。
細心的同學可能發現,以上我們並沒有接入任何音頻,那哪來的音頻數據?
對的,我們還需要接入音頻才能拿到進行上面的這些操作。
三. 音頻的接入和播放
音頻源可以提供一個片段一個片段的音頻采樣數據(以數組的方式),一般,一秒鍾的音頻數據可以被切分成幾萬個這樣的片段。這些片段可以是經過一些數學運算得到 (比如
OscillatorNode
),也可以是音頻或視頻的文件讀出來的(比如AudioBufferSourceNode
和MediaElementAudioSourceNode
),又或者是音頻流(MediaStreamAudioSourceNode
)
音頻接入
方式一:createMediaElementSource
MediaElementAudioSourceNode
接口代表着某個由HTML5 <audio>
或 <video>
元素所組成的音頻源。
<audio id="audio" controls="" autoplay="" loop="" crossorigin="anonymous" src="./1.mp3"></audio> <script> /* AudioContext.createMediaElementSource() 創建一個MediaElementAudioSourceNode接口來關聯HTMLMediaElement. 這可以用來播放和處理來自<video>或<audio> 元素的音頻. */ var audio = document.getElementById('audio'); var ctx = new AudioContext(); var analyser = ctx.createAnalyser(); var audioSrc = ctx.createMediaElementSource(audio); ... // 連接到音頻分析器,分析頻譜 audioSrc.connect(analyser); analyser.connect(ctx.destination); </script>
AudioContext
的 destination 屬性返回一個
AudioDestinationNode
表示 context 中所有音頻(節點)的最終目標節點,一般是音頻渲染設備,比如揚聲器。
方式二:createBufferSource
function decodeBuffer(arrayBuffer) { audioContext.decodeAudioData(arrayBuffer, function(buffer) { play(buffer); }); } function play(buffer) { var audioBufferSourceNode = audioContext.createBufferSource(); audioBufferSourceNode.connect(analyser); // 用於連接到終端設備進行播放聲音 analyser.connect(audioContext.destination); audioBufferSourceNode.buffer = buffer; audioBufferSourceNode.start(); }
兩種獲取ArrayBuffer的方式一種是fileReader, 一種是XMLHttpRequest。
第一種方式:fileReader
<input id="fileChooser" type="file" /> <script> window.onload = function() { var audioContext = new AudioContext(); var analyser = audioContext.createAnalyser(); analyser.fftSize = 256; var fileChooser = document.getElementById('fileChooser'); fileChooser.onchange = function() { if (fileChooser.files[0]) { loadFile(fileChooser.files[0]); } } function loadFile(file) { var fileReader = new FileReader(); fileReader.onload = function(e) {
var arrayBuffer = e.target.result decodeBuffer(arrayBuffer); } fileReader.readAsArrayBuffer(file); } } </script>
第二種:XHR(也是我獲取FLV音頻的方式)
getBuffer () { let _this = this // Fetch中的Response.body實現了getReader()方法用於漸增的讀取原始字節流 // 處理器函數一塊一塊的接收響應體,而不是一次性的。當數據全部被讀完后會將done標記設置為true。 在這種方式下,每次你只需要處理一個chunk,而不是一次性的處理整個響應體。 let myRequest = new Request(this.config.url) fetch(myRequest, { method: 'GET' }) .then( response => { _this._pump(response.body.getReader()) }, error => { console.error('audio stream fetch Error:', error) } ) .catch((e) => { console.log('e:', e) }) } _pump (reader) { var _this = this return reader.read() .then( ({ value, done }) => { if (done) { _this.debug('Stream reader done') } else { let arrayBuffer = value.buffer ... // 獲取下一個chunk _this._pump(reader) } }) .catch((e) => { console.log('[flv audio]read stream:', e) }) }
至此,音頻源的接入和播放即可完成,但對於flv的音頻流,是不能直接用於 decodeAudioData 的,需要增加adts頭部信息方可decode。
四. Flv音頻的異步解碼
AAC ES流無法直接播放,一般需要封裝為ADTS格式才能再次使用,一般是在AAC ES流前添加7個字節的ADTS header。
ES--Elementary Streams (原始流)是直接從編碼器出來的數據流,可以是編碼過的視頻數據流(H.264,MJPEG等),音頻數據流(AAC),或其他編碼數據流的統稱。ES是只包含一種內容的數據流,如只含視頻或只含音頻等。
什么是ADTS header呢?可以參考這篇
1. 那如何添加ADTS header呢?
在 視音頻編解碼學習工程:FLV封裝格式分析器 中介紹了FLV的封裝格式(如上圖),我們可以知道Flv body由若干個tag組成,每個tag包含Tag Header和Tag Data部分,TagData部分又可以分為AudioTagHeader和AudioTagBody,如下:
(圖片來自:https://www.jianshu.com/p/d68d6efe8230)
AudioTagHeader包括音頻的配置信息有音頻編碼類型、采樣率、精度、類型,當SoundFormat為10的時候,即當音頻是aac的時候,AudioTagHeader還包括一個字節的AACPacketType(值為0或1),它表示后面的AudioTagBody是AudioSpecificConfig還是AACframe data,如下圖:
(參考:https://www.adobe.com/content/dam/acom/en/devnet/flv/video_file_format_spec_v10.pdf)
AAC sequence header包含了AudioSpecificConfig,有更詳細音頻的信息,但這種包只出現一次,而且是第一個Audio Tag,因為后面的音頻ES流需要該header的ADTS(Audio Data Transport Stream)頭。AAC raw則包含音頻ES流了,也就是audio payload。
注釋:<ui8> (8-Byte Unsigned Integer)
有關AudioSpecificConfig的詳細信息可以參考 ISO_IEC_14496 1.6.2.1
ADTS的頭信息有7個字節,都可以從 AudioSpecificConfig 中獲取,上代碼:
/** * 計算adts頭部, aac文件需要增加adts頭部才能被audioContext decode * @typedef {Object} AdtsHeadersInit * @property {number} audioObjectType * @property {number} samplingFrequencyIndex * @property {number} channelConfig * @property {number} adtsLen * @param {AdtsHeadersInit} init * 添加aac頭部參考:https://github.com/Xmader/flv2aac/blob/master/main.js */ getAdtsHeaders (init) { const { audioObjectType, samplingFrequencyIndex, channelConfig, adtsLen } = init const headers = new Uint8Array(7) headers[0] = 0xff // syncword:0xfff 高8bits headers[1] = 0xf0 // syncword:0xfff 低4bits headers[1] |= (0 << 3) // MPEG Version:0 for MPEG-4,1 for MPEG-2 1bit headers[1] |= (0 << 1) // Layer:0 2bits headers[1] |= 1 // protection absent:1 1bit headers[2] = (audioObjectType - 1) << 6 // profile:audio_object_type - 1 2bits headers[2] |= (samplingFrequencyIndex & 0x0f) << 2 // sampling frequency index:sampling_frequency_index 4bits headers[2] |= (0 << 1) // private bit:0 1bit headers[2] |= (channelConfig & 0x04) >> 2 // channel configuration:channel_config 高1bit headers[3] = (channelConfig & 0x03) << 6 // channel configuration:channel_config 低2bits headers[3] |= (0 << 5) // original:0 1bit headers[3] |= (0 << 4) // home:0 1bit headers[3] |= (0 << 3) // copyright id bit:0 1bit headers[3] |= (0 << 2) // copyright id start:0 1bit headers[3] |= (adtsLen & 0x1800) >> 11 // frame length:value 高2bits headers[4] = (adtsLen & 0x7f8) >> 3 // frame length:value 中間8bits headers[5] = (adtsLen & 0x7) << 5 // frame length:value 低3bits headers[5] |= 0x1f // buffer fullness:0x7ff 高5bits headers[6] = 0xfc return headers }
其中 audioObjectType, samplingFrequencyIndex, channelConfig, adtsLen 即可從 AAC sequence header 中獲取,幸運的是,flv.js pr354 的作者已經把這部分信息解析出來了,省去了我們很多麻煩。
在flv.js源碼的 demux/flv-demuxer.js 中,有_parseAudioData函數:
... if (aacData.packetType === 0) { // AAC sequence header (AudioSpecificConfig) if (meta.config) { Log.w(this.TAG, 'Found another AudioSpecificConfig!') } let misc = aacData.data meta.audioSampleRate = misc.samplingRate meta.channelCount = misc.channelCount meta.codec = misc.codec meta.originalCodec = misc.originalCodec meta.config = misc.config // added by qli5 meta.configRaw = misc.configRaw // added by Xmader meta.audioObjectType = misc.audioObjectType meta.samplingFrequencyIndex = misc.samplingIndex meta.channelConfig = misc.channelCount // The decode result of Fan aac sample is 1024 PCM samples meta.refSampleDuration = 1024 / meta.audioSampleRate * meta.timescale Log.v(this.TAG, 'Parsed AudioSpecificConfig') if (this._isInitialMetadataDispatched()) { // Non-initial metadata, force dispatch (or flush) parsed frames to remuxer if (this._dispatch && (this._audioTrack.length || this._videoTrack.length)) { this._onDataAvailable(this._audioTrack, this._videoTrack) } } else { this._audioInitialMetadataDispatched = true } // then notify new metadata this._dispatch = false // metadata中的信息提供給外部封裝aac的adts頭部 this._onTrackMetadata('audio', meta) let mi = this._mediaInfo mi.audioCodec = meta.originalCodec mi.audioSampleRate = meta.audioSampleRate mi.audioChannelCount = meta.channelCount if (mi.hasVideo) { if (mi.videoCodec != null) { mi.mimeType = 'video/x-flv; codecs="' + mi.videoCodec + ',' + mi.audioCodec + '"' } } else { mi.mimeType = 'video/x-flv; codecs="' + mi.audioCodec + '"' } if (mi.isComplete()) { this._onMediaInfo(mi) } } ...
因此我們可以通過 _onTrackMetadata 獲得metadata數據。接着我們就可以對AAC data添加ADTS頭部信息:
/** * 獲取添加adts頭部信息的aac數據 * * @param {*} metadata * @param {*} aac * @returns */ getNewAac (aac) { const { audioObjectType, samplingFrequencyIndex, channelCount: channelConfig } = this.metadata let output = [] let _this = this // aac音頻需要增加adts頭部后才能被解析播放 aac.samples.forEach((sample) => { const headers = _this.getAdtsHeaders({ audioObjectType, samplingFrequencyIndex, channelConfig, adtsLen: sample.length + 7 }) output.push(...headers, ...sample.unit) }) return new Uint8Array(output) }
此時flv-demuxer.js具有兩大作用:
- 獲取ADTS頭部信息
- 獲取AAC ES流
最后我們對ES流添加ADTS頭部,交給AudioContext.decodeAudioData解碼並播放。
(此處我們只考慮利用flv-demuxer.js解析flv音頻的功能,處理視頻和MSE喂給video部分不考慮)
2. 對交給demuxer的chunk添加FLV header
addFlvHeader (chunk) { let audioBuffer = null if (this.flvHeader == null) { // copy first 9 bytes (flv header) this.flvHeader = chunk.slice(0, 9) audioBuffer = chunk } else { audioBuffer = this.appendBuffer(this.flvHeader, chunk) } return audioBuffer }
五. FLV音頻的連續播放
Fetch獲取音頻流是一段段的,每一段時間都很短,大概100ms左右,經過添加ADST頭部后,這些一段段的AAC音頻如何連續播放?如此高頻的解碼音頻是否有性能問題?
讓音頻連續的播放起來目前有兩種方式:
第一種堆積播放:
flv-demuxer.js默認的方式,會對之前的音頻進行堆積:
...
if (aacData.packetType === 1) { // AAC raw frame data let dts = this._timestampBase + tagTimestamp let aacSample = { unit: aacData.data, length: aacData.data.byteLength, dts: dts, pts: dts } track.samples.push(aacSample) track.length += aacData.data.length }
...
每次從flv-demuxer.js獲取的AAC ES流都包含上一次解析的流內容,此時解碼后播放需要定位到上次播放的時間,以上次播放到的時間點為起始點,播放當前的音頻流,播放時長為本次流時長減去上次播放的流時長。
此種情況下,利用AudioContext.decodeAudioData的音頻數據會越來越大,延時也就越來越高,消耗的性能也是越來越大。最終會導致瀏覽器的內存溢出,瀏覽器崩潰。
第二種分段播放:
此種情況為了避免上種情況的內存溢出,每次交給demuxer音頻數據時,先對 track.samples 進行清空:
// 清空audio之前的metadata數據 _this.flvDemuxerObj._audioMetadata = null // 此為清除之前的audio流,得到fetch流對應的音頻;若不清除,parseChunk后得到的是從開始累積的aac數據 _this.flvDemuxerObj._audioTrack = { type: 'audio', id: 2, sequenceNumber: 0, samples: [], length: 0 }
這樣每次從demuxer拿到的數據即為Fetch Reader交給它的數據,沒有對歷史數據的累積。
每次播放時,只單獨播放每個片段的音頻數據。我們會把處理好的音頻數據存放在音頻數組 audioStack 內,每次播放從數組內取出第一個 this.audioStack.shift()
我們會在上一段音頻播放結束后,進行出棧播放的操作:
audioBufferSourceNode.onended = function (e) {
_this.loopPlayBuffers()
}
此時我們忽略了從音頻出棧到audioContext播放此音樂的程序運行時間,實際上是非常短暫的,我們幾乎聽不出有停頓。但有一種情況會產生延遲,在音頻出棧的時候,發現音頻棧為空,此時可能是因為網絡原因fetch流產生較大的延遲,這個時候我們必須等待有新的處理好的音頻入棧,才能接着播放,此時我們就會感知到一個短暫的停頓。
計算延遲時間如下:
... if (this.audioStack.length == 0) { console.warn('audioStack為空,等待audio入棧(音頻解析速度慢或遇到問題)') this.delayStartTime = (new Date()).getTime() this.audioPlaying = false return } if (this.delayStartTime !== 0) { let nowTime = (new Date()).getTime() let gap = nowTime - this.delayStartTime this.delayStartTime = 0 this.debugFunc('延遲時間:' + gap + ' ms') } ...
六. 音頻可視化波形實現
通過上文第二點可知我們已經獲取到了音頻可視化的頻譜數據數組audioArray。
我們只需要按照一定規則把數組數據繪制在canvas上即可。
這里我們實現一個圓形的音頻波形。
首先要理解圓形周圍的每個柱形都是一個音譜數據,它的值value就是audioArray數組中的一個值,范圍為[0-256]。
let meterHeight = value * (wave.cheight / 2 - wave.cr) / 256
我們可以自定義一個圓周要有多少個柱形組成,假設由 meterNum 代表柱形的個數,那我們就要從audioArray中取樣,取出meterNum 個數據來:
// 計算采樣步長 var step = Math.round(array.length / meterNum)
然后我們對audioArray頻譜數組每隔step個數據取一個樣本,進行柱形的繪制,並以圓心為中心進行旋轉,旋轉的度數為:
(360 / meterNum) * (wave.PI / 180)
for (let i = 0; i < meterNum; i++) { let value = array[i * step] // wave.cheight / 2 - wave.cr 為波形的最大高度 let meterHeight = value * (wave.cheight / 2 - wave.cr) / 256 || wave.minHeight // 根據圓心為中心點旋轉 wave.ctx.rotate((360 / meterNum) * (wave.PI / 180)) wave.ctx.fillRect(-wave.meterWidth / 2, -wave.cr - meterHeight, wave.meterWidth, meterHeight) } wave.ctx.restore()
}
以上就是每一次繪制需要進行的操作,然后我們利用 requestAnimationFrame 進行循環以上繪制。
以上部分的完整源代碼已經在github, 歡迎大家star試用,有任何問題也歡迎大家及時提出,一起討論改進。
github地址:https://github.com/saysmy/flv-audio-visualization
2019-05-16補充更新:
第四大點:FLV音頻的異步解碼中 AACPacketType ,一般情況下如果音頻格式標准,整個音頻流只有一個 AACPacketType 為0的tag,但也有例外,有一些音頻流會出現多次 AACPacketType值為0的情況, AACPacketType值為0和1交替出現,不影響解碼:
已知問題:
如果你的音視頻無法播放,打開debug,發現有如下圖的warning提示:
則你的flv音視頻格式並不很規范,規范的flv音視頻解析的flvtrunk如下:
它的前9個字節為FLV Header,前三個字節是固定的70,76,86,代表文件標志F、L、V。緊接着是4個字節的previousTagSize, 也是固定的0,0,0,0 ,因為它的前一個tag不存在,大小都為0。再接着便是flv tag,第一個字節是tag type,一般是8(音頻),9(視頻),18(scriptData)。如果不是這樣的格式,就會解析失敗,出現上圖的warning提示。
附:
詳情見:https://cconcolato.github.io/media-mime-support/#audio/mp4;%20codecs=%22mp4a.40%22
參考文章:
https://lucius0.github.io/2017/12/27/archivers/media-study-03/
https://www.jianshu.com/p/d68d6efe8230
https://blog.csdn.net/tx3344/article/details/7414543
https://segmentfault.com/a/1190000017090438
https://github.com/Xmader/flv2aac
以上涉及源碼的github地址:https://github.com/saysmy/flv-audio-visualization