refactor: add LocalStorage support, partial type annotation

This commit is contained in:
sup39 2024-01-11 04:09:13 +09:00
parent 94646d36fc
commit a6887a8046
Signed by: sup39
GPG key ID: 111C00916C1641E5
5 changed files with 236 additions and 120 deletions

View file

@ -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]})`);
}

49
index.d.ts vendored Normal file
View file

@ -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;

View file

@ -1,72 +1,67 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>好和弦的鋼琴鍵盤顯示器</title>
<link rel="stylesheet" href="style.css">
<script src="p5.min.js"></script>
<script src="tonal.min.js"></script>
<meta charset="UTF-8">
<title>好和弦的鋼琴鍵盤顯示器</title>
<link rel="stylesheet" href="style.css">
<script src="p5.min.js"></script>
<script src="tonal.min.js"></script>
<script src="webmidi.js"></script>
<script src="globals.js"></script>
<script src="piano-visualizer.js"></script>
<script src="webmidi.js"></script>
<script src="lib.js"></script>
<script src="globals.js"></script>
<script src="piano-visualizer.js"></script>
</head>
<body>
<div id="main">
<div id="controls" class="center">
<div>
<h3>鋼琴鍵盤顯示器 by NiceChord</h3>
<div style="display: flex; justify-content: space-around;">
<div>
<h5>選擇 MIDI 裝置</h5>
<input id="slider" type="range" min="0" max="0" value="0">
<div id="device">Select Input: </div>
</div>
<div style="display: flex; flex-direction: column; justify-content: center; align-items: start;">
<div>
<label for="colorpicker">選擇顏色</label>
<input type="color" id="colorpicker" value="#ff0090" oninput="changeColor()">
</div>
<div style="display: flex; align-items: center;">
<span style="margin-right: 5px;">彩虹模式</span>
<input type="checkbox" id="rainbow-mode-checkbox" onclick="toggleRainbowMode(this)">
<label for="rainbow-mode-checkbox" class="custom-checkbox">
<span class="switch-txt" turnOn="On" turnOff="Off"></span>
</label>
</div>
<div style="display: flex; align-items: center;">
<span style="margin-right: 5px;">力度模式</span>
<input type="checkbox" id="velocity-mode-checkbox" onclick="toggleVelocityMode(this)">
<label for="velocity-mode-checkbox" class="custom-checkbox">
<span class="switch-txt" turnOn="On" turnOff="Off"></span>
</label>
</div>
</div>
</div>
</div>
<br />
</div>
<div id="main">
<div id="controls" class="center">
<div>
<h3>鋼琴鍵盤顯示器 by NiceChord</h3>
<div style="display: flex; justify-content: space-around;">
<div>
<h5>選擇 MIDI 裝置</h5>
<input id="slider" type="range" min="0" max="0" value="0">
<div id="device">Select Input: </div>
</div>
<div class="center">
<div id="piano-visualizer">
<!-- Our sketch will go here! -->
</div>
<span style="font-size: 11px;">
TIME使用時間 | NOTE COUNT總彈奏音符數 | NPS最近一秒鐘彈奏音符數括號為歷史最大值 | LEGATO圓滑指數最近一秒鐘平均來說有幾個鍵被同時按住 <br />
CALORIES消耗熱量估計值好玩就好| PEDALS左右踏板深度顯示 <br />
(密技:點鍵盤最左上角的角落,可以儲存截圖) <br /><br />
</span>
覺得好用嗎?到 <a href="https://nicechord.com">NiceChord.com</a> 逛逛支持我!原始碼在 <a href="https://github.com/wiwikuan/pianometer">GitHub</a>
<div style="display: flex; flex-direction: column; justify-content: center; align-items: start;">
<div>
<label for="colorpicker">選擇顏色</label>
<input type="color" id="colorpicker" value="#ff0090" oninput="changeColor()">
</div>
<div style="display: flex; align-items: center;">
<span style="margin-right: 5px;">彩虹模式</span>
<input type="checkbox" id="rainbow-mode-checkbox" onclick="toggleRainbowMode(this)">
<label for="rainbow-mode-checkbox" class="custom-checkbox">
<span class="switch-txt" turnOn="On" turnOff="Off"></span>
</label>
</div>
<div style="display: flex; align-items: center;">
<span style="margin-right: 5px;">力度模式</span>
<input type="checkbox" id="velocity-mode-checkbox" onclick="toggleVelocityMode(this)">
<label for="velocity-mode-checkbox" class="custom-checkbox">
<span class="switch-txt" turnOn="On" turnOff="Off"></span>
</label>
</div>
</div>
</div>
</div>
</div>
<br />
</div>
</div>
<div class="center">
<div id="piano-visualizer">
<!-- Our sketch will go here! -->
</div>
<span style="font-size: 11px;">
TIME使用時間 | NOTE COUNT總彈奏音符數 | NPS最近一秒鐘彈奏音符數括號為歷史最大值 | LEGATO圓滑指數最近一秒鐘平均來說有幾個鍵被同時按住 <br />
CALORIES消耗熱量估計值好玩就好| PEDALS左右踏板深度顯示 <br />
(密技:點鍵盤最左上角的角落,可以儲存截圖) <br /><br />
</span>
覺得好用嗎?到 <a href="https://nicechord.com">NiceChord.com</a> 逛逛支持我!原始碼在 <a href="https://github.com/wiwikuan/pianometer">GitHub</a>
</div>
</body>
</html>
</html>

37
lib.js Normal file
View file

@ -0,0 +1,37 @@
/**
* @template {Record<string, any>} 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;
},
});
};

View file

@ -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();