Skip to content

Commit 15e7ddc

Browse files
authored
feat(temperament): add cents input alongside hertz slider (#3920) (#6683)
Musicians typically think about pitch adjustments in cents rather than hertz. The Temperament Widget's custom-pitch editor only exposed a hertz slider, forcing users to convert musical intervals into raw frequencies. - Add a cents input field alongside the existing hertz frequency slider in the edit-frequency dialog - Cents are expressed relative to the starting frequency value at the edited pitch position (0 = unchanged, +50 = halfway toward the next neighbouring pitch, -100 = one full semitone below) - Two-way sync: moving the slider updates the cents value, editing the cents value moves the slider and retriggers the synth - The cents input uses the same .rangeslidervalue pill styling as the frequency display for visual consistency - Clamp cents-to-frequency conversion to the slider's min/max range so the user cannot push the pitch past its neighbouring boundaries - Increase the dialog size and restyle the "done" button so the full UI is visible now that there is an additional input row Closes #3920
1 parent 4c39706 commit 15e7ddc

File tree

2 files changed

+123
-8
lines changed

2 files changed

+123
-8
lines changed

js/widgets/__tests__/temperament.test.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,4 +873,47 @@ describe("TemperamentWidget basic tests", () => {
873873

874874
expect(widget.inTemperament).toBe("equal");
875875
});
876+
877+
describe("cents <-> frequency conversion", () => {
878+
test("_freqToCents returns 0 when frequency equals base", () => {
879+
expect(widget._freqToCents(440, 440)).toBe(0);
880+
});
881+
882+
test("_freqToCents returns 1200 for one octave up", () => {
883+
expect(widget._freqToCents(880, 440)).toBeCloseTo(1200, 6);
884+
});
885+
886+
test("_freqToCents returns -1200 for one octave down", () => {
887+
expect(widget._freqToCents(220, 440)).toBeCloseTo(-1200, 6);
888+
});
889+
890+
test("_freqToCents returns ~100 for one equal-tempered semitone up", () => {
891+
const semitone = 440 * Math.pow(2, 1 / 12);
892+
expect(widget._freqToCents(semitone, 440)).toBeCloseTo(100, 6);
893+
});
894+
895+
test("_centsToFreq returns the base frequency for 0 cents", () => {
896+
expect(widget._centsToFreq(0, 440)).toBe(440);
897+
});
898+
899+
test("_centsToFreq doubles the frequency at +1200 cents", () => {
900+
expect(widget._centsToFreq(1200, 440)).toBeCloseTo(880, 6);
901+
});
902+
903+
test("_centsToFreq halves the frequency at -1200 cents", () => {
904+
expect(widget._centsToFreq(-1200, 440)).toBeCloseTo(220, 6);
905+
});
906+
907+
test("round-trip: freq -> cents -> freq preserves the original", () => {
908+
const freq = 523.25;
909+
const cents = widget._freqToCents(freq, 440);
910+
expect(widget._centsToFreq(cents, 440)).toBeCloseTo(freq, 6);
911+
});
912+
913+
test("round-trip: cents -> freq -> cents preserves the original", () => {
914+
const cents = 47;
915+
const freq = widget._centsToFreq(cents, 440);
916+
expect(widget._freqToCents(freq, 440)).toBeCloseTo(cents, 6);
917+
});
918+
});
876919
});

