diff --git a/README.md b/README.md index 17db2fe..200ffc7 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Omnitone also provides various building blocks for the first-order-ambisonic dec ```js var decoder = Omnitone.createFOADecoder(context, element, { HRTFSetUrl: 'YOUR_HRTF_SET_URL', - postGainDB: 30, + postGainDB: 0, channelMap: [0, 1, 2, 3] }); ``` @@ -217,7 +217,7 @@ Omnitone is designed to run any browser that supports Web Audio API, however, it ## Related Resources * [Google Spatial Media](https://github.com/google/spatial-media) -* [VRView](https://developers.google.com/vr/concepts/vrview/) +* [VR view](https://developers.google.com/vr/concepts/vrview/) * [Web Audio API](https://webaudio.github.io/web-audio-api/) * [WebVR](https://webvr.info/) diff --git a/build/omnitone.min.js b/build/omnitone.min.js index 74f320b..804025e 100644 --- a/build/omnitone.min.js +++ b/build/omnitone.min.js @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -"use strict";i.Omnitone=e(1)},function(t,i,e){"use strict";var n={},s=e(2),o=e(4),a=e(5),h=e(6),c=e(7),r=e(8);n.loadAudioBuffers=function(t,i){return new Promise(function(e,n){new s(t,i,function(t){e(t)},n)})},n.createFOARouter=function(t,i){return new o(t,i)},n.createFOARotator=function(t){return new a(t)},n.createFOAPhaseMatchedFilter=function(t){return new h(t)},n.createFOAVirtualSpeaker=function(t,i){return new c(t,i)},n.createFOADecoder=function(t,i,e){return new r(t,i,e)},t.exports=n},function(t,i,e){"use strict";function n(t,i,e,n,o){this._context=t,this._buffers=new Map,this._loadingTasks={},this._resolve=e,this._reject=n,this._progress=o;for(var a=0;a ["+t+"])"),this._audioElementSource=this._context.createMediaElementSource(this._videoElement),this._foaRouter=new o(this._context,this._channelMap),this._foaRotator=new a(this._context),this._foaPhaseMatchedFilter=new h(this._context),this._audioElementSource.connect(this._foaRouter.input),this._foaRouter.output.connect(this._foaRotator.input),this._foaRotator.output.connect(this._foaPhaseMatchedFilter.input),this._foaVirtualSpeakers=[],this._bypass=this._context.createGain(),this._audioElementSource.connect(this._bypass);var i=Math.pow(10,this._postGainDB/20);_.log("Gain compensation: "+i+" ("+this._postGainDB+"dB)");var e=this;return new Promise(function(t,n){new s(e._context,e._speakerData,function(n){for(var s=0;s ["+t+"])"),this._audioElementSource=this._context.createMediaElementSource(this._videoElement),this._foaRouter=new o(this._context,this._channelMap),this._foaRotator=new a(this._context),this._foaPhaseMatchedFilter=new h(this._context),this._audioElementSource.connect(this._foaRouter.input),this._foaRouter.output.connect(this._foaRotator.input),this._foaRotator.output.connect(this._foaPhaseMatchedFilter.input),this._foaVirtualSpeakers=[],this._bypass=this._context.createGain(),this._audioElementSource.connect(this._bypass);var i=Math.pow(10,this._postGainDB/20);_.log("Gain compensation: "+i+" ("+this._postGainDB+"dB)");var e=this;return new Promise(function(t,n){new s(e._context,e._speakerData,function(n){for(var s=0;smocha.setup('bdd') + diff --git a/test/test-phasematchedfilter.js b/test/test-phasematchedfilter.js new file mode 100644 index 0000000..0729f5d --- /dev/null +++ b/test/test-phasematchedfilter.js @@ -0,0 +1,114 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +describe('FOAPhaseMatchedFilter', function () { + // Test threshold. Approximately -140dBFS. + var TEST_THRESHOLD = 1.0e-7; + + // Parameters for the FOA phase matched filter. + var crossoverFrequency = 690; + var hipassGainCompensation = [-1.4142, -0.8166, -0.8166, -0.8166]; + + var sampleRate = 48000; + var renderLength = 128; + var context; + var testAudioBus; + + // A common task for router tests. Create an OAC for rendering. + beforeEach(function (done) { + context = new OfflineAudioContext(4, renderLength, sampleRate); + testAudioBus = new AudioBus(4, renderLength, sampleRate); + testAudioBus.fillChannelData([0.25, 0.5, 0.75, 1]); + done(); + }); + + + // Testing IIR filter implementation in the browser. (if applicable) + it('Testing the IIR filter implementation.', function (done) { + expect(typeof context.createIIRFilter).to.equal('function'); + + var filterCoefs = getDualBandFilterCoefs( + crossoverFrequency, sampleRate); + + var expectedBus = new AudioBus(4, renderLength, sampleRate); + expectedBus.copyFrom(testAudioBus); + expectedBus.processIIRFilter( + filterCoefs.lowpassB, filterCoefs.lowpassA); + + var source = context.createBufferSource(); + source.buffer = testAudioBus.getAudioBuffer(context); + var filter = context.createIIRFilter( + filterCoefs.lowpassB, filterCoefs.lowpassA); + + source.connect(filter); + filter.connect(context.destination); + source.start(); + + context.startRendering().then(function (renderedBuffer) { + var actualBus = new AudioBus(4, renderLength, sampleRate); + actualBus.copyFromAudioBuffer(renderedBuffer); + var result = expectedBus.compareWith(actualBus, 0.0); + expect(result).to.equal(true); + + done(); + }); + } + ); + + + // Create the expected result from the JS-version of dual band filter, and + // compare with the FOAPhaseMatchedFilter result. + it('Simulate and verify the phase-matched filter implementation.', + function (done) { + // Generate the expected filter result. + var filterCoefs = getDualBandFilterCoefs( + crossoverFrequency, sampleRate); + + var hipassBus = new AudioBus(4, renderLength, sampleRate); + hipassBus.copyFrom(testAudioBus); + hipassBus.processIIRFilter(filterCoefs.hipassB, filterCoefs.hipassA); + hipassBus.processGain(hipassGainCompensation); + + var lowpassBus = new AudioBus(4, renderLength, sampleRate); + lowpassBus.copyFrom(testAudioBus); + lowpassBus.processIIRFilter(filterCoefs.lowpassB, filterCoefs.lowpassA); + + hipassBus.sumFrom(lowpassBus); + + // Generate the actual filter result. + var source = context.createBufferSource(); + source.buffer = testAudioBus.getAudioBuffer(context); + + var dualbandFilter = Omnitone.createFOAPhaseMatchedFilter(context); + + source.connect(dualbandFilter.input); + dualbandFilter.output.connect(context.destination); + + source.start(); + + context.startRendering().then(function (renderedBuffer) { + var actualBus = new AudioBus(4, renderLength, sampleRate); + actualBus.copyFromAudioBuffer(renderedBuffer); + var result = hipassBus.compareWith(actualBus, TEST_THRESHOLD); + expect(result).to.equal(true); + + done(); + }); + } + ); + + +}); diff --git a/test/test-setup.js b/test/test-setup.js index 81772bc..273c6f2 100644 --- a/test/test-setup.js +++ b/test/test-setup.js @@ -53,3 +53,151 @@ function isConstantValueOf(channelData, value) { return Object.keys(mismatches).length === 0; }; + + +/** + * Generate the filter coefficients for the phase matched dual band filter. + * @param {NUmber} crossoverFrequency Filter crossover frequency. + * @param {NUmber} sampleRate Operating sample rate. + * @return {Object} Filter coefficients. + * { lowpassA, lowpassB, hipassA, hipassB } + * (where B is feedforward, A is feedback.) + */ +function getDualBandFilterCoefs(crossoverFrequency, sampleRate) { + var k = Math.tan(Math.PI * crossoverFrequency / sampleRate), + k2 = k * k, + denominator = k2 + 2 * k + 1; + + return { + lowpassA: [1, 2 * (k2 - 1) / denominator, (k2 - 2 * k + 1) / denominator], + lowpassB: [k2 / denominator, 2 * k2 / denominator, k2 / denominator], + hipassA: [1, 2 * (k2 - 1) / denominator, (k2 - 2 * k + 1) / denominator], + hipassB: [1 / denominator, -2 * 1 / denominator, 1 / denominator] + }; +} + +/** + * Kernel processor for IIR filter. (in-place processing) + * @param {Float32Array} channelData A channel data. + * @param {Float32Array} feedforward Feedforward coefficients. + * @param {Float32Array} feedback Feedback coefficients. + */ +function kernel_IIRFIlter (channelData, feedforward, feedback) { + var paddingSize = Math.max(feedforward.length, feedback.length); + var workSize = channelData.length + paddingSize; + var x = new Float32Array(workSize); + var y = new Float64Array(workSize); + + x.set(channelData, paddingSize); + + for (var index = paddingSize; index < workSize; ++index) { + var yn = 0; + for (k = 0; k < feedforward.length; ++k) + yn += feedforward[k] * x[index - k]; + for (k = 0; k < feedback.length; ++k) + yn -= feedback[k] * y[index - k]; + y[index] = yn; + } + + channelData.set(y.slice(paddingSize).map(Math.fround)); +} + + +/** + * A collection of Float32Array as AudioBus abstraction. + * @param {Number} numberOfChannels Number of channels. + * @param {Number} length Buffer length in samples. + * @param {Number} sampleRate Operating sample rate. + */ +function AudioBus (numberOfChannels, length, sampleRate) { + this.numberOfChannels = numberOfChannels; + this.sampleRate = sampleRate; + this.length = length; + this.duration = this.length / this.sampleRate; + + this._channelData = []; + for (var i = 0; i < this.numberOfChannels; ++i) { + this._channelData[i] = new Float32Array(length); + } +} + +AudioBus.prototype.getChannelData = function (channel) { + return this._channelData[channel]; +}; + +AudioBus.prototype.getAudioBuffer = function (context) { + var audioBuffer = context.createBuffer( + this.numberOfChannels, this.length, this.sampleRate); + + for (var channel = 0; channel < this.numberOfChannels; ++channel) + audioBuffer.getChannelData(channel).set(this._channelData[channel]); + + return audioBuffer; +}; + +AudioBus.prototype.fillChannelData = function (samples) { + for (var channel = 0; channel < this.numberOfChannels; ++channel) + this._channelData[channel].fill(samples[channel]); +}; + +AudioBus.prototype.copyFrom = function (otherAudioBus) { + for (var channel = 0; channel < this.numberOfChannels; ++channel) + this._channelData[channel].set(otherAudioBus.getChannelData(channel)); +}; + +AudioBus.prototype.copyFromAudioBuffer = function (audioBuffer) { + for (var channel = 0; channel < this.numberOfChannels; ++channel) + this._channelData[channel].set(audioBuffer.getChannelData(channel)); +}; + +AudioBus.prototype.sumFrom = function (otherAudioBus) { + for (var channel = 0; channel < this.numberOfChannels; ++channel) { + var channelDataA = this._channelData[channel]; + var channelDataB = otherAudioBus.getChannelData(channel); + for (var i = 0; i < this.length; ++i) + channelDataA[i] += channelDataB[i]; + } +}; + +AudioBus.prototype.processGain = function (coefficents) { + for (var channel = 0; channel < this.numberOfChannels; ++channel) { + var channelData = this._channelData[channel]; + for (var i = 0; i < this.length; ++i) + channelData[i] *= coefficents[channel]; + } +}; + +AudioBus.prototype.processIIRFilter = function (feedforward, feedback) { + for (var channel = 0; channel < this.numberOfChannels; ++channel) + kernel_IIRFIlter(this._channelData[channel], feedforward, feedback); +}; + +AudioBus.prototype.compareWith = function (otherAudioBus, threshold) { + var passed = true; + + for (var channel = 0; channel < this.numberOfChannels; ++channel) { + var channelDataA = this._channelData[channel]; + var channelDataB = otherAudioBus.getChannelData(channel); + + for (var i = 0; i < this.length; ++i) { + var absDiff = Math.abs(channelDataA[i] - channelDataB[i]); + if (absDiff > threshold) { + console.log('ERROR: at the index ' + i + ' (' + absDiff + ' > ' + + threshold + ').'); + passed = false; + } + } + } + + return passed; +}; + +AudioBus.prototype.print = function (begin, end) { + begin = (begin || 0); + end = (end || this.length); + console.log('AudioBus: <' + begin + ' ~ ' + end + '>'); + for (var channel = 0; channel < this.numberOfChannels; ++channel) { + console.log(channel + + ' => [' + this._channelData[channel].subarray(begin, end) + ']'); + } +}