diff --git a/src/components/buffered-input/buffered-input.jsx b/src/components/buffered-input/buffered-input.jsx deleted file mode 100644 index 13645956ea5ad1db4fb5e7b10dba5baf1bcd7736..0000000000000000000000000000000000000000 --- a/src/components/buffered-input/buffered-input.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import bindAll from 'lodash.bindall'; -import PropTypes from 'prop-types'; -import React from 'react'; - -class BufferedInput extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'handleChange', - 'handleKeyPress', - 'handleFlush' - ]); - this.state = { - value: null - }; - } - handleKeyPress (e) { - if (e.key === 'Enter') { - this.handleFlush(); - e.target.blur(); - } - } - handleFlush () { - const isNumeric = typeof this.props.value === 'number'; - const validatesNumeric = isNumeric ? !isNaN(this.state.value) : true; - if (this.state.value !== null && validatesNumeric) { - this.props.onSubmit(isNumeric ? Number(this.state.value) : this.state.value); - } - this.setState({value: null}); - } - handleChange (e) { - this.setState({value: e.target.value}); - } - render () { - const bufferedValue = this.state.value === null ? this.props.value : this.state.value; - return ( - <input - {...this.props} - value={bufferedValue} - onBlur={this.handleFlush} - onChange={this.handleChange} - onKeyPress={this.handleKeyPress} - /> - ); - } -} - -BufferedInput.propTypes = { - onSubmit: PropTypes.func.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) -}; -export default BufferedInput; diff --git a/src/components/forms/buffered-input-hoc.jsx b/src/components/forms/buffered-input-hoc.jsx new file mode 100644 index 0000000000000000000000000000000000000000..246dfc03e876c3a357dedc3d09233c859c4ba37a --- /dev/null +++ b/src/components/forms/buffered-input-hoc.jsx @@ -0,0 +1,60 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; + +/** + * Higher Order Component to manage inputs that submit on blur and <enter> + * @param {React.Component} Input text input that consumes onChange, onBlur, onKeyPress + * @returns {React.Component} Buffered input that calls onSubmit on blur and <enter> + */ +export default function (Input) { + class BufferedInput extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleChange', + 'handleKeyPress', + 'handleFlush' + ]); + this.state = { + value: null + }; + } + handleKeyPress (e) { + if (e.key === 'Enter') { + this.handleFlush(); + e.target.blur(); + } + } + handleFlush () { + const isNumeric = typeof this.props.value === 'number'; + const validatesNumeric = isNumeric ? !isNaN(this.state.value) : true; + if (this.state.value !== null && validatesNumeric) { + this.props.onSubmit(isNumeric ? Number(this.state.value) : this.state.value); + } + this.setState({value: null}); + } + handleChange (e) { + this.setState({value: e.target.value}); + } + render () { + const bufferedValue = this.state.value === null ? this.props.value : this.state.value; + return ( + <Input + {...this.props} + value={bufferedValue} + onBlur={this.handleFlush} + onChange={this.handleChange} + onKeyPress={this.handleKeyPress} + /> + ); + } + } + + BufferedInput.propTypes = { + onSubmit: PropTypes.func.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + }; + + return BufferedInput; +} diff --git a/src/components/forms/input.css b/src/components/forms/input.css new file mode 100644 index 0000000000000000000000000000000000000000..6747e2c215a38356104d977b29b9e6008f8b42f8 --- /dev/null +++ b/src/components/forms/input.css @@ -0,0 +1,40 @@ +@import "../../css/units.css"; +@import "../../css/colors.css"; + +.input-form { + padding: $space 0.75rem; + + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 0.625rem; + font-weight: bold; + + border-width: 1px; + border-style: solid; + border-color: $form-border; + border-radius: 2rem; + + outline: none; + cursor: text; + transition: 0.25s ease-out; /* @todo: standardize with var */ + box-shadow: none; + + /* + For truncating overflowing text gracefully + Min-width is for a bug: https://css-tricks.com/flexbox-truncated-text + @todo: move this out into a mixin or a helper component + */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.input-form:focus { + border-color: #4c97ff; + box-shadow: inset 0 0 0 -2px rgba(0, 0, 0, 0.1); +} + +.input-small { + width: 3.5rem; + text-align: center; +} diff --git a/src/components/forms/input.jsx b/src/components/forms/input.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6f9cbffa89fe93575639c4c0a035ad397bf5ad39 --- /dev/null +++ b/src/components/forms/input.jsx @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; + +import styles from './input.css'; + +const Input = props => { + const {small, ...componentProps} = props; + return ( + <input + {...componentProps} + className={classNames(styles.inputForm, { + [styles.inputSmall]: small + })} + /> + ); +}; + +Input.propTypes = { + small: PropTypes.bool +}; + +Input.defaultProps = { + small: false +}; + +export default Input; diff --git a/src/components/forms/label.css b/src/components/forms/label.css new file mode 100644 index 0000000000000000000000000000000000000000..5eb280b87368ee92a4e722356b7d6490e6e8cddf --- /dev/null +++ b/src/components/forms/label.css @@ -0,0 +1,19 @@ +@import "../../css/units.css"; +@import "../../css/colors.css"; + +.input-group { + display: inline-flex; + flex-direction: row; + align-items: center; +} + +.input-label, .input-label-secondary { + font-size: 0.625rem; + margin-right: calc($space / 2); + user-select: none; + cursor: default; +} + +.input-label { + font-weight: bold; +} diff --git a/src/components/forms/label.jsx b/src/components/forms/label.jsx new file mode 100644 index 0000000000000000000000000000000000000000..62f077a746fb5bf6d70c828fa7f34097f89054ac --- /dev/null +++ b/src/components/forms/label.jsx @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import styles from './label.css'; + +const Label = props => ( + <label className={styles.inputGroup}> + <span className={props.secondary ? styles.inputLabelSecondary : styles.inputLabel}> + {props.text} + </span> + {props.children} + </label> +); + +Label.propTypes = { + children: PropTypes.node, + secondary: PropTypes.bool, + text: PropTypes.string.isRequired +}; + +Label.defaultProps = { + secondary: false +}; + +export default Label; diff --git a/src/components/record-modal/record-modal.jsx b/src/components/record-modal/record-modal.jsx index b215ad17f9e2ef866ff4893c49de4ac4210f25a2..7cdf2efa5d64b7c3c750e57cc1c4538194397f21 100644 --- a/src/components/record-modal/record-modal.jsx +++ b/src/components/record-modal/record-modal.jsx @@ -20,6 +20,7 @@ const RecordModal = props => ( levels={props.levels} playhead={props.playhead} playing={props.playing} + sampleRate={props.sampleRate} samples={props.samples} trimEnd={props.trimEnd} trimStart={props.trimStart} @@ -58,6 +59,7 @@ RecordModal.propTypes = { playhead: PropTypes.number, playing: PropTypes.bool, recording: PropTypes.bool, + sampleRate: PropTypes.number, samples: PropTypes.instanceOf(Float32Array), trimEnd: PropTypes.number.isRequired, trimStart: PropTypes.number.isRequired diff --git a/src/components/sound-editor/sound-editor.css b/src/components/sound-editor/sound-editor.css new file mode 100644 index 0000000000000000000000000000000000000000..0bb44f115c65dfabf9e3e899e40aca4b0322fb68 --- /dev/null +++ b/src/components/sound-editor/sound-editor.css @@ -0,0 +1,52 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; + +.editor-container { + display: flex; + flex-direction: column; + padding: calc(2 * $space); +} + +.row { + display: flex; + flex-direction: row; + align-items: center; +} + +.row + .row { + margin-top: calc(2 * $space); +} + +.input-group + .input-group { + margin-left: calc(2 * $space); +} + +.waveform-container { + display: flex; + justify-content: space-around; + align-items: center; + width: 100%; + + position: relative; + + background: $ui-pane-gray; + border: 1px solid $ui-pane-border; + border-radius: 5px; + padding: 3px; +} + +.button { + width: 2rem; + height: 2rem; + padding: 0.25rem; + outline: none; + background: white; + border-radius: 0.25rem; + border: 1px solid #ddd; +} + +.button img { + flex-grow: 1; + max-width: 100%; + max-height: 100%; +} diff --git a/src/components/sound-editor/sound-editor.jsx b/src/components/sound-editor/sound-editor.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3e379b3f5372e57c977438246a8690768eac66d9 --- /dev/null +++ b/src/components/sound-editor/sound-editor.jsx @@ -0,0 +1,69 @@ +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 styles from './sound-editor.css'; + +import playIcon from '../record-modal/icon--play.svg'; +import stopIcon from '../record-modal/icon--stop-playback.svg'; + +const BufferedInput = BufferedInputHOC(Input); + +const SoundEditor = props => ( + <Box className={styles.editorContainer}> + <Box className={styles.row}> + <Box className={styles.inputGroup}> + {props.playhead ? ( + <button + className={classNames(styles.button, styles.stopButtonn)} + onClick={props.onStop} + > + <img src={stopIcon} /> + </button> + ) : ( + <button + className={classNames(styles.button, styles.playButton)} + onClick={props.onPlay} + > + <img src={playIcon} /> + </button> + )} + </Box> + <Box className={styles.inputGroup}> + <Label text="Sound"> + <BufferedInput + tabIndex="1" + type="text" + value={props.name} + onSubmit={props.onChangeName} + /> + </Label> + </Box> + </Box> + <Box className={styles.row}> + <Box className={styles.waveformContainer}> + <Waveform + data={props.chunkLevels} + height={180} + width={600} + /> + </Box> + </Box> + </Box> +); + +SoundEditor.propTypes = { + chunkLevels: PropTypes.arrayOf(PropTypes.number).isRequired, + name: PropTypes.string.isRequired, + onChangeName: PropTypes.func.isRequired, + onPlay: PropTypes.func.isRequired, + onStop: PropTypes.func.isRequired, + playhead: PropTypes.number +}; + +export default SoundEditor; diff --git a/src/components/sprite-info/sprite-info.css b/src/components/sprite-info/sprite-info.css index eaf5c3fa8798c168bbc1e8b8274f923cee17fbfe..e6a758d1c44936e7f3a35a9faa7a2cda6ee6a2a8 100644 --- a/src/components/sprite-info/sprite-info.css +++ b/src/components/sprite-info/sprite-info.css @@ -1,7 +1,5 @@ @import "../../css/units.css"; - -$form-border: #E9EEF2; -$form-radius: calc($space / 2); +@import "../../css/colors.css"; .sprite-info { height: $sprite-info-height; @@ -74,60 +72,6 @@ $form-radius: calc($space / 2); pointer-events: none; } -.input-label { - font-size: 0.625rem; - font-weight: bold; - margin-right: calc($space / 2); - - /* @todo: make this a mixin for all UI text labels */ - user-select: none; - cursor: default; -} - -.input-label-secondary { - font-size: 0.625rem; - margin-right: calc($space / 2); - - /* @todo: make this a mixin for all UI text labels */ - user-select: none; - cursor: default; -} - -.input-form { - padding: $space 0.75rem; - - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 0.625rem; - font-weight: bold; - - border-width: 1px; - border-style: solid; - border-color: $form-border; - border-radius: 2rem; - - outline: none; - cursor: text; - transition: 0.25s ease-out; /* @todo: standardize with var */ - box-shadow: none; - - /* - For truncating overflowing text gracefully - Min-width is for a bug: https://css-tricks.com/flexbox-truncated-text - @todo: move this out into a mixin or a helper component - */ - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -.input-form:focus { - border-color: #4c97ff; - box-shadow: inset 0 0 0 -2px rgba(0, 0, 0, 0.1); -} - -.x, .y, .direction { width: 3.5rem; text-align: center; } - .rotation-select { width: 100%; height: 1.85rem; @@ -135,5 +79,3 @@ $form-radius: calc($space / 2); user-select: none; outline: none; } - -.sprite-name { width: 100%; } diff --git a/src/components/sprite-info/sprite-info.jsx b/src/components/sprite-info/sprite-info.jsx index 73c550c2c5e4169a1f7331ba1125b552cf47c888..fc68c9121a44f6d587b46a15d1654e372b4688f4 100644 --- a/src/components/sprite-info/sprite-info.jsx +++ b/src/components/sprite-info/sprite-info.jsx @@ -3,7 +3,10 @@ import PropTypes from 'prop-types'; import React from 'react'; import Box from '../box/box.jsx'; -import BufferedInput from '../buffered-input/buffered-input.jsx'; +import Label from '../forms/label.jsx'; +import Input from '../forms/input.jsx'; +import BufferedInputHOC from '../forms/buffered-input-hoc.jsx'; + import styles from './sprite-info.css'; import xIcon from './icon--x.svg'; @@ -11,6 +14,7 @@ import yIcon from './icon--y.svg'; import showIcon from './icon--show.svg'; import hideIcon from './icon--hide.svg'; +const BufferedInput = BufferedInputHOC(Input); const ROTATION_STYLES = ['left-right', 'don\'t rotate', 'all around']; class SpriteInfo extends React.Component { @@ -32,16 +36,16 @@ class SpriteInfo extends React.Component { > <div className={classNames(styles.row, styles.rowPrimary)}> <div className={styles.group}> - <span className={styles.inputLabel}>Sprite</span> - <BufferedInput - className={classNames(styles.inputForm, styles.spriteName)} - disabled={this.props.disabled} - placeholder="Name" - tabIndex="1" - type="text" - value={this.props.disabled ? '' : this.props.name} - onSubmit={this.props.onChangeName} - /> + <Label text="Sprite"> + <BufferedInput + disabled={this.props.disabled} + placeholder="Name" + tabIndex="1" + type="text" + value={this.props.disabled ? '' : this.props.name} + onSubmit={this.props.onChangeName} + /> + </Label> </div> <div className={styles.group}> @@ -51,16 +55,17 @@ class SpriteInfo extends React.Component { src={xIcon} /> </div> - <span className={styles.inputLabel}>x</span> - <BufferedInput - className={classNames(styles.inputForm, styles.x)} - disabled={this.props.disabled} - placeholder="x" - tabIndex="2" - type="text" - value={this.props.disabled ? '' : this.props.x} - onSubmit={this.props.onChangeX} - /> + <Label text="x"> + <BufferedInput + small + disabled={this.props.disabled} + placeholder="x" + tabIndex="2" + type="text" + value={this.props.disabled ? '' : this.props.x} + onSubmit={this.props.onChangeX} + /> + </Label> </div> <div className={styles.group}> @@ -70,93 +75,103 @@ class SpriteInfo extends React.Component { src={yIcon} /> </div> - <span className={styles.inputLabel}>y</span> - <BufferedInput - className={classNames(styles.inputForm, styles.y)} - disabled={this.props.disabled} - placeholder="y" - tabIndex="3" - type="text" - value={this.props.disabled ? '' : this.props.y} - onSubmit={this.props.onChangeY} - /> + <Label text="y"> + <BufferedInput + small + disabled={this.props.disabled} + placeholder="y" + tabIndex="3" + type="text" + value={this.props.disabled ? '' : this.props.y} + onSubmit={this.props.onChangeY} + /> + </Label> </div> </div> <div className={classNames(styles.row, styles.rowSecondary)}> <div className={styles.group}> - <span className={styles.inputLabelSecondary}> - Show - </span> - <div> - <div - className={classNames( - styles.radio, - styles.radioLeft, - styles.iconWrapper, - { - [styles.isActive]: this.props.visible && !this.props.disabled, - [styles.isDisabled]: this.props.disabled - } - )} - tabIndex="4" - onClick={this.props.onClickVisible} - > - <img - className={styles.icon} - src={showIcon} - /> - </div> - <div - className={classNames( - styles.radio, - styles.radioRight, - styles.iconWrapper, - { - [styles.isActive]: !this.props.visible && !this.props.disabled, - [styles.isDisabled]: this.props.disabled - } - )} - tabIndex="4" - onClick={this.props.onClickNotVisible} - > - <img - className={styles.icon} - src={hideIcon} - /> + <Label + secondary + text="Show" + > + <div> + <div + className={classNames( + styles.radio, + styles.radioLeft, + styles.iconWrapper, + { + [styles.isActive]: this.props.visible && !this.props.disabled, + [styles.isDisabled]: this.props.disabled + } + )} + tabIndex="4" + onClick={this.props.onClickVisible} + > + <img + className={styles.icon} + src={showIcon} + /> + </div> + <div + className={classNames( + styles.radio, + styles.radioRight, + styles.iconWrapper, + { + [styles.isActive]: !this.props.visible && !this.props.disabled, + [styles.isDisabled]: this.props.disabled + } + )} + tabIndex="4" + onClick={this.props.onClickNotVisible} + > + <img + className={styles.icon} + src={hideIcon} + /> + </div> </div> - </div> + </Label> </div> <div className={styles.group}> - <span className={styles.inputLabelSecondary}>Direction</span> - <BufferedInput - className={classNames(styles.inputForm, styles.direction)} - disabled={this.props.disabled} - tabIndex="5" - type="text" - value={this.props.disabled ? '' : this.props.direction} - onSubmit={this.props.onChangeDirection} - /> + <Label + secondary + text="Direction" + > + <BufferedInput + small + disabled={this.props.disabled} + label="Direction" + tabIndex="5" + type="text" + value={this.props.disabled ? '' : this.props.direction} + onSubmit={this.props.onChangeDirection} + /> + </Label> </div> <div className={styles.group}> - <span className={styles.inputLabelSecondary}> - Rotation - </span> - <select - className={classNames(styles.selectForm, styles.rotationSelect)} - disabled={this.props.disabled} - value={this.props.rotationStyle} - onChange={this.props.onChangeRotationStyle} + <Label + secondary + text="Rotation" > - {ROTATION_STYLES.map(style => ( - <option - key={style} - value={style} - > - {style} - </option> - ))} - </select> + <select + className={classNames(styles.selectForm, styles.rotationSelect)} + disabled={this.props.disabled} + value={this.props.rotationStyle} + onChange={this.props.onChangeRotationStyle} + > + {ROTATION_STYLES.map(style => ( + <option + key={style} + value={style} + > + {style} + </option> + ))} + </select> + </Label> </div> </div> </Box> diff --git a/src/containers/playback-step.jsx b/src/containers/playback-step.jsx index ce61bcc045a0edc011c955fdd74f5ff42bc29f5d..8c2f5e7333be319b1008d1d3a52ccb3c2c6fe684 100644 --- a/src/containers/playback-step.jsx +++ b/src/containers/playback-step.jsx @@ -13,7 +13,7 @@ class PlaybackStep extends React.Component { ]); } componentDidMount () { - this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples); + this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples, this.props.sampleRate); } componentWillUnmount () { this.audioBufferPlayer.stop(); @@ -33,6 +33,7 @@ class PlaybackStep extends React.Component { } render () { const { + sampleRate, // eslint-disable-line no-unused-vars onPlay, // eslint-disable-line no-unused-vars onStopPlaying, // eslint-disable-line no-unused-vars onSetPlayhead, // eslint-disable-line no-unused-vars @@ -49,6 +50,7 @@ class PlaybackStep extends React.Component { } PlaybackStep.propTypes = { + sampleRate: PropTypes.number.isRequired, samples: PropTypes.instanceOf(Float32Array).isRequired, ...PlaybackStepComponent.propTypes }; diff --git a/src/containers/record-modal.jsx b/src/containers/record-modal.jsx index 2f7cff84cba3047715fcda794610be2bb4017537..fd5647c4967722b94806dcf93b5e3d16e5ff928c 100644 --- a/src/containers/record-modal.jsx +++ b/src/containers/record-modal.jsx @@ -105,6 +105,7 @@ class RecordModal extends React.Component { playhead={this.state.playhead} playing={this.state.playing} recording={this.state.recording} + sampleRate={this.state.sampleRate} samples={this.state.samples} trimEnd={this.state.trimEnd} trimStart={this.state.trimStart} diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx new file mode 100644 index 0000000000000000000000000000000000000000..30bd62d843fdf66800f4528c68078ec7ef7e15b2 --- /dev/null +++ b/src/containers/sound-editor.jsx @@ -0,0 +1,100 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import {connect} from 'react-redux'; + +import {computeChunkedRMS} from '../lib/audio/audio-util.js'; + +import SoundEditorComponent from '../components/sound-editor/sound-editor.jsx'; +import AudioBufferPlayer from '../lib/audio/audio-buffer-player.js'; + +class SoundEditor extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleStoppedPlaying', + 'handleChangeName', + 'handlePlay', + 'handleStopPlaying', + 'handleUpdatePlayhead' + ]); + this.state = { + playhead: null, // null is not playing, [0 -> 1] is playing percent + chunkLevels: computeChunkedRMS(this.props.samples) + }; + } + 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)}); + } + } + componentWillUnmount () { + this.audioBufferPlayer.stop(); + } + handlePlay () { + this.audioBufferPlayer.play( + 0, + 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.onRenameSound(this.props.soundIndex, name); + } + render () { + return ( + <SoundEditorComponent + chunkLevels={this.state.chunkLevels} + name={this.props.name} + playhead={this.state.playhead} + trimEnd={this.state.trimEnd} + trimStart={this.state.trimStart} + onChangeName={this.handleChangeName} + onPlay={this.handlePlay} + onSetTrimEnd={this.handleUpdateTrimEnd} + onSetTrimStart={this.handleUpdateTrimStart} + onStop={this.handleStopPlaying} + onTrim={this.handleActivateTrim} + /> + ); + } +} + +SoundEditor.propTypes = { + name: PropTypes.string.isRequired, + onRenameSound: PropTypes.func.isRequired, + sampleRate: PropTypes.number, + samples: PropTypes.instanceOf(Float32Array), + soundIndex: PropTypes.number +}; + +const mapStateToProps = (state, {soundIndex}) => { + const sound = state.vm.editingTarget.sprite.sounds[soundIndex]; + const audioBuffer = state.vm.runtime.audioEngine.audioBuffers[sound.md5]; + return { + sampleRate: audioBuffer.sampleRate, + samples: audioBuffer.getChannelData(0), + name: sound.name, + onRenameSound: state.vm.renameSound.bind(state.vm) + }; +}; + +export default connect( + mapStateToProps +)(SoundEditor); diff --git a/src/containers/sound-tab.jsx b/src/containers/sound-tab.jsx index 4aa43db974043d0871e48232ae9fa4868972eccf..f95f6f7099e7e36c45edc3c405c80486008a49d2 100644 --- a/src/containers/sound-tab.jsx +++ b/src/containers/sound-tab.jsx @@ -8,8 +8,8 @@ import AssetPanel from '../components/asset-panel/asset-panel.jsx'; import soundIcon from '../components/asset-panel/icon--sound.svg'; import addSoundFromLibraryIcon from '../components/asset-panel/icon--add-sound-lib.svg'; import addSoundFromRecordingIcon from '../components/asset-panel/icon--add-sound-record.svg'; - import RecordModal from './record-modal.jsx'; +import SoundEditor from './sound-editor.jsx'; import {connect} from 'react-redux'; @@ -38,13 +38,11 @@ class SoundTab extends React.Component { const target = editingTarget && sprites[editingTarget] ? sprites[editingTarget] : stage; if (target && target.sounds && this.state.selectedSoundIndex > target.sounds.length - 1) { - this.setState({selectedSoundIndex: target.sounds.length - 1}); + this.setState({selectedSoundIndex: Math.max(target.sounds.length - 1, 0)}); } } handleSelectSound (soundIndex) { - const sound = this.props.vm.editingTarget.sprite.sounds[soundIndex]; - this.props.vm.editingTarget.audioPlayer.playSound(sound.md5); this.setState({selectedSoundIndex: soundIndex}); } @@ -108,6 +106,9 @@ class SoundTab extends React.Component { onDeleteClick={this.handleDeleteSound} onItemClick={this.handleSelectSound} > + {target.sounds && target.sounds.length > 0 ? ( + <SoundEditor soundIndex={this.state.selectedSoundIndex} /> + ) : null} {this.props.soundRecorderVisible ? ( <RecordModal /> ) : null} diff --git a/src/css/colors.css b/src/css/colors.css index 036efe3f2843547931b76a2037b781ce82027bc3..45258f13a7243bea9ddadb239654b3afe42473be 100644 --- a/src/css/colors.css +++ b/src/css/colors.css @@ -10,3 +10,5 @@ $red-tertiary: #E64D00; $sound-primary: #CF63CF; $sound-tertiary: #A63FA6; + +$form-border: #E9EEF2; diff --git a/src/css/units.css b/src/css/units.css index 3f9aaba17d8319e322b48c817438ed4e8b92458e..d951e3e1a874a4b34f22483d8d6cdc977d772e32 100644 --- a/src/css/units.css +++ b/src/css/units.css @@ -6,4 +6,6 @@ $menu-bar-height: 3rem; $sprite-info-height: 5.25rem; /* @todo: SpriteInfo isn't explicitly set to this height yet */ $stage-menu-height: 3rem; -$library-header-height: 4.375rem; \ No newline at end of file +$library-header-height: 4.375rem; + +$form-radius: calc($space / 2); diff --git a/src/lib/audio/audio-buffer-player.js b/src/lib/audio/audio-buffer-player.js index 5d643c8463d199d6a0cab38371a71f61c780d307..39affdfd338d607aaa16ffa5917e5629dc52a8c2 100644 --- a/src/lib/audio/audio-buffer-player.js +++ b/src/lib/audio/audio-buffer-player.js @@ -1,9 +1,9 @@ import SharedAudioContext from './shared-audio-context.js'; class AudioBufferPlayer { - constructor (samples) { + constructor (samples, sampleRate) { this.audioContext = new SharedAudioContext(); - this.buffer = this.audioContext.createBuffer(1, samples.length, this.audioContext.sampleRate); + this.buffer = this.audioContext.createBuffer(1, samples.length, sampleRate); this.buffer.getChannelData(0).set(samples); this.source = null; diff --git a/src/lib/audio/audio-recorder.js b/src/lib/audio/audio-recorder.js index e10ff81ce1c7d48936e4aeb465489b9238db2cf4..576021a2ef01ae606c17dfe329b0d800e978d099 100644 --- a/src/lib/audio/audio-recorder.js +++ b/src/lib/audio/audio-recorder.js @@ -1,4 +1,5 @@ import SharedAudioContext from './shared-audio-context.js'; +import {computeRMS} from './audio-util.js'; class AudioRecorder { constructor () { @@ -42,17 +43,6 @@ class AudioRecorder { this.recording = true; } - calculateRMS (samples) { - // Calculate RMS, adapted from https://github.com/Tonejs/Tone.js/blob/master/Tone/component/Meter.js#L88 - const sum = samples.reduce((acc, v) => acc + Math.pow(v, 2), 0); - const rms = Math.sqrt(sum / samples.length); - // Scale it - const unity = 0.35; - const val = rms / unity; - // Scale the output curve - return Math.sqrt(val); - } - attachUserMediaStream (userMediaStream, onUpdate) { this.userMediaStream = userMediaStream; this.mediaStreamSource = this.audioContext.createMediaStreamSource(userMediaStream); @@ -76,7 +66,7 @@ class AudioRecorder { if (this.disposed) return; requestAnimationFrame(update); this.analyserNode.getFloatTimeDomainData(dataArray); - onUpdate(this.calculateRMS(dataArray)); + onUpdate(computeRMS(dataArray)); }; requestAnimationFrame(update); @@ -89,7 +79,7 @@ class AudioRecorder { } stop () { - const chunkLevels = this.buffers.map(buffer => this.calculateRMS(buffer)); + const chunkLevels = this.buffers.map(buffer => computeRMS(buffer)); const maxRMS = Math.max.apply(null, chunkLevels); const threshold = maxRMS / 8; diff --git a/src/lib/audio/audio-util.js b/src/lib/audio/audio-util.js new file mode 100644 index 0000000000000000000000000000000000000000..a493fc7da73b1069c38c0bee92ac050bdd4eb809 --- /dev/null +++ b/src/lib/audio/audio-util.js @@ -0,0 +1,27 @@ +const computeRMS = function (samples, scaling = 0.55) { + if (samples.length === 0) return 0; + // Calculate RMS, adapted from https://github.com/Tonejs/Tone.js/blob/master/Tone/component/Meter.js#L88 + let sum = 0; + for (let i = 0; i < samples.length; i++) { + const sample = samples[i]; + sum += Math.pow(sample, 2); + } + const rms = Math.sqrt(sum / samples.length); + const val = rms / scaling; + return Math.sqrt(val); +}; + +const computeChunkedRMS = function (samples, chunkSize = 1024) { + const sampleCount = samples.length; + const chunkLevels = []; + for (let i = 0; i < sampleCount; i += chunkSize) { + const maxIndex = Math.min(sampleCount, i + chunkSize); + chunkLevels.push(computeRMS(samples.slice(i, maxIndex))); + } + return chunkLevels; +}; + +export { + computeRMS, + computeChunkedRMS +}; diff --git a/test/unit/util/audio-util.test.js b/test/unit/util/audio-util.test.js new file mode 100644 index 0000000000000000000000000000000000000000..714381db64bb61b5ccaa1b37f9265c2ed50c3549 --- /dev/null +++ b/test/unit/util/audio-util.test.js @@ -0,0 +1,52 @@ +/* eslint-env jest */ +import {computeRMS, computeChunkedRMS} from '../../../src/lib/audio/audio-util'; + +describe('computeRMS', () => { + test('returns 0 when given no samples', () => { + expect(computeRMS([])).toEqual(0); + }); + test('returns the RMS scaled by the given unity value and square rooted', () => { + const unity = 0.5; + const samples = [3, 2, 1]; + expect(computeRMS(samples, unity)).toEqual( + Math.sqrt(Math.sqrt((3 * 3 + 2 * 2 + 1 * 1) / 3) / 0.5) + ); + }); + test('uses a default unity value of 0.55', () => { + const samples = [1, 1, 1]; + // raw rms is 1, scaled to (1 / 0.55) and square rooted + expect(computeRMS(samples)).toEqual(Math.sqrt(1 / 0.55)); + }); +}); + + +describe('computeChunkedRMS', () => { + test('computes the rms for each chunk based on chunk size', () => { + const samples = [2, 1, 3, 2, 5]; + const chunkedLevels = computeChunkedRMS(samples, 2); + // chunked to [2, 0], [3, 0], [5] + // rms scaled with default unity of 0.55 + expect(chunkedLevels.length).toEqual(3); + expect(chunkedLevels).toEqual([ + Math.sqrt(Math.sqrt((2 * 2 + 1 * 1) / 2) / 0.55), + Math.sqrt(Math.sqrt((3 * 3 + 2 * 2) / 2) / 0.55), + Math.sqrt(Math.sqrt((5 * 5) / 1) / 0.55) + ]); + }); + test('chunk size larger than sample size creates single chunk', () => { + const samples = [1, 1, 1]; + const chunkedLevels = computeChunkedRMS(samples, 7); + // chunked to [1, 1, 1] + // rms scaled with default unity of 0.55 + expect(chunkedLevels.length).toEqual(1); + expect(chunkedLevels).toEqual([Math.sqrt(1 / 0.55)]); + }); + test('chunk size as multiple is handled correctly', () => { + const samples = [1, 1, 1, 1, 1, 1]; + const chunkedLevels = computeChunkedRMS(samples, 3); + // chunked to [1, 1, 1], [1, 1, 1] + // rms scaled with default unity of 0.55 + expect(chunkedLevels.length).toEqual(2); + expect(chunkedLevels).toEqual([Math.sqrt(1 / 0.55), Math.sqrt(1 / 0.55)]); + }); +});