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
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
Comment thread
byrongamatos marked this conversation as resolved.
- **The master mix is a channel strip in the mixer.** Every audio source now has
a vertical strip in the mixer drawer — the master mix leads the audio band
(matching the DAW console), followed by the stems, then the MIDI parts and the
SOURCE/GUIDE/CLICK/MASTER buses. Its fader, mute, and solo are real: the active
source's reference playback now routes through its own per-source gain before
the SOURCE submix, so riding the master strip actually changes its level. The
Tracks pane still lists the master as a selectable source row (click it to
chart against the full mix), but its strip **controls** — fader, mute, solo —
live only in the mixer, not the pane.
- **Click a track to chart against it.** Selecting an audio track (the master
mix or any stem) in the Tracks column now makes it the **active source**:
the main waveform shows that track and the onset tools (Suggest, snapping)
analyze it — so you can line the grid up against an isolated stem. Playback
is unaffected: every source keeps playing together; only what you *see* and
*analyze* follows the click.

### Fixed
- **Audio stem lanes now draw their waveforms.** The per-stem waveform builder
was handed the decoded `AudioBuffer` instead of its channel `Float32Array`, so
every sample read `undefined` and the peaks collapsed to ±Infinity — the lane
painted off-canvas and looked empty. It now reads `getChannelData(0)` at the
master's ~3 ms/bin resolution, so each stem lane shows its shape like the mix.
- **Parity pass against the DAW track-session design.** The backend now sends
`audio_sources` with the master's pack-authored name (from the manifest
`full` mix) and per-stem display names, and the stem cache filename carries
a per-source index so two stem ids that sanitize alike can't overwrite each
other's audio. Deleting a transcription track from the Tracks context menu
now finishes its cleanup (its `editorRemoveArrangement` reports success
again); clicking an audio LANE on the canvas focuses that source like its
header row does; cycling the tempo guide respects the 🔒 lock and resets a
new guide to plain audio analysis; the header-strip fader regained its
+6 dB range; a removed audio track drops its stale mixer state.
- **Tempo guide + Tracks column reliability.** The Master row and the tempo
guide could vanish (guide read "No guide") because audio sources were
derived from `S.audioUrl`, which active-source switching reassigns to a
focused stem and which isn't set yet at load — the master now rides the
stable `S.masterAudioUrl`. The master track defaults to the **song name**
(not the generic "Master Mix"), and the guide label follows a track's
inline rename. The mixer channel strips now **reorder to match a drag in
the Tracks column** (and rename with it).

### Changed

- **The mixer is now a proper DAW console.** The docked side panel is
Expand Down
51 changes: 47 additions & 4 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,18 @@ def _parse_track_session(data: dict):
return _coerce_track_session(data.get("track_session"), invalid=_FIELD_ABSENT)


def _editor_stem_cache_basename(audio_id, index, sid):
"""Cached-stem filename stem that stays unique per source.

Distinct stem ids can sanitize to the same string (``drums/kit`` and
``drums:kit`` both become ``drums_kit``), so the sanitized id alone would
let one copied stem overwrite another and every URL would then play the
last-copied file. The per-source ``index`` keeps the paths distinct.
"""
safe = re.sub(r"[^a-zA-Z0-9_-]", "_", str(sid))
return f"editor_stem_{audio_id}_{index}_{safe}"


