Skip to content
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,14 @@ The library is persistent by default (`STEMDECK_PERSIST_LIBRARY=1`), so tracks a

**Demucs runs on CPU only:** check the startup log for `device=mps` or `device=cuda`. If you see `cpu`, your torch install may be CPU-only.

**Transpose is greyed out on another machine:** the pitch stage is built on `AudioWorklet`, which browsers only expose on a *secure context*. `https://` and `localhost` count. A plain `http://192.168.x.x` does not, so a client opening StemDeck over the network is never given the API and transpose cannot work there. Changing the speed still works on such a client, but it resamples instead, so the key moves with it.

Three ways to get a secure context, in order of least effort:

- **Tunnel to localhost.** On the client: `ssh -N -L 8000:localhost:8000 user@host`, then open `http://localhost:8000`. The origin is now localhost, so everything works, including transpose.
- **Tailscale Serve.** `tailscale serve 8000` on the host publishes StemDeck on your tailnet over real HTTPS with a genuine certificate, no warnings and nothing to install on the client beyond Tailscale itself. Note the plain Tailscale IP (`100.x.y.z`) is *not* a secure context; it has to go through `serve`.
- **Any HTTPS reverse proxy** in front of StemDeck: Caddy, nginx, or a tunnel like Cloudflare Tunnel.

**Page reloaded mid-job:** the job keeps running server-side. Wait for it to finish, then resubmit.

