diff --git a/README.md b/README.md index 980dafb..5534b3c 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/static/js/i18n.js b/static/js/i18n.js index 9f707b2..0f7fd48 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -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", @@ -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", @@ -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}", @@ -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", @@ -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}の音量", @@ -1587,6 +1590,9 @@ const ja = { "speed.group": "速度", "pitch.group": "全体のキー", "pitch.resetTitle": "すべてのトラックを元のキーに戻す", + "pitch.unavailable": "このトラックでは移調を利用できません", + "pitch.insecureOrigin": "ネットワーク接続では移調を利用できません", + "speed.pitchFollows": "オーディオエンジンがないため、速度を変えるとキーも変わります。", "pitch.downTitle": "半音下げる", "pitch.upTitle": "半音上げる", "speed.ariaLabel": "再生速度", @@ -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}音量", @@ -2139,6 +2144,9 @@ const zhHans = { "speed.group": "速度", "pitch.group": "全局变调", "pitch.resetTitle": "将所有音轨恢复为原调", + "pitch.unavailable": "此音轨不支持移调", + "pitch.insecureOrigin": "通过网络连接时无法使用移调", + "speed.pitchFollows": "没有音频引擎时,改变速度也会改变音高。", "pitch.downTitle": "降低半音", "pitch.upTitle": "升高半音", "speed.ariaLabel": "播放速度", @@ -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}", @@ -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", @@ -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}", @@ -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", @@ -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}", @@ -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", @@ -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}", @@ -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", @@ -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", @@ -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}", @@ -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", @@ -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} 볼륨", @@ -5632,6 +5650,9 @@ const ko = { "speed.group": "속도", "pitch.group": "전체 키", "pitch.resetTitle": "모든 트랙을 원래 키로 되돌려요", + "pitch.unavailable": "이 트랙에서는 조옮김을 쓸 수 없어요", + "pitch.insecureOrigin": "네트워크 연결에서는 조옮김을 쓸 수 없어요", + "speed.pitchFollows": "오디오 엔진이 없으면 속도를 바꿀 때 조성도 함께 바뀌어요.", "pitch.downTitle": "반음 내리기", "pitch.upTitle": "반음 올리기", "speed.ariaLabel": "재생 속도", diff --git a/static/js/mixer.js b/static/js/mixer.js index 5af9486..33ccd78 100644 --- a/static/js/mixer.js +++ b/static/js/mixer.js @@ -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 @@ -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 }); } diff --git a/static/js/pitchBus.js b/static/js/pitchBus.js index 8d6245b..0ea01c8 100644 --- a/static/js/pitchBus.js +++ b/static/js/pitchBus.js @@ -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:// 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"; +} diff --git a/static/js/state.js b/static/js/state.js index 66e8691..10a75b9 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -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"); diff --git a/static/js/transport.js b/static/js/transport.js index cb12422..5a2be58 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -7,6 +7,8 @@ import { pitchUpBtn, pitchValueEl, pitchResetBtn, + pitchWrap, + speedWrap, rulerTime, wavesGrid, loopRegionEl, playheadMarker, multitrack, audioEngine, totalDuration, loopEnabled, loopStart, loopEnd, masterVolume, waveScroll, waveCanvas, multitrackContainer, @@ -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 @@ -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"); + } } /** diff --git a/tests/e2e/transpose.spec.mjs b/tests/e2e/transpose.spec.mjs index 88f7001..e19ea64 100644 --- a/tests/e2e/transpose.spec.mjs +++ b/tests/e2e/transpose.spec.mjs @@ -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:// 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", /.+/); + }); +});