Skip to content

Commit 6a67016

Browse files
committed
Added ability to perform notes from the computer keyboard. Added preferences for controlling keyboard layout and recording options. Recording options do not work yet!
1 parent 0cb3e7d commit 6a67016

File tree

13 files changed

+453
-62
lines changed

13 files changed

+453
-62
lines changed

editor/ColorConfig.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,9 @@ export class ColorConfig {
309309
private static readonly _styleElement: HTMLStyleElement = document.head.appendChild(HTML.style({type: "text/css"}));
310310

311311
public static setTheme(name: string): void {
312-
this._styleElement.textContent = this.themes[name];
312+
let theme: string = this.themes[name];
313+
if (theme == undefined) theme = this.themes["dark classic"];
314+
this._styleElement.textContent = theme;
313315

314316
const themeColor = <HTMLMetaElement> document.querySelector("meta[name='theme-color']");
315317
if (themeColor != null) {

editor/EditorConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export class EditorConfig {
2929

3030
public static readonly isOnMac: boolean = /^Mac/i.test(navigator.platform) || /Mac OS X/i.test(navigator.userAgent) || /^(iPhone|iPad|iPod)/i.test(navigator.platform) || /(iPhone|iPad|iPod)/i.test(navigator.userAgent);
3131
public static readonly ctrlSymbol: string = EditorConfig.isOnMac ? "⌘" : "Ctrl+";
32+
public static readonly ctrlName: string = EditorConfig.isOnMac ? "command" : "control";
3233

3334
public static readonly presetCategories: DictionaryArray<PresetCategory> = toNameMap([
3435
{name: "Custom Instruments", presets: <DictionaryArray<Preset>> toNameMap([

editor/KeyboardLayout.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) 2012-2022 John Nesky and contributing authors, distributed under the MIT license, see accompanying the LICENSE.md file.
2+
3+
import {Config} from "../synth/SynthConfig";
4+
import {SongDocument} from "./SongDocument";
5+
6+
export class KeyboardLayout {
7+
private static _pianoAtC: ReadonlyArray<ReadonlyArray<number | null>> = [
8+
[ 0, 2, 4, 5, 7, 9, 11, 12, 14, 16, 17],
9+
[null, 1, 3,null, 6, 8, 10,null, 13, 15,null, 18],
10+
[ 12, 14, 16, 17, 19, 21, 23, 24, 26, 28, 29, 31, 33],
11+
[null, 13, 15,null, 18, 20, 22,null, 25, 27,null, 30, 32],
12+
];
13+
private static _pianoAtA: ReadonlyArray<ReadonlyArray<number | null>> = [
14+
[ 0, 2, 3, 5, 7, 8, 10, 12, 14, 15, 17],
15+
[ -1, 1,null, 4, 6,null, 9, 11, 13,null, 16, 18],
16+
[ 12, 14, 15, 17, 19, 20, 22, 24, 26, 27, 29, 31, 32],
17+
[ 11, 13,null, 16, 18,null, 21, 23, 25,null, 28, 30,null],
18+
];
19+
20+
private _possiblyPlayingPitchesFromKeyboard: boolean = false;
21+
22+
constructor(private _doc: SongDocument) {
23+
window.addEventListener("blur", this._onWindowBlur);
24+
}
25+
26+
private _onWindowBlur = (event: Event) => {
27+
// Browsers don't explicitly release keys when the page isn't in focus so let's just assume they're all released.
28+
if (this._possiblyPlayingPitchesFromKeyboard) {
29+
this._doc.liveInput.clearAllPitches();
30+
this._possiblyPlayingPitchesFromKeyboard = false;
31+
}
32+
}
33+
34+
public handleKeyEvent(event: KeyboardEvent, pressed: boolean): void {
35+
// See: https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system
36+
switch (event.code) {
37+
case "Backquote": this.handleKey(-1, 3, pressed); break;
38+
case "Digit1": this.handleKey(0, 3, pressed); break;
39+
case "Digit2": this.handleKey(1, 3, pressed); break;
40+
case "Digit3": this.handleKey(2, 3, pressed); break;
41+
case "Digit4": this.handleKey(3, 3, pressed); break;
42+
case "Digit5": this.handleKey(4, 3, pressed); break;
43+
case "Digit6": this.handleKey(5, 3, pressed); break;
44+
case "Digit7": this.handleKey(6, 3, pressed); break;
45+
case "Digit8": this.handleKey(7, 3, pressed); break;
46+
case "Digit9": this.handleKey(8, 3, pressed); break;
47+
case "Digit0": this.handleKey(9, 3, pressed); break;
48+
case "Minus": this.handleKey(10, 3, pressed); break;
49+
case "Equal": this.handleKey(11, 3, pressed); break;
50+
case "IntlYen": this.handleKey(12, 3, pressed); break; // Present on Russian and Japanese keyboards.
51+
52+
case "KeyQ": this.handleKey(0, 2, pressed); break;
53+
case "KeyW": this.handleKey(1, 2, pressed); break;
54+
case "KeyE": this.handleKey(2, 2, pressed); break;
55+
case "KeyR": this.handleKey(3, 2, pressed); break;
56+
case "KeyT": this.handleKey(4, 2, pressed); break;
57+
case "KeyY": this.handleKey(5, 2, pressed); break;
58+
case "KeyU": this.handleKey(6, 2, pressed); break;
59+
case "KeyI": this.handleKey(7, 2, pressed); break;
60+
case "KeyO": this.handleKey(8, 2, pressed); break;
61+
case "KeyP": this.handleKey(9, 2, pressed); break;
62+
case "BracketLeft": this.handleKey(10, 2, pressed); break;
63+
case "BracketRight": this.handleKey(11, 2, pressed); break;
64+
case "Backslash":
65+
// Present on US keyboards... but on non-US keyboards it's also used at a different location, see "IntlHash" below. :/
66+
if (event.key == "\\" || event.key == "|") {
67+
this.handleKey(12, 2, pressed);
68+
} else {
69+
this.handleKey(11, 1, pressed);
70+
}
71+
break;
72+
73+
case "KeyA": this.handleKey(0, 1, pressed); break;
74+
case "KeyS": this.handleKey(1, 1, pressed); break;
75+
case "KeyD": this.handleKey(2, 1, pressed); break;
76+
case "KeyF": this.handleKey(3, 1, pressed); break;
77+
case "KeyG": this.handleKey(4, 1, pressed); break;
78+
case "KeyH": this.handleKey(5, 1, pressed); break;
79+
case "KeyJ": this.handleKey(6, 1, pressed); break;
80+
case "KeyK": this.handleKey(7, 1, pressed); break;
81+
case "KeyL": this.handleKey(8, 1, pressed); break;
82+
case "Semicolon": this.handleKey(9, 1, pressed); break;
83+
case "Quote": this.handleKey(10, 1, pressed); break;
84+
case "IntlHash": this.handleKey(11, 1, pressed); break; // Present on non-US keyboards... but in practice it is actually represented as "Backslash" so this is obsolete. Oh well. :/
85+
86+
case "IntlBackslash": this.handleKey(-1, 0, pressed); break; // Present on Brazillian and many European keyboards.
87+
case "KeyZ": this.handleKey(0, 0, pressed); break;
88+
case "KeyX": this.handleKey(1, 0, pressed); break;
89+
case "KeyC": this.handleKey(2, 0, pressed); break;
90+
case "KeyV": this.handleKey(3, 0, pressed); break;
91+
case "KeyB": this.handleKey(4, 0, pressed); break;
92+
case "KeyN": this.handleKey(5, 0, pressed); break;
93+
case "KeyM": this.handleKey(6, 0, pressed); break;
94+
case "Comma": this.handleKey(7, 0, pressed); break;
95+
case "Period": this.handleKey(8, 0, pressed); break;
96+
case "Slash": this.handleKey(9, 0, pressed); break;
97+
case "IntlRo": this.handleKey(10, 0, pressed); break; // Present on Brazillian and Japanese keyboards.
98+
99+
default: return; //unhandled, don't prevent default.
100+
}
101+
102+
// If the key event was handled as a note, prevent default behavior.
103+
event.preventDefault();
104+
}
105+
106+
public handleKey(x: number, y: number, pressed: boolean): void {
107+
108+
const isDrum: boolean = this._doc.song.getChannelIsNoise(this._doc.channel);
109+
if (isDrum) {
110+
if (x >= 0 && x < Config.drumCount) {
111+
if (pressed) {
112+
this._doc.synth.preferLowerLatency = true;
113+
this._doc.liveInput.addPerformedPitch(x);
114+
this._possiblyPlayingPitchesFromKeyboard = true;
115+
} else {
116+
this._doc.liveInput.removePerformedPitch(x);
117+
}
118+
}
119+
return;
120+
}
121+
122+
let pitchOffset: number | null = null;
123+
let forcedKey: number | null = null;
124+
switch (this._doc.prefs.keyboardLayout) {
125+
case "wickiHayden":
126+
pitchOffset = y * 5 + x * 2;
127+
break;
128+
case "songScale":
129+
const scaleFlags: ReadonlyArray<boolean> = Config.scales[this._doc.song.scale].flags;
130+
const scaleIndices: number[] = <number[]> scaleFlags.map((flag, index) => flag ? index : null).filter((index) => index != null);
131+
pitchOffset = (y - 1 + Math.floor(x / scaleIndices.length)) * Config.pitchesPerOctave + scaleIndices[x % scaleIndices.length];
132+
break;
133+
case "pianoAtC":
134+
pitchOffset = KeyboardLayout._pianoAtC[y][x];
135+
forcedKey = Config.keys.dictionary["C"].basePitch;
136+
break;
137+
case "pianoAtA":
138+
pitchOffset = KeyboardLayout._pianoAtA[y][x];
139+
forcedKey = Config.keys.dictionary["A"].basePitch;
140+
break;
141+
case "pianoTransposingC":
142+
pitchOffset = KeyboardLayout._pianoAtC[y][x];
143+
break;
144+
case "pianoTransposingA":
145+
pitchOffset = KeyboardLayout._pianoAtA[y][x];
146+
break;
147+
}
148+
149+
if (pitchOffset != null) {
150+
const octaveOffset: number = Math.max(0, this._doc.song.channels[this._doc.channel].octave - 1) * Config.pitchesPerOctave;
151+
let keyOffset: number = 0; // The basePitch of the song key is implicit.
152+
153+
if (forcedKey != null) {
154+
const keyBasePitch: number = Config.keys[this._doc.song.key].basePitch;
155+
keyOffset = (forcedKey - keyBasePitch + 144) % 12;
156+
}
157+
158+
const pitch = octaveOffset + keyOffset + pitchOffset;
159+
if (pitch < 0 || pitch > Config.maxPitch) return;
160+
161+
if (pressed) {
162+
this._doc.synth.preferLowerLatency = true;
163+
this._doc.liveInput.addPerformedPitch(pitch);
164+
this._possiblyPlayingPitchesFromKeyboard = true;
165+
} else {
166+
this._doc.liveInput.removePerformedPitch(pitch);
167+
}
168+
}
169+
}
170+
}

editor/LiveInput.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import {Config} from "../synth/SynthConfig";
44
import {SongDocument} from "./SongDocument";
55

66
export class LiveInput {
7+
private _channelIsDrum: boolean = false;
8+
private _channelOctave: number = -1;
9+
private _songKey: number = -1;
710
private _pitchesAreTemporary: boolean = false;
811

912
constructor(private _doc: SongDocument) {
1013
this._doc.notifier.watch(this._documentChanged);
11-
this._documentChanged();
1214
}
1315

1416
public setTemporaryPitches(pitches: number[], duration: number): void {
@@ -21,22 +23,25 @@ export class LiveInput {
2123
this._pitchesAreTemporary = true;
2224
}
2325

24-
public addPitch(pitch: number): void {
26+
public addPerformedPitch(pitch: number): void {
27+
this._doc.synth.maintainLiveInput();
2528
if (this._pitchesAreTemporary) {
26-
this._doc.synth.liveInputPitches.length = 0;
29+
this.clearAllPitches();
2730
this._pitchesAreTemporary = false;
2831
}
32+
if (this._doc.prefs.ignorePerformedNotesNotInScale && !Config.scales[this._doc.song.scale].flags[pitch % Config.pitchesPerOctave]) {
33+
return;
34+
}
2935
if (this._doc.synth.liveInputPitches.indexOf(pitch) == -1) {
3036
this._doc.synth.liveInputPitches.push(pitch);
3137
while (this._doc.synth.liveInputPitches.length > Config.maxChordSize) {
3238
this._doc.synth.liveInputPitches.shift();
3339
}
3440
this._doc.synth.liveInputDuration = Number.MAX_SAFE_INTEGER;
35-
this._doc.synth.liveInputStarted = true;
3641
}
3742
}
3843

39-
public removePitch(pitch: number): void {
44+
public removePerformedPitch(pitch: number): void {
4045
for (let i: number = 0; i < this._doc.synth.liveInputPitches.length; i++) {
4146
if (this._doc.synth.liveInputPitches[i] == pitch) {
4247
this._doc.synth.liveInputPitches.splice(i, 1);
@@ -45,12 +50,20 @@ export class LiveInput {
4550
}
4651
}
4752

48-
public clear(): void {
53+
public clearAllPitches(): void {
4954
this._doc.synth.liveInputPitches.length = 0;
5055
}
5156

5257
private _documentChanged = (): void => {
53-
this._doc.synth.liveInputChannel = this._doc.channel;
58+
const isDrum: boolean = this._doc.song.getChannelIsNoise(this._doc.channel);
59+
const octave: number = this._doc.song.channels[this._doc.channel].octave;
60+
if (this._doc.synth.liveInputChannel != this._doc.channel || this._channelIsDrum != isDrum || this._channelOctave != octave || this._songKey != this._doc.song.key) {
61+
this._doc.synth.liveInputChannel = this._doc.channel;
62+
this._channelIsDrum = isDrum;
63+
this._channelOctave = octave;
64+
this._songKey = this._doc.song.key;
65+
this.clearAllPitches();
66+
}
5467
this._doc.synth.liveInputInstruments = this._doc.recentPatternInstruments[this._doc.channel];
5568
}
5669
}

editor/MidiInput.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2012-2022 John Nesky and contributing authors, distributed under the MIT license, see accompanying the LICENSE.md file.
22

3+
import {Config} from "../synth/SynthConfig";
34
import {SongDocument} from "./SongDocument";
4-
import {LiveInput} from "./LiveInput";
55
import {AnalogousDrum, analogousDrumMap, MidiEventType} from "./Midi";
66

77
declare global {
@@ -29,7 +29,7 @@ interface MIDIMessageEvent {
2929
const id: string = ((Math.random() * 0xffffffff) >>> 0).toString(16);
3030

3131
export class MidiInputHandler {
32-
constructor(private _doc: SongDocument, private _liveInput: LiveInput) {
32+
constructor(private _doc: SongDocument) {
3333
this.registerMidiAccessHandler();
3434
}
3535

@@ -74,14 +74,12 @@ export class MidiInputHandler {
7474

7575
private _unregisterMidiInput = (midiInput: MIDIInput) => {
7676
midiInput.removeEventListener("midimessage", this._onMidiMessage as any);
77-
this._liveInput.clear();
77+
this._doc.liveInput.clearAllPitches();
7878
}
7979

8080
private _onMidiMessage = (event: MIDIMessageEvent) => {
81-
// Ignore midi events if a different tab is handling them.
82-
if (localStorage.getItem("midiHandlerId") != id) return;
83-
84-
this._doc.synth.preferLowerLatency = true;
81+
// Ignore midi events if disabled or a different tab is handling them.
82+
if (!this._doc.prefs.enableMidi || localStorage.getItem("midiHandlerId") != id) return;
8583

8684
const isDrum: boolean = this._doc.song.getChannelIsNoise(this._doc.channel);
8785
let [eventType, key, velocity] = event.data;
@@ -94,6 +92,9 @@ export class MidiInputHandler {
9492
} else {
9593
return;
9694
}
95+
} else {
96+
key -= Config.keys[this._doc.song.key].basePitch; // The basePitch of the song key is implicit so don't include it.
97+
if (key < 0 || key > Config.maxPitch) return;
9798
}
9899

99100
if (eventType == MidiEventType.noteOn && velocity == 0) {
@@ -102,11 +103,11 @@ export class MidiInputHandler {
102103

103104
switch (eventType) {
104105
case MidiEventType.noteOn:
105-
this._doc.synth.maintainLiveInput();
106-
this._liveInput.addPitch(key);
106+
this._doc.synth.preferLowerLatency = true;
107+
this._doc.liveInput.addPerformedPitch(key);
107108
break;
108109
case MidiEventType.noteOff:
109-
this._liveInput.removePitch(key);
110+
this._doc.liveInput.removePerformedPitch(key);
110111
break;
111112
}
112113
}

editor/PatternEditor.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {Chord, Transition, Config} from "../synth/SynthConfig";
44
import {NotePin, Note, makeNotePin, Pattern, Instrument} from "../synth/synth";
55
import {ColorConfig} from "./ColorConfig";
66
import {SongDocument} from "./SongDocument";
7-
import {LiveInput} from "./LiveInput";
87
import {HTML, SVG} from "imperative-html/dist/esm/elements-strict";
98
import {ChangeSequence, UndoableChange} from "./Change";
109
import {ChangeChannelBar, ChangeDragSelectedNotes, ChangeEnsurePatternExists, ChangeNoteTruncate, ChangeNoteAdded, ChangePatternSelection, ChangePinTime, ChangeSizeBend, ChangePitchBend, ChangePitchAdded} from "./changes";
@@ -100,7 +99,7 @@ export class PatternEditor {
10099
private _renderedNoiseChannelCount: number = -1;
101100
private _followPlayheadBar: number = -1;
102101

103-
constructor(private _doc: SongDocument, private _liveInput: LiveInput, private _interactive: boolean, private _barOffset: number) {
102+
constructor(private _doc: SongDocument, private _interactive: boolean, private _barOffset: number) {
104103
for (let i: number = 0; i < Config.pitchesPerOctave; i++) {
105104
const rectangle: SVGRectElement = SVG.rect();
106105
rectangle.setAttribute("x", "1");
@@ -522,7 +521,7 @@ export class PatternEditor {
522521
if (this._doc.prefs.enableNotePreview && !this._doc.synth.playing) {
523522
// Play the new note out loud if enabled.
524523
const duration: number = Math.min(Config.partsPerBeat, this._cursor.end - this._cursor.start);
525-
this._liveInput.setTemporaryPitches([this._cursor.pitch], duration);
524+
this._doc.liveInput.setTemporaryPitches([this._cursor.pitch], duration);
526525
}
527526
}
528527
this._updateSelection();
@@ -893,7 +892,7 @@ export class PatternEditor {
893892

894893
if (this._doc.prefs.enableNotePreview && !this._doc.synth.playing) {
895894
const duration: number = Math.min(Config.partsPerBeat, this._cursor.end - this._cursor.start);
896-
this._liveInput.setTemporaryPitches(this._cursor.curNote.pitches, duration);
895+
this._doc.liveInput.setTemporaryPitches(this._cursor.curNote.pitches, duration);
897896
}
898897
} else {
899898
if (this._cursor.curNote.pitches.length == 1) {

editor/Piano.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {Config} from "../synth/SynthConfig";
44
import {SongDocument} from "./SongDocument";
55
import {HTML} from "imperative-html/dist/esm/elements-strict";
66
import {ColorConfig} from "./ColorConfig";
7-
import {LiveInput} from "./LiveInput";
87

98
export class Piano {
109
private readonly _pianoContainer: HTMLDivElement = HTML.div({style: "width: 100%; height: 100%; display: flex; flex-direction: column-reverse; align-items: stretch;"});
@@ -32,7 +31,7 @@ export class Piano {
3231
private _renderedKey: number = -1;
3332
private _renderedPitchCount: number = -1;
3433

35-
constructor(private _doc: SongDocument, private _liveInput: LiveInput) {
34+
constructor(private _doc: SongDocument) {
3635
for (let i: number = 0; i < Config.drumCount; i++) {
3736
const scale: number = (1.0 - (i / Config.drumCount) * 0.35) * 100;
3837
const brightness: number = 1.0 + ((i - Config.drumCount / 2.0) / Config.drumCount) * 0.5;
@@ -84,13 +83,13 @@ export class Piano {
8483
const octaveOffset: number = this._doc.getBaseVisibleOctave(this._doc.channel) * Config.pitchesPerOctave;
8584
const currentPitch: number = this._cursorPitch + octaveOffset;
8685
if (this._playedPitch == currentPitch) return;
87-
this._liveInput.removePitch(this._playedPitch);
86+
this._doc.liveInput.removePerformedPitch(this._playedPitch);
8887
this._playedPitch = currentPitch;
89-
this._liveInput.addPitch(currentPitch);
88+
this._doc.liveInput.addPerformedPitch(currentPitch);
9089
}
9190

9291
private _releaseLiveInput(): void {
93-
this._liveInput.removePitch(this._playedPitch);
92+
this._doc.liveInput.removePerformedPitch(this._playedPitch);
9493
this._playedPitch = -1;
9594
}
9695

0 commit comments

Comments
 (0)