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
17 changes: 17 additions & 0 deletions data/vst_packs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"mac": {
"url": "https://github.com/got-feedBack/feedBack/releases/download/vst-mac-v1/vst-mac-pack-v1.zip",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"bytes": 0
},
"win": {
"url": "https://github.com/got-feedBack/feedBack/releases/download/vst-win-v1/vst-win-pack-v1.zip",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"bytes": 0
},
"linux": {
"url": "https://github.com/got-feedBack/feedBack/releases/download/vst-linux-v1/vst-linux-pack-v1.zip",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"bytes": 0
}
}
173 changes: 166 additions & 7 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,121 @@ def _find_gear_photo(rs_gear: str) -> Path | None:
# take `_lock` (via _get_master_preset_id) — see _get_conn.
_conn_init_lock = threading.Lock()

# ── Opt-in VST pack download (per-platform sliced) ──────────────────────────
# The fat VST3 bundles (~650 MB) are no longer shipped in the desktop build;
# they download on demand, sliced to the current platform, from a versioned
# release. Written to a WRITABLE config-dir root (the bundled plugin dir is
# read-only in packaged builds), which the loader also searches.
import hashlib # noqa: E402 — kept beside the download code that uses it
import tempfile # noqa: E402
import zipfile # noqa: E402

_VST_DOWNLOAD_CHUNK = 1024 * 256
_vst_pack_state: dict = {"status": "idle", "done": 0, "total": 0, "error": None}
_vst_pack_lock = threading.Lock()


def _current_vst_platform() -> str:
if sys.platform.startswith("darwin"):
return "mac"
if sys.platform.startswith("win"):
return "win"
return "linux"


def _downloaded_vst_root() -> Path:
"""Writable root where on-demand VST packs are extracted (works in packaged
read-only builds, unlike the bundled ``_plugin_dir/vst``)."""
return _config_dir / "nam_rig_builder" / "vst"


def _vst_search_roots() -> list[Path]:
"""VST roots to search: bundled (if present) + the downloaded one. The
downloaded root only exists once setup() has bound _config_dir."""
roots = [_plugin_dir / "vst"]
if _config_dir is not None:
roots.append(_downloaded_vst_root())
return [p for p in roots if p.exists()]


