From 8fae109811afed4935fe409b296df0842b8d2c09 Mon Sep 17 00:00:00 2001
From: Paul Kaplan <pkaplan@media.mit.edu>
Date: Thu, 12 Dec 2019 12:30:12 -0500
Subject: [PATCH] Add downsampler

---
 src/containers/sound-editor.jsx   | 48 ++++++++++++++++---------------
 src/lib/audio/audio-util.js       | 21 +++++++++++++-
 test/unit/util/audio-util.test.js | 36 ++++++++++++++++++++++-
 3 files changed, 80 insertions(+), 25 deletions(-)

diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx
index 52726f2e0..0ce73b2f6 100644
--- a/src/containers/sound-editor.jsx
+++ b/src/containers/sound-editor.jsx
@@ -6,7 +6,11 @@ import VM from 'scratch-vm';
 
 import {connect} from 'react-redux';
 
-import {computeChunkedRMS, encodeAndAddSoundToVM, SOUND_BYTE_LIMIT} from '../lib/audio/audio-util.js';
+import {
+    computeChunkedRMS,
+    encodeAndAddSoundToVM,
+    downsampleIfNeeded
+} 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';
@@ -132,29 +136,27 @@ class SoundEditor extends React.Component {
         });
     }
     submitNewSamples (samples, sampleRate, skipUndo) {
-        return WavEncoder.encode({
-            sampleRate: sampleRate,
-            channelData: [samples]
-        })
-            .then(wavBuffer => {
-                if (wavBuffer.byteLength > SOUND_BYTE_LIMIT) {
-                    log.error(`Refusing to encode sound larger than ${SOUND_BYTE_LIMIT} bytes`);
-                    return Promise.reject();
-                }
-                if (!skipUndo) {
-                    this.redoStack = [];
-                    if (this.undoStack.length >= UNDO_STACK_SIZE) {
-                        this.undoStack.shift(); // Drop the first element off the array
+        return downsampleIfNeeded(samples, sampleRate, this.resampleBufferToRate)
+            .then(({samples: newSamples, sampleRate: newSampleRate}) =>
+                WavEncoder.encode({
+                    sampleRate: newSampleRate,
+                    channelData: [newSamples]
+                }).then(wavBuffer => {
+                    if (!skipUndo) {
+                        this.redoStack = [];
+                        if (this.undoStack.length >= UNDO_STACK_SIZE) {
+                            this.undoStack.shift(); // Drop the first element off the array
+                        }
+                        this.undoStack.push(this.getUndoItem());
                     }
-                    this.undoStack.push(this.getUndoItem());
-                }
-                this.resetState(samples, sampleRate);
-                this.props.vm.updateSoundBuffer(
-                    this.props.soundIndex,
-                    this.audioBufferPlayer.buffer,
-                    new Uint8Array(wavBuffer));
-                return true; // Edit was successful
-            })
+                    this.resetState(newSamples, newSampleRate);
+                    this.props.vm.updateSoundBuffer(
+                        this.props.soundIndex,
+                        this.audioBufferPlayer.buffer,
+                        new Uint8Array(wavBuffer));
+                    return true; // Edit was successful
+                })
+            )
             .catch(e => {
                 // Encoding failed, or the sound was too large to save so edit is rejected
                 log.error(`Encountered error while trying to encode sound update: ${e}`);
diff --git a/src/lib/audio/audio-util.js b/src/lib/audio/audio-util.js
index a01c30de8..2a4c3bb19 100644
--- a/src/lib/audio/audio-util.js
+++ b/src/lib/audio/audio-util.js
@@ -59,9 +59,28 @@ const encodeAndAddSoundToVM = function (vm, samples, sampleRate, name, callback)
     });
 };
 
+const downsampleIfNeeded = (samples, sampleRate, resampler) => {
+    const duration = samples.length / sampleRate;
+    const encodedByteLength = samples.length * 2; /* bitDepth 16 bit */
+    // Resolve immediately if already within byte limit
+    if (encodedByteLength < SOUND_BYTE_LIMIT) {
+        return Promise.resolve({samples, sampleRate});
+    }
+    // If encodeable at 22khz, resample and call submitNewSamples again
+    if (duration * 22050 * 2 < SOUND_BYTE_LIMIT) {
+        return resampler({samples, sampleRate}, 22050);
+    }
+    // If encodeable at 11khz, resample and call submitNewSamples again
+    if (duration * 11025 * 2 < SOUND_BYTE_LIMIT) {
+        return resampler({samples, sampleRate}, 11025);
+    }
+    // Cannot save this sound even at 11khz, refuse to edit
+    return Promise.reject('Sound too large to save, refusing to edit');
+};
+
 export {
     computeRMS,
     computeChunkedRMS,
     encodeAndAddSoundToVM,
-    SOUND_BYTE_LIMIT
+    downsampleIfNeeded
 };
diff --git a/test/unit/util/audio-util.test.js b/test/unit/util/audio-util.test.js
index 24ddcd455..876cf71e4 100644
--- a/test/unit/util/audio-util.test.js
+++ b/test/unit/util/audio-util.test.js
@@ -1,4 +1,4 @@
-import {computeRMS, computeChunkedRMS} from '../../../src/lib/audio/audio-util';
+import {computeRMS, computeChunkedRMS, downsampleIfNeeded} from '../../../src/lib/audio/audio-util';
 
 describe('computeRMS', () => {
     test('returns 0 when given no samples', () => {
@@ -49,3 +49,37 @@ describe('computeChunkedRMS', () => {
         expect(chunkedLevels).toEqual([Math.sqrt(1 / 0.55), Math.sqrt(1 / 0.55)]);
     });
 });
+
+describe('downsampleIfNeeded', () => {
+    const samples = {length: 1};
+    const sampleRate = 44100;
+    test('returns given data when no downsampling needed', async () => {
+        samples.length = 1;
+        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);
+        expect(resampler).toHaveBeenCalledWith({samples, sampleRate}, 22050);
+        expect(res).toEqual('TEST');
+    });
+    test('downsamples to 11025 if that puts it under the limit', async () => {
+        samples.length = 44100 * 7 * 60;
+        const resampler = jest.fn(() => 'TEST');
+        const res = await downsampleIfNeeded(samples, sampleRate, resampler);
+        expect(resampler).toHaveBeenCalledWith({samples, sampleRate}, 11025);
+        expect(res).toEqual('TEST');
+    });
+
+    test('fails if resampling would not put it under the limit', async () => {
+        samples.length = 44100 * 8 * 60;
+        try {
+            await downsampleIfNeeded(samples, sampleRate, null);
+        } catch (e) {
+            expect(e).toEqual('Sound too large to save, refusing to edit');
+        }
+    });
+});
-- 
GitLab