Skip to content

Commit fa5994a

Browse files
authored
Merge pull request scratchfoundation#5377 from paulkaplan/fix-long-sound
[fixup] Implement sound downsampler to try to allow more sounds to be edited
2 parents 1dcba6e + 76f5cfe commit fa5994a

File tree

5 files changed

+225
-99
lines changed

5 files changed

+225
-99
lines changed

src/containers/sound-editor.jsx

Lines changed: 109 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import VM from 'scratch-vm';
66

77
import {connect} from 'react-redux';
88

9-
import {computeChunkedRMS, encodeAndAddSoundToVM, SOUND_BYTE_LIMIT} from '../lib/audio/audio-util.js';
9+
import {
10+
computeChunkedRMS,
11+
encodeAndAddSoundToVM,
12+
downsampleIfNeeded,
13+
dropEveryOtherSample
14+
} from '../lib/audio/audio-util.js';
1015
import AudioEffects from '../lib/audio/audio-effects.js';
1116
import SoundEditorComponent from '../components/sound-editor/sound-editor.jsx';
1217
import AudioBufferPlayer from '../lib/audio/audio-buffer-player.js';
@@ -39,7 +44,8 @@ class SoundEditor extends React.Component {
3944
'paste',
4045
'handleKeyPress',
4146
'handleContainerClick',
42-
'setRef'
47+
'setRef',
48+
'resampleBufferToRate'
4349
]);
4450
this.state = {
4551
copyBuffer: null,
@@ -132,45 +138,32 @@ class SoundEditor extends React.Component {
132138
});
133139
}
134140
submitNewSamples (samples, sampleRate, skipUndo) {
135-
// Encode the new sound into a wav so that it can be stored
136-
let wavBuffer = null;
137-
try {
138-
wavBuffer = WavEncoder.encode.sync({
139-
sampleRate: sampleRate,
140-
channelData: [samples]
141+
return downsampleIfNeeded({samples, sampleRate}, this.resampleBufferToRate)
142+
.then(({samples: newSamples, sampleRate: newSampleRate}) =>
143+
WavEncoder.encode({
144+
sampleRate: newSampleRate,
145+
channelData: [newSamples]
146+
}).then(wavBuffer => {
147+
if (!skipUndo) {
148+
this.redoStack = [];
149+
if (this.undoStack.length >= UNDO_STACK_SIZE) {
150+
this.undoStack.shift(); // Drop the first element off the array
151+
}
152+
this.undoStack.push(this.getUndoItem());
153+
}
154+
this.resetState(newSamples, newSampleRate);
155+
this.props.vm.updateSoundBuffer(
156+
this.props.soundIndex,
157+
this.audioBufferPlayer.buffer,
158+
new Uint8Array(wavBuffer));
159+
return true; // Edit was successful
160+
})
161+
)
162+
.catch(e => {
163+
// Encoding failed, or the sound was too large to save so edit is rejected
164+
log.error(`Encountered error while trying to encode sound update: ${e}`);
165+
return false; // Edit was not applied
141166
});
142-
143-
if (wavBuffer.byteLength > SOUND_BYTE_LIMIT) {
144-
// Cancel the sound update by setting to null
145-
wavBuffer = null;
146-
log.error(`Refusing to encode sound larger than ${SOUND_BYTE_LIMIT} bytes`);
147-
}
148-
} catch (e) {
149-
// This error state is mostly for the mock sounds used during testing.
150-
// Any incorrect sound buffer trying to get interpretd as a Wav file
151-
// should yield this error.
152-
// This can also happen if the sound is too be allocated in memory.
153-
log.error(`Encountered error while trying to encode sound update: ${e}`);
154-
}
155-
156-
// Do not submit sound if it could not be encoded (i.e. if too large)
157-
if (wavBuffer) {
158-
if (!skipUndo) {
159-
this.redoStack = [];
160-
if (this.undoStack.length >= UNDO_STACK_SIZE) {
161-
this.undoStack.shift(); // Drop the first element off the array
162-
}
163-
this.undoStack.push(this.getUndoItem());
164-
}
165-
this.resetState(samples, sampleRate);
166-
this.props.vm.updateSoundBuffer(
167-
this.props.soundIndex,
168-
this.audioBufferPlayer.buffer,
169-
new Uint8Array(wavBuffer));
170-
171-
return true; // Update succeeded
172-
}
173-
return false; // Update failed
174167
}
175168
handlePlay () {
176169
this.audioBufferPlayer.stop();
@@ -209,13 +202,15 @@ class SoundEditor extends React.Component {
209202
newSamples.set(firstPart, 0);
210203
newSamples.set(secondPart, firstPart.length);
211204
}
212-
this.submitNewSamples(newSamples, sampleRate);
213-
this.setState({
214-
trimStart: null,
215-
trimEnd: null
205+
this.submitNewSamples(newSamples, sampleRate).then(() => {
206+
this.setState({
207+
trimStart: null,
208+
trimEnd: null
209+
});
216210
});
217211
}
218212
handleDeleteInverse () {
213+
// Delete everything outside of the trimmers
219214
const {samples, sampleRate} = this.copyCurrentBuffer();
220215
const sampleCount = samples.length;
221216
const startIndex = Math.floor(this.state.trimStart * sampleCount);
@@ -224,10 +219,13 @@ class SoundEditor extends React.Component {
224219
if (clippedSamples.length === 0) {
225220
clippedSamples = new Float32Array(1);
226221
}
227-
this.submitNewSamples(clippedSamples, sampleRate);
228-
this.setState({
229-
trimStart: null,
230-
trimEnd: null
222+
this.submitNewSamples(clippedSamples, sampleRate).then(success => {
223+
if (success) {
224+
this.setState({
225+
trimStart: null,
226+
trimEnd: null
227+
});
228+
}
231229
});
232230
}
233231
handleUpdateTrim (trimStart, trimEnd) {
@@ -257,14 +255,15 @@ class SoundEditor extends React.Component {
257255
effects.process((renderedBuffer, adjustedTrimStart, adjustedTrimEnd) => {
258256
const samples = renderedBuffer.getChannelData(0);
259257
const sampleRate = renderedBuffer.sampleRate;
260-
const success = this.submitNewSamples(samples, sampleRate);
261-
if (success) {
262-
if (this.state.trimStart === null) {
263-
this.handlePlay();
264-
} else {
265-
this.setState({trimStart: adjustedTrimStart, trimEnd: adjustedTrimEnd}, this.handlePlay);
258+
this.submitNewSamples(samples, sampleRate).then(success => {
259+
if (success) {
260+
if (this.state.trimStart === null) {
261+
this.handlePlay();
262+
} else {
263+
this.setState({trimStart: adjustedTrimStart, trimEnd: adjustedTrimEnd}, this.handlePlay);
264+
}
266265
}
267-
}
266+
});
268267
});
269268
}
270269
tooLoud () {
@@ -287,16 +286,22 @@ class SoundEditor extends React.Component {
287286
this.redoStack.push(this.getUndoItem());
288287
const {samples, sampleRate, trimStart, trimEnd} = this.undoStack.pop();
289288
if (samples) {
290-
this.submitNewSamples(samples, sampleRate, true);
291-
this.setState({trimStart: trimStart, trimEnd: trimEnd}, this.handlePlay);
289+
return this.submitNewSamples(samples, sampleRate, true).then(success => {
290+
if (success) {
291+
this.setState({trimStart: trimStart, trimEnd: trimEnd}, this.handlePlay);
292+
}
293+
});
292294
}
293295
}
294296
handleRedo () {
295297
const {samples, sampleRate, trimStart, trimEnd} = this.redoStack.pop();
296298
if (samples) {
297299
this.undoStack.push(this.getUndoItem());
298-
this.submitNewSamples(samples, sampleRate, true);
299-
this.setState({trimStart: trimStart, trimEnd: trimEnd}, this.handlePlay);
300+
return this.submitNewSamples(samples, sampleRate, true).then(success => {
301+
if (success) {
302+
this.setState({trimStart: trimStart, trimEnd: trimEnd}, this.handlePlay);
303+
}
304+
});
300305
}
301306
}
302307
handleCopy () {
@@ -322,25 +327,39 @@ class SoundEditor extends React.Component {
322327
});
323328
}
324329
resampleBufferToRate (buffer, newRate) {
325-
return new Promise(resolve => {
326-
if (window.OfflineAudioContext) {
327-
const sampleRateRatio = newRate / buffer.sampleRate;
328-
const newLength = sampleRateRatio * buffer.samples.length;
329-
const offlineContext = new window.OfflineAudioContext(1, newLength, newRate);
330-
const source = offlineContext.createBufferSource();
331-
const audioBuffer = offlineContext.createBuffer(1, buffer.samples.length, buffer.sampleRate);
332-
audioBuffer.getChannelData(0).set(buffer.samples);
333-
source.buffer = audioBuffer;
334-
source.connect(offlineContext.destination);
335-
source.start();
336-
offlineContext.startRendering();
337-
offlineContext.oncomplete = ({renderedBuffer}) => {
338-
resolve({
339-
samples: renderedBuffer.getChannelData(0),
340-
sampleRate: newRate
341-
});
342-
};
330+
return new Promise((resolve, reject) => {
331+
const sampleRateRatio = newRate / buffer.sampleRate;
332+
const newLength = sampleRateRatio * buffer.samples.length;
333+
let offlineContext;
334+
// Try to use either OfflineAudioContext or webkitOfflineAudioContext to resample
335+
// The constructors will throw if trying to resample at an unsupported rate
336+
// (e.g. Safari/webkitOAC does not support lower than 44khz).
337+
try {
338+
if (window.OfflineAudioContext) {
339+
offlineContext = new window.OfflineAudioContext(1, newLength, newRate);
340+
} else if (window.webkitOfflineAudioContext) {
341+
offlineContext = new window.webkitOfflineAudioContext(1, newLength, newRate);
342+
}
343+
} catch {
344+
// If no OAC available and downsampling by 2, downsample by dropping every other sample.
345+
if (newRate === buffer.sampleRate / 2) {
346+
return resolve(dropEveryOtherSample(buffer));
347+
}
348+
return reject('Could not resample');
343349
}
350+
const source = offlineContext.createBufferSource();
351+
const audioBuffer = offlineContext.createBuffer(1, buffer.samples.length, buffer.sampleRate);
352+
audioBuffer.getChannelData(0).set(buffer.samples);
353+
source.buffer = audioBuffer;
354+
source.connect(offlineContext.destination);
355+
source.start();
356+
offlineContext.startRendering();
357+
offlineContext.oncomplete = ({renderedBuffer}) => {
358+
resolve({
359+
samples: renderedBuffer.getChannelData(0),
360+
sampleRate: newRate
361+
});
362+
};
344363
});
345364
}
346365
paste () {
@@ -351,8 +370,11 @@ class SoundEditor extends React.Component {
351370
const newSamples = new Float32Array(newLength);
352371
newSamples.set(samples, 0);
353372
newSamples.set(this.state.copyBuffer.samples, samples.length);
354-
this.submitNewSamples(newSamples, this.props.sampleRate, false);
355-
this.handlePlay();
373+
this.submitNewSamples(newSamples, this.props.sampleRate, false).then(success => {
374+
if (success) {
375+
this.handlePlay();
376+
}
377+
});
356378
} else {
357379
// else replace the selection with the pasted sound
358380
const trimStartSamples = this.state.trimStart * samples.length;
@@ -371,11 +393,14 @@ class SoundEditor extends React.Component {
371393
const newDurationSeconds = newSamples.length / this.state.copyBuffer.sampleRate;
372394
const adjustedTrimStart = trimStartSeconds / newDurationSeconds;
373395
const adjustedTrimEnd = trimEndSeconds / newDurationSeconds;
374-
this.submitNewSamples(newSamples, this.props.sampleRate, false);
375-
this.setState({
376-
trimStart: adjustedTrimStart,
377-
trimEnd: adjustedTrimEnd
378-
}, this.handlePlay);
396+
this.submitNewSamples(newSamples, this.props.sampleRate, false).then(success => {
397+
if (success) {
398+
this.setState({
399+
trimStart: adjustedTrimStart,
400+
trimEnd: adjustedTrimEnd
401+
}, this.handlePlay);
402+
}
403+
});
379404
}
380405
}
381406
handlePaste () {

src/lib/audio/audio-util.js

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,57 @@ const encodeAndAddSoundToVM = function (vm, samples, sampleRate, name, callback)
5959
});
6060
};
6161

62+
/**
63+
@typedef SoundBuffer
64+
@type {Object}
65+
@property {Float32Array} samples Array of audio samples
66+
@property {number} sampleRate Audio sample rate
67+
*/
68+
69+
/**
70+
* Downsample the given buffer to try to reduce file size below SOUND_BYTE_LIMIT
71+
* @param {SoundBuffer} buffer - Buffer to resample
72+
* @param {function(SoundBuffer):Promise<SoundBuffer>} resampler - resampler function
73+
* @returns {SoundBuffer} Downsampled buffer with half the sample rate
74+
*/
75+
const downsampleIfNeeded = (buffer, resampler) => {
76+
const {samples, sampleRate} = buffer;
77+
const duration = samples.length / sampleRate;
78+
const encodedByteLength = samples.length * 2; /* bitDepth 16 bit */
79+
// Resolve immediately if already within byte limit
80+
if (encodedByteLength < SOUND_BYTE_LIMIT) {
81+
return Promise.resolve({samples, sampleRate});
82+
}
83+
// If encodeable at 22khz, resample and call submitNewSamples again
84+
if (duration * 22050 * 2 < SOUND_BYTE_LIMIT) {
85+
return resampler({samples, sampleRate}, 22050);
86+
}
87+
// Cannot save this sound at 22khz, refuse to edit
88+
// In the future we could introduce further compression here
89+
return Promise.reject('Sound too large to save, refusing to edit');
90+
};
91+
92+
/**
93+
* Drop every other sample of an audio buffer as a last-resort way of downsampling.
94+
* @param {SoundBuffer} buffer - Buffer to resample
95+
* @returns {SoundBuffer} Downsampled buffer with half the sample rate
96+
*/
97+
const dropEveryOtherSample = buffer => {
98+
const newLength = Math.floor(buffer.samples.length / 2);
99+
const newSamples = new Float32Array(newLength);
100+
for (let i = 0; i < newLength; i++) {
101+
newSamples[i] = buffer.samples[i * 2];
102+
}
103+
return {
104+
samples: newSamples,
105+
sampleRate: buffer.sampleRate / 2
106+
};
107+
};
108+
62109
export {
63110
computeRMS,
64111
computeChunkedRMS,
65112
encodeAndAddSoundToVM,
66-
SOUND_BYTE_LIMIT
113+
downsampleIfNeeded,
114+
dropEveryOtherSample
67115
};

test/__mocks__/audio-effects.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ export default class MockAudioEffects {
1414
this.buffer = buffer;
1515
this.name = name;
1616
this.process = jest.fn(done => {
17-
this._finishProcessing = renderedBuffer => done(renderedBuffer, 0, 1);
17+
this._finishProcessing = renderedBuffer => {
18+
done(renderedBuffer, 0, 1);
19+
return new Promise(resolve => setTimeout(resolve));
20+
};
1821
});
1922
MockAudioEffects.instance = this;
2023
}

0 commit comments

Comments
 (0)