diff options
| author | s-ol <s+removethis@s-ol.nu> | 2022-03-21 23:45:16 +0000 |
|---|---|---|
| committer | s-ol <s+removethis@s-ol.nu> | 2022-03-21 23:55:10 +0000 |
| commit | 9f4a54afa2fa3ec60d2b0946dabfcc198b43b44f (patch) | |
| tree | 701f5725f89f35623045cad3e654b5d1ef8332d5 | |
| parent | simpler key styling (diff) | |
| download | isomorphic-kb-explorer-9f4a54afa2fa3ec60d2b0946dabfcc198b43b44f.tar.gz isomorphic-kb-explorer-9f4a54afa2fa3ec60d2b0946dabfcc198b43b44f.zip | |
move MIDI settings in toolbar, reorganize
| -rw-r--r-- | app/app.js | 286 | ||||
| -rw-r--r-- | app/config.js | 278 | ||||
| -rw-r--r-- | app/index.js | 3 | ||||
| -rw-r--r-- | app/style.js | 5 |
4 files changed, 244 insertions, 328 deletions
@@ -1,6 +1,7 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { css, pos, width, height } from './style'; import * as notes from './notes'; +import { useAppState, useMIDI, Toolbar } from './config'; const statbyte = (stat, chan) => (stat << 4) | chan; const CHANNEL = 0b1011; @@ -36,9 +37,8 @@ const Hexagon = ({ x, y, children, state, note, major, scale, noteon, noteoff }) style={pos([x, y], major)} > <div - className="inner" onMouseDown={() => noteon(note)} - onMouseUp ={() => noteoff(note)} + onMouseUp={() => noteoff(note)} > {children} </div> @@ -60,7 +60,7 @@ const layouts = { // B = 7 // C = 2 = x // A + B = 12 = y - let note = 41 + 2 * x + 6 * y; + let note = 5 + 2 * x + 6 * y; if (y % 2 == 0) note += 1; return note; }, @@ -128,7 +128,7 @@ const tallyup = (steps, offset=0) => { }; }; -const Keyboard = ({ w, h, layout: { major, map }, state, scale, labels, noteon, noteoff }) => ( +const Keyboard = ({ w, h, layout: { major, map }, state, scale, transpose, labels, noteon, noteoff }) => ( <div className={`keyboard ${major}-major`} style={{ @@ -138,7 +138,7 @@ const Keyboard = ({ w, h, layout: { major, map }, state, scale, labels, noteon, > {range(w).map((_, x) => ( range(h).map((_, y) => { - const note = map([x, y], [w, h]); + const note = map([x, y], [w, h]) + transpose; const onCore = scale && scale.notes.indexOf(note) >= 0; const onScale = scale && scale.modulo.indexOf(note % 12) >= 0; return ( @@ -182,30 +182,7 @@ const ChordView = ({ chord }) => { </div> ); }; - -css(` -nav { - display: flex; - flex-wrap: wrap; - justfiy-content: space-between; -} - -nav > .group, nav > .spacer { - display: flex; - align-items: baseline; - padding: 0.25em 1em; - gap: 1em; - - border: 0 solid #363636; - border-width: 1px 0 1px 0; -} - -nav > .group:nth-child(2n) { - background: #363636; -} - -nav > .spacer { flex: 1; } -`); +; css(` main { @@ -240,190 +217,123 @@ main:focus-within .focus-msg { } `); -export default class App extends React.Component { - state = { +export default () => { + const [settings, setSettings] = useAppState({ layout: 'wicki_hayden', scale: 'major', labels: 'english', offset: 60, - state: {}, + transpose: 3*12, w: 12, h: 4, - }; - - ref = React.createRef(); - - componentDidMount() { - this.ref.current.addEventListener("keydown", this.onkeydown); - this.ref.current.addEventListener("keyup", this.onkeyup); - } - - componentDidUpdate(prevProps) { - this.send(CHANNEL, ALL_SOUND_OFF, 0); - this.send(CHANNEL, ALL_NOTES_OFF, 0); - - if (prevProps.midiin) - prevProps.midiin.onmidimessage = null; - - if (this.props.midiin) - this.props.midiin.onmidimessage = this.onmessage; - } + }); + const [state, setState] = useState({}); + const midi = useMIDI(); - blink = (note) => { - this.onmessage({ cmd: NOTE_ON, note }); - setTimeout(() => this.onmessage({ cmd: NOTE_OFF, note }), 250); - } + const ref = React.createRef(); - send(command, note, vel=127) { - if (command === NOTE_ON && this.state.offset === null) - this.setState({ offset: note }); + const midiin = midi.inputs.get(settings.midiin); + const midiout = midi.outputs.get(settings.midiout); - if (!this.props.midiout) - return; + const send = (command, note, vel=127) => { + if (!midiout) return; const msg = [statbyte(command, 0), note, vel]; - this.props.midiout.send(msg); - } + midiout.send(msg); + }; - noteon(note) { - if (this.state.offset === null) - this.setState({ offset: note }); + const noteon = (note) => { + if (settings.offset === null) { + setSettings((s) => ({ ...s, offset: note })); + } - this.setState(({ state }) => ({ state: { ...state, [note]: true } })); - this.send(NOTE_ON, note); + setState((s) => ({ ...s, [note]: true })); + send(NOTE_ON, note); } - noteoff(note) { - this.setState(({ state }) => ({ state: { ...state, [note]: false } })); - this.send(NOTE_OFF, note); + const noteoff = (note) => { + setState((s) => ({ ...s, [note]: false })); + send(NOTE_OFF, note); } - set = (e) => { - const key = e.target.name; - let value = e.target.value; - if (key === 'w' || key === 'h') value = +value; - this.setState({ [key]: value }); - }; + useEffect(() => { + ref.current.onkeydown = (e) => { + if (e.repeat) return; - onoffset = (offset) => (e) => this.setState({ offset }); + const note = notes.key2midi[e.code]; + if (!note) return; + e.preventDefault(); + noteon(note); + } - onmessage = (e) => { - const message = parse(e); + ref.current.onkeyup = (e) => { + if (e.repeat) return; - switch (message.cmd) { - case NOTE_ON: - console.info(message.note); - break; + const note = notes.key2midi[e.code]; + if (!note) return; + e.preventDefault(); + noteoff(note); } + }); - switch (message.cmd) { - case NOTE_ON: - this.setState(({ state }) => ({ state: { ...state, [message.note]: true } })); - break; + useEffect(() => { + send(CHANNEL, ALL_SOUND_OFF, 0); + send(CHANNEL, ALL_NOTES_OFF, 0); + }, [midiout]); - case NOTE_OFF: - this.setState(({ state }) => ({ state: { ...state, [message.note]: false } })); - break; - } - } + useEffect(() => { + if (!midiin) return; - onkeydown = (e) => { - if (e.repeat) return; + midiin.onmidimessage = (e) => { + const message = parse(e); - const note = notes.key2midi[e.code]; - if (!note) return; - e.preventDefault(); - this.noteon(note); - } + switch (message.cmd) { + case NOTE_ON: + if (settings.offset === null) { + setSettings((s) => ({ ...s, offset: note })); + } - onkeyup = (e) => { - if (e.repeat) return; + setState((s) => ({ ...s, [message.note]: true })); + break; - const note = notes.key2midi[e.code]; - if (!note) return; - e.preventDefault(); - this.noteoff(note); - } + case NOTE_OFF: + setState((s) => ({ ...s, [message.note]: false })); + break; + } + } - render() { - const { configure } = this.props; - const { w, h, scale, layout, labels, offset, state } = this.state; - - const chord = Object.entries(state).filter(([note, on]) => on).map(([k, _]) => k); - chord.sort(); - - return ( - <div className="app"> - <nav> - <div className="group"> - <button onClick={configure}>midi settings</button> - </div> - <div className="group"> - <label>layout:</label> - <select name="layout" value={layout} onChange={this.set}> - <option value="wicki_hayden">Wicki-Hayden</option> - <option value="harmonic">Harmonic Table</option> - <option value="gerhard">Gerhard</option> - </select> - </div> - <div className="group"> - <label>note format:</label> - <select name="labels" value={labels} onChange={this.set}> - <option value="english">English</option> - <option value="german">German</option> - <option value="sol">Solfège</option> - <option value="midi">MIDI no</option> - </select> - </div> - <div className="group"> - <label>scale:</label> - <button onClick={this.onoffset(null)}> - {labels ? notes.labels[labels][offset % 12] : offset} - </button> - <select name="scale" value={scale} onChange={this.set}> - <option value="none">None</option> - <option value="major">Major</option> - <option value="minor_nat">Natural Minor</option> - <option value="minor_harm">Harmonic Minor</option> - <option value="minor_mel">Melodic Minor</option> - <option value="minor_hung">Hungarian Minor</option> - <option value="whole">Whole-Tone</option> - <option value="penta">Pentatonic</option> - </select> - </div> - <div className="group"> - <label>octave:</label> - <button onClick={this.onoffset(offset - 12)}>-</button> - {Math.floor(offset / 12)} - <button onClick={this.onoffset(offset + 12)}>+</button> - </div> - <div className="group"> - <label>size:</label> - <input className="small" type="number" min="1" name="w" value={w} onChange={this.set} /> - {'x'} - <input className="small" type="number" min="1" name="h" value={h} onChange={this.set} /> - </div> - <div className="spacer" /> - </nav> - - <main ref={this.ref} tabIndex="0"> - <Keyboard - w={w} - h={h} - noteon={this.noteon.bind(this)} - noteoff={this.noteoff.bind(this)} - layout={layouts[layout]} - scale={scale !== "none" && offset !== null && tallyup(scales[scale], offset)} - labels={notes.labels[this.state.labels]} - offset={offset} - state={state} - /> - <ChordView chord={chord} /> - <div className="focus-msg"> - <span>click here to activate<br/>keyboard input</span> - </div> - </main> - </div> - ); - } + return () => { + midiin.onmidimessage = null; + }; + }, [midiin]); + + const { scale, layout, labels, offset } = settings; + const chord = Object.entries(state).filter(([note, on]) => on).map(([k, _]) => k); + chord.sort(); + + return ( + <div className="app"> + <Toolbar + state={settings} + setState={setSettings} + midi={midi} + /> + + <main ref={ref} tabIndex="0"> + <Keyboard + {...settings} + noteon={noteon} + noteoff={noteoff} + layout={layouts[layout]} + scale={scale !== "none" && offset !== null && tallyup(scales[scale], offset)} + labels={notes.labels[labels]} + state={state} + /> + <ChordView chord={chord} /> + <div className="focus-msg"> + <span>click here to activate<br/>keyboard input</span> + </div> + </main> + </div> + ); }; diff --git a/app/config.js b/app/config.js index a59d989..eac34f8 100644 --- a/app/config.js +++ b/app/config.js @@ -1,14 +1,13 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; +import React, { useState, useCallback, useEffect } from 'react'; +import { labels as noteLabels } from './notes'; import { css } from './style'; -const setState = (key, state) => { +const saveState = (key, state) => { try { localStorage.setItem(key, JSON.stringify(state)); - return true; } catch (e) { - return false; + console.error(e); } }; @@ -22,141 +21,146 @@ const loadState = (key, defaultState=null) => { } }; -css(` - .settings { - width: 500px; - margin: auto; - padding: 1em; - text-align: left; - } - - .setting { - margin: 0.4em 0; - } - - .setting > label { - display: inline-block; - width: 200px; - } - - .setting > select, .setting > input, .settings button { - width: 200px; - margin-left: 20px; - } +export const useAppState = (defaultState) => { + const [state, setState] = useState(defaultState); - .setting > select:disabled { - color: #aaa; - background: #444; - } + useEffect(() => setState(loadState('settings', defaultState)), []); + useEffect(() => saveState('settings', state), [state]); - .settings > button { - text-align: center; - margin-right: 20px; - } -`); + return [state, setState]; +}; -const Dropdown = ({ name, list, set, disabled, value }) => ( - <div className="setting"> - <label>{name}</label> - <select value={value} onChange={e => set(e, e.target.value)} disabled={!list || disabled}> - <option>(none)</option> - {list && ([...list.values()]).map((port) => ( - <option value={port.id} key={port.id}>{port.name}</option> - ))} - </select> - </div> +export const useMIDI = () => { + const [inputs, setInputs] = useState(new Map()); + const [outputs, setOutputs] = useState(new Map()); + + const reload = useCallback(() => { + navigator.requestMIDIAccess && + navigator.requestMIDIAccess({ sysex: true }) + .then((access) => { + setInputs(access.inputs); + setOutputs(access.outputs); + }); + }); + + useEffect(reload, []); + + return { inputs, outputs, reload }; +} + +const Dropdown = ({ list, name, onChange, value }) => ( + <select value={value} onChange={onChange} disabled={!list || !list.size}> + <option>(none)</option> + {list && ([...list.values()]).map((port) => ( + <option value={port.id} key={port.id}>{port.name}</option> + ))} + </select> ); -const Checkbox = ({ name, set, value }) => ( - <div className="setting"> - <label>{name}</label> - <input - type="checkbox" - onChange={e => set(e, !value)} - checked={value} - /> - </div> -); +css(` +nav { + display: flex; + flex-wrap: wrap; + justfiy-content: space-between; +} + +nav > .group, nav > .spacer { + display: flex; + align-items: baseline; + padding: 0.25em 1em; + gap: 1em; + + border: 0 solid #363636; + border-width: 1px 0 1px 0; +} + +nav > .group:nth-child(2n) { + background: #363636; +} + +nav > .spacer { flex: 1; } +`); -export default WrappedComponent => - class SettingsStore extends React.Component { - constructor(props) { - super(props); - - this.state = loadState('settings', { configured: false, midiSupport: false, showMidi: false }); - this.configure = () => this.setState({ configured: false }); - - this.reload = () => { - navigator.requestMIDIAccess({ sysex: true }) - .then(access => { - this.inputs = access.inputs; - this.outputs = access.outputs; - this.setState({ - midiSupport: true, - }); - this.forceUpdate(); - }); - } - } - - componentWillUpdate(props, state) { - setState('settings', state); - } - - componentDidMount() { - if (navigator.requestMIDIAccess) - this.reload(); - } - - set(k, v) { - return (e, vv) => { - e.stopPropagation(); - - this.setState({ [k]: v === undefined - ? vv - : v }); - } - } - - render() { - const { configured, midiin, midiout, showMidi } = this.state; - - if (configured) { - return ( - <WrappedComponent - {...this.props} - {...this.state} - midiin={this.inputs && this.inputs.get(midiin)} - midiout={this.outputs && this.outputs.get(midiout)} - configure={this.configure} - /> - ); - } - - return ( - <div className="panel settings"> - <h3>Display</h3> - <Checkbox - name="Show notes as MIDI number" - set={this.set('showMidi')} - value={showMidi} - /> - <h3>MIDI I/O</h3> - <Dropdown - name="MIDI Keyboard Input" - list={this.inputs} - set={this.set('midiin')} - value={midiin} - /> - <Dropdown - name="MIDI Output" - list={this.outputs} - set={this.set('midiout')} - value={midiout} - /> - <button onClick={this.reload}>reload MIDI device list</button> - <button onClick={this.set('configured', true)}>start</button> - </div> - ); - }; - }; +export const Toolbar = ({ state, setState, midi }) => { + const { labels, offset, transpose } = state; + + const track = (key) => ({ + name: key, + value: state[key], + onChange: (e) => { + e.stopPropagation(); + let value = e.target.value; + if (key === 'w' || key === 'h') value = +value; + setState({ ...state, [key]: value }); + }, + }); + + return ( + <nav> + <div className="group"> + <label>layout:</label> + <select {...track('layout')}> + <option value="wicki_hayden">Wicki-Hayden</option> + <option value="harmonic">Harmonic Table</option> + <option value="gerhard">Gerhard</option> + </select> + </div> + <div className="group"> + <label>note format:</label> + <select {...track('labels')}> + <option value="english">English</option> + <option value="german">German</option> + <option value="sol">Solfège</option> + <option value="midi">MIDI no</option> + </select> + </div> + <div className="group"> + <label>scale:</label> + <button onClick={() => setState({ ...state, offset: null })}> + {offset === null + ? "listening..." + : (noteLabels[labels] ? noteLabels[labels][offset % 12] : offset)} + </button> + <select {...track('scale')}> + <option value="none">None</option> + <option value="major">Major</option> + <option value="minor_nat">Natural Minor</option> + <option value="minor_harm">Harmonic Minor</option> + <option value="minor_mel">Melodic Minor</option> + <option value="minor_hung">Hungarian Minor</option> + <option value="whole">Whole-Tone</option> + <option value="penta">Pentatonic</option> + </select> + </div> + <div className="group"> + <label>octave:</label> + <button onClick={() => setState({ ...state, transpose: transpose - 12 })}>-</button> + {Math.floor(transpose / 12)} + <button onClick={() => setState({ ...state, transpose: transpose + 12 })}>+</button> + </div> + <div className="group"> + <label>size:</label> + <input className="small" type="number" min="1" {...track('w')} /> + {'x'} + <input className="small" type="number" min="1" {...track('h')} /> + </div> + <div className="group"> + <label>midi</label> + in: + <Dropdown + name="midiin" + list={midi.inputs} + {...track('midiin')} + /> + out: + <Dropdown + name="midiout" + list={midi.outputs} + {...track('midiout')} + /> + <button onClick={midi.reload}>↻</button> + </div> + <div className="spacer" /> + </nav> + ); +}; diff --git a/app/index.js b/app/index.js index 6e2a51e..75a2a9f 100644 --- a/app/index.js +++ b/app/index.js @@ -1,9 +1,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; import App from './app'; -import config from './config'; -const $App = config(App); +const $App = App; const node = document.createElement('div'); ReactDOM.render(<$App />, node); diff --git a/app/style.js b/app/style.js index a20510e..26a6ce6 100644 --- a/app/style.js +++ b/app/style.js @@ -59,6 +59,9 @@ button, input, select { padding: 0.25em; border-radius: 0.5em; } +button:disabled, input:disabled, select:disabled { + opacity: 0.75; +} input.small { width: 4em; @@ -103,7 +106,7 @@ label { border-color: #363636; color: #848484; } -.hexagon.core > .inner { +.hexagon.core { border-color: #eeeeee; } |
