diff --git a/package.json b/package.json index 9ce93c2..6da4f97 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,15 @@ "dependencies": { "@astrojs/check": "^0.5.10", "@astrojs/mdx": "^2.2.4", - "@astrojs/react": "^3.1.1", + "@astrojs/react": "^3.3.1", "@astrojs/tailwind": "^5.1.0", - "@types/react": "^18.2.74", - "@types/react-dom": "^18.2.24", + "@theoryofnekomata/react-musical-keyboard": "1.0.13", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", "astro": "^4.5.16", "jsdom": "^24.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", "tailwindcss": "^3.4.3", "typescript": "^5.4.4", "verovio": "^4.1.0" @@ -27,6 +28,7 @@ "devDependencies": { "@types/jsdom": "^21.1.6", "@types/node": "^20.12.7", - "@types/verovio": "^3.13.4" + "@types/verovio": "^3.13.4", + "prop-types": "^15.8.1" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90de885..9baf8e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,17 +12,20 @@ dependencies: specifier: ^2.2.4 version: 2.2.4(astro@4.5.16) '@astrojs/react': - specifier: ^3.1.1 - version: 3.1.1(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0)(react@18.2.0)(vite@5.2.8) + specifier: ^3.3.1 + version: 3.3.1(@types/react-dom@18.3.0)(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0)(vite@5.2.8) '@astrojs/tailwind': specifier: ^5.1.0 version: 5.1.0(astro@4.5.16)(tailwindcss@3.4.3) + '@theoryofnekomata/react-musical-keyboard': + specifier: 1.0.13 + version: 1.0.13(mem@6.1.1)(react@18.3.0) '@types/react': - specifier: ^18.2.74 - version: 18.2.74 + specifier: ^18.3.0 + version: 18.3.0 '@types/react-dom': - specifier: ^18.2.24 - version: 18.2.24 + specifier: ^18.3.0 + version: 18.3.0 astro: specifier: ^4.5.16 version: 4.5.16(@types/node@20.12.7)(typescript@5.4.4) @@ -30,11 +33,11 @@ dependencies: specifier: ^24.0.0 version: 24.0.0 react: - specifier: ^18.2.0 - version: 18.2.0 + specifier: ^18.3.0 + version: 18.3.0 react-dom: - specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + specifier: ^18.3.0 + version: 18.3.0(react@18.3.0) tailwindcss: specifier: ^3.4.3 version: 3.4.3 @@ -55,6 +58,9 @@ devDependencies: '@types/verovio': specifier: ^3.13.4 version: 3.13.4 + prop-types: + specifier: ^15.8.1 + version: 15.8.1 packages: @@ -186,20 +192,20 @@ packages: prismjs: 1.29.0 dev: false - /@astrojs/react@3.1.1(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0)(react@18.2.0)(vite@5.2.8): - resolution: {integrity: sha512-Uc4zY8UxkZrSKmiFGPyy+0uUKGgVETJSra5c/65Z2ZckJEHtgLYW0ZqGUoItGr0wJFMv+h1g3Z4OJGapGgcUyA==} - engines: {node: '>=18.14.1'} + /@astrojs/react@3.3.1(@types/react-dom@18.3.0)(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0)(vite@5.2.8): + resolution: {integrity: sha512-dwN6B+C0hQ1jpeQ5EckICpqv76t6RxEslldCP0UQ/ospolNk8GjqL11X/xQSbftiQvGWRMT0Tj5f0Xk17k5qJQ==} + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} peerDependencies: '@types/react': ^17.0.50 || ^18.0.21 '@types/react-dom': ^17.0.17 || ^18.0.6 react: ^17.0.2 || ^18.0.0 react-dom: ^17.0.2 || ^18.0.0 dependencies: - '@types/react': 18.2.74 - '@types/react-dom': 18.2.24 + '@types/react': 18.3.0 + '@types/react-dom': 18.3.0 '@vitejs/plugin-react': 4.2.1(vite@5.2.8) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react: 18.3.0 + react-dom: 18.3.0(react@18.3.0) ultrahtml: 1.5.3 transitivePeerDependencies: - supports-color @@ -1138,6 +1144,17 @@ packages: resolution: {integrity: sha512-ClaUWpt8oTzjcF0MM1P81AeWyzc1sNSJlAjMG80CbwqbFqXSNz+NpQVUC0icobt3sZn43Sn27M4pHD/Jmp3zHw==} dev: false + /@theoryofnekomata/react-musical-keyboard@1.0.13(mem@6.1.1)(react@18.3.0): + resolution: {integrity: sha512-YH3AV7SI5FyiY37QOwZCas8lE9qtUZr8Paso2kAm36JdAUP+GtSETtBXMGRU75dQHjRI2YcIAVlmqxoHPWcEvg==} + engines: {node: '>=10'} + peerDependencies: + mem: ^6.1.0 + react: '>=16' + dependencies: + mem: 6.1.1 + react: 18.3.0 + dev: false + /@types/acorn@4.0.6: resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} dependencies: @@ -1232,14 +1249,14 @@ packages: resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} dev: false - /@types/react-dom@18.2.24: - resolution: {integrity: sha512-cN6upcKd8zkGy4HU9F1+/s98Hrp6D4MOcippK4PoE8OZRngohHZpbJn1GsaDLz87MqvHNoT13nHvNqM9ocRHZg==} + /@types/react-dom@18.3.0: + resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} dependencies: - '@types/react': 18.2.74 + '@types/react': 18.3.0 dev: false - /@types/react@18.2.74: - resolution: {integrity: sha512-9AEqNZZyBx8OdZpxzQlaFEVCSFUM2YXJH46yPOiOpm078k6ZLOCcuAzGum/zK8YBwY+dbahVNbHrbgrAwIRlqw==} + /@types/react@18.3.0: + resolution: {integrity: sha512-DiUcKjzE6soLyln8NNZmyhcQjVv+WsUIFSqetMN0p8927OztKT4VTfFTqsbAi5oAGIcgOmOajlfBqyptDDjZRw==} dependencies: '@types/prop-types': 15.7.12 csstype: 3.1.3 @@ -2764,7 +2781,6 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - dev: false /js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} @@ -2903,7 +2919,6 @@ packages: hasBin: true dependencies: js-tokens: 4.0.0 - dev: false /lru-cache@10.2.0: resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} @@ -2930,6 +2945,13 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: false + /map-age-cleaner@0.1.3: + resolution: {integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==} + engines: {node: '>=6'} + dependencies: + p-defer: 1.0.0 + dev: false + /markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -3142,6 +3164,14 @@ packages: '@types/mdast': 4.0.3 dev: false + /mem@6.1.1: + resolution: {integrity: sha512-Ci6bIfq/UgcxPTYa8dQQ5FY3BzKkT894bwXWXxC/zqs0XgMO2cT20CGkOqda7gZNkmK5VP4x89IGZ6K7hfbn3Q==} + engines: {node: '>=8'} + dependencies: + map-age-cleaner: 0.1.3 + mimic-fn: 3.1.0 + dev: false + /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} dev: false @@ -3516,6 +3546,11 @@ packages: engines: {node: '>=6'} dev: false + /mimic-fn@3.1.0: + resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} + engines: {node: '>=8'} + dev: false + /mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -3629,7 +3664,6 @@ packages: /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - dev: false /object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} @@ -3673,6 +3707,11 @@ packages: strip-ansi: 7.1.0 dev: false + /p-defer@1.0.0: + resolution: {integrity: sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==} + engines: {node: '>=4'} + dev: false + /p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -3940,6 +3979,14 @@ packages: sisteransi: 1.0.5 dev: false + /prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + dev: true + /property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} dev: false @@ -3988,23 +4035,27 @@ packages: dev: false optional: true - /react-dom@18.2.0(react@18.2.0): - resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + /react-dom@18.3.0(react@18.3.0): + resolution: {integrity: sha512-zaKdLBftQJnvb7FtDIpZtsAIb2MZU087RM8bRDZU8LVCCFYjPTsDZJNFUWPcVz3HFSN1n/caxi0ca4B/aaVQGQ==} peerDependencies: - react: ^18.2.0 + react: ^18.3.0 dependencies: loose-envify: 1.4.0 - react: 18.2.0 - scheduler: 0.23.0 + react: 18.3.0 + scheduler: 0.23.1 dev: false + /react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + dev: true + /react-refresh@0.14.0: resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} engines: {node: '>=0.10.0'} dev: false - /react@18.2.0: - resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + /react@18.3.0: + resolution: {integrity: sha512-RPutkJftSAldDibyrjuku7q11d3oy6wKOyPe5K1HA/HwwrXcEqBdHsLypkC2FFYjP7bPUa6gbzSBhw4sY2JcDg==} engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 @@ -4245,8 +4296,8 @@ packages: xmlchars: 2.2.0 dev: false - /scheduler@0.23.0: - resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} + /scheduler@0.23.1: + resolution: {integrity: sha512-5GKS5JGfiah1O38Vfa9srZE4s3wdHbwjlCrvIookrg2FO9aIwKLOJXuJQFlEfNcVSOXuaL2hzDeY20uVXcUtrw==} dependencies: loose-envify: 1.4.0 dev: false diff --git a/src/components/FrequenciesForm/index.tsx b/src/components/FrequenciesForm/index.tsx new file mode 100644 index 0000000..d5646ea --- /dev/null +++ b/src/components/FrequenciesForm/index.tsx @@ -0,0 +1,340 @@ +import * as React from 'react'; +import MusicalKeyboard, { StyledAccidentalKey, StyledNaturalKey, KeyboardMap } from '@theoryofnekomata/react-musical-keyboard'; + +const useFrequenciesForm = () => { + const [baseFrequency, setBaseFrequency] = React.useState(440); + const [baseKey, setBaseKey] = React.useState(69); + const [stretchFactorNumerator, setStretchFactorNumerator] = React.useState(12.125); + const [audioContext, setAudioContext] = React.useState(); + const [keyChannels, setKeyChannels] = React.useState([] as { key: number, velocity: number, channel: number, oscillator: OscillatorNode }[]); + const [gain, setGain] = React.useState(); + const [equalDivisionOfTheOctave] = React.useState(12); + + const handleBaseKeyChange: React.ChangeEventHandler = (e) => { + setBaseKey(e.currentTarget.valueAsNumber); + }; + + const handleBaseFrequencyChange: React.ChangeEventHandler = (e) => { + setBaseFrequency(e.currentTarget.valueAsNumber); + }; + + const handleStretchFactorFineChange: React.ChangeEventHandler = (e) => { + const { form, valueAsNumber } = e.currentTarget; + + setStretchFactorNumerator(valueAsNumber); + + if (!form) { + return; + } + + const coarse = form.elements.namedItem('stretchFactorNumeratorCoarse'); + if (!coarse) { + return; + } + + if (!('value' in coarse)) { + return; + } + + coarse.value = valueAsNumber.toString(); + }; + + const handleStretchFactorCoarseChange: React.ChangeEventHandler = (e) => { + const { form, valueAsNumber } = e.currentTarget; + + setStretchFactorNumerator(valueAsNumber); + + if (!form) { + return; + } + + const fine = form.elements.namedItem('stretchFactorNumeratorFine'); + if (!fine) { + return; + } + + if (!('value' in fine)) { + return; + } + + fine.value = valueAsNumber.toString(); + }; + + React.useEffect(() => { + const audioContext = new AudioContext(); + setAudioContext(audioContext); + + const gainNode = audioContext.createGain(); + gainNode?.gain.setValueAtTime(0.05, audioContext.currentTime); + setGain(gainNode); + + gainNode.connect(audioContext.destination); + }, []); + + const playSound = (keys: { velocity: number, channel: number, key: number }[]) => { + if (!audioContext) { + return; + } + + if (!gain) { + return; + } + + setKeyChannels((oldOscillators) => { + const activeKeys = keys.map(k => `${k.channel}:${k.key}`); + const oscillatorsToCancel = oldOscillators.filter((k) => !activeKeys.includes(`${k.channel}:${k.key}`)); + for (let i = 0; i < oscillatorsToCancel.length; i += 1) { + oldOscillators[i]?.oscillator?.stop(); + oldOscillators[i]?.oscillator?.disconnect(); + } + + return keys.map((k) => { + const existingOscillator = oldOscillators.find((o) => o.key === k.key && o.channel === k.channel); + if (existingOscillator) { + return existingOscillator; + } + + const oscillator = audioContext.createOscillator(); + const f = ( + baseFrequency * ( + 2 ** ( + 1 / equalDivisionOfTheOctave + ) + ) ** ( + ( + k.key - baseKey + ) * ( + stretchFactorNumerator / equalDivisionOfTheOctave + ) + ) + ); + oscillator?.frequency.setValueAtTime(f, audioContext.currentTime); + oscillator.type = 'square'; + oscillator.connect(gain); + oscillator.start(); + return { + ...k, + oscillator, + }; + }); + + }); + }; + + return { + baseFrequency, + baseKey, + stretchFactorNumerator, + handleBaseFrequencyChange, + handleBaseKeyChange, + handleStretchFactorCoarseChange, + handleStretchFactorFineChange, + equalDivisionOfTheOctave, + playSound, + keyChannels, + }; +}; + +const PITCH_CLASSES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const; + +export const FrequenciesForm = () => { + const [showForm, setShowForm] = React.useState(false); + const { + handleStretchFactorCoarseChange, + handleStretchFactorFineChange, + baseKey, + baseFrequency, + handleBaseFrequencyChange, + handleBaseKeyChange, + stretchFactorNumerator, + equalDivisionOfTheOctave, + playSound, + keyChannels, + } = useFrequenciesForm(); + + React.useEffect(() => { + setShowForm(true); + }, []); + + React.useEffect(() => { + + }, []); + + return ( +
+ {showForm && ( + <> +
+ + + + +
+
+ + + +
+ + )} + + + + + + + + + + {PITCH_CLASSES.map((c) => ( + + ))} + + + + {new Array(11).fill(0).map((_, octave) => { + return ( + + + {PITCH_CLASSES.map((_, pitchClassIndex) => { + const i = (octave * PITCH_CLASSES.length) + pitchClassIndex; + const nonStretched = (baseFrequency * (2 ** (1 / equalDivisionOfTheOctave)) ** (i - baseKey)); + const stretched = (baseFrequency * (2 ** (1 / equalDivisionOfTheOctave)) ** ((i - baseKey) * (stretchFactorNumerator / equalDivisionOfTheOctave))); + const difference = (stretched - nonStretched); + return ( + + ); + })} + + ); + })} + +
+ MIDI note frequencies and their stretched counterparts + (base frequency={baseFrequency} Hz for key #{baseKey}, factor={(stretchFactorNumerator/equalDivisionOfTheOctave).toFixed(3)}) +
+ Octave + + Pitch Class +
+ {c} +
{octave} + {nonStretched.toFixed(2)} +
{' '} + {stretched.toFixed(2)} +
{' '} + + ({difference.toFixed(2)}) + +
+ {false && ( + + + + + + + + + + + + + {new Array(128).fill(0).map((_, i) => { + const nonStretched = (baseFrequency * (2 ** (1 / equalDivisionOfTheOctave)) ** (i - baseKey)); + const stretched = (baseFrequency * (2 ** (1 / equalDivisionOfTheOctave)) ** ((i - baseKey) * (stretchFactorNumerator / equalDivisionOfTheOctave))); + const difference = (stretched - nonStretched); + return ( + + + + + + + + ); + })} + +
+ MIDI note frequencies and their stretched counterparts + (base frequency={baseFrequency} Hz for key #{baseKey}, factor={(stretchFactorNumerator/equalDivisionOfTheOctave).toFixed(3)}) +
+ MIDI Note Number + + Key + + Frequency +
+ {' '} + (non-stretched, in Hz) +
+ Frequency +
+ {' '} + (stretched, in Hz) +
+ Difference (in Hz) +
+ {i} + + {PITCH_CLASSES[i % PITCH_CLASSES.length]} + {Math.floor(i / PITCH_CLASSES.length)} + + {nonStretched.toFixed(5)} + + {stretched.toFixed(5)} + + {difference.toFixed(5)} +
+ )} +
+ ); +}; diff --git a/src/pages/appendix01-piano-key-frequencies.mdx b/src/pages/appendix01-piano-key-frequencies.mdx new file mode 100644 index 0000000..441b484 --- /dev/null +++ b/src/pages/appendix01-piano-key-frequencies.mdx @@ -0,0 +1,8 @@ +--- +title: Piano Key Frequencies +layout: ../layouts/Default.astro +--- + +import { FrequenciesForm } from '../components/FrequenciesForm'; + +