Browse Source

Add piano key frequencies appendix.

master
TheoryOfNekomata 8 months ago
parent
commit
45b07c0766
4 changed files with 441 additions and 40 deletions
  1. +8
    -6
      package.json
  2. +85
    -34
      pnpm-lock.yaml
  3. +340
    -0
      src/components/FrequenciesForm/index.tsx
  4. +8
    -0
      src/pages/appendix01-piano-key-frequencies.mdx

+ 8
- 6
package.json View File

@@ -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"
}
}

+ 85
- 34
pnpm-lock.yaml View File

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


+ 340
- 0
src/components/FrequenciesForm/index.tsx View File

@@ -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<AudioContext>();
const [keyChannels, setKeyChannels] = React.useState([] as { key: number, velocity: number, channel: number, oscillator: OscillatorNode }[]);
const [gain, setGain] = React.useState<GainNode>();
const [equalDivisionOfTheOctave] = React.useState(12);

const handleBaseKeyChange: React.ChangeEventHandler<HTMLElementTagNameMap['input']> = (e) => {
setBaseKey(e.currentTarget.valueAsNumber);
};

const handleBaseFrequencyChange: React.ChangeEventHandler<HTMLElementTagNameMap['input']> = (e) => {
setBaseFrequency(e.currentTarget.valueAsNumber);
};

const handleStretchFactorFineChange: React.ChangeEventHandler<HTMLElementTagNameMap['input']> = (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<HTMLElementTagNameMap['input']> = (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 (
<div>
{showForm && (
<>
<form>
<input
type="number"
defaultValue={baseFrequency}
name="baseFrequency"
onChange={handleBaseFrequencyChange}
/>
<input
type="number"
defaultValue={baseKey}
name="baseKey"
onChange={handleBaseKeyChange}
/>
<input
type="range"
min={equalDivisionOfTheOctave * 0.95}
max={equalDivisionOfTheOctave * 1.05}
defaultValue={stretchFactorNumerator}
name="stretchFactorNumeratorFine"
onChange={handleStretchFactorFineChange}
step="any"
/>
<input
type="number"
min={equalDivisionOfTheOctave * 0.95}
max={equalDivisionOfTheOctave * 1.05}
defaultValue={stretchFactorNumerator}
name="stretchFactorNumeratorCoarse"
onChange={handleStretchFactorCoarseChange}
step="any"
/>
</form>
<div style={{ position: 'relative', backgroundColor: 'black', }}>
<MusicalKeyboard
hasMap
startKey={0}
endKey={127}
height={50}
keyComponents={{
accidental: StyledAccidentalKey,
natural: StyledNaturalKey,
}}
keyChannels={keyChannels}
>
<KeyboardMap
channel={0}
onChange={playSound}
/>
</MusicalKeyboard>
</div>
</>
)}

<table
style={{
fontSize: '0.875em',
}}
>
<caption>
MIDI note frequencies and their stretched counterparts
(base frequency={baseFrequency} Hz for key #{baseKey}, factor={(stretchFactorNumerator/equalDivisionOfTheOctave).toFixed(3)})
</caption>
<thead>
<tr>
<th rowSpan={2}>
Octave
</th>
<th colSpan={12}>
Pitch Class
</th>
</tr>
<tr>
{PITCH_CLASSES.map((c) => (
<th
key={c}
style={{ textAlign: 'right' }}
>
{c}
</th>
))}
</tr>
</thead>
<tbody>
{new Array(11).fill(0).map((_, octave) => {
return (
<tr>
<th>{octave}</th>
{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 (
<td
style={{ textAlign: 'right' }}
>
{nonStretched.toFixed(2)}
<br />{' '}
{stretched.toFixed(2)}
<br />{' '}
<small>
({difference.toFixed(2)})
</small>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
{false && (
<table>
<caption>
MIDI note frequencies and their stretched counterparts
(base frequency={baseFrequency} Hz for key #{baseKey}, factor={(stretchFactorNumerator/equalDivisionOfTheOctave).toFixed(3)})
</caption>
<thead>
<tr>
<th>
MIDI Note Number
</th>
<th>
Key
</th>
<th>
Frequency
<br />
{' '}
(non-stretched, in Hz)
</th>
<th>
Frequency
<br />
{' '}
(stretched, in Hz)
</th>
<th>
Difference (in Hz)
</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={i}>
<th>
{i}
</th>
<th>
{PITCH_CLASSES[i % PITCH_CLASSES.length]}
{Math.floor(i / PITCH_CLASSES.length)}
</th>
<td style={{ textAlign: 'right' }}>
{nonStretched.toFixed(5)}
</td>
<td style={{ textAlign: 'right' }}>
{stretched.toFixed(5)}
</td>
<td style={{ textAlign: 'right' }}>
{difference.toFixed(5)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
);
};

+ 8
- 0
src/pages/appendix01-piano-key-frequencies.mdx View File

@@ -0,0 +1,8 @@
---
title: Piano Key Frequencies
layout: ../layouts/Default.astro
---

import { FrequenciesForm } from '../components/FrequenciesForm';

<FrequenciesForm client:load />

Loading…
Cancel
Save