0 | |
import React from 'react';
|
|
0 |
import React, { useState, useEffect } from 'react';
|
1 | 1 |
import { css, pos, width, height } from './style';
|
2 | 2 |
import * as notes from './notes';
|
|
3 |
import { useAppState, useMIDI, Toolbar } from './config';
|
3 | 4 |
|
4 | 5 |
const statbyte = (stat, chan) => (stat << 4) | chan;
|
5 | 6 |
const CHANNEL = 0b1011;
|
|
35 | 36 |
style={pos([x, y], major)}
|
36 | 37 |
>
|
37 | 38 |
<div
|
38 | |
className="inner"
|
39 | 39 |
onMouseDown={() => noteon(note)}
|
40 | |
onMouseUp ={() => noteoff(note)}
|
|
40 |
onMouseUp={() => noteoff(note)}
|
41 | 41 |
>
|
42 | 42 |
{children}
|
43 | 43 |
</div>
|
|
59 | 59 |
// B = 7
|
60 | 60 |
// C = 2 = x
|
61 | 61 |
// A + B = 12 = y
|
62 | |
let note = 41 + 2 * x + 6 * y;
|
|
62 |
let note = 5 + 2 * x + 6 * y;
|
63 | 63 |
if (y % 2 == 0) note += 1;
|
64 | 64 |
return note;
|
65 | 65 |
},
|
|
127 | 127 |
};
|
128 | 128 |
};
|
129 | 129 |
|
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 }) => (
|
131 | 131 |
<div
|
132 | 132 |
className={`keyboard ${major}-major`}
|
133 | 133 |
style={{
|
|
137 | 137 |
>
|
138 | 138 |
{range(w).map((_, x) => (
|
139 | 139 |
range(h).map((_, y) => {
|
140 | |
const note = map([x, y], [w, h]);
|
|
140 |
const note = map([x, y], [w, h]) + transpose;
|
141 | 141 |
const onCore = scale && scale.notes.indexOf(note) >= 0;
|
142 | 142 |
const onScale = scale && scale.modulo.indexOf(note % 12) >= 0;
|
143 | 143 |
return (
|
|
181 | 181 |
</div>
|
182 | 182 |
);
|
183 | 183 |
};
|
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 |
;
|
208 | 185 |
|
209 | 186 |
css(`
|
210 | 187 |
main {
|
|
239 | 216 |
}
|
240 | 217 |
`);
|
241 | 218 |
|
242 | |
export default class App extends React.Component {
|
243 | |
state = {
|
|
219 |
export default () => {
|
|
220 |
const [settings, setSettings] = useAppState({
|
244 | 221 |
layout: 'wicki_hayden',
|
245 | 222 |
scale: 'major',
|
246 | 223 |
labels: 'english',
|
247 | 224 |
offset: 60,
|
248 | |
state: {},
|
|
225 |
transpose: 3*12,
|
249 | 226 |
w: 12,
|
250 | 227 |
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);
|
251 | 242 |
};
|
252 | 243 |
|
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);
|
258 | 251 |
}
|
259 | 252 |
|
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);
|
269 | 256 |
}
|
270 | 257 |
|
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);
|
316 | 266 |
}
|
317 | 267 |
|
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);
|
326 | 275 |
}
|
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 |
};
|