From b9c6f3fb44200e134fee8c545a6cff47625d2d1b Mon Sep 17 00:00:00 2001
From: Paul Kaplan <pkaplan@media.mit.edu>
Date: Mon, 31 Jul 2017 15:22:17 -0400
Subject: [PATCH] Add trimming and tests to the sound editor

---
 .../audio-trimmer/audio-trimmer.jsx           |  70 ++--
 src/components/sound-editor/icon--trim.svg    | Bin 0 -> 989 bytes
 src/components/sound-editor/sound-editor.css  |  21 +-
 src/components/sound-editor/sound-editor.jsx  |  55 +++-
 src/containers/audio-trimmer.jsx              |   2 +
 src/containers/sound-editor.jsx               |  61 +++-
 test/__mocks__/audio-buffer-player.js         |  12 +
 .../__snapshots__/sound-editor.test.jsx.snap  | 311 ++++++++++++++++++
 test/unit/components/sound-editor.test.jsx    |  63 ++++
 test/unit/containers/sound-editor.test.jsx    |  93 ++++++
 10 files changed, 630 insertions(+), 58 deletions(-)
 create mode 100644 src/components/sound-editor/icon--trim.svg
 create mode 100644 test/__mocks__/audio-buffer-player.js
 create mode 100644 test/unit/components/__snapshots__/sound-editor.test.jsx.snap
 create mode 100644 test/unit/components/sound-editor.test.jsx
 create mode 100644 test/unit/containers/sound-editor.test.jsx

