diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx index 61bf712248d..3089b3dc344 100644 --- a/src/containers/sound-editor.jsx +++ b/src/containers/sound-editor.jsx @@ -10,7 +10,7 @@ import { computeChunkedRMS, encodeAndAddSoundToVM, downsampleIfNeeded, - backupDownSampler + dropEveryOtherSample } from '../lib/audio/audio-util.js'; import AudioEffects from '../lib/audio/audio-effects.js'; import SoundEditorComponent from '../components/sound-editor/sound-editor.jsx'; @@ -138,7 +138,7 @@ class SoundEditor extends React.Component { }); } submitNewSamples (samples, sampleRate, skipUndo) { - return downsampleIfNeeded(samples, sampleRate, this.resampleBufferToRate) + return downsampleIfNeeded({samples, sampleRate}, this.resampleBufferToRate) .then(({samples: newSamples, sampleRate: newSampleRate}) => WavEncoder.encode({ sampleRate: newSampleRate, @@ -208,9 +208,9 @@ class SoundEditor extends React.Component { trimEnd: null }); }); - } handleDeleteInverse () { + // Delete everything outside of the trimmers const {samples, sampleRate} = this.copyCurrentBuffer(); const sampleCount = samples.length; const startIndex = Math.floor(this.state.trimStart * sampleCount); @@ -331,19 +331,21 @@ class SoundEditor extends React.Component { const sampleRateRatio = newRate / buffer.sampleRate; const newLength = sampleRateRatio * buffer.samples.length; let offlineContext; - if (window.OfflineAudioContext) { - offlineContext = new window.OfflineAudioContext(1, newLength, newRate); - } else if (window.webkitOfflineAudioContext) { - try { + // Try to use either OfflineAudioContext or webkitOfflineAudioContext to resample + // The constructors will throw if trying to resample at an unsupported rate + // (e.g. Safari/webkitOAC does not support lower than 44khz). + try { + if (window.OfflineAudioContext) { + offlineContext = new window.OfflineAudioContext(1, newLength, newRate); + } else if (window.webkitOfflineAudioContext) { offlineContext = new window.webkitOfflineAudioContext(1, newLength, newRate); - } catch { - if (newRate === (buffer.sampleRate / 2)) { - return resolve(backupDownSampler(buffer, newRate)); - } - return reject('Could not resample'); } - } else { - return reject('No offline audio context'); + } catch { + // If no OAC available and downsampling by 2, downsample by dropping every other sample. + if (newRate === buffer.sampleRate / 2) { + return resolve(dropEveryOtherSample(buffer)); + } + return reject('Could not resample'); } const source = offlineContext.createBufferSource(); const audioBuffer = offlineContext.createBuffer(1, buffer.samples.length, buffer.sampleRate); diff --git a/src/lib/audio/audio-util.js b/src/lib/audio/audio-util.js index 199b66af93d..fba6be96bef 100644 --- a/src/lib/audio/audio-util.js +++ b/src/lib/audio/audio-util.js @@ -1,5 +1,4 @@ import WavEncoder from 'wav-encoder'; -import log from '../log.js'; const SOUND_BYTE_LIMIT = 10 * 1000 * 1000; // 10mb @@ -60,7 +59,21 @@ const encodeAndAddSoundToVM = function (vm, samples, sampleRate, name, callback) }); }; -const downsampleIfNeeded = (samples, sampleRate, resampler) => { +/** + @typedef SoundBuffer + @type {Object} + @property {Float32Array} samples Array of audio samples + @property {number} sampleRate Audio sample rate + */ + +/** + * Downsample the given buffer to try to reduce file size below SOUND_BYTE_LIMIT + * @param {SoundBuffer} buffer - Buffer to resample + * @param {function(SoundBuffer):Promise} resampler - resampler function + * @returns {SoundBuffer} Downsampled buffer with half the sample rate + */ +const downsampleIfNeeded = (buffer, resampler) => { + const {samples, sampleRate} = buffer; const duration = samples.length / sampleRate; const encodedByteLength = samples.length * 2; /* bitDepth 16 bit */ // Resolve immediately if already within byte limit @@ -76,8 +89,12 @@ const downsampleIfNeeded = (samples, sampleRate, resampler) => { return Promise.reject('Sound too large to save, refusing to edit'); }; -const backupDownSampler = (buffer, newRate) => { - log.warn(`Using backup down sampler for conversion from ${buffer.sampleRate} to ${newRate}`); +/** + * Drop every other sample of an audio buffer as a last-resort way of downsampling. + * @param {SoundBuffer} buffer - Buffer to resample + * @returns {SoundBuffer} Downsampled buffer with half the sample rate + */ +const dropEveryOtherSample = buffer => { const newLength = Math.floor(buffer.samples.length / 2); const newSamples = new Float32Array(newLength); for (let i = 0; i < newLength; i++) { @@ -85,7 +102,7 @@ const backupDownSampler = (buffer, newRate) => { } return { samples: newSamples, - sampleRate: newRate + sampleRate: buffer.rate / 2 }; }; @@ -94,5 +111,5 @@ export { computeChunkedRMS, encodeAndAddSoundToVM, downsampleIfNeeded, - backupDownSampler + dropEveryOtherSample }; diff --git a/test/unit/util/audio-util.test.js b/test/unit/util/audio-util.test.js index 01753b2ac14..79ff55e8b55 100644 --- a/test/unit/util/audio-util.test.js +++ b/test/unit/util/audio-util.test.js @@ -2,7 +2,7 @@ import { computeRMS, computeChunkedRMS, downsampleIfNeeded, - backupDownSampler + dropEveryOtherSample } from '../../../src/lib/audio/audio-util'; describe('computeRMS', () => { @@ -60,38 +60,38 @@ describe('downsampleIfNeeded', () => { const sampleRate = 44100; test('returns given data when no downsampling needed', async () => { samples.length = 1; - const res = await downsampleIfNeeded(samples, sampleRate, null); + const res = await downsampleIfNeeded({samples, sampleRate}, null); expect(res.samples).toEqual(samples); expect(res.sampleRate).toEqual(sampleRate); }); test('downsamples to 22050 if that puts it under the limit', async () => { samples.length = 44100 * 3 * 60; const resampler = jest.fn(() => 'TEST'); - const res = await downsampleIfNeeded(samples, sampleRate, resampler); + const res = await downsampleIfNeeded({samples, sampleRate}, resampler); expect(resampler).toHaveBeenCalledWith({samples, sampleRate}, 22050); expect(res).toEqual('TEST'); }); test('fails if resampling would not put it under the limit', async () => { samples.length = 44100 * 4 * 60; try { - await downsampleIfNeeded(samples, sampleRate, null); + await downsampleIfNeeded({samples, sampleRate}, null); } catch (e) { expect(e).toEqual('Sound too large to save, refusing to edit'); } }); }); -describe('backupDownSampler', () => { +describe('dropEveryOtherSample', () => { const buffer = { - samples: [1, 0, 1, 0, 1, 0, 1], + samples: [1, 0, 2, 0, 3, 0], sampleRate: 2 }; test('result is half the length', () => { - const {samples} = backupDownSampler(buffer, 1); + const {samples} = dropEveryOtherSample(buffer, 1); expect(samples.length).toEqual(Math.floor(buffer.samples.length / 2)); }); test('result contains only even-index items', () => { - const {samples} = backupDownSampler(buffer, 1); - expect(samples.every(v => v === 1)).toBe(true); + const {samples} = dropEveryOtherSample(buffer, 1); + expect(samples).toEqual(new Float32Array([1, 2, 3])); }); });