js/widgets/temperament.js

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -547,8 +547,8 @@ function TemperamentWidget() {
547547
const i = Number(event.target.dataset.message);
548548
const that = this;
549549

550-
docById("noteInfo").style.width = "180px";
551-
docById("noteInfo").style.height = "130px";
550+
docById("noteInfo").style.width = "200px";
551+
docById("noteInfo").style.height = "240px";
552552
if (docById("note")) docById("note").textContent = "";
553553
if (docById("frequency")) docById("frequency").textContent = "";
554554

@@ -574,26 +574,55 @@ function TemperamentWidget() {
574574
freqSpan.textContent = this.frequencies[i];
575575
noteInfo.appendChild(freqSpan);
576576

577+
// Cents input (relative to the starting pitch value at this position).
578+
// Musicians typically think in cents rather than hertz, so offer both.
579+
const originalFrequency = parseFloat(this.frequencies[i]);
580+
noteInfo.appendChild(document.createElement("br"));
581+
noteInfo.appendChild(document.createTextNode(`\u00A0\u00A0${_("cents")}`));
582+
const centsInput = document.createElement("input");
583+
centsInput.type = "number";
584+
centsInput.id = "centsInput1";
585+
centsInput.value = "0";
586+
centsInput.step = "1";
587+
// Match the frequency display styling: blue text on grey rounded pill.
588+
centsInput.className = "rangeslidervalue";
589+
centsInput.style.width = "60px";
590+
centsInput.style.textAlign = "center";
591+
centsInput.style.border = "none";
592+
noteInfo.appendChild(centsInput);
593+
577594
noteInfo.appendChild(document.createElement("br"));
578595
noteInfo.appendChild(document.createElement("br"));
579596
const doneDiv = document.createElement("div");
580597
doneDiv.id = "done";
581-
doneDiv.style.background = "rgb(196, 196, 196)";
598+
doneDiv.style.background = "#64B5F6";
599+
doneDiv.style.padding = "6px";
600+
doneDiv.style.marginTop = "8px";
601+
doneDiv.style.borderRadius = "4px";
602+
doneDiv.style.cursor = "pointer";
582603
const doneCenter = document.createElement("center");
583604
doneCenter.textContent = _("done");
605+
doneCenter.style.color = "white";
606+
doneCenter.style.fontWeight = "bold";
584607
doneDiv.appendChild(doneCenter);
585608
noteInfo.appendChild(doneDiv);
586609

587-
docById("frequencySlider1").oninput = function () {
588-
docById("frequencydiv1").textContent = docById("frequencySlider1").value;
589-
const frequency = docById("frequencySlider1").value;
590-
const ratio = frequency / that.frequencies[0];
610+
// Convert between frequency and cents using the widget-level helpers
611+
// (see _freqToCents / _centsToFreq) so the math is testable.
612+
const freqToCents = freq => that._freqToCents(freq, originalFrequency);
613+
const centsToFreq = cents => that._centsToFreq(cents, originalFrequency);
614+
615+
// Shared update logic so both inputs behave identically.
616+
const applyFrequency = frequency => {
617+
const freqNum = parseFloat(frequency);
618+
docById("frequencydiv1").textContent = freqNum.toFixed(2);
619+
const ratio = freqNum / that.frequencies[0];
591620
that.temporaryRatios = that.ratios.slice();
592621
that.temporaryRatios[i] = ratio;
593622
that._logo.resetSynth(0);
594623
that._logo.synth.trigger(
595624
0,
596-
frequency,
625+
freqNum,
597626
Singer.defaultBPMFactor * 0.01,
598627
"electronic synth",
599628
null,
@@ -602,6 +631,26 @@ function TemperamentWidget() {
602631
that.createMainWheel(that.temporaryRatios);
603632
};
604633

634+
docById("frequencySlider1").oninput = function () {
635+
const frequency = parseFloat(docById("frequencySlider1").value);
636+
// Keep cents box in sync without triggering its own handler.
637+
docById("centsInput1").value = Math.round(freqToCents(frequency));
638+
applyFrequency(frequency);
639+
};
640+
641+
docById("centsInput1").oninput = function () {
642+
const cents = parseFloat(docById("centsInput1").value);
643+
if (isNaN(cents)) return;
644+
const sliderEl = docById("frequencySlider1");
645+
const min = parseFloat(sliderEl.getAttribute("min"));
646+
const max = parseFloat(sliderEl.getAttribute("max"));
647+
// Clamp the resulting frequency to the slider's allowed range so
648+
// user cannot move the pitch outside the neighbour boundaries.
649+
const frequency = Math.min(Math.max(centsToFreq(cents), min), max);
650+
sliderEl.value = frequency;
651+
applyFrequency(frequency);
652+
};
653+
605654
docById("done").onclick = function () {
606655
that.ratios = that.temporaryRatios.slice();
607656
that.typeOfEdit = "nonequal";
@@ -622,6 +671,29 @@ function TemperamentWidget() {
622671
};
623672
};
624673

674+
/**
675+
* Converts a frequency (Hz) to cents offset relative to a base frequency.
676+
* 0 cents means the frequency equals the base; +1200 is one octave up;
677+
* +100 is one semitone up.
678+
* @param {number} freq - The frequency in Hz.
679+
* @param {number} baseFreq - The reference frequency in Hz.
680+
* @returns {number} The cents offset from base.
681+
*/
682+
this._freqToCents = function (freq, baseFreq) {
683+
return 1200 * Math.log2(freq / baseFreq);
684+
};
685+
686+
/**
687+
* Converts a cents offset back to an absolute frequency in Hz relative
688+
* to a base frequency.
689+
* @param {number} cents - The cents offset from base.
690+
* @param {number} baseFreq - The reference frequency in Hz.
691+
* @returns {number} The resulting frequency in Hz.
692+
*/
693+
this._centsToFreq = function (cents, baseFreq) {
694+
return baseFreq * Math.pow(2, cents / 1200);
695+
};
696+
625697
/**
626698
* Switches to displaying the graph of notes on the temperament widget.
627699
* @returns {void}

0 commit comments

Comments
 (0)