scratch-gui
Version:
GraphicaL User Interface for creating and running Scratch 3.0 projects
231 lines (221 loc) • 8.33 kB
JSX
import bindAll from 'lodash.bindall';
import PropTypes from 'prop-types';
import React from 'react';
import WavEncoder from 'wav-encoder';
import {connect} from 'react-redux';
import analytics from '../lib/analytics';
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';
import log from '../lib/log.js';
const UNDO_STACK_SIZE = 99;
class SoundEditor extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'copyCurrentBuffer',
'handleStoppedPlaying',
'handleChangeName',
'handlePlay',
'handleStopPlaying',
'handleUpdatePlayhead',
'handleActivateTrim',
'handleUpdateTrimEnd',
'handleUpdateTrimStart',
'handleEffect',
'handleUndo',
'handleRedo',
'submitNewSamples'
]);
this.state = {
chunkLevels: computeChunkedRMS(this.props.samples),
playhead: null, // null is not playing, [0 -> 1] is playing percent
trimStart: null,
trimEnd: null
};
this.redoStack = [];
this.undoStack = [];
}
componentDidMount () {
this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples, this.props.sampleRate);
analytics.pageview('/editors/sound');
}
componentWillReceiveProps (newProps) {
if (newProps.soundId !== this.props.soundId) { // A different sound has been selected
this.redoStack = [];
this.undoStack = [];
this.resetState(newProps.samples, newProps.sampleRate);
}
}
componentWillUnmount () {
this.audioBufferPlayer.stop();
}
resetState (samples, sampleRate) {
this.audioBufferPlayer.stop();
this.audioBufferPlayer = new AudioBufferPlayer(samples, sampleRate);
this.setState({
chunkLevels: computeChunkedRMS(samples),
playhead: null,
trimStart: null,
trimEnd: null
});
}
submitNewSamples (samples, sampleRate, skipUndo) {
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.copyCurrentBuffer());
}
// Encode the new sound into a wav so that it can be stored
let wavBuffer = null;
try {
wavBuffer = WavEncoder.encode.sync({
sampleRate: sampleRate,
channelData: [samples]
});
} catch (e) {
// This error state is mostly for the mock sounds used during testing.
// Any incorrect sound buffer trying to get interpretd as a Wav file
// should yield this error.
log.error(`Encountered error while trying to encode sound update: ${e}`);
}
this.resetState(samples, sampleRate);
this.props.vm.updateSoundBuffer(
this.props.soundIndex,
this.audioBufferPlayer.buffer,
wavBuffer ? new Uint8Array(wavBuffer) : new Uint8Array());
}
handlePlay () {
this.audioBufferPlayer.play(
this.state.trimStart || 0,
this.state.trimEnd || 1,
this.handleUpdatePlayhead,
this.handleStoppedPlaying);
}
handleStopPlaying () {
this.audioBufferPlayer.stop();
this.handleStoppedPlaying();
}
handleStoppedPlaying () {
this.setState({playhead: null});
}
handleUpdatePlayhead (playhead) {
this.setState({playhead});
}
handleChangeName (name) {
this.props.vm.renameSound(this.props.soundIndex, name);
}
handleActivateTrim () {
if (this.state.trimStart === null && this.state.trimEnd === null) {
this.setState({trimEnd: 0.95, trimStart: 0.05});
} else {
const {samples, sampleRate} = this.copyCurrentBuffer();
const sampleCount = samples.length;
const startIndex = Math.floor(this.state.trimStart * sampleCount);
const endIndex = Math.floor(this.state.trimEnd * sampleCount);
const clippedSamples = samples.slice(startIndex, endIndex);
this.submitNewSamples(clippedSamples, sampleRate);
}
}
handleUpdateTrimEnd (trimEnd) {
this.setState({trimEnd});
}
handleUpdateTrimStart (trimStart) {
this.setState({trimStart});
}
effectFactory (name) {
return () => this.handleEffect(name);
}
copyCurrentBuffer () {
// Cannot reliably use props.samples because it gets detached by Firefox
return {
samples: this.audioBufferPlayer.buffer.getChannelData(0),
sampleRate: this.audioBufferPlayer.buffer.sampleRate
};
}
handleEffect (name) {
const effects = new AudioEffects(this.audioBufferPlayer.buffer, name);
effects.process(({renderedBuffer}) => {
const samples = renderedBuffer.getChannelData(0);
const sampleRate = renderedBuffer.sampleRate;
this.submitNewSamples(samples, sampleRate);
this.handlePlay();
});
}
handleUndo () {
this.redoStack.push(this.copyCurrentBuffer());
const {samples, sampleRate} = this.undoStack.pop();
if (samples) {
this.submitNewSamples(samples, sampleRate, true);
this.handlePlay();
}
}
handleRedo () {
const {samples, sampleRate} = this.redoStack.pop();
if (samples) {
this.undoStack.push(this.copyCurrentBuffer());
this.submitNewSamples(samples, sampleRate, true);
this.handlePlay();
}
}
render () {
const {effectTypes} = AudioEffects;
return (
<SoundEditorComponent
canRedo={this.redoStack.length > 0}
canUndo={this.undoStack.length > 0}
chunkLevels={this.state.chunkLevels}
name={this.props.name}
playhead={this.state.playhead}
trimEnd={this.state.trimEnd}
trimStart={this.state.trimStart}
onActivateTrim={this.handleActivateTrim}
onChangeName={this.handleChangeName}
onEcho={this.effectFactory(effectTypes.ECHO)}
onFaster={this.effectFactory(effectTypes.FASTER)}
onLouder={this.effectFactory(effectTypes.LOUDER)}
onPlay={this.handlePlay}
onRedo={this.handleRedo}
onReverse={this.effectFactory(effectTypes.REVERSE)}
onRobot={this.effectFactory(effectTypes.ROBOT)}
onSetTrimEnd={this.handleUpdateTrimEnd}
onSetTrimStart={this.handleUpdateTrimStart}
onSlower={this.effectFactory(effectTypes.SLOWER)}
onSofter={this.effectFactory(effectTypes.SOFTER)}
onStop={this.handleStopPlaying}
onUndo={this.handleUndo}
/>
);
}
}
SoundEditor.propTypes = {
name: PropTypes.string.isRequired,
sampleRate: PropTypes.number,
samples: PropTypes.instanceOf(Float32Array),
soundId: PropTypes.string,
soundIndex: PropTypes.number,
vm: PropTypes.shape({
updateSoundBuffer: PropTypes.func,
renameSound: PropTypes.func
})
};
const mapStateToProps = (state, {soundIndex}) => {
const sprite = state.vm.editingTarget.sprite;
// Make sure the sound index doesn't go out of range.
const index = soundIndex < sprite.sounds.length ? soundIndex : sprite.sounds.length - 1;
const sound = state.vm.editingTarget.sprite.sounds[index];
const audioBuffer = state.vm.getSoundBuffer(index);
return {
soundId: sound.soundId,
sampleRate: audioBuffer.sampleRate,
samples: audioBuffer.getChannelData(0),
name: sound.name,
vm: state.vm
};
};
export default connect(
mapStateToProps
)(SoundEditor);