From d1d98337c8d777121c5a36e7135b484416dce033 Mon Sep 17 00:00:00 2001
From: Paul Kaplan <pkaplan@media.mit.edu>
Date: Thu, 19 Dec 2019 16:01:21 -0500
Subject: [PATCH] Update docs and tests

---
 src/containers/sound-editor.jsx   | 30 ++++++++++++++++--------------
 src/lib/audio/audio-util.js       | 29 +++++++++++++++++++++++------
 test/unit/util/audio-util.test.js | 18 +++++++++---------
 3 files changed, 48 insertions(+), 29 deletions(-)

diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx
index 61bf71224..3089b3dc3 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 199b66af9..fba6be96b 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<SoundBuffer>} 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 01753b2ac..79ff55e8b 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]));
     });
 });
-- 
GitLab