diff --git a/src/components/audio-trimmer/audio-trimmer.jsx b/src/components/audio-trimmer/audio-trimmer.jsx
index d414f3ae2..fc216063a 100644
--- a/src/components/audio-trimmer/audio-trimmer.jsx
+++ b/src/components/audio-trimmer/audio-trimmer.jsx
@@ -10,23 +10,25 @@ const AudioTrimmer = props => (
         className={styles.absolute}
         ref={props.containerRef}
     >
-        <Box
-            className={classNames(styles.absolute, styles.trimBackground, styles.startTrimBackground)}
-            style={{
-                width: `${100 * props.trimStart}%`
-            }}
-            onMouseDown={props.onTrimStartMouseDown}
-        >
-            <Box className={classNames(styles.absolute, styles.trimBackgroundMask)} />
-            <Box className={classNames(styles.trimLine, styles.startTrimLine)}>
-                <Box className={classNames(styles.trimHandle, styles.topTrimHandle, styles.startTrimHandle)}>
-                    <img src={handleIcon} />
-                </Box>
-                <Box className={classNames(styles.trimHandle, styles.bottomTrimHandle, styles.startTrimHandle)}>
-                    <img src={handleIcon} />
+        {props.trimStart !== null ? (
+            <Box
+                className={classNames(styles.absolute, styles.trimBackground, styles.startTrimBackground)}
+                style={{
+                    width: `${100 * props.trimStart}%`
+                }}
+                onMouseDown={props.onTrimStartMouseDown}
+            >
+                <Box className={classNames(styles.absolute, styles.trimBackgroundMask)} />
+                <Box className={classNames(styles.trimLine, styles.startTrimLine)}>
+                    <Box className={classNames(styles.trimHandle, styles.topTrimHandle, styles.startTrimHandle)}>
+                        <img src={handleIcon} />
+                    </Box>
+                    <Box className={classNames(styles.trimHandle, styles.bottomTrimHandle, styles.startTrimHandle)}>
+                        <img src={handleIcon} />
+                    </Box>
                 </Box>
             </Box>
-        </Box>
+        ) : null}
 
         {props.playhead ? (
             <Box
@@ -37,24 +39,26 @@ const AudioTrimmer = props => (
             />
         ) : null}
 
-        <Box
-            className={classNames(styles.absolute, styles.trimBackground, styles.endTrimBackground)}
-            style={{
-                left: `${100 * props.trimEnd}%`,
-                width: `${100 - 100 * props.trimEnd}%`
-            }}
-            onMouseDown={props.onTrimEndMouseDown}
-        >
-            <Box className={classNames(styles.absolute, styles.trimBackgroundMask)} />
-            <Box className={classNames(styles.trimLine, styles.endTrimLine)}>
-                <Box className={classNames(styles.trimHandle, styles.topTrimHandle, styles.endTrimHandle)}>
-                    <img src={handleIcon} />
-                </Box>
-                <Box className={classNames(styles.trimHandle, styles.bottomTrimHandle, styles.endTrimHandle)}>
-                    <img src={handleIcon} />
+        {props.trimEnd !== null ? (
+            <Box
+                className={classNames(styles.absolute, styles.trimBackground, styles.endTrimBackground)}
+                style={{
+                    left: `${100 * props.trimEnd}%`,
+                    width: `${100 - 100 * props.trimEnd}%`
+                }}
+                onMouseDown={props.onTrimEndMouseDown}
+            >
+                <Box className={classNames(styles.absolute, styles.trimBackgroundMask)} />
+                <Box className={classNames(styles.trimLine, styles.endTrimLine)}>
+                    <Box className={classNames(styles.trimHandle, styles.topTrimHandle, styles.endTrimHandle)}>
+                        <img src={handleIcon} />
+                    </Box>
+                    <Box className={classNames(styles.trimHandle, styles.bottomTrimHandle, styles.endTrimHandle)}>
+                        <img src={handleIcon} />
+                    </Box>
                 </Box>
             </Box>
-        </Box>
+        ) : null }
     </div>
 );
 
@@ -63,8 +67,8 @@ AudioTrimmer.propTypes = {
     onTrimEndMouseDown: PropTypes.func.isRequired,
     onTrimStartMouseDown: PropTypes.func.isRequired,
     playhead: PropTypes.number,
-    trimEnd: PropTypes.number.isRequired,
-    trimStart: PropTypes.number.isRequired
+    trimEnd: PropTypes.number,
+    trimStart: PropTypes.number
 };
 
 export default AudioTrimmer;
diff --git a/src/components/sound-editor/icon--trim.svg b/src/components/sound-editor/icon--trim.svg
new file mode 100644
index 0000000000000000000000000000000000000000..cf64941cab62829199fa7d895442c4c17f506c57
GIT binary patch
literal 989
zcmc&zO>f&U488AH2;SK=DN(XiBsnxdPw9^sk}M72G)3&iMSguB<sEw8VSsH><m2O~
zSL6NU?&stDeEnQEt-4m6Bs`7#ybSN-x_O(XkEgDi=b7d}*W0P{%-q4R*{r7XG#oeM
zc^t2|an&u?)ye%n55v0osoT=OyfpRN-u?{7b#uHQ->-)Qd_R6oZ|<;eUL)mRoiGXe
zeM=g8$1U~6Q%K91IXQNu8Yx7;xvyqBE85{2TEP7xz+e=>Lm5E=0%HOO(uEyx61Pej
zaXn?}9R#70>|`qNS63*<>?)|ZCCRcE;-E-IkTi4=y)p^k!nx`>S{U`FlGdz-De`LK
zf2s3ZnMk~riE}BFmog1fYAHDXrN~g5mr0||(xYG-M8P1u$EQk>tJb4|-nSJ0YJsj(
z#^8a4(o-~r&*qE^s}Tef9+#F_eH$o+0U|5jyn|<-DajTB2CD&+X6XqdL-!TyB0%>7
z*p?!og^@!EutqFb6yU`VM&lB3+7p~LDcNQj2WNN(sKD#Z-Ez}hEf_m&12CoxJ!UT{
zB1Mt>z?R3PAPpt$l+*>pNLVWcx$F@O-H}Wli|P~l2R12rSd!ht84hSdMw%JXleeH6
T((W7aqv<xQ?t~Y==jO*3g|pL&

literal 0
HcmV?d00001

diff --git a/src/components/sound-editor/sound-editor.css b/src/components/sound-editor/sound-editor.css
index 0bb44f115..1d67886da 100644
--- a/src/components/sound-editor/sound-editor.css
+++ b/src/components/sound-editor/sound-editor.css
@@ -36,17 +36,36 @@
 }
 
 .button {
-    width: 2rem;
     height: 2rem;
     padding: 0.25rem;
     outline: none;
     background: white;
     border-radius: 0.25rem;
     border: 1px solid #ddd;
+    cursor: pointer;
+    font-size: 0.85rem;
+    transition: 0.2s;
 }
 
 .button img {
     flex-grow: 1;
     max-width: 100%;
     max-height: 100%;
+    min-width: 1.5rem;
+}
+
+.trim-button {
+    display: flex;
+    align-items: center;
+    padding-right: 10px; /* To equalize with empty whitespace from image on left */
+}
+
+.trim-button-active {
+    filter: hue-rotate(155deg); /* @todo replace blue -> red with real submit icon */
+}
+
+.input-group-right {
+    flex-grow: 1;
+    display: flex;
+    flex-direction: row-reverse;
 }
diff --git a/src/components/sound-editor/sound-editor.jsx b/src/components/sound-editor/sound-editor.jsx
index 3e379b3f5..2168ee651 100644
--- a/src/components/sound-editor/sound-editor.jsx
+++ b/src/components/sound-editor/sound-editor.jsx
@@ -1,26 +1,28 @@
 import PropTypes from 'prop-types';
 import React from 'react';
 import classNames from 'classnames';
-import Box from '../box/box.jsx';
 import Waveform from '../waveform/waveform.jsx';
 import Label from '../forms/label.jsx';
 import Input from '../forms/input.jsx';
 import BufferedInputHOC from '../forms/buffered-input-hoc.jsx';
+import AudioTrimmer from '../../containers/audio-trimmer.jsx';
 
 import styles from './sound-editor.css';
 
 import playIcon from '../record-modal/icon--play.svg';
 import stopIcon from '../record-modal/icon--stop-playback.svg';
+import trimIcon from './icon--trim.svg';
 
 const BufferedInput = BufferedInputHOC(Input);
 
 const SoundEditor = props => (
-    <Box className={styles.editorContainer}>
-        <Box className={styles.row}>
-            <Box className={styles.inputGroup}>
+    <div className={styles.editorContainer}>
+        <div className={styles.row}>
+            <div className={styles.inputGroup}>
                 {props.playhead ? (
                     <button
                         className={classNames(styles.button, styles.stopButtonn)}
+                        title={'Stop'}
                         onClick={props.onStop}
                     >
                         <img src={stopIcon} />
@@ -28,13 +30,14 @@ const SoundEditor = props => (
                 ) : (
                     <button
                         className={classNames(styles.button, styles.playButton)}
+                        title={'Play'}
                         onClick={props.onPlay}
                     >
                         <img src={playIcon} />
                     </button>
                 )}
-            </Box>
-            <Box className={styles.inputGroup}>
+            </div>
+            <div className={styles.inputGroup}>
                 <Label text="Sound">
                     <BufferedInput
                         tabIndex="1"
@@ -43,27 +46,51 @@ const SoundEditor = props => (
                         onSubmit={props.onChangeName}
                     />
                 </Label>
-            </Box>
-        </Box>
-        <Box className={styles.row}>
-            <Box className={styles.waveformContainer}>
+            </div>
+            <div className={styles.inputGroupRight}>
+                <button
+                    className={classNames(styles.button, styles.trimButton, {
+                        [styles.trimButtonActive]: props.trimStart !== null
+                    })}
+                    title={props.trimStart === null ? 'Trim' : 'Save'}
+                    onClick={props.onActivateTrim}
+                >
+                    <img src={trimIcon} />
+                    {props.trimStart === null ? 'Trim' : 'Save'}
+                </button>
+            </div>
+        </div>
+        <div className={styles.row}>
+            <div className={styles.waveformContainer}>
                 <Waveform
                     data={props.chunkLevels}
                     height={180}
                     width={600}
                 />
-            </Box>
-        </Box>
-    </Box>
+                <AudioTrimmer
+                    playhead={props.playhead}
+                    trimEnd={props.trimEnd}
+                    trimStart={props.trimStart}
+                    onSetTrimEnd={props.onSetTrimEnd}
+                    onSetTrimStart={props.onSetTrimStart}
+                />
+            </div>
+        </div>
+    </div>
 );
 
 SoundEditor.propTypes = {
     chunkLevels: PropTypes.arrayOf(PropTypes.number).isRequired,
     name: PropTypes.string.isRequired,
+    onActivateTrim: PropTypes.func,
     onChangeName: PropTypes.func.isRequired,
     onPlay: PropTypes.func.isRequired,
+    onSetTrimEnd: PropTypes.func,
+    onSetTrimStart: PropTypes.func,
     onStop: PropTypes.func.isRequired,
-    playhead: PropTypes.number
+    playhead: PropTypes.number,
+    trimEnd: PropTypes.number,
+    trimStart: PropTypes.number
 };
 
 export default SoundEditor;
diff --git a/src/containers/audio-trimmer.jsx b/src/containers/audio-trimmer.jsx
index 07ba2154d..03e91443e 100644
--- a/src/containers/audio-trimmer.jsx
+++ b/src/containers/audio-trimmer.jsx
@@ -20,12 +20,14 @@ class AudioTrimmer extends React.Component {
         const dx = (e.clientX - this.initialX) / containerSize;
         const newTrim = Math.max(0, Math.min(this.props.trimEnd, this.initialTrim + dx));
         this.props.onSetTrimStart(newTrim);
+        e.preventDefault();
     }
     handleTrimEndMouseMove (e) {
         const containerSize = this.containerElement.getBoundingClientRect().width;
         const dx = (e.clientX - this.initialX) / containerSize;
         const newTrim = Math.min(1, Math.max(this.props.trimStart, this.initialTrim + dx));
         this.props.onSetTrimEnd(newTrim);
+        e.preventDefault();
     }
     handleTrimStartMouseUp () {
         window.removeEventListener('mousemove', this.handleTrimStartMouseMove);
diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx
index 7706b1e98..d18678273 100644
--- a/src/containers/sound-editor.jsx
+++ b/src/containers/sound-editor.jsx
@@ -17,30 +17,50 @@ class SoundEditor extends React.Component {
             'handleChangeName',
             'handlePlay',
             'handleStopPlaying',
-            'handleUpdatePlayhead'
+            'handleUpdatePlayhead',
+            'handleActivateTrim',
+            'handleUpdateTrimEnd',
+            'handleUpdateTrimStart'
         ]);
         this.state = {
+            chunkLevels: computeChunkedRMS(this.props.samples),
             playhead: null, // null is not playing, [0 -> 1] is playing percent
-            chunkLevels: computeChunkedRMS(this.props.samples)
+            trimStart: null,
+            trimEnd: null
         };
     }
     componentDidMount () {
         this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples, this.props.sampleRate);
     }
     componentWillReceiveProps (newProps) {
-        if (newProps.soundIndex !== this.props.soundIndex) {
-            this.audioBufferPlayer.stop();
-            this.audioBufferPlayer = new AudioBufferPlayer(newProps.samples, newProps.sampleRate);
-            this.setState({chunkLevels: computeChunkedRMS(newProps.samples)});
+        if (newProps.soundId !== this.props.soundId) { // A different sound has been selected
+            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) {
+        this.resetState(samples, sampleRate);
+        this.props.onUpdateSoundBuffer(
+            this.props.soundIndex,
+            this.audioBufferPlayer.buffer
+        );
+    }
     handlePlay () {
         this.audioBufferPlayer.play(
-            0,
-            1,
+            this.state.trimStart || 0,
+            this.state.trimEnd || 1,
             this.handleUpdatePlayhead,
             this.handleStoppedPlaying);
     }
@@ -57,6 +77,23 @@ class SoundEditor extends React.Component {
     handleChangeName (name) {
         this.props.onRenameSound(this.props.soundIndex, name);
     }
+    handleActivateTrim () {
+        if (this.state.trimStart === null && this.state.trimEnd === null) {
+            this.setState({trimEnd: 0.95, trimStart: 0.05});
+        } else {
+            const sampleCount = this.props.samples.length;
+            const startIndex = Math.floor(this.state.trimStart * sampleCount);
+            const endIndex = Math.floor(this.state.trimEnd * sampleCount);
+            const clippedSamples = this.props.samples.slice(startIndex, endIndex);
+            this.submitNewSamples(clippedSamples, this.props.sampleRate);
+        }
+    }
+    handleUpdateTrimEnd (trimEnd) {
+        this.setState({trimEnd});
+    }
+    handleUpdateTrimStart (trimStart) {
+        this.setState({trimStart});
+    }
     render () {
         return (
             <SoundEditorComponent
@@ -65,12 +102,12 @@ class SoundEditor extends React.Component {
                 playhead={this.state.playhead}
                 trimEnd={this.state.trimEnd}
                 trimStart={this.state.trimStart}
+                onActivateTrim={this.handleActivateTrim}
                 onChangeName={this.handleChangeName}
                 onPlay={this.handlePlay}
                 onSetTrimEnd={this.handleUpdateTrimEnd}
                 onSetTrimStart={this.handleUpdateTrimStart}
                 onStop={this.handleStopPlaying}
-                onTrim={this.handleActivateTrim}
             />
         );
     }
@@ -79,8 +116,10 @@ class SoundEditor extends React.Component {
 SoundEditor.propTypes = {
     name: PropTypes.string.isRequired,
     onRenameSound: PropTypes.func.isRequired,
+    onUpdateSoundBuffer: PropTypes.func.isRequired,
     sampleRate: PropTypes.number,
     samples: PropTypes.instanceOf(Float32Array),
+    soundId: PropTypes.string,
     soundIndex: PropTypes.number
 };
 
@@ -88,10 +127,12 @@ const mapStateToProps = (state, {soundIndex}) => {
     const sound = state.vm.editingTarget.sprite.sounds[soundIndex];
     const audioBuffer = state.vm.getSoundBuffer(soundIndex);
     return {
+        soundId: sound.soundId,
         sampleRate: audioBuffer.sampleRate,
         samples: audioBuffer.getChannelData(0),
         name: sound.name,
-        onRenameSound: state.vm.renameSound.bind(state.vm)
+        onRenameSound: state.vm.renameSound.bind(state.vm),
+        onUpdateSoundBuffer: state.vm.updateSoundBuffer.bind(state.vm)
     };
 };
 
diff --git a/test/__mocks__/audio-buffer-player.js b/test/__mocks__/audio-buffer-player.js
new file mode 100644
index 000000000..c36092be3
--- /dev/null
+++ b/test/__mocks__/audio-buffer-player.js
@@ -0,0 +1,12 @@
+/* eslint-env jest */
+export default class MockAudioBufferPlayer {
+    constructor (samples, sampleRate) {
+        this.samples = samples;
+        this.sampleRate = sampleRate;
+        this.play = jest.fn((trimStart, trimEnd, onUpdate) => {
+            this.onUpdate = onUpdate;
+        });
+        this.stop = jest.fn();
+        MockAudioBufferPlayer.instance = this;
+    }
+}
diff --git a/test/unit/components/__snapshots__/sound-editor.test.jsx.snap b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap
new file mode 100644
index 000000000..b32ad42e3
--- /dev/null
+++ b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap
@@ -0,0 +1,311 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Sound Editor Component matches snapshot 1`] = `
+<div
+  className={undefined}
+>
+  <div
+    className={undefined}
+  >
+    <div
+      className={undefined}
+    >
+      <button
+        className=""
+        onClick={[Function]}
+        title="Stop"
+      >
+        <img
+          src="test-file-stub"
+        />
+      </button>
+    </div>
+    <div
+      className={undefined}
+    >
+      <label
+        className={undefined}
+      >
+        <span
+          className={undefined}
+        >
+          Sound
+        </span>
+        <input
+          className=""
+          onBlur={[Function]}
+          onChange={[Function]}
+          onKeyPress={[Function]}
+          onSubmit={[Function]}
+          tabIndex="1"
+          type="text"
+          value="sound name"
+        />
+      </label>
+    </div>
+    <div
+      className={undefined}
+    >
+      <button
+        className="undefined"
+        onClick={[Function]}
+        title="Save"
+      >
+        <img
+          src="test-file-stub"
+        />
+        Save
+      </button>
+    </div>
+  </div>
+  <div
+    className={undefined}
+  >
+    <div
+      className={undefined}
+    >
+      <svg
+        className={undefined}
+        viewBox="0 0 600 180"
+      >
+        <g
+          transform="scale(1, -1) translate(0, -90)"
+        >
+          <path
+            className={undefined}
+            d="M0 0Q0 0 60 45,Q120 90 180 135,Q240 180 300 225,Q360 270 420 135,Q480 0 480 0,Q480 0 420 -135,Q360 -270 300 -225,Q240 -180 180 -135,Q120 -90 60 -45,Q0 0 0 0Z"
+            strokeLinejoin="round"
+            strokeWidth={2}
+          />
+        </g>
+      </svg>
+      <div
+        className={undefined}
+      >
+        <div
+          className=""
+          onMouseDown={[Function]}
+          style={
+            Object {
+              "alignContent": undefined,
+              "alignItems": undefined,
+              "alignSelf": undefined,
+              "flexBasis": undefined,
+              "flexDirection": undefined,
+              "flexGrow": undefined,
+              "flexShrink": undefined,
+              "flexWrap": undefined,
+              "height": undefined,
+              "justifyContent": undefined,
+              "width": "20%",
+            }
+          }
+        >
+          <div
+            className=""
+            style={
+              Object {
+                "alignContent": undefined,
+                "alignItems": undefined,
+                "alignSelf": undefined,
+                "flexBasis": undefined,
+                "flexDirection": undefined,
+                "flexGrow": undefined,
+                "flexShrink": undefined,
+                "flexWrap": undefined,
+                "height": undefined,
+                "justifyContent": undefined,
+                "width": undefined,
+              }
+            }
+          />
+          <div
+            className=""
+            style={
+              Object {
+                "alignContent": undefined,
+                "alignItems": undefined,
+                "alignSelf": undefined,
+                "flexBasis": undefined,
+                "flexDirection": undefined,
+                "flexGrow": undefined,
+                "flexShrink": undefined,
+                "flexWrap": undefined,
+                "height": undefined,
+                "justifyContent": undefined,
+                "width": undefined,
+              }
+            }
+          >
+            <div
+              className=""
+              style={
+                Object {
+                  "alignContent": undefined,
+                  "alignItems": undefined,
+                  "alignSelf": undefined,
+                  "flexBasis": undefined,
+                  "flexDirection": undefined,
+                  "flexGrow": undefined,
+                  "flexShrink": undefined,
+                  "flexWrap": undefined,
+                  "height": undefined,
+                  "justifyContent": undefined,
+                  "width": undefined,
+                }
+              }
+            >
+              <img
+                src="test-file-stub"
+              />
+            </div>
+            <div
+              className=""
+              style={
+                Object {
+                  "alignContent": undefined,
+                  "alignItems": undefined,
+                  "alignSelf": undefined,
+                  "flexBasis": undefined,
+                  "flexDirection": undefined,
+                  "flexGrow": undefined,
+                  "flexShrink": undefined,
+                  "flexWrap": undefined,
+                  "height": undefined,
+                  "justifyContent": undefined,
+                  "width": undefined,
+                }
+              }
+            >
+              <img
+                src="test-file-stub"
+              />
+            </div>
+          </div>
+        </div>
+        <div
+          className=""
+          style={
+            Object {
+              "alignContent": undefined,
+              "alignItems": undefined,
+              "alignSelf": undefined,
+              "flexBasis": undefined,
+              "flexDirection": undefined,
+              "flexGrow": undefined,
+              "flexShrink": undefined,
+              "flexWrap": undefined,
+              "height": undefined,
+              "justifyContent": undefined,
+              "left": "50%",
+              "width": undefined,
+            }
+          }
+        />
+        <div
+          className=""
+          onMouseDown={[Function]}
+          style={
+            Object {
+              "alignContent": undefined,
+              "alignItems": undefined,
+              "alignSelf": undefined,
+              "flexBasis": undefined,
+              "flexDirection": undefined,
+              "flexGrow": undefined,
+              "flexShrink": undefined,
+              "flexWrap": undefined,
+              "height": undefined,
+              "justifyContent": undefined,
+              "left": "80%",
+              "width": "20%",
+            }
+          }
+        >
+          <div
+            className=""
+            style={
+              Object {
+                "alignContent": undefined,
+                "alignItems": undefined,
+                "alignSelf": undefined,
+                "flexBasis": undefined,
+                "flexDirection": undefined,
+                "flexGrow": undefined,
+                "flexShrink": undefined,
+                "flexWrap": undefined,
+                "height": undefined,
+                "justifyContent": undefined,
+                "width": undefined,
+              }
+            }
+          />
+          <div
+            className=""
+            style={
+              Object {
+                "alignContent": undefined,
+                "alignItems": undefined,
+                "alignSelf": undefined,
+                "flexBasis": undefined,
+                "flexDirection": undefined,
+                "flexGrow": undefined,
+                "flexShrink": undefined,
+                "flexWrap": undefined,
+                "height": undefined,
+                "justifyContent": undefined,
+                "width": undefined,
+              }
+            }
+          >
+            <div
+              className=""
+              style={
+                Object {
+                  "alignContent": undefined,
+                  "alignItems": undefined,
+                  "alignSelf": undefined,
+                  "flexBasis": undefined,
+                  "flexDirection": undefined,
+                  "flexGrow": undefined,
+                  "flexShrink": undefined,
+                  "flexWrap": undefined,
+                  "height": undefined,
+                  "justifyContent": undefined,
+                  "width": undefined,
+                }
+              }
+            >
+              <img
+                src="test-file-stub"
+              />
+            </div>
+            <div
+              className=""
+              style={
+                Object {
+                  "alignContent": undefined,
+                  "alignItems": undefined,
+                  "alignSelf": undefined,
+                  "flexBasis": undefined,
+                  "flexDirection": undefined,
+                  "flexGrow": undefined,
+                  "flexShrink": undefined,
+                  "flexWrap": undefined,
+                  "height": undefined,
+                  "justifyContent": undefined,
+                  "width": undefined,
+                }
+              }
+            >
+              <img
+                src="test-file-stub"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+`;
diff --git a/test/unit/components/sound-editor.test.jsx b/test/unit/components/sound-editor.test.jsx
new file mode 100644
index 000000000..f22609cdb
--- /dev/null
+++ b/test/unit/components/sound-editor.test.jsx
@@ -0,0 +1,63 @@
+/* eslint-env jest */
+import React from 'react'; // eslint-disable-line no-unused-vars
+import {mount} from 'enzyme';
+import SoundEditor from '../../../src/components/sound-editor/sound-editor'; // eslint-disable-line no-unused-vars
+import renderer from 'react-test-renderer';
+
+describe('Sound Editor Component', () => {
+    let props;
+    beforeEach(() => {
+        props = {
+            chunkLevels: [1, 2, 3],
+            name: 'sound name',
+            playhead: 0.5,
+            trimStart: 0.2,
+            trimEnd: 0.8,
+            onActivateTrim: jest.fn(),
+            onChangeName: jest.fn(),
+            onPlay: jest.fn(),
+            onSetTrimEnd: jest.fn(),
+            onSetTrimStart: jest.fn(),
+            onStop: jest.fn()
+        };
+    });
+
+    test('matches snapshot', () => {
+        const component = renderer.create(
+            <SoundEditor {...props} />
+        );
+        expect(component.toJSON()).toMatchSnapshot();
+    });
+
+    test('trim button appears when trims are null', () => {
+        const wrapper = mount(<SoundEditor {...props} trimStart={null} trimEnd={null} />);
+        wrapper.find('button[title="Trim"]').simulate('click');
+        expect(props.onActivateTrim).toHaveBeenCalled();
+    });
+
+    test('save button appears when trims are not null', () => {
+        const wrapper = mount(<SoundEditor {...props} trimStart={0.25} trimEnd={0.75} />);
+        wrapper.find('button[title="Save"]').simulate('click');
+        expect(props.onActivateTrim).toHaveBeenCalled();
+    });
+
+    test('play button appears when playhead is null', () => {
+        const wrapper = mount(<SoundEditor {...props} playhead={null} />);
+        wrapper.find('button[title="Play"]').simulate('click');
+        expect(props.onPlay).toHaveBeenCalled();
+    });
+
+    test('stop button appears when playhead is not null', () => {
+        const wrapper = mount(<SoundEditor {...props} playhead={0.5} />);
+        wrapper.find('button[title="Stop"]').simulate('click');
+        expect(props.onStop).toHaveBeenCalled();
+    });
+
+    test('submitting name calls the callback', () => {
+        const wrapper = mount(<SoundEditor {...props} />);
+        wrapper.find('input')
+            .simulate('change', {target: {value: 'hello'}})
+            .simulate('blur');
+        expect(props.onChangeName).toHaveBeenCalled();
+    });
+});
diff --git a/test/unit/containers/sound-editor.test.jsx b/test/unit/containers/sound-editor.test.jsx
new file mode 100644
index 000000000..a0838689f
--- /dev/null
+++ b/test/unit/containers/sound-editor.test.jsx
@@ -0,0 +1,93 @@
+/* eslint-env jest */
+import React from 'react'; // eslint-disable-line no-unused-vars
+import {mount} from 'enzyme';
+import configureStore from 'redux-mock-store';
+import mockAudioBufferPlayer from '../../__mocks__/audio-buffer-player.js';
+
+import SoundEditor from '../../../src/containers/sound-editor'; // eslint-disable-line no-unused-vars
+// eslint-disable-next-line no-unused-vars
+import SoundEditorComponent from '../../../src/components/sound-editor/sound-editor';
+
+jest.mock('../../../src/lib/audio/audio-buffer-player', () => mockAudioBufferPlayer);
+
+describe('Sound Editor Container', () => {
+    const mockStore = configureStore();
+    let store;
+    let soundIndex;
+    let soundBuffer;
+    let samples = new Float32Array([0, 0, 0]); // eslint-disable-line no-undef
+    let vm;
+
+    beforeEach(() => {
+        soundIndex = 0;
+        soundBuffer = {
+            sampleRate: 0,
+            getChannelData: jest.fn(() => samples)
+        };
+        vm = {
+            getSoundBuffer: jest.fn(() => soundBuffer),
+            renameSound: jest.fn(),
+            updateSoundBuffer: jest.fn(),
+            editingTarget: {
+                sprite: {
+                    sounds: [{name: 'first name', id: 'first id'}]
+                }
+            }
+        };
+        store = mockStore({vm});
+    });
+
+    test('should pass the correct data to the component from the store', () => {
+        const wrapper = mount(<SoundEditor store={store} soundIndex={soundIndex} />);
+        const componentProps = wrapper.find(SoundEditorComponent).props();
+        // Data retreived and processed by the `connect` with the store
+        expect(componentProps.name).toEqual('first name');
+        expect(componentProps.chunkLevels).toEqual([0]);
+        expect(mockAudioBufferPlayer.instance.samples).toEqual(samples);
+        // Initial data
+        expect(componentProps.playhead).toEqual(null);
+        expect(componentProps.trimStart).toEqual(null);
+        expect(componentProps.trimEnd).toEqual(null);
+
+    });
+
+    test('it plays when clicked and stops when clicked again', () => {
+        const wrapper = mount(<SoundEditor store={store} soundIndex={soundIndex} />);
+        const component = wrapper.find(SoundEditorComponent);
+        // Ensure rendering doesn't start playing any sounds
+        expect(mockAudioBufferPlayer.instance.play.mock.calls).toEqual([]);
+        expect(mockAudioBufferPlayer.instance.stop.mock.calls).toEqual([]);
+
+        component.props().onPlay();
+        expect(mockAudioBufferPlayer.instance.play).toHaveBeenCalled();
+
+        // Mock the audio buffer player calling onUpdate
+        mockAudioBufferPlayer.instance.onUpdate(0.5);
+        expect(component.props().playhead).toEqual(0.5);
+
+        component.props().onStop();
+        expect(mockAudioBufferPlayer.instance.stop).toHaveBeenCalled();
+        expect(component.props().playhead).toEqual(null);
+    });
+
+    test('it sets the component props for trimming and submits to the vm', () => {
+        const wrapper = mount(<SoundEditor store={store} soundIndex={soundIndex} />);
+        const component = wrapper.find(SoundEditorComponent);
+
+        component.props().onActivateTrim();
+        expect(component.props().trimStart).not.toEqual(null);
+        expect(component.props().trimEnd).not.toEqual(null);
+
+        component.props().onActivateTrim();
+        expect(vm.updateSoundBuffer).toHaveBeenCalled();
+        expect(component.props().trimStart).toEqual(null);
+        expect(component.props().trimEnd).toEqual(null);
+    });
+
+    test('it submits name changes to the vm', () => {
+        const wrapper = mount(<SoundEditor store={store} soundIndex={soundIndex} />);
+        const component = wrapper.find(SoundEditorComponent);
+        component.props().onChangeName('hello');
+        expect(vm.renameSound).toHaveBeenCalledWith(soundIndex, 'hello');
+    });
+});
-- 
GitLab