diff --git a/package.json b/package.json index 9d1cf0ef1e0beb80fc03ca8c2cfa3f95c2d906ec..1c766e8b49309cde678121dfd18bb76add111d64 100644 --- a/package.json +++ b/package.json @@ -70,10 +70,10 @@ "react-modal": "2.2.2", "react-redux": "5.0.5", "react-style-proptype": "3.0.0", - "react-test-renderer": "^15.5.4", - "redux-mock-store": "^1.2.3", "react-tabs": "1.1.0", + "react-test-renderer": "^15.5.4", "redux": "3.7.0", + "redux-mock-store": "^1.2.3", "redux-throttle": "0.1.1", "rimraf": "^2.6.1", "scratch-audio": "latest", @@ -85,6 +85,7 @@ "svg-to-image": "1.1.3", "svg-url-loader": "2.1.0", "wav-encoder": "1.1.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 6f3bb484135379d973d7ad3f1ec38a124d3fa4da..1328e88fc8c28a31e7af3cb2566c1aafe0a04030 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'; @@ -98,10 +98,17 @@ 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(); + }); } render () { + const {effectTypes} = AudioEffects; return ( <SoundEditorComponent chunkLevels={this.state.chunkLevels} @@ -111,16 +118,16 @@ 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} - 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} /> ); diff --git a/src/lib/audio/audio-effects.js b/src/lib/audio/audio-effects.js new file mode 100644 index 0000000000000000000000000000000000000000..683568b6978cb149a814c2809d2dd1fb902e3ff9 --- /dev/null +++ b/src/lib/audio/audio-effects.js @@ -0,0 +1,81 @@ +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. + 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 = 1.5; + sampleCount = Math.floor(buffer.length / playbackRate); + break; + case effectTypes.SLOWER: + playbackRate = 0.5; + 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.5)); + break; + case effectTypes.SOFTER: + ({input, output} = new VolumeEffect(this.audioContext, 0.5)); + 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..3a8326bc031ba81f01472a2e083af3abfaaf8601 --- /dev/null +++ b/src/lib/audio/effects/echo-effect.js @@ -0,0 +1,39 @@ +class EchoEffect { + constructor (audioContext, delayTime) { + this.audioContext = audioContext; + this.delayTime = delayTime; + this.input = this.audioContext.createGain(); + this.output = this.audioContext.createGain(); + + if (delayTime === 0) { + this.input.connect(this.output); + return; + } + + this.effectInput = this.audioContext.createGain(); + this.effectInput.gain.value = 0.75; + // this.effectInput.gain.setValueAtTime(0.5, this.audioContext.currentTime + Math.max(0, 0.75 * duration)) + + this.delay = this.audioContext.createDelay(1); + this.delay.delayTime.value = delayTime; + this.decay = this.audioContext.createGain(); // @todo chain + this.decay.gain.value = 0.3; + + this.compressor = this.audioContext.createDynamicsCompressor(); + this.compressor.threshold.value = -30; + this.compressor.knee.value = 40; + 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..c909559e847ab26716f6c19e61b09b74f4e86fbf --- /dev/null +++ b/src/lib/audio/effects/robot-effect.js @@ -0,0 +1,102 @@ +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 = -35; + compressor.knee.value = 40; + 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 cca22eea98ff1350be1b04ad9b9aaa35bf6ef846..b84a1233ff753d30baac63bb25fc4c543b178201 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(); @@ -89,4 +92,72 @@ describe('Sound Editor Container', () => { component.props().onChangeName('hello'); 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(); + }); });