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 }; } }