def _apply_track_session(manifest: dict, session) -> None:
"""Write/remove the `editor_track_session` manifest extension key."""
if session is _FIELD_ABSENT:
Expand Down Expand Up @@ -4123,23 +4135,36 @@ def _safe_stem_path(stem_entry: dict) -> "Path | None":
# mixer can load and balance them live. Only for genuine
# multi-stem sloppaks — a single-`full` sloppak has nothing to mix.
_stem_urls = []
for _s in loaded.stems:
for _index, _s in enumerate(loaded.stems):
_sid = (_s.get("id") or "").strip()
if not _sid or _sid == "full":
continue
_sp = _safe_stem_path(_s)
if _sp is None or not _sp.exists():
continue
_sext = _sp.suffix or ".ogg"
_safe_sid = re.sub(r"[^a-zA-Z0-9_-]", "_", _sid)
_sdest = STORAGE_DIR / f"editor_stem_{audio_id}_{_safe_sid}{_sext}"
# Per-source index keeps the cached filename unique even when two
# distinct ids sanitize to the same string ('drums/kit' vs
# 'drums:kit') — otherwise one stem overwrites another and every
# URL plays the last-copied file.
_base = _editor_stem_cache_basename(audio_id, _index, _sid)
_sdest = STORAGE_DIR / f"{_base}{_sext}"
try:
shutil.copy2(_sp, _sdest)
except OSError:
continue
try:
_source_offset = float(_s.get("offset", 0) or 0)
except (TypeError, ValueError):
_source_offset = 0.0
if not math.isfinite(_source_offset):
_source_offset = 0.0
_stem_urls.append({
"id": _sid,
"url": f"{STORAGE_URL}/editor_stem_{audio_id}_{_safe_sid}{_sext}",
"name": (str(_s.get("name")).strip()[:160]
if isinstance(_s.get("name"), str) and _s.get("name").strip() else _sid),
"url": f"{STORAGE_URL}/{_base}{_sext}",
"offset": _source_offset,
})

result = _song_to_dict(song, audio_url)
Expand All @@ -4163,6 +4188,24 @@ def _safe_stem_path(stem_entry: dict) -> "Path | None":
loaded.manifest.get("editor_track_session"), invalid=None)
if _tree:
result["track_session"] = _tree
# Editor audio sources (master + stems) with display names. The
# master's name comes from the manifest's `full` mix entry when the
# pack authored one; absent that, the frontend falls back to the
# song name. The Tracks column, the tempo-guide label, and the mixer
# strips all read these names. (Ported from the DAW track-session
# backend; ids stay the bare manifest ids this editor already uses.)
_master_manifest = next((s for s in loaded.stems
if isinstance(s, dict) and str(s.get("id") or "") == "full"), {})
_master_name = _master_manifest.get("name")
_master_name = (_master_name.strip()[:160]
if isinstance(_master_name, str) and _master_name.strip() else "")
_audio_sources = [{"id": "master", "name": _master_name,
"kind": "master", "url": audio_url or ""}]
for _su in _stem_urls:
_audio_sources.append({"id": _su["id"], "name": _su.get("name") or _su["id"],
"kind": "stem", "url": _su.get("url") or "",
"offset": _su.get("offset", 0)})
Comment thread
byrongamatos marked this conversation as resolved.
result["audio_sources"] = _audio_sources
# `lib/sloppak.load_song()` doesn't restore song.offset (the
# sloppak format doesn't carry an explicit offset field today),
# so song.offset is 0 here. If the manifest happens to surface
Expand Down
10 changes: 5 additions & 5 deletions src/arrangement.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,12 @@ export async function editorRenameArrangement() {
export async function editorRemoveArrangement() {
if (_recState !== 'idle') {
setStatus('Cannot remove an arrangement while recording. Stop the take first.');
return;
return false;
}
if (S.arrangements.length <= 1) return;
if (S.arrangements.length <= 1) return false;
const removeIdx = S.currentArr;
const arr = S.arrangements[removeIdx];
if (!confirm(`Remove "${arr.name}" arrangement?`)) return;
if (!confirm(`Remove "${arr.name}" arrangement?`)) return false;

// Remove from backend first
if (S.sessionId) {
Expand All @@ -179,11 +179,11 @@ export async function editorRemoveArrangement() {
const result = await resp.json();
if (result.error) {
setStatus('Remove failed: ' + result.error);
return;
return false;
}
} catch (e) {
setStatus('Remove failed: ' + e.message);
return;
return false;
}
}

Expand Down
Loading
Loading