diff --git a/globals.js b/globals.js index 40748fb..7f4b990 100644 --- a/globals.js +++ b/globals.js @@ -1,11 +1,21 @@ +// @ts-check + let midiSelectSlider; +let midiIn; // for piano visualizer +const state = State('pianometer/', { + color: '#2ee5b8', + rainbowMode: false, // 彩虹模式 + velocityMode: false, // 力度模式 +}); +/** @type {Color} */ +let keyOnColor; // 「按下時」的顏色 [HSB Color Mode] +/** @type {Color} */ +let pedaledColor; // 「踏板踩住」的顏色 [HSB Color Mode] let nowPedaling = false; // is it pedaling?(不要動) let isKeyOn = []; // what notes are being pressed (1 or 0)(不要動) let isPedaled = []; // what notes are pedaled (1 or 0)(不要動) -let keyOnColor // set it in setup() -let pedaledColor // set it in setup() let isBlack = [0, 11, 0, 13, 0, 0, 11, 0, 12, 0, 13, 0]; // 是黑鍵嗎?是的話,相對左方的白鍵位移多少?(default: {0, 11, 0, 13, 0, 0, 11, 0, 12, 0, 13, 0}) let border = 3; // 左方留空幾個畫素?(default: 3) let whiteKeyWidth = 20; // 白鍵多寬?(default: 20) @@ -16,8 +26,6 @@ let radius = 5; // 白鍵圓角(default: 5) let bRadius = 4; // 黑鍵圓角(default: 4) let keyAreaY = 3; // 白鍵從 Y 軸座標多少開始?(default: 3) let keyAreaHeight = 70; // 白鍵多高?(default: 70) -let rainbowMode = false; // 彩虹模式 (default: false) -let velocityMode = false; // 力度模式 (default: false) let cc64now = 0; // 現在的踏板狀態 let cc67now = 0; @@ -38,27 +46,28 @@ let totalIntensityScore = 0; let notePressedCount = 0; let notePressedCountHistory = []; -WebMidi.enable(function (err) { //check if WebMidi.js is enabled +WebMidi.enable(function (err) { // check if WebMidi.js is enabled if (err) { - console.log("WebMidi could not be enabled.", err); + console.log('WebMidi could not be enabled.', err); } else { - console.log("WebMidi enabled!"); + console.log('WebMidi enabled!'); } //name our visible MIDI input and output ports - console.log("---"); - console.log("Inputs Ports: "); - for (i = 0; i < WebMidi.inputs.length; i++) { - console.log(i + ": " + WebMidi.inputs[i].name); - } + console.log('---'); + console.log('Inputs Ports: '); + WebMidi.inputs.forEach(({name}, i) => { + console.log(i + ': ' + name); + }); - console.log("---"); - console.log("Output Ports: "); - for (i = 0; i < WebMidi.outputs.length; i++) { - console.log(i + ": " + WebMidi.outputs[i].name); - } - midiSelectSlider = select("#slider"); - midiSelectSlider.attribute("max", WebMidi.inputs.length - 1); + console.log('---'); + console.log('Output Ports: '); + WebMidi.outputs.forEach(({name}, i) => { + console.log(i + ': ' + name); + }); + + midiSelectSlider = select('#slider'); + midiSelectSlider.attribute('max', WebMidi.inputs.length - 1); midiSelectSlider.input(inputChanged); midiIn = WebMidi.inputs[midiSelectSlider.value()] inputChanged(); @@ -71,22 +80,26 @@ function inputChanged() { midiIn.removeListener(); midiIn = WebMidi.inputs[midiSelectSlider.value()]; - midiIn.addListener('noteon', "all", function (e) { - console.log("Received 'noteon' message (" + e.note.number + ", " + e.velocity + ")."); + midiIn.addListener('noteon', 'all', function (e) { + console.log('Received \'noteon\' message (' + e.note.number + ', ' + e.velocity + ').'); noteOn(e.note.number, e.velocity); }); - midiIn.addListener('noteoff', "all", function (e) { - console.log("Received 'noteoff' message (" + e.note.number + ", " + e.velocity + ")."); + midiIn.addListener('noteoff', 'all', function (e) { + console.log('Received \'noteoff\' message (' + e.note.number + ', ' + e.velocity + ').'); noteOff(e.note.number, e.velocity); }) midiIn.addListener('controlchange', 'all', function(e) { - console.log("Received control change message:", e.controller.number, e.value); + console.log('Received control change message:', e.controller.number, e.value); controllerChange(e.controller.number, e.value) }); console.log(midiIn.name); - select("#device").html(midiIn.name); + select('#device').html(midiIn.name); }; +/** + * @param {number} pitch + * @param {number} velocity + */ function noteOn(pitch, velocity) { totalNotesPlayed++; notesThisFrame++; @@ -99,10 +112,18 @@ function noteOn(pitch, velocity) { } } +/** + * @param {number} pitch + * @param {number} _velocity + */ function noteOff(pitch, _velocity) { isKeyOn[pitch] = 0; } +/** + * @param {number} number + * @param {number} value + */ function controllerChange(number, value) { // Receive a controllerChange if (number == 64) { @@ -128,20 +149,27 @@ function controllerChange(number, value) { } } +/** + * @param {HTMLInputElement} cb + */ function toggleRainbowMode(cb) { - rainbowMode = cb.checked; - if (rainbowMode) + state.rainbowMode = cb.checked; + if (state.rainbowMode) { select('#colorpicker').attribute('disabled', true) - else + } else { select('#colorpicker').removeAttribute('disabled') + } } +/** + * @param {HTMLInputElement} cb + */ function toggleVelocityMode(cb) { - velocityMode = cb.checked; + state.velocityMode = cb.checked; } function changeColor() { - keyOnColor = color(select('#colorpicker').value()); - let darkenedColor = keyOnColor.levels.map(x => floor(x * .7)); + keyOnColor = color(state.color = select('#colorpicker').value()); + const darkenedColor = keyOnColor.levels.map(x => Math.floor(x * .7)); pedaledColor = color(`rgb(${darkenedColor[0]}, ${darkenedColor[1]}, ${darkenedColor[2]})`); } diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..e987a63 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,49 @@ +const WebMidi: { + enable(callback: (err: Error|null)=>void): void + inputs: MidiIn[] + outputs: any[] +}; +type MidiIn = { + name: string + addListener(type: 'noteon', channel: string, listener: (e: { + note: {number: number} + velocity: number + })=>void): void + addListener(type: 'noteoff', channel: string, listener: (e: { + note: {number: number} + velocity: number + })=>void): void + addListener(type: 'controlchange', channel: string, listener: (e: { + controller: {number: number} + value: number + })=>void): void +}; + +const Tonal: any; + +function select(selector: string): any; + +function color(color: string): Color; +type Color = { + levels: number[], +}; + +const createCanvas: Function; +const colorMode: Function; +const smooth: Function; +const frameRate: Function; +const {HSB, BOLD, LEFT, TOP}: any; +const background: Function; +const fill: Function; +const stroke: Function; +const strokeWeight: Function; +const rect: Function; +const textFont: Function; +const textStyle: Function; +const textSize: Function; +const textAlign: Function; +const text: Function; +const saveCanvas: Function; +const {mouseX, mouseY}: number; + +function map(...argv: any[]): number; diff --git a/index.html b/index.html index 95e2197..27c074b 100644 --- a/index.html +++ b/index.html @@ -1,72 +1,67 @@ + - - 好和弦的鋼琴鍵盤顯示器 - - - + + 好和弦的鋼琴鍵盤顯示器 + + + - - - + + + + -
-
-
-