**`./run.sh: Permission denied`:** run `chmod +x run.sh`.
Expand Down
41 changes: 31 additions & 10 deletions static/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,6 @@ const en = {
"aria.laneKeyDown": "Transpose {name} down one semitone",
"mixer.key.title": "Transpose this lane: {n} semitones",
"mixer.key.drumsLocked": "Drums are never transposed",
"mixer.key.unavailable": "Transpose needs Web Audio",
"aria.soloOnly": "Solo only {name}",
"aria.download": "Download {name}",
"aria.volume": "{name} volume",
Expand Down Expand Up @@ -426,6 +425,9 @@ const en = {
"speed.group": "Speed",
"pitch.group": "Global key",
"pitch.resetTitle": "Put every lane back in the original key",
"pitch.unavailable": "Transpose is not available for this track",
"pitch.insecureOrigin": "Transpose is not available over a network connection",
"speed.pitchFollows": "Without the audio engine, changing the speed also changes the key.",
"pitch.downTitle": "Transpose down a semitone",
"pitch.upTitle": "Transpose up a semitone",
"speed.ariaLabel": "Playback speed",
Expand Down Expand Up @@ -851,7 +853,6 @@ const pl = {
"aria.laneKeyDown": "Transponuj {name} o półton w dół",
"mixer.key.title": "Transpozycja tej ścieżki: {n} półtonu",
"mixer.key.drumsLocked": "Perkusja nigdy nie jest transponowana",
"mixer.key.unavailable": "Transpozycja wymaga Web Audio",
"aria.soloOnly": "Tylko solo: {name}",
"aria.download": "Pobierz: {name}",
"aria.volume": "Głośność: {name}",
Expand Down Expand Up @@ -1011,6 +1012,9 @@ const pl = {
"speed.group": "Prędkość",
"pitch.group": "Tonacja globalna",
"pitch.resetTitle": "Przywróć wszystkim ścieżkom oryginalną tonację",
"pitch.unavailable": "Transpozycja jest niedostępna dla tego utworu",
"pitch.insecureOrigin": "Transpozycja nie jest dostępna przez połączenie sieciowe",
"speed.pitchFollows": "Bez silnika audio zmiana tempa zmienia także tonację.",
"pitch.downTitle": "Transponuj w dół o półton",
"pitch.upTitle": "Transponuj w górę o półton",
"speed.ariaLabel": "Prędkość odtwarzania",
Expand Down Expand Up @@ -1428,7 +1432,6 @@ const ja = {
"aria.laneKeyDown": "{name}を半音下げる",
"mixer.key.title": "このトラックの移調: {n} 半音",
"mixer.key.drumsLocked": "ドラムは移調されません",
"mixer.key.unavailable": "移調には Web Audio が必要です",
"aria.soloOnly": "{name}のみソロ",
"aria.download": "{name}をダウンロード",
"aria.volume": "{name}の音量",
Expand Down Expand Up @@ -1587,6 +1590,9 @@ const ja = {
"speed.group": "速度",
"pitch.group": "全体のキー",
"pitch.resetTitle": "すべてのトラックを元のキーに戻す",
"pitch.unavailable": "このトラックでは移調を利用できません",
"pitch.insecureOrigin": "ネットワーク接続では移調を利用できません",
"speed.pitchFollows": "オーディオエンジンがないため、速度を変えるとキーも変わります。",
"pitch.downTitle": "半音下げる",
"pitch.upTitle": "半音上げる",
"speed.ariaLabel": "再生速度",
Expand Down Expand Up @@ -1980,7 +1986,6 @@ const zhHans = {
"aria.laneKeyDown": "将{name}降低半音",
"mixer.key.title": "此音轨移调:{n} 个半音",
"mixer.key.drumsLocked": "鼓声部不会被移调",
"mixer.key.unavailable": "移调需要 Web Audio",
"aria.soloOnly": "仅独奏{name}",
"aria.download": "下载{name}",
"aria.volume": "{name}音量",
Expand Down Expand Up @@ -2139,6 +2144,9 @@ const zhHans = {
"speed.group": "速度",
"pitch.group": "全局变调",
"pitch.resetTitle": "将所有音轨恢复为原调",
"pitch.unavailable": "此音轨不支持移调",
"pitch.insecureOrigin": "通过网络连接时无法使用移调",
"speed.pitchFollows": "没有音频引擎时,改变速度也会改变音高。",
"pitch.downTitle": "降低半音",
"pitch.upTitle": "升高半音",
"speed.ariaLabel": "播放速度",
Expand Down Expand Up @@ -2532,7 +2540,6 @@ const de = {
"aria.laneKeyDown": "{name} um einen Halbton tiefer transponieren",
"mixer.key.title": "Transposition dieser Spur: {n} Halbtöne",
"mixer.key.drumsLocked": "Schlagzeug wird nie transponiert",
"mixer.key.unavailable": "Transposition benötigt Web Audio",
"aria.soloOnly": "Nur {name} solo",
"aria.download": "{name} herunterladen",
"aria.volume": "Lautstärke von {name}",
Expand Down Expand Up @@ -2692,6 +2699,9 @@ const de = {
"speed.group": "Geschwindigkeit",
"pitch.group": "Globale Tonart",
"pitch.resetTitle": "Alle Spuren in die Originaltonart zurücksetzen",
"pitch.unavailable": "Transposition ist für diesen Track nicht verfügbar",
"pitch.insecureOrigin": "Transposition ist über eine Netzwerkverbindung nicht verfügbar",
"speed.pitchFollows": "Ohne die Audio-Engine ändert sich mit dem Tempo auch die Tonart.",
"pitch.downTitle": "Einen Halbton tiefer transponieren",
"pitch.upTitle": "Einen Halbton höher transponieren",
"speed.ariaLabel": "Wiedergabegeschwindigkeit",
Expand Down Expand Up @@ -3095,7 +3105,6 @@ const pt = {
"aria.laneKeyDown": "Transpor {name} um semitom para baixo",
"mixer.key.title": "Transposição desta faixa: {n} semitons",
"mixer.key.drumsLocked": "A bateria nunca é transposta",
"mixer.key.unavailable": "A transposição precisa de Web Audio",
"aria.soloOnly": "Apenas solo {name}",
"aria.download": "Baixar {name}",
"aria.volume": "Volume de {name}",
Expand Down Expand Up @@ -3255,6 +3264,9 @@ const pt = {
"speed.group": "Velocidade",
"pitch.group": "Tom global",
"pitch.resetTitle": "Retornar todas as faixas ao tom original",
"pitch.unavailable": "A transposição não está disponível para esta faixa",
"pitch.insecureOrigin": "A transposição não está disponível através de uma conexão de rede",
"speed.pitchFollows": "Sem o motor de áudio, mudar a velocidade também muda o tom.",
"pitch.downTitle": "Transpor um semitom para baixo",
"pitch.upTitle": "Transpor um semitom para cima",
"speed.ariaLabel": "Velocidade de reprodução",
Expand Down Expand Up @@ -3660,7 +3672,6 @@ const id = {
"aria.laneKeyDown": "Turunkan {name} satu semiton",
"mixer.key.title": "Transposisi trek ini: {n} semiton",
"mixer.key.drumsLocked": "Drum tidak pernah ditransposisi",
"mixer.key.unavailable": "Transposisi memerlukan Web Audio",
"aria.soloOnly": "Hanya solo {name}",
"aria.download": "Unduh {name}",
"aria.volume": "Volume {name}",
Expand Down Expand Up @@ -3819,6 +3830,9 @@ const id = {
"speed.group": "Kecepatan",
"pitch.group": "Nada dasar global",
"pitch.resetTitle": "Kembalikan semua trek ke nada aslinya",
"pitch.unavailable": "Transposisi tidak tersedia untuk trek ini",
"pitch.insecureOrigin": "Transposisi tidak tersedia melalui koneksi jaringan",
"speed.pitchFollows": "Tanpa mesin audio, mengubah kecepatan juga mengubah nada.",
"pitch.downTitle": "Turunkan satu semitone",
"pitch.upTitle": "Naikkan satu semitone",
"speed.ariaLabel": "Kecepatan putar",
Expand Down Expand Up @@ -4212,7 +4226,6 @@ const fr = {
"aria.laneKeyDown": "Transposer {name} d'un demi-ton vers le bas",
"mixer.key.title": "Transposition de cette piste : {n} demi-tons",
"mixer.key.drumsLocked": "La batterie n'est jamais transposée",
"mixer.key.unavailable": "La transposition nécessite Web Audio",
"aria.soloOnly": "Solo uniquement {name}",
"aria.download": "Télécharger {name}",
"aria.volume": "Volume {name}",
Expand Down Expand Up @@ -4372,6 +4385,9 @@ const fr = {
"speed.group": "Vitesse",
"pitch.group": "Tonalité globale",
"pitch.resetTitle": "Remettre toutes les pistes dans la tonalité d'origine",
"pitch.unavailable": "La transposition n'est pas disponible pour ce morceau",
"pitch.insecureOrigin": "La transposition n'est pas disponible via une connexion réseau",
"speed.pitchFollows": "Sans le moteur audio, changer la vitesse change aussi la tonalité.",
"pitch.downTitle": "Transposer d'un demi-ton vers le bas",
"pitch.upTitle": "Transposer d'un demi-ton vers le haut",
"speed.ariaLabel": "Vitesse de lecture",
Expand Down Expand Up @@ -4734,6 +4750,7 @@ const fr = {
// through pt (see FALLBACK), so the two variants cannot drift and a key
// added to pt later is picked up here rather than reverting to English.
const ptPT = {
"pitch.insecureOrigin": "A transposição não está disponível através de uma ligação de rede",
"position.loopScrollHint": "Desloque para ajustar",
"settings.autoDelete.title": "Eliminar automaticamente as faixas concluídas",
"pitch.resetTitle": "Repor todas as faixas no tom original",
Expand Down Expand Up @@ -4887,7 +4904,6 @@ const es = {
"aria.laneKeyDown": "Transponer {name} un semitono hacia abajo",
"mixer.key.title": "Transposición de esta pista: {n} semitonos",
"mixer.key.drumsLocked": "La batería nunca se transpone",
"mixer.key.unavailable": "La transposición necesita Web Audio",
"aria.soloOnly": "Solo únicamente {name}",
"aria.download": "Descargar {name}",
"aria.volume": "Volumen de {name}",
Expand Down Expand Up @@ -5047,6 +5063,9 @@ const es = {
"speed.group": "Velocidad",
"pitch.group": "Tonalidad global",
"pitch.resetTitle": "Devolver todas las pistas al tono original",
"pitch.unavailable": "La transposición no está disponible para esta pista",
"pitch.insecureOrigin": "La transposición no está disponible a través de una conexión de red",
"speed.pitchFollows": "Sin el motor de audio, cambiar la velocidad también cambia el tono.",
"pitch.downTitle": "Transponer un semitono hacia abajo",
"pitch.upTitle": "Transponer un semitono hacia arriba",
"speed.ariaLabel": "Velocidad",
Expand Down Expand Up @@ -5473,7 +5492,6 @@ const ko = {
"aria.laneKeyDown": "{name} 반음 내리기",
"mixer.key.title": "이 트랙 조옮김: {n} 반음",
"mixer.key.drumsLocked": "드럼은 조옮김하지 않아요",
"mixer.key.unavailable": "조옮김에는 Web Audio가 필요해요",
"aria.soloOnly": "{name}만 솔로",
"aria.download": "{name} 다운로드",
"aria.volume": "{name} 볼륨",
Expand Down Expand Up @@ -5632,6 +5650,9 @@ const ko = {
"speed.group": "속도",
"pitch.group": "전체 키",
"pitch.resetTitle": "모든 트랙을 원래 키로 되돌려요",
"pitch.unavailable": "이 트랙에서는 조옮김을 쓸 수 없어요",
"pitch.insecureOrigin": "네트워크 연결에서는 조옮김을 쓸 수 없어요",
"speed.pitchFollows": "오디오 엔진이 없으면 속도를 바꿀 때 조성도 함께 바뀌어요.",
"pitch.downTitle": "반음 내리기",
"pitch.upTitle": "반음 올리기",
"speed.ariaLabel": "재생 속도",
Expand Down
7 changes: 5 additions & 2 deletions static/js/mixer.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from "./state.js";
import { storeGet, storeSetDebounced } from "./utils.js";
import { t } from "./i18n.js";
import { PITCH_MAX, PITCH_MIN, clampPitch } from "./pitchBus.js";
import { PITCH_MAX, PITCH_MIN, clampPitch, pitchBlockedKey } from "./pitchBus.js";

function defaultMixerEntry() {
// `pitch` is the key this lane is actually in, in semitones from the
Expand Down Expand Up @@ -140,7 +140,10 @@ export function refreshLaneKeyVisual(name) {
// availability left a drum lane explaining the wrong thing after the engine
// came up, because whichever ran last won and neither restored the other.
if (locked) wrap.title = t("mixer.key.drumsLocked");
else if (unsupported) wrap.title = t("mixer.key.unavailable");
// Same reason as the transport control, so the same sentence: a lane saying
// "needs Web Audio" while the group above it names the real cause would just
// be two answers to one question (#552).
else if (unsupported) wrap.title = t(pitchBlockedKey());
else wrap.title = t("mixer.key.title", { n: signed });
}

Expand Down
19 changes: 19 additions & 0 deletions static/js/pitchBus.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,22 @@ export function effectivePitch(name, semitones, pitchable) {
export function inputForPitch(semitones) {
return clampPitch(semitones) + ZERO_INPUT;
}

/**
* The i18n key explaining why transpose is unavailable.
*
* Here for the same reason the input mapping is: the transport control and the
* lane steppers both have to say it, and two copies of this answer would drift
* into two different answers to one question.
*
* AudioWorklet is a secure-context API. A browser on http://<lan-ip> is never
* given it, so the SoundTouch stage cannot be built at all -- which is the
* common case by far, StemDeck hosted on the network and opened from another
* machine. "Needs Web Audio" sent those people looking for a missing browser
* feature when Web Audio was working fine and the origin was the problem
* (#552). Only on an already-secure origin is a genuine worklet failure the
* remaining explanation.
*/
export function pitchBlockedKey() {
return window.isSecureContext === false ? "pitch.insecureOrigin" : "pitch.unavailable";
}
5 changes: 5 additions & 0 deletions static/js/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export const pitchDownBtn = $("t-pitch-down");
export const pitchUpBtn = $("t-pitch-up");
export const pitchValueEl = $("t-pitch-value");
export const pitchResetBtn = $("t-pitch-reset");
// The group wrappers, not the buttons: a disabled button does not fire
// pointer events in every browser, so a title on it is unreadable exactly
// when it has something to say.
export const pitchWrap = $("t-pitch-wrap");
export const speedWrap = $("t-speed-wrap");
export const npArt = $("np-art");
export const npThumb = $("np-thumb");

Expand Down
18 changes: 18 additions & 0 deletions static/js/transport.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
pitchUpBtn,
pitchValueEl,
pitchResetBtn,
pitchWrap,
speedWrap,
rulerTime, wavesGrid, loopRegionEl, playheadMarker,
multitrack, audioEngine, totalDuration, loopEnabled, loopStart, loopEnd, masterVolume,
waveScroll, waveCanvas, multitrackContainer,
Expand All @@ -26,6 +28,7 @@ import { applyMix, nudgeAllLanePitches, resetAllLanePitches } from "./mixer.js";
import { isDownbeatIndex, getBeats as getGridBeats, getBars as getGridBars } from "./beatgrid.js";
import { computeCountIn } from "./metronome.js";
import { t } from "./i18n.js";
import { pitchBlockedKey } from "./pitchBus.js";

// Zoom range. 1 is the whole track fitted to the panel; there is nothing below
// it to show, so it is the floor rather than a soft default. 5 is the ceiling
Expand Down Expand Up @@ -930,6 +933,21 @@ function renderPitch() {
if (pitchDownBtn) pitchDownBtn.disabled = !_pitchAvailable || _pitchSemitones <= PITCH_MIN;
if (pitchUpBtn) pitchUpBtn.disabled = !_pitchAvailable || _pitchSemitones >= PITCH_MAX;
if (pitchResetBtn) pitchResetBtn.disabled = !_pitchAvailable;

// Three dead buttons still wearing "Transpose up a semitone" explain nothing.
// The reason lives on the group, because a disabled button does not reliably
// fire the pointer events a tooltip needs.
if (pitchWrap) {
if (_pitchAvailable) pitchWrap.removeAttribute("title");
else pitchWrap.title = t(pitchBlockedKey());
}
// Without the pitch stage the sources resample instead, so speed drags the
// key with it. That is the one degradation here that is otherwise silent:
// the control still works, it just quietly does something else (#552).
if (speedWrap) {
if (_pitchAvailable) speedWrap.removeAttribute("title");
else speedWrap.title = t("speed.pitchFollows");
}
}

/**
Expand Down
56 changes: 56 additions & 0 deletions tests/e2e/transpose.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -329,3 +329,59 @@ test.describe("per-lane transpose", () => {
await expect(laneUp(page, "vocals")).toBeDisabled();
});
});

// AudioWorklet is a secure-context API, so a client reaching a networked
// StemDeck over http://<lan-ip> is never given it and transpose cannot work
// there at all. Nothing can be done about that in the page; what the page owes
// the user is the actual reason, because "needs Web Audio" sent people looking
// for a missing browser feature while Web Audio was working fine (#552).
//
// isSecureContext is read-only, so it is redefined before any app script runs.
// The engine is separately denied its worklet: on the localhost origin these
// tests run from, the real one would load and there would be nothing to
// explain.
test.describe("transpose over an insecure origin", () => {
const asInsecureClient = (page) =>
page.addInitScript(() => {
Object.defineProperty(window, "isSecureContext", { value: false, configurable: true });
window.AudioWorkletNode = undefined;
});

test("the transport control says the connection is the problem", async ({ page }) => {
await asInsecureClient(page);
await openStudio(page);
await expect(down(page)).toBeDisabled();
// The reason sits on the group: a disabled button does not reliably fire
// the pointer events a tooltip needs.
await expect(page.locator("#t-pitch-wrap")).toHaveAttribute("title", /network connection/i);
});

test("a lane stepper gives the same answer, not a different one", async ({ page }) => {
await asInsecureClient(page);
await openStudio(page);
await expect(laneKey(page, "vocals")).toHaveAttribute("title", /network connection/i);
});

test("the speed control admits it will move the key too", async ({ page }) => {
await asInsecureClient(page);
await openStudio(page);
// Speed still works here, it just resamples -- the one degradation that is
// otherwise completely silent.
await expect(page.locator("#t-speed-wrap")).toHaveAttribute("title", /key|pitch/i);
});

test("a secure origin that still cannot build the worklet says something else", async ({ page }) => {
await page.addInitScript(() => { window.AudioWorkletNode = undefined; });
await openStudio(page);
const title = await page.locator("#t-pitch-wrap").getAttribute("title");
expect(title).toBeTruthy();
expect(title).not.toMatch(/network connection/i);
});

test("nothing is explained when transpose works", async ({ page }) => {
await openStudio(page);
await expect(up(page)).toBeEnabled();
await expect(page.locator("#t-pitch-wrap")).not.toHaveAttribute("title", /.+/);
await expect(page.locator("#t-speed-wrap")).not.toHaveAttribute("title", /.+/);
});
});
Loading