synthing

a waveform sequencing synth on the web
Log | Files | Refs | Submodules

commit bd28ca810eab6d2303217ade018939a0b6cf5f2d
parent 539500c7df344f7c530fb5120f43a0a34cecb1af
Author: Massimo Siboldi <mdsiboldi@gmail.com>
Date:   Mon, 19 Feb 2018 21:41:29 -0800

Too many changes for one commit, but i'll list them out
sorry, future me
- knob broken into param and wheel, now param has room for a child
element, which is sometimes wheel.
- now Param can be fixed to a certain precision.
- more data associated with ADSR to have better control over look /
feel and data ranges.
- metroMs becomes bpm, and it becomes a setTimeout loop to allow for
changing bpm. bpm has a cool param UI, soon to be with a blinking
light. there is an improperly named and / or hacky variable over there.
- a custom checkbox starts to exist, without the accesibility of
normal checkboxes. focusable custom checkbox is on the way.
- now the App does the combining of waveforms ceremony, so that Synth
can use it as well as other canvasses (performance mode)
- edited some default settings for bug fixes and looks

Diffstat:
Msrc/App/index.js | 239++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Asrc/CheckBox/index.js | 18++++++++++++++++++
Asrc/CheckBox/style.css | 23+++++++++++++++++++++++
Dsrc/Knob/index.js | 34----------------------------------
Asrc/Param/index.js | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Param/style.css | 33+++++++++++++++++++++++++++++++++
Msrc/Polyphonic.js | 8++++----
Msrc/Synth/index.js | 6+++---
Msrc/WaveManager/index.js | 48++++++++++++++++++++++++++++--------------------
Msrc/WaveManager/style.css | 12++++++++++--
Asrc/Wheel/index.js | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/consts.js | 2+-
Msrc/helpers.js | 1+
13 files changed, 377 insertions(+), 150 deletions(-)