鋼琴鍵盤顯示器 by NiceChord

-
-
-
選擇 MIDI 裝置
- -
Select Input:
-
-
-
- - -
-
- 彩虹模式 - - -
-
- 力度模式 - - -
-
-
-
-
- -
+
+
+
+

鋼琴鍵盤顯示器 by NiceChord

+
+
+
選擇 MIDI 裝置
+ +
Select Input:
- -
- -
- -
- - TIME:使用時間 | NOTE COUNT:總彈奏音符數 | NPS:最近一秒鐘彈奏音符數(括號為歷史最大值) | LEGATO:圓滑指數(最近一秒鐘平均來說有幾個鍵被同時按住)
- CALORIES:消耗熱量(估計值,好玩就好)| PEDALS:左右踏板深度顯示
- (密技:點鍵盤最左上角的角落,可以儲存截圖)

-
- - - 覺得好用嗎?到 NiceChord.com 逛逛支持我!原始碼在 GitHub。 +
+
+ + +
+
+ 彩虹模式 + + +
+
+ 力度模式 + + +
- - -
+
+
+
+
+
+ +
+
+ +
+ + TIME:使用時間 | NOTE COUNT:總彈奏音符數 | NPS:最近一秒鐘彈奏音符數(括號為歷史最大值) | LEGATO:圓滑指數(最近一秒鐘平均來說有幾個鍵被同時按住)
+ CALORIES:消耗熱量(估計值,好玩就好)| PEDALS:左右踏板深度顯示
+ (密技:點鍵盤最左上角的角落,可以儲存截圖)

