diff --git a/app/components/labeledSlider.js b/app/components/labeledSlider.js
new file mode 100644
index 0000000..cc867bb
--- /dev/null
+++ b/app/components/labeledSlider.js
@@ -0,0 +1,251 @@
+// Etcnome - A programmable metronome
+// Copyright (C) 2021 Jacob Katz
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+const TRANSFORM = new WeakMap();
+const REVERSE_TRANSFORM = new WeakMap();
+const VALUE = new WeakMap();
+const DEFAULT = new WeakMap();
+
+const validate = (slider) => {
+ if (!slider.text.readOnly && !slider.text.disabled) {
+ const reversedVal = slider.reverseTransform
+ ? slider.reverseTransform(slider.text.value)
+ : slider.text.value;
+ if (reversedVal < slider.min || reversedVal > slider.max) {
+ const constrainedVal = Math.max(
+ slider.min,
+ Math.min(slider.max, reversedVal)
+ );
+ slider.text.value = slider.transform
+ ? slider.transform(constrainedVal)
+ : constrainedVal;
+ }
+ slider.slider.value = slider.reverseTransform
+ ? slider.reverseTransform(slider.text.value)
+ : slider.text.value;
+ } else {
+ slider.text.value = slider.transform
+ ? slider.transform(slider.slider.value)
+ : slider.slider.value;
+ }
+ VALUE.set(slider, slider.text.value);
+};
+
+class LabeledSlider extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ const form = document.createElement("form");
+
+ const label = document.createElement("label");
+ const labelText = document.createTextNode("");
+
+ const slider = document.createElement("input");
+ slider.type = "range";
+
+ const output = document.createElement("output");
+
+ const text = document.createElement("input");
+ text.type = "number";
+ text.step = "any";
+ text.readOnly = this.reverseTransform === null && this.transform !== null;
+
+ output.appendChild(text);
+
+ const resetButton = document.createElement("button");
+ resetButton.type = "button"; // by default it is a submit button otherwise
+ resetButton.innerText = "reset";
+ resetButton.classList.add("hidden");
+ resetButton.addEventListener("click", () => {
+ if (this.default !== null && typeof this.default !== "undefined") {
+ this.value = this.default;
+ text.dispatchEvent(
+ new Event("input", {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ })
+ );
+ }
+ });
+
+ this.shadowRoot.appendChild(form);
+ form.appendChild(label);
+ label.className = "cols";
+ const col1 = document.createElement("div");
+ col1.className = "col";
+ label.appendChild(col1);
+ const col2 = document.createElement("div");
+ col2.className = "col";
+ label.appendChild(col2);
+
+ col1.appendChild(labelText);
+ col1.appendChild(resetButton);
+ col2.appendChild(slider);
+ col2.appendChild(output);
+
+ slider.addEventListener("input", () => {
+ text.value = this.transform ? this.transform(slider.value) : slider.value;
+ validate(this);
+ });
+
+ text.addEventListener("input", (e) => {
+ if (text.value === "") {
+ e.stopPropagation();
+ return;
+ }
+ if (this.reverseTransform) {
+ slider.value = this.reverseTransform(text.value);
+ } else if (!this.transform) {
+ slider.value = text.value;
+ }
+ validate(this);
+ });
+
+ text.addEventListener("change", (e) => {
+ if (text.value === "") {
+ e.stopPropagation();
+ text.value = this.transform
+ ? this.transform(slider.value)
+ : slider.value;
+ return;
+ }
+ validate(this);
+ });
+
+ const style = document.createElement("style");
+ style.innerText = `
+ :host { display: inline-block; }
+ form { display: inline-block; }
+ .hidden { display: none; }
+ .cols { display: flex; }
+ .col { display: flex; flex-direction: column; }
+ `;
+ this.shadowRoot.appendChild(style);
+
+ this.labelText = labelText;
+ this.slider = slider;
+ this.text = text;
+ this.resetButton = resetButton;
+ }
+
+ set default(val) {
+ DEFAULT.set(this, val);
+ const { resetButton } = this;
+ if (this.default !== null && typeof this.default !== "undefined") {
+ resetButton.classList.remove("hidden");
+ } else if (!resetButton.classList.contains("hidden")) {
+ resetButton.classList.add("hidden");
+ }
+ }
+
+ get default() {
+ return DEFAULT.get(this);
+ }
+
+ set disabled(disabled) {
+ this.slider.disabled = disabled;
+ this.text.disabled = disabled;
+ this.reset.disabled = disabled;
+ }
+
+ get disabled() {
+ return this.slider.disabled;
+ }
+
+ set readOnly(readOnly) {
+ this.slider.readOnly = readOnly;
+ this.text.readOnly =
+ readOnly || (this.reverseTransform === null && this.transform !== null);
+ }
+
+ get readOnly() {
+ return this.slider.readOnly;
+ }
+
+ set transform(transform) {
+ TRANSFORM.set(this, transform);
+ validate(this);
+ this.text.readOnly =
+ this.readOnly ||
+ (this.reverseTransform === null && this.transform !== null);
+ }
+
+ get transform() {
+ return TRANSFORM.get(this);
+ }
+
+ set reverseTransform(reverseTransform) {
+ REVERSE_TRANSFORM.set(this, reverseTransform);
+ validate(this);
+ this.text.readOnly =
+ this.readOnly ||
+ (this.reverseTransform === null && this.transform !== null);
+ }
+
+ get reverseTransform() {
+ return REVERSE_TRANSFORM.get(this);
+ }
+
+ set name(name) {
+ this.labelText.data = name;
+ }
+
+ get name() {
+ return this.labelText.data;
+ }
+
+ set min(min) {
+ this.slider.min = min;
+ }
+
+ get min() {
+ return this.slider.min;
+ }
+
+ set max(max) {
+ this.slider.max = max;
+ }
+
+ get max() {
+ return this.slider.max;
+ }
+
+ set step(step) {
+ this.slider.step = step;
+ }
+
+ get step() {
+ return this.slider.step;
+ }
+
+ set value(value) {
+ if (this.reverseTransform) {
+ this.text.value = value;
+ validate(this);
+ } else if (!this.transform) {
+ this.slider.value = value;
+ this.text.value = value;
+ validate(this);
+ }
+ }
+
+ get value() {
+ return VALUE.get(this);
+ }
+}
+
+window.customElements.define("labeled-slider", LabeledSlider);
diff --git a/app/components/playerControls.js b/app/components/playerControls.js
index 1a9ed5e..030f3de 100644
--- a/app/components/playerControls.js
+++ b/app/components/playerControls.js
@@ -15,6 +15,7 @@
// along with this program. If not, see .
import "./playerButton.js";
+import "./labeledSlider.js";
import { State as PlayerState } from "../../lib/player/player.js";
const PLAYER = new WeakMap();
@@ -64,6 +65,7 @@ class PlayerControls extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
+ this.updateCallback = () => update(this);
this.playButton = document.createElement("player-button");
this.playButton.label = "play";
@@ -85,11 +87,25 @@ class PlayerControls extends HTMLElement {
this.repeatCheckbox.type = "checkbox";
repeatLabel.appendChild(this.repeatCheckbox);
+ this.speedControl = document.createElement("labeled-slider");
+ this.speedControl.name = "speed";
+ this.speedControl.min = -2;
+ this.speedControl.max = 2;
+ this.speedControl.step = 0.1;
+ this.speedControl.value = 1;
+ this.speedControl.default = 1;
+ this.speedControl.transform = (x) => 2 ** x;
+ this.speedControl.reverseTransform = (x) => Math.log2(x);
+
+ this.speedControl.addEventListener("input", () =>
+ this.player?.setSpeed(this.speedControl.value)
+ );
+
this.shadowRoot.appendChild(this.firstButton);
this.shadowRoot.appendChild(this.stopButton);
+ this.shadowRoot.appendChild(this.speedControl);
this.shadowRoot.appendChild(repeatLabel);
- this.updateCallback = () => update(this);
update(this);
this.playButton.addEventListener("click", () => this.player?.play());
@@ -108,6 +124,7 @@ class PlayerControls extends HTMLElement {
this.player?.removeEventListener("stateChange", this.updateCallback);
PLAYER.set(this, player);
this.player?.addEventListener("stateChange", this.updateCallback);
+ this.player?.setSpeed(this.speedControl?.value || 1);
update(this);
}
}
diff --git a/lib/player/player.js b/lib/player/player.js
index f55414d..58d6f02 100644
--- a/lib/player/player.js
+++ b/lib/player/player.js
@@ -61,6 +61,11 @@ class Player extends EventTarget {
this.state = State.empty;
this.repeat = false;
this.reset();
+ this.speed = 1;
+ }
+
+ setSpeed(speed) {
+ this.speed = speed;
}
setRepeat(repeat) {
@@ -191,7 +196,7 @@ class Player extends EventTarget {
// 'ended' message is recieved and we call stop.
wrap(lastSource) {
this.beaterator?.toStart();
- const next = this.beaterator?.next();
+ const next = this.beaterator?.next(this.speed);
lastSource.then((source) =>
source.addEventListener("ended", () => {
// if we're not repeating (or we are, but there was nothing next) stop.
@@ -204,7 +209,7 @@ class Player extends EventTarget {
}
// handle the base case by retrieving the next beat as a default argument.
- queue(beat = this.beaterator?.next()) {
+ queue(beat = this.beaterator?.next(this.speed)) {
try {
if (!beat) {
// if somehow we pass a non-beat here, there is very little we can do -
@@ -217,7 +222,8 @@ class Player extends EventTarget {
let sourcePromise = this.schedule(beat);
return sourcePromise.then((source) => {
const realDuration = source.buffer.length / this.audioCtx.sampleRate;
- let next = this.beaterator?.next() || this.wrap(sourcePromise);
+ let next =
+ this.beaterator?.next(this.speed) || this.wrap(sourcePromise);
// schedule up any beats that need to start before this sample
// finishes. It's a dumb edge case, but possible if a click
// sample with long silence is used, or if an extreme tempo
@@ -231,7 +237,7 @@ class Player extends EventTarget {
// which keeps it _tested_, and that has value in itself.
while (next?.time < time + realDuration * 5) {
sourcePromise = this.schedule(next);
- next = this.beaterator?.next() || this.wrap(sourcePromise);
+ next = this.beaterator?.next(this.speed) || this.wrap(sourcePromise);
}
// normal path: when this beat is done playing, prepare the next one.
diff --git a/lib/track/beaterator.js b/lib/track/beaterator.js
index 6925a51..d23de66 100644
--- a/lib/track/beaterator.js
+++ b/lib/track/beaterator.js
@@ -42,7 +42,7 @@ class Beaterator {
return !!filteredNext(this);
}
- next() {
+ next(speed = 1) {
const nextBeat = filteredNext(this);
if (nextBeat === null) {
return null;
@@ -50,7 +50,7 @@ class Beaterator {
this.lastBeat = nextBeat;
const { soundSpec, duration } = nextBeat;
const time = this.nextTime;
- this.nextTime += duration;
+ this.nextTime += duration / speed;
return { soundSpec, time };
}
}