Component: js/widgets/pitchstaircase.js - PitchStaircase._dissectStair()
Related upstream report: #8332 - "Pitch Staircase: Frequency multiplies beyond audible range (10^38 Hz), breaking stair layout and audio"
Summary
Clicking a stair step in the Pitch Staircase widget "dissects" it into a new pitch using the two ratio inputs (Divide into fields). The new frequency is computed as:
const inputNum = parseFloat(inputNum2 / inputNum1);
...
const newFrequency = parseFloat(frequency) / inputNum;
If the two ratio fields are entered so that ratio1 > ratio2 (e.g. 3 and 2, instead of the conventional 2 and 3), inputNum becomes < 1, so every click multiplies the frequency instead of dividing it. There is no upper or lower bound applied to newFrequency anywhere in the function.
Interestingly, clampNumber is already imported into this file and is used a few lines above to bound the visual width of a stair (clampNumber(rawWidth, 20, PitchStaircase.INNERWINDOWWIDTH)), but the same guard was never applied to the frequency value itself.
Steps to reproduce
- Open the Pitch Staircase widget with any starting pitch.
- Set the two ratio fields to
3 and 2 (instead of the default 2 and 3).
- Repeatedly click the same stair step (or any step whose resulting
frequency keeps landing near the same row).
Actual behavior
Each click multiplies the frequency by ratio1 / ratio2 (1.5x in the example
above). This compounds exponentially:
| Clicks |
Frequency (starting from 392 Hz) |
| 5 |
2,976 Hz |
| 10 |
22,604 Hz |
| 20 |
1,303,500 Hz |
| 40 |
4,334,474,269 Hz |
| 50 |
249,947,628,083 Hz |
This unclamped value then flows into three places, all of which break:
- Audio
this.activity.logo.synth.trigger(0, frequency, ...) is called with the runaway frequency, feeding an absurd value straight to the Web Audio oscillator.
- UI the stair's label (
frequency.toFixed(2)) renders an unreadable,
huge number, and the stair layout breaks.
- Project export the raw frequency is baked into generated "Hertz blocks" when exporting the stairs as blocks, corrupting the saved project. Note that
frequencyToPitch() itself is properly clamped to [A0, C10],
so pitch-name conversion never breaks it's specifically the raw stored/displayed/played frequency that is left unguarded.
Expected behavior
Dissecting a stair should always keep the resulting frequency within a sane, audible range, regardless of the order the ratio fields are entered in or how many times a stair is dissected.
Why this matters
This is not an edge case requiring malicious input it's a completely ordinary interaction (typing the ratio the "wrong" way round, or simply clicking a step several times while experimenting) in a widget explicitly
aimed at students exploring musical ratios. It reliably breaks both the UI and audio output within seconds of normal use, and can silently corrupt a saved project.
Component:
js/widgets/pitchstaircase.js-PitchStaircase._dissectStair()Related upstream report: #8332 - "Pitch Staircase: Frequency multiplies beyond audible range (10^38 Hz), breaking stair layout and audio"
Summary
Clicking a stair step in the Pitch Staircase widget "dissects" it into a new pitch using the two ratio inputs (
Divide intofields). The new frequency is computed as:If the two ratio fields are entered so that
ratio1 > ratio2(e.g.3and2, instead of the conventional2and3),inputNumbecomes< 1, so every click multiplies the frequency instead of dividing it. There is no upper or lower bound applied tonewFrequencyanywhere in the function.Interestingly,
clampNumberis already imported into this file and is used a few lines above to bound the visual width of a stair (clampNumber(rawWidth, 20, PitchStaircase.INNERWINDOWWIDTH)), but the same guard was never applied to the frequency value itself.Steps to reproduce
3and2(instead of the default2and3).frequency keeps landing near the same row).
Actual behavior
Each click multiplies the frequency by
ratio1 / ratio2(1.5x in the exampleabove). This compounds exponentially:
This unclamped value then flows into three places, all of which break:
this.activity.logo.synth.trigger(0, frequency, ...)is called with the runaway frequency, feeding an absurd value straight to the Web Audio oscillator.frequency.toFixed(2)) renders an unreadable,huge number, and the stair layout breaks.
frequencyToPitch()itself is properly clamped to[A0, C10],so pitch-name conversion never breaks it's specifically the raw stored/displayed/played frequency that is left unguarded.
Expected behavior
Dissecting a stair should always keep the resulting frequency within a sane, audible range, regardless of the order the ratio fields are entered in or how many times a stair is dissected.
Why this matters
This is not an edge case requiring malicious input it's a completely ordinary interaction (typing the ratio the "wrong" way round, or simply clicking a step several times while experimenting) in a widget explicitly
aimed at students exploring musical ratios. It reliably breaks both the UI and audio output within seconds of normal use, and can silently corrupt a saved project.