chore: import upstream snapshot with attribution
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:32:09 +08:00
commit 8f10353f0c
135 changed files with 37786 additions and 0 deletions
+443
View File
@@ -0,0 +1,443 @@
/* ───────────── Top bar ───────────── */
.app:not(.is-import) .appbar {
padding-top: 12px;
padding-bottom: 14px;
}
/* ─── Collapsed icon strip (double-click to toggle) ─── */
/* Expandable body wrapper — same grid-row trick as .widget-body */
.appbar-body {
display: grid;
grid-template-rows: 1fr;
overflow: hidden;
opacity: 1;
width: 100%;
min-height: 0;
transition: grid-template-rows 220ms ease, opacity 180ms ease;
}
.appbar-body-inner {
min-height: 0;
width: 100%;
display: grid;
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
align-items: stretch;
gap: 22px;
}
/* Strip wrapper — collapsed by default */
.appbar-icon-strip {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
opacity: 0;
pointer-events: none;
min-height: 0; /* prevent flex parent from reserving space when row is 0fr */
transition: grid-template-rows 220ms ease, opacity 200ms ease 20ms;
}
.appbar-strip-inner {
min-height: 0;
display: flex;
align-items: center;
gap: 5px;
margin: 0 auto;
}
/* Collapsed state */
.app.appbar-collapsed .appbar-body {
grid-template-rows: 0fr;
opacity: 0;
pointer-events: none;
height: 0; /* force flex child to zero — grid-template-rows alone isn't enough in a flex container */
}
.app.appbar-collapsed .appbar-icon-strip {
grid-template-rows: 1fr;
opacity: 1;
pointer-events: auto;
width: 100%;
height: 40px;
align-items: center;
}
.app.appbar-collapsed .appbar {
min-height: 52px;
height: 52px;
padding: 6px 14px;
justify-content: center;
align-items: center;
}
.app.appbar-collapsed .appbar-strip-inner {
height: 40px;
align-items: center;
}
.strip-sq {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--glass-line);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
transition: background 120ms, border-color 120ms, opacity 120ms;
}
.strip-sq:hover { background: rgba(255,255,255,0.08) !important; }
.strip-sq-process:hover { filter: brightness(1.1); }
.strip-sq-brand {
font-family: var(--font-mono);
font-weight: 700;
font-size: 12px;
color: var(--ink);
background: rgba(255,255,255,0.05);
letter-spacing: -0.03em;
}
.strip-sq-url {
background: rgba(255,255,255,0.03);
color: var(--ink-faint);
margin-right: 3px;
}
.strip-sq-stems {
display: flex;
align-items: center;
gap: 5px;
}
.strip-sq-stem {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
transition: opacity 120ms, background 120ms;
}
.strip-sq-stem:hover { opacity: 0.85 !important; }
.strip-sq-stem.inactive { opacity: 0.2; }
.strip-sq-process {
background: linear-gradient(180deg, var(--gold-bright) 0%, var(--gold) 100%);
color: #17130c;
border-color: transparent;
margin-left: 4px;
}
.strip-sq-process.loading {
cursor: progress;
filter: saturate(0.92);
}
.strip-sq-process.loading svg {
display: none;
}
.strip-sq-process.loading::before {
content: "";
width: 16px;
height: 16px;
flex: 0 0 16px;
border-radius: 50%;
background: conic-gradient(currentColor 0deg 270deg, transparent 270deg 360deg);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
animation: spin 0.85s linear infinite;
}
.brand-version {
font-size: 11px;
color: rgba(216,222,230,0.35);
margin-top: 3px;
display: flex;
align-items: center;
gap: 6px;
}
.brand-update-chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 5px;
border-radius: 3px;
background: rgba(100, 200, 120, 0.10);
border: 1px solid rgba(100, 200, 120, 0.22);
color: #6dcc82;
font-size: 9px;
text-decoration: none;
}
.brand-update-chip:hover {
background: rgba(100, 200, 120, 0.2);
color: #88dfa0;
}
.appbar {
display: flex;
flex-direction: column;
padding: 20px 24px 22px;
border: 1px solid var(--glass-line);
border-radius: 16px;
background:
radial-gradient(circle at 82% -20%, rgba(216, 168, 74, 0.11), transparent 20rem),
linear-gradient(180deg, rgba(27, 39, 48, 0.76), rgba(13, 22, 30, 0.84));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.045),
var(--shadow-panel);
transition: padding 220ms ease;
}
.brand-group {
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
min-width: 0;
padding-right: 8px;
}
.brand-row {
display: flex;
align-items: center;
gap: 8px;
}
.brand {
margin: 0;
font-family: var(--font-mono);
font-weight: 700;
font-size: 24px;
line-height: 1;
color: var(--ink);
letter-spacing: -0.01em;
}
.brand-logo {
display: block;
width: 190px;
height: auto;
}
.beta-badge {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 5px;
border: 1px solid rgba(216, 168, 74, 0.65);
border-radius: 6px;
background: var(--gold-soft);
color: var(--gold-bright);
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.brand-sub {
font-size: 13px;
color: var(--ink-dim);
white-space: nowrap;
}
.post-urlbar,
.post-process-btn {
display: none;
}
.appbar-form {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.appbar-import-panel {
min-width: 0;
}
.appbar-import-panel .import-input-row {
grid-template-columns: minmax(0, 1fr) clamp(140px, 14vw, 220px);
gap: clamp(12px, 2vw, 28px);
padding-bottom: 22px;
}
.appbar-import-panel .url-wrap {
min-height: 58px;
padding: 0 20px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 11px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent),
rgba(3, 9, 14, 0.72);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.035),
inset 0 -1px 0 rgba(0, 0, 0, 0.32),
0 0 0 0 rgba(216, 168, 74, 0);
transition:
border-color var(--t-base) var(--easing),
box-shadow var(--t-base) var(--easing),
background-color var(--t-base) var(--easing);
}
.appbar-import-panel .url-wrap:focus-within {
border-color: rgba(216, 168, 74, 0.56);
background-color: rgba(6, 13, 19, 0.86);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.045),
inset 0 -1px 0 rgba(0, 0, 0, 0.34),
0 0 0 3px rgba(216, 168, 74, 0.08),
0 14px 34px rgba(0, 0, 0, 0.22);
}
.appbar-import-panel .url-prefix {
color: #cbd2dc;
opacity: 0.92;
}
.appbar-import-panel input[type="url"] {
font-size: clamp(15px, 0.95vw, 18px);
height: 100%;
color: #edf0f4;
caret-color: var(--gold-bright);
letter-spacing: 0;
}
.appbar-import-panel .btn-primary {
height: 58px;
min-width: 0;
width: 100%;
border-radius: 11px;
font-size: clamp(14px, 0.9vw, 16px);
}
.appbar-import-panel .stem-choice-row {
grid-template-columns: clamp(110px, 11vw, 156px) repeat(6, minmax(0, 1fr));
gap: clamp(8px, 1vw, 14px);
padding-top: 20px;
}
.appbar-import-panel .stem-choice-row > span {
font-size: clamp(12px, 0.8vw, 14px);
}
.appbar-import-panel .stem-choice {
min-height: 48px;
padding: 0 14px;
border-radius: 10px;
font-size: clamp(12px, 0.8vw, 14px);
background:
linear-gradient(180deg, color-mix(in srgb, currentColor 9%, transparent), transparent),
rgba(8, 15, 22, 0.42);
}
.url-wrap {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
min-width: 0;
cursor: text;
}
.url-prefix {
flex-shrink: 0;
color: #d7dbe2;
}
.appbar-form input[type="url"],
.appbar-import-panel input[type="url"],
.import-card input[type="url"] {
flex: 1;
background: transparent;
border: 0;
outline: 0;
color: var(--ink);
font-family: var(--font-mono);
font-size: 19px;
padding: 0;
min-width: 0;
appearance: none;
box-shadow: none;
}
.appbar-form input[type="url"]::placeholder,
.appbar-import-panel input[type="url"]::placeholder,
.import-card input[type="url"]::placeholder {
color: #9da3ad;
opacity: 0.78;
}
.appbar-import-panel input[type="url"]::selection {
background: rgba(216, 168, 74, 0.28);
color: #fff6de;
}
.appbar-import-panel input[type="url"] {
font-size: clamp(15px, 0.95vw, 18px);
color: #edf0f4;
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: 56px;
min-width: 150px;
background: linear-gradient(180deg, var(--gold-bright) 0%, var(--gold) 100%);
color: #17130c;
border: 0;
border-radius: var(--radius);
padding: 0 24px;
font-family: var(--font-mono);
font-size: 16px;
font-weight: 700;
cursor: pointer;
position: relative;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.38),
0 16px 36px rgba(216, 168, 74, 0.18);
transition:
filter var(--t-base) var(--easing),
transform var(--t-fast) var(--easing);
}
.btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn-primary:active:not(:disabled) {
transform: scale(0.98);
}
.btn-primary:disabled {
opacity: 0.82;
cursor: progress;
}
.btn-primary.loading {
filter: saturate(0.92);
}
.btn-primary.loading .icon {
display: none;
}
.btn-primary.loading::before {
content: "";
width: 18px;
height: 18px;
flex: 0 0 18px;
border-radius: 50%;
background: conic-gradient(currentColor 0deg 270deg, transparent 270deg 360deg);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
animation: spin 0.85s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.btn-primary.loading::before {
animation-duration: 1.8s;
}
}
+110
View File
@@ -0,0 +1,110 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
background:
radial-gradient(circle at 50% 0%, rgba(216, 168, 74, 0.1), transparent 34rem),
radial-gradient(circle at 0% 45%, rgba(216, 168, 74, 0.055), transparent 28rem),
radial-gradient(circle at 100% 45%, rgba(216, 168, 74, 0.055), transparent 28rem),
linear-gradient(180deg, #081016 0%, #050a0f 100%);
color: var(--ink);
font-family: var(--font-mono);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.hidden {
display: none !important;
}
:focus {
outline: 0;
}
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
.surface-panel {
border: 1px solid var(--glass-line);
background:
linear-gradient(180deg, rgba(28, 39, 48, 0.82), rgba(15, 23, 31, 0.86));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.045),
var(--shadow-panel);
}
.app {
max-width: 1640px;
margin: 0 auto;
min-height: 100vh;
padding: 24px 28px 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.app:not(.is-import) {
max-width: none;
width: 100%;
height: 100vh;
min-height: 0;
padding: 16px 29px 0;
gap: 8px;
overflow: hidden;
}
.app.is-import .now-playing,
.app.is-import #lanes {
display: none;
}
.app:not(.is-import) .import-page,
.app:not(.is-import) .site-footer {
display: none;
}
.site-footer {
display: flex;
align-items: center;
gap: 24px;
margin-top: auto;
padding: 18px 14px 22px;
color: #8d929a;
font-size: 14px;
}
.site-footer > span {
margin-right: auto;
}
.site-footer nav {
display: flex;
align-items: center;
gap: 26px;
}
.site-footer a {
color: inherit;
text-decoration: none;
}
.site-footer a:hover {
color: var(--ink);
}
.help-btn {
height: 42px;
padding: 0 16px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: var(--radius);
background: rgba(18, 26, 34, 0.75);
color: var(--ink);
font: inherit;
}
+493
View File
@@ -0,0 +1,493 @@
/* ──────────────────────────────────────────────
catalog.css — Left catalog / history panel
────────────────────────────────────────────── */
/* ─── Panel shell ─── */
.catalog {
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(28,39,48,0.82), rgba(15,23,31,0.86));
border: 1px solid var(--glass-line);
border-radius: 12px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.045), var(--shadow-panel);
padding: 12px 10px;
gap: 10px;
height: 100%;
min-height: 0;
position: relative;
top: 0;
overflow: hidden;
transition: padding 220ms ease;
}
.app.cat-collapsed .catalog { padding: 12px 8px; }
/* ─── Header (click to collapse) ─── */
.catalog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px 10px;
border-bottom: 1px solid rgba(157,170,182,0.1);
cursor: pointer;
user-select: none;
border-radius: 6px;
transition: background 120ms;
flex-shrink: 0;
}
.catalog-head:hover { background: rgba(31,43,52,0.55); }
.catalog-head h3 {
margin: 0;
font-family: var(--font-mono);
font-size: 17px;
font-weight: 600;
color: var(--ink);
white-space: nowrap;
}
.catalog-head .cat-count {
color: var(--ink-faint);
font-size: 11px;
flex: 1;
padding-left: 8px;
white-space: nowrap;
}
.cat-chevron {
width: 15px;
height: 15px;
color: var(--ink-faint);
transition: transform 220ms ease;
flex-shrink: 0;
}
/* Hide text content when collapsed */
.app.cat-collapsed .catalog-head h3,
.app.cat-collapsed .catalog-head .cat-count { display: none; }
.app.cat-collapsed .catalog-head { justify-content: center; padding: 10px 0; }
.app.cat-collapsed .cat-chevron { transform: rotate(180deg); }
/* ─── Search ─── */
.catalog-search {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(21,31,39,0.6);
color: var(--ink-faint);
font-size: 12px;
flex-shrink: 0;
}
.catalog-search svg { flex-shrink: 0; }
.catalog-search input {
min-width: 0;
width: 100%;
border: 0;
background: transparent;
color: var(--ink);
font: inherit;
font-size: 12px;
outline: none;
padding: 0;
}
.catalog-search input::placeholder {
color: var(--ink-faint);
}
/* ─── New folder button ─── */
.new-folder-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border: 1px dashed var(--line);
border-radius: 8px;
background: transparent;
color: var(--ink-dim);
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
transition: all 120ms;
width: 100%;
flex-shrink: 0;
}
.new-folder-btn:hover {
border-color: var(--gold-line);
color: var(--gold);
}
.new-folder-btn svg { width: 13px; height: 13px; flex-shrink: 0; }
/* ─── Catalog list ─── */
.catalog-list {
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
flex: 1;
padding-right: 2px;
}
/* ─── Folder ─── */
.folder { display: flex; flex-direction: column; }
.folder-head {
--folder-color: var(--gold);
display: flex;
align-items: center;
gap: 7px;
padding: 6px 8px;
border-radius: 7px;
cursor: pointer;
color: var(--ink-dim);
transition: background 120ms, color 120ms;
user-select: none;
}
.folder-head:hover { background: rgba(31,43,52,0.55); color: var(--ink); }
.folder.drop-target .folder-head {
background: rgba(216,168,74,0.10);
box-shadow: inset 0 0 0 1px var(--gold-line);
color: var(--ink);
}
.folder-head .f-chevron {
width: 11px; height: 11px;
transition: transform 180ms ease;
flex-shrink: 0;
color: var(--ink-faint);
}
.folder.collapsed .folder-head .f-chevron { transform: rotate(-90deg); }
.folder-head .f-icon {
width: 13px;
height: 13px;
flex-shrink: 0;
color: var(--folder-color);
}
.folder-head .f-name {
font-size: 12px;
font-weight: 500;
color: inherit;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: transparent;
border: none;
padding: 0;
font-family: inherit;
outline: none;
cursor: inherit;
}
.folder-head .f-name[contenteditable="true"] {
background: rgba(28,34,43,0.8);
padding: 1px 4px;
border-radius: 3px;
cursor: text;
}
.folder-head .f-count { font-size: 10.5px; color: var(--ink-faint); }
.folder-color-dot {
width: 13px;
height: 13px;
border: 1px solid rgba(255,255,255,0.22);
border-radius: 999px;
background: var(--folder-color);
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.22);
cursor: pointer;
padding: 0;
}
.folder-color-dot:hover,
.folder-color-dot:focus-visible {
border-color: rgba(255,255,255,0.82);
outline: none;
}
.folder-color-dot.active {
box-shadow: 0 0 0 2px rgba(255,255,255,0.18), inset 0 0 0 1px rgba(0,0,0,0.24);
border-color: rgba(255,255,255,0.86);
}
.folder-head .f-del {
width: 18px; height: 18px;
border: none; background: transparent;
color: var(--ink-faint);
cursor: pointer;
opacity: 0;
display: flex; align-items: center; justify-content: center;
border-radius: 4px;
padding: 0;
}
.folder-head:hover .f-del { opacity: 1; }
.folder-head .f-del:hover { background: rgba(21,31,39,0.8); color: var(--danger); }
.folder-editor-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
background: rgba(3, 8, 13, 0.42);
backdrop-filter: blur(2px);
}
.folder-editor {
width: min(300px, calc(100vw - 32px));
border: 1px solid var(--glass-line);
border-radius: 10px;
background: linear-gradient(180deg, rgba(28,39,48,0.98), rgba(13,21,29,0.98));
box-shadow: var(--shadow-panel);
padding: 12px;
color: var(--ink);
font-family: var(--font-mono);
}
.folder-editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
font-size: 13px;
font-weight: 600;
}
.folder-editor-close {
width: 24px;
height: 24px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--ink-faint);
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
}
.folder-editor-close:hover {
background: rgba(21,31,39,0.8);
color: var(--ink);
}
.folder-editor-field {
display: grid;
gap: 7px;
margin-bottom: 12px;
color: var(--ink-faint);
font-size: 10.5px;
}
.folder-editor-name {
width: 100%;
min-height: 34px;
border: 1px solid var(--line);
border-radius: 7px;
background: rgba(10, 17, 24, 0.78);
color: var(--ink);
font: inherit;
font-size: 12px;
padding: 0 10px;
outline: none;
}
.folder-editor-name:focus {
border-color: var(--gold-line);
box-shadow: 0 0 0 2px rgba(216,168,74,0.12);
}
.folder-editor-colors {
display: flex;
align-items: center;
gap: 9px;
}
.folder-editor-colors .folder-color-dot {
width: 22px;
height: 22px;
}
.folder-editor-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.folder-editor-actions button {
min-height: 30px;
border-radius: 7px;
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
padding: 0 11px;
}
.folder-editor-cancel {
border: 1px solid var(--line);
background: rgba(21,31,39,0.52);
color: var(--ink-dim);
}
.folder-editor-save {
border: 1px solid var(--gold-line);
background: rgba(216,168,74,0.16);
color: var(--gold);
}
.folder-body {
display: flex;
flex-direction: column;
gap: 3px;
padding: 3px 0 3px 14px;
margin-left: 7px;
border-left: 1px solid rgba(157,170,182,0.1);
}
.folder.collapsed .folder-body { display: none; }
.folder-empty {
color: var(--ink-faint);
font-size: 11px;
padding: 5px 8px;
font-style: italic;
}
/* ─── Track item ─── */
.cat-item {
display: flex;
gap: 9px;
align-items: center;
padding: 7px 8px;
border: 1px solid transparent;
border-radius: 9px;
cursor: pointer;
transition: background 120ms, border-color 120ms;
}
.cat-item:hover { background: rgba(31,43,52,0.55); border-color: var(--line); }
.cat-item.active { background: rgba(31,43,52,0.55); border-color: var(--gold-line); }
.cat-item.dragging { opacity: 0.4; }
.cat-item[draggable="true"] { cursor: grab; }
.cat-thumb {
width: 44px; height: 44px;
border-radius: 7px;
flex-shrink: 0;
overflow: hidden;
border: 1px solid var(--line);
background: rgba(21,31,39,0.8);
}
.cat-thumb svg,
.cat-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cat-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.cat-title {
color: var(--ink);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cat-sub {
color: var(--ink-faint);
font-size: 10.5px;
display: flex;
gap: 5px;
white-space: nowrap;
overflow: hidden;
}
.cat-sub .dot { opacity: 0.4; }
.cat-status {
width: 7px; height: 7px;
border-radius: 50%;
background: #5fbc56;
flex-shrink: 0;
}
.cat-status.processing {
background: var(--gold);
animation: cat-pulse 1.4s infinite;
}
@keyframes cat-pulse { 0%,100%{opacity:1;} 50%{opacity:0.3;} }
.cat-del {
width: 20px;
height: 20px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--ink-faint);
cursor: pointer;
opacity: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
flex-shrink: 0;
transition: opacity 120ms, background 120ms, color 120ms;
}
.cat-item:hover .cat-del,
.cat-del:focus-visible {
opacity: 1;
}
.cat-del:hover {
background: rgba(21,31,39,0.8);
color: var(--danger);
}
/* ─── Catalog footer ─── */
.catalog-foot {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 6px 0;
border-top: 1px solid rgba(157,170,182,0.08);
color: var(--ink-faint);
font-size: 11px;
flex-shrink: 0;
}
.catalog-foot svg { flex-shrink: 0; }
/* ─── Collapsed strip: just album thumbs ─── */
.cat-collapsed-strip {
display: none;
flex-direction: column;
gap: 8px;
align-items: center;
padding-top: 4px;
overflow-y: auto;
flex: 1;
}
.app.cat-collapsed .cat-collapsed-strip { display: flex; }
.app.cat-collapsed .catalog-search,
.app.cat-collapsed .new-folder-btn,
.app.cat-collapsed .catalog-list,
.app.cat-collapsed .catalog-foot { display: none; }
.strip-thumb {
width: 40px; height: 40px;
border-radius: 7px;
overflow: hidden;
border: 1px solid var(--line);
cursor: pointer;
transition: border-color 120ms, transform 120ms;
flex-shrink: 0;
background: rgba(21,31,39,0.8);
color: var(--ink-dim);
}
.strip-thumb:hover { transform: scale(1.06); }
.strip-thumb.active {
border-color: var(--gold-line);
box-shadow: 0 0 0 2px rgba(216,168,74,0.18);
}
.strip-thumb.folder-thumb {
color: var(--folder-color, var(--gold));
}
.strip-thumb.trash-thumb {
color: var(--ink-faint);
}
.strip-thumb svg,
.strip-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; }
.strip-thumb.folder-thumb svg,
.strip-thumb.trash-thumb svg {
width: 22px;
height: 22px;
margin: 8px auto;
}
+2424
View File
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
/* ───────────── Job progress ───────────── */
.job {
display: none !important;
background: var(--panel);
border: var(--border-w) solid var(--line);
border-radius: var(--radius);
padding: 14px 16px;
}
.job-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
}
#job-title {
font-size: 14px;
flex: 1;
word-break: break-word;
}
#job-stage {
font-size: 13px;
color: var(--ink-dim);
white-space: nowrap;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
progress {
width: 100%;
height: 4px;
appearance: none;
background: var(--line);
border: 0;
border-radius: 2px;
overflow: hidden;
}
progress::-webkit-progress-bar {
background: var(--line);
}
progress::-webkit-progress-value {
background: var(--ink);
transition: width var(--t-base) var(--easing);
}
progress::-moz-progress-bar {
background: var(--ink);
}
/* Compact technical detail line under the progress bar -- shows the
truthful backend `stage` value while #job-stage rotates phrases. */
.job-detail {
margin-top: 6px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-dim);
opacity: 0.65;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cancel-btn {
flex-shrink: 0;
background: transparent;
color: var(--ink-dim);
border: var(--border-w) solid var(--line);
border-radius: var(--radius-xs);
padding: 4px 10px;
font: inherit;
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
transition:
background-color var(--t-base) var(--easing),
color var(--t-base) var(--easing),
border-color var(--t-base) var(--easing);
}
.cancel-btn:hover:not(:disabled) {
background: var(--danger);
color: var(--bg);
border-color: var(--danger);
}
.cancel-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* ───────────── Error ───────────── */
.error {
background: rgba(214, 90, 74, 0.1);
border: var(--border-w) solid rgba(214, 90, 74, 0.5);
color: var(--danger);
border-radius: var(--radius);
padding: 12px 16px;
display: flex;
align-items: flex-start;
gap: 12px;
}
.error-msg {
flex: 1;
word-break: break-word;
font-size: 13px;
}
.retry-btn {
flex-shrink: 0;
background: transparent;
color: var(--danger);
border: var(--border-w) solid var(--danger);
border-radius: var(--radius-xs);
padding: 5px 12px;
font: inherit;
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
transition:
background-color var(--t-base) var(--easing),
color var(--t-base) var(--easing);
}
.retry-btn:hover {
background: var(--danger);
color: var(--bg);
}
+106
View File
@@ -0,0 +1,106 @@
/* ───────────── Master fader + VU ─────────────
* Console-style: tick-marked vertical fader on the left, twin segmented
* LED VU meters on the right, both inside a recessed dark panel.
* Custom rendering throughout — `appearance: none` so the browser
* doesn't fall back to its blue-track default vertical slider, which
* ignores ::-webkit-slider-thumb overrides and offsets the thumb. */
.np-master {
display: flex;
align-items: stretch;
gap: 10px;
height: 140px;
padding: 8px 10px;
background: linear-gradient(180deg, #141414, #0d0d0d);
border: 1px solid var(--line);
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.4);
}
/* Fader column with tick marks down both sides. */
.master-fader-track {
position: relative;
height: 100%;
width: 44px;
display: flex;
align-items: stretch;
justify-content: center;
}
.master-fader-track::before,
.master-fader-track::after {
content: "";
position: absolute;
top: 4px;
bottom: 4px;
width: 5px;
background-image: repeating-linear-gradient(
to bottom,
var(--line-strong) 0,
var(--line-strong) 1px,
transparent 1px,
transparent 8px
);
pointer-events: none;
}
.master-fader-track::before { left: 4px; }
.master-fader-track::after { right: 4px; }
.master-fader {
/* No `appearance: slider-vertical` — that's a legacy webkit shortcut
that forces a blue-track default vertical slider AND ignores
::-webkit-slider-thumb overrides (which is why the thumb floats
left of center). `writing-mode: vertical-lr` rotates the
horizontal range input, `direction: rtl` puts max at the top. */
-webkit-appearance: none;
appearance: none;
-moz-appearance: none;
writing-mode: vertical-lr;
direction: rtl;
width: 100%;
height: 100%;
background: transparent;
cursor: pointer;
outline: none;
margin: 0;
padding: 0;
}
.master-fader::-webkit-slider-runnable-track {
width: 4px;
height: 100%;
background: #050505;
border: 1px solid var(--line);
border-radius: 2px;
}
.master-fader::-moz-range-track {
width: 4px;
height: 100%;
background: #050505;
border: 1px solid var(--line);
border-radius: 2px;
}
.master-fader::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
/* In writing-mode: vertical-lr, `width` is the long (horizontal) axis
and `height` is the thin (vertical) axis. Pill-shaped horizontal
handle. margin-left: -12px centers it on the 4px track. */
width: 28px;
height: 14px;
margin-left: -12px;
background: linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%);
border: 1px solid #050505;
border-radius: 3px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
inset 0 -1px 0 rgba(0, 0, 0, 0.7),
0 1px 2px rgba(0, 0, 0, 0.7);
cursor: pointer;
}
.master-fader::-moz-range-thumb {
width: 28px;
height: 14px;
background: linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%);
border: 1px solid #050505;
border-radius: 3px;
cursor: pointer;
}
+418
View File
@@ -0,0 +1,418 @@
/* ───────────── Preview mixer ───────────── */
.mixer-column {
min-width: 0;
display: grid;
grid-template-columns: repeat(6, minmax(68px, 1fr));
gap: 10px;
}
.lane-header {
min-height: 190px;
display: grid;
grid-template-rows: auto 1fr;
justify-items: center;
position: relative;
transition: opacity var(--t-base) var(--easing);
color: var(--stem-color, #d8dde4);
}
.lane-header[data-stem="vocals"] { --stem-color: #e54249; }
.lane-header[data-stem="drums"] { --stem-color: #f06a14; }
.lane-header[data-stem="bass"] { --stem-color: #eab414; }
.lane-header[data-stem="guitar"] { --stem-color: #5bbf50; }
.lane-header[data-stem="piano"] { --stem-color: #7f45d8; }
.lane-header[data-stem="other"] { --stem-color: #3e8dcf; }
.lane-header[data-stem="original"] { --stem-color: #a8b0bd; }
.lane-header + .lane-header {
border-left: 1px solid var(--glass-line);
}
.lane-header.muted {
opacity: 0.45;
}
.lane-stripe {
display: none;
}
.lane-content {
display: contents;
}
.lane-name-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 0;
}
.lane-icon {
width: 29px;
height: 29px;
flex: 0 0 auto;
color: var(--stem-color);
filter: drop-shadow(0 0 10px color-mix(in srgb, currentColor 34%, transparent));
}
.lane-icon-toggle {
width: 42px;
height: 42px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--stem-color);
cursor: pointer;
opacity: 0.38;
transition:
opacity var(--t-base) var(--easing),
border-color var(--t-base) var(--easing),
background-color var(--t-base) var(--easing),
transform var(--t-fast) var(--easing);
}
.lane-icon-toggle.active {
opacity: 1;
border-color: color-mix(in srgb, currentColor 24%, transparent);
background: color-mix(in srgb, currentColor 8%, transparent);
box-shadow: inset 0 0 18px color-mix(in srgb, currentColor 8%, transparent);
}
.lane-icon-toggle:hover:not(:disabled) {
opacity: 1;
transform: translateY(-1px);
}
.lane-icon-toggle:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
.lane-icon-toggle:disabled {
cursor: default;
opacity: 0.25;
}
.lane-name {
color: #d7dce3 !important;
font-size: 14px;
}
.lane-controls {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 106px;
align-items: end;
justify-items: center;
gap: 10px;
}
.lane-mini-wave {
display: none;
}
.lane-dl {
display: none;
color: #d8dde4;
text-decoration: none;
}
.lane-knob {
grid-column: 1 / -1;
grid-row: 1;
width: 48px;
height: 104px;
border-radius: 0;
border: 0;
background:
repeating-linear-gradient(to bottom, rgba(148, 163, 184, 0.22) 0 1px, transparent 1px 12px),
linear-gradient(90deg, transparent 0 44%, rgba(148, 163, 184, 0.2) 44% 56%, transparent 56% 100%);
position: relative;
cursor: ns-resize;
/* --lane-pos: 0..1 = bottom..top. Default 0.5 = unity gain (vol=1
of LANE_VOLUME_MAX=2). JS rewrites this when volume changes. */
--lane-pos: 0.5;
}
/* Fader thumb. Travels vertically along the track based on --lane-pos.
The travel envelope is 100% - 18px (the thumb's own height) so the
thumb never overshoots the track edges. The transition is only for
programmatic moves (Reset, dblclick, wheel) -- during an active
drag we kill the transition (.lane-knob.dragging rule below) so the
thumb tracks the cursor 1:1 instead of chasing it through an
easing curve, which reads as a rigid/laggy fader. */
.lane-knob::before {
content: "";
position: absolute;
left: 50%;
top: calc((1 - var(--lane-pos)) * (100% - 14px));
width: 30px;
height: 14px;
transform: translateX(-50%);
border-radius: 3px;
border: 1px solid #050505;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.2), transparent 42%),
linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
inset 0 -1px 0 rgba(0, 0, 0, 0.7),
0 0 0 1px color-mix(in srgb, currentColor 34%, transparent),
0 1px 2px rgba(0, 0, 0, 0.72);
transition: top 120ms var(--easing);
will-change: top;
pointer-events: none;
}
.lane-knob.dragging::before {
transition: none;
}
/* Slight grip cue while actively dragging -- thumb gets a tighter
shadow ring so it feels "picked up". */
.lane-knob.dragging {
cursor: grabbing;
}
.lane-knob-indicator {
display: none;
}
.lane-knob.disabled {
opacity: 0.45;
pointer-events: none;
}
.ms-btn {
width: 30px;
height: 30px;
border: 0;
border-radius: 6px;
background: rgba(255, 255, 255, 0.055);
color: #d8dde4;
font-family: var(--font-mono);
font-size: 14px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
background-color var(--t-base) var(--easing),
transform var(--t-fast) var(--easing);
}
.ms-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.ms-btn:active {
transform: scale(0.92);
}
.ms-btn.mute.active {
background: var(--danger);
color: white;
}
.ms-btn.solo.active {
background: var(--gold);
color: #17130c;
}
.ms-btn:disabled {
opacity: 0.38;
pointer-events: none;
}
.lane-controls .ms-btn.mute {
grid-column: 1;
grid-row: 2;
}
.lane-controls .ms-btn.solo {
grid-column: 2;
grid-row: 2;
}
.lane-vu {
display: none;
}
.app:not(.is-import) .preview-mixer-body {
height: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0;
padding-top: 0;
}
.app:not(.is-import) .mixer-column {
height: 100%;
grid-template-columns: repeat(var(--visible-track-count, 6), minmax(52px, 1fr));
gap: 0;
}
.app:not(.is-import) .lane-header {
min-height: 0;
height: 100%;
grid-template-rows: 64px 1fr;
padding: 7px 0 6px;
overflow: hidden;
}
.app:not(.is-import) .lane-name-row {
gap: 6px;
}
.app:not(.is-import) .lane-icon {
width: 26px;
height: 26px;
}
.app:not(.is-import) .lane-icon-toggle {
width: 38px;
height: 38px;
}
.app:not(.is-import) .lane-name {
font-size: 12px;
}
.app:not(.is-import) .lane-controls {
width: 100%;
height: auto;
min-height: 0;
align-self: stretch;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
gap: 0;
padding: 0 5px 38px;
}
.app:not(.is-import) .lane-knob {
width: 42px;
height: 100%;
min-height: 0;
justify-self: center;
}
.app:not(.is-import) .lane-vu {
display: block;
position: absolute;
left: 50%;
top: 72px;
bottom: 48px;
width: 12px;
height: auto;
transform: translateX(18px);
border-radius: 2px;
overflow: hidden;
background:
linear-gradient(to top,
rgba(48, 197, 87, 0.14) 0 55%,
rgba(225, 178, 46, 0.14) 55% 80%,
rgba(214, 64, 64, 0.14) 80% 100%),
rgba(4, 10, 15, 0.82);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.12);
}
.app:not(.is-import) .lane-dl {
display: grid;
place-items: center;
position: absolute;
left: 8px;
right: 8px;
bottom: 6px;
height: 34px;
transform: none;
border: 1px solid color-mix(in srgb, var(--stem-color) 28%, rgba(148, 163, 184, 0.18));
border-radius: 8px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--stem-color) 13%, transparent), transparent),
rgba(255, 255, 255, 0.055);
color: var(--stem-color);
opacity: 0.9;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.035),
0 6px 14px rgba(0, 0, 0, 0.16);
transition:
opacity var(--t-base) var(--easing),
background-color var(--t-base) var(--easing),
border-color var(--t-base) var(--easing),
transform var(--t-fast) var(--easing);
}
.app:not(.is-import) .lane-dl svg {
width: 19px;
height: 19px;
stroke-width: 2.15;
}
.app:not(.is-import) .lane-dl:hover:not(.disabled) {
opacity: 1;
border-color: color-mix(in srgb, var(--stem-color) 58%, transparent);
background:
linear-gradient(180deg, color-mix(in srgb, var(--stem-color) 18%, transparent), transparent),
rgba(255, 255, 255, 0.075);
transform: translateY(-1px);
}
.app:not(.is-import) .lane-dl:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
.app:not(.is-import) .lane-dl.disabled {
opacity: 0.28;
pointer-events: none;
}
.app:not(.is-import) .lane-vu::before {
content: "";
position: absolute;
left: 1px;
right: 1px;
top: 1px;
bottom: 1px;
background:
repeating-linear-gradient(to top, transparent 0 3px, rgba(0, 0, 0, 0.4) 3px 5px),
linear-gradient(to top, #24bf55 0 58%, #e1b22e 58% 80%, #d64040 80% 100%);
clip-path: inset(calc(100% - var(--vu-level, 0%)) 0 0 0);
transition: clip-path 30ms linear;
}
.app:not(.is-import) .lane-vu::after {
content: "";
position: absolute;
left: 1px;
right: 1px;
bottom: var(--vu-peak, 0%);
height: 2px;
background: rgba(255, 255, 255, 0.78);
transition: bottom 80ms linear;
}
.app:not(.is-import) .master-strip {
height: 100%;
min-height: 0;
grid-template-columns: 1fr;
grid-template-rows: 72px 1fr;
gap: 8px;
overflow: hidden;
}
.app:not(.is-import) .master-strip .master-fader-track {
height: calc(100% - 4px);
min-height: 0;
}
.app:not(.is-import) .master-strip .master-fader-track::before,
.app:not(.is-import) .master-strip .master-fader-track::after {
display: none;
}
+311
View File
@@ -0,0 +1,311 @@
/* ───────────── Responsive ─────────────
* Breakpoints:
* ≤1100px — laptop / portrait tablet: now-playing card reflows to 3-col
* with master spanning full width below transport.
* ≤900px — landscape phone / small tablet: appbar wraps, lanes scroll
* horizontally with a sticky mixer column, master compresses.
* ≤640px — phone: mixer column narrower, brand smaller, transport
* buttons full-width touch-friendly, master inline-compact.
* ≤480px — small phone: tightest layout, art shrinks, paddings minimal.
* pointer:coarse — touch input regardless of screen size: bump
* interactive targets to a comfortable hit area.
*/
@media (max-width: 1100px) {
.import-card {
width: min(100%, 900px);
}
.stem-choice-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stem-choice-row > span {
grid-column: 1 / -1;
}
.appbar {
grid-template-columns: 1fr;
}
.appbar-import-panel .import-input-row {
grid-template-columns: minmax(0, 1fr) minmax(144px, 180px);
}
.appbar-import-panel .stem-choice-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.appbar-import-panel .stem-choice-row > span {
grid-column: 1 / -1;
}
.feature-strip {
grid-template-columns: 1fr;
gap: 22px;
}
.feature-item + .feature-item {
border-left: 0;
border-top: 1px solid var(--gold-line);
padding-left: 0;
padding-top: 22px;
}
.now-playing {
grid-template-columns: auto 1fr auto;
grid-template-areas:
"art info transport"
"art master master";
gap: 14px 16px;
}
.np-art { grid-area: art; }
.np-info { grid-area: info; }
.np-transport { grid-area: transport; }
.np-master { grid-area: master; }
}
@media (max-width: 900px) {
.app {
padding: 16px;
gap: 12px;
}
.appbar {
gap: 12px;
}
.import-page {
padding: 54px 18px 20px;
}
.import-copy h2 {
font-size: 31px;
}
.import-card {
margin-top: 36px;
padding: 18px;
}
.import-input-row {
grid-template-columns: 1fr;
}
.appbar-import-panel {
padding: 16px;
}
.appbar-import-panel .import-input-row {
grid-template-columns: 1fr;
gap: 12px;
}
.btn-primary {
width: 100%;
}
.brand-group {
flex: 1;
}
.appbar-form {
order: 3;
width: 100%;
flex: none;
}
/* Compress the master section to recover vertical space — the full
140px panel makes the now-playing card too tall when stacked. */
.np-master {
height: 90px;
padding: 6px 8px;
}
/* Lane scrolling: mixer column stays put, waves scroll under it. */
.lanes-body,
.lanes-ruler {
overflow-x: auto;
}
.mixer-column,
.lanes-ruler-gutter {
position: sticky;
left: 0;
z-index: 4;
background: var(--panel);
}
/* Keep the waves area readable when scrolling — without a min-width
the waveform canvases collapse below ~250px and become unusable. */
.waves-column,
.lanes-ruler-time {
min-width: 480px;
}
}
@media (max-width: 640px) {
:root {
--header-w: 200px;
}
.brand {
font-size: 22px;
}
.brand-logo {
width: 146px;
}
.brand-sub {
font-size: 12px;
}
.import-copy h2 {
font-size: 26px;
}
.import-copy p {
font-size: 15px;
}
.stem-choice-row {
grid-template-columns: 1fr 1fr;
}
.feature-strip {
padding: 22px;
}
.feature-item {
grid-template-columns: 52px 1fr;
}
.now-playing {
grid-template-columns: auto 1fr;
grid-template-areas:
"art info"
"transport master";
gap: 12px;
}
/* Inline transport + master side-by-side rather than stacked, so the
card stays compact. Master is already compressed by the 900px rule. */
.np-master {
height: 80px;
}
.np-transport {
align-self: center;
}
.lane-mini-wave {
display: none; /* save horizontal space in the mixer column */
}
.np-title {
font-size: 14px;
-webkit-line-clamp: 3;
}
}
@media (max-width: 480px) {
:root {
--header-w: 180px;
--lane-h: 84px;
}
.app {
padding: 12px;
gap: 10px;
}
/* Single-column stem buttons; the 2-col grid still squeezes labels at
~360px because each button needs the icon + text + 8px gap. */
.stem-choice-row {
grid-template-columns: 1fr;
}
.stem-choice {
min-height: 44px;
}
.feature-strip {
margin-top: 24px;
}
.privacy-note {
margin-top: 22px;
font-size: 13px;
text-align: center;
}
.now-playing {
padding: 10px 12px;
grid-template-columns: auto 1fr;
grid-template-areas:
"art info"
"transport transport"
"master master";
gap: 10px;
}
.np-art {
width: 56px;
height: 56px;
}
.np-art-play {
width: 28px;
height: 28px;
}
.np-master {
height: 72px;
}
.np-transport {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.chip {
padding: 3px 8px;
font-size: 12px;
}
/* Drop the second chip column to one row of just the essentials */
.np-chips {
gap: 6px;
}
/* Mixer rows: tighten internal padding so M/S/knob/cloud fit. */
.lane-content {
padding: 8px 36px 8px 8px;
}
.lane-name {
font-size: 13px;
}
/* Tighten lane VU on phone so the row stays readable. */
.lane-vu {
width: 18px;
right: 4px;
}
/* Keep waves usable — lower the min-width slightly on tiny screens. */
.waves-column,
.lanes-ruler-time {
min-width: 360px;
}
}
/* Touch-input devices: enlarge interactive targets to ≥40px hit area
regardless of screen size. Desktop stays compact. */
@media (pointer: coarse) {
.ms-btn {
width: 40px;
height: 40px;
font-size: 13px;
}
.lane-knob {
width: 40px;
height: 40px;
}
.lane-knob-indicator {
transform-origin: 50% 16px;
height: 11px;
top: 5px;
}
.lane-dl {
width: 36px;
height: 36px;
}
.btn-transport {
width: 44px;
height: 44px;
}
.avatar-btn {
width: 44px;
height: 44px;
}
/* Bigger master fader thumb so it's easier to grab. */
.master-fader::-webkit-slider-thumb {
width: 32px;
height: 18px;
margin-left: -14px;
}
.master-fader::-moz-range-thumb {
width: 32px;
height: 18px;
}
}
/* Landscape orientation on short screens (phones held sideways): the
now-playing card eats too much vertical space if it tries to stack.
Prefer a single horizontal row. */
@media (max-height: 500px) and (orientation: landscape) {
.now-playing {
grid-template-columns: auto 1fr auto auto;
grid-template-areas: "art info transport master";
gap: 10px;
padding: 8px 12px;
}
.np-master {
height: 70px;
}
.np-art {
width: 56px;
height: 56px;
}
}
+632
View File
@@ -0,0 +1,632 @@
/* ───────────── Import page ───────────── */
.import-page {
min-height: clamp(420px, 60vh, 704px);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
padding: clamp(28px, 6vw, 78px) clamp(14px, 3vw, 24px) clamp(18px, 3vw, 24px);
border: 1px solid var(--glass-line);
border-radius: 12px;
background:
radial-gradient(circle at 50% 22%, rgba(216, 168, 74, 0.1), transparent 23rem),
radial-gradient(circle at 8% 45%, rgba(216, 168, 74, 0.06), transparent 22rem),
radial-gradient(circle at 92% 45%, rgba(216, 168, 74, 0.06), transparent 22rem),
linear-gradient(180deg, rgba(16, 26, 34, 0.78), rgba(8, 15, 22, 0.88));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
.import-copy {
text-align: center;
position: relative;
z-index: 2;
}
.import-copy h2 {
margin: 0;
color: #f1f3f5;
font-size: clamp(24px, 3.4vw + 8px, 39px);
line-height: 1.12;
font-weight: 700;
}
.import-copy h2 span {
color: var(--gold-bright);
white-space: nowrap;
}
.import-copy p {
margin: 16px 0 0;
color: #a9b0bb;
font-size: clamp(14px, 1vw + 8px, 18px);
}
.import-card {
width: min(100%, 1088px);
margin-top: clamp(20px, 4vw, 58px);
padding: clamp(14px, 2vw, 19px) clamp(16px, 3vw, 36px) clamp(18px, 2.5vw, 28px);
border-radius: 13px;
position: relative;
z-index: 2;
}
.import-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
align-items: center;
padding-bottom: 18px;
border-bottom: 1px solid var(--glass-line);
}
.stem-choice-row {
display: grid;
grid-template-columns: 138px repeat(6, minmax(86px, 1fr));
gap: 14px;
align-items: center;
padding-top: 22px;
}
.stem-choice-row > span {
color: #d9dde4;
font-weight: 600;
white-space: nowrap;
}
.stem-choice {
min-height: 54px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: var(--radius);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent),
rgba(8, 15, 22, 0.34);
color: currentColor;
font: inherit;
font-weight: 700;
transition:
border-color var(--t-base) var(--easing),
background-color var(--t-base) var(--easing),
box-shadow var(--t-base) var(--easing),
transform var(--t-fast) var(--easing);
}
.stem-choice:hover {
transform: translateY(-1px);
background-color: rgba(255, 255, 255, 0.045);
box-shadow: inset 0 0 22px color-mix(in srgb, currentColor 10%, transparent);
}
/* Deselected state -- the user has unchecked this stem from the
"stems to extract" set, so it's greyed out and won't appear in the
studio dashboard after processing. Maintains layout (still occupies
its grid slot) so the row doesn't reflow on toggle. */
.stem-choice[aria-pressed="false"] {
opacity: 0.32;
border-color: rgba(148, 163, 184, 0.2) !important;
background: rgba(8, 15, 22, 0.2);
}
.stem-choice[aria-pressed="false"]:hover {
opacity: 0.55;
}
.stem-choice svg {
flex: 0 0 auto;
}
.stem-choice.vocals { color: #ff3f46; border-color: rgba(255, 63, 70, 0.28); }
.stem-choice.drums { color: #ff7a1a; border-color: rgba(255, 122, 26, 0.24); }
.stem-choice.bass { color: #ffd04d; border-color: rgba(255, 208, 77, 0.24); }
.stem-choice.guitar { color: #3fc952; border-color: rgba(63, 201, 82, 0.24); }
.stem-choice.piano { color: #974cff; border-color: rgba(151, 76, 255, 0.24); }
.stem-choice.other { color: #3695ff; border-color: rgba(54, 149, 255, 0.24); }
.privacy-note {
display: inline-flex;
align-items: center;
gap: 9px;
margin-top: 34px;
color: #a6adb7;
font-size: 15px;
position: relative;
z-index: 2;
}
.feature-strip {
width: min(100%, 1160px);
min-height: 136px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center;
gap: clamp(18px, 2.4vw, 36px);
margin-top: clamp(22px, 4vw, 54px);
padding: clamp(16px, 2.4vw, 24px) clamp(18px, 3vw, 46px);
border-radius: 13px;
position: relative;
z-index: 2;
}
.feature-item {
display: grid;
grid-template-columns: clamp(40px, 4vw, 68px) 1fr;
align-items: center;
gap: clamp(12px, 1.5vw, 24px);
color: var(--gold);
}
.feature-item svg {
width: 100%;
height: auto;
max-width: 48px;
}
.feature-item + .feature-item {
border-left: 1px solid var(--gold-line);
padding-left: clamp(18px, 2.4vw, 42px);
}
.feature-item strong {
display: block;
color: var(--ink);
font-size: clamp(14px, 1vw + 6px, 16px);
margin-bottom: 6px;
}
.feature-item span {
color: #a6adb7;
font-size: clamp(13px, 0.8vw + 8px, 15px);
}
/* The HTML hard-codes <br> in feature descriptions for desktop layout;
on narrow viewports those forced breaks produce ragged 2-word lines.
Collapse them so the text wraps naturally. */
@media (max-width: 720px) {
.feature-item br { display: none; }
}
.wave-lines {
position: absolute;
top: 152px;
width: 32%;
height: 120px;
opacity: 0.46;
background:
repeating-linear-gradient(108deg, transparent 0 24px, rgba(216, 168, 74, 0.52) 25px 27px, transparent 28px 50px);
mask-image: radial-gradient(ellipse, black 0 48%, transparent 72%);
}
.wave-left {
left: 0;
transform: skewY(-7deg);
}
.wave-right {
right: 0;
transform: skewY(7deg);
}
/* Decorative diagonal lines crowd the headline on narrow viewports
(they're absolutely positioned at top: 152px; width: 32%) -- hide
them below the tablet breakpoint to keep the headline area clean. */
@media (max-width: 720px) {
.wave-lines { display: none; }
}
/* ───────────── Now-playing card ───────────── */
.now-playing {
display: grid;
grid-template-columns:
clamp(110px, 11vw, 160px)
minmax(0, 1.2fr)
minmax(0, 0.9fr)
minmax(0, 0.65fr)
minmax(0, 0.65fr)
minmax(0, 0.8fr);
align-items: center;
gap: 0;
min-height: 224px;
padding: 23px 24px;
border-radius: 12px;
}
.app:not(.is-import) .now-playing {
flex: 0 0 auto;
min-height: clamp(120px, 14vh, 160px);
padding-top: clamp(7px, 1vh, 12px);
padding-bottom: clamp(7px, 1vh, 12px);
}
.app:not(.is-import) .np-art {
width: clamp(92px, 13vh, 132px);
height: clamp(92px, 14vh, 144px);
}
.np-art {
position: relative;
width: 154px;
height: 168px;
border-radius: 9px;
overflow: hidden;
background:
linear-gradient(135deg, rgba(14, 22, 30, 0.95), rgba(5, 9, 13, 0.96)),
var(--panel-2);
flex-shrink: 0;
}
.np-art-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--ink-faint);
background: linear-gradient(135deg, #1a1a1a, #0e0e0e);
}
.np-art-img {
position: absolute;
inset: 6px;
width: 100%;
height: 100%;
width: calc(100% - 12px);
height: calc(100% - 12px);
object-fit: contain;
display: none;
border-radius: 6px;
}
.np-art-img.loaded {
display: block;
}
.np-art-play {
width: 50px;
height: 50px;
border-radius: 50%;
border: 0;
background: linear-gradient(180deg, var(--gold-bright), var(--gold));
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 12px 24px rgba(216, 168, 74, 0.2);
transition: transform var(--t-fast) var(--easing), background-color var(--t-base) var(--easing);
}
.np-art-play:hover {
background: linear-gradient(180deg, #ecc76e, #c78a30);
}
.np-art-play svg {
margin-left: 2px;
}
.np-info {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
padding: 0 clamp(14px, 2vw, 28px) 0 clamp(16px, 2.2vw, 30px);
border-right: 1px solid var(--glass-line);
}
.np-title {
font-size: clamp(19px, 1.45vw, 24px);
font-weight: 500;
color: var(--ink);
line-height: 1.2;
white-space: normal;
overflow: hidden;
text-overflow: clip;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow-wrap: anywhere;
max-width: 100%;
}
.np-chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 5px 12px;
background: rgba(255, 255, 255, 0.055);
color: var(--ink);
border: var(--border-w) solid var(--line);
border-radius: var(--radius-xs);
font-size: 14px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.np-play-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
margin-top: 6px;
color: #c6cbd2;
font-size: 14px;
}
.np-scrub {
height: 4px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.16);
position: relative;
}
.np-scrub span {
position: absolute;
left: 0;
top: 50%;
width: 14px;
height: 14px;
transform: translateY(-50%);
border-radius: 50%;
background: var(--gold-bright);
box-shadow: 0 0 0 4px rgba(216, 168, 74, 0.18);
}
.stem-energy,
.analysis-card {
min-height: clamp(100px, 14vh, 156px);
padding: 0 clamp(10px, 1.4vw, 24px);
border-left: 1px solid var(--glass-line);
}
.stem-energy {
grid-column: 3;
}
.key-card {
grid-column: 4;
}
.loudness-card {
grid-column: 5;
}
.beat-card {
grid-column: 6;
}
.loudness-card.hidden + .beat-card {
grid-column: 5 / 7;
}
.stem-energy h3,
.analysis-card h3 {
margin: 0 0 13px;
color: #c9ced6;
font-size: 14px;
text-transform: uppercase;
}
.stem-energy h3 span,
.analysis-card h3 span,
.panel-title span {
display: inline-grid;
place-items: center;
width: 16px;
height: 16px;
margin-left: 4px;
border: 1px solid currentColor;
border-radius: 50%;
font-size: 10px;
opacity: 0.72;
}
.energy-row {
display: grid;
grid-template-columns: 78px 1fr 40px;
align-items: center;
gap: 9px;
margin: 5px 0;
color: var(--stem-color);
font-size: 13px;
}
.energy-row span {
font-weight: 700;
}
.energy-row b {
height: 12px;
border-radius: 999px;
background:
linear-gradient(90deg, currentColor 0 var(--v, 0%), rgba(148, 163, 184, 0.13) var(--v, 0%) 100%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
transition: background 80ms linear;
}
.energy-row em {
color: #c8cdd4;
font-style: normal;
font-weight: 700;
}
.vocals { --stem-color: #e54249; color: var(--stem-color); }
.drums { --stem-color: #f06a14; color: var(--stem-color); }
.bass { --stem-color: #eab414; color: var(--stem-color); }
.guitar { --stem-color: #5bbf50; color: var(--stem-color); }
.piano { --stem-color: #7f45d8; color: var(--stem-color); }
.other { --stem-color: #3e8dcf; color: var(--stem-color); }
.original { --stem-color: #a8b0bd; color: var(--stem-color); }
.analysis-card {
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
}
.key-card {
align-items: center;
text-align: center;
}
.beat-card {
align-items: center;
text-align: center;
}
.analysis-card strong {
color: var(--gold-bright);
font-size: clamp(17px, 1.7vw, 26px);
line-height: 1.1;
font-weight: 500;
}
.analysis-card small {
color: #b9bfc7;
font-size: 12px;
}
.confidence-ring {
width: 66px;
height: 66px;
position: relative;
display: grid;
place-items: center;
border-radius: 50%;
background:
conic-gradient(
var(--gold) calc(var(--confidence-pct, 0) * 1%),
rgba(216, 168, 74, 0.16) 0
);
color: #e9edf2;
isolation: isolate;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.confidence-ring::before {
content: "";
position: absolute;
inset: 4px;
border-radius: 50%;
background: var(--panel);
}
.confidence-ring::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
box-shadow:
inset 0 0 0 1px rgba(216, 168, 74, 0.12),
0 0 14px rgba(216, 168, 74, 0.1);
pointer-events: none;
}
.confidence-ring span {
position: relative;
z-index: 1;
}
.loudness-card strong {
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
align-items: baseline;
color: #f0f2f5;
}
.beat-values {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
min-width: 0;
}
.beat-values strong {
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
align-items: baseline;
color: var(--gold-bright);
text-align: left;
}
.beat-values strong small {
display: block;
margin-top: 0;
color: #b9bfc7;
}
.np-transport {
display: flex;
align-items: center;
gap: 26px;
color: #cfd4db;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.np-transport-row {
display: flex;
align-items: center;
gap: 22px;
}
.btn-transport {
width: 58px;
height: 58px;
background: rgba(255, 255, 255, 0.055);
color: var(--ink);
border: 1px solid var(--glass-line);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
background-color var(--t-base) var(--easing),
color var(--t-base) var(--easing),
border-color var(--t-base) var(--easing),
transform var(--t-fast) var(--easing);
}
.btn-transport:hover {
background: rgba(255, 255, 255, 0.09);
border-color: var(--line-strong);
}
.btn-transport:active {
transform: scale(0.94);
}
.btn-transport .pause-icon {
display: none;
}
.btn-transport.playing .pause-icon {
display: block;
}
.btn-transport.playing svg:not(.pause-icon) {
display: none;
}
.btn-transport.playing {
background: linear-gradient(180deg, #4ade80, #16a34a);
color: white;
border-color: #15803d;
box-shadow: 0 16px 34px rgba(34, 197, 94, 0.22);
}
.btn-transport.stopped {
background: linear-gradient(180deg, #f87171, #dc2626);
color: white;
border-color: #b91c1c;
box-shadow: 0 16px 34px rgba(239, 68, 68, 0.22);
}
.btn-transport.loop.active {
background: linear-gradient(180deg, var(--gold-bright), var(--gold));
color: white;
border-color: var(--gold-line);
box-shadow: 0 16px 34px rgba(216, 168, 74, 0.18);
}
.np-time {
font-size: 13px;
color: var(--ink-dim);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
+71
View File
@@ -0,0 +1,71 @@
/* ── Design tokens ── */
:root {
color-scheme: dark;
/* Surfaces */
--bg: #0b0f12;
--bg-2: #0f1418;
--panel: #131a1f;
--panel-2: #182026;
--panel-3: #1d262d;
--border: #232c34;
--border-strong: #2e3942;
/* Text */
--fg: #e8ecf0;
--fg-2: #c2c9d1;
--muted: #8a939c;
--muted-2: #5d666e;
/* Stem colours */
--vocals: #ef4444;
--drums: #f97316;
--bass: #eab308;
--guitar: #22c55e;
--piano: #a855f7;
--other: #9ca3af;
/* Accent */
--accent: #f4b740;
--accent-2: #d99a2b;
/* Legacy compat aliases used by waves.css / player.js */
--gold: var(--accent);
--gold-bright: #f8c054;
--gold-soft: rgba(244,183,64,0.16);
--gold-line: rgba(244,183,64,0.28);
--ink: var(--fg);
--ink-dim: var(--fg-2);
--ink-faint: var(--muted);
--line: var(--border);
--line-strong: var(--border-strong);
--glass-line: var(--border);
--danger: #d65a4a;
--focus-ring: rgba(236,236,236,0.55);
/* Typography */
--font-sans: 'Inter', -apple-system, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
/* Layout */
--header-w: 300px;
--lane-h: 86px;
--radius: 10px;
--radius-sm: 8px;
--radius-xs: 6px;
/* Motion */
--t-fast: 80ms;
--t-base: 120ms;
--easing: cubic-bezier(0.2,0.8,0.2,1);
--shadow-panel: 0 4px 24px rgba(0,0,0,0.4);
}
@media (prefers-reduced-motion: reduce) {
:root { --t-fast: 0ms; --t-base: 0ms; }
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
+1011
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg width="1200" height="320" viewBox="0 0 1200 320" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">StemDeck Horizontal Logo</title>
<desc id="desc">StemDeck logo with golden waveform symbol and wordmark.</desc>
<defs>
<linearGradient id="gold" x1="82" y1="48" x2="282" y2="264" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE38A"/>
<stop offset="0.48" stop-color="#F2B53D"/>
<stop offset="1" stop-color="#C98512"/>
</linearGradient>
</defs>
<g fill="url(#gold)">
<rect x="78" y="154" width="16" height="12" rx="6"/>
<rect x="110" y="118" width="20" height="84" rx="10"/>
<rect x="144" y="78" width="22" height="164" rx="11"/>
<rect x="178" y="126" width="18" height="68" rx="9"/>
<rect x="212" y="52" width="24" height="216" rx="12"/>
<rect x="250" y="106" width="20" height="108" rx="10"/>
<rect x="284" y="80" width="22" height="160" rx="11"/>
<rect x="318" y="128" width="20" height="64" rx="10"/>
<rect x="352" y="154" width="16" height="12" rx="6"/>
</g>
<text x="430" y="178" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="104" font-weight="700" letter-spacing="-4" fill="#F4F6F8">Stem</text>
<text x="678" y="178" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="104" font-weight="700" letter-spacing="-4" fill="#F2B53D">Deck</text>
<text x="435" y="232" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="28" font-weight="500" letter-spacing="11" fill="#7F8A94">POWERED STEM SEPARATION</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+738
View File
@@ -0,0 +1,738 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>StemDeck — split any track into stems</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/variables.css" />
<link rel="stylesheet" href="/css/waves.css" />
<link rel="stylesheet" href="/css/daw.css" />
</head>
<body>
<div class="app daw" id="app">
<!-- ══ TOPBAR ══ -->
<div class="daw-topbar">
<!-- Brand -->
<div class="daw-brand">
<span class="daw-brand-name">
<span class="fg">Stem</span><span class="accent">Deck</span>
</span>
</div>
<span id="brandVersion" class="daw-version"></span>
<!-- Separator -->
<div class="daw-sep"></div>
<!-- Composer pill -->
<form id="job-form" class="daw-composer">
<!-- URL zone (JS uses .url-wrap for drag events) -->
<div class="daw-url-zone url-wrap">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--muted)" stroke-width="1.8" style="flex-shrink:0" aria-hidden="true">
<path d="M10 14a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1 M14 10a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/>
</svg>
<input
id="url"
type="url"
placeholder="Paste a YouTube or SoundCloud link, or drop an audio file…"
spellcheck="false"
autocomplete="off"
/>
<!-- File pill (shown when file selected) -->
<div class="file-pill hidden" id="filePill">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
<span class="file-name" id="fileName"></span>
<span class="file-size" id="fileSize"></span>
<button class="file-clear" id="fileClear" type="button" aria-label="Remove file">×</button>
</div>
<button class="daw-upload-btn" type="button" title="Upload audio file" id="uploadFileBtn" aria-label="Upload audio file">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M12 3v12 M7 8l5-5 5 5 M5 21h14"/>
</svg>
</button>
<input id="fileInput" type="file" accept=".mp3,.wav,.flac,.mp4,.m4a,audio/mpeg,audio/wav,audio/flac,video/mp4,audio/mp4" style="display:none" aria-hidden="true" />
</div>
<!-- Divider -->
<div class="daw-composer-sep"></div>
<!-- Stem selection -->
<div class="daw-stem-section">
<span class="daw-extract-label">Extract</span>
<div class="daw-stem-chips">
<button class="stem-choice stem-choice-all" type="button" id="stemAllBtn" aria-pressed="true">All</button>
<button class="stem-choice" type="button" data-stem="vocals" aria-pressed="true" style="--color:var(--vocals)">
<span class="stem-dot"></span>Vocals
</button>
<button class="stem-choice" type="button" data-stem="drums" aria-pressed="true" style="--color:var(--drums)">
<span class="stem-dot"></span>Drums
</button>
<button class="stem-choice" type="button" data-stem="bass" aria-pressed="true" style="--color:var(--bass)">
<span class="stem-dot"></span>Bass
</button>
<button class="stem-choice" type="button" data-stem="guitar" aria-pressed="true" style="--color:var(--guitar)">
<span class="stem-dot"></span>Guitar
</button>
<button class="stem-choice" type="button" data-stem="piano" aria-pressed="true" style="--color:var(--piano)">
<span class="stem-dot"></span>Piano
</button>
<button class="stem-choice" type="button" data-stem="other" aria-pressed="false" style="--color:var(--other)">
<span class="stem-dot"></span>Other
</button>
</div>
</div>
<!-- Process button -->
<button id="submit" class="daw-process-btn" type="submit">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13 2 3 14h7l-1 8 10-12h-7z"/>
</svg>
Split stems
</button>
</form>
<!-- Notification bell -->
<div class="daw-notif-wrap" style="position:relative;flex-shrink:0;">
<button class="daw-iconbtn daw-notif-btn" id="notifBtn" title="Notifications" type="button" aria-label="Notifications" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9 M13.7 21a2 2 0 0 1-3.4 0"/>
</svg>
<span class="daw-notif-badge hidden" id="notifBadge" aria-hidden="true"></span>
</button>
<div class="daw-notif-panel" role="menu" aria-label="Notifications">
<div class="daw-notif-header">
<span class="uplabel">Notifications</span>
<button class="daw-notif-close" type="button" aria-label="Close notifications">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="daw-notif-list" id="notifList">
<div class="daw-notif-card daw-notif-release hidden" id="notifReleaseCard">
<div class="daw-notif-card-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z"/></svg>
</div>
<div class="daw-notif-card-body">
<div class="daw-notif-card-title">New release available</div>
<div class="daw-notif-card-desc" id="notifReleaseDesc"></div>
</div>
<button class="daw-notif-dismiss" id="notifReleaseDismiss" type="button" aria-label="Dismiss notification">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<p class="daw-notif-empty" id="notifEmpty">No new notifications</p>
</div>
</div>
</div>
<!-- Hidden collapsed strip (JS compatibility) -->
<div class="hidden" id="appbarStrip" aria-hidden="true" style="display:none!important">
<div><div class="strip-sq-stems" id="appbarStripStems"></div></div>
</div>
</div>
<!-- ══ BODY ══ -->
<div class="daw-body">
<!-- ── Sidebar / Catalog ── -->
<aside class="sidebar" id="catalogPanel">
<!-- Rail -->
<nav class="sidebar-rail" aria-label="Library navigation">
<button class="rail-btn rail-collapse" id="sidebarCollapseBtn" type="button" title="Toggle library" aria-label="Toggle library" aria-expanded="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<button class="rail-btn rail-library active" id="appMenuBtn" type="button" title="Library" aria-label="Library" aria-pressed="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M4 4h16v16H4z M4 9h16"/>
</svg>
<span>Library</span>
</button>
<button class="rail-btn rail-favorites" type="button" title="Favorites" aria-label="Favorites" aria-pressed="false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
<span>Favorites</span>
</button>
<button class="rail-btn rail-trash" type="button" title="Trash" aria-label="Trash" aria-pressed="false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M3 6h18 M8 6V4h8v2 M19 6l-1 14H6L5 6"/>
</svg>
<span>Trash</span>
</button>
<div style="flex:1"></div>
<button class="rail-btn" id="settingsBtn" type="button" aria-label="Settings" aria-haspopup="dialog">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v2 M12 20v2 M4 12H2 M22 12h-2 M5 5l1.5 1.5 M17.5 17.5 19 19 M5 19l1.5-1.5 M17.5 6.5 19 5"/>
</svg>
<span>Settings</span>
</button>
<button class="rail-btn rail-recommend" id="friendsBtn" type="button" title="We Recommend" aria-label="We Recommend" aria-haspopup="dialog">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>
</svg>
<span>We<br>Recommend</span>
</button>
<button class="rail-btn" id="aboutBtn" type="button" title="Help" aria-label="Help">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="12" cy="12" r="9"/>
<path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 .8-1 1.7 M12 17h.01"/>
</svg>
<span>Help</span>
</button>
</nav>
<!-- Sidebar body -->
<div class="sidebar-body">
<!-- Search -->
<div class="daw-search" id="catalogToggle" role="button" tabindex="0" aria-label="Search library">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>
</svg>
<input id="catalogSearch" type="search" placeholder="Search or #tag…" autocomplete="off" spellcheck="false" />
<ul class="tag-suggest" id="tagSuggest" role="listbox" aria-label="Tag suggestions"></ul>
<span class="search-kbd" aria-hidden="true">⌘K</span>
</div>
<!-- Clear bin (visible only in trash-view) -->
<div id="clearBinBar" class="clear-bin-bar">
<button id="clearBinBtn" class="clear-bin-btn" type="button">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M3 6h18 M8 6V4h8v2 M19 6l-1 14H6L5 6"/>
</svg>
Empty trash
</button>
</div>
<!-- Catalog list (JS populates) -->
<div id="catalogList" class="daw-lib-list" role="list"></div>
<!-- Collapsed strip (JS compat) -->
<div id="catalogStrip" class="cat-collapsed-strip"></div>
</div>
</aside>
<!-- ── Main area ── -->
<div class="daw-main-col">
<div id="lanes" class="daw-main">
<!-- Job progress -->
<div id="error" class="error hidden" role="alert"></div>
<!-- Track header / info panel -->
<div id="transport" class="daw-track-header">
<!-- Row 1: track card + metadata cards -->
<div class="daw-info-row">
<div class="daw-meta-card" data-meta="key">
<span class="meta-card-label">KEY</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-key"></span>
<div class="daw-camelot-ring hidden" id="summary-confidence"></div>
</div>
<span class="meta-card-sub" id="summary-scale"></span>
<span class="meta-card-sub hidden" id="summary-confidence-label"></span>
</div>
<div class="daw-meta-card" data-meta="bpm">
<span class="meta-card-label">BPM</span>
<div class="meta-card-main">
<span class="meta-card-value accent" id="summary-bpm"></span>
</div>
</div>
<div class="daw-meta-card" data-meta="lufs">
<span class="meta-card-label">LUFS</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-lufs"></span>
</div>
<span class="meta-card-sub" id="summary-peak"></span>
</div>
<div class="daw-meta-card" data-meta="duration">
<span class="meta-card-label">DURATION</span>
<div class="meta-card-main">
<span class="meta-card-value num" id="summary-duration"></span>
</div>
</div>
<div class="daw-meta-card" data-meta="scale">
<span class="meta-card-label">SCALE</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-scale-name"></span>
</div>
</div>
<div class="daw-meta-card" data-meta="dr">
<span class="meta-card-label">DYNAMIC RANGE</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-dr"></span>
</div>
<span class="meta-card-sub" id="summary-dr-label"></span>
</div>
<div class="daw-meta-card" data-meta="stability">
<span class="meta-card-label">TEMPO STABILITY</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-stability"></span>
</div>
<span class="meta-card-sub" id="summary-stability-label"></span>
</div>
</div>
<!-- Row 2: stem presence cards -->
<div class="stem-presence-panel">
<div class="stem-card inactive" data-stem="vocals">
<span class="stem-card-label">VOCAL PRESENCE</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="drums">
<span class="stem-card-label">DRUM INTENSITY</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="bass">
<span class="stem-card-label">BASS DEPTH</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="guitar">
<span class="stem-card-label">GUITAR PRESENCE</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="piano">
<span class="stem-card-label">PIANO PRESENCE</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="other">
<span class="stem-card-label">OTHER</span>
<span class="stem-card-pct"></span>
</div>
</div>
<!-- Hidden compat -->
<div style="display:none" aria-hidden="true"><div id="loudness-card"></div></div>
</div>
<!-- Sections bar -->
<div class="daw-section-ribbon">
<div class="daw-mixer-label daw-sections-header">
<span class="daw-label-title">Sections</span>
<span class="sections-save-indicator hidden" id="sectionsSaveIndicator" aria-live="polite" aria-label="Saving sections"></span>
<button class="sections-add-btn-label" id="sectionsAddBtn" type="button" title="Add section" aria-label="Add section">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
Add
</button>
</div>
<div class="daw-sections-area" id="daw-sections"></div>
</div>
<!-- Waveform header / ruler -->
<div class="daw-wave-header">
<div class="daw-wave-label">
<span class="daw-label-title">Mixer</span>
<span class="uplabel daw-label-sub">Drag fader · M/S</span>
</div>
<div class="daw-ruler-area">
<div class="lanes-ruler" data-position="top">
<div class="lanes-ruler-time" id="ruler-time">
<div class="playhead-marker" aria-hidden="true">
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
<polygon points="0,0 10,0 5,8" fill="var(--accent)"/>
</svg>
</div>
</div>
</div>
</div>
</div>
<!-- Content area: stems + waveform -->
<div class="daw-content">
<!-- Stems panel (left — M/S controls per stem) -->
<div class="daw-stems-panel" aria-label="Stems">
<!-- Presence panel hidden but in DOM for JS -->
<div style="display:none" aria-hidden="true">
<section class="presence-panel surface-panel">
<div class="presence-ruler" id="presence-ruler">
<span></span><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b>
</div>
<div class="presence-grid">
<div class="presence-labels">
<span class="vocals">Vocals</span><span class="drums">Drums</span>
<span class="bass">Bass</span><span class="guitar">Guitar</span>
<span class="piano">Piano</span><span class="other">Others</span>
</div>
<div class="presence-bars">
<i class="vocals" data-stem="vocals"></i>
<i class="drums" data-stem="drums"></i>
<i class="bass" data-stem="bass"></i>
<i class="guitar" data-stem="guitar"></i>
<i class="piano" data-stem="piano"></i>
<i class="other" data-stem="other"></i>
<div class="presence-playhead hidden" id="presence-playhead" aria-hidden="true"></div>
</div>
</div>
</section>
</div>
<!-- Stem rows with M/S -->
<div class="stem-list" aria-label="Waveform lane icons">
<span class="original" data-stem="original">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
Original
</em>
<b class="stem-mute" data-stem="original" role="button" tabindex="0" aria-label="Mute Original" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="original" role="button" tabindex="0" aria-label="Solo Original" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="original" aria-label="Solo only Original"></button>
<i class="mini-meter"></i>
</span>
<span class="vocals" data-stem="vocals">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/></svg>
Vocals
</em>
<b class="stem-mute" data-stem="vocals" role="button" tabindex="0" aria-label="Mute Vocals" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="vocals" role="button" tabindex="0" aria-label="Solo Vocals" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="vocals" aria-label="Solo only Vocals"></button>
<i class="mini-meter"></i>
</span>
<span class="drums" data-stem="drums">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M7 13.5a5 5 0 0 0 10 0"/><path d="M7 13.5h10"/><circle cx="9" cy="10" r="2.5"/><circle cx="15" cy="10" r="2.5"/></svg>
Drums
</em>
<b class="stem-mute" data-stem="drums" role="button" tabindex="0" aria-label="Mute Drums" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="drums" role="button" tabindex="0" aria-label="Solo Drums" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="drums" aria-label="Solo only Drums"></button>
<i class="mini-meter"></i>
</span>
<span class="bass" data-stem="bass">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M16.5 3h4v5h-3"/><path d="M17.5 5.5 9.8 13.2"/><path d="M10 13c1.6 2.2 1.1 5.1-1.2 6.5-2.1 1.3-5 .5-6-1.6-.9-1.9-.1-4.1 1.8-5 .9-.4 1.8-.4 2.8-.1.1-1.1.6-2.1 1.6-2.6 1.2-.6 2.6-.1 3.2 1.1"/></svg>
Bass
</em>
<b class="stem-mute" data-stem="bass" role="button" tabindex="0" aria-label="Mute Bass" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="bass" role="button" tabindex="0" aria-label="Solo Bass" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="bass" aria-label="Solo only Bass"></button>
<i class="mini-meter"></i>
</span>
<span class="guitar" data-stem="guitar">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M16 4.5 20 2l2 2-2.5 4"/><path d="M18.2 5.8 10.2 13.8"/><circle cx="7" cy="16.4" r="1.4"/></svg>
Guitar
</em>
<b class="stem-mute" data-stem="guitar" role="button" tabindex="0" aria-label="Mute Guitar" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="guitar" role="button" tabindex="0" aria-label="Solo Guitar" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="guitar" aria-label="Solo only Guitar"></button>
<i class="mini-meter"></i>
</span>
<span class="piano" data-stem="piano">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 5v14"/><path d="M12 5v14"/><path d="M17 5v14"/></svg>
Piano
</em>
<b class="stem-mute" data-stem="piano" role="button" tabindex="0" aria-label="Mute Piano" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="piano" role="button" tabindex="0" aria-label="Solo Piano" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="piano" aria-label="Solo only Piano"></button>
<i class="mini-meter"></i>
</span>
<span class="other" data-stem="other">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M4 13v-2"/><path d="M8 17V7"/><path d="M12 21V3"/><path d="M16 17V7"/><path d="M20 13v-2"/></svg>
Others
</em>
<b class="stem-mute" data-stem="other" role="button" tabindex="0" aria-label="Mute Other" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="other" role="button" tabindex="0" aria-label="Solo Other" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="other" aria-label="Solo only Other"></button>
<i class="mini-meter"></i>
</span>
</div>
<!-- Mixer faders (JS populates) -->
<div id="mixer" class="mixer-column"></div>
</div>
<!-- Waveform panel -->
<div class="daw-wave-panel wave-editor surface-panel">
<div id="job" class="job hidden" role="status" aria-live="polite">
<div class="job-header">
<div id="job-title" class="title"></div>
<div id="job-stage" class="stage"></div>
<button id="job-cancel" class="cancel-btn hidden" type="button" aria-label="Cancel job">Cancel</button>
</div>
<progress id="progress" max="100" value="0"></progress>
<div id="job-detail" class="job-detail" aria-live="polite"></div>
</div>
<div class="wave-scroll" id="wave-scroll">
<div class="wave-canvas" id="wave-canvas">
<div class="waves-column">
<div id="waves-grid" class="waves-grid" aria-hidden="true"></div>
<div id="multitrack-container"></div>
<div id="loop-region" class="loop-region hidden" aria-hidden="true"></div>
</div>
<div class="wave-loading-overlay hidden" id="waveLoadingOverlay" aria-label="Loading waveform" aria-live="polite">
<div class="wave-loading-lines" aria-hidden="true">
<i></i><i></i><i></i><i></i><i></i><i></i>
</div>
<p id="waveLoadingPhrase" class="wave-loading-msg"></p>
</div>
</div>
</div>
</div>
</div><!-- /.daw-content -->
<!-- Transport footer -->
</div><!-- /#lanes -->
<!-- ══ FOOTER — sibling of #lanes inside .daw-main-col, not clipped ══ -->
<footer class="daw-footer">
<!-- Top row: track info | transport + time | chips -->
<div class="footer-content">
<div class="footer-track">
<div class="footer-track-title-row">
<div class="daw-track-title footer-track-title" id="title"></div>
<button class="daw-fav-btn" id="fav-btn" aria-label="Toggle favorite" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
</button>
</div>
<div class="footer-track-body">
<div class="daw-cover footer-cover" id="np-art">
<div class="np-art-placeholder" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
</div>
<img id="np-thumb" class="np-art-img" alt="" referrerpolicy="no-referrer" />
</div>
<div class="daw-track-meta">
<div class="daw-track-time-row">
<span class="num daw-track-sub" id="t-time"></span>
<span class="daw-track-sep" aria-hidden="true">|</span>
<span class="daw-track-stems-label" id="t-stems-chip">— Stems</span>
</div>
<div class="daw-track-details">
<div class="daw-detail-row">
<span class="daw-detail-label">Extracted</span>
<span class="daw-detail-val" id="track-extracted"></span>
</div>
<div class="daw-detail-row">
<span class="daw-detail-label">Source</span>
<span class="daw-detail-val" id="track-source"></span>
</div>
<div class="daw-detail-row">
<span class="daw-detail-label">Quality</span>
<span class="daw-detail-val" id="track-quality"></span>
</div>
</div>
</div>
</div><!-- /.footer-track-body -->
<!-- Hidden spans for JS compat -->
<div style="display:none" aria-hidden="true">
<span id="t-bpm"></span><span id="t-key"></span><span id="track-artist"></span>
</div>
</div>
<div class="footer-center">
<div class="footer-transport">
<button class="daw-iconbtn btn-transport" id="t-stop" type="button" title="Stop" aria-label="Stop">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12"/>
</svg>
</button>
<button class="daw-play-btn btn-transport" id="t-play" type="button" aria-label="Play / Pause">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true" style="margin-left:2px">
<path d="M6 4l14 8L6 20z"/>
</svg>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" class="pause-icon" aria-hidden="true" style="display:none">
<rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/>
</svg>
</button>
<button class="daw-iconbtn btn-transport loop" id="t-loop" type="button" title="Loop (L)" aria-label="Loop">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M17 2l4 4-4 4 M3 12V8a2 2 0 0 1 2-2h16 M7 22l-4-4 4-4 M21 12v4a2 2 0 0 1-2 2H3"/>
</svg>
</button>
<div class="footer-loop-times" title="Exact loop start / end (mm:ss.mmm or seconds)">
<input type="text" id="t-loop-start" class="loop-time-input num" inputmode="decimal" spellcheck="false" autocomplete="off" aria-label="Loop start" value="00:00.000">
<span class="loop-times-sep" aria-hidden="true">-</span>
<input type="text" id="t-loop-end" class="loop-time-input num" inputmode="decimal" spellcheck="false" autocomplete="off" aria-label="Loop end" value="00:00.000">
</div>
</div>
<div class="footer-time-row">
<span class="num footer-elapsed" id="footer-time-elapsed">0:00</span>
<span class="footer-time-sep">/</span>
<span class="num footer-total" id="footer-time-total">0:00</span>
</div>
<div class="tempo-bar" id="t-speed-wrap" title="Playback speed (double-click to reset)">
<span class="tempo-bar-label">TEMPO</span>
<input type="range" id="t-speed" min="0" max="2" step="0.25" value="1" aria-label="Playback speed">
<span class="tempo-bar-divider" aria-hidden="true"></span>
<span class="tempo-bar-val" id="t-speed-label">1.0x</span>
</div>
</div>
<div class="footer-chips">
<div class="footer-chip-wrap" id="footer-export-wrap">
<button class="footer-chip footer-chip-primary" id="t-export-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="t-export-panel">
<svg class="export-dl-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<svg class="export-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke-opacity="0.25"/><path d="M21 12a9 9 0 0 0-9-9"/>
</svg>
<span class="export-btn-label" id="t-export-label">Export Mix</span>
<svg class="export-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<div class="footer-chip-panel chip-panel-export hidden" id="t-export-panel" role="menu" aria-label="Export options">
<div class="export-format-toggle" role="radiogroup" aria-label="Export format">
<button class="export-fmt active" id="t-fmt-wav" type="button" role="radio" aria-checked="true">WAV</button>
<button class="export-fmt" id="t-fmt-mp3" type="button" role="radio" aria-checked="false">MP3</button>
<button class="export-fmt" id="t-fmt-flac" type="button" role="radio" aria-checked="false">FLAC</button>
<button class="export-fmt export-fmt-video" id="t-fmt-mp4" type="button" role="radio" aria-checked="false">MP4</button>
</div>
<button class="chip-panel-item export-item" id="t-export-mix" type="button" role="menuitem">
<span class="chip-item-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><line x1="4" y1="9" x2="4" y2="15"/><line x1="9" y1="5" x2="9" y2="19"/><line x1="14" y1="8" x2="14" y2="16"/><line x1="19" y1="4" x2="19" y2="20"/></svg>
</span>
<span class="chip-item-text">
<span class="chip-item-title">Export Mix</span>
<span class="chip-item-desc">Export the mixed audio</span>
</span>
<svg class="chip-item-check" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
</button>
<button class="chip-panel-item export-item" id="t-export-stems" type="button" role="menuitem">
<span class="chip-item-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><polygon points="12 2 22 8.5 12 15 2 8.5 12 2"/><polyline points="2 15.5 12 22 22 15.5"/></svg>
</span>
<span class="chip-item-text">
<span class="chip-item-title">Export All Stems</span>
<span class="chip-item-desc">All stems as a .zip</span>
</span>
</button>
<button class="chip-panel-item export-item" id="t-export-region" type="button" role="menuitem" aria-disabled="true">
<span class="chip-item-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>
</span>
<span class="chip-item-text">
<span class="chip-item-title">Export Current Region</span>
<span class="chip-item-desc">Export selected region</span>
</span>
</button>
</div>
</div>
</div>
</div><!-- /.footer-content -->
<!-- Full-width waveform bar -->
<div class="footer-wave-bar">
<canvas id="footer-waveform" aria-hidden="true"></canvas>
<div class="footer-scrub" id="footer-scrub" role="slider" aria-label="Seek" tabindex="0">
<div class="footer-scrub-fill" id="footer-scrub-fill"></div>
</div>
</div>
</footer>
</div><!-- /.daw-main-col -->
</div><!-- /.daw-body -->
</div><!-- /.daw -->
<!-- About dialog -->
<div class="about-backdrop hidden" id="aboutDialog" role="dialog" aria-modal="true" aria-labelledby="aboutTitle">
<div class="about-card">
<button class="about-close" id="aboutClose" type="button" aria-label="Close about dialog">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<div class="about-logo" aria-hidden="true">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<rect x="2" y="10" width="3" height="8" rx="1.5" fill="currentColor" opacity=".5"/>
<rect x="7" y="6" width="3" height="16" rx="1.5" fill="currentColor" opacity=".7"/>
<rect x="12" y="2" width="4" height="24" rx="2" fill="currentColor"/>
<rect x="18" y="6" width="3" height="16" rx="1.5" fill="currentColor" opacity=".7"/>
<rect x="23" y="10" width="3" height="8" rx="1.5" fill="currentColor" opacity=".5"/>
</svg>
</div>
<h2 id="aboutTitle">StemDeck</h2>
<p class="about-tagline">Open source. No subscriptions. Built by musicians, for musicians.</p>
<span class="about-version-badge" id="aboutVersion"></span>
<div class="about-primary-links">
<a class="about-link about-link-primary" href="https://stemdeck.app" target="_blank" rel="noopener noreferrer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
Website
</a>
<a class="about-link about-link-secondary" href="https://github.com/stemdeckapp/stemdeck" target="_blank" rel="noopener noreferrer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>
GitHub
</a>
</div>
<div class="about-divider"></div>
<div class="about-socials">
<a class="about-social-btn" href="https://discord.gg/JGk7FdZb9N" target="_blank" rel="noopener noreferrer" title="Discord" aria-label="Discord">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
</a>
<a class="about-social-btn" href="https://www.reddit.com/r/StemDeckApp/" target="_blank" rel="noopener noreferrer" title="Reddit" aria-label="Reddit">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/></svg>
</a>
<a class="about-social-btn" href="https://www.instagram.com/stemdeck" target="_blank" rel="noopener noreferrer" title="Instagram" aria-label="Instagram">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg>
</a>
<a class="about-social-btn" href="https://x.com/StemDeckApp" target="_blank" rel="noopener noreferrer" title="X" aria-label="X">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.747l7.73-8.835L1.254 2.25H8.08l4.253 5.622 5.911-5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</a>
</div>
</div>
</div>
<!-- Our Friends dialog -->
<div class="about-backdrop hidden" id="friendsDialog" role="dialog" aria-modal="true" aria-labelledby="friendsTitle">
<div class="about-card friends-card">
<button class="about-close" id="friendsClose" type="button" aria-label="Close friends dialog">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<div class="about-logo" aria-hidden="true">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>
</svg>
</div>
<h2 id="friendsTitle">We Recommend</h2>
<p class="about-tagline">Wonderful people doing beautiful work. Go meet them ❤️</p>
<div class="lib-friends-grid" id="friendsDialogGrid"></div>
</div>
</div>
<script type="module" src="/js/main.js"></script>
<script type="module" src="/js/ui-chrome.js"></script>
</body>
</html>
+149
View File
@@ -0,0 +1,149 @@
import { TRACK_NAMES } from "./constants.js";
import {
multitrack, mixerEl, audioContext, trackAnalysers,
vuRafId, trackIndex,
setAudioContext, setVuRafId,
} from "./state.js";
export function attachAnalysers() {
if (!multitrack) return;
if (trackAnalysers.length) return;
const wsArr = multitrack.wavesurfers || multitrack._wavesurfers;
const audios = multitrack.audios;
if (!wsArr?.length || !audios?.length) return;
const ctx = multitrack.audioContext;
if (!ctx) return;
if (ctx.state === "suspended") ctx.resume().catch(() => {});
setAudioContext(ctx);
for (const stemName of TRACK_NAMES) {
const idx = trackIndex[stemName];
if (idx === undefined) continue;
const audioEl = audios[idx];
if (!audioEl) continue;
const isHtmlMedia = audioEl instanceof HTMLMediaElement;
const isWebAudio = typeof audioEl.getGainNode === "function";
if (!isHtmlMedia && !isWebAudio) continue;
if (isHtmlMedia && !audioEl.src) continue;
let analyser;
try {
analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.5;
if (isWebAudio) {
// wavesurfer's audio wrapper exposes its internal gain node;
// analyser taps the post-gain signal.
audioEl.getGainNode().connect(analyser);
} else {
// Bare HTMLAudioElement: route through Web Audio so we can tap
// the post-gain signal. applyMix() may have already created the
// MediaElementSource + GainNode chain; re-use it if so (the spec
// only allows createMediaElementSource once per element).
if (!audioEl._stMediaSource) {
audioEl._stMediaSource = ctx.createMediaElementSource(audioEl);
audioEl._stGainNode = ctx.createGain();
audioEl._stMediaSource.connect(audioEl._stGainNode);
audioEl._stGainNode.connect(ctx.destination);
audioEl.volume = 1;
} else if (!audioEl._stGainNode) {
audioEl._stGainNode = ctx.createGain();
audioEl._stMediaSource.connect(audioEl._stGainNode);
audioEl._stGainNode.connect(ctx.destination);
audioEl.volume = 1;
}
audioEl._stGainNode.connect(analyser);
}
} catch (err) {
console.warn("VU analyser hookup failed for stem", stemName, err);
continue;
}
// Time-domain buffer must be sized to fftSize (not frequencyBinCount,
// which is fftSize/2). With a too-small buffer, getByteTimeDomainData
// only writes the first N samples and trailing entries keep stale
// values, biasing RMS toward whatever was last left there.
const data = new Uint8Array(analyser.fftSize);
const vuEl = mixerEl.querySelector(`.lane-vu[data-stem="${stemName}"]`);
const miniMeterEl = document.querySelector(`.stem-list .${stemName} .mini-meter`);
trackAnalysers.push({ analyser, data, vuEl, miniMeterEl, peak: 0 });
}
const tick = () => {
for (const t of trackAnalysers) {
t.analyser.getByteTimeDomainData(t.data);
let sum = 0;
for (let i = 0; i < t.data.length; i++) {
const v = (t.data[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / t.data.length);
// Linear RMS with 2.5x gain. Compensates for the 0.5 master
// attenuation the analyser sees post-gain via createMediaElementSource,
// so typical -20 dBFS music lands in the 25-50% bar range, drum
// hits push toward 80-100%, silence falls to 0. Linear (not dB)
// gives the meter punch on transients that dB scaling smooths out.
const level = Math.min(1, rms * 2.5);
// Peak hold: bar follows level on the way up, falls slowly on
// the way down. That's what makes a VU look alive -- it lifts
// sharply on each drum hit / vocal phrase and drifts down in
// between rather than tracking instantaneous RMS jitter.
const prevPeak = t.peak || 0;
const nextPeak = level > prevPeak ? level : Math.max(0, prevPeak - 0.012);
t.peak = nextPeak;
// Separate peak-hold marker that holds the recent max for
// ~600 ms before falling, so a thin tick visually marks
// "loudest moment in the last second" above the bar.
const prevHold = t.peakHold || 0;
let nextHold = prevHold;
let holdFrames = t.holdFrames || 0;
if (level > prevHold) {
nextHold = level;
holdFrames = 36;
} else if (holdFrames > 0) {
holdFrames -= 1;
} else {
nextHold = Math.max(0, prevHold - 0.02);
}
t.peakHold = nextHold;
t.holdFrames = holdFrames;
const lvlPct = Math.round(level * 100);
const peakPct = Math.round(nextPeak * 100);
const holdPct = Math.round(nextHold * 100);
if (t.vuEl) {
if (lvlPct !== t.lastLevelPct) {
t.vuEl.style.setProperty("--vu-level", `${lvlPct}%`);
}
if (holdPct !== t.lastHoldPct) {
t.vuEl.style.setProperty("--vu-peak", `${holdPct}%`);
}
}
if (t.miniMeterEl) {
if (peakPct !== t.lastPeakPct) {
t.miniMeterEl.style.setProperty("--vu-scale", nextPeak.toFixed(3));
}
if (holdPct !== t.lastHoldPct) {
t.miniMeterEl.style.setProperty("--vu-peak-pct", String(holdPct));
t.miniMeterEl.style.setProperty(
"--vu-peak-opacity",
nextHold > 0.04 ? "1" : "0",
);
}
}
t.lastLevelPct = lvlPct;
t.lastPeakPct = peakPct;
t.lastHoldPct = holdPct;
}
setVuRafId(requestAnimationFrame(tick));
};
setVuRafId(requestAnimationFrame(tick));
}
export function stopVuLoop() {
if (vuRafId) {
cancelAnimationFrame(vuRafId);
setVuRafId(null);
}
}
+218
View File
@@ -0,0 +1,218 @@
// Web Audio decode-and-mix playback engine.
//
// Safari/WKWebView goes choppy when playing N streaming <audio> elements (one per
// stem) over HTTP/1.1: the 6-connection-per-origin cap + small media buffers + the
// multitrack's per-element currentTime nudging cause underruns. This engine instead
// decodes each active stem once into an AudioBuffer and plays them all from a single
// AudioContext clock — sample-accurate, zero streaming connections during playback,
// no drift. Works identically on WKWebView, Safari, and Chrome.
//
// Graph: per-stem AudioBufferSourceNode -> GainNode (vol/mute/solo) -> AnalyserNode (VU)
// -> masterGain -> SoundTouchNode -> destination
//
// Used behind a feature flag (see player.js) so it can be A/B'd against the legacy
// streaming path before cutover.
const AudioCtx = window.AudioContext || window.webkitAudioContext;
/**
* @param {{name:string,url:string}[]} stems Active stems only (caller filters).
* @param {{onTime?:(t:number)=>void, onEnded?:()=>void}} cbs
*/
export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
// Mobile/iOS only starts audio from a context resumed inside a user gesture.
// Callers can pass a shared, gesture-unlocked `context` (the mobile UI does);
// desktop passes none and we own a fresh one. We only close contexts we own.
const ctx = context || new AudioCtx();
const ownsCtx = !context;
const master = ctx.createGain();
// SoundTouch pitch-preserving time-stretch on the master bus.
// Falls back to tape-effect (playbackRate) if AudioWorklet is unavailable.
let stNode = null;
const _workletReady = (ctx.audioWorklet
? ctx.audioWorklet.addModule('/vendor/soundtouch-processor.js').then(() => {
stNode = new AudioWorkletNode(ctx, 'soundtouch-processor');
master.connect(stNode);
stNode.connect(ctx.destination);
}).catch((err) => {
console.warn('[audioEngine] SoundTouch worklet load failed, using tape-effect fallback:', err);
master.connect(ctx.destination);
})
: Promise.resolve().then(() => { master.connect(ctx.destination); }));
/** @type {Map<string,{buffer:AudioBuffer,gain:GainNode,analyser:AnalyserNode,source:AudioBufferSourceNode|null}>} */
const tracks = new Map();
let duration = 0;
let playing = false;
let startCtxTime = 0; // ctx.currentTime at playback start
let startOffset = 0; // media offset at that moment
let rafId = null;
let destroyed = false;
let loop = { enabled: false, start: 0, end: 0 };
let _playbackRate = 1.0;
// Decode all stems up front AND load the SoundTouch worklet in parallel.
// Resolves true once at least one stem is ready (worklet load is best-effort).
const ready = (async () => {
await Promise.all([
_workletReady,
...stems.map(async (s) => {
if (!s?.url) return;
try {
const res = await fetch(s.url);
if (!res.ok) throw new Error(`fetch ${res.status}`);
const buffer = await ctx.decodeAudioData(await res.arrayBuffer());
if (destroyed) return;
const gain = ctx.createGain();
const analyser = ctx.createAnalyser();
analyser.fftSize = 1024;
gain.connect(analyser);
analyser.connect(master);
tracks.set(s.name, { buffer, gain, analyser, source: null });
duration = Math.max(duration, buffer.duration);
} catch (e) {
console.warn(`[audioEngine] decode failed for ${s.name}:`, e);
}
}),
]);
return tracks.size > 0;
})();
const now = () => (playing ? (ctx.currentTime - startCtxTime) * _playbackRate + startOffset : startOffset);
function stopSources() {
for (const t of tracks.values()) {
if (t.source) {
try { t.source.stop(); } catch { /* already stopped */ }
try { t.source.disconnect(); } catch { /* noop */ }
t.source = null;
}
}
}
function startSources(offset) {
const when = ctx.currentTime;
for (const t of tracks.values()) {
const src = ctx.createBufferSource();
src.buffer = t.buffer;
// SoundTouch handles time-stretch; playbackRate stays 1.0.
// Falls back to tape-effect only when the worklet is unavailable.
if (!stNode) src.playbackRate.value = _playbackRate;
src.connect(t.gain);
src.start(when, Math.max(0, Math.min(offset, t.buffer.duration)));
t.source = src;
}
startCtxTime = when;
startOffset = offset;
}
function tick() {
if (!playing) return;
let t = now();
if (loop.enabled && loop.end > loop.start && t >= loop.end) {
seek(loop.start);
t = loop.start;
} else if (t >= duration) {
pause();
startOffset = duration;
onTime?.(duration);
onEnded?.();
return;
}
onTime?.(t);
rafId = requestAnimationFrame(tick);
}
function play() {
if (playing || destroyed || !tracks.size) return;
// Safari: resume the context fire-and-forget within the user-gesture tick.
if (ctx.state === "suspended") ctx.resume().catch(() => {});
let off = startOffset;
if (off >= duration) off = 0;
startSources(off);
playing = true;
rafId = requestAnimationFrame(tick);
}
function pause() {
if (!playing) return;
const t = now();
stopSources();
playing = false;
startOffset = Math.max(0, Math.min(t, duration));
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
}
function seek(t) {
const clamped = Math.max(0, Math.min(t, duration || 0));
if (playing) {
stopSources();
startSources(clamped);
} else {
startOffset = clamped;
}
onTime?.(clamped);
}
function setGain(name, v) {
const t = tracks.get(name);
if (t) t.gain.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
}
function setMasterGain(v) {
master.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
}
function destroy() {
destroyed = true;
stopSources();
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
tracks.clear();
if (stNode) { try { stNode.disconnect(); } catch { /* noop */ } }
if (ownsCtx) ctx.close().catch(() => {});
}
return {
ready,
play,
pause,
seek,
setTime: seek, // alias to match the multitrack interface used by transport.js
isPlaying: () => playing,
getCurrentTime: now,
getDuration: () => duration,
setLoop: (enabled, start, end) => { loop = { enabled, start, end }; },
setPlaybackRate(rate) {
_playbackRate = rate;
if (stNode) {
// Pitch-preserving: update SoundTouch tempo parameter
stNode.parameters.get('tempo').value = rate;
} else {
// Tape-effect fallback
for (const t of tracks.values()) {
if (t.source) t.source.playbackRate.value = rate;
}
}
},
setGain,
setMasterGain,
getAnalyser: (name) => tracks.get(name)?.analyser ?? null,
// Decoded AudioBuffers keyed by stem name — reused by the visuals (overview
// waveforms, mini-waves, VU envelopes, energy bars) so they don't need the
// multitrack to also decode the audio. Map<name, AudioBuffer>.
getBuffers: () => {
const m = new Map();
for (const [name, t] of tracks) m.set(name, t.buffer);
return m;
},
destroy,
audioContext: ctx,
};
}
// Rough decoded-PCM memory estimate (Float32 = 4 bytes/sample/channel) used by the
// caller's guard to fall back to streaming for very long / many-stem tracks.
export function estimateDecodedBytes(durationSec, stemCount, channels = 2, sampleRate = 44100) {
return Math.round(durationSec * stemCount * channels * sampleRate * 4);
}
+2194
View File
File diff suppressed because it is too large Load Diff
+442
View File
@@ -0,0 +1,442 @@
// Chunked audio engine for the mobile player.
//
// Fetches WAV stems in fixed-size windows via HTTP Range requests and chains
// AudioBufferSourceNodes back-to-back for gapless playback. Compared to the
// full-decode engine (audioEngine.js):
// - First audio after ~7 MB download (one 10-second chunk per 4 stems on WiFi)
// instead of waiting for the complete file
// - Peak RAM ~28 MB vs ~420 MB for a 5-minute 4-stem track
// - No track-length cap
// - Same glitch-free behavior on Safari/WKWebView: AudioBufferSourceNode,
// no streaming elements, no HTTP/1.1 connection-cap underruns
//
// The backend's FileResponse already handles Range requests natively (Starlette
// 1.3.x), so no server-side changes are needed.
//
// Graph: per-stem AudioBufferSourceNode -> GainNode -> masterGain -> SoundTouchNode -> destination
const CHUNK_SEC = 5; // seconds of audio per chunk
const LOOKAHEAD_SEC = 12; // schedule next chunk this far ahead of playhead
// ---------------------------------------------------------------------------
// WAV parsing
// ---------------------------------------------------------------------------
function _parseWavHeader(buf) {
const view = new DataView(buf);
const tag = (off) => String.fromCharCode(...new Uint8Array(buf, off, 4));
if (tag(0) !== "RIFF" || tag(8) !== "WAVE") return null;
let audioFormat = 1, channels = 2, sampleRate = 44100, bitsPerSample = 16;
let dataOffset = -1, dataSize = 0;
let off = 12;
while (off + 8 <= buf.byteLength) {
const id = tag(off);
const size = view.getUint32(off + 4, true);
if (id === "fmt ") {
audioFormat = view.getUint16(off + 8, true);
channels = view.getUint16(off + 10, true);
sampleRate = view.getUint32(off + 12, true);
bitsPerSample = view.getUint16(off + 22, true);
} else if (id === "data") {
dataOffset = off + 8;
dataSize = size;
break;
}
off += 8 + size + (size & 1); // chunks are word-aligned
}
if (dataOffset < 0) return null;
const bytesPerFrame = channels * (bitsPerSample >> 3);
return {
audioFormat, channels, sampleRate, bitsPerSample,
dataOffset, dataSize, bytesPerFrame,
duration: dataSize / (bytesPerFrame * sampleRate),
};
}
// Convert raw interleaved PCM bytes to an AudioBuffer.
// Fast paths for the common cases (stereo 16-bit, stereo float32).
function _pcmToAudioBuffer(ctx, pcmData, header) {
const { channels, sampleRate, bitsPerSample, audioFormat } = header;
const totalSamples = Math.floor(pcmData.byteLength / (channels * (bitsPerSample >> 3)));
if (totalSamples === 0) return null;
const ab = ctx.createBuffer(channels, totalSamples, sampleRate);
if (bitsPerSample === 16) {
const src = new Int16Array(pcmData);
const scale = 1 / 32768;
if (channels === 2) {
const ch0 = ab.getChannelData(0);
const ch1 = ab.getChannelData(1);
for (let i = 0, j = 0; i < totalSamples; i++, j += 2) {
ch0[i] = src[j] * scale;
ch1[i] = src[j + 1] * scale;
}
} else {
for (let ch = 0; ch < channels; ch++) {
const out = ab.getChannelData(ch);
for (let i = 0; i < totalSamples; i++) out[i] = src[i * channels + ch] * scale;
}
}
} else if (audioFormat === 3 && bitsPerSample === 32) {
const src = new Float32Array(pcmData);
if (channels === 2) {
const ch0 = ab.getChannelData(0);
const ch1 = ab.getChannelData(1);
for (let i = 0, j = 0; i < totalSamples; i++, j += 2) {
ch0[i] = src[j];
ch1[i] = src[j + 1];
}
} else {
for (let ch = 0; ch < channels; ch++) {
const out = ab.getChannelData(ch);
for (let i = 0; i < totalSamples; i++) out[i] = src[i * channels + ch];
}
}
} else {
return null; // unsupported format
}
return ab;
}
// ---------------------------------------------------------------------------
// Engine factory
// ---------------------------------------------------------------------------
/**
* @param {{name:string,url:string}[]} stems Active stems (WAV URLs).
* @param {{onTime?:(t:number)=>void, onEnded?:()=>void, context?:AudioContext}} opts
*/
export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {}) {
const AC = window.AudioContext || window.webkitAudioContext;
const ctx = context || new AC();
const ownsCtx = !context;
const master = ctx.createGain();
let stNode = null;
let _playbackRate = 1.0;
const _workletReady = (ctx.audioWorklet
? ctx.audioWorklet.addModule('/vendor/soundtouch-processor.js').then(() => {
stNode = new AudioWorkletNode(ctx, 'soundtouch-processor');
master.connect(stNode);
stNode.connect(ctx.destination);
}).catch((err) => {
console.warn('[chunkedEngine] SoundTouch worklet failed, tape-effect fallback:', err);
master.connect(ctx.destination);
})
: Promise.resolve().then(() => { master.connect(ctx.destination); }));
// Per-stem state: url, parsed WAV header, gain node, currently playing nodes
const stemMap = new Map();
for (const s of stems) {
if (!s?.url) continue;
const gain = ctx.createGain();
gain.connect(master);
stemMap.set(s.name, { url: s.url, header: null, gain, activeNodes: [] });
}
let _duration = 0;
let playing = false;
let destroyed = false;
let rafId = null;
// Playback clock: getCurrentTime = ctx.currentTime - _startCtxTime + _startOffset
let _startCtxTime = 0;
let _startOffset = 0;
// _scheduledTo: track position (seconds) up to which AudioBufferSourceNodes
// have already been scheduled. Always sits at a chunk boundary after play().
let _scheduledTo = 0;
// True once the first AudioBufferSourceNode is actually queued; guards
// getCurrentTime() from advancing during an async chunk fetch.
let _audioStarted = false;
let _filling = false; // prevents concurrent _scheduleNext() calls
// Chunk cache: chunkIdx -> { promise: Promise<Map>, result: Map|null }
// result is set synchronously once the promise resolves so play() can
// schedule chunk 0 without an async await after ready() completes.
const _cache = new Map();
function _getCurrentTime() {
if (!playing || !_audioStarted) return _startOffset;
return Math.min((ctx.currentTime - _startCtxTime) * _playbackRate + _startOffset, _duration);
}
// --- fetch helpers ---
async function _fetchHeader(url) {
const res = await fetch(url, { headers: { Range: "bytes=0-1023" } });
const buf = await res.arrayBuffer();
return _parseWavHeader(buf);
}
async function _fetchPcm(stem, chunkIdx) {
const { url, header } = stem;
const { dataOffset, dataSize, bytesPerFrame, sampleRate } = header;
const chunkBytes = Math.floor(CHUNK_SEC * sampleRate) * bytesPerFrame;
const byteStart = dataOffset + chunkIdx * chunkBytes;
if (byteStart >= dataOffset + dataSize) return null; // past end of file
const byteEnd = Math.min(byteStart + chunkBytes, dataOffset + dataSize) - 1;
const res = await fetch(url, { headers: { Range: `bytes=${byteStart}-${byteEnd}` } });
if (!res.ok && res.status !== 206) throw new Error(`Range fetch ${res.status}`);
return res.arrayBuffer();
}
// Returns a Promise<Map<name, AudioBuffer>>. Deduplicates: if a fetch for
// chunkIdx is already in flight, returns the same promise.
function _fetchChunk(chunkIdx) {
const hit = _cache.get(chunkIdx);
if (hit) return hit.promise;
const entry = { promise: null, result: null };
entry.promise = (async () => {
const pairs = await Promise.all(
[...stemMap.entries()]
.filter(([, s]) => s.header)
.map(async ([name, stem]) => {
try {
const pcm = await _fetchPcm(stem, chunkIdx);
if (!pcm) return [name, null];
return [name, _pcmToAudioBuffer(ctx, pcm, stem.header)];
} catch (e) {
console.warn(`[chunked] chunk ${chunkIdx} stem ${name}:`, e);
return [name, null];
}
})
);
const map = new Map(pairs.filter(([, b]) => b));
entry.result = map;
// Keep at most the previous chunk + current in cache to bound memory.
for (const k of _cache.keys()) {
if (k < chunkIdx - 1) _cache.delete(k);
}
return map;
})();
_cache.set(chunkIdx, entry);
return entry.promise;
}
// --- node lifecycle ---
function _stopNodes() {
for (const stem of stemMap.values()) {
for (const node of stem.activeNodes) {
try { node.stop(); } catch { /* already stopped */ }
try { node.disconnect(); } catch { /* noop */ }
}
stem.activeNodes = [];
}
}
// Schedule all stems' AudioBufferSourceNodes to start at `when` (AudioContext
// time), beginning `startSecs` into each buffer. Returns the duration of audio
// that will play (max over stems of buffer.duration - startSecs).
function _scheduleChunk(buffers, when, startSecs) {
let playDur = 0;
for (const [name, stem] of stemMap) {
const buf = buffers.get(name);
if (!buf) continue;
const node = ctx.createBufferSource();
node.buffer = buf;
if (!stNode) node.playbackRate.value = _playbackRate; // tape-effect fallback
node.connect(stem.gain);
const offset = Math.max(0, Math.min(startSecs, buf.duration - 0.001));
node.start(when, offset);
stem.activeNodes.push(node);
playDur = Math.max(playDur, buf.duration - offset);
}
return playDur;
}
// --- lookahead scheduler ---
async function _scheduleNext() {
const chunkIdx = Math.floor(_scheduledTo / CHUNK_SEC);
_fetchChunk(chunkIdx + 1); // fire-and-forget pre-fetch of the chunk after
// Use the synchronous result if already decoded, otherwise await.
const hit = _cache.get(chunkIdx);
const buffers = hit?.result ?? await _fetchChunk(chunkIdx);
if (!playing || destroyed) return;
if (!buffers || buffers.size === 0) return; // past end; tick() handles onEnded
// With SoundTouch, sources play at 1.0x so scheduled wall-clock time equals
// audio time. With tape-effect (_playbackRate != 1, no stNode), sources play
// faster/slower so wall-clock time = audio seconds / _playbackRate.
const playFactor = stNode ? 1.0 : _playbackRate;
const idealWhen = _startCtxTime + (_scheduledTo - _startOffset) / playFactor;
const when = Math.max(idealWhen, ctx.currentTime + 0.01);
// If we're late (slow network), skip the audio portion that already "passed".
const firstBuf = buffers.values().next().value;
const maxSkip = firstBuf ? Math.max(0, firstBuf.duration - 0.001) : 0;
const bufOffset = Math.min(Math.max(0, when - idealWhen) * playFactor, maxSkip);
const dur = _scheduleChunk(buffers, when, bufOffset);
_scheduledTo += bufOffset + dur; // always advances by ~CHUNK_SEC
}
function _maybeSchedule() {
if (_filling || !playing || destroyed) return;
if (_scheduledTo >= _duration) return;
if (_scheduledTo - _getCurrentTime() < LOOKAHEAD_SEC) {
_filling = true;
_scheduleNext().finally(() => { _filling = false; });
}
}
function _tick() {
if (!playing) return;
const t = _getCurrentTime();
if (t >= _duration) {
playing = false;
_audioStarted = false;
_startOffset = _duration;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
onTime?.(_duration);
onEnded?.();
return;
}
_maybeSchedule();
onTime?.(t);
rafId = requestAnimationFrame(_tick);
}
// --- public API ---
function play() {
if (playing || destroyed) return;
if (ctx.state === "suspended") ctx.resume().catch(() => {});
playing = true;
const chunkIdx = Math.floor(_startOffset / CHUNK_SEC);
const offsetWithin = _startOffset - chunkIdx * CHUNK_SEC;
const startWith = (buffers) => {
if (!playing || destroyed) return;
const when = ctx.currentTime + 0.05;
_startCtxTime = when;
const dur = _scheduleChunk(buffers, when, offsetWithin);
_scheduledTo = _startOffset + dur;
_audioStarted = true;
_fetchChunk(chunkIdx + 1); // pre-fetch next chunk
rafId = requestAnimationFrame(_tick);
};
// chunk 0 is pre-decoded during ready(), so the sync path is the hot path.
const hit = _cache.get(chunkIdx);
if (hit?.result) {
startWith(hit.result);
} else {
_fetchChunk(chunkIdx)
.then(startWith)
.catch((e) => {
console.warn("[chunked] play fetch failed:", e);
playing = false;
_audioStarted = false;
});
}
}
function pause() {
if (!playing) return;
_startOffset = _getCurrentTime();
_stopNodes();
playing = false;
_audioStarted = false;
_scheduledTo = _startOffset;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
}
function seek(t) {
const clamped = Math.max(0, Math.min(t, _duration || 0));
const wasPlaying = playing;
if (wasPlaying) {
_stopNodes();
playing = false;
_audioStarted = false;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
}
_startOffset = clamped;
_scheduledTo = clamped;
// Evict cache for chunks before the new position.
const newIdx = Math.floor(clamped / CHUNK_SEC);
for (const k of _cache.keys()) {
if (k < newIdx) _cache.delete(k);
}
onTime?.(clamped);
if (wasPlaying) play();
}
// Initialize: fetch all WAV headers in parallel and load the SoundTouch worklet.
// Chunk 0 is kicked off in the background so ready() resolves quickly (headers
// only, ~6 x 1 KB) instead of blocking on the full first-chunk download (~5 MB).
// play() handles the case where chunk 0 is not yet cached.
const ready = (async () => {
if (!stemMap.size) return false;
await Promise.all([
_workletReady,
...[...stemMap.values()].map(async (stem) => {
try { stem.header = await _fetchHeader(stem.url); }
catch (e) { console.warn("[chunked] header fetch failed:", e); }
}),
]);
for (const stem of stemMap.values()) {
if (stem.header) _duration = Math.max(_duration, stem.header.duration);
}
if (!_duration) return false;
// Kick off chunk 0 and 1 in the background; play() picks up the cached result.
_fetchChunk(0);
_fetchChunk(1);
return true;
})();
return {
ready,
play,
pause,
seek,
setTime: seek,
isPlaying: () => playing,
getCurrentTime: _getCurrentTime,
getDuration: () => _duration,
setLoop: () => {},
setGain(name, v) {
const stem = stemMap.get(name);
if (stem) stem.gain.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
},
setMasterGain(v) {
master.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
},
setPlaybackRate(rate) {
const t = _getCurrentTime(); // capture before updating rate
_playbackRate = rate;
if (stNode) {
stNode.parameters.get('tempo').value = rate;
} else if (playing) {
// Tape-effect fallback: seek to current position so new source nodes
// are created with the updated playbackRate and scheduling math resets.
seek(t);
}
},
getAnalyser: () => null,
getBuffers: () => new Map(),
destroy() {
destroyed = true;
if (playing) pause();
if (stNode) { try { stNode.disconnect(); } catch { /* noop */ } }
_cache.clear();
stemMap.clear();
if (ownsCtx) ctx.close().catch(() => {});
},
audioContext: ctx,
};
}
+47
View File
@@ -0,0 +1,47 @@
// Fallback list used before /api/config responds. Kept in sync with
// STEM_NAMES in app/core/config.py — the API is the canonical source.
export let STEM_NAMES = ["vocals", "drums", "bass", "guitar", "piano", "other"];
export let TRACK_NAMES = ["original", ...STEM_NAMES];
export async function syncStemNamesFromAPI() {
try {
const res = await fetch("/api/config");
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.stem_names) && data.stem_names.length > 0) {
STEM_NAMES = data.stem_names;
TRACK_NAMES = ["original", ...STEM_NAMES];
}
} catch (e) {
console.warn("[constants] failed to sync stem names from API:", e);
}
}
export const STEM_DISPLAY = {
vocals: "Vocals",
drums: "Drums",
bass: "Bass",
guitar: "Guitar",
piano: "Piano",
other: "Other",
original: "Original",
};
// FL Studio-style channel palette: saturated but slightly dusty, designed
// to read well on a dark background.
export const STEM_COLORS = {
vocals: "#e85f6f",
drums: "#e89048",
bass: "#e8b848",
guitar: "#88d878",
piano: "#b88fe8",
other: "#88a8c8",
original: "#a8b0bd",
};
export const PROGRESS_COLOR = "#3a3a3a";
export const LOOP_DEFAULT_START_FRAC = 0.25;
export const LOOP_DEFAULT_END_FRAC = 0.5;
export const LANE_VOLUME_MAX = 2;
+511
View File
@@ -0,0 +1,511 @@
import {
form, urlInput, submitBtn, errorEl, jobBox, jobTitleEl, jobStageEl,
jobDetailEl, jobCancelBtn, progressEl, titleEl, bpmChip, keyChip,
eventSource, setEventSource, setCurrentJobId, currentJobId,
selectedStems,
} from "./state.js";
import { destroyPlayer, wireUpAudio, setWaveformLoading, updateFooterTrack } from "./player.js";
import { stagePhrases } from "./phrases.js";
import { addTrackToLibrary, setCurrentTrack, updateTrackStatus, applyStemPresenceCards } from "./catalog.js";
import { initSections } from "./sections.js";
// Playful stage label rotation (Claude-Code-style flair). The backend
// emits truthful stage strings; we surface them in the small #job-detail
// line so progress is debuggable, while #job-stage rotates whimsy.
const ROTATION_MS = 2500;
let phraseTimerId = null;
let lastStatus = null;
let jobPollTimerId = null;
const renderedJobs = new Set();
const jobSources = new Map();
const TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]);
function setSubmitProcessing(processing) {
submitBtn.disabled = processing;
submitBtn.classList.toggle("loading", processing);
document.querySelector(".strip-sq-process")?.classList.toggle("loading", processing);
const label = submitBtn.querySelector("span");
if (label) label.textContent = processing ? "Processing" : "Process";
}
function pickPhrase(status) {
const pool = stagePhrases[status] || stagePhrases.default;
return pool[Math.floor(Math.random() * pool.length)];
}
function setOverlayPhrase(text) {
const el = document.getElementById("waveLoadingPhrase");
if (el) el.textContent = text;
}
function startPhraseRotation(status) {
stopPhraseRotation();
const phrase = pickPhrase(status);
jobStageEl.textContent = phrase;
setOverlayPhrase(phrase);
phraseTimerId = setInterval(() => {
const p = pickPhrase(status);
jobStageEl.textContent = p;
setOverlayPhrase(p);
}, ROTATION_MS);
}
function stopPhraseRotation() {
if (phraseTimerId) {
clearInterval(phraseTimerId);
phraseTimerId = null;
}
jobStageEl.textContent = "";
}
function stopJobPolling() {
if (jobPollTimerId) {
clearInterval(jobPollTimerId);
jobPollTimerId = null;
}
}
export function showError(message) {
errorEl.textContent = "";
const msg = document.createElement("div");
msg.className = "error-msg";
msg.textContent = message;
const retry = document.createElement("button");
retry.className = "retry-btn";
retry.type = "button";
retry.textContent = "Try again";
retry.addEventListener("click", () => {
errorEl.classList.add("hidden");
urlInput.focus();
urlInput.select();
});
errorEl.append(msg, retry);
errorEl.classList.remove("hidden");
}
export function reset() {
if (eventSource) {
eventSource.close();
setEventSource(null);
}
stopJobPolling();
stopPhraseRotation();
lastStatus = null;
destroyPlayer();
errorEl.classList.add("hidden");
errorEl.textContent = "";
jobBox.classList.add("hidden");
jobCancelBtn.classList.add("hidden");
jobTitleEl.textContent = "";
jobStageEl.textContent = "";
jobDetailEl.textContent = "";
progressEl.value = 0;
setSubmitProcessing(false);
setCurrentJobId(null);
}
function applyState(state) {
if (state.job_id) {
addTrackToLibrary({
id: state.job_id,
title: state.title || urlInput.value || "Processing track",
channel: state.status === "done" ? "Extracted" : "Processing",
thumb: state.thumbnail,
stems: state.selected_stems || state.stems?.map((stem) => stem.name) || [...selectedStems],
selectedStems: state.selected_stems || [...selectedStems],
audioStems: state.stems || [],
status: state.status,
duration: state.duration,
bpm: state.bpm,
key: state.key,
scale: state.scale,
keyConfidence: state.key_confidence,
lufs: state.lufs,
peakDb: state.peak_db,
stemPresence: state.stem_presence,
sourceUrl: jobSources.get(state.job_id) || urlInput.value,
createdAt: state.created_at,
});
setCurrentTrack(state.job_id);
}
if (state.title) {
jobTitleEl.textContent = state.title;
titleEl.textContent = state.title;
}
if (state.bpm) bpmChip.textContent = `${state.bpm} BPM`;
if (state.key) keyChip.textContent = state.key;
if (state.title || state.bpm || state.key || state.thumbnail) {
updateFooterTrack({
title: state.title,
thumbnail: state.thumbnail,
key: state.key,
bpm: state.bpm,
stemCount: state.stems ? state.stems.filter((s) => s.name !== "original").length : null,
});
}
const summaryKey = document.getElementById("summary-key");
const summaryBpm = document.getElementById("summary-bpm");
const summaryScale = document.getElementById("summary-scale");
const summaryScaleName = document.getElementById("summary-scale-name");
const summaryConfidence = document.getElementById("summary-confidence");
const summaryConfidenceLabel = document.getElementById("summary-confidence-label");
const summaryLufs = document.getElementById("summary-lufs");
const summaryPeak = document.getElementById("summary-peak");
const summaryDuration = document.getElementById("summary-duration");
if (summaryKey && state.key) summaryKey.textContent = state.key;
if (summaryBpm && state.bpm) summaryBpm.textContent = String(state.bpm);
if (summaryScale && state.scale) summaryScale.textContent = state.scale;
if (summaryScaleName && state.scale) summaryScaleName.textContent = state.scale;
if (summaryLufs && state.lufs != null) summaryLufs.textContent = state.lufs.toFixed(1);
if (summaryPeak && state.peak_db != null) summaryPeak.textContent = `Peak ${state.peak_db.toFixed(1)} dB`;
if (summaryDuration && state.duration) {
const m = Math.floor(state.duration / 60);
const s = Math.floor(state.duration % 60).toString().padStart(2, "0");
summaryDuration.textContent = `${m.toString().padStart(2, "0")}:${s}`;
}
if (summaryConfidence && state.key_confidence != null) {
const confidence = Math.max(0, Math.min(100, Number(state.key_confidence)));
const confSpan = document.createElement("span");
confSpan.textContent = `${confidence}%`;
summaryConfidence.textContent = "";
summaryConfidence.appendChild(confSpan);
summaryConfidence.style.setProperty("--confidence-pct", confidence);
summaryConfidence.classList.remove("hidden");
summaryConfidenceLabel?.classList.remove("hidden");
}
const summaryDr = document.getElementById("summary-dr");
const summaryDrLabel = document.getElementById("summary-dr-label");
const summaryStability = document.getElementById("summary-stability");
const summaryStabilityLabel = document.getElementById("summary-stability-label");
if (summaryDr && state.dynamic_range != null) summaryDr.textContent = String(state.dynamic_range);
if (summaryDrLabel && state.dynamic_range != null) {
const dr = state.dynamic_range;
summaryDrLabel.textContent = dr < 7 ? "Compressed" : dr < 10 ? "Moderate" : dr < 14 ? "High" : "Wide";
}
if (summaryStability && state.tempo_stability != null) {
summaryStability.textContent = `${state.tempo_stability}%`;
summaryStability.className = "meta-card-value" + (state.tempo_stability >= 80 ? " stability-high" : "");
}
if (summaryStabilityLabel && state.tempo_stability != null) {
const s = state.tempo_stability;
summaryStabilityLabel.textContent = s >= 90 ? "Very Stable" : s >= 70 ? "Stable" : s >= 50 ? "Moderate" : "Variable";
}
if (state.stem_presence != null) {
applyStemPresenceCards(state.stem_presence);
}
// Stage label is owned by the phrase-rotation timer below; we don't
// overwrite it from each SSE tick. The truthful backend stage goes
// to the small detail line instead.
jobDetailEl.textContent = state.stage || "";
progressEl.value = Math.round((state.progress || 0) * 100);
// Cancel button is visible exactly while the job is in a non-terminal state.
const terminal = TERMINAL_STATUSES.has(state.status);
jobCancelBtn.classList.toggle("hidden", terminal);
if (state.status !== lastStatus) {
if (terminal) stopPhraseRotation();
else startPhraseRotation(state.status);
lastStatus = state.status;
}
if (state.status === "error") {
stopJobPolling();
updateTrackStatus(state.job_id, "error");
setWaveformLoading(false);
showError(state.error || "Unknown error");
setSubmitProcessing(false);
} else if (state.status === "cancelled") {
stopJobPolling();
updateTrackStatus(state.job_id, "cancelled");
setWaveformLoading(false);
jobBox.classList.add("hidden");
setSubmitProcessing(false);
} else if (state.status === "done") {
stopJobPolling();
updateTrackStatus(state.job_id, "done");
jobBox.classList.add("hidden");
if (!renderedJobs.has(state.job_id)) {
renderedJobs.add(state.job_id);
wireUpAudio(
state.job_id,
state.stems || [],
state.duration || 0,
state.thumbnail,
state.mix_url ?? null,
state.title || "",
null,
state.has_video ?? false,
);
initSections(state.job_id, state.sections, state.duration || 0);
}
setSubmitProcessing(false);
}
}
async function probeJob(jobId) {
const r = await fetch(`/api/jobs/${jobId}`);
if (!r.ok) {
if (r.status === 404) throw new Error("Job no longer exists on the server");
throw new Error(`Job probe failed: ${r.status}`);
}
const s = await r.json();
applyState(s);
return s;
}
function startJobPolling(jobId) {
stopJobPolling();
const tick = async () => {
try {
const s = await probeJob(jobId);
if (TERMINAL_STATUSES.has(s.status)) stopJobPolling();
} catch (err) {
console.warn("[job] REST fallback failed:", err);
}
};
tick();
jobPollTimerId = setInterval(tick, 1000);
}
// Connect (or reconnect) to the SSE stream for a job. On unexpected
// disconnect we probe /api/jobs/{id} to decide: if the job is already
// terminal, accept its final state; otherwise reconnect with backoff.
// Falls back to REST polling only after SSE exhausts its retry budget.
function connectEvents(jobId) {
let attempt = 0;
let stopped = false;
const open = () => {
const es = new EventSource(`/api/jobs/${jobId}/events`);
setEventSource(es);
es.onmessage = (ev) => {
attempt = 0; // any successful frame resets backoff
let s;
try { s = JSON.parse(ev.data); } catch { return; }
// Defer by one tick so synchronous user event handlers (clicks,
// input events) always complete before SSE state is applied.
setTimeout(() => {
applyState(s);
if (TERMINAL_STATUSES.has(s.status)) {
stopped = true;
es.close();
setEventSource(null);
}
}, 0);
};
es.onerror = async () => {
if (stopped) return;
es.close();
setEventSource(null);
// Probe REST once before declaring failure -- handles dev-server
// reloads and brief network blips where the job is actually fine.
try {
const s = await probeJob(jobId);
if (TERMINAL_STATUSES.has(s.status)) {
stopped = true;
return;
}
} catch (err) {
if (err.message === "Job no longer exists on the server") {
stopped = true;
showError(err.message);
setSubmitProcessing(false);
return;
}
// Network down -- fall through to backoff.
}
attempt += 1;
if (attempt > 6) {
// SSE gave up — activate REST polling as the fallback.
startJobPolling(jobId);
return;
}
// 0.5s, 1s, 2s, 4s, 8s, 16s
const delay = 500 * Math.pow(2, attempt - 1);
setTimeout(() => { if (!stopped) open(); }, delay);
};
};
open();
}
async function cancelCurrentJob() {
const id = currentJobId;
if (!id) return;
jobCancelBtn.disabled = true;
jobCancelBtn.textContent = "Cancelling…";
try {
await fetch(`/api/jobs/${id}/cancel`, { method: "POST" });
// The next SSE frame (or the REST probe in connectEvents) will
// surface the cancelled state and hide the button via applyState.
} catch {
/* SSE will reflect the result regardless */
} finally {
jobCancelBtn.disabled = false;
jobCancelBtn.textContent = "Cancel";
}
}
function sanitizeFilename(name) {
// Strip extension, collapse whitespace, cap at 120 chars — mirrors the
// backend _sanitize_title() so title and sourceUrl match on both sides.
return name
.replace(/\.[^.]+$/, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120);
}
// Programmatic URL import — re-uses the full studio/SSE pipeline (same as the
// import form's URL path). Used by the library "Sync again" auto-restore to
// re-download + re-separate a track whose backend audio was swept. Takes over
// the studio like a normal import. Returns the new job id, or null on failure.
export async function importFromUrl(url, { title, stems } = {}) {
if (!url || url.startsWith("local:")) return null; // local files can't auto-restore
reset();
setSubmitProcessing(true);
setWaveformLoading(true, "");
const stemSel = stems?.length ? stems : [...selectedStems];
let jobId;
try {
const res = await fetch("/api/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, stems: stemSel }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.statusText);
jobId = data.job_id;
} catch (err) {
showError(`Failed to restore track: ${err.message}`);
setSubmitProcessing(false);
return null;
}
setCurrentJobId(jobId);
jobSources.set(jobId, url);
// Merges into the existing library entry by sourceUrl (replaceTrackId),
// preserving its folder placement; status updates as SSE frames arrive.
addTrackToLibrary({
id: jobId,
title: title || url || "Processing track",
channel: "Processing",
thumb: "",
stems: stemSel,
selectedStems: stemSel,
audioStems: [],
status: "processing",
bpm: null,
key: null,
scale: null,
keyConfidence: null,
lufs: null,
peakDb: null,
sourceUrl: url,
});
setCurrentTrack(jobId);
jobBox.classList.add("hidden");
jobCancelBtn.classList.add("hidden");
startPhraseRotation("queued");
lastStatus = "queued";
connectEvents(jobId);
return jobId;
}
export function wireJobForm() {
jobCancelBtn.addEventListener("click", cancelCurrentJob);
form.addEventListener("submit", async (e) => {
e.preventDefault();
reset();
setSubmitProcessing(true);
const fileInput = document.getElementById("fileInput");
// Prefer _file cache: browsers (WKWebView, Chromium) silently clear
// fileInput.files after a fetch() submission, breaking re-submits.
const file = fileInput?._file ?? fileInput?.files?.[0] ?? null;
const sanitized = file ? sanitizeFilename(file.name) : null;
const sourceUrl = file ? `local:${sanitized}` : urlInput.value;
const displayTitle = sanitized ?? (urlInput.value || "Processing track");
const postUrlText = document.getElementById("post-url-text");
if (postUrlText) postUrlText.textContent = displayTitle;
// Show overlay immediately for both paths. File uploads show "Uploading…"
// in the overlay phrase until the fetch completes and SSE takes over.
setWaveformLoading(true, file ? "Uploading…" : "");
if (file) {
lastStatus = "queued";
}
let fetchInit;
if (file) {
const fd = new FormData();
fd.append("file", file);
fd.append("stems", JSON.stringify([...selectedStems]));
fetchInit = { method: "POST", body: fd };
} else {
fetchInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: urlInput.value,
// Backend uses this to decide whether to ffmpeg-amix a
// "selected stems" track (mix.wav) at the end of the pipeline.
stems: [...selectedStems],
}),
};
}
let jobId;
try {
const res = await fetch("/api/jobs", fetchInit);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.statusText);
jobId = data.job_id;
} catch (err) {
if (file) jobBox.classList.add("hidden");
showError(`Failed to start job: ${err.message}`);
setSubmitProcessing(false);
return;
}
setCurrentJobId(jobId);
jobSources.set(jobId, sourceUrl);
addTrackToLibrary({
id: jobId,
title: displayTitle,
channel: "Processing",
thumb: "",
stems: [...selectedStems],
selectedStems: [...selectedStems],
audioStems: [],
status: "processing",
bpm: null,
key: null,
scale: null,
keyConfidence: null,
lufs: null,
peakDb: null,
sourceUrl,
});
setCurrentTrack(jobId);
// Both paths: keep job box hidden, overlay drives the UI.
// Start phrase rotation now that the job exists on the server.
jobBox.classList.add("hidden");
jobCancelBtn.classList.add("hidden");
startPhraseRotation("queued");
lastStatus = "queued";
connectEvents(jobId);
});
}
+443
View File
@@ -0,0 +1,443 @@
import {
playBtn, loopBtn, multitrack, totalDuration, loopEnabled, loopStart, loopEnd,
setLoopStart, setLoopEnd, selectedStems, saveSelectedStems, stemSelectionReady,
} from "./state.js";
import { STEM_NAMES, syncStemNamesFromAPI } from "./constants.js";
import { renderEmptyShell, buildStripStems, downloadCurrentMix, downloadCurrentVideo, downloadAllStemsZip, downloadRegionMix, drawFooterPlaceholder } from "./player.js";
import { wireJobForm, showError } from "./job.js";
import { wireTransportButtons } from "./transport.js";
import { togglePlayPause, updateLoopRegionVisual } from "./transport.js";
import { wireStemListControls, wireMixerToolbar } from "./mixer.js";
import { initCatalog } from "./catalog.js";
import { runStoreMigrationIfNeeded } from "./utils.js";
// ─── Stem choice toggles on the import page ───
//
// Filter-chip semantics (Spotify-style). The natural mental model when
// a user sees all 6 stems lit up is "everything is extracted"; when
// they then click ONE chip, they expect "now only this one". A plain
// toggle inverts the clicked chip and leaves the others on, which
// reads as "I just deselected the one I wanted" -- exactly the user
// confusion that prompted this fix.
//
// Algorithm:
// - "All selected" is the implicit default (no filter applied).
// - First click on a chip while in default state switches to
// "only this stem" (clears all others).
// - Subsequent clicks on inactive chips ADD them to the filter.
// - Clicks on the only-selected chip clear it; if that empties the
// selection, we revert to "all selected" (wraparound).
//
// Persisted across reloads so the next song honors the user's last
// chosen subset, but a 0-selection state is normalized to all 6.
function refreshStemChoiceVisuals() {
for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) {
btn.setAttribute(
"aria-pressed",
String(selectedStems.has(btn.dataset.stem)),
);
}
}
function handleStemChoiceClick(stem) {
const allSelected = selectedStems.size === STEM_NAMES.length;
if (allSelected) {
// Default state -> switch to "only this stem".
selectedStems.clear();
selectedStems.add(stem);
} else if (selectedStems.has(stem)) {
selectedStems.delete(stem);
if (selectedStems.size === 0) {
// Empty out wraps back to "all" so the user is never stuck.
for (const n of STEM_NAMES) selectedStems.add(n);
}
} else {
selectedStems.add(stem);
}
saveSelectedStems();
refreshStemChoiceVisuals();
buildStripStems();
}
function wireStemChoiceButtons() {
refreshStemChoiceVisuals();
for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) {
btn.addEventListener("click", () => handleStemChoiceClick(btn.dataset.stem));
}
}
function wireAllButton() {
const allBtn = document.getElementById("stemAllBtn");
if (!allBtn) return;
function syncAllBtn() {
allBtn.setAttribute("aria-pressed", String(selectedStems.size === STEM_NAMES.length));
}
allBtn.addEventListener("click", () => {
const allSelected = selectedStems.size === STEM_NAMES.length;
if (allSelected) {
selectedStems.clear();
} else {
for (const n of STEM_NAMES) selectedStems.add(n);
}
saveSelectedStems();
refreshStemChoiceVisuals();
buildStripStems();
syncAllBtn();
});
/* Keep All in sync when individual stems are toggled */
for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) {
btn.addEventListener("click", syncAllBtn);
}
syncAllBtn();
}
// ─── Wire everything up ───
syncStemNamesFromAPI().then(() => buildStripStems());
wireJobForm();
wireTransportButtons();
wireFooterControls();
requestAnimationFrame(drawFooterPlaceholder);
wireStemListControls();
wireMixerToolbar();
wireStemChoiceButtons();
wireAllButton();
wireFileDrop();
wireAppShellControls();
(async () => {
await runStoreMigrationIfNeeded();
await stemSelectionReady;
refreshStemChoiceVisuals();
await initCatalog();
})().catch(console.error);
// ─── Footer: speed dropdown, export dropdown, scrub seek ───
function wireFooterControls() {
// ── Export split-button dropdown ──
// The full button toggles the export menu. Export actions live inside
// the dropdown so the hit target is predictable.
// The menu offers Mix / All Stems / Current Region, with a WAV/MP3 toggle in
// the header. All exports reuse the backend-served download helpers.
const exportBtn = document.getElementById("t-export-btn");
const exportPanel = document.getElementById("t-export-panel");
const exportLabel = document.getElementById("t-export-label");
const fmtWav = document.getElementById("t-fmt-wav");
const fmtMp3 = document.getElementById("t-fmt-mp3");
const fmtFlac = document.getElementById("t-fmt-flac");
const fmtMp4 = document.getElementById("t-fmt-mp4");
const exportWrap = document.getElementById("footer-export-wrap");
const itemMix = document.getElementById("t-export-mix");
const itemStems = document.getElementById("t-export-stems");
const itemRegion = document.getElementById("t-export-region");
const mixDescEl = itemMix?.querySelector(".chip-item-desc");
// Only the rows actually visible in the current format mode (MP4 hides the
// audio-only Stems/Region rows).
const actionItems = () =>
[itemMix, itemStems, itemRegion].filter((it) => it && it.offsetParent !== null);
// MP4 is a format choice, shown only for jobs with a preserved video track.
// It applies to the mix only — stems/region are audio-only.
const videoAvailable = () => !!exportWrap?.classList.contains("has-video");
let format = "wav";
let busy = false;
const panelOpen = () => exportPanel && !exportPanel.classList.contains("hidden");
function openPanel() {
closeAllChipPanels();
// A previous (video) job may have left MP4 selected; revert if unavailable now.
if (format === "mp4" && !videoAvailable()) setFormat("wav");
exportPanel?.classList.remove("hidden");
exportBtn?.setAttribute("aria-expanded", "true");
}
function closePanel() {
exportPanel?.classList.add("hidden");
exportBtn?.setAttribute("aria-expanded", "false");
}
function setFormat(f) {
format = f;
for (const [btn, val] of [[fmtWav, "wav"], [fmtMp3, "mp3"], [fmtFlac, "flac"], [fmtMp4, "mp4"]]) {
btn?.classList.toggle("active", f === val);
btn?.setAttribute("aria-checked", String(f === val));
}
applyFormatState();
}
fmtWav?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("wav"); });
fmtMp3?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("mp3"); });
fmtFlac?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("flac"); });
fmtMp4?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("mp4"); });
// MP4 exports the mix muxed with the source video. Stems and region have no
// video equivalent, so they're hidden (via .fmt-mp4) while MP4 is selected,
// leaving just "Export Mix".
function applyFormatState() {
const video = format === "mp4";
exportPanel?.classList.toggle("fmt-mp4", video);
if (mixDescEl) {
mixDescEl.textContent = video ? "Export mix with the original video" : "Export the mixed audio";
}
if (!video) updateLoopRegionVisual(); // restores the region item's disabled state
}
function resetBusy() {
busy = false;
exportBtn?.classList.remove("is-busy");
if (exportLabel) exportLabel.textContent = "Export Mix";
itemMix?.removeAttribute("aria-disabled");
applyFormatState(); // restores stems/region per the active format
}
// Single-file (mix/region) downloads give no JS-observable byte progress, so
// show a brief indeterminate "Exporting…" state, then reset.
function flashBusy() {
busy = true;
exportBtn?.classList.add("is-busy");
if (exportLabel) exportLabel.textContent = "Exporting…";
actionItems().forEach((it) => it?.setAttribute("aria-disabled", "true"));
closePanel();
window.setTimeout(resetBusy, 1200);
}
exportBtn?.addEventListener("click", (e) => {
e.stopPropagation();
if (busy) return;
panelOpen() ? closePanel() : openPanel();
});
// Export Mix: MP4 produces the video; any other format an audio mix.
itemMix?.addEventListener("click", (e) => {
e.stopPropagation();
if (busy) return;
const ok = format === "mp4" ? downloadCurrentVideo() : downloadCurrentMix(format);
if (!ok) { showError("All stems are muted - nothing to export."); return; }
flashBusy();
});
itemRegion?.addEventListener("click", (e) => {
e.stopPropagation();
if (busy || itemRegion.getAttribute("aria-disabled") === "true") return;
const ok = downloadRegionMix(format);
if (!ok) { showError("All stems are muted - nothing to export."); return; }
flashBusy();
});
// All Stems = a single backend-built ZIP, named after the song. Audio-only,
// so it's disabled (and inert) while MP4 is the selected format.
itemStems?.addEventListener("click", (e) => {
e.stopPropagation();
if (busy || itemStems.getAttribute("aria-disabled") === "true") return;
downloadAllStemsZip(format);
flashBusy();
});
// Keyboard: ↓ opens/moves into the menu, ↑/↓ cycle rows, Esc closes + restores focus.
exportBtn?.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!panelOpen()) openPanel();
actionItems().find((it) => it?.getAttribute("aria-disabled") !== "true")?.focus();
}
});
exportPanel?.addEventListener("keydown", (e) => {
const focusable = actionItems().filter((it) => it && it.getAttribute("aria-disabled") !== "true");
const idx = focusable.indexOf(document.activeElement);
if (e.key === "Escape") { closePanel(); exportBtn?.focus(); }
else if (e.key === "ArrowDown") { e.preventDefault(); focusable[(idx + 1) % focusable.length]?.focus(); }
else if (e.key === "ArrowUp") { e.preventDefault(); focusable[(idx - 1 + focusable.length) % focusable.length]?.focus(); }
});
// ── Scrub bar seek ──
const scrub = document.getElementById("footer-scrub");
if (scrub) {
function seekToX(clientX) {
if (!multitrack || !totalDuration) return;
const rect = scrub.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
multitrack.setTime(frac * totalDuration);
}
let _scrubbing = false;
scrub.addEventListener("mousedown", (e) => {
_scrubbing = true;
seekToX(e.clientX);
});
document.addEventListener("mousemove", (e) => { if (_scrubbing) seekToX(e.clientX); });
document.addEventListener("mouseup", () => { _scrubbing = false; });
}
// ── Close panels on outside click ──
document.addEventListener("click", closeAllChipPanels);
}
function closeAllChipPanels() {
document.querySelectorAll(".footer-chip-panel:not(.hidden)").forEach((p) => {
p.classList.add("hidden");
p.previousElementSibling?.setAttribute("aria-expanded", "false");
});
}
// ─── File drop on URL input ───
function wireFileDrop() {
const urlWrap = document.querySelector(".url-wrap");
const urlInput = document.getElementById("url");
const fileInput = document.getElementById("fileInput");
const filePill = document.getElementById("filePill");
const fileName = document.getElementById("fileName");
const fileSize = document.getElementById("fileSize");
const fileClear = document.getElementById("fileClear");
if (!urlWrap || !urlInput || !fileInput || !filePill) return;
function formatBytes(n) {
return n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
}
const MAX_UPLOAD_BYTES = 400 * 1024 * 1024; // must match server _MAX_UPLOAD_BYTES
function applyFile(file) {
if (!file) return;
const lower = file.name.toLowerCase();
if (!lower.endsWith(".mp3") && !lower.endsWith(".wav") && !lower.endsWith(".flac") &&
!lower.endsWith(".mp4") && !lower.endsWith(".m4a")) {
showError("Only MP3, WAV, FLAC, MP4, and M4A files are supported.");
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
showError(`File is too large (${formatBytes(file.size)}). Maximum is 400 MB.`);
return;
}
if (fileName) fileName.textContent = file.name;
if (fileSize) fileSize.textContent = formatBytes(file.size);
filePill.classList.remove("hidden");
urlWrap.classList.add("has-file");
// Cache the File object directly on the element so job.js can always
// retrieve it even after the browser clears fileInput.files following
// a fetch() submission (known WKWebView / Chromium behaviour).
fileInput._file = file;
const dt = new DataTransfer();
dt.items.add(file);
fileInput.files = dt.files;
urlInput.value = "";
urlInput.removeAttribute("required");
}
function clearFile() {
filePill.classList.add("hidden");
urlWrap.classList.remove("has-file");
fileInput._file = null;
fileInput.value = "";
urlInput.setAttribute("required", "");
}
fileClear?.addEventListener("click", clearFile);
urlWrap.addEventListener("dragover", (e) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
urlWrap.classList.add("drag-over");
});
urlWrap.addEventListener("dragleave", (e) => {
if (!urlWrap.contains(e.relatedTarget)) urlWrap.classList.remove("drag-over");
});
urlWrap.addEventListener("drop", (e) => {
e.preventDefault();
urlWrap.classList.remove("drag-over");
const file = e.dataTransfer.files[0];
if (file) applyFile(file);
});
fileInput.addEventListener("change", () => {
if (fileInput.files[0]) applyFile(fileInput.files[0]);
});
}
// ─── App shell controls ───
function wireAppShellControls() {
document.getElementById("appMenuBtn")?.addEventListener("click", (e) => {
e.stopPropagation();
const app = document.querySelector(".app");
// In trash view: switch back to library.
if (document.querySelector(".sidebar.trash-view, .sidebar.favorites-view")) {
document.querySelector(".rail-library")?.click();
}
// If collapsed: open. Never collapse from the library button.
if (app?.classList.contains("cat-collapsed")) {
document.getElementById("catalogToggle")?.click();
}
});
}
// ─── Keyboard shortcuts ───
document.addEventListener("keydown", (e) => {
if (!multitrack) return;
if (e.target instanceof HTMLInputElement) return;
if (e.code === "Space") {
e.preventDefault();
togglePlayPause();
} else if (e.code === "BracketLeft") {
e.preventDefault();
multitrack.setTime(Math.max(0, multitrack.getCurrentTime() - 5));
} else if (e.code === "BracketRight") {
e.preventDefault();
multitrack.setTime(
Math.min(multitrack.getDuration(), multitrack.getCurrentTime() + 5),
);
} else if (e.code === "KeyL") {
e.preventDefault();
loopBtn.click();
} else if (e.code === "KeyI" && loopEnabled && multitrack) {
e.preventDefault();
setLoopStart(Math.min(multitrack.getCurrentTime(), loopEnd - 0.5));
updateLoopRegionVisual();
} else if (e.code === "KeyO" && loopEnabled && multitrack) {
e.preventDefault();
setLoopEnd(Math.max(multitrack.getCurrentTime(), loopStart + 0.5));
updateLoopRegionVisual();
}
});
// ─── External links ───
document.addEventListener("click", (e) => {
const dl = e.target.closest("a.lane-dl");
if (dl?.href) {
const openUrl = window.__TAURI__?.core?.invoke;
if (openUrl) {
e.preventDefault();
openUrl("open_url", { url: dl.href });
}
return;
}
const anchor = e.target.closest('a[target="_blank"]');
if (anchor?.href) {
const openUrl = window.__TAURI__?.core?.invoke;
if (openUrl) {
e.preventDefault();
openUrl("open_url", { url: anchor.href });
}
}
});
// ─── Global error logging ───
window.addEventListener("error", (e) => {
console.error("[app:error]", e.message, "\n", e.filename, ":", e.lineno, "\n", e.error?.stack ?? "");
});
window.addEventListener("unhandledrejection", (e) => {
console.error("[app:unhandledrejection]", e.reason?.message ?? e.reason, "\n", e.reason?.stack ?? "");
});
// ─── Bootstrap ───
buildStripStems();
renderEmptyShell();
+500
View File
@@ -0,0 +1,500 @@
import {
STEM_NAMES, TRACK_NAMES, STEM_COLORS, STEM_DISPLAY, LANE_VOLUME_MAX,
} from "./constants.js";
import {
mixerState, mixerEl, stemListEl, currentJobId, multitrack, trackIndex,
masterVolume, audioEngine,
} from "./state.js";
import { storeGet, storeSetDebounced } from "./utils.js";
function defaultMixerEntry() {
return { volume: 1, muted: false, soloed: false };
}
export function ensureMixerStateDefaults() {
for (const name of TRACK_NAMES) {
if (!mixerState[name]) mixerState[name] = defaultMixerEntry();
}
}
export async function loadMixIntoState(jobId, loadedStemNames = STEM_NAMES) {
let stored = {};
try {
const data = await storeGet(`stemdeck:mix:${jobId}`, {});
if (data && typeof data === "object") stored = data;
} catch (e) { console.warn("[mixer] failed to load mix state:", e); }
for (const name of TRACK_NAMES) {
Object.assign(mixerState[name], defaultMixerEntry(), stored[name] || {});
}
// If all loaded stems are muted the session is unplayable -- unmute as recovery.
const loadedStems = loadedStemNames.filter((n) => mixerState[n]);
if (loadedStems.length > 0 && loadedStems.every((n) => mixerState[n].muted)) {
for (const name of loadedStems) mixerState[name].muted = false;
}
}
export function resetMixerState() {
for (const name of TRACK_NAMES) {
Object.assign(mixerState[name], defaultMixerEntry());
}
}
function saveMix() {
if (!currentJobId) return;
storeSetDebounced(`stemdeck:mix:${currentJobId}`, mixerState);
}
export function applyMix() {
if (!multitrack) return;
const anySolo = TRACK_NAMES.some((name) => trackIndex[name] !== undefined && mixerState[name]?.soloed);
for (const name of TRACK_NAMES) {
const s = mixerState[name];
if (!s) continue;
let effective = s.volume;
if (s.muted) effective = 0;
else if (anySolo && !s.soloed) effective = 0;
const idx = trackIndex[name];
if (idx === undefined) continue;
const targetGain = effective * masterVolume;
if (audioEngine) {
// Web Audio engine owns playback: set the per-stem gain directly (no 1.0
// cap, so >1.0 lane boost works), and skip the streaming volume path —
// the multitrack is mounted for visuals only and never plays.
audioEngine.setGain(name, targetGain);
continue;
}
const audioEl = multitrack.audios?.[idx];
if (audioEl instanceof HTMLMediaElement) {
// WKWebView does not pass audio through MediaElementSource → GainNode →
// destination. Use the native HTMLAudioElement volume path instead and
// cap at 1.0 (the spec limit). Boost above unity is not supported on
// this platform but basic volume/mute works reliably.
audioEl.volume = Math.max(0, Math.min(1, targetGain));
} else {
multitrack.setTrackVolume(idx, targetGain);
}
}
}
export function updateLaneKnobVisual(knobEl, v) {
const frac = Math.max(0, Math.min(1, v / LANE_VOLUME_MAX));
knobEl.style.setProperty("--lane-pos", frac.toFixed(3));
knobEl.setAttribute("aria-valuenow", v.toFixed(2));
const input = knobEl.querySelector(".mx-fader-input");
if (input) {
input.value = String(v);
// Set --lane-pos directly on the input so ::webkit-slider-runnable-track
// can see it — WebKit shadow DOM pseudo-elements don't inherit vars from ancestors.
input.style.setProperty("--lane-pos", frac.toFixed(3));
}
const val = knobEl.closest(".lane-header")?.querySelector(".mx-val");
if (val) {
const db = v <= 0 ? "-∞" : (20 * Math.log10(v)).toFixed(1);
val.textContent = db === "-∞" ? "-∞" : `${parseFloat(db) > 0 ? "+" : ""}${db}`;
}
}
export function setLaneVolume(name, v) {
const state = mixerState[name];
if (!state) return;
state.volume = Math.max(0, Math.min(LANE_VOLUME_MAX, v));
const knob = mixerEl.querySelector(`.lane-knob[data-stem="${name}"]`);
if (knob) updateLaneKnobVisual(knob, state.volume);
applyMix();
saveMix();
}
export function refreshMixerVisuals() {
for (const name of TRACK_NAMES) {
const state = mixerState[name];
if (!state) continue;
// Mixer-column lane header
const row = mixerEl.querySelector(`.lane-header[data-stem="${name}"]`);
if (row) {
const muteBtn = row.querySelector(".mute");
const soloBtn = row.querySelector(".solo");
if (soloBtn) soloBtn.classList.toggle("active", state.soloed);
const iconToggle = row.querySelector(".lane-icon-toggle");
if (iconToggle) {
iconToggle.classList.toggle("active", !state.muted);
iconToggle.setAttribute("aria-pressed", String(!state.muted));
}
row.classList.toggle("muted", state.muted);
const knob = row.querySelector(".lane-knob");
if (knob) updateLaneKnobVisual(knob, state.volume);
}
// Stems-list panel row (mirrors the mixer column buttons)
if (stemListEl) {
const slRow = stemListEl.querySelector(`span[data-stem="${name}"]`);
if (slRow) {
const m = slRow.querySelector(".stem-mute");
const s = slRow.querySelector(".stem-solo");
const mon = slRow.querySelector(".stem-monitor");
if (m) {
m.classList.toggle("active", state.muted);
m.setAttribute("aria-pressed", String(state.muted));
}
if (s) {
s.classList.toggle("active", state.soloed);
s.setAttribute("aria-pressed", String(state.soloed));
}
if (mon) {
// Active when this stem is THE lone solo (the "monitor" target).
const others = TRACK_NAMES.filter((n) => n !== name);
const lone = state.soloed
&& others.every((n) => !mixerState[n]?.soloed);
mon.classList.toggle("active", lone);
}
slRow.classList.toggle("muted", state.muted);
}
}
}
}
export function setLaneControlsEnabled(enabled) {
for (const b of mixerEl.querySelectorAll(".ms-btn")) b.disabled = !enabled;
for (const b of mixerEl.querySelectorAll(".lane-icon-toggle")) b.disabled = !enabled;
for (const a of mixerEl.querySelectorAll(".lane-dl")) {
a.classList.toggle("disabled", !enabled);
if (!enabled) {
a.setAttribute("aria-disabled", "true");
a.setAttribute("tabindex", "-1");
} else {
a.removeAttribute("aria-disabled");
a.removeAttribute("tabindex");
}
}
for (const k of mixerEl.querySelectorAll(".lane-knob")) {
k.classList.toggle("disabled", !enabled);
k.setAttribute("aria-disabled", String(!enabled));
k.setAttribute("tabindex", enabled ? "0" : "-1");
}
}
const MINI_WAVE_BARS = 40;
const MINI_WAVE_VIEWBOX_H = 26;
function emptyMiniWaveSvg(stemName) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "lane-mini-wave");
svg.dataset.stem = stemName;
svg.setAttribute("preserveAspectRatio", "none");
svg.setAttribute("viewBox", `0 0 ${MINI_WAVE_BARS * 2} ${MINI_WAVE_VIEWBOX_H}`);
return svg;
}
function makeMiniWaveSvg(stemName, color) {
// Seeded placeholder bars used while real peaks haven't loaded yet.
let s = 0;
for (const c of stemName) s = (s * 31 + c.charCodeAt(0)) >>> 0;
const rng = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
const svg = emptyMiniWaveSvg(stemName);
for (let i = 0; i < MINI_WAVE_BARS; i++) {
const env = Math.sin((i / MINI_WAVE_BARS) * Math.PI) * 0.7 + 0.3;
const h = Math.max(2, env * (rng() * 0.6 + 0.25) * MINI_WAVE_VIEWBOX_H);
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", `${i * 2}`);
rect.setAttribute("y", `${(MINI_WAVE_VIEWBOX_H - h) / 2}`);
rect.setAttribute("width", "1");
rect.setAttribute("height", `${h}`);
rect.setAttribute("fill", color);
rect.setAttribute("opacity", "0.6");
svg.appendChild(rect);
}
return svg;
}
export function renderRealMiniWave(stemName, audioBuffer, color) {
const svg = mixerEl.querySelector(`.lane-mini-wave[data-stem="${stemName}"]`);
if (!svg || !audioBuffer || typeof audioBuffer.getChannelData !== "function") return;
const ch = audioBuffer.getChannelData(0);
if (!ch || !ch.length) return;
const binSize = Math.max(1, Math.floor(ch.length / MINI_WAVE_BARS));
const peaks = new Array(MINI_WAVE_BARS);
let max = 0;
for (let i = 0; i < MINI_WAVE_BARS; i++) {
const start = i * binSize;
const end = i === MINI_WAVE_BARS - 1 ? ch.length : start + binSize;
let p = 0;
for (let j = start; j < end; j++) {
const v = Math.abs(ch[j]);
if (v > p) p = v;
}
peaks[i] = p;
if (p > max) max = p;
}
const norm = max > 0 ? 1 / max : 0;
while (svg.firstChild) svg.removeChild(svg.firstChild);
for (let i = 0; i < MINI_WAVE_BARS; i++) {
const h = Math.max(1.5, peaks[i] * norm * MINI_WAVE_VIEWBOX_H);
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", `${i * 2}`);
rect.setAttribute("y", `${(MINI_WAVE_VIEWBOX_H - h) / 2}`);
rect.setAttribute("width", "1");
rect.setAttribute("height", `${h}`);
rect.setAttribute("fill", color);
rect.setAttribute("opacity", "0.95");
svg.appendChild(rect);
}
}
function downloadIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("width", "18");
svg.setAttribute("height", "18");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("aria-hidden", "true");
svg.innerHTML =
'<path d="M12 3v11"></path>' +
'<path d="m7.5 9.5 4.5 4.5 4.5-4.5"></path>' +
'<rect x="5" y="17" width="14" height="4" rx="1.5"></rect>';
return svg;
}
function makeVolumeKnob(stemName, color) {
const wrap = document.createElement("div");
wrap.className = "lane-knob mx-fader";
wrap.dataset.stem = stemName;
const input = document.createElement("input");
input.type = "range";
input.className = "mx-fader-input";
input.setAttribute("min", "0");
input.setAttribute("max", String(LANE_VOLUME_MAX));
input.setAttribute("step", "0.01");
input.setAttribute("value", "1");
input.style.setProperty("--fader-color", color);
input.style.setProperty("--lane-pos", "0.5");
input.setAttribute("aria-label", `${STEM_DISPLAY[stemName] || stemName} volume`);
input.addEventListener("input", () => setLaneVolume(stemName, parseFloat(input.value)));
input.addEventListener("dblclick", (e) => { e.stopPropagation(); setLaneVolume(stemName, 1); });
wrap.appendChild(input);
wrap.addEventListener("dblclick", () => setLaneVolume(stemName, 1));
wrap.addEventListener("wheel", (e) => {
e.preventDefault();
const cur = mixerState[stemName]?.volume ?? 1;
setLaneVolume(stemName, cur - Math.sign(e.deltaY) * (e.shiftKey ? 0.2 : 0.04));
}, { passive: false });
return wrap;
}
function stemIconMarkup(stemName) {
const common = 'class="lane-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"';
const icons = {
vocals: `<svg ${common}><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><path d="M12 19v3"></path></svg>`,
drums: `<svg ${common}><path d="M7 13.5a5 5 0 0 0 10 0"></path><path d="M7 13.5h10"></path><circle cx="9" cy="10" r="2.5"></circle><circle cx="15" cy="10" r="2.5"></circle><path d="M4 6.5h5"></path><path d="M15 6.5h5"></path><path d="M6.5 6.5v5"></path><path d="M17.5 6.5v5"></path><path d="M10 18l-2 3"></path><path d="M14 18l2 3"></path><path d="M4 18l16-8"></path></svg>`,
bass: `<svg ${common}><path d="M16.5 3h4v5h-3"></path><path d="M17.5 5.5 9.8 13.2"></path><path d="M10 13c1.6 2.2 1.1 5.1-1.2 6.5-2.1 1.3-5 .5-6-1.6-.9-1.9-.1-4.1 1.8-5 .9-.4 1.8-.4 2.8-.1.1-1.1.6-2.1 1.6-2.6 1.2-.6 2.6-.1 3.2 1.1"></path><path d="M6.7 16.4h.01"></path><path d="M13.5 9.5l3 3"></path><path d="M18.2 3v4.6"></path><path d="M20.5 3v4"></path></svg>`,
guitar: `<svg ${common}><path d="M16 4.5 20 2l2 2-2.5 4"></path><path d="M18.2 5.8 10.2 13.8"></path><path d="M10.5 13.5c1.1 1.7.5 4.2-1.5 5.5-2.2 1.5-5.3.8-6.3-1.3-.8-1.7.1-3.6 1.9-4.2 1-.3 1.8-.1 2.7.5.1-1.1.6-2.1 1.6-2.6 1.4-.7 2.7.2 1.6 2.1Z"></path><path d="M6.5 15.1c1.3.6 2.2 1.5 2.9 2.8"></path><circle cx="7" cy="16.4" r="1.4"></circle><path d="M14 8l3 3"></path></svg>`,
piano: `<svg ${common}><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="M7 5v14"></path><path d="M12 5v14"></path><path d="M17 5v14"></path><path d="M9.5 5v7"></path><path d="M14.5 5v7"></path></svg>`,
other: `<svg ${common}><path d="M4 13v-2"></path><path d="M8 17V7"></path><path d="M12 21V3"></path><path d="M16 17V7"></path><path d="M20 13v-2"></path></svg>`,
original: `<svg ${common}><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`,
};
return icons[stemName] || icons.other;
}
export function renderMixerRow(stem) {
const state = mixerState[stem.name];
const color = STEM_COLORS[stem.name] || "#a0a0a0";
const display = STEM_DISPLAY[stem.name] || stem.name;
const row = document.createElement("div");
row.className = "lane-header mx-row";
row.dataset.stem = stem.name;
// Col 1: stem icon
const iconCell = document.createElement("div");
iconCell.className = "mx-icon";
iconCell.style.color = color;
iconCell.innerHTML = stemIconMarkup(stem.name);
// Hidden lane-stripe kept for CSS compat
const stripe = document.createElement("div");
stripe.className = "lane-stripe";
stripe.style.background = color;
row.appendChild(stripe);
// Col 2: name
const nameEl = document.createElement("span");
nameEl.className = "mx-name";
nameEl.style.color = color;
nameEl.textContent = display;
// Col 3: horizontal fader
const fader = makeVolumeKnob(stem.name, color);
// Col 4: VU meter
const vu = document.createElement("div");
vu.className = "lane-vu mx-meter";
vu.dataset.stem = stem.name;
vu.innerHTML = '<div class="lane-vu-bar mx-meter-fill"></div><div class="lane-vu-bar"></div>';
// Col 5: value label
const val = document.createElement("span");
val.className = "mx-val";
const initFrac = Math.max(0, Math.min(1, (state?.volume ?? 1) / LANE_VOLUME_MAX));
val.textContent = String(Math.round(initFrac * 100));
// Col 6: M button
const muteBtn = document.createElement("button");
muteBtn.type = "button";
muteBtn.className = "lane-icon-toggle mx-btn mute";
muteBtn.textContent = "M";
muteBtn.setAttribute("aria-label", `Mute ${display}`);
muteBtn.setAttribute("aria-pressed", String(state?.muted ?? false));
if (!state?.muted) muteBtn.classList.add("active");
// Col 7: S button
const soloBtn = document.createElement("button");
soloBtn.type = "button";
soloBtn.className = "solo ms-btn mx-btn";
soloBtn.textContent = "S";
soloBtn.setAttribute("aria-label", `Solo ${display}`);
soloBtn.setAttribute("aria-pressed", String(state?.soloed ?? false));
if (state?.soloed) soloBtn.classList.add("active");
// Col 8: download
const dl = document.createElement("a");
dl.className = "lane-dl mx-btn";
dl.href = stem.url;
dl.download = `${stem.name}.wav`;
dl.title = `Download ${display}`;
dl.appendChild(downloadIcon());
// Wrap name + VU in a column so VU appears below the name
const nameVuCol = document.createElement("div");
nameVuCol.className = "lane-name-vu";
nameVuCol.append(nameEl, vu);
row.append(iconCell, nameVuCol, fader, val, muteBtn, soloBtn, dl);
muteBtn.addEventListener("click", () => toggleStemMute(stem.name));
soloBtn.addEventListener("click", () => toggleStemSolo(stem.name));
row.classList.toggle("muted", state?.muted ?? false);
if (state) updateLaneKnobVisual(fader, state.volume);
return { row, vuEl: vu };
}
// ─── Stem-list panel (Stems sidebar) ───
//
// The stems-list panel renders a parallel set of M / S / Monitor controls
// that share state with the mixer-column lane-header buttons. Both UIs
// drive `mixerState`; either one updates the audio mix and both visuals
// re-render via refreshMixerVisuals().
export function toggleStemMute(name) {
const state = mixerState[name];
if (!state) return;
state.muted = !state.muted;
refreshMixerVisuals();
applyMix();
saveMix();
}
export function toggleStemSolo(name) {
const state = mixerState[name];
if (!state) return;
state.soloed = !state.soloed;
refreshMixerVisuals();
applyMix();
saveMix();
}
// "Monitor" = solo only this stem. If already the lone solo, clear all
// solos (toggle-style behavior, like Logic's "Solo Safe" / Reaper's
// solo-exclusive). Also clears mute on the target so it's audible.
export function soloOnlyStem(name) {
const state = mixerState[name];
if (!state) return;
const others = TRACK_NAMES.filter((n) => n !== name);
const isAlreadyAlone = state.soloed && others.every((n) => !mixerState[n]?.soloed);
if (isAlreadyAlone) {
state.soloed = false;
} else {
for (const n of TRACK_NAMES) {
if (!mixerState[n]) continue;
mixerState[n].soloed = (n === name);
}
state.muted = false;
}
refreshMixerVisuals();
applyMix();
saveMix();
}
export function resetMixer() {
for (const name of TRACK_NAMES) {
const s = mixerState[name];
if (!s) continue;
s.volume = 1;
s.muted = false;
s.soloed = false;
}
refreshMixerVisuals();
applyMix();
saveMix();
}
export function muteAll() {
// Toggle: if every stem is muted, un-mute all; otherwise mute all.
const allMuted = STEM_NAMES.every((n) => mixerState[n]?.muted);
for (const name of TRACK_NAMES) {
const s = mixerState[name];
if (!s) continue;
s.muted = !allMuted;
}
refreshMixerVisuals();
applyMix();
saveMix();
}
export function clearAllSolos() {
for (const name of TRACK_NAMES) {
const s = mixerState[name];
if (!s) continue;
s.soloed = false;
}
refreshMixerVisuals();
applyMix();
saveMix();
}
export function wireMixerToolbar() {
document.getElementById("mixer-reset")?.addEventListener("click", resetMixer);
document.getElementById("mixer-mute-all")?.addEventListener("click", muteAll);
document.getElementById("mixer-clear-solo")?.addEventListener("click", clearAllSolos);
}
export function wireStemListControls() {
if (!stemListEl) return;
for (const btn of stemListEl.querySelectorAll(".stem-mute")) {
btn.addEventListener("click", () => toggleStemMute(btn.dataset.stem));
btn.addEventListener("keydown", (e) => {
if (e.code === "Space" || e.code === "Enter") {
e.preventDefault();
toggleStemMute(btn.dataset.stem);
}
});
}
for (const btn of stemListEl.querySelectorAll(".stem-solo")) {
btn.addEventListener("click", () => toggleStemSolo(btn.dataset.stem));
btn.addEventListener("keydown", (e) => {
if (e.code === "Space" || e.code === "Enter") {
e.preventDefault();
toggleStemSolo(btn.dataset.stem);
}
});
}
for (const btn of stemListEl.querySelectorAll(".stem-monitor")) {
btn.addEventListener("click", () => soloOnlyStem(btn.dataset.stem));
}
}
+54
View File
@@ -0,0 +1,54 @@
// Playful labels rotated by job.js every ROTATION_MS while a job is in
// progress. Keyed by Job.status values from the backend. The progress
// bar carries the real percentage; this is purely UI personality.
//
// Edit freely — additions/removals don't require any code changes.
export const stagePhrases = {
queued: [
"Lacing up…",
"Tuning forks…",
"Spinning up…",
"Warming the tubes…",
],
// Downloading is load-in: gear arriving, getting set up, before the
// sound check starts.
downloading: [
"Load-in…",
"Rolling in flight cases…",
"Setting up the kit…",
"Snaking the cables…",
"Plugging into the patch bay…",
],
// Separating is the long stage — keep the user company with band /
// sound-check vignettes. Short observational sentences, the kind of
// thing you'd overhear at a rehearsal.
separating: [
"Tuning the bass…",
"Guitarist checking himself in the mirror…",
"Pick fell on the floor…",
"In-ear check…",
"Mic check, one two…",
"\"More me in the monitor\"…",
"Drummer adjusting the snare…",
"Singer warming up… lalala",
"Capo on, capo off…",
"Tightening the lugs…",
"Tuning the floor tom…",
"Coiling a cable…",
"Bassist plugged in backwards…",
"Pedalboard wiggling…",
"Tech swapping a 9V battery…",
"Roadie taping down the setlist…",
"\"Is this thing on?\"…",
"Stepping on a fuzz pedal…",
"Tuning the high E…",
"Drummer twirling sticks…",
"Singer sipping tea…",
"Quick bathroom break…",
"\"Can I get more vocals?\"…",
"Snare too snappy…",
"Levels look good…",
],
default: ["Working on it…"],
};
+1414
View File
File diff suppressed because it is too large Load Diff
+382
View File
@@ -0,0 +1,382 @@
// sections.js — interactive sections bar above the waveform
const SECTION_COLORS = [
"#4a7fff",
"#2ab8e8",
"#9a4aff",
"#ff8a20",
"#00c8a0",
"#ff4a90",
"#e8c840",
"#00d4d4",
];
const MIN_SEC = 0.5; // minimum section duration in seconds
const DEFAULT_WIDTH_FRAC = 0.12; // default new section = 12% of track
let _trackId = null;
let _duration = 0;
let _sections = [];
let _container = null;
let _saveTimer = null;
// ─── Public API ───────────────────────────────────────────
export function initSections(trackId, sections, duration) {
_trackId = trackId;
_duration = Math.max(1, duration || 0);
_sections = (sections || []).map((s) => ({ ...s }));
_container = document.getElementById("daw-sections");
if (!_container) return;
// Wire the static "Add" button in the label area (may already be wired)
const addBtn = document.getElementById("sectionsAddBtn");
if (addBtn && !addBtn.dataset.sectionsWired) {
addBtn.dataset.sectionsWired = "1";
addBtn.addEventListener("click", () => _addSection());
}
_render();
}
export function destroySections() {
// Flush any pending debounced save before clearing state so switching tracks
// never drops unsaved sections. _save() serializes _sections synchronously
// (JSON.stringify runs before the first await) so it's safe to clear state
// after calling it.
if (_saveTimer !== null) {
clearTimeout(_saveTimer);
_saveTimer = null;
_save();
}
_hideSaveIndicator();
_trackId = null;
_sections = [];
_duration = 0;
if (_container) _container.innerHTML = "";
_container = null;
}
// ─── Rendering ────────────────────────────────────────────
function _render() {
if (!_container) return;
_container.innerHTML = "";
const sorted = [..._sections].sort((a, b) => a.start - b.start);
for (const section of sorted) {
_container.appendChild(_makeSectionEl(section));
}
}
function _makeSectionEl(section) {
const pctStart = (section.start / _duration) * 100;
const pctWidth = ((section.end - section.start) / _duration) * 100;
const el = document.createElement("div");
el.className = "section-block";
el.dataset.id = section.id;
el.style.cssText = `left:${pctStart.toFixed(4)}%;width:${pctWidth.toFixed(4)}%;--sc:${section.color}`;
el.innerHTML = `
<div class="section-handle section-handle-l" data-edge="left"></div>
<span class="section-label">${_esc(section.name)}</span>
<button class="section-del" type="button" aria-label="Delete section" tabindex="-1">×</button>
<div class="section-handle section-handle-r" data-edge="right"></div>
`;
el.querySelector(".section-del").addEventListener("click", (e) => {
e.stopPropagation();
_deleteSection(section.id);
});
el.querySelector(".section-label").addEventListener("dblclick", (e) => {
e.stopPropagation();
_openRename(section.id, el.querySelector(".section-label"));
});
_wireDrag(el, section);
for (const h of el.querySelectorAll(".section-handle")) {
_wireResize(h, el, section);
}
return el;
}
function _esc(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ─── Drag to move ─────────────────────────────────────────
function _wireDrag(el, section) {
let active = false;
let startX = 0;
let origStart = 0;
el.addEventListener("pointerdown", (e) => {
if (e.target.closest(".section-handle,.section-del")) return;
active = true;
startX = e.clientX;
origStart = section.start;
el.setPointerCapture(e.pointerId);
el.classList.add("sec-dragging");
e.preventDefault();
});
el.addEventListener("pointermove", (e) => {
if (!active) return;
const cw = _container.getBoundingClientRect().width;
if (!cw) return;
const dt = ((e.clientX - startX) / cw) * _duration;
const w = section.end - section.start;
section.start = _clampMove(section.id, origStart + dt, w);
section.end = section.start + w;
el.style.left = `${(section.start / _duration) * 100}%`;
});
el.addEventListener("pointerup", () => {
if (!active) return;
active = false;
el.classList.remove("sec-dragging");
_scheduleSave();
});
el.addEventListener("pointercancel", () => {
active = false;
el.classList.remove("sec-dragging");
});
}
// ─── Resize handles ───────────────────────────────────────
function _wireResize(handle, el, section) {
const edge = handle.dataset.edge;
let active = false;
let startX = 0;
let origTime = 0;
handle.addEventListener("pointerdown", (e) => {
active = true;
startX = e.clientX;
origTime = edge === "left" ? section.start : section.end;
handle.setPointerCapture(e.pointerId);
el.classList.add("sec-resizing");
e.preventDefault();
e.stopPropagation();
});
handle.addEventListener("pointermove", (e) => {
if (!active) return;
const cw = _container.getBoundingClientRect().width;
if (!cw) return;
const dt = ((e.clientX - startX) / cw) * _duration;
const desired = origTime + dt;
if (edge === "left") {
const lbound = _leftNeighborEnd(section.id);
const max = section.end - MIN_SEC;
section.start = Math.max(lbound, Math.min(max, desired));
} else {
const rbound = _rightNeighborStart(section.id);
const min = section.start + MIN_SEC;
section.end = Math.min(rbound, Math.max(min, desired));
}
const ps = (section.start / _duration) * 100;
const pw = ((section.end - section.start) / _duration) * 100;
el.style.left = `${ps}%`;
el.style.width = `${pw}%`;
});
handle.addEventListener("pointerup", () => {
if (!active) return;
active = false;
el.classList.remove("sec-resizing");
_scheduleSave();
});
handle.addEventListener("pointercancel", () => {
active = false;
el.classList.remove("sec-resizing");
});
}
// ─── Collision helpers ────────────────────────────────────
function _clampMove(id, desiredStart, width) {
let start = Math.max(0, Math.min(_duration - width, desiredStart));
const end = () => start + width;
const others = _sections.filter((s) => s.id !== id);
for (const o of others) {
if (start < o.end && end() > o.start) {
// Snap to whichever edge is closer to desired
const snapRight = o.end;
const snapLeft = o.start - width;
const dr = Math.abs(desiredStart - snapRight);
const dl = Math.abs(desiredStart - snapLeft);
start = dl < dr ? Math.max(0, snapLeft) : Math.min(_duration - width, snapRight);
}
}
return start;
}
function _leftNeighborEnd(id) {
const s = _sections.find((x) => x.id === id);
let bound = 0;
for (const o of _sections) {
if (o.id === id) continue;
if (o.end <= s.end) bound = Math.max(bound, o.end);
}
return bound;
}
function _rightNeighborStart(id) {
const s = _sections.find((x) => x.id === id);
let bound = _duration;
for (const o of _sections) {
if (o.id === id) continue;
if (o.start >= s.start) bound = Math.min(bound, o.start);
}
return bound;
}
// ─── CRUD ─────────────────────────────────────────────────
function _addSection() {
const defW = _duration * DEFAULT_WIDTH_FRAC;
const sorted = [..._sections].sort((a, b) => a.start - b.start);
// Find first gap ≥ defW
let start = 0;
for (const s of sorted) {
if (s.start - start >= defW) break;
start = Math.max(start, s.end);
}
// Clamp and verify room
start = Math.min(start, _duration - MIN_SEC);
if (start < 0) return;
const end = Math.min(start + defW, _duration);
if (end - start < MIN_SEC) return;
// Verify no overlap
if (_sections.some((s) => start < s.end && end > s.start)) return;
const color = _nextColor();
const section = { id: _nextId(), name: "Section", start, end, color };
_sections.push(section);
_render();
_scheduleSave();
// Open rename immediately
const el = _container?.querySelector(`[data-id="${section.id}"]`);
if (el) _openRename(section.id, el.querySelector(".section-label"));
}
function _deleteSection(id) {
_sections = _sections.filter((s) => s.id !== id);
_render();
_scheduleSave();
}
function _openRename(id, labelEl) {
if (!labelEl) return;
const section = _sections.find((s) => s.id === id);
if (!section) return;
const input = document.createElement("input");
input.className = "section-rename-input";
input.type = "text";
input.value = section.name;
input.style.setProperty("--sc", section.color);
labelEl.replaceWith(input);
input.focus();
input.select();
const commit = () => {
const n = input.value.trim();
if (n) section.name = n;
_render();
_scheduleSave();
};
input.addEventListener("blur", commit, { once: true });
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); input.blur(); }
if (e.key === "Escape") { input.value = section.name; input.removeEventListener("blur", commit); input.blur(); }
});
}
// ─── Persistence ──────────────────────────────────────────
let _savedTimer = null;
function _showSaving() {
const el = document.getElementById("sectionsSaveIndicator");
if (!el) return;
clearTimeout(_savedTimer);
el.textContent = "Saving";
el.className = "sections-save-indicator";
}
function _showSaved() {
const el = document.getElementById("sectionsSaveIndicator");
if (!el) return;
el.textContent = "Saved";
el.className = "sections-save-indicator saved";
_savedTimer = setTimeout(() => {
el.className = "sections-save-indicator hidden";
}, 1800);
}
function _hideSaveIndicator() {
const el = document.getElementById("sectionsSaveIndicator");
if (el) el.className = "sections-save-indicator hidden";
clearTimeout(_savedTimer);
}
function _scheduleSave() {
clearTimeout(_saveTimer);
_showSaving();
_saveTimer = setTimeout(_save, 600);
}
async function _save() {
if (!_trackId) return;
const id = _trackId;
const body = JSON.stringify({ sections: _sections });
try {
const res = await fetch(`/api/jobs/${id}/sections`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body,
});
if (!res.ok) {
const detail = await res.text().catch(() => String(res.status));
console.warn("[sections] save failed:", res.status, detail);
if (id === _trackId) _hideSaveIndicator();
return;
}
if (id === _trackId) _showSaved();
} catch (e) {
console.warn("[sections] save failed:", e);
if (id === _trackId) _hideSaveIndicator();
}
}
// ─── Utilities ────────────────────────────────────────────
function _nextColor() {
const used = new Set(_sections.map((s) => s.color));
return SECTION_COLORS.find((c) => !used.has(c)) ?? SECTION_COLORS[_sections.length % SECTION_COLORS.length];
}
function _nextId() {
return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
+76
View File
@@ -0,0 +1,76 @@
// Shared, DOM-free job/library helpers used by both the mobile UI and
// (incrementally) the desktop catalog. Anything in here must stay free of
// document/window-element access so it can be imported from either entry
// point. The API (`GET /api/jobs`) is the canonical data source.
import { fmtTime } from "../utils.js";
// Mirror of catalog.js PROCESSING_STATUSES — keep in sync with the backend
// job lifecycle. These map to the amber "working" dot.
const PROCESSING_STATUSES = new Set(["queued", "downloading", "analyzing", "separating", "processing"]);
export async function fetchJobs() {
const res = await fetch("/api/jobs", { cache: "no-store" });
if (!res.ok) throw new Error(`GET /api/jobs -> ${res.status}`);
return res.json();
}
export function deriveSource(sourceUrl) {
if (!sourceUrl) return "Local file";
if (sourceUrl.startsWith("local:")) return "Local file";
if (sourceUrl.includes("youtube.com") || sourceUrl.includes("youtu.be")) return "YouTube";
if (sourceUrl.includes("soundcloud.com")) return "SoundCloud";
return "Web";
}
// One of: "done" (green), "processing" (amber), "unavailable" (grey/red).
export function statusKind(status) {
if (PROCESSING_STATUSES.has(status)) return "processing";
if (status === "done") return "done";
return "unavailable"; // unavailable / error / failed / unknown
}
export function coverInitial(title) {
const t = (title || "").trim();
return t ? t[0].toUpperCase() : "♪";
}
// Deterministic cover gradient: the same track always gets the same color.
// Palette matches the mobile design's aesthetic (design/mobile prototype).
const GRADIENTS = [
"linear-gradient(150deg,#7b46f0,#3f6ef0 55%,#1b245f)",
"linear-gradient(140deg,#2bd4c4,#1a6d9e)",
"linear-gradient(150deg,#c44ad0,#5a2b9e)",
"linear-gradient(150deg,#f06a6a,#7a2b4e)",
"linear-gradient(150deg,#4a9bf5,#2b3a7a)",
"linear-gradient(150deg,#f5a516,#d2541f)",
"linear-gradient(150deg,#3fcf6e,#1a6d4e)",
];
export function coverGradient(seed) {
const s = String(seed || "");
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return GRADIENTS[h % GRADIENTS.length];
}
// Normalize an /api/jobs state object into the shape the mobile UI renders.
export function jobToCard(state) {
const id = state.job_id;
const title = state.title || "Untitled";
const stemCount = (state.stems || state.selected_stems || []).length;
const parts = [];
if (state.duration) parts.push(fmtTime(state.duration));
if (stemCount) parts.push(`${stemCount} stem${stemCount === 1 ? "" : "s"}`);
return {
id,
title,
sub: deriveSource(state.source_url),
meta: parts.join(" · "),
stemCount,
status: statusKind(state.status),
createdAt: state.created_at || 0,
initial: coverInitial(title),
gradient: coverGradient(id || title),
thumb: typeof state.thumbnail === "string" ? state.thumbnail : "",
};
}
+149
View File
@@ -0,0 +1,149 @@
import { $, storeGet, storeSet } from "./utils.js";
import { STEM_NAMES } from "./constants.js";
// ─── DOM refs ───
export const form = $("job-form");
export const urlInput = $("url");
export const submitBtn = $("submit");
export const playBtn = $("t-play");
export const playMiniBtn = $("t-play-mini");
export const stopBtn = $("t-stop");
export const loopBtn = $("t-loop");
export const titleEl = $("title");
export const bpmChip = $("t-bpm");
export const keyChip = $("t-key");
export const stemsChip = $("t-stems-chip");
export const timeEl = $("t-time");
export const masterFader = $("t-master");
export const speedEl = $("t-speed");
export const speedLabelEl = $("t-speed-label");
export const npArt = $("np-art");
export const npThumb = $("np-thumb");
export const jobBox = $("job");
export const jobTitleEl = $("job-title");
export const jobStageEl = $("job-stage");
export const jobDetailEl = $("job-detail");
export const jobCancelBtn = $("job-cancel");
export const progressEl = $("progress");
export const errorEl = $("error");
export const lanesEl = $("lanes");
export const mixerEl = $("mixer");
export const multitrackContainer = $("multitrack-container");
export const wavesGrid = $("waves-grid");
export const rulerTime = $("ruler-time");
export const loopRegionEl = $("loop-region");
export const playheadMarker = document.querySelector(".playhead-marker");
export const waveScroll = $("wave-scroll");
export const waveCanvas = $("wave-canvas");
export const presenceRulerEl = $("presence-ruler");
export const presencePlayheadEl = $("presence-playhead");
export const footerTimeElapsed = $("footer-time-elapsed");
export const footerTimeTotal = $("footer-time-total");
export const loopStartInput = $("t-loop-start");
export const loopEndInput = $("t-loop-end");
export const stemListEl = document.querySelector(".stem-list");
export const npScrubEl = document.querySelector(".np-scrub");
export const npScrubFill = $("footer-scrub-fill");
export const footerTitle = $("footer-title");
export const footerMeta = $("footer-meta");
export const footerThumb = $("footer-thumb");
// ─── Mutable state ───
export let eventSource = null;
export let multitrack = null;
// Web Audio decode-and-mix engine (Safari-safe playback). Null = legacy streaming path.
export let audioEngine = null;
export let currentJobId = null;
// `mixerState` is mutated in place (never reassigned). renderMixerRow's
// closures capture each entry by reference, so on a new job we merge
// localStorage values into the existing objects rather than replacing them.
export const mixerState = {};
export let trackIndex = {};
export let totalDuration = 0;
export let loopEnabled = false;
export let loopStart = 0;
export let loopEnd = 0;
// Selected stems for extraction. The set determines (a) which stem
// rows render in the studio dashboard after a job completes and (b)
// which stem audio gets loaded into the multitrack. Backend always
// runs Demucs on all 6 stems regardless -- filtering happens entirely
// client-side at render time. Persisted across reloads in localStorage
// so a user who turns off "Vocals" stays set up that way for the
// next song.
const _STEM_SEL_KEY = "stemdeck:selected-stems";
// Start with all stems selected (safe default). The async store load below
// updates this binding once the store is available; ES module live bindings
// ensure all importers see the updated value on next read.
export let selectedStems = new Set(STEM_NAMES);
// Resolves when the persisted stem selection has been loaded from the store.
// Consumers that need the exact stored selection (e.g. the stem-choice UI)
// should await this before reading selectedStems.
export const stemSelectionReady = (async () => {
try {
const arr = await storeGet(_STEM_SEL_KEY, null);
if (Array.isArray(arr) && arr.length > 0) {
const valid = arr.filter((n) => STEM_NAMES.includes(n));
if (valid.length > 0) {
selectedStems = new Set(valid);
return;
}
}
} catch (e) { console.warn("[state] failed to load stem selection:", e); }
// Keep the all-stems default.
})();
export function saveSelectedStems() {
storeSet(_STEM_SEL_KEY, [...selectedStems]).catch((e) =>
console.warn("[state] failed to save stem selection:", e)
);
}
export function setStemSelected(name, selected) {
if (selected) selectedStems.add(name);
else selectedStems.delete(name);
saveSelectedStems();
}
// Web Audio analysers for live VU meters.
export let audioContext = null;
export let masterVolume = 0.5; // mirrored from masterFader.value
export const trackAnalysers = []; // index → { analyser, data, vuEl }
export let vuRafId = null;
// Master bus nodes — created once in audio.js, shared across mixer.js.
// masterBusGain is driven by the master fader; masterLimiter is a
// transparent brickwall limiter that prevents inter-stem summing clipping.
export let masterBusGain = null;
export let masterLimiter = null;
// ─── Setter helpers for mutable state (so other modules can update) ───
export function setEventSource(v) { eventSource = v; }
export function setMultitrack(v) { multitrack = v; }
export function setAudioEngine(v) { audioEngine = v; }
export function setCurrentJobId(v) { currentJobId = v; }
export function setTrackIndex(v) { trackIndex = v; }
export function setTotalDuration(v) { totalDuration = v; }
export function setLoopEnabled(v) { loopEnabled = v; }
export function setLoopStart(v) { loopStart = v; }
export function setLoopEnd(v) { loopEnd = v; }
export function setAudioContext(v) { audioContext = v; }
export function setMasterVolume(v) { masterVolume = v; }
export let playbackSpeed = 1.0;
export function setPlaybackSpeed(v) { playbackSpeed = v; }
export function setVuRafId(v) { vuRafId = v; }
export function setMasterBusGain(v) { masterBusGain = v; }
export function setMasterLimiter(v) { masterLimiter = v; }
// Footer waveform draw callback — set by player.js, called by transport.js
export let footerWaveDrawFn = null;
export function setFooterWaveDrawFn(fn) { footerWaveDrawFn = fn; }
+536
View File
@@ -0,0 +1,536 @@
import { fmtTime, fmtTickLabel, fmtTimeMs, parseTimecode } from "./utils.js";
import {
playBtn, playMiniBtn, stopBtn, loopBtn, timeEl, masterFader,
speedEl, speedLabelEl,
rulerTime, wavesGrid, loopRegionEl, playheadMarker,
multitrack, audioEngine, totalDuration, loopEnabled, loopStart, loopEnd, masterVolume,
waveScroll, waveCanvas, multitrackContainer,
presenceRulerEl, presencePlayheadEl,
footerTimeElapsed, footerTimeTotal, npScrubFill, footerWaveDrawFn,
loopStartInput, loopEndInput,
setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume, setPlaybackSpeed,
} from "./state.js";
import { applyMix } from "./mixer.js";
const MIN_LOOP_SEC = 0.2;
// Below this visible width the waveform stops compressing to fit and instead
// keeps a minimum size, overflowing horizontally so .wave-scroll can scroll.
const WAVE_MIN_WIDTH = 720;
// rulerTime is the canonical timeline reference for both click->time
// and time->pixel mapping. The wave-editor lays the ruler and the
// waveform body out so they should be horizontally aligned (both gutter
// 48 px on the left in studio mode), but using one element for both
// halves of the round-trip eliminates any subtle CSS drift -- clicking
// "1:00" on the ruler always lands a marker exactly under that tick,
// regardless of how the waves layer below happens to size itself.
function rulerRect() {
return rulerTime?.getBoundingClientRect() || { left: 0, width: 1 };
}
function loopOverlayParent() {
return document.querySelector(".waves-column") || rulerTime;
}
function ensureLoopRegionParent() {
const parent = loopOverlayParent();
if (parent && loopRegionEl.parentElement !== parent) {
parent.appendChild(loopRegionEl);
}
}
function timeFromClientX(clientX) {
if (!totalDuration) return null;
const rect = rulerRect();
const x = clientX - rect.left;
const frac = Math.max(0, Math.min(1, x / Math.max(1, rect.width)));
return frac * totalDuration;
}
function setPlayheadTime(sec) {
const tx = audioEngine ?? multitrack;
if (!tx || !totalDuration) return;
const next = Math.max(0, Math.min(totalDuration, sec));
tx.setTime(next);
updatePlayheadMarker(next);
updateFooterTimes(next);
updatePresencePlayhead(next);
}
export function buildRuler(durationSec) {
rulerTime.innerHTML = "";
wavesGrid.innerHTML = "";
const marker = document.createElement("div");
marker.className = "playhead-marker";
marker.setAttribute("aria-hidden", "true");
marker.innerHTML =
'<svg viewBox="0 0 10 10" width="10" height="10"><polygon points="0,0 10,0 5,8" fill="#e54e4e"></polygon></svg>';
rulerTime.appendChild(marker);
if (!durationSec || durationSec <= 0) return;
const step = durationSec < 90 ? 15 : durationSec < 300 ? 30 : 60;
for (let t = 0; t <= durationSec; t += step) {
const leftPct = (t / durationSec) * 100;
const tick = document.createElement("div");
tick.className = "tick";
tick.style.left = `${leftPct}%`;
tick.innerHTML = `<span class="tick-label">${fmtTickLabel(t)}</span>`;
rulerTime.appendChild(tick);
const grid = document.createElement("div");
grid.className = "grid-line";
grid.style.left = `${leftPct}%`;
wavesGrid.appendChild(grid);
}
}
export function updatePlayheadMarker(currentSec) {
if (!playheadMarker || !totalDuration) return;
const m = rulerTime.querySelector(".playhead-marker");
if (m) {
// Position relative to the ruler itself (the marker is a ruler
// child) so the playhead always sits exactly under the tick at
// the matching time. Use percent instead of px: app-level CSS
// zoom scales getBoundingClientRect() values, while left/width
// styles are interpreted in unzoomed layout pixels.
const pct = Math.max(0, Math.min(100, (currentSec / totalDuration) * 100));
m.style.left = `${pct}%`;
}
}
// Mirror the elapsed/total time into the transport-footer's two side
// labels (which used to show hardcoded "00:00.000" / "03:38.000") and
// drive the small scrub bar in the now-playing card. Driven from the
// same wavesurfer "timeupdate" event that already updates #t-time, so
// every label stays in sync without extra event plumbing.
export function updateFooterTimes(currentSec) {
if (!totalDuration) return;
if (footerTimeElapsed) footerTimeElapsed.textContent = fmtTime(currentSec);
if (footerTimeTotal) footerTimeTotal.textContent = fmtTime(totalDuration);
const pct = Math.max(0, Math.min(100, (currentSec / totalDuration) * 100));
if (npScrubFill) npScrubFill.style.width = `${pct}%`;
footerWaveDrawFn?.(pct / 100);
}
// Build the presence-panel ruler labels from the actual track duration.
// The HTML ships 8 placeholder <b> tags ("0:00 ... 3:38"); we replace
// each label's text with a tick at evenly-spaced fractions of the song.
export function buildPresenceRuler(durationSec) {
if (!presenceRulerEl) return;
const ticks = presenceRulerEl.querySelectorAll("b");
if (!ticks.length) return;
if (!durationSec || durationSec <= 0) {
for (const t of ticks) t.textContent = "0:00";
return;
}
// 8 ticks -- evenly distribute from 0 to duration.
const n = ticks.length;
for (let i = 0; i < n; i++) {
const frac = i / (n - 1);
ticks[i].textContent = fmtTickLabel(frac * durationSec);
}
}
// Move the gold playhead line that overlays the presence-bars panel.
// Uses left% within the .presence-bars container, which spans the full
// duration -- matches the ruler ticks above it.
export function updatePresencePlayhead(currentSec) {
if (!presencePlayheadEl) return;
if (!totalDuration || totalDuration <= 0) {
presencePlayheadEl.classList.add("hidden");
return;
}
const pct = Math.max(0, Math.min(100, (currentSec / totalDuration) * 100));
presencePlayheadEl.style.left = `${pct}%`;
presencePlayheadEl.classList.remove("hidden");
}
export function updateLoopRegionVisual() {
const regionItem = document.getElementById("t-export-region");
const hasRegion = loopEnabled && totalDuration > 0 && loopEnd > loopStart;
if (regionItem) regionItem.setAttribute("aria-disabled", String(!hasRegion));
// Keep the engine's loop bounds in sync with every loop change (toggle/drag);
// the engine wraps playback itself off these values. No-op on streaming path.
audioEngine?.setLoop(loopEnabled, loopStart, loopEnd);
// Mirror the bounds into the exact-loop text fields (skips fields being edited).
syncLoopInputs();
if (!loopEnabled || !totalDuration) {
loopRegionEl.classList.add("hidden");
return;
}
ensureLoopRegionParent();
// Keep the loop overlay in the same normalized timeline coordinate
// system as the ruler ticks. Percentages avoid CSS zoom mismatch:
// pointer coordinates and getBoundingClientRect() are visual pixels,
// but style.left/style.width in px are unzoomed layout pixels.
const startPct = Math.max(0, Math.min(100, (loopStart / totalDuration) * 100));
const endPct = Math.max(0, Math.min(100, (loopEnd / totalDuration) * 100));
loopRegionEl.style.left = `${startPct}%`;
loopRegionEl.style.width = `${Math.max(0, endPct - startPct)}%`;
loopRegionEl.classList.remove("hidden");
}
// Keep the exact-loop text fields in sync with loopStart/loopEnd after any
// programmatic change (drag, toggle). Never overwrite a field the user is
// actively editing, and disable both when no track is loaded.
function syncLoopInputs() {
const enabled = totalDuration > 0;
for (const [input, value] of [
[loopStartInput, loopStart],
[loopEndInput, loopEnd],
]) {
if (!input) continue;
input.disabled = !enabled;
if (document.activeElement !== input) input.value = fmtTimeMs(value);
}
}
// Commit a typed loop time. Invalid/out-of-range input reverts the field to the
// current stored value (self-evident rejection) rather than raising an error;
// showError lives in the import form and would surface in the wrong place.
function commitLoopInput(which) {
const input = which === "start" ? loopStartInput : loopEndInput;
if (!input) return;
const revert = () => {
input.value = fmtTimeMs(which === "start" ? loopStart : loopEnd);
};
const parsed = parseTimecode(input.value);
if (parsed === null || totalDuration <= 0) {
revert();
return;
}
const v = Math.max(0, Math.min(totalDuration, parsed));
const start = which === "start" ? v : loopStart;
const end = which === "end" ? v : loopEnd;
if (end - start < MIN_LOOP_SEC) {
revert();
return;
}
setLoopStart(start);
setLoopEnd(end);
setLoopEnabled(true);
loopBtn.classList.add("active");
updateLoopRegionVisual();
}
function wireLoopInputs() {
for (const [input, which] of [
[loopStartInput, "start"],
[loopEndInput, "end"],
]) {
if (!input) continue;
input.addEventListener("blur", () => commitLoopInput(which));
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
input.blur();
} else if (e.key === "Escape") {
e.preventDefault();
input.value = fmtTimeMs(which === "start" ? loopStart : loopEnd);
input.blur();
}
});
}
syncLoopInputs();
}
// Standard DAW transport state machine:
// [stopped] (paused at start) ─Play→ [playing]
// ↑ ↓ Play
// Stop [paused] (paused mid-track)
// Stop ↓
// [stopped]
//
// Play button is a Play/Pause toggle. Stop both pauses and returns the
// playhead to 0 (or loopStart if loop is on). Visual state is driven
// from the multitrack lifecycle events in player.js (mt.on play/pause/
// timeupdate) — click handlers only mutate the transport, never the
// button's CSS class. That way manual seeks (e.g. clicking the ruler)
// keep the button states in sync without extra plumbing.
// WKWebView (Tauri desktop) has small audio buffers. After a seek, all
// audio elements drop their buffers and issue new range requests simultaneously.
// Calling play() before they reach HAVE_FUTURE_DATA (readyState >= 3) causes
// choppiness. Wait for all elements to be ready, with a hard 1.5 s fallback so
// the user is never stuck. Desktop browsers buffer aggressively enough that
// this wait is skipped entirely (readyState is already >= 3 by the time play
// is pressed after a seek).
function _playWhenReady() {
if (!multitrack) return;
const inTauri = Boolean(window.__TAURI__?.core?.invoke);
if (!inTauri) { multitrack.play(); return; }
const audios = (multitrack.audios ?? [])
.filter((a) => a instanceof HTMLMediaElement && a.src);
const notReady = audios.filter((a) => a.readyState < 3);
if (!notReady.length) { multitrack.play(); return; }
let fired = false;
const fire = () => { if (!fired && multitrack && !multitrack.isPlaying()) { fired = true; multitrack.play(); } };
const waits = notReady.map((a) => new Promise((res) => {
if (a.readyState >= 3) { res(); return; }
const onReady = () => { a.removeEventListener("canplay", onReady); res(); };
a.addEventListener("canplay", onReady);
}));
Promise.all(waits).then(fire);
window.setTimeout(fire, 1500);
}
export function togglePlayPause() {
const eng = audioEngine;
const tx = eng ?? multitrack;
if (!tx) return;
if (tx.isPlaying()) {
tx.pause();
// The engine emits no play/pause events (the multitrack stays silent), so
// the play-button visual that the ws "pause" handler normally toggles must
// be driven here directly.
if (eng) playBtn.classList.remove("playing");
return;
}
const ctx = tx.audioContext;
// Safari requires play() to be called synchronously within the user-gesture
// handler. Resume the AudioContext fire-and-forget so the context becomes
// live, then call play() immediately on the same tick.
if (ctx && ctx.state === "suspended") {
ctx.resume().catch(() => {});
}
// Snap playhead to loopStart on play (DAW convention).
if (loopEnabled && totalDuration > 0) {
tx.setTime(loopStart);
}
if (eng) {
eng.play();
playBtn.classList.add("playing");
stopBtn.classList.remove("stopped");
} else {
_playWhenReady();
}
}
export function stopTransport() {
const eng = audioEngine;
const tx = eng ?? multitrack;
if (!tx) return;
tx.pause();
tx.setTime(loopEnabled ? loopStart : 0); // engine: setTime → onTime → stop visual
if (eng) playBtn.classList.remove("playing");
}
export function toggleLoop() {
setLoopEnabled(!loopEnabled);
loopBtn.classList.toggle("active", loopEnabled);
updateLoopRegionVisual();
}
// Click-drag on the timeline ruler or waveform body to define the loop
// region. Drag direction doesn't matter -- start and end get sorted.
// Tiny drags are treated as clicks and seek the playhead instead.
function wireLoopDrag() {
let dragging = false;
let dragStartTime = 0;
let activePointerId = null;
let moved = false;
const startDrag = (e, surface) => {
if (e.button !== 0 || e.target.closest(".loop-region")) return;
const t = timeFromClientX(e.clientX);
if (t === null) return;
dragging = true;
activePointerId = e.pointerId;
moved = false;
dragStartTime = t;
setLoopStart(t);
setLoopEnd(t);
setLoopEnabled(true);
loopBtn.classList.add("active");
updateLoopRegionVisual();
surface.setPointerCapture(e.pointerId);
e.preventDefault();
};
const moveDrag = (e) => {
if (!dragging || e.pointerId !== activePointerId) return;
const t = timeFromClientX(e.clientX);
if (t === null) return;
if (Math.abs(t - dragStartTime) >= MIN_LOOP_SEC) moved = true;
if (t < dragStartTime) {
setLoopStart(t);
setLoopEnd(dragStartTime);
} else {
setLoopStart(dragStartTime);
setLoopEnd(t);
}
updateLoopRegionVisual();
};
const finishDrag = (e) => {
if (!dragging || e.pointerId !== activePointerId) return;
dragging = false;
activePointerId = null;
const clicked = !moved || loopEnd - loopStart < MIN_LOOP_SEC;
if (clicked) {
setLoopEnabled(false);
loopBtn.classList.remove("active");
updateLoopRegionVisual();
setPlayheadTime(dragStartTime);
}
};
const wavesColumn = document.querySelector(".waves-column");
const surfaces = [rulerTime, wavesColumn].filter(Boolean);
for (const surface of surfaces) {
surface.addEventListener("pointerdown", (e) => {
if (surface === rulerTime && e.target !== rulerTime) return;
startDrag(e, surface);
});
surface.addEventListener("pointermove", moveDrag);
surface.addEventListener("pointerup", finishDrag);
surface.addEventListener("pointercancel", finishDrag);
}
}
// ─── Zoom ───
//
// Single CSS variable `--zoom` on .wave-canvas drives the visual width
// (canvas = 100% * zoom). Multitrack's pxPerSec is set to match so its
// internal canvases stay the exact same pixel width as the canvas; that
// way the bundle never adds its own internal horizontal scroll, which
// historically broke alignment with our ruler/loop overlay.
//
// All percentage-positioned children (ruler ticks, playhead, grid lines,
// loop region) automatically stretch with the canvas, so the loop drag
// math stays correct without any per-element width logic.
// Keep the header ruler horizontally aligned with the (possibly wider, scrolled)
// waveform body. The ruler lives outside .wave-scroll, so translate it by the
// same scrollLeft; .daw-ruler-area uses overflow-x: clip to hide the spill while
// leaving the vertical playhead line (overflow-y: visible) intact.
export function syncRulerScroll() {
if (rulerTime && waveScroll) {
rulerTime.style.transform = `translateX(${-waveScroll.scrollLeft}px)`;
}
}
export function applyWaveZoom() {
const lanes = document.getElementById("lanes") || waveCanvas;
const wavesColumn = document.querySelector(".waves-column");
if (wavesColumn) {
lanes?.style.setProperty("--wave-playhead-h", `${wavesColumn.clientHeight}px`);
}
if (multitrack && totalDuration > 0 && waveScroll) {
const baseWidth = waveScroll.clientWidth;
if (baseWidth > 0) {
// Fit to the visible width, but never compress below WAVE_MIN_WIDTH.
const contentWidth = Math.max(baseWidth, WAVE_MIN_WIDTH);
const zoom = contentWidth / baseWidth;
// Widen the container via --zoom FIRST. Then, after the browser has
// reflowed it, zoom WaveSurfer to fit the container's *actual* width.
// Measuring post-reflow avoids the resize race where WaveSurfer renders
// wider than its container and exposes its own (unstyled, light) internal
// horizontal scrollbar — the only horizontal scroll must come from the
// outer .wave-scroll, which also keeps the ruler/playhead aligned.
lanes?.style.setProperty("--zoom", String(zoom));
requestAnimationFrame(() => {
if (!multitrack || totalDuration <= 0) return;
const w = multitrackContainer?.clientWidth || contentWidth;
try { multitrack.zoom(w / totalDuration); } catch { /* ignore -- pre-canplay */ }
syncRulerScroll();
});
}
}
}
function wireZoomButtons() {
if (waveScroll) {
let rafId = null;
const ro = new ResizeObserver(() => {
if (!multitrack || totalDuration <= 0) return;
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => { rafId = null; applyWaveZoom(); });
});
ro.observe(waveScroll);
}
if (waveScroll) {
waveScroll.addEventListener("wheel", (e) => {
if (waveScroll.scrollWidth <= waveScroll.clientWidth) return;
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault();
waveScroll.scrollLeft += e.deltaY;
}
}, { passive: false });
waveScroll.addEventListener("scroll", syncRulerScroll, { passive: true });
}
applyWaveZoom();
}
// Keep the mixer column and the waveform area scrolled in lockstep so stem
// controls stay aligned with their lanes when the stack overflows (#159).
function wireLaneScrollSync() {
const mixer = document.getElementById("mixer");
if (!mixer || !waveScroll) return;
// Mirror scrollTop between the two panes by assigning only when the values
// differ. The echo stops on its own (once equal, the partner's handler is a
// no-op), so no reentrancy guard / rAF is needed — that frame-delayed guard
// was what made inertial scrolling stutter (#163).
const link = (src, dst) =>
src.addEventListener("scroll", () => {
if (dst.scrollTop !== src.scrollTop) dst.scrollTop = src.scrollTop;
}, { passive: true });
link(mixer, waveScroll);
link(waveScroll, mixer);
}
// ─── Wire transport buttons ───
export function wireTransportButtons() {
playBtn.addEventListener("click", togglePlayPause);
playMiniBtn?.addEventListener("click", togglePlayPause);
stopBtn.addEventListener("click", stopTransport);
loopBtn.addEventListener("click", toggleLoop);
wireLoopDrag();
wireLoopInputs();
wireZoomButtons();
wireLaneScrollSync();
masterFader?.addEventListener("input", () => {
setMasterVolume(parseFloat(masterFader.value));
applyMix();
});
masterFader?.addEventListener("dblclick", () => {
masterFader.value = "0.5";
setMasterVolume(0.5);
applyMix();
});
wireSpeedControl();
}
function applySpeed(rate) {
const clamped = Math.max(0.25, Math.min(2, rate));
setPlaybackSpeed(clamped);
if (speedEl) {
speedEl.value = String(clamped);
// range is 0-2; 1.0 sits at exactly 50%
const pct = (clamped / 2) * 100;
speedEl.style.setProperty("--speed-pct", `${pct.toFixed(1)}%`);
}
if (speedLabelEl) speedLabelEl.textContent = `${clamped % 1 === 0 ? clamped.toFixed(1) : clamped}x`;
audioEngine?.setPlaybackRate?.(clamped);
if (multitrack) {
for (const a of (multitrack.audios ?? [])) {
try { a.playbackRate = clamped; } catch { /* noop */ }
}
}
}
export function resetSpeed() {
applySpeed(1.0);
}
function wireSpeedControl() {
if (!speedEl) return;
speedEl.addEventListener("input", () => applySpeed(parseFloat(speedEl.value)));
speedEl.addEventListener("dblclick", () => applySpeed(1.0));
speedEl.addEventListener("wheel", (e) => {
e.preventDefault();
const delta = e.deltaY < 0 ? 0.25 : -0.25;
applySpeed(parseFloat(speedEl.value) + delta);
}, { passive: false });
}
+31
View File
@@ -0,0 +1,31 @@
// Small UI-chrome handlers extracted from inline index.html scripts / onclick
// attributes so the Content-Security-Policy can forbid inline script (#171).
// Loaded as a module (deferred), so the DOM is parsed before this runs.
// Upload button → trigger the hidden file input.
document.getElementById("uploadFileBtn")?.addEventListener("click", () => {
document.getElementById("fileInput")?.click();
});
// Notification panel: toggle / close / close-on-outside-click.
const notifBtn = document.getElementById("notifBtn");
const notifWrap = notifBtn?.closest(".daw-notif-wrap");
function setNotifOpen(open) {
notifWrap?.classList.toggle("open", open);
notifBtn?.setAttribute("aria-expanded", String(open));
}
notifBtn?.addEventListener("click", () => {
setNotifOpen(!notifWrap?.classList.contains("open"));
});
document
.querySelector(".daw-notif-close")
?.addEventListener("click", () => setNotifOpen(false));
document.addEventListener("click", (e) => {
if (notifWrap?.classList.contains("open") && !notifWrap.contains(e.target)) {
setNotifOpen(false);
}
});
+135
View File
@@ -0,0 +1,135 @@
// ─── Persistent store (tauri-plugin-store via custom commands) ───
//
// Falls back to localStorage when running outside Tauri (browser dev mode).
// The store is backed by ~/Library/Application Support/app.stemdeck.desktop/user-data.json
// on macOS — outside WebKit's reach, so WebView resets can never destroy user data.
export async function storeGet(key, fallback = null) {
if (window.__TAURI__?.core?.invoke) {
try {
const val = await window.__TAURI__.core.invoke("store_get", { key });
return val ?? fallback;
} catch (e) { console.warn("[store] get failed for", key, e); return fallback; }
}
try {
const raw = localStorage.getItem(key);
return raw !== null ? JSON.parse(raw) : fallback;
} catch (e) { console.warn("[store] localStorage get failed for", key, e); return fallback; }
}
export async function storeSet(key, value) {
if (window.__TAURI__?.core?.invoke) {
try {
await window.__TAURI__.core.invoke("store_set", { key, value });
} catch (e) { console.warn("[store] set failed for", key, e); }
return;
}
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { console.warn("[store] localStorage set failed", e); }
}
// Debounced variant — coalesces rapid writes (e.g. mixer slider moves) into
// a single store write ~300ms after the last call. Each call snapshots the
// value so no mutation races occur.
const _storePending = new Map();
export function storeSetDebounced(key, value, delayMs = 300) {
if (_storePending.has(key)) clearTimeout(_storePending.get(key));
const snapshot = structuredClone(value);
_storePending.set(key, setTimeout(() => {
_storePending.delete(key);
storeSet(key, snapshot);
}, delayMs));
}
// Keys that hold critical user data and must be migrated from localStorage
// to the store on first launch after this feature ships.
const _MIGRATE_KEYS = [
"stemdeck.folders",
"stemdeck.deleted_jobs",
"stemdeck:selected-stems",
];
// One-time bootstrap: copy localStorage → store for existing users.
// On fresh installs (no localStorage data) this is a no-op that just writes
// the migration flag so future upgrades can safely clear WebKit.
export async function runStoreMigrationIfNeeded() {
if (!window.__TAURI__?.core?.invoke) return;
try {
// Check if any critical key is absent from the store but present in localStorage.
const needs = await Promise.all(
_MIGRATE_KEYS.map(async (k) => {
const inStore = await window.__TAURI__.core.invoke("store_get", { key: k });
return inStore === null && localStorage.getItem(k) !== null;
})
).then((r) => r.some(Boolean));
if (needs) {
// Migrate fixed keys.
for (const k of _MIGRATE_KEYS) {
try {
const raw = localStorage.getItem(k);
if (raw !== null) {
await window.__TAURI__.core.invoke("store_set", { key: k, value: JSON.parse(raw) });
}
} catch (e) { console.warn("[store] migration failed for key", k, e); }
}
// Migrate per-job mix keys (stemdeck:mix:<jobId>).
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k?.startsWith("stemdeck:mix:")) {
try {
const raw = localStorage.getItem(k);
if (raw !== null) {
await window.__TAURI__.core.invoke("store_set", { key: k, value: JSON.parse(raw) });
}
} catch (e) { console.warn("[store] migration failed for key", k, e); }
}
}
}
// Write the flag regardless of whether migration was needed. This covers
// fresh installs (no localStorage data) so future upgrades clear WebKit.
await window.__TAURI__.core.invoke("mark_store_migration_done").catch((e) =>
console.warn("[store] mark_store_migration_done failed:", e)
);
} catch (e) { console.warn("[store] migration error:", e); }
}
export function fmtTime(s) {
if (!isFinite(s) || s < 0) return "00:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60).toString().padStart(2, "0");
return `${m.toString().padStart(2, "0")}:${sec}`;
}
// Ruler tick: M:SS with no leading zero on the minutes digit ("0:30", "1:00", "12:30").
export function fmtTickLabel(s) {
if (!isFinite(s) || s < 0) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60).toString().padStart(2, "0");
return `${m}:${sec}`;
}
// Millisecond-precise timecode "mm:ss.mmm" for the exact-loop inputs. Integer-ms
// math avoids a rounding carry bug (e.g. 0.9999s -> "00:01.000", not "00:00.1000").
export function fmtTimeMs(s) {
if (!isFinite(s) || s < 0) return "00:00.000";
const totalMs = Math.round(s * 1000);
const m = Math.floor(totalMs / 60000);
const sec = Math.floor((totalMs % 60000) / 1000);
const ms = totalMs % 1000;
return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}.${ms
.toString()
.padStart(3, "0")}`;
}
// Parse a user-typed loop time. Accepts "mm:ss(.mmm)" (seconds field 0-59) or a
// plain decimal-seconds value ("12.48"). Returns seconds, or null if unparseable.
export function parseTimecode(str) {
const t = String(str ?? "").trim();
const colon = /^(\d+):([0-5]?\d(?:\.\d{1,3})?)$/.exec(t);
if (colon) return parseInt(colon[1], 10) * 60 + parseFloat(colon[2]);
if (/^\d+(?:\.\d+)?$/.test(t)) return parseFloat(t);
return null;
}
export const $ = (id) => document.getElementById(id);
+939
View File
@@ -0,0 +1,939 @@
// StemDeck mobile — vanilla port of the Claude Design prototype
// (design/mobile/StemDeck-Mobile.dc.html). No framework, no build step,
// to match the rest of static/. Library + Mixer are wired to the real API
// (GET /api/jobs, /api/jobs/{id}, the Web Audio engine, mixdown export).
// Extract is still mock pending the SSE/upload wiring (next step).
import { fetchJobs, jobToCard } from "../js/shared/jobs.js";
import { createChunkedAudioEngine } from "../js/chunkedAudioEngine.js";
// Per-stem label + color, keyed by the backend stem name. Unknown names fall
// back to a rotating palette so non-standard models still render sensibly.
const STEM_META = {
vocals: { label: "Vocals", color: "#f0506e" },
drums: { label: "Drums", color: "#f5862b" },
bass: { label: "Bass", color: "#f5c518" },
guitar: { label: "Guitar", color: "#3fcf6e" },
piano: { label: "Piano", color: "#9b6cf0" },
other: { label: "Other", color: "#4a9bf5" },
};
const FALLBACK_COLORS = ["#f0506e", "#f5862b", "#f5c518", "#3fcf6e", "#9b6cf0", "#4a9bf5", "#2bd4c4", "#c44ad0"];
function stemMeta(name, idx) {
return STEM_META[name] || { label: name.charAt(0).toUpperCase() + name.slice(1), color: FALLBACK_COLORS[idx % FALLBACK_COLORS.length] };
}
// Stem chips shown on the (still-mock) Extract screen.
const EXTRACT_STEMS = Object.entries(STEM_META).map(([id, m]) => ({ id, name: m.label, color: m.color }));
const DEFAULT_GRADIENT = "linear-gradient(150deg,#3a3a42,#202026)";
const FILTERS = ["All", "Favorites"];
// ─── Web Audio engine (reused from desktop: ../js/audioEngine.js) ───
let engine = null;
let engineTrackId = null;
let engineToken = 0; // guards against races when switching tracks mid-decode
let engineReady = false; // true once the current engine has decodable stems
let pendingPlay = false;
// Shared AudioContext, created + resumed inside a user gesture (mobile/iOS
// won't start audio otherwise). Once running it stays running, so later
// programmatic play() calls work even outside a gesture.
let audioCtx = null;
function ensureAudioCtx() {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
if (!audioCtx) audioCtx = new AC();
if (audioCtx.state === "suspended") audioCtx.resume().catch(() => {});
return audioCtx;
}
const ICON = {
chevron: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>',
dots: '<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>',
prev: '<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor"><path d="M7 6v12M19 6l-9 6 9 6z"/></svg>',
next: '<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor"><path d="M17 6v12M5 6l9 6-9 6z"/></svg>',
play: (sz, fill) => `<svg width="${sz}" height="${sz}" viewBox="0 0 24 24" fill="${fill}"><path d="M8 5.5v13l11-6.5z"/></svg>`,
pause: (sz, fill) => `<svg width="${sz}" height="${sz}" viewBox="0 0 24 24" fill="${fill}"><rect x="6" y="5" width="4" height="14" rx="1.3"/><rect x="14" y="5" width="4" height="14" rx="1.3"/></svg>`,
download: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M8 11l4 4 4-4"/><path d="M4 21h16"/></svg>',
search: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#65656d" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>',
link: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#65656d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>',
upload: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4"/><path d="M8 8l4-4 4 4"/><path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>',
check: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>',
scissors: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4L8.12 15.88M14.47 14.48L20 20M8.12 8.12L12 12"/></svg>',
tabLib: '<svg width="23" height="23" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>',
tabMix: '<svg width="23" height="23" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 8h16M4 16h16"/><circle cx="9" cy="8" r="2.4" fill="currentColor" stroke="none"/><circle cx="15" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>',
tabExt: '<svg width="23" height="23" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.6"/><circle cx="6" cy="18" r="2.6"/><path d="M20 4L8.5 15.5M14 14.5L20 20M8.5 8.5L11.5 11.5"/></svg>',
};
const state = {
tab: "mixer",
mixerView: "stems",
playing: false,
progress: 0,
vols: {}, // per-stem gain 0..1, keyed by backend stem name
muted: {},
solo: {},
speed: 1.0,
selected: { vocals: true, drums: true, bass: true, guitar: true, piano: true, other: true },
quality: "High",
filter: "All",
// Library data from /api/jobs. libState: "loading" | "ready" | "empty" | "error".
tracks: [],
libState: "loading",
swipedTrackId: null, // track whose swipe-to-delete is revealed
current: null, // selected track card (with .detail once loaded), or null
// Extract screen.
extractUrl: "",
extractFile: null, // File chosen for upload
extractJob: null, // { id, title, status, progress, stage } while a job runs
};
let extractES = null; // EventSource for the active extraction
let extractPoll = null; // REST poll fallback timer
const app = document.getElementById("app");
function fmt(sec) {
if (!isFinite(sec) || sec < 0) sec = 0;
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return m + ":" + String(s).padStart(2, "0");
}
function esc(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
}
// Cover/thumbnail art. Uses the real YouTube/SoundCloud thumbnail when present
// (layered over the gradient as a fallback if it fails to load); otherwise the
// gradient + initial. The URL is constrained to a clean https URL so it can't
// break out of the CSS url().
function safeThumb(url) {
return typeof url === "string" && /^https:\/\/[^"'()\\\s]+$/.test(url) ? url : "";
}
function artStyle(card) {
const g = (card && card.gradient) || DEFAULT_GRADIENT;
const t = card && safeThumb(card.thumb);
return t
? `background-image:url('${t}'), ${g};background-size:cover;background-position:center;`
: `background:${g};`;
}
function artLabel(card) {
return card && safeThumb(card.thumb) ? "" : esc((card && card.initial) || "♪");
}
// ─── Mixer / engine helpers ───
function lanes() {
return state.current?.detail?.lanes || [];
}
function curDuration() {
return state.current?.detail?.duration || 0;
}
function anySolo() {
return Object.values(state.solo).some(Boolean);
}
function laneActive(name) {
const muted = !!state.muted[name];
const soloed = !!state.solo[name];
return !(muted || (anySolo() && !soloed));
}
function effectiveGain(name) {
return laneActive(name) ? (state.vols[name] ?? 1) : 0;
}
function applyMix() {
if (!engine) return;
for (const l of lanes()) engine.setGain(l.name, effectiveGain(l.name));
}
function cap(s) {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
function drLabel(dr) {
if (dr < 7) return "Compressed";
if (dr < 10) return "Moderate";
if (dr < 14) return "High";
return "Wide";
}
function stabilityLabel(pct) {
if (pct >= 90) return "Very stable";
if (pct >= 70) return "Stable";
if (pct >= 40) return "Variable";
return "Unstable";
}
// Build the Analysis tab's stat cards + stem-presence rows from a job detail.
function extractAnalysis(d, laneList) {
const stats = [];
if (d.key) stats.push({ k: "KEY", v: d.key, s: d.scale ? cap(d.scale) : "" });
if (d.bpm) stats.push({ k: "TEMPO", v: String(Math.round(d.bpm)), s: "BPM" });
if (d.lufs != null) stats.push({ k: "LUFS", v: Number(d.lufs).toFixed(1), s: d.peak_db != null ? `Peak ${Number(d.peak_db).toFixed(1)} dB` : "Integrated" });
if (d.duration) stats.push({ k: "DURATION", v: fmt(d.duration), s: "Full length" });
if (d.dynamic_range != null) stats.push({ k: "DYN RANGE", v: Number(d.dynamic_range).toFixed(1), s: drLabel(d.dynamic_range) });
if (d.tempo_stability != null) {
const pct = d.tempo_stability > 1 ? d.tempo_stability : d.tempo_stability * 100;
stats.push({ k: "STABILITY", v: Math.round(pct) + "%", s: stabilityLabel(pct) });
}
const sp = d.stem_presence || {};
const presence = laneList
.filter((l) => sp[l.name] != null)
.map((l) => ({ name: l.label, color: l.color, val: Math.round(sp[l.name]) }));
return { stats, presence };
}
// Repaint the main waveform's played (yellow) vs. remaining bars as playback
// advances. Only touches the DOM when the played-bar count actually changes
// (≤ N times over the whole track), not every animation frame.
let _lastPlayedBar = -1;
function paintWaveProgress() {
const bars = app.querySelectorAll(".wave-bars > i");
if (!bars.length) return;
const played = Math.round(state.progress * bars.length);
if (played === _lastPlayedBar) return;
for (let i = 0; i < bars.length; i++) {
bars[i].style.background = i <= played ? "#f5b417" : "#34343c";
}
_lastPlayedBar = played;
}
function onEngineTime(t) {
const dur = curDuration() || 1;
state.progress = Math.max(0, Math.min(1, t / dur));
const head = app.querySelector(".playhead");
const cur = app.querySelector(".wave-times .cur");
if (head) head.style.left = state.progress * 100 + "%";
if (cur) cur.textContent = fmt(t);
paintWaveProgress();
}
// Load a track: fetch detail, build lanes, spin up the Web Audio engine.
async function openTrack(card, { autoplay = false } = {}) {
state.tab = "mixer";
state.current = { ...card, detail: null, loading: true, error: null };
state.playing = false;
state.progress = 0;
state.speed = 1.0;
render();
if (engine) { engine.destroy(); engine = null; engineTrackId = null; }
engineReady = false;
const token = ++engineToken;
pendingPlay = autoplay;
let detail;
try {
const res = await fetch(`/api/jobs/${card.id}`, { cache: "no-store" });
if (!res.ok) throw new Error(`GET /api/jobs/${card.id} -> ${res.status}`);
detail = await res.json();
} catch (e) {
if (token !== engineToken) return;
console.warn("[mobile] track detail failed:", e);
state.current.loading = false;
state.current.error = "Couldn't load this track.";
render();
return;
}
if (token !== engineToken) return;
// Use WAV stems with range-request chunking (chunkedAudioEngine.js): fetches
// 10-second windows at a time, so the first audio starts after ~7 MB instead
// of the full file, and peak RAM stays around 28 MB regardless of track length.
const laneList = (detail.stems || [])
.filter((s) => s && s.name !== "original" && s.url)
.map((s, i) => ({ name: s.name, url: s.url, ...stemMeta(s.name, i) }));
state.vols = {};
state.muted = {};
state.solo = {};
for (const l of laneList) state.vols[l.name] = 1;
state.current.detail = {
duration: detail.duration || 0,
lanes: laneList,
hasVideo: !!detail.has_video,
analysis: extractAnalysis(detail, laneList),
};
state.current.loading = false;
state.current.stemCount = laneList.length;
if (!laneList.length) {
state.current.error = "This track has no playable stems yet.";
render();
return;
}
const engineOpts = {
onTime: onEngineTime,
onEnded: () => { state.playing = false; render(); },
context: ensureAudioCtx(),
};
engine = createChunkedAudioEngine(laneList.map((l) => ({ name: l.name, url: l.url })), engineOpts);
engineTrackId = card.id;
render();
const ok = await engine.ready;
if (token !== engineToken) return;
if (!ok) {
state.current.error = "Couldn't load this track's audio.";
pendingPlay = false;
toast("Couldn't load this track's audio.");
render();
return;
}
engineReady = true;
applyMix();
render();
if (pendingPlay) { pendingPlay = false; engine.play(); state.playing = engine.isPlaying(); render(); }
}
function prevTrack() {
if (!state.tracks.length || !state.current) return;
const idx = state.tracks.findIndex((t) => t.id === state.current.id);
const target = state.tracks[idx - 1];
if (target) openTrack(target, { autoplay: state.playing });
}
function nextTrack() {
if (!state.tracks.length || !state.current) return;
const idx = state.tracks.findIndex((t) => t.id === state.current.id);
const target = state.tracks[idx + 1];
if (target) openTrack(target, { autoplay: state.playing });
}
function togglePlay() {
if (!state.current) return;
ensureAudioCtx(); // unlock audio within this gesture
// Engine not loaded for this track yet → load then autoplay.
if (!engine || engineTrackId !== state.current.id) {
openTrack(state.current, { autoplay: true });
return;
}
// Decode still in flight → remember the intent and play once ready.
if (!engineReady) {
pendingPlay = true;
return;
}
if (engine.isPlaying()) engine.pause();
else engine.play();
state.playing = engine.isPlaying();
render();
}
function seekToFraction(frac) {
if (!engine) return;
engine.seek(Math.max(0, Math.min(1, frac)) * curDuration());
}
function triggerDownload(url) {
const a = document.createElement("a");
a.href = url;
a.download = "";
document.body.appendChild(a);
a.click();
a.remove();
}
function exportMix(kind) {
const id = state.current?.id;
if (!id || !lanes().length) return;
const names = [];
const gains = [];
for (const l of lanes()) {
const g = effectiveGain(l.name);
if (g > 0) { names.push(l.name); gains.push(g); }
}
if (!names.length) { toast("Every stem is muted — nothing to export."); return; }
const q = new URLSearchParams({ stems: names.join(","), gains: gains.map((g) => g.toFixed(3)).join(",") });
const path = kind === "mp4" ? `video.mp4` : `mixdown.wav`;
triggerDownload(`/api/jobs/${id}/${path}?${q}`);
}
let _toastTimer = null;
function toast(msg) {
let el = document.getElementById("m-toast");
if (!el) {
el = document.createElement("div");
el.id = "m-toast";
el.className = "m-toast";
document.body.appendChild(el);
}
el.textContent = msg;
el.classList.add("show");
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.classList.remove("show"), 2400);
}
function mainWaveform() {
const N = 56;
const played = Math.round(state.progress * N);
let bars = "";
for (let i = 0; i < N; i++) {
let h = 24 + Math.abs(Math.sin(i * 0.7) + Math.sin(i * 0.21) * 0.7) * 46 + ((i * 37) % 11);
h = Math.max(14, Math.min(98, h));
const bg = i <= played ? "#f5b417" : "#34343c";
bars += `<i style="height:${h}%;background:${bg}"></i>`;
}
return bars;
}
function stemWave(idx, color, vol, eff) {
const wn = 28;
const wplayed = Math.round(state.progress * wn);
let out = "";
for (let i = 0; i < wn; i++) {
let wh = 16 + Math.abs(Math.sin(i * (0.55 + idx * 0.13)) + Math.sin(i * 0.33 + idx) * 0.6) * 62 + ((i * (7 + idx * 3)) % 9);
wh = Math.max(7, Math.min(98, wh)) * (0.3 + 0.7 * vol);
const wc = eff ? "#34343c" : i <= wplayed ? color : color + "3d";
out += `<i style="height:${wh}%;background:${wc}"></i>`;
}
return out;
}
function stemsBody() {
const c = state.current;
if (!c) return `<div class="lib-note">Pick a track from your Library to start mixing.</div>`;
if (c.loading) return `<div class="lib-note">Loading stems…</div>`;
if (c.error) return `<div class="lib-note">${esc(c.error)}</div>`;
const ll = lanes();
if (!ll.length) return `<div class="lib-note">No stems available.</div>`;
return `<div class="stems-grid">${ll.map((l, idx) => {
const vol = state.vols[l.name] ?? 1;
const muted = !!state.muted[l.name];
const soloed = !!state.solo[l.name];
const eff = !laneActive(l.name);
const pct = Math.round(vol * 100) + "%";
const mOn = muted ? `background:${l.color};color:#15100a;border-color:${l.color};` : "";
const sOn = soloed ? "background:#f5b417;color:#1a1206;border-color:#f5b417;" : "";
return `<div class="stem">
<div class="stem-top">
<div class="stem-dot" style="background:${l.color};box-shadow:0 0 8px ${l.color}88;opacity:${eff ? 0.4 : 1}"></div>
<div class="stem-name" style="color:${eff ? "#6a6a72" : l.color}">${esc(l.label)}</div>
<button class="ms-btn" style="${mOn}" data-action="mute" data-id="${l.name}">M</button>
<button class="ms-btn" style="${sOn}" data-action="solo" data-id="${l.name}">S</button>
</div>
<div class="stem-wave">${stemWave(idx, l.color, vol, eff)}</div>
<div class="fader" data-fader data-id="${l.name}">
<div class="fader-track"></div>
<div class="fader-fill" style="width:${pct};background:${eff ? "#46464e" : l.color}"></div>
<div class="fader-knob" style="left:${pct};background:${eff ? "#8a8a90" : "#fff"}"></div>
</div>
</div>`;
}).join("")}</div>`;
}
function analysisBody() {
const a = state.current?.detail?.analysis;
const hasVideo = state.current?.detail?.hasVideo;
const statsHtml = a && a.stats.length
? `<div class="stats">${a.stats.map((x) => `<div class="stat"><div class="stat-k">${x.k}</div><div class="stat-v">${esc(x.v)}</div><div class="stat-s">${esc(x.s)}</div></div>`).join("")}</div>`
: `<div class="lib-note" style="margin-top:8px">Analysis not available for this track.</div>`;
const presenceHtml = a && a.presence.length
? `<div class="eyebrow">STEM PRESENCE</div>${a.presence.map((p) => `<div class="presence-row"><div class="presence-name">${esc(p.name)}</div><div class="presence-bar"><i style="width:${p.val}%;background:${p.color}"></i></div><span class="presence-val">${p.val}%</span></div>`).join("")}`
: "";
const exportBtns = `<button class="cta sm" data-action="export" data-kind="wav">${ICON.download}Export mix</button>${
hasVideo ? `<button class="cta sm" data-action="export" data-kind="mp4" style="margin-top:10px">${ICON.download}Export MP4 (with video)</button>` : ""
}`;
return `<div class="pad" style="padding-top:16px">${statsHtml}${presenceHtml}${exportBtns}</div>`;
}
function mixerScreen() {
const c = state.current || { title: "No track selected", sub: "Pick one from your Library", initial: "♪", gradient: DEFAULT_GRADIENT, stemCount: 0 };
const sourceTag = c.sub || "—";
const stemTag = c.stemCount ? `${c.stemCount} stems` : "";
const dur = curDuration();
const body = state.mixerView === "stems" ? stemsBody() : analysisBody();
const ready = engineReady && engineTrackId === state.current?.id;
const preparing = !!state.current && !state.current.loading && !state.current.error && !ready;
const canPlay = ready;
const curIdx = state.current ? state.tracks.findIndex((t) => t.id === state.current.id) : -1;
const hasPrev = curIdx > 0;
const hasNext = curIdx >= 0 && curIdx < state.tracks.length - 1;
return `<div class="screen scrl">
<div class="pad">
<div class="mx-head">
<button class="icon-btn" data-action="tab" data-tab="library">${ICON.chevron}</button>
<span class="now-playing">NOW PLAYING</span>
<button class="icon-btn">${ICON.dots}</button>
</div>
<div class="cover-wrap">
<div class="cover" style="${artStyle(c)}"><span>${artLabel(c)}</span></div>
<div class="track-title">${esc(c.title)}</div>
<div class="track-sub">${esc(c.sub)}</div>
<div class="tags"><span class="tag">${esc(sourceTag)}</span>${stemTag ? `<span class="tag">${stemTag}</span>` : ""}</div>
</div>
<div class="wave">
<div class="wave-bars" data-seek>${mainWaveform()}<div class="playhead" style="left:${state.progress * 100}%"></div></div>
<div class="wave-times"><span class="cur">${fmt(state.progress * dur)}</span><span class="dur">${fmt(dur)}</span></div>
</div>
<div class="transport">
<button class="t-step" data-action="prev" ${hasPrev ? "" : "disabled"}>${ICON.prev}</button>
<button class="t-play" data-action="play" data-playing="${state.playing}" ${canPlay ? "" : "disabled style=opacity:.45"}>${state.playing ? ICON.pause(26, "#1a1206") : ICON.play(28, "#1a1206")}</button>
<button class="t-step" data-action="next" ${hasNext ? "" : "disabled"}>${ICON.next}</button>
</div>
<div class="speed-row">
<span class="speed-row-label">Speed</span>
<input type="range" class="speed-slider" data-speed min="0" max="2" step="0.25" value="${state.speed}">
<span class="speed-row-val">${state.speed % 1 === 0 ? state.speed.toFixed(1) : state.speed}x</span>
</div>
${preparing ? '<div class="mx-prep">Preparing audio…</div>' : ""}
<div class="segmented">
<button class="${state.mixerView === "stems" ? "on" : ""}" data-action="mixview" data-view="stems">Stems</button>
<button class="${state.mixerView === "analysis" ? "on" : ""}" data-action="mixview" data-view="analysis">Analysis</button>
</div>
</div>
${body}
</div>`;
}
function libraryBody() {
if (state.libState === "loading") {
return `<div class="lib-note">Loading your library…</div>`;
}
if (state.libState === "error") {
return `<div class="lib-note">Couldn't reach the server. <button class="lib-retry" data-action="reload">Retry</button></div>`;
}
if (state.libState === "empty") {
return `<div class="lib-note">No tracks yet. Head to <b>Extract</b> to split your first song.</div>`;
}
return `<div class="eyebrow">RECENT</div>
${state.tracks.map((t) => `<div class="track-wrap${state.swipedTrackId === t.id ? " swiped" : ""}">
<button class="track-delete" data-action="delete" data-id="${esc(t.id)}">Delete</button>
<div class="track" data-action="open" data-id="${esc(t.id)}">
<div class="track-art" style="${artStyle(t)}">${artLabel(t)}</div>
<div class="track-info"><div class="t">${esc(t.title)}</div><div class="s">${esc(t.sub)}</div><div class="m">${esc(t.meta)}</div></div>
<div class="track-dot ${t.status}"></div>
<button class="track-load" data-action="open" data-id="${esc(t.id)}">Load</button>
</div>
</div>`).join("")}`;
}
function libraryScreen() {
return `<div class="screen scrl">
<div class="pad">
<div class="lib-head">
<span class="h1">Library</span>
<div class="avatar">JS</div>
</div>
<div class="search">${ICON.search}<span>Search your library</span></div>
<div class="filters scrl">${FILTERS.map((f) => `<span class="filter ${f === state.filter ? "on" : ""}" data-action="filter" data-filter="${f}">${f}</span>`).join("")}</div>
${libraryBody()}
</div>
</div>`;
}
function extractProgressCard() {
const j = state.extractJob;
if (!j) return "";
const pct = Math.round((j.progress || 0) * 100);
const failed = j.status === "error";
const cancelled = j.status === "cancelled";
const done = j.status === "done";
const line = failed ? "Failed" : cancelled ? "Cancelled" : done ? "Done" : `${j.stage || j.status}`;
return `<div class="eyebrow">IN PROGRESS</div>
<div class="progress-card">
<div class="progress-top">
<div class="progress-art"></div>
<div style="flex:1;min-width:0">
<div style="font-size:14px;font-weight:600;color:#eaeaec;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(j.title || "Track")}</div>
<div style="font-size:12px;color:${failed ? "#f0506e" : "#7a7a82"};margin-top:1px">${esc(line)}</div>
</div>
<span class="progress-pct">${done ? "100" : pct}%</span>
</div>
<div class="progress-bar"><i style="width:${done ? 100 : pct}%"></i></div>
</div>`;
}
function extractScreen() {
const fileName = state.extractFile?.name;
return `<div class="screen scrl">
<div class="pad">
<div class="h1">Extract stems</div>
<div class="sub">Paste a link or upload audio to split into stems.</div>
<div class="paste">${ICON.link}<input id="ext-url" class="ext-input" type="url" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="Paste YouTube or audio URL" value="${esc(state.extractUrl || "")}"></div>
<button class="upload" data-action="pick-file">${ICON.upload}${fileName ? esc(fileName) : "Upload audio file"}</button>
<input id="ext-file" type="file" accept="audio/*,video/mp4,.mp3,.wav,.flac,.m4a,.ogg" style="display:none">
<div class="eyebrow">STEMS TO EXTRACT</div>
<div class="chips">${EXTRACT_STEMS.map((s) => {
const on = !!state.selected[s.id];
const onStyle = on ? `border-color:${s.color};background:${s.color}1c;` : "";
return `<button class="chip-btn ${on ? "on" : ""}" style="${onStyle}" data-action="chip" data-id="${s.id}"><div class="dot" style="background:${s.color}"></div><span class="nm">${s.name}</span>${on ? ICON.check : ""}</button>`;
}).join("")}</div>
<button class="cta" style="margin-top:22px" data-action="split">${ICON.scissors}Split stems</button>
${extractProgressCard()}
</div>
</div>`;
}
// Submit an extraction (URL or file) and follow it to completion.
async function startExtraction() {
const stems = Object.keys(state.selected).filter((k) => state.selected[k]);
if (!stems.length) { toast("Pick at least one stem."); return; }
const file = state.extractFile;
const url = (state.extractUrl || "").trim();
if (!file && !url) { toast("Paste a URL or choose a file first."); return; }
let init, title;
if (file) {
const fd = new FormData();
fd.append("file", file);
fd.append("stems", JSON.stringify(stems));
init = { method: "POST", body: fd };
title = file.name;
} else {
init = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, stems }) };
title = url;
}
state.extractJob = { id: null, title, status: "queued", progress: 0, stage: "Queued" };
render();
let jobId;
try {
const res = await fetch("/api/jobs", init);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText);
jobId = data.job_id;
} catch (e) {
console.warn("[mobile] extraction submit failed:", e);
state.extractJob = null;
toast(`Couldn't start: ${e.message}`);
render();
return;
}
state.extractJob.id = jobId;
state.extractFile = null;
state.extractUrl = "";
followExtraction(jobId);
render();
}
function _onExtractState(jobId, s) {
if (!state.extractJob || state.extractJob.id !== jobId) return false;
state.extractJob.status = s.status;
state.extractJob.progress = s.progress || 0;
state.extractJob.stage = s.stage || s.status;
if (state.tab === "extract") render();
if (s.status === "done" || s.status === "error" || s.status === "cancelled") {
if (s.status === "done") { toast("Stems ready!"); loadLibrary(); }
else if (s.status === "error") toast("Extraction failed.");
const finishedId = jobId;
setTimeout(() => {
if (state.extractJob && state.extractJob.id === finishedId) {
state.extractJob = null;
if (state.tab === "extract") render();
}
}, 4000);
return true; // terminal
}
return false;
}
// SSE with a REST-poll fallback (mirrors the desktop's resilience, simplified).
function followExtraction(jobId) {
if (extractES) { extractES.close(); extractES = null; }
if (extractPoll) { clearInterval(extractPoll); extractPoll = null; }
const startPolling = () => {
if (extractPoll) return;
extractPoll = setInterval(async () => {
try {
const r = await fetch(`/api/jobs/${jobId}`, { cache: "no-store" });
if (!r.ok) throw new Error(String(r.status));
if (_onExtractState(jobId, await r.json())) { clearInterval(extractPoll); extractPoll = null; }
} catch { /* keep polling */ }
}, 2500);
};
try {
const es = new EventSource(`/api/jobs/${jobId}/events`);
extractES = es;
es.onmessage = (ev) => {
let s;
try { s = JSON.parse(ev.data); } catch { return; }
if (_onExtractState(jobId, s)) { es.close(); extractES = null; }
};
es.onerror = () => { es.close(); extractES = null; startPolling(); };
} catch {
startPolling();
}
}
function miniPlayer() {
if (state.tab === "mixer" || !state.current) return "";
const c = state.current;
return `<div class="mini" data-action="tab" data-tab="mixer">
<div class="mini-art" style="${artStyle(c)}">${artLabel(c)}</div>
<div class="mini-info"><div class="t">${esc(c.title)}</div><div class="s">${esc(c.sub)}</div></div>
<button class="mini-play" data-action="play-mini">${state.playing ? ICON.pause(17, "#1a1206") : ICON.play(18, "#1a1206")}</button>
</div>`;
}
function tabBar() {
const t = (tab, icon, label) => `<button class="tab ${state.tab === tab ? "on" : ""}" data-action="tab" data-tab="${tab}">${icon}${label}</button>`;
return `<div class="tabbar">${t("extract", ICON.tabExt, "Extract")}${t("mixer", ICON.tabMix, "Mixer")}${t("library", ICON.tabLib, "Library")}</div>`;
}
function render() {
let screen = mixerScreen();
if (state.tab === "library") screen = libraryScreen();
else if (state.tab === "extract") screen = extractScreen();
app.innerHTML = screen + miniPlayer() + tabBar();
_lastPlayedBar = -1; // bars were just rebuilt; force a repaint on next tick
wireFaders();
wireSwipe();
}
// Suppresses the synthetic click that follows a swipe gesture, so swiping a
// row open doesn't immediately count as a tap.
let suppressClick = false;
// Swipe a library row left to reveal its Delete button (iOS-style). Wired
// imperatively after each render.
function wireSwipe() {
app.querySelectorAll(".track-wrap .track").forEach((row) => {
const id = row.dataset.id;
let x0 = 0, y0 = 0, active = false, horizontal = false;
const base = () => (state.swipedTrackId === id ? -84 : 0);
row.addEventListener("pointerdown", (e) => {
x0 = e.clientX; y0 = e.clientY; active = true; horizontal = false;
row.style.transition = "none";
});
row.addEventListener("pointermove", (e) => {
if (!active) return;
const dx = e.clientX - x0;
const dy = e.clientY - y0;
if (!horizontal && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) horizontal = true;
if (horizontal) {
e.preventDefault();
const tx = Math.max(-84, Math.min(0, base() + dx));
row.style.transform = `translateX(${tx}px)`;
}
});
const end = (e) => {
if (!active) return;
active = false;
if (!horizontal) { row.style.transition = ""; row.style.transform = ""; return; }
suppressClick = true;
setTimeout(() => { suppressClick = false; }, 60);
const dx = e.clientX - x0;
const open = dx < -40 ? true : dx > 40 ? false : state.swipedTrackId === id;
// Animate to the snap position by toggling the class (no DOM rebuild, so
// it transitions smoothly from wherever the finger let go).
closeOtherSwipes(row.closest(".track-wrap"));
row.style.transition = "transform 0.22s cubic-bezier(0.22,1,0.36,1)";
row.closest(".track-wrap").classList.toggle("swiped", open);
row.style.transform = "";
state.swipedTrackId = open ? id : null;
};
row.addEventListener("pointerup", end);
row.addEventListener("pointercancel", end);
});
}
// Smoothly close any revealed row (optionally except `keep`), without a full
// re-render so the close animates.
function closeOtherSwipes(keep) {
app.querySelectorAll(".track-wrap.swiped").forEach((w) => {
if (w === keep) return;
const r = w.querySelector(".track");
if (r) { r.style.transition = "transform 0.22s cubic-bezier(0.22,1,0.36,1)"; r.style.transform = ""; }
w.classList.remove("swiped");
});
}
function closeSwipe() {
state.swipedTrackId = null;
closeOtherSwipes(null);
}
async function deleteTrack(id) {
state.swipedTrackId = null;
try {
await fetch(`/api/jobs/${id}`, { method: "DELETE" });
} catch (e) {
console.warn("[mobile] delete failed:", e);
}
state.tracks = state.tracks.filter((t) => t.id !== id);
if (!state.tracks.length) state.libState = "empty";
if (state.current?.id === id) {
if (engine) { engine.destroy(); engine = null; engineTrackId = null; engineReady = false; }
state.current = null;
state.playing = false;
}
toast("Track deleted");
render();
}
// Faders + waveform seek need live pointer drag, so they're wired imperatively
// after each render (the rest of the UI uses click delegation below).
function wireFaders() {
app.querySelectorAll("[data-fader]").forEach((el) => {
el.addEventListener("pointerdown", (e) => {
e.preventDefault();
const id = el.dataset.id;
const fill = el.querySelector(".fader-fill");
const knob = el.querySelector(".fader-knob");
const rect = el.getBoundingClientRect();
const set = (cx) => {
let v = (cx - rect.left) / rect.width;
v = Math.max(0, Math.min(1, v));
state.vols[id] = v;
const pct = Math.round(v * 100) + "%";
fill.style.width = pct;
knob.style.left = pct;
if (engine && laneActive(id)) engine.setGain(id, v);
};
set(e.clientX);
el.setPointerCapture(e.pointerId);
const mv = (ev) => set(ev.clientX);
const up = () => {
el.removeEventListener("pointermove", mv);
el.removeEventListener("pointerup", up);
};
el.addEventListener("pointermove", mv);
el.addEventListener("pointerup", up);
});
});
const speedSlider = app.querySelector("[data-speed]");
if (speedSlider) {
speedSlider.addEventListener("input", () => {
const rate = parseFloat(speedSlider.value);
state.speed = rate;
const valEl = speedSlider.parentElement?.querySelector(".speed-row-val");
if (valEl) valEl.textContent = `${rate % 1 === 0 ? rate.toFixed(1) : rate}x`;
if (engine) engine.setPlaybackRate(rate);
});
}
const bars = app.querySelector("[data-seek]");
if (bars) {
bars.addEventListener("pointerdown", (e) => {
if (!engine || !curDuration()) return;
e.preventDefault();
const rect = bars.getBoundingClientRect();
const head = bars.querySelector(".playhead");
const seek = (cx) => {
const frac = Math.max(0, Math.min(1, (cx - rect.left) / rect.width));
if (head) head.style.left = frac * 100 + "%";
const cur = app.querySelector(".wave-times .cur");
if (cur) cur.textContent = fmt(frac * curDuration());
state.progress = frac;
paintWaveProgress();
seekToFraction(frac);
};
seek(e.clientX);
bars.setPointerCapture(e.pointerId);
const mv = (ev) => seek(ev.clientX);
const up = () => {
bars.removeEventListener("pointermove", mv);
bars.removeEventListener("pointerup", up);
};
bars.addEventListener("pointermove", mv);
bars.addEventListener("pointerup", up);
});
}
}
app.addEventListener("click", (e) => {
if (suppressClick) { suppressClick = false; return; }
const t = e.target.closest("[data-action]");
if (!t) return;
ensureAudioCtx(); // any in-app tap unlocks audio on mobile
const a = t.dataset.action;
// A row is swiped open: any tap other than its Delete just closes it.
if (state.swipedTrackId && a !== "delete") {
closeSwipe();
return;
}
switch (a) {
case "delete":
deleteTrack(t.dataset.id);
return;
case "tab":
state.tab = t.dataset.tab;
break;
case "mixview":
state.mixerView = t.dataset.view;
break;
case "prev":
prevTrack();
return;
case "next":
nextTrack();
return;
case "play":
case "play-mini":
e.stopPropagation();
togglePlay();
return;
case "mute":
state.muted[t.dataset.id] = !state.muted[t.dataset.id];
applyMix();
break;
case "solo":
state.solo[t.dataset.id] = !state.solo[t.dataset.id];
applyMix();
break;
case "chip":
state.selected[t.dataset.id] = !state.selected[t.dataset.id];
break;
case "qual":
state.quality = t.dataset.q;
break;
case "filter":
state.filter = t.dataset.filter;
break;
case "export":
exportMix(t.dataset.kind || "wav");
return;
case "split":
startExtraction();
return;
case "pick-file":
document.getElementById("ext-file")?.click();
return;
case "open": {
const track = state.tracks.find((x) => x.id === t.dataset.id);
if (track) openTrack(track);
return;
}
case "reload":
loadLibrary();
return;
default:
return;
}
render();
});
// URL field is uncontrolled-but-tracked: store keystrokes in state without
// re-rendering (which would steal focus), so the value survives later renders.
app.addEventListener("input", (e) => {
if (e.target.id === "ext-url") state.extractUrl = e.target.value;
});
app.addEventListener("change", (e) => {
if (e.target.id === "ext-file") {
state.extractFile = e.target.files?.[0] || null;
render();
}
});
// Load the real library from /api/jobs (newest first). On success, seed the
// Mixer with the most recent track if nothing is selected yet.
async function loadLibrary() {
state.libState = "loading";
render();
try {
const jobs = await fetchJobs();
state.tracks = jobs.map(jobToCard).sort((a, b) => b.createdAt - a.createdAt);
state.libState = state.tracks.length ? "ready" : "empty";
} catch (e) {
console.warn("[mobile] failed to load library:", e);
state.libState = "error";
}
render();
}
render();
loadLibrary();
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1"
/>
<meta name="theme-color" content="#0b0b0d" />
<title>StemDeck</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/mobile/styles.css" />
</head>
<body>
<!-- The whole UI is rendered by app.js into #app. This is a static
visual shell on mock data (issue: mobile redesign). Screens and
interactions are real; data is not yet wired to the API. -->
<div id="app" class="m-app"></div>
<script type="module" src="/mobile/app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+15
View File
File diff suppressed because one or more lines are too long
+160
View File
@@ -0,0 +1,160 @@
'use strict';
// SoundTouch WSOLA time-stretcher - AudioWorkletProcessor
// Adapted from SoundTouch C++ by Olli Parviainen. MIT License.
// Self-contained; no imports required.
const SEQUENCE_MS = 82;
const SEEK_MS = 28;
const OVERLAP_MS = 12;
class FloatFifo {
constructor() {
this._d = new Float32Array(65536);
this._r = 0;
this._w = 0;
}
get avail() { return this._w - this._r; }
clear() { this._r = this._w = 0; }
peek(offset) { return this._d[this._r + offset]; }
consume(n) { this._r = Math.min(this._r + n, this._w); }
shift(dst, dstOff, n) {
n = Math.min(n, this.avail);
for (let i = 0; i < n; i++) dst[dstOff + i] = this._d[this._r++];
return n;
}
push(src, srcOff, n) {
this._ensureRoom(n);
for (let i = 0; i < n; i++) this._d[this._w++] = src[srcOff + i];
}
_compact() {
const av = this.avail;
this._d.copyWithin(0, this._r, this._w);
this._r = 0;
this._w = av;
}
_ensureRoom(n) {
if (this._r > 32768) this._compact();
if (this._w + n > this._d.length) {
this._compact();
if (this._w + n > this._d.length) {
const nd = new Float32Array(Math.max(this._d.length * 2, this._w + n + 4096));
nd.set(this._d.subarray(0, this._w));
this._d = nd;
}
}
}
}
function findBestOffset(ref, refLen, fifo, seekLen) {
let bestOff = 0, bestCorr = -Infinity;
for (let off = 0; off < seekLen; off++) {
let corr = 0;
for (let i = 0; i < refLen; i++) corr += ref[i] * fifo.peek(off + i);
if (corr > bestCorr) { bestCorr = corr; bestOff = off; }
}
return bestOff;
}
class SoundTouchProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{
name: 'tempo',
defaultValue: 1.0,
minValue: 0.25,
maxValue: 4.0,
automationRate: 'k-rate',
}];
}
constructor() {
super();
const sr = sampleRate;
this._ovLen = Math.round(OVERLAP_MS * sr / 1000);
this._seekLen = Math.round(SEEK_MS * sr / 1000);
this._seqLen = Math.round(SEQUENCE_MS * sr / 1000);
this._midLen = this._seqLen - 2 * this._ovLen;
this._inL = new FloatFifo();
this._inR = new FloatFifo();
this._outL = new FloatFifo();
this._outR = new FloatFifo();
// Overlap carry-over buffers (crossfade region saved from previous sequence)
this._carryL = new Float32Array(this._ovLen);
this._carryR = new Float32Array(this._ovLen);
// Pre-allocated temp output (seqLen - ovLen samples per sequence max)
const outPerSeq = this._ovLen + this._midLen;
this._tmpL = new Float32Array(outPerSeq);
this._tmpR = new Float32Array(outPerSeq);
}
process(inputs, outputs, parameters) {
const inp = inputs[0];
const outp = outputs[0];
const frames = 128;
const tempo = parameters.tempo[0];
const inL = inp?.[0] || new Float32Array(frames);
const inR = inp?.[1] || inL;
const outL = outp[0];
const outR = outp[1] || outL;
// Passthrough at 1.0 - zero DSP cost
if (Math.abs(tempo - 1.0) < 0.001) {
outL.set(inL);
if (outR !== outL) outR.set(inR);
return true;
}
this._inL.push(inL, 0, frames);
this._inR.push(inR, 0, frames);
const needed = this._ovLen + this._seekLen + this._seqLen;
while (this._inL.avail >= needed) this._processSeq(tempo);
const got = this._outL.shift(outL, 0, frames);
this._outR.shift(outR, 0, frames);
for (let i = got; i < frames; i++) { outL[i] = 0; if (outR !== outL) outR[i] = 0; }
return true;
}
_processSeq(tempo) {
const ovLen = this._ovLen;
const midLen = this._midLen;
const seqLen = this._seqLen;
const outLen = ovLen + midLen;
const bestOff = findBestOffset(this._carryL, ovLen, this._inL, this._seekLen);
// Crossfade carry-over with the new sequence start
for (let i = 0; i < ovLen; i++) {
const w = i / ovLen;
this._tmpL[i] = this._carryL[i] * (1 - w) + this._inL.peek(bestOff + i) * w;
this._tmpR[i] = this._carryR[i] * (1 - w) + this._inR.peek(bestOff + i) * w;
}
// Copy middle section verbatim
for (let i = 0; i < midLen; i++) {
this._tmpL[ovLen + i] = this._inL.peek(bestOff + ovLen + i);
this._tmpR[ovLen + i] = this._inR.peek(bestOff + ovLen + i);
}
// Save last ovLen samples as new carry-over for next crossfade
for (let i = 0; i < ovLen; i++) {
this._carryL[i] = this._inL.peek(bestOff + ovLen + midLen + i);
this._carryR[i] = this._inR.peek(bestOff + ovLen + midLen + i);
}
this._outL.push(this._tmpL, 0, outLen);
this._outR.push(this._tmpR, 0, outLen);
// Advance input: tempo controls how many input samples map to one output sequence
const advance = Math.round((seqLen - ovLen) * tempo) + bestOff;
this._inL.consume(advance);
this._inR.consume(advance);
}
}
registerProcessor('soundtouch-processor', SoundTouchProcessor);