diff --git a/app/api/jobs.py b/app/api/jobs.py index 1c52847d..62a8c0ee 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -63,6 +63,7 @@ get_discogs_token, get_max_duration_sec, ) +from app.core.stem_files import stem_path, stem_url from app.core.stems_location import is_relocating from app.pipeline import discogs, jobqueue from app.pipeline.analyze import dynamic_range_db, measure_loudness @@ -103,6 +104,7 @@ ) from app.pipeline.lyrics_retime import saved_view as saved_retime_view from app.pipeline.runner import _pipeline_lock +from app.pipeline.stem_storage import store_split_stems from app.pipeline.vocal_split import split_vocals from app.pipeline.work_lookup import find_work, might_have_work @@ -501,7 +503,7 @@ def _loudness_sources(job_id: str) -> list[Path]: stems_dir = (JOBS_DIR / job_id / "stems").resolve() if not stems_dir.is_relative_to(JOBS_DIR.resolve()): return [] - return [p for p in (stems_dir / f"{n}.wav" for n in STEM_NAMES) if p.is_file()] + return [p for p in (stem_path(stems_dir, n) for n in STEM_NAMES) if p.is_file()] def _upgrade_loudness(job: Job) -> None: @@ -805,6 +807,12 @@ async def start_vocal_split(job_id: str) -> Response: try: async with _pipeline_lock: new_names = await asyncio.to_thread(split_vocals, job, stems_dir) + # The peaks are read from the new WAVs first; then the two are kept + # the way the song keeps its stems (#783). Neither raises. + rms_values = await asyncio.to_thread( + merge_stem_peaks, stems_dir, ["vocals", *new_names] + ) + await asyncio.to_thread(store_split_stems, job, stems_dir, new_names) except Exception as e: cause = classify_failure("\n".join([*(getattr(e, "tail", None) or []), str(e)])) logger.warning("[%s] vocal split failed: %s", job_id, e, exc_info=True) @@ -817,11 +825,10 @@ async def start_vocal_split(job_id: str) -> Response: existing = {s["name"] for s in job.stems} for name in new_names: if name not in existing: - job.stems.append({"name": name, "url": f"/api/jobs/{job_id}/stems/{name}.wav"}) - # "vocals" rides along so presence_for_split can recover the scale the base - # stems were normalised against; without it the two new cards would have no - # percentage to show. - rms_values = merge_stem_peaks(stems_dir, ["vocals", *new_names]) + job.stems.append({"name": name, "url": stem_url(job_id, stems_dir, name)}) + # "vocals" rode along in merge_stem_peaks above so presence_for_split can + # recover the scale the base stems were normalised against; without it the + # two new cards would have no percentage to show. extra_presence = presence_for_split(rms_values, job.stem_presence) if extra_presence: job.stem_presence = {**(job.stem_presence or {}), **extra_presence} @@ -1195,7 +1202,7 @@ def _fingerprint_audio(job: Job) -> list[Path]: stems_root = (JOBS_DIR / job.id / "stems").resolve() stems = [] for name in STEM_NAMES: - path = (stems_root / f"{name}.wav").resolve() + path = stem_path(stems_root, name).resolve() if path.is_file() and path.is_relative_to(JOBS_DIR.resolve()): stems.append(path) return stems @@ -1560,7 +1567,7 @@ def _retime_ready(job_dir: Path, lyrics: dict | None) -> None: raise HTTPException(status_code=404, detail="no lyrics") if not lyric_pairs(lyrics): raise HTTPException(status_code=409, detail="lyrics have no lines") - vocals = (job_dir / "stems" / "vocals.wav").resolve() + vocals = stem_path(job_dir / "stems", "vocals").resolve() if not vocals.is_relative_to(JOBS_DIR.resolve()) or not vocals.is_file(): raise HTTPException(status_code=409, detail="no vocals stem") diff --git a/app/api/stems.py b/app/api/stems.py index 95f796e6..0c5113f4 100644 --- a/app/api/stems.py +++ b/app/api/stems.py @@ -35,6 +35,7 @@ ) from app.core.registry import get as registry_get from app.core.settings import get_export_sample_rate +from app.core.stem_files import stem_ext, stem_media_type, stem_path from app.core.takes import take_dir from app.pipeline.audio_stats import vocal_envelope from app.pipeline.click_render import cache_key as click_cache_key @@ -349,7 +350,8 @@ def _validate_stem_path(job_id: str, name: str): job = registry_get(job_id) if job is None or job.status != "done": raise HTTPException(status_code=404, detail="job not ready") - path = (JOBS_DIR / job_id / "stems" / f"{name}.wav").resolve() + # WAV or MP3, whichever this song keeps (#783). + path = stem_path(JOBS_DIR / job_id / "stems", name).resolve() if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()): raise HTTPException(status_code=404, detail="stem not found") return path @@ -1044,7 +1046,7 @@ def _expand_render_lanes( # pointing at whichever component was tried last. paths: list[Path] = [] for comp in components: - comp_path = (JOBS_DIR / job_id / "stems" / f"{comp}.wav").resolve() + comp_path = stem_path(JOBS_DIR / job_id / "stems", comp).resolve() if not comp_path.is_file() or not comp_path.is_relative_to(JOBS_DIR.resolve()): paths = [] break @@ -1071,7 +1073,7 @@ def _job_original_set(job_id: str) -> frozenset[str] | None: """The stems the job's own original.wav holds (make_original_track: every base stem not selected), or None when it has no such file.""" job = registry_get(job_id) - path = (JOBS_DIR / job_id / "stems" / "original.wav").resolve() + path = stem_path(JOBS_DIR / job_id / "stems", "original").resolve() if job is None or not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()): return None selected = set(job.selected_stems) @@ -1128,7 +1130,7 @@ def _expand_mode_lanes( continue paths: list[Path] = [] for comp in components: - comp_path = (JOBS_DIR / job_id / "stems" / f"{comp}.wav").resolve() + comp_path = stem_path(JOBS_DIR / job_id / "stems", comp).resolve() if not comp_path.is_file() or not comp_path.is_relative_to(JOBS_DIR.resolve()): paths = [] break @@ -1498,7 +1500,7 @@ async def get_vocal_envelope(job_id: str) -> Response: if job is None or job.status != "done": raise HTTPException(status_code=404, detail="job not ready") stems_dir = (JOBS_DIR / job_id / "stems").resolve() - src = stems_dir / "vocals.wav" + src = stem_path(stems_dir, "vocals") dest = stems_dir / _VOCAL_ENVELOPE_FILE jobs_root = JOBS_DIR.resolve() if not src.is_file() or not src.is_relative_to(jobs_root) or not dest.is_relative_to(jobs_root): @@ -1557,12 +1559,16 @@ async def get_stem( click_accent: int = Query(default=-1, ge=-1, le=32, description="-1 auto, 0 off, N per bar"), click_gain: float = Query(default=0.6, ge=0, le=4, description="Click level"), ) -> FileResponse | StreamingResponse: - """Download a WAV stem. Optional ?start=&end= trims to a time region.""" + """A stem, as the song stores it: WAV, or MP3 for a song stored as MP3 + (#783), with its own type and file name whatever this URL says. Optional + ?start=&end= trims to a time region, always rendered as WAV.""" path = _validate_stem_path(job_id, name) if start is None and end is None: return FileResponse( - path, media_type="audio/wav", filename=_stem_download_name(job_id, name, "wav") + path, + media_type=stem_media_type(path), + filename=_stem_download_name(job_id, name, stem_ext(path)), ) if start is None or end is None or start >= end: @@ -2211,17 +2217,22 @@ def _arcname(prefix: str, name: str, ext: str) -> str: def _build_stems_zip( sources: list[tuple[str, Path]], fmt: str, dest: Path, prefix: str = "" ) -> None: - """Blocking: write the stems into a ZIP. WAV files are stored as-is; MP3, - FLAC, and OGG are transcoded per stem via ffmpeg. ZIP_STORED throughout - audio doesn't - meaningfully compress, and STORED keeps the build fast. Runs in a thread.""" - if fmt == "wav": + """Blocking: write the stems into a ZIP. A file already in `fmt` is stored + as-is (a WAV in a WAV zip, and an MP3-stored stem in an MP3 zip, which is + not encoded a second time); anything else is transcoded per stem via + ffmpeg. ZIP_STORED throughout - audio doesn't meaningfully compress, and + STORED keeps the build fast. Runs in a thread.""" + if all(p.suffix.lower() == f".{fmt}" for _name, p in sources): with zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf: for name, p in sources: - zf.write(p, arcname=_arcname(prefix, name, "wav")) + zf.write(p, arcname=_arcname(prefix, name, fmt)) return encode = _ENCODE_ARGS[fmt] with tempfile.TemporaryDirectory() as td, zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf: for name, p in sources: + if p.suffix.lower() == f".{fmt}": + zf.write(p, arcname=_arcname(prefix, name, fmt)) + continue out = os.path.join(td, f"{name}.{fmt}") cmd = [ ffmpeg_executable(), @@ -2292,7 +2303,7 @@ async def get_all_stems_zip( sources: list[tuple[str, Path]] = [] for name in wanted: - p = (stems_dir / f"{name}.wav").resolve() + p = stem_path(stems_dir, name).resolve() if p.is_file() and p.is_relative_to(jobs_root): sources.append((name, p)) labels = {t.get("id"): t.get("label") for t in job.takes} diff --git a/app/api/takes.py b/app/api/takes.py index b0e98dd2..277ec2a7 100644 --- a/app/api/takes.py +++ b/app/api/takes.py @@ -91,6 +91,7 @@ from app.core.registry import get as registry_get from app.core.registry import persist as registry_persist from app.core.settings import get_export_sample_rate +from app.core.stem_files import stem_path from app.core.takes import ( camera_file, make_track, @@ -268,7 +269,9 @@ def _reference_stem(job_id: str) -> Path | None: if not stems_dir.is_relative_to(JOBS_DIR.resolve()): return None for name in (*STEM_NAMES, *EXTRA_STEM_NAMES, "original"): - path = stems_dir / f"{name}.wav" + # WAV or MP3 (#783): an MP3 stem decodes to the WAV's exact frame + # count, which stem_storage verified before keeping it. + path = stem_path(stems_dir, name) if path.is_file(): return path return None diff --git a/app/core/config.py b/app/core/config.py index b41ca515..5041cb79 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -403,6 +403,19 @@ def js_solver_available() -> bool: # A backing track without one stem (app/pipeline/backing.py): a sum of five # WAVs, seconds for a song; the ceiling only guards a stuck disk. TIMEOUT_BACKING = _env_int("STEMDECK_TIMEOUT_BACKING", 120) + +# How a finished song's stems are kept on disk (#783): "wav" (16-bit PCM, as +# separated) or "mp3" (CBR libmp3lame at one of STEM_MP3_KBPS_CHOICES). The +# default for an install with no saved choice and no env var. One line to +# change. 128 kbps is the maintainer's choice: stems summed again in the +# mixer carry the coding noise of each one. +DEFAULT_STEM_STORAGE: tuple[str, int] = ("mp3", 128) +STEM_FORMAT_CHOICES: tuple[str, ...] = ("wav", "mp3") +STEM_MP3_KBPS_CHOICES: tuple[int, ...] = (128, 192, 256) +# The MP3 step at the end of the pipeline (app/pipeline/stem_storage.py): one +# ffmpeg per stem, a few seconds each. Terminated when ffmpeg is silent this +# long; the song then keeps its WAVs. +TIMEOUT_STEM_TRANSCODE_STALL = _env_int("STEMDECK_TIMEOUT_STEM_TRANSCODE_STALL", 120) # Video render of a take (libx264 + AAC). Slower than any audio export: a # 5-minute 1080p30 encode runs about 1x to 3x realtime on a desktop CPU. TIMEOUT_VIDEO_RENDER = _env_int("STEMDECK_TIMEOUT_VIDEO_RENDER", 3600) diff --git a/app/core/models.py b/app/core/models.py index 7373ce7b..678ba6ea 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -338,6 +338,13 @@ class Job: # Wall-clock seconds per pipeline stage ({"download": 12.3, ...}); written # to metadata.json and the one-line completion summary in the log. stage_timings: dict[str, float] | None = None + # How this song's stems are kept on disk (#783): "wav" or "mp3", set when + # the job finishes from the Stem storage format setting of that moment, + # with the MP3 bitrate (kbps) or None. None for a song finished before the + # setting existed, whose stems are WAV. Readers do not branch on it: they + # resolve each file through app.core.stem_files, which looks at the disk. + stem_format: str | None = None + stem_mp3_kbps: int | None = None # On-demand lead/backing vocal split (#275) -- a post-hoc action on an # already-"done" job, not part of the main pipeline. "none" until the user # asks for it; "error" leaves the job's base stems untouched (see @@ -431,6 +438,8 @@ def to_state(self) -> dict[str, Any]: "compute_device": self.compute_device, "gpu_fallback": self.gpu_fallback, "stage_timings": self.stage_timings, + "stem_format": self.stem_format, + "stem_mp3_kbps": self.stem_mp3_kbps, "vocal_split": self.vocal_split, "trashed_at": self.trashed_at, "favorite": self.favorite, diff --git a/app/core/registry.py b/app/core/registry.py index 3bc7612e..c706a0cb 100644 --- a/app/core/registry.py +++ b/app/core/registry.py @@ -14,6 +14,7 @@ from app.core.config import DEMUCS_MODEL, EXTRA_STEM_NAMES, JOB_ID_RE, LYRICS_FILE, STEM_NAMES from app.core.models import Job, clean_artist, clean_audio_tags, clean_identity, clean_work from app.core.settings import quarantine_corrupt +from app.core.stem_files import stem_exists, stem_url from app.core.takes import reconcile_tracks, recover_takes logger = logging.getLogger("stemdeck.registry") @@ -566,22 +567,23 @@ def _recover_done_job(job_dir: Path) -> Job | None: stems_dir = job_dir / "stems" if not stems_dir.is_dir(): return None + # WAV or MP3, whichever the song was stored as (#783). stems = [ - {"name": name, "url": f"/api/jobs/{job_dir.name}/stems/{name}.wav"} + {"name": name, "url": stem_url(job_dir.name, stems_dir, name)} for name in ("original", *STEM_NAMES, *EXTRA_STEM_NAMES) - if (stems_dir / f"{name}.wav").is_file() + if stem_exists(stems_dir, name) ] if not stems: return None mix_url = None - if (stems_dir / "mix.wav").is_file(): - mix_url = f"/api/jobs/{job_dir.name}/stems/mix.wav" + if stem_exists(stems_dir, "mix"): + mix_url = stem_url(job_dir.name, stems_dir, "mix") selected = [stem["name"] for stem in stems if stem["name"] in STEM_NAMES] or list(STEM_NAMES) # A restart between the split finishing and its next registry persist # would otherwise report the job as never split -- derive from disk the # same way `stems` above does, so the recovered library entry doesn't # regress (#275). - has_split = all((stems_dir / f"{name}.wav").is_file() for name in EXTRA_STEM_NAMES) + has_split = all(stem_exists(stems_dir, name) for name in EXTRA_STEM_NAMES) vocal_split = "done" if has_split else "none" meta_path = job_dir / "metadata.json" meta: dict = {} @@ -633,6 +635,10 @@ def _recover_done_job(job_dir: Path) -> Job | None: # The file is the record: metadata.json predates it for most jobs. has_lyrics=(job_dir / LYRICS_FILE).is_file(), vocal_split=vocal_split, + stem_format=meta.get("stem_format") if meta.get("stem_format") in ("wav", "mp3") else None, + stem_mp3_kbps=meta.get("stem_mp3_kbps") + if isinstance(meta.get("stem_mp3_kbps"), int) + else None, # Takes are files first: their folders survive whatever the registry # lost, so the lanes come back with the job. takes=recover_takes(job_dir), diff --git a/app/core/settings.py b/app/core/settings.py index f18542c1..1014569d 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -18,6 +18,8 @@ - `discogs_token` - the user's Discogs token, for band profiles. - `transcribe_lyrics` - Whisper lyrics when none are found: auto | on | off. - `video_encoder` - H.264 encoder of the take render: auto | cpu | an encoder. +- `stem_format` - how new songs' stems are stored: wav | mp3 (#783). +- `stem_mp3_kbps` - the MP3 bitrate when stem_format is mp3: 128 | 192 | 256. Defaults fall back to the config.py constants (which honor their env vars), so nothing changes until the user overrides a value. @@ -36,8 +38,11 @@ from app.core.config import ( DATA_DIR, + DEFAULT_STEM_STORAGE, MAX_DURATION_SEC, PLAYLIST_MAX_ITEMS, + STEM_FORMAT_CHOICES, + STEM_MP3_KBPS_CHOICES, VIDEO_ENCODER_CHOICES, VIDEO_MAX_HEIGHT, available_torch_devices, @@ -635,6 +640,73 @@ def set_separation_quality(value: str) -> str: return choice +# ── stem_format / stem_mp3_kbps ── +# How a song's stems are stored once it finishes (#783): "wav" or "mp3" at a +# CBR bitrate. Read when a job finishes (app/pipeline/stem_storage.py), so a +# change applies to the next song without a restart; songs already in the +# library keep whatever they were stored as. STEMDECK_STEM_FORMAT and +# STEMDECK_STEM_MP3_KBPS seed the defaults for Docker and Unraid; a choice +# saved in Settings wins over them, as for separation_quality. +def _default_stem_format() -> str: + env = os.environ.get("STEMDECK_STEM_FORMAT", "").strip().lower() + return env if env in STEM_FORMAT_CHOICES else DEFAULT_STEM_STORAGE[0] + + +def _default_stem_mp3_kbps() -> int: + raw = os.environ.get("STEMDECK_STEM_MP3_KBPS", "").strip().lower().removesuffix("k") + try: + kbps = int(raw) + except ValueError: + return DEFAULT_STEM_STORAGE[1] + return kbps if kbps in STEM_MP3_KBPS_CHOICES else DEFAULT_STEM_STORAGE[1] + + +def get_stem_format() -> str: + with _LOCK: + v = _ensure().get("stem_format") + return v if isinstance(v, str) and v in STEM_FORMAT_CHOICES else _default_stem_format() + + +def get_stem_mp3_kbps() -> int: + with _LOCK: + v = _ensure().get("stem_mp3_kbps") + ok = isinstance(v, int) and not isinstance(v, bool) and v in STEM_MP3_KBPS_CHOICES + return v if ok else _default_stem_mp3_kbps() + + +def check_stem_format(value: object) -> str: + """The whitelisted format for ``value``, or ValueError (never echoing it).""" + choice = value.strip().lower() if isinstance(value, str) else "" + if choice not in STEM_FORMAT_CHOICES: + raise ValueError("stem_format must be one of: " + ", ".join(STEM_FORMAT_CHOICES)) + return STEM_FORMAT_CHOICES[STEM_FORMAT_CHOICES.index(choice)] + + +def check_stem_mp3_kbps(value: object) -> int: + """The whitelisted bitrate for ``value``, or ValueError (never echoing it).""" + kbps = value if isinstance(value, int) and not isinstance(value, bool) else None + if kbps not in STEM_MP3_KBPS_CHOICES: + raise ValueError( + "stem_mp3_kbps must be one of: " + ", ".join(map(str, STEM_MP3_KBPS_CHOICES)) + ) + return kbps + + +def set_stem_storage(fmt: object = None, kbps: object = None) -> tuple[str, int]: + """Persist either or both, after both are validated: a request with one + bad value changes nothing. Returns what now applies.""" + choice = check_stem_format(fmt) if fmt is not None else None + rate = check_stem_mp3_kbps(kbps) if kbps is not None else None + with _LOCK: + if choice is not None: + _ensure()["stem_format"] = choice + if rate is not None: + _ensure()["stem_mp3_kbps"] = rate + if choice is not None or rate is not None: + _save() + return get_stem_format(), get_stem_mp3_kbps() + + # ── video_encoder ── # The H.264 encoder of the take render (app/pipeline/video_encoder.py): "auto" # (default) uses a GPU encoder that passed a real test encode on this machine, diff --git a/app/core/stem_files.py b/app/core/stem_files.py new file mode 100644 index 00000000..0b0b72e9 --- /dev/null +++ b/app/core/stem_files.py @@ -0,0 +1,134 @@ +"""Where a stem's audio file is, whatever format the song keeps it in (#783). + +A library holds songs stored as WAV (every song before #783, and any song made +with the WAV setting) and songs stored as MP3. Every reader that opens a stem +of a finished song goes through `stem_path`, so none of them needs to know +which kind of song it has. + +The rule is the file on disk, WAV first: + +- A WAV song has `.wav`. It may also have `.mp3`, but that is the + download cache GET /stems/{name}.mp3 writes beside the WAV (a VBR transcode), + never the stem itself, so the WAV always wins. +- An MP3 song has only `.mp3`: its WAVs are deleted once the MP3s are + verified (app/pipeline/stem_storage.py). +- While that step runs, and if it fails, the WAVs are still there and win. + +So the answer is right in every state a song's folder can be in, without +reading the job record. `job.stem_format` records what the song was stored as, +for the page and for diagnostics. + +MP3 stems decode to exactly the WAV's samples in length and position: the LAME +header ffmpeg writes carries the encoder delay and padding, and ffmpeg, +libsndfile and Chromium's decodeAudioData all honour it (measured, see +tests/test_stem_storage.py). The storage step verifies the length with both +server-side decoders before it deletes anything. +""" + +from __future__ import annotations + +import contextlib +import shutil +import subprocess +import tempfile +from collections.abc import Iterator +from pathlib import Path + +from app.core.config import TIMEOUT_FFMPEG, ffmpeg_executable + +# In the order they are looked for. WAV first: see the module docstring. +STEM_EXTS: tuple[str, ...] = (".wav", ".mp3") + +_MEDIA_TYPES = {".wav": "audio/wav", ".mp3": "audio/mpeg"} + + +def stem_path(stems_dir: Path, name: str) -> Path: + """The file holding stem `name`: `.wav` when it exists, else + `.mp3` when that exists, else the `.wav` path (which does not + exist, so a caller's own is_file() check fails as it always did). + + `name` must already be whitelisted by the caller.""" + wav = stems_dir / f"{name}.wav" + if wav.is_file(): + return wav + mp3 = stems_dir / f"{name}.mp3" + if mp3.is_file(): + return mp3 + return wav + + +def stem_exists(stems_dir: Path, name: str) -> bool: + return stem_path(stems_dir, name).is_file() + + +def stem_ext(path: Path) -> str: + """ "wav" or "mp3" for a stem file.""" + return path.suffix.lower().removeprefix(".") + + +def stem_media_type(path: Path) -> str: + return _MEDIA_TYPES.get(path.suffix.lower(), "application/octet-stream") + + +def stem_url(job_id: str, stems_dir: Path, name: str) -> str: + """The URL the page loads stem `name` from, ending in the file's own + extension so the browser and a download both get the right type.""" + return f"/api/jobs/{job_id}/stems/{name}.{stem_ext(stem_path(stems_dir, name))}" + + +def stem_names_on_disk(stems_dir: Path) -> list[str]: + """Every name with a stem file in `stems_dir`, sorted, each once.""" + names: set[str] = set() + for ext in STEM_EXTS: + names.update( + p.stem for p in stems_dir.glob(f"*{ext}") if p.is_file() and not p.name.startswith(".") + ) + return sorted(names) + + +def is_stem_audio(path: Path) -> bool: + return path.suffix.lower() in STEM_EXTS + + +@contextlib.contextmanager +def decoded_wav(path: Path) -> Iterator[Path]: + """`path` itself when it is a WAV, else a float32 WAV decoded from it by + ffmpeg in a temporary folder that is removed afterwards. + + For a reader that hands the stem to a third-party library which may not + decode MP3 the way ffmpeg does (the vocal split's audio-separator). Raises + RuntimeError when the decode fails.""" + if path.suffix.lower() == ".wav": + yield path + return + folder = Path(tempfile.mkdtemp(prefix="stemdeck-decode-")) + try: + out = folder / f"{path.stem}.wav" + cmd = [ + ffmpeg_executable(), + "-nostdin", + "-loglevel", + "error", + "-i", + str(path), + "-map", + "0:a:0", + "-c:a", + "pcm_f32le", + "-f", + "wav", + "-y", + str(out), + ] + proc = subprocess.run( # noqa: S603 -- list args, no shell, trusted ffmpeg + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=TIMEOUT_FFMPEG, + ) + if proc.returncode != 0 or not out.is_file(): + raise RuntimeError(f"could not decode {path.name}") + yield out + finally: + shutil.rmtree(folder, ignore_errors=True) diff --git a/app/main.py b/app/main.py index 12173c15..b582c953 100644 --- a/app/main.py +++ b/app/main.py @@ -72,6 +72,8 @@ get_playlist_max_items, get_port, get_separation_quality, + get_stem_format, + get_stem_mp3_kbps, get_transcribe_lyrics, get_video_encoder, get_video_max_height, @@ -90,6 +92,7 @@ set_playlist_max_items, set_port, set_separation_quality, + set_stem_storage, set_transcribe_lyrics, set_video_encoder, set_video_max_height, @@ -394,6 +397,10 @@ def _settings_payload() -> dict[str, object]: "video_max_height": get_video_max_height(), "export_sample_rate": get_export_sample_rate(), "separation_quality": get_separation_quality(), + # How new songs' stems are stored (#783). Read when a job finishes; + # songs already in the library keep their own format. + "stem_format": get_stem_format(), + "stem_mp3_kbps": get_stem_mp3_kbps(), # Whisper lyrics for a job no lookup found any for: "auto" (on a GPU # only) | "on" | "off". "transcribe_lyrics": get_transcribe_lyrics(), @@ -551,6 +558,13 @@ async def update_settings(request: Request) -> dict[str, object]: set_separation_quality(str(body["separation_quality"])) except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) from None + if "stem_format" in body or "stem_mp3_kbps" in body: + try: + # Both checked before either is kept: one bad value changes nothing. + set_stem_storage(body.get("stem_format"), body.get("stem_mp3_kbps")) + except ValueError as e: + # The messages list the whitelist; they never echo the value. + raise HTTPException(status_code=422, detail=str(e)) from None if "transcribe_lyrics" in body: try: set_transcribe_lyrics(str(body["transcribe_lyrics"])) diff --git a/app/pipeline/backing.py b/app/pipeline/backing.py index 2cbef475..426a789e 100644 --- a/app/pipeline/backing.py +++ b/app/pipeline/backing.py @@ -39,6 +39,7 @@ import soundfile as sf from app.core.config import EXTRA_STEM_NAMES, STEM_NAMES +from app.core.stem_files import stem_exists, stem_path logger = logging.getLogger("stemdeck.backing") @@ -64,12 +65,13 @@ def backing_inputs(stems_dir: Path, remove: str) -> list[Path]: if remove not in REMOVABLE: raise ValueError(f"unknown stem {remove!r}") names = [n for n in STEM_NAMES if n != remove] - paths = [stems_dir / f"{n}.wav" for n in names] + # WAV or MP3, whichever the song keeps (#783); libsndfile reads both. + paths = [stem_path(stems_dir, n) for n in names] present = [p for p in paths if p.is_file()] - vocals_gone = not (stems_dir / "vocals.wav").is_file() + vocals_gone = not stem_exists(stems_dir, "vocals") if remove != "vocals" and vocals_gone: # No vocals.wav but a finished split: its two halves stand in for it. - split = [stems_dir / f"{n}.wav" for n in EXTRA_STEM_NAMES] + split = [stem_path(stems_dir, n) for n in EXTRA_STEM_NAMES] if all(p.is_file() for p in split): present += split return present @@ -226,11 +228,11 @@ def rest_inputs(stems_dir: Path, names: tuple[str, ...]) -> list[Path]: vocals.wav already holds them.""" paths: list[Path] = [] for name in names: - p = stems_dir / f"{name}.wav" + p = stem_path(stems_dir, name) if p.is_file(): paths.append(p) continue - split = [stems_dir / f"{n}.wav" for n in EXTRA_STEM_NAMES] + split = [stem_path(stems_dir, n) for n in EXTRA_STEM_NAMES] if name == "vocals" and all(s.is_file() for s in split): paths += split continue diff --git a/app/pipeline/collect.py b/app/pipeline/collect.py index 5d7cc5dd..49fb5462 100644 --- a/app/pipeline/collect.py +++ b/app/pipeline/collect.py @@ -26,6 +26,7 @@ from app.core.registry import persist as registry_persist from app.core.registry import remove as registry_remove from app.core.registry import set_proc +from app.core.stem_files import is_stem_audio, stem_names_on_disk, stem_path from app.pipeline.audio_stats import scan_stem logger = logging.getLogger("stemdeck.collect") @@ -294,7 +295,7 @@ def merge_stem_peaks( ) for name in [*backfill, *new_names]: - wav = (sources or {}).get(name) or stems_dir / f"{name}.wav" + wav = (sources or {}).get(name) or stem_path(stems_dir, name) if not wav.is_file(): continue try: @@ -332,7 +333,8 @@ def merge_stem_peaks( def _stem_wav_names(stems_dir: Path) -> list[str]: - return sorted(p.stem for p in stems_dir.glob("*.wav") if p.is_file()) + # WAV or MP3 (#783): an MP3-stored song has no WAVs left. + return stem_names_on_disk(stems_dir) def fill_missing_peaks(stems_dir: Path, sources: dict[str, Path] | None = None) -> bool: @@ -353,7 +355,7 @@ def fill_missing_peaks(stems_dir: Path, sources: dict[str, Path] | None = None) if path.is_file(): return True wanted: dict[str, Path] = { - name: stems_dir / f"{name}.wav" for name in _stem_wav_names(stems_dir) + name: stem_path(stems_dir, name) for name in _stem_wav_names(stems_dir) } for name, wav in (sources or {}).items(): if wav.is_file(): @@ -442,7 +444,9 @@ def _has_takes_on_disk(job_dir: Path) -> bool: def _has_stems_on_disk(job_dir: Path) -> bool: stems = job_dir / "stems" try: - return stems.is_dir() and any(p.suffix.lower() == ".wav" for p in stems.iterdir()) + # An MP3-stored song (#783) holds stems too: missing it here would let + # the sweep delete an unregistered folder that still has a song in it. + return stems.is_dir() and any(is_stem_audio(p) for p in stems.iterdir()) except OSError: return True diff --git a/app/pipeline/lyrics_align.py b/app/pipeline/lyrics_align.py index fd63a35a..07ea24f7 100644 --- a/app/pipeline/lyrics_align.py +++ b/app/pipeline/lyrics_align.py @@ -44,6 +44,7 @@ LYRICS_ALIGN_TOLERANCE_SEC, ) from app.core.models import Job +from app.core.stem_files import stem_path from app.pipeline.audio_stats import vocal_envelope logger = logging.getLogger("stemdeck.lyrics") @@ -194,7 +195,7 @@ def estimate_offset(starts: list[float], strength: np.ndarray, hop: float) -> fl def _envelope(stems_dir: Path) -> tuple[float, list[int]] | None: """The vocals stem's envelope: the one kept beside it when it is not older than the stem, else read from the stem now. None without vocals.""" - vocals = stems_dir / "vocals.wav" + vocals = stem_path(stems_dir, "vocals") if not vocals.is_file(): return None kept = stems_dir / _ENVELOPE_FILE diff --git a/app/pipeline/lyrics_retime.py b/app/pipeline/lyrics_retime.py index c9a7c669..a4eefc87 100644 --- a/app/pipeline/lyrics_retime.py +++ b/app/pipeline/lyrics_retime.py @@ -112,6 +112,7 @@ LYRICS_TRANSCRIPT_FILE, LYRICS_USER_SYNCED_MAX_LINES, ) +from app.core.stem_files import stem_path from app.pipeline.lyrics_align import ( _HIT_RISE_DB, _LOUD_PERCENTILE, @@ -1458,12 +1459,29 @@ def _vocals_stamp(job_dir: Path) -> list[int] | None: was heard from. A stem made again (a re-separation writing it in place) no longer matches, and its transcript is heard afresh.""" try: - st = (job_dir / "stems" / "vocals.wav").stat() + st = stem_path(job_dir / "stems", "vocals").stat() except OSError: return None return [st.st_size, st.st_mtime_ns] +def restamp_transcript(job_dir: Path, old_stamp: list[int]) -> None: + """The vocals stem was stored again in another format, same audio (#783): + a transcript heard from it (stamped ``old_stamp``) is moved onto the new + file's stamp, so it is not heard afresh. Anything else is left as it is.""" + from app.pipeline.lyrics_lookup import _write_json_atomic + + path = transcript_path(job_dir) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return + if not isinstance(data, dict) or data.get("vocals") != old_stamp: + return + data["vocals"] = _vocals_stamp(job_dir) + _write_json_atomic(path, data) + + def save_transcript( job_dir: Path, result: dict[str, Any] | None = None, @@ -1820,7 +1838,7 @@ def _hear( when the worker failed. Raises RetimeFailed without vocals, JobCancelled.""" from app.pipeline.transcribe import _device, _run_worker, _spawn_worker_cmd - vocals = job_dir / "stems" / "vocals.wav" + vocals = stem_path(job_dir / "stems", "vocals") if not vocals.is_file(): raise RetimeFailed("no vocals stem") cmd = _spawn_worker_cmd(vocals, _device(job)) diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py index f6ed889d..e0b05bab 100644 --- a/app/pipeline/runner.py +++ b/app/pipeline/runner.py @@ -40,6 +40,7 @@ from app.pipeline.lyrics_retime import retime_after_separation from app.pipeline.sections import detect_sections from app.pipeline.separate import separate +from app.pipeline.stem_storage import store_stems from app.pipeline.transcribe import transcribe_lyrics logger = logging.getLogger("stemdeck.pipeline") @@ -324,12 +325,30 @@ def _run_with_band_lookup(job: Job, source: Path, job_dir: Path) -> None: _lap(job, "retime", mark) +def _store_stems(job: Job, job_dir: Path) -> None: + """WAV or MP3 storage of the stems (#783). Last, once no stage reads the + stems any more: separation and every analysis ran on the WAVs. Never + fails the song; store_stems keeps the WAVs on any failure, and the guard + is for a future change there, as for the beat grid.""" + _check_cancel(job) + mark = time.monotonic() + try: + store_stems(job, job_dir) + except JobCancelled: + raise + except Exception: + logger.exception("stem storage stage failed for job %s; stems kept as WAV", job.id) + _set(job, stem_format="wav", stem_mp3_kbps=None) + _lap(job, "store", mark) + + def _run_blocking(job: Job, url: str, job_dir: Path) -> None: _check_cancel(job) mark = time.monotonic() source = download(job, url, job_dir) _lap(job, "download", mark) _run_with_band_lookup(job, source, job_dir) + _store_stems(job, job_dir) def _run_local_blocking(job: Job, source_path: Path, job_dir: Path) -> None: @@ -338,6 +357,7 @@ def _run_local_blocking(job: Job, source_path: Path, job_dir: Path) -> None: source = _prepare_local_source(job, source_path, job_dir) _lap(job, "prepare", mark) _run_with_band_lookup(job, source, job_dir) + _store_stems(job, job_dir) def _write_metadata(job: Job, job_dir: Path) -> None: @@ -367,6 +387,8 @@ def _write_metadata(job: Job, job_dir: Path) -> None: "compute_device": job.compute_device, "gpu_fallback": job.gpu_fallback, "stage_timings": job.stage_timings, + "stem_format": job.stem_format, + "stem_mp3_kbps": job.stem_mp3_kbps, } try: (job_dir / "metadata.json").write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8") diff --git a/app/pipeline/stem_storage.py b/app/pipeline/stem_storage.py new file mode 100644 index 00000000..05946fce --- /dev/null +++ b/app/pipeline/stem_storage.py @@ -0,0 +1,489 @@ +"""The last pipeline stage: keep a finished song's stems as WAV or MP3 (#783). + +Separation and every analysis stage run on WAV exactly as they always did. +Only once the song has nothing left to read from its stems does this stage +look at the Stem storage format setting (read now, so a change applies to the +next song without a restart): + +- WAV: nothing is touched. The job records "wav". +- MP3: every stem file in stems/ (the six stems, original, mix) is encoded by + ffmpeg's libmp3lame at a constant bitrate into a temporary file, and each + one is verified: ffmpeg and libsndfile, the two decoders the server reads + stems with, must both decode it to exactly the WAV's frame count, and the + start of the decoded audio must line up with the WAV's. Only when every stem + passed are the MP3s moved into place (rename, so never half-written) and the + WAVs deleted. + +Any failure (ffmpeg without libmp3lame, a full disk, a stall, an output that +does not decode or does not line up) removes the temporary files, keeps every +WAV and records the song as "wav". The song finishes either way: this stage +never fails a job. A cancel raises JobCancelled, after the same cleanup. + +Sample accuracy: libmp3lame adds an encoder delay and padding. ffmpeg writes +both into the LAME/Xing header of the first frame (-write_xing 1, its +default, made explicit below), and ffmpeg, libsndfile (mpg123) and Chromium's +decodeAudioData all trim by it, so a decoded MP3 starts on the same sample as +the WAV and has the same length. tests/test_stem_storage.py measures it. + +`original.wav` here is the complement backing track (make_original_track), +a stem like the others, so it is stored the same way. The song's own source is +not in stems/ and is never touched: an upload's kept source. stays as it +was uploaded. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +import threading +import time +import uuid +from pathlib import Path + +from app.core.config import ( + EXTRA_STEM_NAMES, + STEM_NAMES, + TIMEOUT_FFMPEG, + TIMEOUT_STEM_TRANSCODE_STALL, + ffmpeg_executable, +) +from app.core.models import Job, JobCancelled, _set +from app.core.registry import set_proc +from app.core.settings import get_stem_format, get_stem_mp3_kbps +from app.core.stem_files import stem_url + +logger = logging.getLogger("stemdeck.pipeline") + +# Every stem file a finished song can hold. +STORED_NAMES: tuple[str, ...] = (*STEM_NAMES, *EXTRA_STEM_NAMES, "original", "mix") +# The alignment check compares the loudest block of this many seconds of each +# stem, found in one sequential pass over both files (no seeking, constant +# memory). After the exact length check it catches an output that decodes to +# the right length but not to the right place. +_ALIGN_BLOCK_SEC = 10 +# A block quieter than this RMS is near silence: a stem that never plays has +# nothing to line up, so only its length is checked. +_ALIGN_MIN_RMS = 0.01 +# The lags searched either side of 0, in samples: past the largest offset a +# decoder that ignored the LAME header would add (1105 = 576 encoder delay + +# 529 decoder delay at 44.1 kHz). +_ALIGN_MAX_LAG = 4096 +# A correct encode correlates at about 0.99 at lag 0 even at 128 kbps. +# Refused: a block whose lag-0 correlation is under the floor (not this audio), +# or clearly below the best one within the searched lags (shifted). The ratio +# leaves room for steady tones, whose correlation repeats every period. +_ALIGN_MIN_CORR = 0.5 +_ALIGN_MIN_RATIO = 0.98 +# The encoder's output is shown to nobody. Kept for the log line on failure. +_TAIL_LINES = 15 + + +class StemStorageError(RuntimeError): + """The MP3s could not be made or did not verify. The WAVs stay.""" + + +def _encode_cmd(src: Path, dest: Path, kbps: int) -> list[str]: + """One stem to CBR MP3. Module-level seam: tests swap in a stub encoder. + + `-progress pipe:1` prints a status block about once a second, which is + what the stall watchdog listens to.""" + return [ + ffmpeg_executable(), + "-nostdin", + "-hide_banner", + "-loglevel", + "error", + "-nostats", + "-progress", + "pipe:1", + "-i", + str(src), + "-map", + "0:a:0", + "-c:a", + "libmp3lame", + "-b:a", + f"{kbps}k", + # The LAME header carries the encoder delay and padding, which is + # what makes the decoded MP3 sample-aligned with the WAV. + "-write_xing", + "1", + "-f", + "mp3", + "-y", + str(dest), + ] + + +def _encode(job: Job, src: Path, dest: Path, kbps: int) -> None: + """Run the encoder for one stem, registered for cancel, under a stall + watchdog. Raises JobCancelled or StemStorageError.""" + try: + proc = subprocess.Popen( # noqa: S603 -- list args, no shell, trusted ffmpeg + _encode_cmd(src, dest, kbps), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + except OSError as exc: + raise StemStorageError("the MP3 encoder could not be started") from exc + set_proc(job.id, proc) + last_output = [time.monotonic()] + stalled = threading.Event() + done = threading.Event() + err_tail: list[str] = [] + + def _watchdog() -> None: + while not done.wait(timeout=0.2): + if job.cancel_requested: + proc.terminate() + return + if time.monotonic() - last_output[0] > TIMEOUT_STEM_TRANSCODE_STALL: + logger.warning( + "[%s] stem encode silent for %ss; stopping it", + job.id, + TIMEOUT_STEM_TRANSCODE_STALL, + ) + stalled.set() + proc.terminate() + return + + def _drain_stderr() -> None: + assert proc.stderr is not None + for line in proc.stderr: + err_tail.append(line.rstrip()) + del err_tail[:-_TAIL_LINES] + + watch = threading.Thread(target=_watchdog, daemon=True) + drain = threading.Thread(target=_drain_stderr, daemon=True) + watch.start() + drain.start() + try: + assert proc.stdout is not None + for _line in proc.stdout: + last_output[0] = time.monotonic() + try: + proc.wait(timeout=TIMEOUT_FFMPEG) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + raise StemStorageError("the MP3 encoder did not exit") from None + finally: + done.set() + set_proc(job.id, None) + watch.join(timeout=2) + drain.join(timeout=2) + if proc.poll() is None: + proc.terminate() + try: + proc.communicate(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + if job.cancel_requested: + raise JobCancelled() + if stalled.is_set(): + raise StemStorageError("the MP3 encoder stalled") + if proc.returncode != 0: + detail = " | ".join(err_tail[-3:]) or f"exit {proc.returncode}" + raise StemStorageError(f"the MP3 encoder failed: {detail}") + if not dest.is_file() or dest.stat().st_size == 0: + raise StemStorageError("the MP3 encoder wrote nothing") + + +def _ffmpeg_frames(job: Job, path: Path, channels: int) -> int: + """Frames ffmpeg decodes `path` to: the whole file, counted as it streams.""" + cmd = [ + ffmpeg_executable(), + "-nostdin", + "-loglevel", + "error", + "-i", + str(path), + "-map", + "0:a:0", + "-ac", + str(channels), + "-c:a", + "pcm_s16le", + "-f", + "s16le", + "-", + ] + proc = subprocess.Popen( # noqa: S603 -- list args, no shell, trusted ffmpeg + cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL + ) + set_proc(job.id, proc) + total = 0 + try: + assert proc.stdout is not None + while chunk := proc.stdout.read(1 << 20): + total += len(chunk) + proc.wait(timeout=TIMEOUT_FFMPEG) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + raise StemStorageError("the MP3 check did not finish") from None + finally: + set_proc(job.id, None) + if job.cancel_requested: + raise JobCancelled() + if proc.returncode != 0: + raise StemStorageError("the MP3 does not decode") + return total // (2 * channels) + + +def verify_mp3(job: Job, wav: Path, mp3: Path) -> None: + """Raise StemStorageError unless `mp3` decodes to `wav`'s exact length in + both ffmpeg and libsndfile, at its rate and channel count, and its + loudest block lines up with the WAV's (cross-correlation peak at lag 0).""" + import numpy as np + import soundfile as sf + + try: + ref = sf.info(str(wav)) + got = sf.info(str(mp3)) + except Exception as exc: # libsndfile raises its own types + raise StemStorageError("the MP3 cannot be read by libsndfile") from exc + if (got.samplerate, got.channels) != (ref.samplerate, ref.channels): + raise StemStorageError("the MP3 has another rate or channel count") + if got.frames != ref.frames: + raise StemStorageError(f"libsndfile decodes {got.frames} frames, the WAV has {ref.frames}") + ff = _ffmpeg_frames(job, mp3, ref.channels) + if ff != ref.frames: + raise StemStorageError(f"ffmpeg decodes {ff} frames, the WAV has {ref.frames}") + try: + a, b = _loudest_block(wav, mp3, ref.samplerate * _ALIGN_BLOCK_SEC) + except Exception as exc: + raise StemStorageError("the MP3 cannot be read back") from exc + if a is None or float(np.sqrt(np.mean(a * a))) < _ALIGN_MIN_RMS: + return + zero, best, lag = _correlation(a, b, _ALIGN_MAX_LAG) + if zero < _ALIGN_MIN_CORR or zero < _ALIGN_MIN_RATIO * best: + raise StemStorageError( + f"the MP3 does not line up with the WAV (lag {lag}, {zero:.3f} at 0 against {best:.3f})" + ) + + +def _loudest_block(wav: Path, mp3: Path, size: int): + """Channel 0 of the loudest `size`-frame block of `wav`, and the same + block of `mp3`, read side by side from the start. (None, None) for an + empty file.""" + import numpy as np + import soundfile as sf + + best_rms, best = -1.0, (None, None) + pairs = zip( + sf.blocks(str(wav), blocksize=size, dtype="float32", always_2d=True), + sf.blocks(str(mp3), blocksize=size, dtype="float32", always_2d=True), + strict=True, + ) + for a, b in pairs: + if len(a) != len(b): + raise StemStorageError("the MP3 reads back at another length") + a0 = a[:, 0].astype(np.float64) + rms = float(np.sqrt(np.mean(a0 * a0))) if a0.size else 0.0 + if rms > best_rms: + best_rms, best = rms, (a0, b[:, 0].astype(np.float64)) + return best + + +def _correlation(a, b, max_lag: int) -> tuple[float, float, int]: + """Normalised cross-correlation of b against a: its value at lag 0, its + best value within +-max_lag, and that lag.""" + import numpy as np + + n = len(a) + nfft = 1 << (2 * n).bit_length() + c = np.fft.irfft(np.fft.rfft(b, nfft) * np.conj(np.fft.rfft(a, nfft)), nfft) + denom = float(np.sqrt(np.sum(a * a) * np.sum(b * b))) + if denom <= 0: + return 0.0, 0.0, 0 + k = min(max_lag, n - 1) + window = np.concatenate([c[-k:], c[: k + 1]]) / denom if k else c[:1] / denom + i = int(np.argmax(window)) + return float(c[0] / denom), float(window[i]), i - k + + +def _stamp(path: Path) -> list[int] | None: + try: + st = path.stat() + except OSError: + return None + return [st.st_size, st.st_mtime_ns] + + +def _keep_derived_files(job_dir: Path, vocals_wav_stamp: list[int] | None) -> None: + """Files kept beside the stems that are checked against the vocals stem's + size and time: the same audio is now in another file, so they stay valid + and are not recomputed. Best effort.""" + stems_dir = job_dir / "stems" + vocals_mp3 = stems_dir / "vocals.mp3" + if vocals_wav_stamp is None or not vocals_mp3.is_file(): + return + try: + from app.pipeline.lyrics_retime import restamp_transcript + + restamp_transcript(job_dir, vocals_wav_stamp) + except Exception: + logger.info("[%s] could not keep the transcript's stamp", job_dir.name, exc_info=True) + envelope = stems_dir / "vocal_envelope.json" + try: + # Kept while it is not older than the stem (lyrics_align._envelope, + # GET /vocal-envelope): it was made from the WAV, and still holds. + if envelope.is_file() and envelope.stat().st_mtime_ns >= vocals_wav_stamp[1]: + os.utime(envelope) + except OSError: + logger.info("[%s] could not keep the vocal envelope", job_dir.name, exc_info=True) + + +def _record(job: Job, fmt: str, kbps: int | None) -> None: + _set(job, stem_format=fmt, stem_mp3_kbps=kbps) + + +def _refresh_urls(job: Job, stems_dir: Path) -> None: + """Point the job's stem URLs (and the mix link, when it is a stem file) at + the files now on disk.""" + stems = [] + for entry in job.stems: + name = entry.get("name", "") + stems.append({**entry, "url": stem_url(job.id, stems_dir, name)} if name else entry) + prefix = f"/api/jobs/{job.id}/stems/" + mix_url = job.mix_url + if mix_url and mix_url.startswith(prefix): + name = Path(mix_url[len(prefix) :]).stem + if name in STORED_NAMES: + mix_url = stem_url(job.id, stems_dir, name) + _set(job, stems=stems, mix_url=mix_url) + + +def store_stems(job: Job, job_dir: Path) -> None: + """Keep the song's stems in the format the setting asks for now. Never + raises but for a cancel; see the module docstring.""" + if job.cancel_requested: + raise JobCancelled() + fmt = get_stem_format() + if fmt != "mp3": + _record(job, "wav", None) + return + kbps = get_stem_mp3_kbps() + stems_dir = job_dir / "stems" + wavs = [p for p in (stems_dir / f"{n}.wav" for n in STORED_NAMES) if p.is_file()] + if not wavs: + _record(job, "wav", None) + return + + _set(job, stage="Compressing stems...") + started = time.monotonic() + made: list[tuple[Path, Path]] = [] + try: + for wav in wavs: + if job.cancel_requested: + raise JobCancelled() + # Dot-prefixed and .tmp-marked: no reader takes it for a stem. + tmp = stems_dir / f".{wav.stem}.{uuid.uuid4().hex}.tmp.mp3" + made.append((wav, tmp)) + _encode(job, wav, tmp, kbps) + verify_mp3(job, wav, tmp) + except JobCancelled: + _discard(made) + raise + except Exception as exc: + # StemStorageError, or anything unexpected (OSError on a full disk, + # libsndfile): the WAVs are all still here, so the song is a WAV song. + _discard(made) + logger.warning("[%s] stems kept as WAV: MP3 storage failed: %s", job.id, exc) + _record(job, "wav", None) + return + + vocals_stamp = _stamp(stems_dir / "vocals.wav") + moved: list[Path] = [] + try: + for wav, tmp in made: + final = wav.with_suffix(".mp3") + os.replace(tmp, final) + moved.append(final) + except OSError as exc: + # Every WAV is still here and wins over an MP3 of the same name + # (stem_files.stem_path); the moved ones are removed so nothing + # half-done is left behind. + _discard(made) + for final in moved: + final.unlink(missing_ok=True) + logger.warning("[%s] stems kept as WAV: could not move the MP3s: %s", job.id, exc) + _record(job, "wav", None) + return + + before = sum(_size(w) for w in wavs) + after = sum(_size(w.with_suffix(".mp3")) for w in wavs) + for wav in wavs: + try: + wav.unlink() + except OSError: + # Harmless: a WAV left behind is still a valid stem and wins over + # its MP3. Logged, so the disk it costs is not a mystery. + logger.warning("[%s] could not delete %s after storing it as MP3", job.id, wav.name) + _keep_derived_files(job_dir, vocals_stamp) + _refresh_urls(job, stems_dir) + _record(job, "mp3", kbps) + logger.info( + "[%s] stems stored as MP3 %dk: %d files, %.1f MB -> %.1f MB in %.1fs", + job.id, + kbps, + len(wavs), + before / 1e6, + after / 1e6, + time.monotonic() - started, + ) + + +def _size(path: Path) -> int: + try: + return path.stat().st_size + except OSError: + return 0 + + +def _discard(made: list[tuple[Path, Path]]) -> None: + for _wav, tmp in made: + try: + tmp.unlink(missing_ok=True) + except OSError: + logger.debug("could not remove %s", tmp, exc_info=True) + + +def store_split_stems(job: Job, stems_dir: Path, names: list[str]) -> None: + """The on-demand lead/backing split writes WAVs into a song that may be + stored as MP3. They are stored the way the song is, with the same + verification, and stay WAV on any failure. Never raises.""" + if job.stem_format != "mp3" or not job.stem_mp3_kbps: + return + made: list[tuple[Path, Path]] = [] + try: + for name in names: + wav = stems_dir / f"{name}.wav" + if not wav.is_file(): + continue + tmp = stems_dir / f".{name}.{uuid.uuid4().hex}.tmp.mp3" + made.append((wav, tmp)) + _encode(job, wav, tmp, int(job.stem_mp3_kbps)) + verify_mp3(job, wav, tmp) + for wav, tmp in made: + os.replace(tmp, wav.with_suffix(".mp3")) + except Exception as exc: + _discard(made) + for wav, _tmp in made: + if wav.is_file(): + # Never both for long: the WAV wins anyway, the MP3 is clutter. + wav.with_suffix(".mp3").unlink(missing_ok=True) + logger.warning("[%s] split stems kept as WAV: %s", job.id, exc) + return + for wav, _tmp in made: + try: + wav.unlink() + except OSError: + logger.warning("[%s] could not delete %s after storing it as MP3", job.id, wav.name) diff --git a/app/pipeline/transcribe.py b/app/pipeline/transcribe.py index 054cecfd..bbb8fa59 100644 --- a/app/pipeline/transcribe.py +++ b/app/pipeline/transcribe.py @@ -62,6 +62,7 @@ from app.core.models import Job, JobCancelled, _set from app.core.registry import set_proc from app.core.settings import lyrics_mending_enabled, transcribe_lyrics_enabled +from app.core.stem_files import stem_exists, stem_path from app.pipeline.lyrics_lookup import lyrics_path, read_lyrics, write_lyrics from app.pipeline.lyrics_repair import looks_stripped, mend_from_transcript, words_of from app.pipeline.lyrics_retime import save_transcript @@ -144,7 +145,7 @@ def skip_reason(job: Job, job_dir: Path) -> str | None: def _vocals_reason(job: Job, job_dir: Path) -> str | None: """Why this job's vocals are not worth transcribing, or None.""" - if not (job_dir / "stems" / "vocals.wav").is_file(): + if not stem_exists(job_dir / "stems", "vocals"): return "no vocals stem" presence = (job.stem_presence or {}).get("vocals") if isinstance(presence, int) and presence < TRANSCRIBE_MIN_VOCAL_PRESENCE: @@ -483,7 +484,7 @@ def transcribe_lyrics(job: Job, job_dir: Path) -> bool: logger.info("[%s] lyrics transcription skipped: %s", job.id, reason) return False _set(job, stage=_STAGE) - vocals = job_dir / "stems" / "vocals.wav" + vocals = stem_path(job_dir / "stems", "vocals") result = _run_worker(job, _spawn_worker_cmd(vocals, _device(job))) if result is None: return False @@ -571,7 +572,7 @@ def mend_lyrics(job: Job, job_dir: Path) -> bool: return False started = time.monotonic() _set(job, stage=_STAGE) - vocals = job_dir / "stems" / "vocals.wav" + vocals = stem_path(job_dir / "stems", "vocals") result = _run_worker(job, _spawn_worker_cmd(vocals, _device(job)) + _language_gate()) if result is None: return False diff --git a/app/pipeline/vocal_split.py b/app/pipeline/vocal_split.py index a3d1ebf8..84310a37 100644 --- a/app/pipeline/vocal_split.py +++ b/app/pipeline/vocal_split.py @@ -25,6 +25,7 @@ from app.core.models import Job from app.core.registry import set_proc from app.core.settings import get_demucs_device +from app.core.stem_files import decoded_wav, stem_path from app.pipeline.errors import SeparationError logger = logging.getLogger("stemdeck.pipeline") @@ -50,9 +51,23 @@ def split_vocals(job: Job, stems_dir: Path) -> list[str]: responsible for catching failures and leaving the job's base stems untouched -- this function never mutates anything but stems_dir's contents, and never touches vocals.wav itself.""" - vocals_path = stems_dir / "vocals.wav" + vocals_path = stem_path(stems_dir, "vocals") if not vocals_path.is_file(): raise SeparationError("vocals.wav not found -- job has no vocals stem to split") + # audio-separator gets a WAV whatever the song keeps (#783): an MP3 vocals + # stem is decoded by ffmpeg first, so the model reads exactly the samples + # every other reader does. + try: + with decoded_wav(vocals_path) as wav_in: + return _split(job, wav_in, stems_dir) + except SeparationError: + raise + except RuntimeError as exc: + raise SeparationError("could not read the vocals stem") from exc + + +def _split(job: Job, vocals_path: Path, stems_dir: Path) -> list[str]: + """split_vocals on a WAV vocals file.""" device = get_demucs_device() env = os.environ.copy() diff --git a/docs/api.md b/docs/api.md index d78010b6..0e8ee30e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -13,7 +13,7 @@ The endpoints the web app itself uses. They are not a stable public interface an | GET | `/api/jobs/{id}/events` | SSE stream of job state | | POST | `/api/jobs/{id}/cancel` | Terminate active subprocess and cancel job | | 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}/stems/{name}.wav` | Stream a single stem as the song stores it: WAV, or MP3 (`audio/mpeg`) for a song stored as MP3 (#783) | +| GET | `/api/jobs/{id}/stems/{name}.mp3` | A stem as MP3: the stored file for a song stored as MP3, else a cached transcode | | 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) | diff --git a/docs/configuration.md b/docs/configuration.md index 914b0f86..4075cff2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,6 +19,8 @@ | `STEMDECK_MAX_DURATION_SEC` | `1200` | Reject audio longer than this (seconds). | | `STEMDECK_JOB_TTL_SECONDS` | `86400` | How long to keep job dirs on disk. | | `STEMDECK_MAX_PENDING_JOBS` | `3` | Max queued jobs before returning 503. | +| `STEMDECK_STEM_FORMAT` | `mp3` | How new songs' stems are stored: `mp3` or `wav`. Songs already in the library keep their format. A choice saved in Settings > Library wins over this. | +| `STEMDECK_STEM_MP3_KBPS` | `128` | MP3 stem bitrate: `128`, `192` or `256` (constant bitrate). Used only with `mp3`. | | `STEMDECK_TIMEOUT_FFMPEG` | `300` | ffmpeg subprocess timeout (seconds). | | `STEMDECK_TIMEOUT_ANALYZE` | `120` | Audio analysis timeout (seconds). | | `STEMDECK_TIMEOUT_DEMUCS_STALL` | `1800` | Kill Demucs if no output for this many seconds. | diff --git a/static/js/catalog.js b/static/js/catalog.js index 3c65188b..92c96274 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -4324,7 +4324,15 @@ async function wireGeneralSettings(overlay) { const autoDeleteDaysRow = overlay.querySelector(".auto-delete-days-row"); const autoDeleteDays = overlay.querySelector(".set-auto-delete-days"); const autoDeleteDaysDesc = overlay.querySelector(".auto-delete-days-desc"); - if (!durInput && !playlistInput && !heightSel && !sampleRateSel && !portInput && !deviceSel && !qualitySel && !transcribeSel && !cookiesInput && !autoDeleteInput) return; + const stemFormatSel = overlay.querySelector(".set-stem-format"); + const stemKbpsSel = overlay.querySelector(".set-stem-kbps"); + const stemKbpsRow = overlay.querySelector(".stem-bitrate-row"); + if (!durInput && !playlistInput && !heightSel && !sampleRateSel && !portInput && !deviceSel && !qualitySel && !transcribeSel && !cookiesInput && !autoDeleteInput && !stemFormatSel) return; + + // The bitrate only means anything for MP3, so its row shows only then. + const syncStemKbps = () => { + if (stemKbpsRow) stemKbpsRow.hidden = stemFormatSel?.value !== "mp3"; + }; // Last server-confirmed device choice, to revert the select when the server // rejects a forced device (e.g. CUDA not available on this machine). @@ -4354,6 +4362,11 @@ async function wireGeneralSettings(overlay) { if (portInput && d.port) portInput.value = String(d.port); if (qualitySel && d.separation_quality) qualitySel.value = d.separation_quality; if (transcribeSel && d.transcribe_lyrics) transcribeSel.value = d.transcribe_lyrics; + if (stemFormatSel && d.stem_format) { + stemFormatSel.value = d.stem_format; + syncStemKbps(); + } + if (stemKbpsSel && d.stem_mp3_kbps) stemKbpsSel.value = String(d.stem_mp3_kbps); // Unset is the normal case, so read the key rather than truthiness -- // clearing the field must survive the round trip and not be repopulated. if (cookiesInput && "cookies_file" in d) cookiesInput.value = d.cookies_file || ""; @@ -4491,6 +4504,14 @@ async function wireGeneralSettings(overlay) { transcribeSel?.addEventListener("change", () => { post({ transcribe_lyrics: transcribeSel.value }); }); + // Read when a song finishes, so the next one is stored this way: no restart. + stemFormatSel?.addEventListener("change", () => { + syncStemKbps(); + post({ stem_format: stemFormatSel.value }); + }); + stemKbpsSel?.addEventListener("change", () => { + post({ stem_mp3_kbps: parseInt(stemKbpsSel.value, 10) }); + }); autoDeleteInput?.addEventListener("change", () => { // Enable the days field immediately rather than waiting for the round // trip, so the switch does not appear to do nothing on a slow response. @@ -5031,6 +5052,30 @@ function openLibraryEditor() { +
+