diff --git a/src/App/index.js b/src/App/index.js @@ -3,7 +3,8 @@ import WaveEditor from '../WaveEditor/'; import WaveManager from '../WaveManager/'; import Synth from '../Synth/'; import CircleButton from '../CircleButton/'; -import Knob from '../Knob/'; +import Param from '../Param/'; +import Wheel from '../Wheel/'; import './App.css'; import consts from '../consts.js'; import helpers from '../helpers.js'; @@ -27,7 +28,7 @@ const immObjArray = { const newArr = arr.slice(); newArr.splice(idx, 1); return newArr; - } + } } const boolArray = { @@ -47,18 +48,45 @@ const boolArray = { } } +const adsrProperties = [ + { + name: 'attack', + suffix: 's', + maxVal: 10 + }, + { + name: 'decay', + suffix: 's', + maxVal: 10 + }, + { + name: 'sustain', + maxVal: 2 + }, + { + name: 'release', + suffix: 's', + maxVal: 30, + } +]; + const Adsr = (props) => ( - <div> - {['a', 'd', 's', 'r'].map((letter) => ( - <Knob - val={props.adsr[letter]} - minVal={0} - maxVal={3} - step={0.1} - update={(newVal) => {props.update(letter, newVal)}} - /> - ))} + <div style="display: inline-block;"> + {adsrProperties.map((aspect) => ( + <Param + suffix={aspect.suffix || ''} + precision={1} + val={props.adsr[aspect.name]} + name={aspect.name} + minVal={0} + maxVal={aspect.maxVal} + step={0.1} + update={(newVal) => {props.update(aspect.name, newVal)}} + > + <Wheel percent={props.adsr[aspect.name] / aspect.maxVal} /> + </Param> + ))} </div> ) @@ -68,26 +96,27 @@ class App extends Component { super(); let initBeats = 4; this.state = { - metroMs: 250, + bpm: 120, beat: 0, waveforms: [{ + active: true, waveform: initialWave.slice(), - beats: boolArray.create(initBeats) + beats: boolArray.update(boolArray.create(initBeats), 0, true) }], numBeats: initBeats, editingWaveformIdx: 0, adsr: { - a: 0.3, - d: 1, - s: 0.4, - r: 1 + attack: 0.3, + decay: 1, + sustain: 0.4, + release: 1 } } } - updateAdsr = (letter, val) => { + updateAdsr = (aspect, val) => { this.setState({ - adsr: Object.assign({}, this.state.adsr, {[letter]: val}) + adsr: Object.assign({}, this.state.adsr, {[aspect]: val}) }); } @@ -100,6 +129,19 @@ class App extends Component { return accum; }, []) + //TODO: for some reason, this function ends up modifying the array passed in as a prop to WaveEditor. I probably need to break editor out into viewer as well. But in any case, there should be a nice way to observe changes in the array or at least communicate when a change has happened. + totalWaveform = () => { + const allWaves = this.activeWaveforms(); + if (allWaves.length === 0) { + return new Array(consts.BUF_SIZE).fill(0); + } + const firstWave = allWaves.shift(); + return allWaves.reduce( + (totalArray, currWaveform, i) => totalArray.map((val, j) => ((val * (i + 1)) + currWaveform[j]) / (i + 2)), + firstWave + ) + } + updateWaveform = (idx = this.state.editingWaveformIdx, opts) => { this.setState({ waveforms: immObjArray.update(this.state.waveforms, idx, opts) @@ -124,7 +166,7 @@ class App extends Component { waveform, beats: boolArray.create(this.state.numBeats) }); - + const state = { waveforms }; @@ -149,15 +191,18 @@ class App extends Component { } metro = () => { + this.setState({ + interval: true + }); const loop = () => { - this.setState({ - beat: (this.state.beat + 1) % this.state.numBeats - }) + if (this.state.interval) { + this.setState({ + beat: (this.state.beat + 1) % this.state.numBeats + }) + window.setTimeout(loop, (1 / this.state.bpm) * 60000); + } } - const interval = window.setInterval(loop, this.state.metroMs); - this.setState({ - interval - }) + loop(); } stopMetro = () => { @@ -165,7 +210,7 @@ class App extends Component { window.clearInterval(this.state.interval); } this.setState({ - interval: null, + interval: false, beat: 0 }) } @@ -175,69 +220,91 @@ class App extends Component { console.log('wow i got through', e.key); } + setBpm = (newBpm) => { + this.setState({ + bpm: newBpm + }); + } + render() { const waves = this.state.waveforms.map((form, idx) => { return ( - <WaveManager - activate={this.changeEditingWaveform.bind(this, idx)} - remove={this.removeWaveform.bind(this, idx)} - duplicate={() => { - let pleaseActivate = false; - if (this.state.editingWaveformIdx === idx) { - pleaseActivate = true; - } - this.addWaveform(this.state.waveforms[idx].waveform.slice(), idx + 1, pleaseActivate); - }} - activated={idx === this.state.editingWaveformIdx} - beats={this.state.waveforms[idx].beats} - beat={this.state.beat} - updateBeat={(i, val) => { - this.updateWaveform(idx, { - beats: boolArray.update( - this.state.waveforms[idx].beats, - i, - val - ) - }); - }} - ></WaveManager> + <WaveManager + activate={this.changeEditingWaveform.bind(this, idx)} + remove={this.removeWaveform.bind(this, idx)} + duplicate={() => { + let pleaseActivate = false; + if (this.state.editingWaveformIdx === idx) { + pleaseActivate = true; + } + this.addWaveform(this.state.waveforms[idx].waveform.slice(), idx + 1, pleaseActivate); + }} + activated={idx === this.state.editingWaveformIdx} + waveform={this.state.waveforms[idx].waveform} + beats={this.state.waveforms[idx].beats} + beat={this.state.beat} + updateBeat={(i, val) => { + this.updateWaveform(idx, { + beats: boolArray.update( + this.state.waveforms[idx].beats, + i, + val + ) + }); + }} + ></WaveManager> ); }) return ( - <div - className="App" - onKeyDown={this.keyHandler} - > - <h1>synthing</h1> - <WaveEditor - mouseData={this.state.mouseData} - waveform={this.editingWaveform()} - updateWaveform={(waveform) => { - this.updateWaveform(this.state.editingWaveformIdx, {waveform}); - }} - ></WaveEditor> - <div class="global-controls"> - <CircleButton - active={this.state.interval} - action={this.metro} - disabled={this.state.interval} - > - <div class="triangle"></div> - </CircleButton> - <CircleButton - active={!this.state.interval} - action={this.stopMetro} - > - <div class="rectangle"></div> - </CircleButton> - <button onClick={() => {this.setBeats(this.state.numBeats + 1)}}>+ beat</button> - <button onClick={() => {this.setBeats(this.state.numBeats - 1)}}>- beat</button> - <Adsr adsr={this.state.adsr} update={this.updateAdsr} /> - </div> - {waves} - <button onClick={() => this.addWaveform()}>+</button> - <Synth waveforms={this.activeWaveforms()} adsr={this.state.adsr}></Synth> - </div> + <div + className="App" + onKeyDown={this.keyHandler} + > + <h1>synthing</h1> + <WaveEditor + mouseData={this.state.mouseData} + waveform={this.editingWaveform()} + updateWaveform={(waveform) => { + this.updateWaveform(this.state.editingWaveformIdx, {waveform}); + }} + ></WaveEditor> + <div class="global-controls"> + <CircleButton + active={this.state.interval} + action={this.metro} + disabled={this.state.interval} + > + <div class="triangle"></div> + </CircleButton> + <CircleButton + active={!this.state.interval} + action={this.stopMetro} + > + <div class="rectangle"></div> + </CircleButton> + <Param + precision={0} + name="bpm" + minVal={20} + maxVal={600} + step={1} + val={this.state.bpm} + update={this.setBpm} + /> + <Param + name="beats" + minVal="1" + maxVal={16} + step="1" + val={this.state.numBeats} + update={this.setBeats} + /> + <Adsr adsr={this.state.adsr} update={this.updateAdsr} /> + </div> + {waves} + <button onClick={() => this.addWaveform()}>+</button> + <Synth waveform={this.totalWaveform()} adsr={this.state.adsr}></Synth> + </div> ); } } diff --git a/src/CheckBox/index.js b/src/CheckBox/index.js @@ -0,0 +1,18 @@ +import { h } from 'preact'; +import helpers from '../helpers'; +import './style.css'; + +const handleToggle = (update, checked, ev) => { + update(!checked); +} + +export default (props) => { + return ( + <div + class={`${props.class} checkbox`} + onClick={helpers.partial(handleToggle, props.update, props.checked)} + > + {props.checked ? <div class="checkbox-circle"></div> : null } + </div> + ); +} diff --git a/src/CheckBox/style.css b/src/CheckBox/style.css @@ -0,0 +1,23 @@ +.checkbox { + background: #f1ddd8; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin: 5px; +} +.checkbox .checkbox-circle { + width: 25px; + height: 25px; + border-radius: 999px; + background: #ff735e; +} + +.checkbox.beat { + background: #ff735e; +} + +.checkbox.beat .checkbox-circle { + background: #f1ddd8; +} diff --git a/src/Knob/index.js b/src/Knob/index.js @@ -1,34 +0,0 @@ -import { h, Component} from 'preact'; -import helpers from '../helpers.js'; - -export default class Knob extends Component { - componentDidMount() { - const handleMove = (ev) => { - let step = this.props.step || 0.5; - let newVal = this.props.val - (ev.movementY * step); - if (typeof(this.props.minVal) === 'number' && (newVal < this.props.minVal)) { - newVal = this.props.minVal; - } else if (typeof(this.props.maxVal) === 'number' && (newVal > this.props.maxVal)) { - newVal = this.props.maxVal; - } - this.props.update(newVal); - } - this.knobRef.addEventListener('dblclick', (ev) => { - console.log('double'); - this.knobRef.setAttribute('contenteditable', true); - }); - this.knobRef.addEventListener('mousedown', (ev) => { - ev.preventDefault(); - document.addEventListener('mousemove', handleMove); - helpers.oneTime(document, 'mouseup', (ev) => { - document.removeEventListener('mousemove', handleMove); - }); - }); - } - render() { - return ( - <div ref={(knob) => {this.knobRef = knob}}>{this.props.val}</div> - ) - } -} - diff --git a/src/Param/index.js b/src/Param/index.js @@ -0,0 +1,49 @@ +import { h, Component} from 'preact'; +import helpers from '../helpers.js'; +import './style.css'; + +export default class Param extends Component { + componentDidMount() { + const handleMove = (ev) => { + let step = this.props.step || 0.5; + let newVal = this.props.val - (ev.movementY * step); + if (typeof(this.props.minVal) === 'number' && (newVal < this.props.minVal)) { + newVal = this.props.minVal; + } else if (typeof(this.props.maxVal) === 'number' && (newVal > this.props.maxVal)) { + newVal = this.props.maxVal; + } + this.props.update(newVal); + } + this.paramRef.addEventListener('mousedown', (ev) => { + ev.preventDefault(); + document.addEventListener('mousemove', handleMove); + helpers.oneTime(document, 'mouseup', (ev) => { + document.removeEventListener('mousemove', handleMove); + }); + }); + } + handleChange(e) { + this.props.update(e.target.value); + } + getNumString() { + return (this.props.precision ? + this.props.val.toFixed(this.props.precision) : + this.props.val) + (this.props.suffix || ''); + } + render() { + const inputId = `param-${this.props.name}`; + return ( + <div class="param" ref={(param) => {this.paramRef = param}}> + <label for={inputId}>{this.props.name}</label> + <input + type="text" + id={inputId} + value={this.getNumString()} + onChange={this.handleChange} + ></input> + {this.props.children} + </div> + ) + } +} + diff --git a/src/Param/style.css b/src/Param/style.css @@ -0,0 +1,33 @@ +.param { + position: relative; + display: inline-block; + padding: 10px; + padding-right: 46px; + box-sizing: border-box; +} + +.param > * { + cursor: ns-resize; +} + +.param label { + display: block; + font-size: 11px; + text-align: left; + position: relative; + left: -1px; +} + +.param input { + border: 0; + width: 60px; + height: 22px; + font-size: 16px; + text-align: center; +} + +.param > canvas { + position: absolute; + right: 5px; + top: 15px; +} diff --git a/src/Polyphonic.js b/src/Polyphonic.js @@ -13,10 +13,10 @@ export default class Polyphonic { let osc = this.audioContext.createOscillator(); let gain = this.audioContext.createGain(); let envelope = new Envelope(this.audioContext, { - attackTime: adsr.a, - decayTime: adsr.d, - sustainLevel: adsr.s, - releaseTime: adsr.r, + attackTime: adsr.attack, + decayTime: adsr.decay, + sustainLevel: adsr.sustain, + releaseTime: adsr.release, maxLevel: 0.4 }); diff --git a/src/Synth/index.js b/src/Synth/index.js @@ -25,7 +25,7 @@ export default class Synth extends Component { return false; } componentWillReceiveProps(newProps) { - updateAudio(newProps.waveforms); + updateAudio(newProps.waveform); } render() { return null; @@ -42,8 +42,8 @@ function combineWaveforms(waveforms) { return res; } -function updateAudio(waveforms) { - const waveform = combineWaveforms(waveforms); +function updateAudio(waveform) { + //const waveform = combineWaveforms(waveforms); FFT.forward(waveform); const periodicWave = ac.createPeriodicWave( new Float32Array(FFT.real), diff --git a/src/WaveManager/index.js b/src/WaveManager/index.js @@ -1,30 +1,38 @@ import { h, Component } from 'preact'; +import CheckBox from '../CheckBox/'; +import WaveTable from '../WaveTable/'; +import helpers from '../helpers'; import './style.css'; export default class WaveManager extends Component { render() { return ( <div class="wave-manager"> - <div class="buttons"> - {this.props.activated ? 'x' : ''} - <button onClick={this.props.activate}>activate</button> - <button onClick={this.props.remove}>remove</button> - <button onClick={this.props.duplicate}>dupe</button> - </div> - <div class="beats"> - {this.props.beats.map((val, idx) => { - return ( - <span class={this.props.beat === idx ? 'beat' : ''}> - <input - type="checkbox" - checked={val} - onChange={(ev) => {this.props.updateBeat( - idx, - ev.target.checked)}} - ></input> - </span> - )})} - </div> + <div class="buttons"> + {this.props.activated ? ( + <div> + <button onClick={this.props.remove}>remove</button> + <button onClick={this.props.duplicate}>dupe</button> + </div> + ) : ''} + </div> + <div class="beats"> + <div onClick={this.props.activate}> + <WaveTable + height={40} + width={75} + waveform={this.props.waveform.slice()} + /> + </div> + + {this.props.beats.map((val, idx) => { + return ( + <CheckBox + class={this.props.beat === idx ? 'beat' : ''} + checked={val} + update={helpers.partial(this.props.updateBeat, idx)} /> + )})} + </div> </div> ); } diff --git a/src/WaveManager/style.css b/src/WaveManager/style.css @@ -1,3 +1,11 @@ -.beat { - background: red; +.beats { + display: flex; +} + +.beats > * { + margin: 5px; +} + +.beats > *:first-child { + margin-right: 10px; } diff --git a/src/Wheel/index.js b/src/Wheel/index.js @@ -0,0 +1,54 @@ +import { h, Component} from 'preact'; + +const drawCircle = (canvas, percent) => { + const context = canvas.getContext('2d'); + const color = '#ff735e'; + + context.lineWidth = 6; + context.clearRect(0,0,36,36); + context.beginPath(); + context.strokeStyle = "#00000011"; + + if (percent === 0) { + context.arc(18, 18, 15, 0, 10); + context.stroke(); + } + else { + const begin = -(Math.PI / 2); + const end = 2 * Math.PI * percent - Math.PI / 2; + + context.arc(18, 18, 15, end, begin); + context.stroke(); + context.beginPath(); + context.strokeStyle = color; + context.arc(18, 18, 15, begin, end); + context.stroke(); + } + +} + +export default class Wheel extends Component { + componentWillUpdate() { + return false; + } + + componentDidMount() { + drawCircle(this.wheelRef, this.props.percent); + } + + componentWillReceiveProps(newProps) { + if (newProps.percent !== this.props.percent); + drawCircle(this.wheelRef, newProps.percent); + } + + render() { + return ( + <canvas + class="wheel" + height="36" + width="36" + ref={(ref) => {this.wheelRef = ref}} + ></canvas> + ); + } +} diff --git a/src/consts.js b/src/consts.js @@ -1,3 +1,3 @@ export default { - BUF_SIZE: 1024 + BUF_SIZE: 256 } diff --git a/src/helpers.js b/src/helpers.js @@ -1,4 +1,5 @@ export default { + partial: (f, ...args) => (...moreArgs) => f(...args, ...moreArgs), linear: (m, x, b) => (m * x) + b, bounded: (val, min, max) => val < min ? min : (val > max ? max : val), scale: (buf, amt) => buf.map(val => val * amt),