diff --git a/src/components/sound-editor/icon--copy-to-new.svg b/src/components/sound-editor/icon--copy-to-new.svg
new file mode 100644
index 000000000..8bfc76ac5
--- /dev/null
+++ b/src/components/sound-editor/icon--copy-to-new.svg
@@ -0,0 +1,18 @@
+
+
\ No newline at end of file
diff --git a/src/components/sound-editor/icon--copy.svg b/src/components/sound-editor/icon--copy.svg
new file mode 100644
index 000000000..4b902ad12
--- /dev/null
+++ b/src/components/sound-editor/icon--copy.svg
@@ -0,0 +1,32 @@
+
+
\ No newline at end of file
diff --git a/src/components/sound-editor/icon--delete.svg b/src/components/sound-editor/icon--delete.svg
new file mode 100644
index 000000000..8f662201e
--- /dev/null
+++ b/src/components/sound-editor/icon--delete.svg
@@ -0,0 +1,18 @@
+
+
\ No newline at end of file
diff --git a/src/components/sound-editor/icon--lounder.svg b/src/components/sound-editor/icon--lounder.svg
deleted file mode 100644
index a1022b4b8..000000000
--- a/src/components/sound-editor/icon--lounder.svg
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
\ No newline at end of file
diff --git a/src/components/sound-editor/icon--paste.svg b/src/components/sound-editor/icon--paste.svg
new file mode 100644
index 000000000..5d8bd9063
--- /dev/null
+++ b/src/components/sound-editor/icon--paste.svg
@@ -0,0 +1,18 @@
+
+
\ No newline at end of file
diff --git a/src/components/sound-editor/sound-editor.css b/src/components/sound-editor/sound-editor.css
index 279875fc8..406a26430 100644
--- a/src/components/sound-editor/sound-editor.css
+++ b/src/components/sound-editor/sound-editor.css
@@ -118,6 +118,7 @@ $border-radius: 0.25rem;
color: $text-primary;
font-size: 0.625rem;
user-select: none;
+
}
[dir="ltr"] .trim-button {
@@ -130,7 +131,6 @@ $border-radius: 0.25rem;
.trim-button > img {
width: 1.25rem;
- margin-bottom: -0.375rem;
}
.effect-button {
@@ -150,6 +150,23 @@ $border-radius: 0.25rem;
margin-bottom: -0.375rem;
}
+.tool-button {
+ flex-basis: 60px;
+ color: $text-primary;
+ font-size: 0.625rem;
+ user-select: none;
+}
+
+.tool-button + .tool-button {
+ margin: 0;
+}
+
+.tool-button img {
+ width: 4rem;
+ height: 1.25rem;
+ margin-bottom: -0.375rem;
+}
+
/* mirror the louder/softer speaker icons when rtl */
[dir="rtl"] .effect-button:nth-of-type(6) img {
transform: scaleX(-1);
diff --git a/src/components/sound-editor/sound-editor.jsx b/src/components/sound-editor/sound-editor.jsx
index cbd731f14..8306229eb 100644
--- a/src/components/sound-editor/sound-editor.jsx
+++ b/src/components/sound-editor/sound-editor.jsx
@@ -30,6 +30,11 @@ import fadeOutIcon from './icon--fade-out.svg';
import fadeInIcon from './icon--fade-in.svg';
import muteIcon from './icon--mute.svg';
+import deleteIcon from './icon--delete.svg';
+import copyIcon from './icon--copy.svg';
+import pasteIcon from './icon--paste.svg';
+import copyToNewIcon from './icon--copy-to-new.svg';
+
const BufferedInput = BufferedInputHOC(Input);
const messages = defineMessages({
@@ -163,17 +168,33 @@ const SoundEditor = props => (
+
+
+
+
+
- ) : (
-
- )}
- onClick={props.onActivateTrim}
+ className={styles.toolButton}
+ disabled={props.trimStart === null}
+ img={deleteIcon}
+ title={'Delete'}
+ onClick={props.onDelete}
/>
@@ -284,6 +305,7 @@ const SoundEditor = props => (
);
SoundEditor.propTypes = {
+ canPaste: PropTypes.bool.isRequired,
canRedo: PropTypes.bool.isRequired,
canUndo: PropTypes.bool.isRequired,
chunkLevels: PropTypes.arrayOf(PropTypes.number).isRequired,
@@ -292,12 +314,16 @@ SoundEditor.propTypes = {
onActivateTrim: PropTypes.func.isRequired,
onChangeName: PropTypes.func.isRequired,
onContainerClick: PropTypes.func.isRequired,
+ onCopy: PropTypes.func.isRequired,
+ onCopyToNew: PropTypes.func.isRequired,
+ onDelete: PropTypes.func,
onEcho: PropTypes.func.isRequired,
onFadeIn: PropTypes.func.isRequired,
onFadeOut: PropTypes.func.isRequired,
onFaster: PropTypes.func.isRequired,
onLouder: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
+ onPaste: PropTypes.func.isRequired,
onPlay: PropTypes.func.isRequired,
onRedo: PropTypes.func.isRequired,
onReverse: PropTypes.func.isRequired,
diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx
index d841b0869..253bf8cfb 100644
--- a/src/containers/sound-editor.jsx
+++ b/src/containers/sound-editor.jsx
@@ -2,6 +2,7 @@ import bindAll from 'lodash.bindall';
import PropTypes from 'prop-types';
import React from 'react';
import WavEncoder from 'wav-encoder';
+import VM from 'scratch-vm';
import {connect} from 'react-redux';
@@ -17,7 +18,9 @@ class SoundEditor extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
+ 'copy',
'copyCurrentBuffer',
+ 'handleCopyToNew',
'handleStoppedPlaying',
'handleChangeName',
'handlePlay',
@@ -29,10 +32,14 @@ class SoundEditor extends React.Component {
'handleUndo',
'handleRedo',
'submitNewSamples',
+ 'handleCopy',
+ 'handlePaste',
+ 'paste',
'handleContainerClick',
'setRef'
]);
this.state = {
+ copyBuffer: null,
chunkLevels: computeChunkedRMS(this.props.samples),
playhead: null, // null is not playing, [0 -> 1] is playing percent
trimStart: null,
@@ -213,6 +220,127 @@ class SoundEditor extends React.Component {
this.setState({trimStart: trimStart, trimEnd: trimEnd}, this.handlePlay);
}
}
+ handleCopy () {
+ this.copy();
+ }
+ copy (callback) {
+ const trimStart = this.state.trimStart === null ? 0.0 : this.state.trimStart;
+ const trimEnd = this.state.trimEnd === null ? 1.0 : this.state.trimEnd;
+
+ const trimStartSamples = trimStart * this.props.samples.length;
+ const trimEndSamples = trimEnd * this.props.samples.length;
+
+ const newCopyBuffer = this.copyCurrentBuffer();
+ newCopyBuffer.samples = newCopyBuffer.samples.slice(trimStartSamples, trimEndSamples);
+
+ this.setState({
+ copyBuffer: newCopyBuffer
+ }, callback);
+ }
+ handleCopyToNew () {
+ this.copy(this.copyToNew);
+ }
+ copyToNew () {
+ WavEncoder.encode({
+ sampleRate: this.state.copyBuffer.sampleRate,
+ channelData: [this.state.copyBuffer.samples]
+ }).then(wavBuffer => {
+ const vmSound = {
+ format: '',
+ dataFormat: 'wav',
+ rate: this.state.copyBuffer.sampleRate,
+ sampleCount: this.state.copyBuffer.samples.length
+ };
+
+ // Create an asset from the encoded .wav and get resulting md5
+ const storage = this.props.vm.runtime.storage;
+ vmSound.asset = storage.createAsset(
+ storage.AssetType.Sound,
+ storage.DataFormat.WAV,
+ new Uint8Array(wavBuffer),
+ null,
+ true // generate md5
+ );
+ vmSound.assetId = vmSound.asset.assetId;
+
+ // update vmSound object with md5 property
+ vmSound.md5 = `${vmSound.assetId}.${vmSound.dataFormat}`;
+ // The VM will update the sound name to a fresh name
+ vmSound.name = this.props.name;
+
+ this.props.vm.addSound(vmSound);
+ });
+ }
+ resampleBufferToRate (buffer, newRate) {
+ return new Promise(resolve => {
+ if (window.OfflineAudioContext) {
+ const sampleRateRatio = newRate / buffer.sampleRate;
+ const newLength = sampleRateRatio * buffer.samples.length;
+ const offlineContext = new window.OfflineAudioContext(1, newLength, newRate);
+ const source = offlineContext.createBufferSource();
+ const audioBuffer = this.audioContext.createBuffer(1, buffer.samples.length, buffer.sampleRate);
+ audioBuffer.getChannelData(0).set(buffer.samples);
+ source.buffer = audioBuffer;
+ source.connect(offlineContext.destination);
+ source.start();
+ offlineContext.startRendering();
+ offlineContext.oncomplete = ({renderedBuffer}) => {
+ resolve({
+ samples: renderedBuffer.getChannelData(0),
+ sampleRate: newRate
+ });
+ };
+ }
+ });
+ }
+ paste () {
+ // If there's no selection, paste at the end of the sound
+ if (this.state.trimStart === null) {
+ const newLength = this.props.samples.length + this.state.copyBuffer.samples.length;
+ const newSamples = new Float32Array(newLength);
+ newSamples.set(this.props.samples, 0);
+ newSamples.set(this.state.copyBuffer.samples, this.props.samples.length);
+ this.submitNewSamples(newSamples, this.props.sampleRate, false);
+ } else {
+ // else replace the selection with the pasted sound
+ const trimStartSamples = this.state.trimStart * this.props.samples.length;
+ const trimEndSamples = this.state.trimEnd * this.props.samples.length;
+ const firstPart = this.props.samples.slice(0, trimStartSamples);
+ const lastPart = this.props.samples.slice(trimEndSamples);
+ const newLength = firstPart.length + this.state.copyBuffer.samples.length + lastPart.length;
+ const newSamples = new Float32Array(newLength);
+ newSamples.set(firstPart, 0);
+ newSamples.set(this.state.copyBuffer.samples, firstPart.length);
+ newSamples.set(lastPart, firstPart.length + this.state.copyBuffer.samples.length);
+
+ const trimStartSeconds = trimStartSamples / this.props.sampleRate;
+ const trimEndSeconds = trimStartSeconds +
+ (this.state.copyBuffer.samples.length / this.state.copyBuffer.sampleRate);
+ const newDurationSeconds = newSamples.length / this.state.copyBuffer.sampleRate;
+ const adjustedTrimStart = trimStartSeconds / newDurationSeconds;
+ const adjustedTrimEnd = trimEndSeconds / newDurationSeconds;
+ this.setState({
+ trimStart: adjustedTrimStart,
+ trimEnd: adjustedTrimEnd
+ });
+
+ this.submitNewSamples(newSamples, this.props.sampleRate, false);
+ }
+
+ this.handlePlay();
+ }
+ handlePaste () {
+ if (!this.state.copyBuffer) return;
+ if (this.state.copyBuffer.sampleRate === this.props.sampleRate) {
+ this.paste();
+ } else {
+ this.resampleBufferToRate(this.state.copyBuffer, this.props.sampleRate).then(buffer => {
+ this.setState({
+ copyBuffer: buffer
+ }, this.paste);
+ });
+ }
+ }
setRef (element) {
this.ref = element;
}
@@ -226,6 +354,7 @@ class SoundEditor extends React.Component {
const {effectTypes} = AudioEffects;
return (
0}
canUndo={this.undoStack.length > 0}
chunkLevels={this.state.chunkLevels}
@@ -237,6 +366,8 @@ class SoundEditor extends React.Component {
onActivateTrim={this.handleDelete}
onChangeName={this.handleChangeName}
onContainerClick={this.handleContainerClick}
+ onCopy={this.handleCopy}
+ onCopyToNew={this.handleCopyToNew}
onDelete={this.handleDelete}
onEcho={this.effectFactory(effectTypes.ECHO)}
onFadeIn={this.effectFactory(effectTypes.FADEIN)}
@@ -244,6 +375,7 @@ class SoundEditor extends React.Component {
onFaster={this.effectFactory(effectTypes.FASTER)}
onLouder={this.effectFactory(effectTypes.LOUDER)}
onMute={this.effectFactory(effectTypes.MUTE)}
+ onPaste={this.handlePaste}
onPlay={this.handlePlay}
onRedo={this.handleRedo}
onReverse={this.effectFactory(effectTypes.REVERSE)}
@@ -264,10 +396,7 @@ SoundEditor.propTypes = {
samples: PropTypes.instanceOf(Float32Array),
soundId: PropTypes.string,
soundIndex: PropTypes.number,
- vm: PropTypes.shape({
- updateSoundBuffer: PropTypes.func,
- renameSound: PropTypes.func
- })
+ vm: PropTypes.instanceOf(VM).isRequired
};
const mapStateToProps = (state, {soundIndex}) => {