Stem files

+
+
+
+
Stem storage format
+
How the stems of songs you add from now on are stored. New songs are stored as MP3 unless you choose WAV. Songs you already have keep their format. MP3 takes about a tenth of the space but is lossy; WAV is lossless.
+
+ +
+
+
+
MP3 bitrate
+
Higher sounds closer to the original and takes more space. Applies to songs added from now on.
+
+ +
+
+

Automatic cleanup

@@ -5500,6 +5545,9 @@ function settingsSearchEntries(overlay) { continue; } for (const row of rows) { + // A row its own section is hiding (the MP3 bitrate while WAV is chosen) + // would be a result that opens on nothing. + if (row.hidden) continue; const title = row.querySelector(".settings-row-title")?.textContent.trim(); if (!title) continue; const desc = [...row.querySelectorAll(".settings-row-desc")] diff --git a/static/js/helpMode.js b/static/js/helpMode.js index 73e7eebb..d320e529 100644 --- a/static/js/helpMode.js +++ b/static/js/helpMode.js @@ -275,6 +275,8 @@ const SETTINGS = [ ["settings.tab.library", [ ["settings.stemsLocation.title", "help.set.stemsLocation"], ["settings.exportsLocation.title", "help.set.exportsLocation"], + ["settings.stemFormat.title", "help.set.stemFormat"], + ["settings.stemBitrate.title", "help.set.stemBitrate"], ["settings.autoDelete.title", "help.set.autoDelete"], ["settings.outOfSync.subhead", "help.set.outOfSync"], ["settings.resetData.title", "help.set.resetData"], diff --git a/static/js/i18n.js b/static/js/i18n.js index 73b78e9c..e7931039 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -935,6 +935,8 @@ const en = { "help.set.cookies": "Only needed if YouTube asks you to prove you are not a bot.", "help.set.stemsLocation": "Where your separated songs are stored.", "help.set.exportsLocation": "Where stems and loops you drag out of StemDeck go.", + "help.set.stemFormat": "Chooses whether the stems of new songs are kept as MP3, which is smaller but lossy, or as WAV, which is lossless. Songs already in the library keep their format.", + "help.set.stemBitrate": "The MP3 quality for new songs: 128, 192 or 256 kbps. Higher sounds closer to the original and takes more space.", "help.set.autoDelete": "Off unless you turn it on. Deletes songs after the number of days you set.", "help.set.device": "Your graphics card or your processor, for separating.", "help.set.quality": "Best gives cleaner stems and takes twice as long.", @@ -1266,6 +1268,7 @@ const en = { "settings.group.separation": "Separation", "settings.group.imports": "Imports", "settings.group.storage": "Storage", + "settings.group.stemFiles": "Stem files", "settings.group.cleanup": "Automatic cleanup", "settings.group.audio": "Audio", "settings.group.video": "Video", @@ -1328,6 +1331,13 @@ const en = { "loop.dragOutStem": "Drag {name} for this region into a DAW or folder", "settings.exportsLocation.title": "Exports folder", "settings.exportsLocation.desc": "Where audio goes when you drag a stem or a loop out of StemDeck. Nothing here is deleted automatically, because a project that references a dragged file needs it to stay put.", + "settings.stemFormat.title": "Stem storage format", + "settings.stemFormat.desc": "How the stems of songs you add from now on are stored. New songs are stored as MP3 unless you choose WAV. Songs you already have keep their format. MP3 takes about a tenth of the space but is lossy; WAV is lossless.", + "settings.stemFormat.mp3": "MP3 (smaller)", + "settings.stemFormat.wav": "WAV (lossless)", + "settings.stemBitrate.title": "MP3 bitrate", + "settings.stemBitrate.desc": "Higher sounds closer to the original and takes more space. Applies to songs added from now on.", + "settings.stemBitrate.option": "{kbps} kbps", "settings.exportsLocation.change": "Change…", "settings.exportsLocation.pickerFailed": "The folder picker could not be opened.", "settings.stemsLocation.resetting": "Resetting…", @@ -2701,6 +2711,8 @@ const pl = { "help.set.cookies": "Potrzebne tylko, gdy YouTube poprosi o potwierdzenie, że nie jesteś botem.", "help.set.stemsLocation": "Gdzie zapisywane są rozdzielone utwory.", "help.set.exportsLocation": "Dokąd trafiają ścieżki i pętle przeciągnięte poza StemDeck.", + "help.set.stemFormat": "Określa, czy ścieżki nowych utworów są zapisywane jako MP3, mniejszy, ale stratny, czy jako bezstratny WAV. Utwory już w bibliotece zachowują swój format.", + "help.set.stemBitrate": "Jakość MP3 dla nowych utworów: 128, 192 lub 256 kb/s. Wyższa brzmi bliżej oryginału i zajmuje więcej miejsca.", "help.set.autoDelete": "Wyłączone, dopóki tego nie włączysz. Usuwa utwory po wybranej liczbie dni.", "help.set.device": "Twoja karta graficzna lub procesor, do rozdzielania.", "help.set.quality": "Best daje czystsze ścieżki i trwa dwa razy dłużej.", @@ -3029,6 +3041,7 @@ const pl = { "settings.group.separation": "Separacja", "settings.group.imports": "Import", "settings.group.storage": "Miejsce na dysku", + "settings.group.stemFiles": "Pliki ścieżek", "settings.group.cleanup": "Automatyczne czyszczenie", "settings.group.audio": "Dźwięk", "settings.group.video": "Wideo", @@ -3091,6 +3104,13 @@ const pl = { "loop.dragOutStem": "Przeciągnij ścieżkę {name} z tego fragmentu do DAW lub folderu", "settings.exportsLocation.title": "Folder eksportu", "settings.exportsLocation.desc": "Tu trafia dźwięk, gdy przeciągasz ścieżkę lub pętlę poza StemDeck. Nic nie jest tu usuwane automatycznie, ponieważ projekt odwołujący się do przeciągniętego pliku potrzebuje go w tym miejscu.", + "settings.stemFormat.title": "Format przechowywania ścieżek", + "settings.stemFormat.desc": "Jak są zapisywane ścieżki utworów dodanych od teraz. Nowe utwory są zapisywane jako MP3, chyba że wybierzesz WAV. Utwory, które już masz, zachowują swój format. MP3 zajmuje około jednej dziesiątej miejsca, ale jest stratny; WAV jest bezstratny.", + "settings.stemFormat.mp3": "MP3 (mniejszy)", + "settings.stemFormat.wav": "WAV (bezstratny)", + "settings.stemBitrate.title": "Przepływność MP3", + "settings.stemBitrate.desc": "Wyższa brzmi bliżej oryginału i zajmuje więcej miejsca. Dotyczy utworów dodanych od teraz.", + "settings.stemBitrate.option": "{kbps} kb/s", "settings.exportsLocation.change": "Zmień…", "settings.exportsLocation.pickerFailed": "Nie udało się otworzyć okna wyboru folderu.", "settings.stemsLocation.resetting": "Resetowanie…", @@ -4457,6 +4477,8 @@ const ja = { "help.set.cookies": "YouTube にボットでないことの確認を求められたときだけ必要です。", "help.set.stemsLocation": "分離した曲の保存場所です。", "help.set.exportsLocation": "StemDeck からドラッグしたステムやループの保存先です。", + "help.set.stemFormat": "新しい曲のステムを、小さいが非可逆のMP3で保存するか、可逆のWAVで保存するかを選びます。ライブラリにある曲は今の形式のままです。", + "help.set.stemBitrate": "新しい曲のMP3の音質です(128、192、256 kbps)。高いほど元の音に近くなり、容量も増えます。", "help.set.autoDelete": "オンにしない限りオフです。指定した日数が過ぎた曲を削除します。", "help.set.device": "分離に使うグラフィックカードまたはプロセッサーです。", "help.set.quality": "Best はステムがよりきれいになり、時間が 2 倍かかります。", @@ -4784,6 +4806,7 @@ const ja = { "settings.group.separation": "分離", "settings.group.imports": "インポート", "settings.group.storage": "保存場所", + "settings.group.stemFiles": "ステムのファイル", "settings.group.cleanup": "自動削除", "settings.group.audio": "オーディオ", "settings.group.video": "ビデオ", @@ -4846,6 +4869,13 @@ const ja = { "loop.dragOutStem": "この区間の{name}をDAWやフォルダーにドラッグ", "settings.exportsLocation.title": "書き出し先フォルダー", "settings.exportsLocation.desc": "StemDeckからステムやループをドラッグしたときの保存先です。ドラッグしたファイルを参照するプロジェクトのため、ここのファイルが自動で削除されることはありません。", + "settings.stemFormat.title": "ステムの保存形式", + "settings.stemFormat.desc": "これから追加する曲のステムの保存方法です。WAVを選ばない限り、新しい曲はMP3で保存されます。すでにある曲は今の形式のままです。MP3は容量が約10分の1になりますが非可逆です。WAVは可逆です。", + "settings.stemFormat.mp3": "MP3(小さい)", + "settings.stemFormat.wav": "WAV(可逆)", + "settings.stemBitrate.title": "MP3のビットレート", + "settings.stemBitrate.desc": "高いほど元の音に近くなり、容量も増えます。これから追加する曲に適用されます。", + "settings.stemBitrate.option": "{kbps} kbps", "settings.exportsLocation.change": "変更…", "settings.exportsLocation.pickerFailed": "フォルダー選択画面を開けませんでした。", "settings.stemsLocation.resetting": "リセット中…", @@ -6188,6 +6218,8 @@ const zhHans = { "help.set.cookies": "只有在 YouTube 要求你证明自己不是机器人时才需要。", "help.set.stemsLocation": "分离后歌曲的存放位置。", "help.set.exportsLocation": "从 StemDeck 拖出的音轨和循环的存放位置。", + "help.set.stemFormat": "选择新歌曲的分轨以 MP3(更小但有损)还是 WAV(无损)保存。资料库中已有的歌曲保持原格式。", + "help.set.stemBitrate": "新歌曲的 MP3 音质:128、192 或 256 kbps。越高越接近原声,占用空间也越多。", "help.set.autoDelete": "默认关闭。开启后会在你设定的天数后删除歌曲。", "help.set.device": "用于分离的显卡或处理器。", "help.set.quality": "Best 的分离更干净,耗时是两倍。", @@ -6515,6 +6547,7 @@ const zhHans = { "settings.group.separation": "分离", "settings.group.imports": "导入", "settings.group.storage": "存储", + "settings.group.stemFiles": "分轨文件", "settings.group.cleanup": "自动清理", "settings.group.audio": "音频", "settings.group.video": "视频", @@ -6577,6 +6610,13 @@ const zhHans = { "loop.dragOutStem": "将此区域的{name}拖入 DAW 或文件夹", "settings.exportsLocation.title": "导出文件夹", "settings.exportsLocation.desc": "从 StemDeck 拖出分轨或循环段时音频的存放位置。这里的文件不会被自动删除,因为引用已拖出文件的工程需要它们保留在原处。", + "settings.stemFormat.title": "分轨存储格式", + "settings.stemFormat.desc": "此后添加的歌曲的分轨如何存储。除非选择 WAV,新歌曲都以 MP3 存储。已有的歌曲保持原格式。MP3 只占约十分之一的空间,但有损;WAV 无损。", + "settings.stemFormat.mp3": "MP3(更小)", + "settings.stemFormat.wav": "WAV(无损)", + "settings.stemBitrate.title": "MP3 码率", + "settings.stemBitrate.desc": "越高越接近原声,占用空间也越多。适用于此后添加的歌曲。", + "settings.stemBitrate.option": "{kbps} kbps", "settings.exportsLocation.change": "更改…", "settings.exportsLocation.pickerFailed": "无法打开文件夹选择器。", "settings.stemsLocation.resetting": "正在重置…", @@ -7919,6 +7959,8 @@ const de = { "help.set.cookies": "Nur nötig, wenn YouTube verlangt, dass du beweist, kein Bot zu sein.", "help.set.stemsLocation": "Wo deine zerlegten Songs gespeichert werden.", "help.set.exportsLocation": "Wohin Stems und Loops gehen, die du aus StemDeck herausziehst.", + "help.set.stemFormat": "Legt fest, ob die Spuren neuer Songs als MP3 gespeichert werden, kleiner, aber verlustbehaftet, oder als verlustfreies WAV. Songs in der Bibliothek behalten ihr Format.", + "help.set.stemBitrate": "Die MP3-Qualität neuer Songs: 128, 192 oder 256 kbit/s. Höher klingt näher am Original und braucht mehr Platz.", "help.set.autoDelete": "Aus, solange du es nicht einschaltest. Löscht Songs nach der Anzahl Tage, die du festlegst.", "help.set.device": "Deine Grafikkarte oder dein Prozessor, für das Zerlegen.", "help.set.quality": "Best liefert sauberere Stems und dauert doppelt so lange.", @@ -8247,6 +8289,7 @@ const de = { "settings.group.separation": "Trennung", "settings.group.imports": "Importe", "settings.group.storage": "Speicherort", + "settings.group.stemFiles": "Spurdateien", "settings.group.cleanup": "Automatisches Aufräumen", "settings.group.audio": "Audio", "settings.group.video": "Video", @@ -8309,6 +8352,13 @@ const de = { "loop.dragOutStem": "{name} aus diesem Bereich in eine DAW oder einen Ordner ziehen", "settings.exportsLocation.title": "Export-Ordner", "settings.exportsLocation.desc": "Hierhin kommt Audio, wenn du eine Spur oder eine Schleife aus StemDeck ziehst. Hier wird nichts automatisch gelöscht, denn ein Projekt, das eine gezogene Datei referenziert, braucht sie an Ort und Stelle.", + "settings.stemFormat.title": "Speicherformat der Spuren", + "settings.stemFormat.desc": "Wie die Spuren von Songs gespeichert werden, die du ab jetzt hinzufügst. Neue Songs werden als MP3 gespeichert, außer du wählst WAV. Songs, die du schon hast, behalten ihr Format. MP3 braucht etwa ein Zehntel des Platzes, ist aber verlustbehaftet; WAV ist verlustfrei.", + "settings.stemFormat.mp3": "MP3 (kleiner)", + "settings.stemFormat.wav": "WAV (verlustfrei)", + "settings.stemBitrate.title": "MP3-Bitrate", + "settings.stemBitrate.desc": "Höher klingt näher am Original und braucht mehr Platz. Gilt für Songs, die du ab jetzt hinzufügst.", + "settings.stemBitrate.option": "{kbps} kbit/s", "settings.exportsLocation.change": "Ändern…", "settings.exportsLocation.pickerFailed": "Die Ordnerauswahl konnte nicht geöffnet werden.", "settings.stemsLocation.resetting": "Wird zurückgesetzt…", @@ -9661,6 +9711,8 @@ const pt = { "help.set.cookies": "Só é preciso se o YouTube pedir para você provar que não é um robô.", "help.set.stemsLocation": "Onde suas músicas separadas ficam guardadas.", "help.set.exportsLocation": "Para onde vão as faixas e loops que você arrasta para fora do StemDeck.", + "help.set.stemFormat": "Escolhe se os stems das músicas novas são guardados em MP3, menor mas com perdas, ou em WAV, sem perdas. As músicas que já estão na biblioteca mantêm o formato.", + "help.set.stemBitrate": "A qualidade do MP3 das músicas novas: 128, 192 ou 256 kbps. Quanto mais alta, mais próxima do original e mais espaço ocupa.", "help.set.autoDelete": "Desligado a menos que você ligue. Apaga músicas depois do número de dias que você escolher.", "help.set.device": "Sua placa de vídeo ou seu processador, para separar.", "help.set.quality": "Best dá faixas mais limpas e leva o dobro do tempo.", @@ -9989,6 +10041,7 @@ const pt = { "settings.group.separation": "Separação", "settings.group.imports": "Importações", "settings.group.storage": "Armazenamento", + "settings.group.stemFiles": "Arquivos dos stems", "settings.group.cleanup": "Limpeza automática", "settings.group.audio": "Áudio", "settings.group.video": "Vídeo", @@ -10051,6 +10104,13 @@ const pt = { "loop.dragOutStem": "Arraste {name} deste trecho para uma DAW ou pasta", "settings.exportsLocation.title": "Pasta de exportação", "settings.exportsLocation.desc": "Onde o áudio vai quando você arrasta um stem ou um loop para fora do StemDeck. Nada aqui é apagado automaticamente, porque um projeto que referencia um arquivo arrastado precisa que ele continue no lugar.", + "settings.stemFormat.title": "Formato de armazenamento dos stems", + "settings.stemFormat.desc": "Como são guardados os stems das músicas adicionadas a partir de agora. Músicas novas são guardadas em MP3, a menos que escolha WAV. As músicas que já tem mantêm o formato. O MP3 ocupa cerca de um décimo do espaço, mas tem perdas; o WAV não tem perdas.", + "settings.stemFormat.mp3": "MP3 (menor)", + "settings.stemFormat.wav": "WAV (sem perdas)", + "settings.stemBitrate.title": "Taxa de bits do MP3", + "settings.stemBitrate.desc": "Quanto mais alta, mais próxima do original e mais espaço ocupa. Vale para as músicas adicionadas a partir de agora.", + "settings.stemBitrate.option": "{kbps} kbps", "settings.exportsLocation.change": "Alterar…", "settings.exportsLocation.pickerFailed": "Não foi possível abrir o seletor de pastas.", "settings.stemsLocation.resetting": "Redefinindo…", @@ -11405,6 +11465,8 @@ const id = { "help.set.cookies": "Hanya diperlukan jika YouTube meminta Anda membuktikan bahwa Anda bukan bot.", "help.set.stemsLocation": "Tempat lagu yang sudah dipisahkan disimpan.", "help.set.exportsLocation": "Tujuan stem dan loop yang Anda seret keluar dari StemDeck.", + "help.set.stemFormat": "Memilih apakah stem lagu baru disimpan sebagai MP3, lebih kecil tetapi lossy, atau WAV yang lossless. Lagu yang sudah ada di pustaka tetap dengan formatnya.", + "help.set.stemBitrate": "Kualitas MP3 untuk lagu baru: 128, 192, atau 256 kbps. Makin tinggi makin mendekati aslinya dan makin banyak ruang yang dipakai.", "help.set.autoDelete": "Mati kecuali Anda menyalakannya. Menghapus lagu setelah jumlah hari yang Anda tentukan.", "help.set.device": "Kartu grafis atau prosesor Anda, untuk memisahkan.", "help.set.quality": "Best memberi stem yang lebih bersih dan butuh waktu dua kali lipat.", @@ -11732,6 +11794,7 @@ const id = { "settings.group.separation": "Pemisahan", "settings.group.imports": "Impor", "settings.group.storage": "Penyimpanan", + "settings.group.stemFiles": "Berkas stem", "settings.group.cleanup": "Pembersihan otomatis", "settings.group.audio": "Audio", "settings.group.video": "Video", @@ -11794,6 +11857,13 @@ const id = { "loop.dragOutStem": "Seret {name} dari bagian ini ke DAW atau folder", "settings.exportsLocation.title": "Folder ekspor", "settings.exportsLocation.desc": "Tempat audio disimpan saat Anda menyeret stem atau loop keluar dari StemDeck. Tidak ada yang dihapus otomatis di sini, karena proyek yang merujuk berkas yang diseret membutuhkannya tetap di tempatnya.", + "settings.stemFormat.title": "Format penyimpanan stem", + "settings.stemFormat.desc": "Cara stem lagu yang Anda tambahkan mulai sekarang disimpan. Lagu baru disimpan sebagai MP3 kecuali Anda memilih WAV. Lagu yang sudah ada tetap dengan formatnya. MP3 memakan sekitar sepersepuluh ruang tetapi lossy; WAV lossless.", + "settings.stemFormat.mp3": "MP3 (lebih kecil)", + "settings.stemFormat.wav": "WAV (lossless)", + "settings.stemBitrate.title": "Bitrate MP3", + "settings.stemBitrate.desc": "Makin tinggi makin mendekati aslinya dan makin banyak ruang yang dipakai. Berlaku untuk lagu yang ditambahkan mulai sekarang.", + "settings.stemBitrate.option": "{kbps} kbps", "settings.exportsLocation.change": "Ubah…", "settings.exportsLocation.pickerFailed": "Pemilih folder tidak dapat dibuka.", "settings.stemsLocation.resetting": "Mengatur ulang…", @@ -13136,6 +13206,8 @@ const fr = { "help.set.cookies": "Utile seulement si YouTube vous demande de prouver que vous n'êtes pas un robot.", "help.set.stemsLocation": "Où sont stockés vos morceaux séparés.", "help.set.exportsLocation": "Où vont les pistes et boucles que vous faites glisser hors de StemDeck.", + "help.set.stemFormat": "Choisit si les pistes des nouveaux morceaux sont stockées en MP3, plus léger mais avec perte, ou en WAV, sans perte. Les morceaux déjà dans la bibliothèque gardent leur format.", + "help.set.stemBitrate": "La qualité MP3 des nouveaux morceaux : 128, 192 ou 256 kbit/s. Plus elle est élevée, plus le son est proche de l'original et plus il prend de place.", "help.set.autoDelete": "Désactivé sauf si vous l'activez. Supprime les morceaux après le nombre de jours choisi.", "help.set.device": "Votre carte graphique ou votre processeur, pour la séparation.", "help.set.quality": "Best donne des pistes plus propres et prend deux fois plus de temps.", @@ -13465,6 +13537,7 @@ const fr = { "settings.group.separation": "Séparation", "settings.group.imports": "Imports", "settings.group.storage": "Stockage", + "settings.group.stemFiles": "Fichiers des pistes", "settings.group.cleanup": "Nettoyage automatique", "settings.group.audio": "Audio", "settings.group.video": "Vidéo", @@ -13527,6 +13600,13 @@ const fr = { "loop.dragOutStem": "Glissez {name} de cette sélection vers une STAN ou un dossier", "settings.exportsLocation.title": "Dossier d'export", "settings.exportsLocation.desc": "Là où va l'audio quand vous faites glisser une piste ou une boucle hors de StemDeck. Rien n'y est supprimé automatiquement, car un projet qui référence un fichier glissé a besoin qu'il reste en place.", + "settings.stemFormat.title": "Format de stockage des pistes", + "settings.stemFormat.desc": "Comment sont stockées les pistes des morceaux ajoutés à partir de maintenant. Les nouveaux morceaux sont stockés en MP3, sauf si vous choisissez WAV. Les morceaux que vous avez déjà gardent leur format. Le MP3 prend environ un dixième de la place, mais avec perte ; le WAV est sans perte.", + "settings.stemFormat.mp3": "MP3 (plus léger)", + "settings.stemFormat.wav": "WAV (sans perte)", + "settings.stemBitrate.title": "Débit MP3", + "settings.stemBitrate.desc": "Plus il est élevé, plus le son est proche de l'original et plus il prend de place. S'applique aux morceaux ajoutés à partir de maintenant.", + "settings.stemBitrate.option": "{kbps} kbit/s", "settings.exportsLocation.change": "Modifier…", "settings.exportsLocation.pickerFailed": "Impossible d'ouvrir le sélecteur de dossier.", "settings.stemsLocation.resetting": "Réinitialisation…", @@ -14559,6 +14639,7 @@ const ptPT = { "help.donateQr": "Leia com a câmara do telemóvel para abrir a página do StemDeck no GitHub.", "help.donateCopy": "Copia o endereço da página do StemDeck no GitHub, para o colar e enviar a um amigo.", "help.donateShare": "Abre o menu de partilha do sistema com o endereço da página do StemDeck no GitHub.", + "settings.group.stemFiles": "Ficheiros dos stems", }; const es = { @@ -15279,6 +15360,8 @@ const es = { "help.set.cookies": "Solo hace falta si YouTube te pide demostrar que no eres un bot.", "help.set.stemsLocation": "Dónde se guardan tus canciones separadas.", "help.set.exportsLocation": "Adónde van las pistas y bucles que arrastras fuera de StemDeck.", + "help.set.stemFormat": "Elige si las pistas de las canciones nuevas se guardan en MP3, más pequeño pero con pérdida, o en WAV, sin pérdida. Las canciones que ya están en la biblioteca conservan su formato.", + "help.set.stemBitrate": "La calidad del MP3 de las canciones nuevas: 128, 192 o 256 kbps. Cuanto más alta, más se parece al original y más espacio ocupa.", "help.set.autoDelete": "Desactivado salvo que lo actives. Borra canciones tras el número de días que elijas.", "help.set.device": "Tu tarjeta gráfica o tu procesador, para separar.", "help.set.quality": "Best da pistas más limpias y tarda el doble.", @@ -15608,6 +15691,7 @@ const es = { "settings.group.separation": "Separación", "settings.group.imports": "Importaciones", "settings.group.storage": "Almacenamiento", + "settings.group.stemFiles": "Archivos de las pistas", "settings.group.cleanup": "Limpieza automática", "settings.group.audio": "Audio", "settings.group.video": "Vídeo", @@ -15670,6 +15754,13 @@ const es = { "loop.dragOutStem": "Arrastra {name} de esta región a una DAW o a una carpeta", "settings.exportsLocation.title": "Carpeta de exportación", "settings.exportsLocation.desc": "Donde va el audio cuando arrastras una pista o un bucle fuera de StemDeck. Aquí no se borra nada automáticamente, porque un proyecto que referencia un archivo arrastrado necesita que siga en su sitio.", + "settings.stemFormat.title": "Formato de almacenamiento de las pistas", + "settings.stemFormat.desc": "Cómo se guardan las pistas de las canciones que añadas a partir de ahora. Las canciones nuevas se guardan en MP3 salvo que elijas WAV. Las canciones que ya tienes conservan su formato. El MP3 ocupa alrededor de una décima parte del espacio, pero tiene pérdida; el WAV no tiene pérdida.", + "settings.stemFormat.mp3": "MP3 (más pequeño)", + "settings.stemFormat.wav": "WAV (sin pérdida)", + "settings.stemBitrate.title": "Tasa de bits del MP3", + "settings.stemBitrate.desc": "Cuanto más alta, más se parece al original y más espacio ocupa. Se aplica a las canciones añadidas a partir de ahora.", + "settings.stemBitrate.option": "{kbps} kbps", "settings.exportsLocation.change": "Cambiar…", "settings.exportsLocation.pickerFailed": "No se pudo abrir el selector de carpetas.", "settings.stemsLocation.resetting": "Restableciendo…", @@ -17044,6 +17135,8 @@ const ko = { "help.set.cookies": "YouTube가 봇이 아님을 확인해 달라고 할 때만 필요해요.", "help.set.stemsLocation": "분리한 곡을 저장하는 곳이에요.", "help.set.exportsLocation": "StemDeck 밖으로 끌어낸 스템과 루프가 저장되는 곳이에요.", + "help.set.stemFormat": "새 곡의 스템을 작지만 손실 압축인 MP3로 저장할지, 무손실인 WAV로 저장할지 고릅니다. 라이브러리에 이미 있는 곡은 지금 형식을 유지합니다.", + "help.set.stemBitrate": "새 곡의 MP3 음질입니다: 128, 192 또는 256 kbps. 높을수록 원음에 가깝고 용량을 더 차지합니다.", "help.set.autoDelete": "직접 켜기 전에는 꺼져 있어요. 정한 날짜가 지나면 곡을 삭제해요.", "help.set.device": "분리에 쓰는 그래픽 카드나 프로세서예요.", "help.set.quality": "Best는 스템이 더 깨끗하고 시간이 두 배 걸려요.", @@ -17371,6 +17464,7 @@ const ko = { "settings.group.separation": "분리", "settings.group.imports": "가져오기", "settings.group.storage": "저장 공간", + "settings.group.stemFiles": "스템 파일", "settings.group.cleanup": "자동 정리", "settings.group.audio": "오디오", "settings.group.video": "비디오", @@ -17433,6 +17527,13 @@ const ko = { "loop.dragOutStem": "이 구간의 {name}을(를) DAW나 폴더로 끌어다 놓기", "settings.exportsLocation.title": "내보내기 폴더", "settings.exportsLocation.desc": "StemDeck에서 스템이나 루프를 끌어낼 때 오디오가 저장되는 곳입니다. 끌어다 놓은 파일을 참조하는 프로젝트가 그 파일을 그대로 필요로 하므로, 여기서는 아무것도 자동으로 삭제되지 않습니다.", + "settings.stemFormat.title": "스템 저장 형식", + "settings.stemFormat.desc": "앞으로 추가하는 곡의 스템을 저장하는 방식입니다. WAV를 고르지 않으면 새 곡은 MP3로 저장됩니다. 이미 있는 곡은 지금 형식을 유지합니다. MP3는 용량이 약 10분의 1이지만 손실 압축이고, WAV는 무손실입니다.", + "settings.stemFormat.mp3": "MP3 (더 작음)", + "settings.stemFormat.wav": "WAV (무손실)", + "settings.stemBitrate.title": "MP3 비트레이트", + "settings.stemBitrate.desc": "높을수록 원음에 가깝고 용량을 더 차지합니다. 앞으로 추가하는 곡에 적용됩니다.", + "settings.stemBitrate.option": "{kbps} kbps", "settings.exportsLocation.change": "변경…", "settings.exportsLocation.pickerFailed": "폴더 선택창을 열 수 없습니다.", "settings.stemsLocation.resetting": "되돌리는 중…", diff --git a/static/js/mixer.js b/static/js/mixer.js index 8a65b01f..13fcbef6 100644 --- a/static/js/mixer.js +++ b/static/js/mixer.js @@ -20,6 +20,7 @@ import { import { storeGet, storeSetDebounced, storeFlushPending } from "./utils.js"; import { getMode } from "./mode.js"; import { t } from "./i18n.js"; +import { stemFileExt } from "./playbackStems.js"; import { PITCH_MAX, PITCH_MIN, clampPitch, pitchBlockedKey } from "./pitchBus.js"; function defaultMixerEntry() { @@ -733,7 +734,7 @@ export function renderMixerRow(stem) { const dl = document.createElement("a"); dl.className = "lane-dl mx-btn"; dl.href = stem.url; - dl.download = `${stem.name}.wav`; + dl.download = `${stem.name}.${stemFileExt(stem.url)}`; dl.title = t("aria.download", { name: display }); dl.appendChild(downloadIcon()); diff --git a/static/js/playbackStems.js b/static/js/playbackStems.js index 05ea162c..87c65a64 100644 --- a/static/js/playbackStems.js +++ b/static/js/playbackStems.js @@ -101,3 +101,29 @@ export function addVisualOnlyStems(playbackStems, visibleStems) { .map((stem) => ({ ...stem, controlName: stem.name, pitched: false, visualOnly: true })), ]; } + +/** + * Whether any of these stems is stored compressed (MP3, #783), by the + * extension its URL ends in. The chunked engine streams PCM straight out of + * a WAV by Range request and cannot read an MP3, so a song stored as MP3 + * opens on the full-decode engine instead, whose decodeAudioData honours the + * MP3's encoder delay and padding and lines the stems up to the sample (the + * server checks the same before it keeps an MP3). + * + * @param {{url?:string}[]} stems + */ +export function hasCompressedStems(stems) { + return (stems || []).some((stem) => stemFileExt(stem?.url) === "mp3"); +} + +/** + * The file extension a stem downloads with: "mp3" for a stem stored as MP3, + * else "wav". The desktop app saves a lane download under the name the page + * gives it, so the name has to say what the bytes are. + * + * @param {string|undefined} url + */ +export function stemFileExt(url) { + const path = String(url || "").split(/[?#]/)[0]; + return /\.mp3$/i.test(path) ? "mp3" : "wav"; +} diff --git a/static/js/player.js b/static/js/player.js index eca70315..08cc7ce0 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -41,7 +41,7 @@ import { createAudioEngine, estimateDecodedBytes } from "./audioEngine.js"; import { createChunkedAudioEngine } from "./chunkedAudioEngine.js"; import { createPlaybackContext } from "./audioContext.js"; import { effectivePitch } from "./pitchBus.js"; -import { addVisualOnlyStems, buildPlaybackStems, sumPeaks } from "./playbackStems.js"; +import { addVisualOnlyStems, buildPlaybackStems, hasCompressedStems, stemFileExt, sumPeaks } from "./playbackStems.js"; import { layoutLanes, originalSource, jobOriginalSet, layoutSignature, baseLanes } from "./partChoice.js"; import { applyAllLaneEq, exportFxParam } from "./eqEngine.js"; import { exportMasterParam, setMixSource, resetForSong as resetMasterForSong } from "./masterEngine.js"; @@ -1520,7 +1520,7 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti // A recording track with no take yet has nothing to download. if (dl && stem.url) { dl.href = stem.url; - dl.download = _stemFilename(stem.name); + dl.download = _stemFilename(stem.name, stemFileExt(stem.url)); } } @@ -1556,7 +1556,11 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti // the 6-connection HTTP/1.1 limit and `canplay` stalls (the WebView2/WKWebView // freeze). Null URLs make the multitrack's