+
+ 覺得好用嗎?到 NiceChord.com 逛逛支持我!原始碼在 GitHub。 +
- \ No newline at end of file + diff --git a/lib.js b/lib.js new file mode 100644 index 0000000..39ad07d --- /dev/null +++ b/lib.js @@ -0,0 +1,37 @@ +/** + * @template {Record} T + * @param {string} keyPrefix + * @param {T} defaultState + * @returns {T} + */ +function State(keyPrefix, defaultState) { + /** @param {string} key */ + function getFromRegistry(key) { + try { + // TODO validate type + const rawValue = localStorage.getItem(keyPrefix+key); + if (rawValue == null) return null; + return JSON.parse(rawValue); + } catch { + return null; + } + } + return new Proxy(/**@type T*/(Object.fromEntries( + Object.entries(defaultState) + .map(([k, v]) => [k, getFromRegistry(k) ?? v]), + )), { + get(cache, key) { + if (typeof key === 'symbol') return undefined; + // cache hit + if (key in cache) return cache[key]; + // cache miss => get from localStorage + return /**@type{any}*/(cache)[key] = getFromRegistry(key); + }, + set(cache, key, value) { + if (typeof key === 'symbol') return false; + /**@type{any}*/(cache)[key] = value; + localStorage.setItem(keyPrefix+key, JSON.stringify(value)); + return true; + }, + }); +}; diff --git a/piano-visualizer.js b/piano-visualizer.js index 192b2a6..9a544d1 100644 --- a/piano-visualizer.js +++ b/piano-visualizer.js @@ -1,11 +1,17 @@ +// @ts-check + function setup() { createCanvas(1098, 118).parent('piano-visualizer'); colorMode(HSB, 360, 100, 100, 100); - keyOnColor = color(326, 100, 100, 100); // <---- 編輯這裡換「按下時」的顏色![HSB Color Mode] - pedaledColor = color(326, 100, 70, 100); // <---- 編輯這裡換「踏板踩住」的顏色![HSB Color Mode] smooth(2); frameRate(60); initKeys(); + + // init config elements + select('#colorpicker').value(state.color); + changeColor(); + select('#rainbow-mode-checkbox').checked(state.rainbowMode); + select('#velocity-mode-checkbox').checked(state.velocityMode); } function draw() { @@ -18,7 +24,7 @@ function draw() { function calculateSessionTime() { let currentTime = new Date(); - let timeElapsed = currentTime - sessionStartTime; + let timeElapsed = +currentTime - +sessionStartTime; // Convert time elapsed to hours, minutes, and seconds let seconds = Math.floor((timeElapsed / 1000) % 60); let minutes = Math.floor((timeElapsed / (1000 * 60)) % 60); @@ -32,13 +38,14 @@ function calculateSessionTime() { } function initKeys() { - for (i = 0; i < 128; i++) { + for (let i = 0; i < 128; i++) { isKeyOn[i] = 0; isPedaled[i] = 0; } } function drawWhiteKeys() { + const {velocityMode, rainbowMode} = state; let wIndex = 0; // white key index stroke(0, 0, 0); strokeWeight(1); @@ -46,9 +53,9 @@ function drawWhiteKeys() { if (isBlack[i % 12] == 0) { // it's a white key if (velocityMode) { - let m = max(isKeyOn[i], isPedaled[i]) * .9 + .1; + let m = Math.max(isKeyOn[i], isPedaled[i]) * .9 + .1; if ((isKeyOn[i] || isPedaled[i]) && !rainbowMode) { - let whitenedColor = keyOnColor.levels.map(x => floor(x * m + 255 * (1 - m))); + let whitenedColor = keyOnColor.levels.map(x => Math.floor(x * m + 255 * (1 - m))); fill(`rgb(${whitenedColor[0]}, ${whitenedColor[1]}, ${whitenedColor[2]})`); // keypressed } else if ((isKeyOn[i] || isPedaled[i]) && rainbowMode) { fill(map(i, 21, 108, 0, 1080) % 360, 100 * m, 100, 100); // rainbowMode @@ -77,6 +84,7 @@ function drawWhiteKeys() { } function drawBlackKeys() { + const {rainbowMode, velocityMode} = state; let wIndex = 0; // white key index stroke(0, 0, 0); strokeWeight(1.5); @@ -89,9 +97,9 @@ function drawBlackKeys() { if (isBlack[i % 12] > 0) { // it's a black key if (velocityMode) { - let m = max(isKeyOn[i], isPedaled[i]) * .9 + .1; + let m = Math.max(isKeyOn[i], isPedaled[i]) * .9 + .1; if ((isKeyOn[i] || isPedaled[i]) && !rainbowMode) { - let whitenedColor = keyOnColor.levels.map(x => floor(x * m + 255 * (1 - m))); + let whitenedColor = keyOnColor.levels.map(x => Math.floor(x * m + 255 * (1 - m))); fill(`rgb(${whitenedColor[0]}, ${whitenedColor[1]}, ${whitenedColor[2]})`); // keypressed } else if ((isKeyOn[i] || isPedaled[i]) && rainbowMode) { fill(map(i, 21, 108, 0, 1080) % 360, 100 * m, 100, 100); // rainbowMode @@ -127,19 +135,19 @@ function drawTexts() { textAlign(LEFT, TOP); // TIME - let timeText = "TIME" + "\n" + calculateSessionTime(); + let timeText = 'TIME' + '\n' + calculateSessionTime(); text(timeText, 5, 79); // PEDAL - let pedalText = "PEDALS" + "\nL " + convertNumberToBars(cc67now) + " R " + convertNumberToBars(cc64now) + let pedalText = 'PEDALS' + '\nL ' + convertNumberToBars(cc67now) + ' R ' + convertNumberToBars(cc64now) text(pedalText, 860, 79); // NOTES - let notesText = "NOTE COUNT" + "\n" + totalNotesPlayed; + let notesText = 'NOTE COUNT' + '\n' + totalNotesPlayed; text(notesText, 85, 79); // CALORIES - let caloriesText = "CALORIES" + "\n" + (totalIntensityScore / 250).toFixed(3); // 250 Intensity = 1 kcal. + let caloriesText = 'CALORIES' + '\n' + (totalIntensityScore / 250).toFixed(3); // 250 Intensity = 1 kcal. text(caloriesText, 350, 79); // SHORT-TERM DENSITY @@ -147,19 +155,20 @@ function drawTexts() { if (shortTermDensity > notesSMax) { notesSMax = shortTermDensity }; - let shortTermDensityText = "NPS(MAX)" + "\n" + shortTermDensity + " (" + notesSMax + ")"; + let shortTermDensityText = 'NPS(MAX)' + '\n' + shortTermDensity + ' (' + notesSMax + ')'; text(shortTermDensityText, 190, 79); // LEGATO SCORE let legatoScore = legatoHistory.reduce((accumulator, currentValue) => accumulator + currentValue, 0) legatoScore /= 60; - let legatoText = "LEGATO" + "\n" + legatoScore.toFixed(2); + let legatoText = 'LEGATO' + '\n' + legatoScore.toFixed(2); text(legatoText, 276, 79); // NOW PLAYING - let chordSymbol = Tonal.Chord.detect(getPressedKeys(false), { assumePerfectFifth: true }) - let chordSymbolWithoutM = chordSymbol.map((str) => str.replace(/M($|(?=\/))/g, "")); // get rid of the M's - let nowPlayingText = truncateString(getPressedKeys(true), 47) + "\n" + truncateString(chordSymbolWithoutM.join(' '), 47); + /** @type {string[]} */ + let chordSymbol = Tonal.Chord.detect(getPressedKeys(), { assumePerfectFifth: true }) + let chordSymbolWithoutM = chordSymbol.map((str) => str.replace(/M($|(?=\/))/g, '')); // get rid of the M's + let nowPlayingText = truncateString(getPressedKeys().join(' '), 47) + '\n' + truncateString(chordSymbolWithoutM.join(' '), 47); text(nowPlayingText, 440, 79); } @@ -171,6 +180,7 @@ function pushHistories() { legatoHistory.shift(); } +/** @param {number} number */ function convertNumberToBars(number) { if (number < 0 || number > 127) { throw new Error('Number must be between 0 and 127'); @@ -182,22 +192,22 @@ function convertNumberToBars(number) { // Calculate the number of bars const numberOfBars = Math.ceil(number / scaleFactor); - // Create a string with the calculated number of "|" characters + // Create a string with the calculated number of '|' characters const barString = '|'.repeat(numberOfBars); - // Calculate the number of "." characters required to fill the remaining space + // Calculate the number of '.' characters required to fill the remaining space const numberOfDots = maxBars - numberOfBars; - // Create a string with the calculated number of "." characters + // Create a string with the calculated number of '.' characters const dotString = '.'.repeat(numberOfDots); - // Combine the "|" and "." strings + // Combine the '|' and '.' strings const combinedString = barString + dotString; return combinedString; } -function getPressedKeys(returnString = true) { +function getPressedKeys() { let pressedOrPedaled = []; for (let i = 0; i < isKeyOn.length; i++) { @@ -219,18 +229,14 @@ function getPressedKeys(returnString = true) { pressedKeys.push(`${noteName}${octave}`); } } - if (returnString == true){ - return pressedKeys.join(' '); - } else { - return pressedKeys; - } + return pressedKeys; } +/** @param {string} str */ function truncateString(str, maxLength = 40) { if (str.length <= maxLength) { return str; } - return str.slice(0, maxLength - 3) + '...'; } @@ -249,6 +255,7 @@ function mouseClicked() { const fileName = `nicechord-pianometer-${strDate}_${strTime}`; saveCanvas(fileName, 'png'); } + if (mouseY > 76) { if (mouseX <= 84) { sessionStartTime = new Date();