Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ StemDeck is free and **does not accept any money, sponsorship, or funding** - no
| Lisbon Guitar Works | Guitar building | [dlimaguitars.com](https://dlimaguitars.com) |
| Joao Gaspar | Producer/Film Scorer, Touring/Session Musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) |
| Kris Luthier | Luthier and Musical Instrument Repair, Lisboa | [@krisluthier](https://www.instagram.com/krisluthier) |
| Thomann | Musical instruments & music gear | [@thomann.music](https://www.instagram.com/thomann.music) |
| Analog4Lyfe | Analog music gear | [@analog4lyfe](https://www.instagram.com/analog4lyfe) |


---
Expand Down Expand Up @@ -309,7 +311,7 @@ Stems land in `./jobs/` on the host. Demucs weights are cached in a named volume
| PATCH | `/api/jobs/{id}/sections` | Save waveform section markers for a job |
| GET | `/api/jobs/{id}/stems/{name}.wav` | Stream a single stem WAV file |
| GET | `/api/jobs/{id}/stems/{name}.mp3` | Transcode and stream a stem as MP3 |
| GET | `/api/jobs/{id}/video.mp4` | Mux the current mix with the source video (MP4 upload or YouTube) into a karaoke MP4 |
| GET | `/api/jobs/{id}/video.mp4` | Mux the current mix with the source video (MP4 upload or YouTube) into an MP4 |
| DELETE | `/api/jobs/{id}` | Remove job dir from disk (terminal jobs only) |

---
Expand Down
6 changes: 3 additions & 3 deletions app/api/stems.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def _validate_stem_path(job_id: str, name: str):

def _parse_lane_gains(stems: str, gains: str) -> tuple[list[str], list[float]]:
"""Parse and validate parallel comma-separated lane names and linear gains.
Shared by the audio mixdown and the karaoke-video mux. Raises HTTPException
Shared by the audio mixdown and the MP4 video mux. Raises HTTPException
on malformed input, unknown lanes, or out-of-range gains."""
names = [s for s in stems.split(",") if s]
raw_gains = [g for g in gains.split(",") if g]
Expand Down Expand Up @@ -270,7 +270,7 @@ async def get_video_mixdown(
gains: str = Query(..., description="Comma-separated linear gains, parallel to stems"),
) -> StreamingResponse:
"""Mux a fresh audio mixdown of the current mixer state with the job's preserved
video into a karaoke MP4 (issue #219). Mirrors get_mixdown's audio graph (encoded
video into an MP4 (issue #219). Mirrors get_mixdown's audio graph (encoded
as AAC) and stream-copies video.mp4 -- the silent video kept from an .mp4 upload
or the real video stream downloaded for a YouTube job. 404 when the job has no
video (SoundCloud / plain audio uploads).
Expand Down Expand Up @@ -328,7 +328,7 @@ async def get_video_mixdown(
"pipe:1",
]

filename = f"{_safe_title(job.title)}_karaoke.mp4"
filename = f"{_safe_title(job.title)}_video.mp4"
return StreamingResponse(
_stream_ffmpeg(cmd),
media_type="video/mp4",
Expand Down
2 changes: 1 addition & 1 deletion app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def _detect_device() -> str:
TIMEOUT_FFMPEG = _env_int("STEMDECK_TIMEOUT_FFMPEG", 300)
TIMEOUT_ANALYZE = _env_int("STEMDECK_TIMEOUT_ANALYZE", 120)
TIMEOUT_DEMUCS_STALL = _env_int("STEMDECK_TIMEOUT_DEMUCS_STALL", 1800)
# Max height for the karaoke-MP4 video stream pulled from YouTube (issue #219).
# Max height for the MP4 video stream pulled from YouTube (issue #219).
# Capped to keep downloads reasonable; 1080p of a full song is large.
VIDEO_MAX_HEIGHT = max(144, _env_int("STEMDECK_VIDEO_MAX_HEIGHT", 720))

Expand Down
2 changes: 1 addition & 1 deletion app/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class Job:
mix_url: str | None = None # populated when a strict subset was selected
source_url: str | None = None # original URL or "local:<filename>" for file uploads
# True when a silent video track (video.mp4) was preserved from an .mp4
# upload, enabling the "Export Mix (with video)" karaoke export.
# upload, enabling the "Export Mix (with video)" MP4 export.
has_video: bool = False
error: str | None = None
# Set by POST /api/jobs/{id}/cancel; consumed by pipeline stages.
Expand Down
8 changes: 4 additions & 4 deletions app/pipeline/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def normalize_youtube_url(url: str) -> str:

def _download_video_track(job: Job, url: str, job_dir: Path) -> None:
"""Best-effort: download a video-only H.264/MP4 stream to video.mp4 for the
karaoke-MP4 export (issue #219). The audio source is downloaded separately as
MP4 export (issue #219). The audio source is downloaded separately as
usual; this is a second, additive fetch so the audio pipeline is untouched.

Video-only MP4 needs no ffmpeg merge, so this can't break an audio-only job:
Expand All @@ -159,7 +159,7 @@ def _download_video_track(job: Job, url: str, job_dir: Path) -> None:
JobCancelled, which the runner treats like any other cancellation.

Capped at VIDEO_MAX_HEIGHT to keep downloads reasonable -- a full song at
1080p is large, and karaoke playback doesn't need it."""
1080p is large, and the MP4 export doesn't need it."""

def vhook(d: dict) -> None:
if job.cancel_requested:
Expand Down Expand Up @@ -240,7 +240,7 @@ def hook(d: dict) -> None:
_set(job, progress=1.0, stage="Download complete")

# YouTube jobs additionally fetch the real video stream (below) for the
# karaoke-MP4 export (issue #219). SoundCloud is audio-only and excluded.
# MP4 export (issue #219). SoundCloud is audio-only and excluded.
is_youtube = url.startswith("https://www.youtube.com/")

# No postprocessors -- Demucs reads the raw audio container (webm/m4a/opus/...)
Expand Down Expand Up @@ -295,7 +295,7 @@ def hook(d: dict) -> None:
deduped = [t for t in raw_tags if not (t in seen or seen.add(t))] # type: ignore[func-returns-value]
_set(job, tags=deduped[:8] or None)

# Best-effort: fetch the real video stream for the karaoke-MP4 export.
# Best-effort: fetch the real video stream for the MP4 export.
# Non-fatal -- on any failure the job proceeds audio-only.
if is_youtube:
_download_video_track(job, url, job_dir)
Expand Down
4 changes: 2 additions & 2 deletions app/pipeline/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def _check_cancel(job: Job) -> None:
def _extract_video_track(job: Job, source: Path, job_dir: Path) -> None:
"""For an .mp4 upload, preserve a silent video-only track at
video.mp4 so the studio can later mux it with a custom stem mix
into a karaoke video (issue #219). Stream-copies the video (no
into an MP4 (issue #219). Stream-copies the video (no
re-encode) -- fast and lossless.

Best-effort: an .mp4 with no video stream (audio-only container)
Expand Down Expand Up @@ -83,7 +83,7 @@ def _prepare_local_source(job: Job, source: Path, job_dir: Path) -> Path:
would otherwise process silently and output as silence.

For .mp4 uploads, first preserves a silent video.mp4 for later
karaoke-video export. Deletes the original source file after a
MP4 export. Deletes the original source file after a
successful transcode."""
from app.core.config import ffmpeg_executable

Expand Down
20 changes: 20 additions & 0 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,10 @@ input, textarea { font-family: inherit; }
.rail-btn.active { color: var(--accent); background: var(--panel); }
.rail-btn svg { flex-shrink: 0; }
.rail-btn span { white-space: nowrap; }
/* "We Recommend" is wider than the other one-word rail labels, so it stacks onto
two centered lines; let the button grow so the second line + icon aren't clipped. */
.rail-btn.rail-recommend { height: auto; min-height: 40px; padding: 3px 0; }
.rail-btn.rail-recommend span { white-space: normal; text-align: center; line-height: 1.1; }

/* Sidebar body */
.sidebar-body {
Expand Down Expand Up @@ -2218,6 +2222,22 @@ input, textarea { font-family: inherit; }
object-fit: cover;
border: 1px solid var(--border);
}
/* Monogram fallback when a tile has no image (or it fails to load): matches the
round avatar size, in the accent colour, so the grid stays on-brand. */
.lib-friend-monogram {
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-sans);
font-size: 20px;
font-weight: 600;
color: var(--accent);
background: var(--panel-3);
border: 1px solid var(--border);
}
/* Small Instagram glyph under the text on tiles that link to Instagram */
.lib-friend-ig {
/* block + no-shrink avoids the WebKit baseline clip on small inline SVGs */
Expand Down
12 changes: 5 additions & 7 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,11 @@
</svg>
<span>Settings</span>
</button>
<button class="rail-btn" id="friendsBtn" type="button" title="Supporters" aria-label="Supporters" aria-haspopup="dialog">
<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">
<rect x="2" y="7" width="20" height="13" rx="2"/>
<path d="m17 2-5 5-5-5"/>
<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>Supporters</span>
<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">
Expand Down Expand Up @@ -713,11 +712,10 @@ <h2 id="aboutTitle">StemDeck</h2>
</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">
<rect x="2" y="7" width="20" height="13" rx="2"/>
<path d="m17 2-5 5-5-5"/>
<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">Supporters</h2>
<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>
Expand Down
26 changes: 26 additions & 0 deletions static/js/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ const FRIENDS = [
logo: "/img/friends/kris-luthier.jpg",
avatar: true,
},
{
name: "Thomann",
role: "Musical instruments & music gear",
url: "https://www.instagram.com/thomann.music",
logo: "/img/friends/thomann.jpg",
avatar: true,
},
{
name: "Analog4Lyfe",
role: "Analog music gear",
url: "https://www.instagram.com/analog4lyfe",
logo: "/img/friends/analog4lyfe.jpg",
avatar: true,
},
];

// Instagram glyph (Simple Icons), shown under tiles that link to Instagram.
Expand Down Expand Up @@ -1632,13 +1646,25 @@ function wireSupportersDialog() {
a.rel = "noopener noreferrer";
a.title = f.name;
a.style.setProperty("--tilt", tilts[i % tilts.length]);
// A monogram avatar (first initial) keeps the tile on-brand when an entry
// has no image, or its image fails to load (e.g. before the asset is added).
const makeMonogram = () => {
const m = document.createElement("span");
m.className = "lib-friend-monogram";
m.textContent = (f.name || "?").trim().charAt(0).toUpperCase();
m.setAttribute("aria-hidden", "true");
return m;
};
if (f.logo) {
const img = document.createElement("img");
img.className = f.avatar ? "lib-friend-avatar" : "lib-friend-logo";
img.src = f.logo;
img.alt = f.name;
img.loading = "lazy";
img.addEventListener("error", () => img.replaceWith(makeMonogram()));
a.appendChild(img);
} else {
a.appendChild(makeMonogram());
}
const name = document.createElement("span");
name.className = "lib-friend-name";
Expand Down
6 changes: 3 additions & 3 deletions static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,12 +176,12 @@ function wireFooterControls() {

// 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" relabelled for karaoke.
// leaving just "Export Mix".
function applyFormatState() {
const video = format === "mp4";
exportPanel?.classList.toggle("fmt-mp4", video);
if (mixDescEl) {
mixDescEl.textContent = video ? "Export mix with video for karaoke" : "Export the mixed audio";
mixDescEl.textContent = video ? "Export mix with the original video" : "Export the mixed audio";
}
if (!video) updateLoopRegionVisual(); // restores the region item's disabled state
}
Expand Down Expand Up @@ -211,7 +211,7 @@ function wireFooterControls() {
panelOpen() ? closePanel() : openPanel();
});

// Export Mix: MP4 produces the karaoke video; any other format an audio mix.
// Export Mix: MP4 produces the video; any other format an audio mix.
itemMix?.addEventListener("click", (e) => {
e.stopPropagation();
if (busy) return;
Expand Down
4 changes: 2 additions & 2 deletions static/js/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -1335,7 +1335,7 @@ export function downloadCurrentMix(ext = "wav") {
return true;
}

// Karaoke video: the preserved source video muxed with the current audio mix.
// MP4 export: the preserved source video muxed with the current audio mix.
// Only meaningful for mp4-sourced jobs (currentJobHasVideo()); returns false when
// there's no video track or every lane is muted.
export function downloadCurrentVideo() {
Expand All @@ -1351,7 +1351,7 @@ export function downloadCurrentVideo() {
.replace(/_{2,}/g, "_")
.slice(0, 80)
.replace(/^_+|_+$/g, "");
const name = safe ? `${safe}_karaoke.mp4` : "karaoke.mp4";
const name = safe ? `${safe}_video.mp4` : "video.mp4";
_triggerDownload(`/api/jobs/${currentJobId}/video.mp4?${q}`, name);
return true;
}
Expand Down
2 changes: 1 addition & 1 deletion tests/test_stems_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ def test_all_stems_zip_flac(client, tmp_path):
assert zf.read("vocals.flac")[:4] == b"fLaC"


# --- karaoke video mux endpoint (#219) ---
# --- MP4 video mux endpoint (#219) ---


def _make_video_file(tmp_path, job_id: str) -> None:
Expand Down