Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Exposes speed control for playback #58

Merged
merged 1 commit into from
Mar 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 251 additions & 0 deletions app/components/labeledSlider.js
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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);
19 changes: 18 additions & 1 deletion app/components/playerControls.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.

import "./playerButton.js";
import "./labeledSlider.js";
import { State as PlayerState } from "../../lib/player/player.js";

const PLAYER = new WeakMap();
Expand Down Expand Up @@ -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";
Expand All @@ -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());
Expand All @@ -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);
}
}
Expand Down
14 changes: 10 additions & 4 deletions lib/player/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand All @@ -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 -
Expand All @@ -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
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions lib/track/beaterator.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,15 @@ class Beaterator {
return !!filteredNext(this);
}

next() {
next(speed = 1) {
const nextBeat = filteredNext(this);
if (nextBeat === null) {
return null;
}
this.lastBeat = nextBeat;
const { soundSpec, duration } = nextBeat;
const time = this.nextTime;
this.nextTime += duration;
this.nextTime += duration / speed;
return { soundSpec, time };
}
}
Expand Down