diff --git a/package.json b/package.json index bad718d7eecc7b33968243a26795c72d09fe0029..9f7b08cf6fd298fc3bd5900da8bcd44c92a78187 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "svg-to-image": "1.1.3", "svg-url-loader": "2.1.0", "wav-encoder": "1.2.0", + "web-audio-test-api": "^0.5.2", "webpack": "^2.4.1", "webpack-dev-server": "^2.4.1", "xhr": "2.4.0" diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx index 7170b645a24e8840c5bae4f51351819a64694c7f..67c4b1b5fdab808d0920decf7a2becd240a8d43f 100644 --- a/src/containers/sound-editor.jsx +++ b/src/containers/sound-editor.jsx @@ -5,7 +5,7 @@ import React from 'react'; import {connect} from 'react-redux'; import {computeChunkedRMS} from '../lib/audio/audio-util.js'; - +import AudioEffects from '../lib/audio/audio-effects.js'; import SoundEditorComponent from '../components/sound-editor/sound-editor.jsx'; import AudioBufferPlayer from '../lib/audio/audio-buffer-player.js'; @@ -110,8 +110,14 @@ class SoundEditor extends React.Component { effectFactory (name) { return () => this.handleEffect(name); } - handleEffect (/* name */) { - // @todo implement effects + handleEffect (name) { + const effects = new AudioEffects(this.audioBufferPlayer.buffer, name); + effects.process().then(newBuffer => { + const samples = newBuffer.getChannelData(0); + const sampleRate = newBuffer.sampleRate; + this.submitNewSamples(samples, sampleRate); + this.handlePlay(); + }); } handleUndo () { this.redoStack.push(this.props.samples.slice(0)); @@ -128,6 +134,7 @@ class SoundEditor extends React.Component { } } render () { + const {effectTypes} = AudioEffects; return ( <SoundEditorComponent canRedo={this.redoStack.length > 0} @@ -139,17 +146,17 @@ class SoundEditor extends React.Component { trimStart={this.state.trimStart} onActivateTrim={this.handleActivateTrim} onChangeName={this.handleChangeName} - onEcho={this.effectFactory('echo')} - onFaster={this.effectFactory('faster')} - onLouder={this.effectFactory('louder')} + onEcho={this.effectFactory(effectTypes.ECHO)} + onFaster={this.effectFactory(effectTypes.FASTER)} + onLouder={this.effectFactory(effectTypes.LOUDER)} onPlay={this.handlePlay} onRedo={this.handleRedo} - onReverse={this.effectFactory('reverse')} - onRobot={this.effectFactory('robot')} + onReverse={this.effectFactory(effectTypes.REVERSE)} + onRobot={this.effectFactory(effectTypes.ROBOT)} onSetTrimEnd={this.handleUpdateTrimEnd} onSetTrimStart={this.handleUpdateTrimStart} - onSlower={this.effectFactory('slower')} - onSofter={this.effectFactory('softer')} + onSlower={this.effectFactory(effectTypes.SLOWER)} + onSofter={this.effectFactory(effectTypes.SOFTER)} onStop={this.handleStopPlaying} onUndo={this.handleUndo} /> diff --git a/src/lib/audio/audio-effects.js b/src/lib/audio/audio-effects.js new file mode 100644 index 0000000000000000000000000000000000000000..b7ba65cb2384bd3896a607bb5c7ffef185dc84d3 --- /dev/null +++ b/src/lib/audio/audio-effects.js @@ -0,0 +1,82 @@ +import EchoEffect from './effects/echo-effect.js'; +import RobotEffect from './effects/robot-effect.js'; +import VolumeEffect from './effects/volume-effect.js'; + +const effectTypes = { + ROBOT: 'robot', + REVERSE: 'reverse', + LOUDER: 'higher', + SOFTER: 'lower', + FASTER: 'faster', + SLOWER: 'slower', + ECHO: 'echo' +}; + +class AudioEffects { + static get effectTypes () { + return effectTypes; + } + constructor (buffer, name) { + // Some effects will modify the playback rate and/or number of samples. + // Need to precompute those values to create the offline audio context. + const pitchRatio = Math.pow(2, 4 / 12); // A major third + let sampleCount = buffer.length; + let playbackRate = 1; + switch (name) { + case effectTypes.ECHO: + sampleCount = buffer.length + 0.25 * 3 * buffer.sampleRate; + break; + case effectTypes.FASTER: + playbackRate = pitchRatio; + sampleCount = Math.floor(buffer.length / playbackRate); + break; + case effectTypes.SLOWER: + playbackRate = 1 / pitchRatio; + sampleCount = Math.floor(buffer.length / playbackRate); + break; + case effectTypes.REVERSE: + buffer.getChannelData(0).reverse(); + break; + } + + this.audioContext = new OfflineAudioContext(1, sampleCount, buffer.sampleRate); + this.buffer = buffer; + this.source = this.audioContext.createBufferSource(); + this.source.buffer = this.buffer; + this.source.playbackRate.value = playbackRate; + this.name = name; + } + process () { + // Some effects need to use more nodes and must expose an input and output + let input; + let output; + switch (this.name) { + case effectTypes.LOUDER: + ({input, output} = new VolumeEffect(this.audioContext, 1.25)); + break; + case effectTypes.SOFTER: + ({input, output} = new VolumeEffect(this.audioContext, 0.75)); + break; + case effectTypes.ECHO: + ({input, output} = new EchoEffect(this.audioContext, 0.25)); + break; + case effectTypes.ROBOT: + ({input, output} = new RobotEffect(this.audioContext, 0.25)); + break; + } + + if (input && output) { + this.source.connect(input); + output.connect(this.audioContext.destination); + } else { + // No effects nodes are needed, wire directly to the output + this.source.connect(this.audioContext.destination); + } + + this.source.start(); + + return this.audioContext.startRendering(); + } +} + +export default AudioEffects; diff --git a/src/lib/audio/effects/echo-effect.js b/src/lib/audio/effects/echo-effect.js new file mode 100644 index 0000000000000000000000000000000000000000..3c7c06806b3b99ff7a09d985dd52629d323bbc32 --- /dev/null +++ b/src/lib/audio/effects/echo-effect.js @@ -0,0 +1,33 @@ +class EchoEffect { + constructor (audioContext, delayTime) { + this.audioContext = audioContext; + this.delayTime = delayTime; + this.input = this.audioContext.createGain(); + this.output = this.audioContext.createGain(); + + this.effectInput = this.audioContext.createGain(); + this.effectInput.gain.value = 0.75; + + this.delay = this.audioContext.createDelay(1); + this.delay.delayTime.value = delayTime; + this.decay = this.audioContext.createGain(); + this.decay.gain.value = 0.3; + + this.compressor = this.audioContext.createDynamicsCompressor(); + this.compressor.threshold.value = -5; + this.compressor.knee.value = 15; + this.compressor.ratio.value = 12; + this.compressor.attack.value = 0; + this.compressor.release.value = 0.25; + + this.input.connect(this.effectInput); + this.effectInput.connect(this.delay); + this.delay.connect(this.compressor); + this.input.connect(this.compressor); + this.delay.connect(this.decay); + this.decay.connect(this.delay); + this.compressor.connect(this.output); + } +} + +export default EchoEffect; diff --git a/src/lib/audio/effects/robot-effect.js b/src/lib/audio/effects/robot-effect.js new file mode 100644 index 0000000000000000000000000000000000000000..d530b9e6e039891f7881f5860de9991684f32b90 --- /dev/null +++ b/src/lib/audio/effects/robot-effect.js @@ -0,0 +1,101 @@ +class RobotEffect { + constructor (audioContext) { + this.audioContext = audioContext; + + this.input = this.audioContext.createGain(); + this.output = this.audioContext.createGain(); + + // Ring modulator inspired by BBC Dalek voice + // http://recherche.ircam.fr/pub/dafx11/Papers/66_e.pdf + // https://github.com/bbc/webaudio.prototyping.bbc.co.uk + + // > There are four parallel signal paths, two which process the + // > combination Vc + Vin / 2 and two which process Vc - Vin/2. + // > Each branch consists of a non-linearity [diode]... + const createDiodeNode = () => { + const node = this.audioContext.createWaveShaper(); + + // Piecewise function given by (2) in Parker paper + const transform = (v, vb = 0.2, vl = 0.4, h = 0.65) => { + if (v <= vb) return 0; + if (v <= vl) return h * (Math.pow(v - vb, 2) / (2 * vl - 2 * vb)); + return h * v - h * vl + h * (Math.pow(v - vb, 2) / (2 * vl - 2 * vb)); + }; + + // Create the waveshaper curve with the voltage transform above + const bufferLength = 1024; + const curve = new Float32Array(bufferLength); + for (let i = 0; i < bufferLength; i++) { + const voltage = 2 * (i / bufferLength) - 1; + curve[i] = transform(voltage); + } + node.curve = curve; + return node; + }; + + const oscillator = this.audioContext.createOscillator(); + oscillator.frequency.value = 50; + oscillator.start(0); + + const vInGain = this.audioContext.createGain(); + vInGain.gain.value = 0.5; + + const vInInverter1 = this.audioContext.createGain(); + vInInverter1.gain.value = -1; + + const vInInverter2 = this.audioContext.createGain(); + vInInverter2.gain.value = -1; + + const vInDiode1 = createDiodeNode(this.audioContext); + const vInDiode2 = createDiodeNode(this.audioContext); + + const vInInverter3 = this.audioContext.createGain(); + vInInverter3.gain.value = -1; + + const vcInverter1 = this.audioContext.createGain(); + vcInverter1.gain.value = -1; + + const vcDiode3 = createDiodeNode(this.audioContext); + const vcDiode4 = createDiodeNode(this.audioContext); + + const compressor = this.audioContext.createDynamicsCompressor(); + compressor.threshold.value = -5; + compressor.knee.value = 15; + compressor.ratio.value = 12; + compressor.attack.value = 0; + compressor.release.value = 0.25; + + const biquadFilter = this.audioContext.createBiquadFilter(); + biquadFilter.type = 'highpass'; + biquadFilter.frequency.value = 1000; + biquadFilter.gain.value = 1.25; + + this.input.connect(vcInverter1); + this.input.connect(vcDiode4); + + vcInverter1.connect(vcDiode3); + + oscillator.connect(vInGain); + vInGain.connect(vInInverter1); + vInGain.connect(vcInverter1); + vInGain.connect(vcDiode4); + + vInInverter1.connect(vInInverter2); + vInInverter1.connect(vInDiode2); + vInInverter2.connect(vInDiode1); + + vInDiode1.connect(vInInverter3); + vInDiode2.connect(vInInverter3); + + vInInverter3.connect(compressor); + vcDiode3.connect(compressor); + vcDiode4.connect(compressor); + + this.input.connect(biquadFilter); + biquadFilter.connect(compressor); + + compressor.connect(this.output); + } +} + +export default RobotEffect; diff --git a/src/lib/audio/effects/volume-effect.js b/src/lib/audio/effects/volume-effect.js new file mode 100644 index 0000000000000000000000000000000000000000..bffc422acbc9f1ea12bec768bc56539eb1c72085 --- /dev/null +++ b/src/lib/audio/effects/volume-effect.js @@ -0,0 +1,16 @@ +class VolumeEffect { + constructor (audioContext, volume) { + this.audioContext = audioContext; + + this.input = this.audioContext.createGain(); + this.output = this.audioContext.createGain(); + + this.gain = this.audioContext.createGain(); + this.gain.gain.value = volume; + + this.input.connect(this.gain); + this.gain.connect(this.output); + } +} + +export default VolumeEffect; diff --git a/test/__mocks__/audio-effects.js b/test/__mocks__/audio-effects.js new file mode 100644 index 0000000000000000000000000000000000000000..bebf0fdc282ede058ca0e4be175d92e5861b565c --- /dev/null +++ b/test/__mocks__/audio-effects.js @@ -0,0 +1,24 @@ +/* eslint-env jest */ +export default class MockAudioEffects { + static get effectTypes () { // @todo can this be imported from the real file? + return { + ROBOT: 'robot', + REVERSE: 'reverse', + LOUDER: 'higher', + SOFTER: 'lower', + FASTER: 'faster', + SLOWER: 'slower', + ECHO: 'echo' + }; + } + constructor (buffer, name) { + this.buffer = buffer; + this.name = name; + this._mockResult = {}; + this._bufferPromise = new Promise(resolve => { // eslint-disable-line no-undef + this._finishProcessing = (newBuffer) => resolve(newBuffer); + }); + this.process = jest.fn(() => this._bufferPromise); + MockAudioEffects.instance = this; + } +} diff --git a/test/unit/containers/sound-editor.test.jsx b/test/unit/containers/sound-editor.test.jsx index 2d1920063c22f1c6020faac4356a99a0d3f9d461..5a5618ea96cd88491b5a7de40cc0d321632ae1b8 100644 --- a/test/unit/containers/sound-editor.test.jsx +++ b/test/unit/containers/sound-editor.test.jsx @@ -3,11 +3,14 @@ import React from 'react'; // eslint-disable-line no-unused-vars import {mountWithIntl} from '../../helpers/intl-helpers'; import configureStore from 'redux-mock-store'; import mockAudioBufferPlayer from '../../__mocks__/audio-buffer-player.js'; +import mockAudioEffects from '../../__mocks__/audio-effects.js'; + import SoundEditor from '../../../src/containers/sound-editor'; // eslint-disable-line no-unused-vars // eslint-disable-next-line no-unused-vars import SoundEditorComponent from '../../../src/components/sound-editor/sound-editor'; jest.mock('../../../src/lib/audio/audio-buffer-player', () => mockAudioBufferPlayer); +jest.mock('../../../src/lib/audio/audio-effects', () => mockAudioEffects); describe('Sound Editor Container', () => { const mockStore = configureStore(); @@ -90,6 +93,74 @@ describe('Sound Editor Container', () => { expect(vm.renameSound).toHaveBeenCalledWith(soundIndex, 'hello'); }); + test('it handles an effect by submitting the result and playing', (done) => { + const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const component = wrapper.find(SoundEditorComponent); + component.props().onReverse(); // Could be any of the effects, just testing the end result + mockAudioEffects.instance._finishProcessing(soundBuffer); + process.nextTick(() => { + expect(mockAudioBufferPlayer.instance.play).toHaveBeenCalled(); + expect(vm.updateSoundBuffer).toHaveBeenCalled(); + done(); + }); + }); + + test('it handles reverse effect correctly', () => { + const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const component = wrapper.find(SoundEditorComponent); + component.props().onReverse(); + expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.REVERSE); + expect(mockAudioEffects.instance.process).toHaveBeenCalled(); + }); + + test('it handles louder effect correctly', () => { + const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const component = wrapper.find(SoundEditorComponent); + component.props().onLouder(); + expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.LOUDER); + expect(mockAudioEffects.instance.process).toHaveBeenCalled(); + }); + + test('it handles softer effect correctly', () => { + const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const component = wrapper.find(SoundEditorComponent); + component.props().onSofter(); + expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.SOFTER); + expect(mockAudioEffects.instance.process).toHaveBeenCalled(); + }); + + test('it handles faster effect correctly', () => { + const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const component = wrapper.find(SoundEditorComponent); + component.props().onFaster(); + expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.FASTER); + expect(mockAudioEffects.instance.process).toHaveBeenCalled(); + }); + + test('it handles slower effect correctly', () => { + const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const component = wrapper.find(SoundEditorComponent); + component.props().onSlower(); + expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.SLOWER); + expect(mockAudioEffects.instance.process).toHaveBeenCalled(); + }); + + test('it handles echo effect correctly', () => { + const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const component = wrapper.find(SoundEditorComponent); + component.props().onEcho(); + expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.ECHO); + expect(mockAudioEffects.instance.process).toHaveBeenCalled(); + }); + + test('it handles robot effect correctly', () => { + const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const component = wrapper.find(SoundEditorComponent); + component.props().onRobot(); + expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.ROBOT); + expect(mockAudioEffects.instance.process).toHaveBeenCalled(); + }); + test('undo/redo functionality', () => { const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); const component = wrapper.find(SoundEditorComponent); diff --git a/test/unit/util/audio-effects.test.js b/test/unit/util/audio-effects.test.js new file mode 100644 index 0000000000000000000000000000000000000000..e30a2e6c14d40528fddf8b5dd2f69ebc93bec5f0 --- /dev/null +++ b/test/unit/util/audio-effects.test.js @@ -0,0 +1,59 @@ +/* eslint-env jest */ +/* global AudioNode AudioContext WebAudioTestAPI */ +import 'web-audio-test-api'; +WebAudioTestAPI.setState({ + 'OfflineAudioContext#startRendering': 'promise' +}); + +import AudioEffects from '../../../src/lib/audio/audio-effects'; +import RobotEffect from '../../../src/lib/audio/effects/robot-effect'; +import EchoEffect from '../../../src/lib/audio/effects/echo-effect'; +import VolumeEffect from '../../../src/lib/audio/effects/volume-effect'; + +describe('Audio Effects manager', () => { + let audioContext = new AudioContext(); + let audioBuffer = audioContext.createBuffer(1, 400, 44100); + + test('changes buffer length and playback rate for faster effect', () => { + const audioEffects = new AudioEffects(audioBuffer, 'faster'); + expect(audioEffects.audioContext._.length).toBeLessThan(400); + expect(audioEffects.source.playbackRate.value).toBeGreaterThan(1); + }); + + test('changes buffer length and playback rate for slower effect', () => { + const audioEffects = new AudioEffects(audioBuffer, 'slower'); + expect(audioEffects.audioContext._.length).toBeGreaterThan(400); + expect(audioEffects.source.playbackRate.value).toBeLessThan(1); + }); + + test('changes buffer length for echo effect', () => { + const audioEffects = new AudioEffects(audioBuffer, 'echo'); + expect(audioEffects.audioContext._.length).toBeGreaterThan(400); + }); + + test.skip('process starts the offline rendering context and returns a promise', () => { + // @todo haven't been able to get web audio test api to actually run render + }); +}); + +describe('Effects', () => { + let audioContext; + + beforeEach(() => { + audioContext = new AudioContext(); + }); + + test('all effects provide an input and output that are connected', () => { + const robotEffect = new RobotEffect(audioContext, 0.5); + expect(robotEffect.input).toBeInstanceOf(AudioNode); + expect(robotEffect.output).toBeInstanceOf(AudioNode); + + const echoEffect = new EchoEffect(audioContext, 0.5); + expect(echoEffect.input).toBeInstanceOf(AudioNode); + expect(echoEffect.output).toBeInstanceOf(AudioNode); + + const volumeEffect = new VolumeEffect(audioContext, 0.5); + expect(volumeEffect.input).toBeInstanceOf(AudioNode); + expect(volumeEffect.output).toBeInstanceOf(AudioNode); + }); +});