def _vst_pack_manifest() -> dict:
"""Parsed data/vst_packs.json ({platform: {url, sha256, bytes}}), or {}."""
try:
return json.loads((_plugin_dir / "data" / "vst_packs.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}


def _vst_installed() -> bool:
"""True once at least one .vst3 is present in any search root."""
for root in _vst_search_roots():
for d in (root, *(c for c in root.iterdir() if c.is_dir())):
if d.name == "src":
continue
if any(d.glob("*.vst3")):
return True
return False


def _safe_extract_tree(zf: zipfile.ZipFile, dest: Path) -> None:
"""Extract a nested zip under dest with a zip-slip guard (VST packs are
directory trees, unlike career's flat venue packs)."""
dest = dest.resolve()
for info in zf.infolist():
if info.is_dir():
continue
target = (dest / info.filename).resolve()
if dest != target and dest not in target.parents:
raise ValueError(f"unsafe path in pack: {info.filename!r}")
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, open(target, "wb") as out:
shutil.copyfileobj(src, out)


def _download_vst_pack(pack: dict, state: dict) -> None:
"""Worker: stream → sha256 verify → nested extract → merge into the
writable VST root. Mirrors career._download_pack, but the tree is nested."""
dest_root = _downloaded_vst_root()
dest_root.mkdir(parents=True, exist_ok=True)
staging = Path(tempfile.mkdtemp(prefix="rb-vst-", dir=str(dest_root.parent)))
zip_path = staging / "pack.zip"
try:
digest = hashlib.sha256()
req = urllib.request.Request(pack["url"], headers={"User-Agent": "feedBack-rig-builder"})
with urllib.request.urlopen(req, timeout=60) as resp, open(zip_path, "wb") as out:
state["total"] = int(resp.headers.get("Content-Length") or pack.get("bytes") or 0)
while True:
chunk = resp.read(_VST_DOWNLOAD_CHUNK)
if not chunk:
break
digest.update(chunk)
out.write(chunk)
state["done"] += len(chunk)
if digest.hexdigest() != pack["sha256"]:
raise ValueError("sha256 mismatch — corrupt or tampered download")
extract = staging / "vst"
extract.mkdir()
with zipfile.ZipFile(zip_path) as zf:
_safe_extract_tree(zf, extract)
zip_path.unlink()
# Merge into the live root (keep anything already there, e.g. vst/src in
# a dev checkout); overwrite bundle files with the freshly downloaded set.
for item in extract.iterdir():
dst = dest_root / item.name
if item.is_dir():
shutil.copytree(item, dst, dirs_exist_ok=True)
else:
shutil.copy2(item, dst)
state["status"] = "done"
log.info("rig_builder: VST pack installed into %s", dest_root)
except Exception as exc: # noqa: BLE001 — surface any failure to the UI
state["status"] = "error"
state["error"] = str(exc)
log.warning("rig_builder: VST pack download failed: %s", exc)
finally:
shutil.rmtree(staging, ignore_errors=True)


# Rig Builder reads open song packs — `.sloppak` (original) and `.feedpak`
# (newer name for the same on-disk layout: a zip / directory holding
# manifest.yaml + arrangements/*.json). The host `sloppak` module loads both
Expand Down Expand Up @@ -1826,18 +1941,23 @@ def _bundled_vst_plugins() -> list[dict]:
native engine's scan cache. Listing them here lets batch mapping and the UI
resolve our built-in DSP by absolute path immediately after a restart.
"""
root = _plugin_dir / "vst"
if not root.exists():
# Search the bundled root AND the writable downloaded root (opt-in VST
# packs land there in packaged builds where _plugin_dir is read-only).
roots = _vst_search_roots()
if not roots:
return []
# The bundles are filed under category subdirs (vst/amps, vst/pedals,
# vst/racks); search the root and those one-level subdirs but NOT the C++
# vst/racks); search each root and those one-level subdirs but NOT the C++
# `src/` tree and NOT inside the .vst3 bundles themselves (which embed
# per-platform binaries like Contents/x86_64-win/<name>.vst3).
search_dirs = [root]
for d in sorted(root.iterdir()):
if d.is_dir() and d.name != "src" and not d.name.endswith((".vst3", ".component")):
search_dirs.append(d)
search_dirs = []
for root in roots:
search_dirs.append(root)
for d in sorted(root.iterdir()):
if d.is_dir() and d.name != "src" and not d.name.endswith((".vst3", ".component")):
search_dirs.append(d)
out = []
seen = set()
for suffix, fmt in ((".vst3", "VST3"), (".component", "AudioUnit")):
entries = []
for base in search_dirs:
Expand All @@ -1846,6 +1966,11 @@ def _bundled_vst_plugins() -> list[dict]:
if not entry.exists():
continue
name = entry.name[:-len(suffix)]
# A bundle can appear in both the bundled and downloaded roots
# (dev checkout that also downloaded); first one wins.
if (name, fmt) in seen:
continue
seen.add((name, fmt))
out.append({
"name": name,
"manufacturer": "Rig Builder",
Expand Down Expand Up @@ -7090,6 +7215,40 @@ def _rb_ver_tuple(v):
except Exception:
return None

@app.get("/api/plugins/rig_builder/vst_pack_status")
def rb_vst_pack_status():
"""Whether the simulated-rig VSTs are present, and the pack size for the
current platform (for the download-consent disclosure)."""
plat = _current_vst_platform()
pack = _vst_pack_manifest().get(plat) or {}
with _vst_pack_lock:
dl = dict(_vst_pack_state)
return {
"platform": plat,
"installed": _vst_installed(),
# Only "available" once a publish has stamped the real size — the
# committed manifest is a 0-byte placeholder until then.
"has_pack": (pack.get("bytes") or 0) > 0,
"pack_bytes": pack.get("bytes") or None,
"download": dl,
}

@app.post("/api/plugins/rig_builder/download_vst_pack")
def rb_download_vst_pack():
"""Start the per-platform VST pack download (409 if already running)."""
plat = _current_vst_platform()
pack = _vst_pack_manifest().get(plat) or {}
if (pack.get("bytes") or 0) <= 0 or not pack.get("sha256"):
return JSONResponse({"error": f"no VST pack published for {plat}"}, status_code=404)
with _vst_pack_lock:
if _vst_pack_state["status"] == "running":
return JSONResponse({"error": "already downloading"}, status_code=409)
_vst_pack_state.update(status="running", done=0,
total=pack.get("bytes") or 0, error=None)
state = _vst_pack_state
threading.Thread(target=_download_vst_pack, args=(pack, state), daemon=True).start()
return {"ok": True, "platform": plat}

@app.get("/api/plugins/rig_builder/update_status")
def rb_update_status():
local = _rb_own_version()
Expand Down
130 changes: 129 additions & 1 deletion screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,129 @@ window.RB_API = window.RB_API || '/api/plugins/rig_builder';

window.NAM_API = window.NAM_API || '/api/plugins/nam_tone';

// ── Opt-in simulated-rig VST pack: first-run prompt + achievements ──────────
// DESIGN INTENT (for other contributors): these achievements reward *real
// musician learning*, not game busywork — the same arc as career's. The VSTs
// (~650 MB) are no longer bundled; the player opts in and grows into the game:
// - deciding to learn simulated amps/effects (downloads the rig pack),
// - building a rig from a downloaded tone,
// - or running their own real gear alongside the game (a rig, no tones).
// Keep future achievements in that spirit. Contributed to the achievements
// plugin via its cross-plugin API (rig_builder is a source, competency only).
const RB_VST_WELCOME_KEY = 'feedBack-rig-vst-prompt'; // 'dismissed' once declined
const RB_TONE_DOWNLOADED_KEY = 'feedBack-rig-downloaded-tone'; // '1' after any tone dl
const RB_ACHIEVEMENTS = [
{ id: 'rig_simulated', title: 'Simulated Rig',
description: 'Download the simulated amp & effects pack.', category: 'rig', sourceId: 'rig_builder' },
{ id: 'rig_from_tone', title: 'First Rig from Tone',
description: 'Build a rig from a downloaded tone.', category: 'rig', sourceId: 'rig_builder' },
{ id: 'rig_gearhead', title: 'Gearhead',
description: 'Build a rig with your own gear — no downloaded tones.', category: 'rig', sourceId: 'rig_builder' },
];

function rbWithAchievements(fn) {
const api = window.feedBack && window.feedBack.achievements;
if (api) { try { fn(api); } catch (_) { /* optional */ } return; }
(window.__feedBackAchievementsPending =
window.__feedBackAchievementsPending || []).push(fn);
}
function rbGrantAchievement(id) { rbWithAchievements((api) => api.unlock(id)); }
function rbRegisterAchievements() { rbWithAchievements((api) => api.registerAll(RB_ACHIEVEMENTS)); }

// '' when unknown, so callers can omit the "(~X MB)" suffix.
function rbFormatBytes(n) {
if (!n || n <= 0) return '';
const mb = n / (1024 * 1024);
return mb >= 1024 ? `~${(mb / 1024).toFixed(1)} GB` : `~${Math.round(mb)} MB`;
}
function rbLsGet(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }
function rbLsSet(k, v) { try { localStorage.setItem(k, v); } catch (_) { /* ok */ } }
function rbHasDownloadedTone() { return rbLsGet(RB_TONE_DOWNLOADED_KEY) === '1'; }
// Built a rig → which achievement? Downloaded a tone first vs used own gear.
function rbRigSaveAchievement(hasTone) { return hasTone ? 'rig_from_tone' : 'rig_gearhead'; }

async function rbFetchVstPackStatus() {
const r = await fetch(`${window.RB_API}/vst_pack_status`);
if (!r.ok) throw new Error(`vst_pack_status ${r.status}`);
return r.json();
}

// First run: if the simulated-rig VSTs aren't installed and a pack is published,
// offer the download (with its size) unless the user already declined.
async function rbMaybeShowRigPrompt() {
let st;
try { st = await rbFetchVstPackStatus(); } catch (_) { return; }
if (!st || st.installed || !st.has_pack) return;
if (rbLsGet(RB_VST_WELCOME_KEY) === 'dismissed') return;
const root = document.getElementById('rb-root');
if (!root || document.getElementById('rb-vst-prompt')) return;
const size = rbFormatBytes(st.pack_bytes);
const el = document.createElement('div');
el.id = 'rb-vst-prompt';
el.className = 'mb-4';
el.innerHTML = rbBanner('blue', 'Use a simulated rig?',
`Download the simulated amp & effects pack${size ? ` (${size})` : ''} to play through modeled gear. ` +
`Prefer your own rig? You can skip this and route your real amp instead.` +
`<div class="mt-3 flex items-center gap-2">` +
`<button id="rb-vst-download" class="career-btn career-btn-primary">Download${size ? ` (${size})` : ''}</button>` +
`<button id="rb-vst-skip" class="career-btn career-btn-ghost">Not now</button></div>` +
`<div data-rb-vst-msg class="text-gray-400 mt-2"></div>`);
root.prepend(el);
document.getElementById('rb-vst-skip')?.addEventListener('click', () => {
rbLsSet(RB_VST_WELCOME_KEY, 'dismissed');
el.remove();
});
document.getElementById('rb-vst-download')?.addEventListener('click', () => rbStartVstDownload(el));
}

async function rbStartVstDownload(el) {
const btn = document.getElementById('rb-vst-download');
if (btn) btn.disabled = true;
const msg = el.querySelector('[data-rb-vst-msg]');
try {
const r = await fetch(`${window.RB_API}/download_vst_pack`, { method: 'POST' });
if (!r.ok) {
const d = await r.json().catch(() => ({}));
if (msg) msg.textContent = `Couldn't start: ${d.error || r.status}`;
if (btn) btn.disabled = false;
return;
}
} catch (e) {
if (msg) msg.textContent = `Couldn't start: ${e.message || e}`;
if (btn) btn.disabled = false;
return;
}
rbVstPollUntilDone(el);
}

// One-shot download poll (mirrors rbOauthPoll's recursive-setTimeout model).
async function rbVstPollUntilDone(el) {
let st;
try { st = await rbFetchVstPackStatus(); } catch (_) { st = null; }
const dl = (st && st.download) || {};
const msg = el.querySelector('[data-rb-vst-msg]');
if (dl.status === 'running') {
const pct = dl.total ? Math.round((dl.done / dl.total) * 100) : 0;
if (msg) msg.textContent = `Downloading the simulated rig… ${pct}%`;
setTimeout(() => rbVstPollUntilDone(el), 800);
return;
}
if (dl.status === 'error') {
if (msg) msg.textContent = `Download failed: ${dl.error || ''} — try again`;
const btn = document.getElementById('rb-vst-download');
if (btn) btn.disabled = false;
return;
}
// done (or idle after a completed run): the simulated rig is installed.
rbGrantAchievement('rig_simulated');
el.remove();
}

window.__rbTest = {
rbFormatBytes, rbRigSaveAchievement, rbHasDownloadedTone,
RB_ACHIEVEMENTS, RB_VST_WELCOME_KEY, RB_TONE_DOWNLOADED_KEY,
};

function rbScopeCss(css) {
const prefixSelector = (selector) => {
const s = selector.trim();
Expand Down Expand Up @@ -3626,6 +3749,8 @@ async function rbInit() {
return;
}
rbRenderStatus();
rbRegisterAchievements(); // idempotent; renders greyed before earned
rbMaybeShowRigPrompt(); // first-run opt-in VST download (async, non-blocking)
rbShowTab(rbState.currentTab);
rbStudioRenderToneChips(); // show the current-tone label right away
rbStudioLoadSavedTones().catch(() => {}); // then fill in saved tones
Expand Down Expand Up @@ -5660,6 +5785,9 @@ async function rbStudioCommitSaveTone(name) {
if (!r.ok) { alert(`Save failed: ${d.error || r.status} — restart the app if you just updated (new backend route).`); return; }
} catch (e) { alert('Save failed: ' + (e.message || e)); return; }
document.getElementById('rb-tone-menu')?.classList.add('hidden');
// Built & saved a rig → a real musician milestone: from a downloaded tone,
// or from the player's own gear (no tones). Idempotent; only the first fires.
rbGrantAchievement(rbRigSaveAchievement(rbHasDownloadedTone()));
await rbStudioLoadSavedTones();
rbStudioShowSavedTone(name);
}
Expand Down Expand Up @@ -6535,7 +6663,7 @@ async function rbAutoDownloadSong(filename, unmappedCount, container) {
return;
}
const parts = [];
if (result.downloaded) parts.push(`${result.downloaded} downloaded`);
if (result.downloaded) { parts.push(`${result.downloaded} downloaded`); rbLsSet(RB_TONE_DOWNLOADED_KEY, '1'); }
if (result.rs_ir_used) parts.push(`${result.rs_ir_used} IRs`);
if (result.skipped_assigned) parts.push(`${result.skipped_assigned} reused`);
if (result.skipped_no_candidate) parts.push(`${result.skipped_no_candidate} unmatched`);
Expand Down
Loading