git.s-ol.nu isomorphic-kb-explorer / 9f4a54a
move MIDI settings in toolbar, reorganize s-ol 4 months ago
4 changed file(s) with 255 addition(s) and 339 deletion(s). Raw diff Collapse all Expand all
0 import React from 'react';
0 import React, { useState, useEffect } from 'react';
11 import { css, pos, width, height } from './style';
22 import * as notes from './notes';
3 import { useAppState, useMIDI, Toolbar } from './config';
34
45 const statbyte = (stat, chan) => (stat << 4) | chan;
56 const CHANNEL = 0b1011;
3536 style={pos([x, y], major)}
3637 >
3738 <div
38 className="inner"
3939 onMouseDown={() => noteon(note)}
40 onMouseUp ={() => noteoff(note)}
40 onMouseUp={() => noteoff(note)}
4141 >
4242 {children}
4343 </div>
5959 // B = 7
6060 // C = 2 = x
6161 // A + B = 12 = y
62 let note = 41 + 2 * x + 6 * y;
62 let note = 5 + 2 * x + 6 * y;
6363 if (y % 2 == 0) note += 1;
6464 return note;
6565 },
127127 };
128128 };
129129
130 const Keyboard = ({ w, h, layout: { major, map }, state, scale, labels, noteon, noteoff }) => (
130 const Keyboard = ({ w, h, layout: { major, map }, state, scale, transpose, labels, noteon, noteoff }) => (
131131 <div
132132 className={`keyboard ${major}-major`}
133133 style={{
137137 >
138138 {range(w).map((_, x) => (
139139 range(h).map((_, y) => {
140 const note = map([x, y], [w, h]);
140 const note = map([x, y], [w, h]) + transpose;
141141 const onCore = scale && scale.notes.indexOf(note) >= 0;
142142 const onScale = scale && scale.modulo.indexOf(note % 12) >= 0;
143143 return (
181181 </div>
182182 );
183183 };
184
185 css(`
186 nav {
187 display: flex;
188 flex-wrap: wrap;
189 justfiy-content: space-between;
190 }
191
192 nav > .group, nav > .spacer {
193 display: flex;
194 align-items: baseline;
195 padding: 0.25em 1em;
196 gap: 1em;
197
198 border: 0 solid #363636;
199 border-width: 1px 0 1px 0;
200 }
201
202 nav > .group:nth-child(2n) {
203 background: #363636;
204 }
205
206 nav > .spacer { flex: 1; }
207 `);
184 ;
208185
209186 css(`
210187 main {
239216 }
240217 `);
241218
242 export default class App extends React.Component {
243 state = {
219 export default () => {
220 const [settings, setSettings] = useAppState({
244221 layout: 'wicki_hayden',
245222 scale: 'major',
246223 labels: 'english',
247224 offset: 60,
248 state: {},
225 transpose: 3*12,
249226 w: 12,
250227 h: 4,
228 });
229 const [state, setState] = useState({});
230 const midi = useMIDI();
231
232 const ref = React.createRef();
233
234 const midiin = midi.inputs.get(settings.midiin);
235 const midiout = midi.outputs.get(settings.midiout);
236
237 const send = (command, note, vel=127) => {
238 if (!midiout) return;
239
240 const msg = [statbyte(command, 0), note, vel];
241 midiout.send(msg);
251242 };
252243
253 ref = React.createRef();
254
255 componentDidMount() {
256 this.ref.current.addEventListener("keydown", this.onkeydown);
257 this.ref.current.addEventListener("keyup", this.onkeyup);
244 const noteon = (note) => {
245 if (settings.offset === null) {
246 setSettings((s) => ({ ...s, offset: note }));
247 }
248
249 setState((s) => ({ ...s, [note]: true }));
250 send(NOTE_ON, note);
258251 }
259252
260 componentDidUpdate(prevProps) {
261 this.send(CHANNEL, ALL_SOUND_OFF, 0);
262 this.send(CHANNEL, ALL_NOTES_OFF, 0);
263
264 if (prevProps.midiin)
265 prevProps.midiin.onmidimessage = null;
266
267 if (this.props.midiin)
268 this.props.midiin.onmidimessage = this.onmessage;
253 const noteoff = (note) => {
254 setState((s) => ({ ...s, [note]: false }));
255 send(NOTE_OFF, note);
269256 }
270257
271 blink = (note) => {
272 this.onmessage({ cmd: NOTE_ON, note });
273 setTimeout(() => this.onmessage({ cmd: NOTE_OFF, note }), 250);
274 }
275
276 send(command, note, vel=127) {
277 if (command === NOTE_ON && this.state.offset === null)
278 this.setState({ offset: note });
279
280 if (!this.props.midiout)
281 return;
282
283 const msg = [statbyte(command, 0), note, vel];
284 this.props.midiout.send(msg);
285 }
286
287 noteon(note) {
288 if (this.state.offset === null)
289 this.setState({ offset: note });
290
291 this.setState(({ state }) => ({ state: { ...state, [note]: true } }));
292 this.send(NOTE_ON, note);
293 }
294
295 noteoff(note) {
296 this.setState(({ state }) => ({ state: { ...state, [note]: false } }));
297 this.send(NOTE_OFF, note);
298 }
299
300 set = (e) => {
301 const key = e.target.name;
302 let value = e.target.value;
303 if (key === 'w' || key === 'h') value = +value;
304 this.setState({ [key]: value });
305 };
306
307 onoffset = (offset) => (e) => this.setState({ offset });
308
309 onmessage = (e) => {
310 const message = parse(e);
311
312 switch (message.cmd) {
313 case NOTE_ON:
314 console.info(message.note);
315 break;
258 useEffect(() => {
259 ref.current.onkeydown = (e) => {
260 if (e.repeat) return;
261
262 const note = notes.key2midi[e.code];
263 if (!note) return;
264 e.preventDefault();
265 noteon(note);
316266 }
317267
318 switch (message.cmd) {
319 case NOTE_ON:
320 this.setState(({ state }) => ({ state: { ...state, [message.note]: true } }));
321 break;
322
323 case NOTE_OFF:
324 this.setState(({ state }) => ({ state: { ...state, [message.note]: false } }));
325 break;
268 ref.current.onkeyup = (e) => {
269 if (e.repeat) return;
270
271 const note = notes.key2midi[e.code];
272 if (!note) return;
273 e.preventDefault();
274 noteoff(note);
326275 }
327 }
328
329 onkeydown = (e) => {
330 if (e.repeat) return;
331
332 const note = notes.key2midi[e.code];
333 if (!note) return;
334 e.preventDefault();
335 this.noteon(note);
336 }
337
338 onkeyup = (e) => {
339 if (e.repeat) return;
340
341 const note = notes.key2midi[e.code];
342 if (!note) return;
343 e.preventDefault();
344 this.noteoff(note);
345 }
346
347 render() {
348 const { configure } = this.props;
349 const { w, h, scale, layout, labels, offset, state } = this.state;
350
351 const chord = Object.entries(state).filter(([note, on]) => on).map(([k, _]) => k);
352 chord.sort();
353
354 return (
355 <div className="app">
356 <nav>
357 <div className="group">
358 <button onClick={configure}>midi settings</button>
359 </div>
360 <div className="group">
361 <label>layout:</label>
362 <select name="layout" value={layout} onChange={this.set}>
363 <option value="wicki_hayden">Wicki-Hayden</option>
364 <option value="harmonic">Harmonic Table</option>
365 <option value="gerhard">Gerhard</option>
366 </select>
367 </div>
368 <div className="group">
369 <label>note format:</label>
370 <select name="labels" value={labels} onChange={this.set}>
371 <option value="english">English</option>
372 <option value="german">German</option>
373 <option value="sol">Solfège</option>
374 <option value="midi">MIDI no</option>
375 </select>
376 </div>
377 <div className="group">
378 <label>scale:</label>
379 <button onClick={this.onoffset(null)}>
380 {labels ? notes.labels[labels][offset % 12] : offset}
381 </button>
382 <select name="scale" value={scale} onChange={this.set}>
383 <option value="none">None</option>
384 <option value="major">Major</option>
385 <option value="minor_nat">Natural Minor</option>
386 <option value="minor_harm">Harmonic Minor</option>
387 <option value="minor_mel">Melodic Minor</option>
388 <option value="minor_hung">Hungarian Minor</option>
389 <option value="whole">Whole-Tone</option>
390 <option value="penta">Pentatonic</option>
391 </select>
392 </div>
393 <div className="group">
394 <label>octave:</label>
395 <button onClick={this.onoffset(offset - 12)}>-</button>
396 {Math.floor(offset / 12)}
397 <button onClick={this.onoffset(offset + 12)}>+</button>
398 </div>
399 <div className="group">
400 <label>size:</label>
401 <input className="small" type="number" min="1" name="w" value={w} onChange={this.set} />
402 {'x'}
403 <input className="small" type="number" min="1" name="h" value={h} onChange={this.set} />
404 </div>
405 <div className="spacer" />
406 </nav>
407
408 <main ref={this.ref} tabIndex="0">
409 <Keyboard
410 w={w}
411 h={h}
412 noteon={this.noteon.bind(this)}
413 noteoff={this.noteoff.bind(this)}
414 layout={layouts[layout]}
415 scale={scale !== "none" && offset !== null && tallyup(scales[scale], offset)}
416 labels={notes.labels[this.state.labels]}
417 offset={offset}
418 state={state}
419 />
420 <ChordView chord={chord} />
421 <div className="focus-msg">
422 <span>click here to activate<br/>keyboard input</span>
423 </div>
424 </main>
425 </div>
426 );
427 }
428 };
276 });
277
278 useEffect(() => {
279 send(CHANNEL, ALL_SOUND_OFF, 0);
280 send(CHANNEL, ALL_NOTES_OFF, 0);
281 }, [midiout]);
282
283 useEffect(() => {
284 if (!midiin) return;
285
286 midiin.onmidimessage = (e) => {
287 const message = parse(e);
288
289 switch (message.cmd) {
290 case NOTE_ON:
291 if (settings.offset === null) {
292 setSettings((s) => ({ ...s, offset: note }));
293 }
294
295 setState((s) => ({ ...s, [message.note]: true }));
296 break;
297
298 case NOTE_OFF:
299 setState((s) => ({ ...s, [message.note]: false }));
300 break;
301 }
302 }
303
304 return () => {
305 midiin.onmidimessage = null;
306 };
307 }, [midiin]);
308
309 const { scale, layout, labels, offset } = settings;
310 const chord = Object.entries(state).filter(([note, on]) => on).map(([k, _]) => k);
311 chord.sort();
312
313 return (
314 <div className="app">
315 <Toolbar
316 state={settings}
317 setState={setSettings}
318 midi={midi}
319 />
320
321 <main ref={ref} tabIndex="0">
322 <Keyboard
323 {...settings}
324 noteon={noteon}
325 noteoff={noteoff}
326 layout={layouts[layout]}
327 scale={scale !== "none" && offset !== null && tallyup(scales[scale], offset)}
328 labels={notes.labels[labels]}
329 state={state}
330 />
331 <ChordView chord={chord} />
332 <div className="focus-msg">
333 <span>click here to activate<br/>keyboard input</span>
334 </div>
335 </main>
336 </div>
337 );
338 };
0 import React from 'react';
1 import ReactDOM from 'react-dom';
0 import React, { useState, useCallback, useEffect } from 'react';
1 import { labels as noteLabels } from './notes';
22 import { css } from './style';
33
4 const setState = (key, state) => {
4 const saveState = (key, state) => {
55 try {
66 localStorage.setItem(key, JSON.stringify(state));
7 return true;
87 }
98 catch (e) {
10 return false;
9 console.error(e);
1110 }
1211 };
1312
2120 }
2221 };
2322
23 export const useAppState = (defaultState) => {
24 const [state, setState] = useState(defaultState);
25
26 useEffect(() => setState(loadState('settings', defaultState)), []);
27 useEffect(() => saveState('settings', state), [state]);
28
29 return [state, setState];
30 };
31
32 export const useMIDI = () => {
33 const [inputs, setInputs] = useState(new Map());
34 const [outputs, setOutputs] = useState(new Map());
35
36 const reload = useCallback(() => {
37 navigator.requestMIDIAccess &&
38 navigator.requestMIDIAccess({ sysex: true })
39 .then((access) => {
40 setInputs(access.inputs);
41 setOutputs(access.outputs);
42 });
43 });
44
45 useEffect(reload, []);
46
47 return { inputs, outputs, reload };
48 }
49
50 const Dropdown = ({ list, name, onChange, value }) => (
51 <select value={value} onChange={onChange} disabled={!list || !list.size}>
52 <option>(none)</option>
53 {list && ([...list.values()]).map((port) => (
54 <option value={port.id} key={port.id}>{port.name}</option>
55 ))}
56 </select>
57 );
58
2459 css(`
25 .settings {
26 width: 500px;
27 margin: auto;
28 padding: 1em;
29 text-align: left;
30 }
60 nav {
61 display: flex;
62 flex-wrap: wrap;
63 justfiy-content: space-between;
64 }
3165
32 .setting {
33 margin: 0.4em 0;
34 }
66 nav > .group, nav > .spacer {
67 display: flex;
68 align-items: baseline;
69 padding: 0.25em 1em;
70 gap: 1em;
3571
36 .setting > label {
37 display: inline-block;
38 width: 200px;
39 }
72 border: 0 solid #363636;
73 border-width: 1px 0 1px 0;
74 }
4075
41 .setting > select, .setting > input, .settings button {
42 width: 200px;
43 margin-left: 20px;
44 }
76 nav > .group:nth-child(2n) {
77 background: #363636;
78 }
4579
46 .setting > select:disabled {
47 color: #aaa;
48 background: #444;
49 }
50
51 .settings > button {
52 text-align: center;
53 margin-right: 20px;
54 }
80 nav > .spacer { flex: 1; }
5581 `);
5682
57 const Dropdown = ({ name, list, set, disabled, value }) => (
58 <div className="setting">
59 <label>{name}</label>
60 <select value={value} onChange={e => set(e, e.target.value)} disabled={!list || disabled}>
61 <option>(none)</option>
62 {list && ([...list.values()]).map((port) => (
63 <option value={port.id} key={port.id}>{port.name}</option>
64 ))}
65 </select>
66 </div>
67 );
83 export const Toolbar = ({ state, setState, midi }) => {
84 const { labels, offset, transpose } = state;
6885
69 const Checkbox = ({ name, set, value }) => (
70 <div className="setting">
71 <label>{name}</label>
72 <input
73 type="checkbox"
74 onChange={e => set(e, !value)}
75 checked={value}
76 />
77 </div>
78 );
86 const track = (key) => ({
87 name: key,
88 value: state[key],
89 onChange: (e) => {
90 e.stopPropagation();
91 let value = e.target.value;
92 if (key === 'w' || key === 'h') value = +value;
93 setState({ ...state, [key]: value });
94 },
95 });
7996
80 export default WrappedComponent =>
81 class SettingsStore extends React.Component {
82 constructor(props) {
83 super(props);
84
85 this.state = loadState('settings', { configured: false, midiSupport: false, showMidi: false });
86 this.configure = () => this.setState({ configured: false });
87
88 this.reload = () => {
89 navigator.requestMIDIAccess({ sysex: true })
90 .then(access => {
91 this.inputs = access.inputs;
92 this.outputs = access.outputs;
93 this.setState({
94 midiSupport: true,
95 });
96 this.forceUpdate();
97 });
98 }
99 }
100
101 componentWillUpdate(props, state) {
102 setState('settings', state);
103 }
104
105 componentDidMount() {
106 if (navigator.requestMIDIAccess)
107 this.reload();
108 }
109
110 set(k, v) {
111 return (e, vv) => {
112 e.stopPropagation();
113
114 this.setState({ [k]: v === undefined
115 ? vv
116 : v });
117 }
118 }
119
120 render() {
121 const { configured, midiin, midiout, showMidi } = this.state;
122
123 if (configured) {
124 return (
125 <WrappedComponent
126 {...this.props}
127 {...this.state}
128 midiin={this.inputs && this.inputs.get(midiin)}
129 midiout={this.outputs && this.outputs.get(midiout)}
130 configure={this.configure}
131 />
132 );
133 }
134
135 return (
136 <div className="panel settings">
137 <h3>Display</h3>
138 <Checkbox
139 name="Show notes as MIDI number"
140 set={this.set('showMidi')}
141 value={showMidi}
142 />
143 <h3>MIDI I/O</h3>
144 <Dropdown
145 name="MIDI Keyboard Input"
146 list={this.inputs}
147 set={this.set('midiin')}
148 value={midiin}
149 />
150 <Dropdown
151 name="MIDI Output"
152 list={this.outputs}
153 set={this.set('midiout')}
154 value={midiout}
155 />
156 <button onClick={this.reload}>reload MIDI device list</button>
157 <button onClick={this.set('configured', true)}>start</button>
158 </div>
159 );
160 };
161 };
97 return (
98 <nav>
99 <div className="group">
100 <label>layout:</label>
101 <select {...track('layout')}>
102 <option value="wicki_hayden">Wicki-Hayden</option>
103 <option value="harmonic">Harmonic Table</option>
104 <option value="gerhard">Gerhard</option>
105 </select>
106 </div>
107 <div className="group">
108 <label>note format:</label>
109 <select {...track('labels')}>
110 <option value="english">English</option>
111 <option value="german">German</option>
112 <option value="sol">Solfège</option>
113 <option value="midi">MIDI no</option>
114 </select>
115 </div>
116 <div className="group">
117 <label>scale:</label>
118 <button onClick={() => setState({ ...state, offset: null })}>
119 {offset === null
120 ? "listening..."
121 : (noteLabels[labels] ? noteLabels[labels][offset % 12] : offset)}
122 </button>
123 <select {...track('scale')}>
124 <option value="none">None</option>
125 <option value="major">Major</option>
126 <option value="minor_nat">Natural Minor</option>
127 <option value="minor_harm">Harmonic Minor</option>
128 <option value="minor_mel">Melodic Minor</option>
129 <option value="minor_hung">Hungarian Minor</option>
130 <option value="whole">Whole-Tone</option>
131 <option value="penta">Pentatonic</option>
132 </select>
133 </div>
134 <div className="group">
135 <label>octave:</label>
136 <button onClick={() => setState({ ...state, transpose: transpose - 12 })}>-</button>
137 {Math.floor(transpose / 12)}
138 <button onClick={() => setState({ ...state, transpose: transpose + 12 })}>+</button>
139 </div>
140 <div className="group">
141 <label>size:</label>
142 <input className="small" type="number" min="1" {...track('w')} />
143 {'x'}
144 <input className="small" type="number" min="1" {...track('h')} />
145 </div>
146 <div className="group">
147 <label>midi</label>
148 in:
149 <Dropdown
150 name="midiin"
151 list={midi.inputs}
152 {...track('midiin')}
153 />
154 out:
155 <Dropdown
156 name="midiout"
157 list={midi.outputs}
158 {...track('midiout')}
159 />
160 <button onClick={midi.reload}>↻</button>
161 </div>
162 <div className="spacer" />
163 </nav>
164 );
165 };
00 import React from 'react';
11 import ReactDOM from 'react-dom';
22 import App from './app';
3 import config from './config';
43
5 const $App = config(App);
4 const $App = App;
65
76 const node = document.createElement('div');
87 ReactDOM.render(<$App />, node);
5858 padding: 0.25em;
5959 border-radius: 0.5em;
6060 }
61 button:disabled, input:disabled, select:disabled {
62 opacity: 0.75;
63 }
6164
6265 input.small {
6366 width: 4em;
102105 border-color: #363636;
103106 color: #848484;
104107 }
105 .hexagon.core > .inner {
108 .hexagon.core {
106109 border-color: #eeeeee;
107110 }
108111