polats Claude Opus 4.8 (1M context) commited on
Commit
ae61c19
·
1 Parent(s): 63d105f

Add local ACE-Step music sidecar + cinematic title sequence

Browse files

ACE-Step (music + sung vocals) as a local sidecar, mirroring the aya/klein
pattern so the main app generates audio without HF quota:
- spaces/acestep-zerogpu/ (own .venv-acestep — ACE-Step pins transformers==4.50/
spacy==3.8 that clash with the other sidecars; needs torchcodec for WAV I/O)
- run_sidecars.sh `acestep` case (port 7866, on-demand), .env TINY_ACESTEP_SPACE
- app.py: _acestep_generate helper + POST /api/music route

Cinematic hero-select title sequence (web/titleSequence.js, web/tiny.js,
comboBattler startCinematic/stopCinematic/getCineAnchors):
- ambient camera tour over decorative warbands (no-combat actors, ~15/group,
phyllotaxis packing) behind the always-usable picker
- TINY ARMY wordmark + letterbox/vignette overlay
- ACE-Step title theme (web/audio/*, LFS) — tavern active, orchestral kept for
reuse; music ducks when the game starts; mute button beside the char-sheet button
- hero speaks their saved voice (IndexedDB) on the info page + on select

Also: klein sidecar serializes LoRA-swap + generation with a lock (adapter-leak fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

.gitattributes CHANGED
@@ -46,3 +46,6 @@ web/assets/minifantasy/Minifantasy_Temple_Of_The_Snake_God_v1.0_Commercial_Versi
46
  web/assets/minifantasy/Minifantasy_Orc_Kingdom_v1.0/Minifantasy_Orc_Kingdom_Assets/Props/Prop_Shadows.png filter=lfs diff=lfs merge=lfs -text
47
  web/assets/minifantasy/Minifantasy_Orc_Kingdom_v1.0/Minifantasy_Orc_Kingdom_Assets/Tileset/Tile_Shadows.png filter=lfs diff=lfs merge=lfs -text
48
  web/assets/minifantasy/Minifantasy_Necropolis_v1.0/Minifantasy_Necropolis_Assets/Tileset/Buildings/TileasetAndPremadeShadows.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
46
  web/assets/minifantasy/Minifantasy_Orc_Kingdom_v1.0/Minifantasy_Orc_Kingdom_Assets/Props/Prop_Shadows.png filter=lfs diff=lfs merge=lfs -text
47
  web/assets/minifantasy/Minifantasy_Orc_Kingdom_v1.0/Minifantasy_Orc_Kingdom_Assets/Tileset/Tile_Shadows.png filter=lfs diff=lfs merge=lfs -text
48
  web/assets/minifantasy/Minifantasy_Necropolis_v1.0/Minifantasy_Necropolis_Assets/Tileset/Buildings/TileasetAndPremadeShadows.png filter=lfs diff=lfs merge=lfs -text
49
+
50
+ # Generated audio themes (ACE-Step) — binary, required LFS by the HF hub Xet check.
51
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -2,4 +2,6 @@
2
  __pycache__/
3
  *.pyc
4
  .venv/
 
5
  logs/
 
 
2
  __pycache__/
3
  *.pyc
4
  .venv/
5
+ .venv-acestep/
6
  logs/
7
+ outputs/
app.py CHANGED
@@ -352,6 +352,9 @@ _DASHSCOPE_URL = _DASHSCOPE_BASE + "/api/v1/services/audio/tts/customization"
352
  # soundfile`. Lazy-loaded; the Space (cpu-basic) leaves this unset and uses DashScope.
353
  TTS_MODE = os.environ.get("TINY_TTS_MODE", "").strip().lower()
354
  VOXCPM_SPACE = os.environ.get("TINY_VOXCPM_SPACE", "").strip()
 
 
 
355
  TINY_AYA_SPACE = os.environ.get("TINY_AYA_SPACE", "").strip()
356
  MINICPM5_SPACE = os.environ.get("TINY_MINICPM5_SPACE", "").strip()
357
  # Coding model (Skill Forge): Mellum2 is a ZeroGPU sidecar (same /generate contract as
@@ -550,6 +553,35 @@ def _voxcpm_clone(text, ref_audio_b64, ref_text, instruct):
550
  return f.read()
551
 
552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  def _tiny_aya_generate(system, user, max_tokens, temperature):
554
  from gradio_client import Client
555
  client = Client(TINY_AYA_SPACE, token=HF_TOKEN or None)
@@ -742,6 +774,29 @@ async def voxcpm_clone(request: Request):
742
  return Response(wav, media_type="audio/wav", headers={"Cache-Control": "no-store"})
743
 
744
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
745
  # ── Persona portraits (image generation) ─────────────────────────────────────
746
  # Mirrors the voice path: TINY_IMAGE_MODE=local runs the OPEN WEIGHTS on your GPU
747
  # (Z-Image-Turbo, 6B, ~12 GB bf16 — coexists with the TTS model on a 24 GB card);
 
352
  # soundfile`. Lazy-loaded; the Space (cpu-basic) leaves this unset and uses DashScope.
353
  TTS_MODE = os.environ.get("TINY_TTS_MODE", "").strip().lower()
354
  VOXCPM_SPACE = os.environ.get("TINY_VOXCPM_SPACE", "").strip()
355
+ # ACE-Step music + sung-vocal generation (local sidecar; see spaces/acestep-zerogpu). NB this is a
356
+ # MUSIC model — its "vocals" are sung lyrics, not speech; VoxCPM stays the path for dialogue/TTS.
357
+ ACESTEP_SPACE = os.environ.get("TINY_ACESTEP_SPACE", "").strip()
358
  TINY_AYA_SPACE = os.environ.get("TINY_AYA_SPACE", "").strip()
359
  MINICPM5_SPACE = os.environ.get("TINY_MINICPM5_SPACE", "").strip()
360
  # Coding model (Skill Forge): Mellum2 is a ZeroGPU sidecar (same /generate contract as
 
553
  return f.read()
554
 
555
 
556
+ def _acestep_generate(prompt, lyrics, duration, steps, guidance, seed):
557
+ """Call the ACE-Step sidecar's /generate and return the rendered WAV bytes. Same retry shape
558
+ as _voxcpm_predict (the sidecar shares one GPU pipeline, so transient queue/GPU hiccups retry)."""
559
+ from gradio_client import Client
560
+ client = Client(ACESTEP_SPACE, token=HF_TOKEN or None)
561
+ last_err = None
562
+ for attempt in range(3):
563
+ try:
564
+ result = client.predict(
565
+ prompt or "",
566
+ lyrics or "",
567
+ float(duration or 30.0),
568
+ int(steps or 60),
569
+ float(guidance if guidance is not None else 15.0),
570
+ int(seed if seed is not None else -1),
571
+ api_name="/generate",
572
+ )
573
+ path = result[0] if isinstance(result, (tuple, list)) else result
574
+ with open(os.fspath(path), "rb") as f:
575
+ return f.read()
576
+ except Exception as e: # noqa: BLE001
577
+ last_err = e
578
+ msg = str(e).lower()
579
+ if attempt == 2 or not any(s in msg for s in ("accelerator", "queue", "gpu", "timeout", "temporarily")):
580
+ raise
581
+ time.sleep(1.5 * (attempt + 1))
582
+ raise last_err
583
+
584
+
585
  def _tiny_aya_generate(system, user, max_tokens, temperature):
586
  from gradio_client import Client
587
  client = Client(TINY_AYA_SPACE, token=HF_TOKEN or None)
 
774
  return Response(wav, media_type="audio/wav", headers={"Cache-Control": "no-store"})
775
 
776
 
777
+ @fastapi_app.post("/api/music")
778
+ async def api_music(request: Request):
779
+ """Generate music (and optional sung vocals) with the ACE-Step sidecar. Returns a WAV.
780
+ Body: {prompt, lyrics?, duration?, steps?, guidance?, seed?}. prompt = tags/style;
781
+ lyrics blank or "[inst]" = instrumental."""
782
+ body = await request.json()
783
+ prompt = (body.get("prompt") or "").strip()
784
+ if not prompt:
785
+ return Response("prompt required", status_code=400)
786
+ if not ACESTEP_SPACE:
787
+ return Response("TINY_ACESTEP_SPACE not set", status_code=503)
788
+ lyrics = body.get("lyrics") or ""
789
+ duration = body.get("duration", 30.0)
790
+ steps = body.get("steps", 60)
791
+ guidance = body.get("guidance", 15.0)
792
+ seed = body.get("seed", -1)
793
+ try:
794
+ wav = await asyncio.to_thread(_acestep_generate, prompt, lyrics, duration, steps, guidance, seed)
795
+ except Exception as e: # noqa: BLE001
796
+ return Response(f"ACE-Step error: {e}", status_code=502)
797
+ return Response(wav, media_type="audio/wav", headers={"Cache-Control": "no-store"})
798
+
799
+
800
  # ── Persona portraits (image generation) ─────────────────────────────────────
801
  # Mirrors the voice path: TINY_IMAGE_MODE=local runs the OPEN WEIGHTS on your GPU
802
  # (Z-Image-Turbo, 6B, ~12 GB bf16 — coexists with the TTS model on a 24 GB card);
docs/local-sidecars.md CHANGED
@@ -8,6 +8,18 @@ talks to them unchanged — only the `*_SPACE` env var changes from a Space ID t
8
  |------------|-----------------------------|------|--------------------------------|-------------------|
9
  | Tiny Aya | `spaces/tiny-aya-zerogpu/` | 7864 | `CohereLabs/tiny-aya-global` | `TINY_AYA_SPACE` |
10
  | Klein | `spaces/klein-zerogpu/` | 7865 | `black-forest-labs/FLUX.2-klein-4B` + `polats/weiner-klein-lora` | `TINY_KLEIN_SPACE` |
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  Mirrors of `polats/tiny-army-tiny-aya-zerogpu` and `polats/tiny-army-klein-zerogpu`, adapted for
13
  local hardware (no ZeroGPU). The hosted `app.py`s are unchanged in spirit; the local diffs are
@@ -16,13 +28,14 @@ documented in each file's header.
16
  ## Run
17
 
18
  ```bash
19
- ./run_sidecars.sh # start both, wait until ready (logs in logs/)
20
  ./run_sidecars.sh aya # just tiny-aya
21
  ./run_sidecars.sh klein # just klein
22
- ./run_sidecars.sh stop # stop both
 
23
  ```
24
 
25
- `.env` already points `TINY_AYA_SPACE` / `TINY_KLEIN_SPACE` at the local URLs.
26
 
27
  ### Network access
28
 
 
8
  |------------|-----------------------------|------|--------------------------------|-------------------|
9
  | Tiny Aya | `spaces/tiny-aya-zerogpu/` | 7864 | `CohereLabs/tiny-aya-global` | `TINY_AYA_SPACE` |
10
  | Klein | `spaces/klein-zerogpu/` | 7865 | `black-forest-labs/FLUX.2-klein-4B` + `polats/weiner-klein-lora` | `TINY_KLEIN_SPACE` |
11
+ | ACE-Step | `spaces/acestep-zerogpu/` | 7866 | [ACE-Step](https://github.com/ace-step/ACE-Step) — music + sung vocals | `TINY_ACESTEP_SPACE` |
12
+
13
+ **ACE-Step is the odd one out:** it's a *music* model (not text/image), and it pins
14
+ `transformers==4.50`/`spacy==3.8.4` which clash with the aya/klein venv — so it runs in its **own**
15
+ `.venv-acestep` (Python 3.12; spacy has no 3.13 wheel) and also needs `torchcodec` for audio I/O.
16
+ Start it on demand with `./run_sidecars.sh acestep` (it's intentionally **not** in the `all`
17
+ default — heavier on the shared card). API:
18
+ `/generate(prompt, lyrics, duration, infer_step, guidance_scale, seed) -> wav`; the main app
19
+ exposes it at `POST /api/music` (`{prompt, lyrics?, duration?, steps?, guidance?, seed?}` → WAV).
20
+ "Vocals" = lyrics SUNG inside a track (blank or `[inst]` = instrumental); **VoxCPM** remains the
21
+ path for spoken dialogue/TTS. `cpu_offload` keeps it ~0.7 GB idle / ~8 GB peak; the first call
22
+ auto-downloads checkpoints to `~/.cache/ace-step`.
23
 
24
  Mirrors of `polats/tiny-army-tiny-aya-zerogpu` and `polats/tiny-army-klein-zerogpu`, adapted for
25
  local hardware (no ZeroGPU). The hosted `app.py`s are unchanged in spirit; the local diffs are
 
28
  ## Run
29
 
30
  ```bash
31
+ ./run_sidecars.sh # start aya + klein, wait until ready (logs in logs/)
32
  ./run_sidecars.sh aya # just tiny-aya
33
  ./run_sidecars.sh klein # just klein
34
+ ./run_sidecars.sh acestep # just ACE-Step (music; own venv, on demand)
35
+ ./run_sidecars.sh stop # stop all three
36
  ```
37
 
38
+ `.env` already points `TINY_AYA_SPACE` / `TINY_KLEIN_SPACE` / `TINY_ACESTEP_SPACE` at local URLs.
39
 
40
  ### Network access
41
 
run_sidecars.sh CHANGED
@@ -9,29 +9,34 @@
9
  # TINY_KLEIN_SPACE=http://127.0.0.1:7865
10
  #
11
  # Usage:
12
- # ./run_sidecars.sh # start both, stream logs to logs/, wait until ready
13
  # ./run_sidecars.sh aya # just tiny-aya
14
  # ./run_sidecars.sh klein # just klein
15
- # ./run_sidecars.sh stop # stop both
 
16
  set -euo pipefail
17
  cd "$(dirname "$0")"
18
 
19
  PY="${PY:-.venv/bin/python}"
 
 
 
20
  AYA_PORT="${AYA_PORT:-7864}"
21
  KLEIN_PORT="${KLEIN_PORT:-7865}"
 
22
  # Bind address. 0.0.0.0 = reachable from the LAN (other machines on the network) at this host's
23
  # IP:PORT. Set BIND=127.0.0.1 to restrict to localhost (main app on the same box only).
24
  BIND="${BIND:-0.0.0.0}"
25
  mkdir -p logs
26
 
27
  start_one() {
28
- local name="$1" dir="$2" port="$3"
29
  if [ -f "logs/$name.pid" ] && kill -0 "$(cat "logs/$name.pid")" 2>/dev/null; then
30
  echo "[$name] already running (pid $(cat "logs/$name.pid")) on :$port"
31
  return
32
  fi
33
  echo "[$name] starting on $BIND:$port (logs/$name.log)"
34
- PORT="$port" GRADIO_SERVER_NAME="$BIND" nohup "$PY" "spaces/$dir/app.py" \
35
  > "logs/$name.log" 2>&1 &
36
  echo $! > "logs/$name.pid"
37
  }
@@ -62,9 +67,12 @@ stop_one() {
62
  }
63
 
64
  case "${1:-all}" in
65
- stop) stop_one aya; stop_one klein ;;
66
  aya) start_one aya tiny-aya-zerogpu "$AYA_PORT"; wait_ready aya "$AYA_PORT" ;;
67
  klein) start_one klein klein-zerogpu "$KLEIN_PORT"; wait_ready klein "$KLEIN_PORT" ;;
 
 
 
68
  all|*)
69
  start_one aya tiny-aya-zerogpu "$AYA_PORT"
70
  start_one klein klein-zerogpu "$KLEIN_PORT"
 
9
  # TINY_KLEIN_SPACE=http://127.0.0.1:7865
10
  #
11
  # Usage:
12
+ # ./run_sidecars.sh # start aya + klein, stream logs to logs/, wait until ready
13
  # ./run_sidecars.sh aya # just tiny-aya
14
  # ./run_sidecars.sh klein # just klein
15
+ # ./run_sidecars.sh acestep # just ACE-Step music sidecar (own .venv-acestep, started on demand)
16
+ # ./run_sidecars.sh stop # stop all three
17
  set -euo pipefail
18
  cd "$(dirname "$0")"
19
 
20
  PY="${PY:-.venv/bin/python}"
21
+ # ACE-Step pins transformers==4.50 / spacy==3.8 that conflict with the aya/klein venv, so it runs
22
+ # in its own Python 3.12 venv. Override with ACESTEP_PY if you put it elsewhere.
23
+ ACESTEP_PY="${ACESTEP_PY:-.venv-acestep/bin/python}"
24
  AYA_PORT="${AYA_PORT:-7864}"
25
  KLEIN_PORT="${KLEIN_PORT:-7865}"
26
+ ACESTEP_PORT="${ACESTEP_PORT:-7866}"
27
  # Bind address. 0.0.0.0 = reachable from the LAN (other machines on the network) at this host's
28
  # IP:PORT. Set BIND=127.0.0.1 to restrict to localhost (main app on the same box only).
29
  BIND="${BIND:-0.0.0.0}"
30
  mkdir -p logs
31
 
32
  start_one() {
33
+ local name="$1" dir="$2" port="$3" py="${4:-$PY}"
34
  if [ -f "logs/$name.pid" ] && kill -0 "$(cat "logs/$name.pid")" 2>/dev/null; then
35
  echo "[$name] already running (pid $(cat "logs/$name.pid")) on :$port"
36
  return
37
  fi
38
  echo "[$name] starting on $BIND:$port (logs/$name.log)"
39
+ PORT="$port" GRADIO_SERVER_NAME="$BIND" nohup "$py" "spaces/$dir/app.py" \
40
  > "logs/$name.log" 2>&1 &
41
  echo $! > "logs/$name.pid"
42
  }
 
67
  }
68
 
69
  case "${1:-all}" in
70
+ stop) stop_one aya; stop_one klein; stop_one acestep ;;
71
  aya) start_one aya tiny-aya-zerogpu "$AYA_PORT"; wait_ready aya "$AYA_PORT" ;;
72
  klein) start_one klein klein-zerogpu "$KLEIN_PORT"; wait_ready klein "$KLEIN_PORT" ;;
73
+ # ACE-Step (music + sung vocals) — its own venv; not in `all` since it's heavier on the
74
+ # shared card. Start it on demand: ./run_sidecars.sh acestep
75
+ acestep) start_one acestep acestep-zerogpu "$ACESTEP_PORT" "$ACESTEP_PY"; wait_ready acestep "$ACESTEP_PORT" ;;
76
  all|*)
77
  start_one aya tiny-aya-zerogpu "$AYA_PORT"
78
  start_one klein klein-zerogpu "$KLEIN_PORT"
spaces/acestep-zerogpu/.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ __pycache__/
2
+ outputs/
3
+ *.wav
spaces/acestep-zerogpu/README.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Tiny Army ACE-Step
3
+ emoji: 🎵
4
+ sdk: gradio
5
+ app_file: app.py
6
+ ---
7
+
8
+ # Tiny Army ACE-Step — local sidecar
9
+
10
+ Local sidecar wrapping [ACE-Step](https://github.com/ace-step/ACE-Step) for **music + sung-vocal**
11
+ generation, so the main app makes audio without burning HF quota. Mirrors the other sidecars'
12
+ Gradio API so `gradio_client` talks to it unchanged:
13
+
14
+ ```
15
+ /generate(prompt, lyrics, audio_duration, infer_step, guidance_scale, seed) -> wav file
16
+ ```
17
+
18
+ - **prompt** — tags / style, e.g. `epic orchestral, cinematic, heroic brass`
19
+ - **lyrics** — `[verse]/[chorus]` markers for sung vocals; blank or `[inst]` for an instrumental
20
+ - ACE-Step is a *music* model — its "voice" is singing, not speech. Keep **VoxCPM** for dialogue.
21
+
22
+ ## Run
23
+
24
+ Runs in a **dedicated venv** (`.venv-acestep`, Python 3.12) because ACE-Step pins
25
+ `transformers==4.50` / `spacy==3.8.4`, which conflict with the aya/klein sidecars. Launch via:
26
+
27
+ ```bash
28
+ ./run_sidecars.sh acestep # port 7866
29
+ ```
30
+
31
+ Then point the main app at it (already in `.env`): `TINY_ACESTEP_SPACE=http://127.0.0.1:7866`.
32
+
33
+ ### Env knobs
34
+ - `TINY_ACESTEP_OFFLOAD=0` — keep the model GPU-resident (faster) when the card is free; default
35
+ `1` enables `cpu_offload` (~8 GB peak) to coexist with the shared text-encoder server.
36
+ - `TINY_ACESTEP_CKPT` — checkpoint dir (default `~/.cache/ace-step`, auto-downloaded first run).
37
+ - `TINY_ACESTEP_STEPS` / `_GUIDANCE` / `_DURATION` / `_MAX_DURATION` / `_DTYPE`.
spaces/acestep-zerogpu/app.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ # LOCAL SIDECAR for ACE-Step (https://github.com/ace-step/ACE-Step) — music + sung-vocal
4
+ # generation, run on the local 3090 instead of a hosted Space so the main app burns no HF quota.
5
+ #
6
+ # Gradio API contract (so the main app's gradio_client talks to it the same way it talks to the
7
+ # other sidecars):
8
+ # /generate(prompt, lyrics, audio_duration:float, infer_step:int, guidance_scale:float, seed:int)
9
+ # -> audio file path (wav)
10
+ #
11
+ # Notes:
12
+ # * ACE-Step is a *music* model. "Vocals" = lyrics SUNG inside a track (give it tags + lyrics
13
+ # with [verse]/[chorus] markers). It is NOT speech TTS — VoxCPM stays the tool for dialogue.
14
+ # Leave `lyrics` blank (or "[inst]") for an instrumental.
15
+ # * ACE-Step pins transformers==4.50 / accelerate==1.6 which conflict with the aya/klein
16
+ # sidecars' transformers>=5.4 — so this sidecar runs in its OWN venv (.venv-acestep). The
17
+ # run_sidecars.sh `acestep` case launches it with that interpreter.
18
+ # * cpu_offload=True keeps peak VRAM ~8 GB so it coexists with the shared text-encoder server
19
+ # (same philosophy as klein's sequential offload). Set TINY_ACESTEP_OFFLOAD=0 when the card
20
+ # is free for full-speed GPU-resident inference.
21
+ # * First run downloads ~3.5B-param checkpoints to ~/.cache/ace-step (a few GB); subsequent
22
+ # starts are fast. Override the location with TINY_ACESTEP_CKPT.
23
+ import os
24
+ import random
25
+ import threading
26
+ import time
27
+
28
+ # Reduce CUDA fragmentation under shared-card memory pressure. Must precede CUDA init.
29
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
30
+
31
+ try:
32
+ from dotenv import load_dotenv
33
+ load_dotenv(os.path.join(os.path.dirname(__file__), "..", "..", ".env"))
34
+ load_dotenv()
35
+ except Exception: # noqa: BLE001
36
+ pass
37
+
38
+ import gradio as gr
39
+ import torch
40
+ from acestep.pipeline_ace_step import ACEStepPipeline
41
+
42
+ CKPT_DIR = os.environ.get("TINY_ACESTEP_CKPT") or None # None -> default ~/.cache/ace-step
43
+ DTYPE = os.environ.get("TINY_ACESTEP_DTYPE", "bfloat16").strip()
44
+ # Offload on by default: the 3090 is shared (desktop + text-encoder server). "0"/"false" = keep
45
+ # the model GPU-resident for full speed when the card is free.
46
+ OFFLOAD = os.environ.get("TINY_ACESTEP_OFFLOAD", "1").strip().lower() not in ("0", "false", "no")
47
+ DEFAULT_STEPS = int(os.environ.get("TINY_ACESTEP_STEPS", "60"))
48
+ DEFAULT_GUIDANCE = float(os.environ.get("TINY_ACESTEP_GUIDANCE", "15.0"))
49
+ DEFAULT_DURATION = float(os.environ.get("TINY_ACESTEP_DURATION", "30.0"))
50
+ MAX_DURATION = float(os.environ.get("TINY_ACESTEP_MAX_DURATION", "240.0"))
51
+ MAX_SEED = 2_147_483_647
52
+
53
+ print(f"[acestep] loading pipeline (dtype={DTYPE}, cpu_offload={OFFLOAD and torch.cuda.is_available()})", flush=True)
54
+ _t0 = time.time()
55
+ pipe = ACEStepPipeline(
56
+ checkpoint_dir=CKPT_DIR,
57
+ dtype=DTYPE,
58
+ cpu_offload=OFFLOAD and torch.cuda.is_available(),
59
+ overlapped_decode=True,
60
+ torch_compile=False,
61
+ )
62
+ print(f"[acestep] pipeline constructed in {time.time() - _t0:.1f}s (weights load lazily on first call)", flush=True)
63
+
64
+ # The pipeline is process-global and shared by every caller; serialize generations so concurrent
65
+ # requests don't trample each other's state (same guard as the other sidecars).
66
+ _gen_lock = threading.Lock()
67
+
68
+
69
+ def _first_audio(out):
70
+ """ACE-Step's __call__ returns a list of audio paths, sometimes paired with a params dict
71
+ ((paths, params)). Dig out the first audio file path robustly."""
72
+ seen = out
73
+ # Unwrap a (result, params) tuple/list whose 2nd element is the metadata dict.
74
+ if isinstance(seen, (list, tuple)) and len(seen) == 2 and isinstance(seen[1], dict):
75
+ seen = seen[0]
76
+ if isinstance(seen, (list, tuple)):
77
+ seen = seen[0] if seen else None
78
+ if seen is None:
79
+ raise gr.Error("ACE-Step returned no audio")
80
+ return os.fspath(seen)
81
+
82
+
83
+ def generate(prompt: str, lyrics: str = "", audio_duration: float = DEFAULT_DURATION,
84
+ infer_step: int = DEFAULT_STEPS, guidance_scale: float = DEFAULT_GUIDANCE,
85
+ seed: int = -1):
86
+ if not prompt or not prompt.strip():
87
+ raise gr.Error("prompt (tags / style) required, e.g. 'lofi hip hop, mellow, rainy'")
88
+ dur = float(audio_duration or DEFAULT_DURATION)
89
+ dur = max(1.0, min(dur, MAX_DURATION))
90
+ steps = max(1, min(int(infer_step or DEFAULT_STEPS), 200))
91
+ guidance = float(guidance_scale if guidance_scale is not None else DEFAULT_GUIDANCE)
92
+ if seed is None or int(seed) < 0:
93
+ seed = random.randint(0, MAX_SEED)
94
+ with _gen_lock:
95
+ out = pipe(
96
+ format="wav",
97
+ audio_duration=dur,
98
+ prompt=prompt.strip(),
99
+ lyrics=(lyrics or "").strip(),
100
+ infer_step=steps,
101
+ guidance_scale=guidance,
102
+ manual_seeds=[int(seed)],
103
+ )
104
+ if torch.cuda.is_available():
105
+ torch.cuda.empty_cache()
106
+ return _first_audio(out)
107
+
108
+
109
+ demo = gr.Interface(
110
+ fn=generate,
111
+ inputs=[
112
+ gr.Textbox(label="Prompt (tags / style)", lines=2,
113
+ placeholder="e.g. epic orchestral, cinematic, heroic brass"),
114
+ gr.Textbox(label="Lyrics (blank or [inst] = instrumental)", lines=6,
115
+ placeholder="[verse]\n...\n[chorus]\n..."),
116
+ gr.Number(label="Duration (s)", value=DEFAULT_DURATION),
117
+ gr.Number(label="Inference steps", value=DEFAULT_STEPS, precision=0),
118
+ gr.Number(label="Guidance scale", value=DEFAULT_GUIDANCE),
119
+ gr.Number(label="Seed (-1 = random)", value=-1, precision=0),
120
+ ],
121
+ outputs=gr.Audio(type="filepath", label="Generated audio"),
122
+ api_name="generate",
123
+ title="Tiny Army ACE-Step — local sidecar",
124
+ description="Text-to-music + sung vocals. Leave lyrics blank for instrumental.",
125
+ )
126
+
127
+ if __name__ == "__main__":
128
+ demo.queue().launch(
129
+ server_name=os.environ.get("GRADIO_SERVER_NAME", "127.0.0.1"),
130
+ server_port=int(os.environ.get("PORT", "7866")),
131
+ show_error=True,
132
+ )
spaces/acestep-zerogpu/requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # ACE-Step pulls its own pinned deps (transformers==4.50, accelerate==1.6, spacy==3.8, torch, ...).
2
+ # Those conflict with the aya/klein sidecars' transformers>=5.4, so this sidecar runs in its OWN
3
+ # venv (.venv-acestep, Python 3.12 — spacy 3.8.4 has no 3.13 wheel). Install with:
4
+ # python3.12 -m venv .venv-acestep
5
+ # .venv-acestep/bin/pip install -r spaces/acestep-zerogpu/requirements.txt
6
+ git+https://github.com/ace-step/ACE-Step.git
7
+ torchcodec # torchaudio>=2.9 writes audio via torchcodec; ACE-Step's save step needs it
8
+ python-dotenv
spaces/klein-zerogpu/app.py CHANGED
@@ -20,6 +20,7 @@ from __future__ import annotations
20
  # * Loads the repo .env for HF_TOKEN.
21
  import os
22
  import random
 
23
  import time
24
 
25
  # Reduce CUDA fragmentation under the shared-card memory pressure (the offload churn allocates
@@ -78,6 +79,10 @@ print(f"[klein] loaded in {time.time() - _t0:.1f}s", flush=True)
78
 
79
  _lora_active: str | None = None
80
  _offload_enabled = False
 
 
 
 
81
 
82
 
83
  def _load_lora(name: str) -> None:
@@ -130,31 +135,33 @@ def _to_img(x):
130
  def generate(prompt: str, seed: int = 42, lora: str = DEFAULT_LORA, ref_image=None, ref_image2=None):
131
  if not prompt or not prompt.strip():
132
  raise gr.Error("prompt required")
133
- _ensure_lora(lora)
134
  # With offload enabled the pipeline places its own modules; the generator must live on the
135
  # device the latents end up on (cuda when offloading, else cpu).
136
  dev = "cuda" if torch.cuda.is_available() else "cpu"
137
  if seed is None or int(seed) < 0:
138
  seed = random.randint(0, MAX_SEED)
139
- kwargs = dict(
140
- prompt=prompt.strip(),
141
- width=1024,
142
- height=1024,
143
- num_inference_steps=STEPS,
144
- guidance_scale=GUIDANCE,
145
- generator=torch.Generator(device=dev).manual_seed(int(seed)),
146
- )
147
  # FLUX.2 native multi-image conditioning: pass [pose anchor, identity ref] as a LIST so the
148
  # composition follows one reference (e.g. a mannequin/depth render of a kimodo pose) while
149
  # character identity comes from the other. One image -> passed alone (proven single-ref path).
150
  refs = [r for r in (_to_img(ref_image), _to_img(ref_image2)) if r is not None]
151
- if len(refs) == 1:
152
- kwargs["image"] = refs[0]
153
- elif refs:
154
- kwargs["image"] = refs
155
- img = pipe(**kwargs).images[0]
156
- if dev == "cuda":
157
- torch.cuda.empty_cache()
 
 
 
 
 
 
 
 
 
 
 
158
  return img
159
 
160
 
 
20
  # * Loads the repo .env for HF_TOKEN.
21
  import os
22
  import random
23
+ import threading
24
  import time
25
 
26
  # Reduce CUDA fragmentation under the shared-card memory pressure (the offload churn allocates
 
79
 
80
  _lora_active: str | None = None
81
  _offload_enabled = False
82
+ # Serialize LoRA-state + generation: the pipe and its loaded LoRA are process-global and shared
83
+ # by every caller, so without this a concurrent no-LoRA request could render while another has
84
+ # weiner attached (and vice-versa). See the hosted Space fix for the same leak.
85
+ _gen_lock = threading.Lock()
86
 
87
 
88
  def _load_lora(name: str) -> None:
 
135
  def generate(prompt: str, seed: int = 42, lora: str = DEFAULT_LORA, ref_image=None, ref_image2=None):
136
  if not prompt or not prompt.strip():
137
  raise gr.Error("prompt required")
 
138
  # With offload enabled the pipeline places its own modules; the generator must live on the
139
  # device the latents end up on (cuda when offloading, else cpu).
140
  dev = "cuda" if torch.cuda.is_available() else "cpu"
141
  if seed is None or int(seed) < 0:
142
  seed = random.randint(0, MAX_SEED)
 
 
 
 
 
 
 
 
143
  # FLUX.2 native multi-image conditioning: pass [pose anchor, identity ref] as a LIST so the
144
  # composition follows one reference (e.g. a mannequin/depth render of a kimodo pose) while
145
  # character identity comes from the other. One image -> passed alone (proven single-ref path).
146
  refs = [r for r in (_to_img(ref_image), _to_img(ref_image2)) if r is not None]
147
+ # Exclusive ownership of the shared pipe for the whole LoRA-set + generate (no adapter leak).
148
+ with _gen_lock:
149
+ _ensure_lora(lora)
150
+ kwargs = dict(
151
+ prompt=prompt.strip(),
152
+ width=1024,
153
+ height=1024,
154
+ num_inference_steps=STEPS,
155
+ guidance_scale=GUIDANCE,
156
+ generator=torch.Generator(device=dev).manual_seed(int(seed)),
157
+ )
158
+ if len(refs) == 1:
159
+ kwargs["image"] = refs[0]
160
+ elif refs:
161
+ kwargs["image"] = refs
162
+ img = pipe(**kwargs).images[0]
163
+ if dev == "cuda":
164
+ torch.cuda.empty_cache()
165
  return img
166
 
167
 
web/audio/theme-orchestral.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4682604e975e339eb8f40f47fc2351a61b79786aacb87c630b8f7152705960d5
3
+ size 600884
web/audio/theme-tavern.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:85781da5bf224d995ad8964846396a588ef953ad2a63f3c82c8bd267f3f0b5ad
3
+ size 648620
web/audio/title-theme.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:85781da5bf224d995ad8964846396a588ef953ad2a63f3c82c8bd267f3f0b5ad
3
+ size 648620
web/comboBattler.js CHANGED
@@ -4887,8 +4887,10 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
4887
  v.sprite.scale.set(1);
4888
  v.sprite.alpha = a.alive ? 1 : 0.32;
4889
  }
4890
- drawBars(a.id, a, dtMS, b.t);
4891
- drawStatus(a.id, a, b.t, depth);
 
 
4892
  }
4893
  }
4894
  function updateFloats(dtMS) {
@@ -6182,6 +6184,8 @@ function mountComboBattler(pixi, host, opts = {}) {
6182
  const ARENA = { ox: 0, oy: 0 };
6183
  const fieldToWorld = (fx, fy) => ({ x: ARENA.ox + fx * G, y: ARENA.oy + fy * G });
6184
  const worldToField = (wx, wy) => ({ x: (wx - ARENA.ox) / G, y: (wy - ARENA.oy) / G });
 
 
6185
  const map = createGameWorld(pixi, host, { seed, keyboardPan: false });
6186
  const roamWalkable = gameWorldWalkable(seed);
6187
  const world = { walkable: (fx, fy) => {
@@ -6194,6 +6198,7 @@ function mountComboBattler(pixi, host, opts = {}) {
6194
  return { forgottenPlains: e, orc: e, necropolis: e };
6195
  })();
6196
  let battle = null, R = null, combatRoot = null, rings = null, markers = null, navDbg = null, spawnFn = null, dead = false;
 
6197
  let emoteLayer = null;
6198
  let skillVfxLayer = null;
6199
  const emoPrev = /* @__PURE__ */ new Map();
@@ -6861,8 +6866,140 @@ function mountComboBattler(pixi, host, opts = {}) {
6861
  routePath = null;
6862
  routeIdx = 0;
6863
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6864
  async function spawnHero(player) {
6865
  if (!alive || !player) return;
 
 
6866
  const bnds = map.getBounds();
6867
  ARENA.ox = bnds.x0;
6868
  ARENA.oy = bnds.y0;
@@ -7093,7 +7230,7 @@ function mountComboBattler(pixi, host, opts = {}) {
7093
  const p = pa();
7094
  return p ? { name: p.name, profession: p.profession, hp: Math.round(p.hp), maxHp: Math.round(p.maxHp), skills: (p.bar || []).map((s) => s.name) } : null;
7095
  }
7096
- const ctrl = { ready, selectHero, getSpawnWorld, getSnapshot, getHero, heroEmote, buffHero, setPaused, setHeroSkills, getThreat: () => ({ ...runState }), resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
7097
  if (typeof window !== "undefined") {
7098
  window.__comboSnap = () => ctrl.getSnapshot();
7099
  window.__combo = ctrl;
 
4887
  v.sprite.scale.set(1);
4888
  v.sprite.alpha = a.alive ? 1 : 0.32;
4889
  }
4890
+ if (!a.decor) {
4891
+ drawBars(a.id, a, dtMS, b.t);
4892
+ drawStatus(a.id, a, b.t, depth);
4893
+ }
4894
  }
4895
  }
4896
  function updateFloats(dtMS) {
 
6184
  const ARENA = { ox: 0, oy: 0 };
6185
  const fieldToWorld = (fx, fy) => ({ x: ARENA.ox + fx * G, y: ARENA.oy + fy * G });
6186
  const worldToField = (wx, wy) => ({ x: (wx - ARENA.ox) / G, y: (wy - ARENA.oy) / G });
6187
+ const FSTEP_C = TILE8 / G;
6188
+ const CINE_SPRITE_MUL = 4;
6189
  const map = createGameWorld(pixi, host, { seed, keyboardPan: false });
6190
  const roamWalkable = gameWorldWalkable(seed);
6191
  const world = { walkable: (fx, fy) => {
 
6198
  return { forgottenPlains: e, orc: e, necropolis: e };
6199
  })();
6200
  let battle = null, R = null, combatRoot = null, rings = null, markers = null, navDbg = null, spawnFn = null, dead = false;
6201
+ let cinematic = false, cineActors = null;
6202
  let emoteLayer = null;
6203
  let skillVfxLayer = null;
6204
  const emoPrev = /* @__PURE__ */ new Map();
 
6866
  routePath = null;
6867
  routeIdx = 0;
6868
  }
6869
+ function cineSnapWorld(wx, wy) {
6870
+ const f = worldToField(wx, wy);
6871
+ for (let r = 0; r <= 18; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
6872
+ if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
6873
+ const x = f.x + dx * FSTEP_C, y = f.y + dy * FSTEP_C;
6874
+ if (world.walkable(x, y)) return fieldToWorld(x, y);
6875
+ }
6876
+ return { x: wx, y: wy };
6877
+ }
6878
+ async function startCinematic({ groups = [] } = {}) {
6879
+ if (!alive) return [];
6880
+ if (battle || combatRoot) teardownCombat();
6881
+ const bnds = map.getBounds();
6882
+ ARENA.ox = bnds.x0;
6883
+ ARENA.oy = bnds.y0;
6884
+ const field = { w: (bnds.x1 - bnds.x0) / G, h: (bnds.y1 - bnds.y0) / G };
6885
+ battle = makeTeamBattle({ seed, players: [], enemies: [], sandbox: true, freeCast: false, world, field });
6886
+ cinematic = true;
6887
+ cineActors = [];
6888
+ combatRoot = new Container();
6889
+ combatRoot.scale.set(G);
6890
+ combatRoot.position.set(ARENA.ox, ARENA.oy);
6891
+ const units = new Container();
6892
+ units.sortableChildren = true;
6893
+ const fx = new Container();
6894
+ const proj = new Graphics();
6895
+ combatRoot.addChild(units, proj, fx);
6896
+ map.getEntityLayer().addChild(combatRoot);
6897
+ const firstSheet = groups[0]?.units?.[0]?.sheets;
6898
+ const ch = await contentHeight(firstSheet?.idle || firstSheet?.walk);
6899
+ if (!alive || !cinematic) return [];
6900
+ depthScale.v = SPRITE_TILES * TILE8 / ((ch || 24) * G) * CINE_SPRITE_MUL;
6901
+ R = await createCombatRenderer({ pixi, defsById: {}, layers: { units, fx, projLayer: proj }, coords: { mapX: (x) => x, mapY: (y) => y, depthOf: () => depthScale.v }, getBattle: () => battle });
6902
+ if (!alive || !cinematic) return [];
6903
+ let n = 0;
6904
+ const anchors = [];
6905
+ for (const g of groups) {
6906
+ const anchor = cineSnapWorld(g.world.x, g.world.y);
6907
+ anchors.push(anchor);
6908
+ const center = worldToField(anchor.x, anchor.y);
6909
+ const list = g.units || [];
6910
+ const GOLDEN = Math.PI * (3 - Math.sqrt(5));
6911
+ for (let i = 0; i < list.length; i++) {
6912
+ const entry = list[i];
6913
+ const ang = i * GOLDEN;
6914
+ const rad = (0.6 + Math.sqrt(i) * 0.85) * FSTEP_C * CINE_SPRITE_MUL;
6915
+ let pos = { x: center.x + Math.cos(ang) * rad, y: center.y + Math.sin(ang) * rad };
6916
+ if (!world.walkable(pos.x, pos.y)) pos = center;
6917
+ const id = "C" + n++;
6918
+ const a = spawnActor(battle, { ...entry.unit || {}, name: entry.name }, "enemy", id);
6919
+ a.x = pos.x;
6920
+ a.y = pos.y;
6921
+ a.decor = true;
6922
+ a.faceX = Math.cos(ang) < 0 ? -1 : 1;
6923
+ a.faceY = 0;
6924
+ a.home = { x: pos.x, y: pos.y };
6925
+ a.wander = { tx: pos.x, ty: pos.y, wait: Math.random() * 2 };
6926
+ if (alive && cinematic) await R.addActor(id, { name: entry.name, ...sd(entry.sheets) });
6927
+ cineActors.push(a);
6928
+ }
6929
+ }
6930
+ if (!alive || !cinematic) {
6931
+ stopCinematic();
6932
+ return [];
6933
+ }
6934
+ offTick = map.onTick(cineTick);
6935
+ return anchors;
6936
+ }
6937
+ function cineTick(ticker) {
6938
+ if (!cinematic || !battle || !R || paused) return;
6939
+ const dtMS = ticker.deltaMS, dt = Math.min(dtMS / 1e3, 0.1);
6940
+ battle.t += dt;
6941
+ const SPEED = 8 * FSTEP_C * CINE_SPRITE_MUL;
6942
+ for (const a of cineActors || []) {
6943
+ const w = a.wander;
6944
+ const dx = w.tx - a.x, dy = w.ty - a.y, d = Math.hypot(dx, dy);
6945
+ if (d < 1 || w.wait > 0) {
6946
+ a.moving = false;
6947
+ w.wait -= dt;
6948
+ if (w.wait <= 0) {
6949
+ const ang = Math.random() * Math.PI * 2, rr = (0.5 + Math.random() * 1.6) * FSTEP_C * CINE_SPRITE_MUL;
6950
+ const nx = a.home.x + Math.cos(ang) * rr, ny = a.home.y + Math.sin(ang) * rr;
6951
+ if (world.walkable(nx, ny)) {
6952
+ w.tx = nx;
6953
+ w.ty = ny;
6954
+ }
6955
+ w.wait = 0.8 + Math.random() * 2.5;
6956
+ }
6957
+ } else {
6958
+ const s = Math.min(d, SPEED * dt);
6959
+ a.x += dx / d * s;
6960
+ a.y += dy / d * s;
6961
+ a.faceX = dx < 0 ? -1 : 1;
6962
+ a.faceY = dy < 0 ? -1 : 1;
6963
+ a.moving = true;
6964
+ }
6965
+ }
6966
+ R.syncActors(battle, dtMS, battle.t);
6967
+ }
6968
+ function stopCinematic() {
6969
+ if (!cinematic && !cineActors) return;
6970
+ cinematic = false;
6971
+ cineActors = null;
6972
+ teardownCombat();
6973
+ }
6974
+ function getCineAnchors(count = 6) {
6975
+ const out = [];
6976
+ const push = (tx, ty) => {
6977
+ if (!roamWalkable(tx, ty)) return false;
6978
+ const x = (tx + 0.5) * TILE8, y = (ty + 0.5) * TILE8;
6979
+ if (out.some((a) => Math.hypot(a.x - x, a.y - y) < 60 * TILE8)) return false;
6980
+ out.push({ x, y, biome: map.biomeAt(tx, ty) || "forgottenPlains" });
6981
+ return true;
6982
+ };
6983
+ try {
6984
+ const sw = gameWorldSpawn(seed);
6985
+ push(Math.round(sw.x / TILE8), Math.round(sw.y / TILE8));
6986
+ } catch {
6987
+ }
6988
+ const bnds = map.getBounds();
6989
+ const tx0 = Math.round(bnds.x0 / TILE8), ty0 = Math.round(bnds.y0 / TILE8);
6990
+ const tw = Math.round((bnds.x1 - bnds.x0) / TILE8), th = Math.round((bnds.y1 - bnds.y0) / TILE8);
6991
+ let guard = 0;
6992
+ while (out.length < count && guard++ < 4e3) push(tx0 + (8 + Math.random() * (tw - 16) | 0), ty0 + (8 + Math.random() * (th - 16) | 0));
6993
+ for (let i = out.length - 1; i > 0; i--) {
6994
+ const j = Math.random() * (i + 1) | 0;
6995
+ [out[i], out[j]] = [out[j], out[i]];
6996
+ }
6997
+ return out;
6998
+ }
6999
  async function spawnHero(player) {
7000
  if (!alive || !player) return;
7001
+ cinematic = false;
7002
+ cineActors = null;
7003
  const bnds = map.getBounds();
7004
  ARENA.ox = bnds.x0;
7005
  ARENA.oy = bnds.y0;
 
7230
  const p = pa();
7231
  return p ? { name: p.name, profession: p.profession, hp: Math.round(p.hp), maxHp: Math.round(p.maxHp), skills: (p.bar || []).map((s) => s.name) } : null;
7232
  }
7233
+ const ctrl = { ready, selectHero, startCinematic, stopCinematic, getCineAnchors, getSpawnWorld, getSnapshot, getHero, heroEmote, buffHero, setPaused, setHeroSkills, getThreat: () => ({ ...runState }), resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
7234
  if (typeof window !== "undefined") {
7235
  window.__comboSnap = () => ctrl.getSnapshot();
7236
  window.__combo = ctrl;
web/tiny.js CHANGED
@@ -15,7 +15,7 @@ import { mountMapSandbox } from '/web/mapSandbox.js'
15
  import { mountComboBattler } from '/web/comboBattler.js'
16
  import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
17
  import { mountHeroCreator, animateIdleIcon } from '/web/heroCreator.js'
18
- import { listPersonas, onRosterChange, getPortrait, getPersona, patchPersona, setActiveHeroId } from '/web/personaStore.js'
19
  import { applyXp, enemyXpValue, levelProgress, xpToNext, appendEvent, statBonuses } from '/web/progression.js'
20
  import { getSkillIcon } from '/web/personaStore.js'
21
  import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
@@ -24,6 +24,8 @@ import { forgeSkillForHero } from '/web/skillForge.js'
24
  import { mountAfterAction } from '/web/afterAction.js'
25
  import { mountLevelUp, applyPerk } from '/web/levelUpCards.js'
26
  import { createTutorial } from '/web/tutorial.js'
 
 
27
 
28
  // First-run guided tour (in-world copy). Steps advance as the player acts; always skippable.
29
  const TUTORIAL_STEPS = [
@@ -302,6 +304,17 @@ async function fillAvatar(box, persona, chars, sizePx) {
302
  if (pc?.idle) animateIdleIcon(box, spriteUrl(pc.idle), sizePx)
303
  }
304
  }
 
 
 
 
 
 
 
 
 
 
 
305
  // Hero detail page (before spawning): big portrait/idle + name/class/about/quote and a Select button.
306
  // onSelect fires when confirmed; Back / backdrop just close (returns to the picker).
307
  function openHeroDetail(host, persona, chars, onSelect) {
@@ -320,7 +333,8 @@ function openHeroDetail(host, persona, chars, onSelect) {
320
  const select = document.createElement('button'); select.className = 'hero-detail-select'; select.type = 'button'; select.textContent = 'Select ▶'
321
  foot.append(back, select)
322
  card.append(portrait, info, foot); backdrop.append(card); host.appendChild(backdrop)
323
- const close = () => backdrop.remove()
 
324
  back.addEventListener('click', close)
325
  backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close() })
326
  select.addEventListener('click', () => { close(); onSelect() })
@@ -425,10 +439,76 @@ whenEl('battle-stage', async (el) => {
425
  // Cinematic: the picker presents a zoomed-out overview; confirming a hero flies the camera DOWN to
426
  // the spawn point and drops them in there.
427
  const flyToOverview = () => { const b = comboCtrl.map.getBounds(); if (b) comboCtrl.map.flyTo((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, OVERVIEW_ZOOM, 700).catch(() => {}) }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  const spawnWithFly = (p) => {
 
 
429
  picker?.remove(); picker = null
430
  // Re-read from the store so we carry live progression (level/xp/skills), not a stale picker copy.
431
  currentHero = (p && p.id && getPersona(p.id)) || p
 
432
  setActiveHeroId(currentHero?.id || null)
433
  runStart = { level: currentHero?.progression?.level || 1 } // for the after-action "levels gained"
434
  deathShown = false
@@ -497,7 +577,9 @@ whenEl('battle-stage', async (el) => {
497
  return buildHeroPicker(el, personas.length ? personas : [FALLBACK], chars, onPick,
498
  () => openCreateModal(el, spawnWithFly))
499
  }
500
- const showPicker = () => { xpHud.style.display = 'none'; if (!picker) { picker = buildPicker(); flyToOverview() } }
 
 
501
  // Rebuild the open picker whenever the roster changes — so a hero just created (or removed) shows
502
  // up in the "Choose your hero" bar without waiting for the next time it reopens.
503
  const refreshPicker = () => { if (picker) { picker.remove(); picker = buildPicker() } }
@@ -507,6 +589,13 @@ whenEl('battle-stage', async (el) => {
507
  const sheetBtn = document.createElement('button'); sheetBtn.type = 'button'; sheetBtn.title = 'Character (C)'; sheetBtn.dataset.tut = 'sheet'
508
  sheetBtn.innerHTML = '☰<span style="font:700 9px var(--tac-font,system-ui);display:block;margin-top:-2px;color:#9aa4b2;letter-spacing:.04em">C</span>'
509
  sheetBtn.style.cssText = 'position:absolute;top:14px;right:14px;z-index:7;width:44px;height:46px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.85);color:#e8e8e8;font:600 17px var(--tac-font,system-ui);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1'
 
 
 
 
 
 
 
510
  const sheet = document.createElement('aside')
511
  sheet.style.cssText = 'position:absolute;top:0;right:0;bottom:0;width:min(300px,82vw);z-index:8;background:rgba(16,20,27,.97);border-left:1px solid #2a3340;box-shadow:-8px 0 24px rgba(0,0,0,.45);transform:translateX(100%);transition:transform .22s ease;overflow-y:auto;color:#e8e8e8;font:13px var(--tac-font,system-ui);box-sizing:border-box;padding:18px 16px'
512
  // A forged-skill detail bottom-sheet: big action illustration (lazy Klein render, cached),
@@ -761,7 +850,7 @@ whenEl('battle-stage', async (el) => {
761
  const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
762
  if (e.key === 'c' || e.key === 'C') { setSheet(!sheetOpen); e.preventDefault() }
763
  })
764
- el.append(sheetBtn, sheet)
765
 
766
  // ── XP / level HUD chip (top-left) — always visible while a hero is in play ──
767
  const xpHud = document.createElement('div')
 
15
  import { mountComboBattler } from '/web/comboBattler.js'
16
  import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
17
  import { mountHeroCreator, animateIdleIcon } from '/web/heroCreator.js'
18
+ import { listPersonas, onRosterChange, getPortrait, getPersona, patchPersona, setActiveHeroId, getAudio } from '/web/personaStore.js'
19
  import { applyXp, enemyXpValue, levelProgress, xpToNext, appendEvent, statBonuses } from '/web/progression.js'
20
  import { getSkillIcon } from '/web/personaStore.js'
21
  import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
 
24
  import { mountAfterAction } from '/web/afterAction.js'
25
  import { mountLevelUp, applyPerk } from '/web/levelUpCards.js'
26
  import { createTutorial } from '/web/tutorial.js'
27
+ import { mountTitleSequence } from '/web/titleSequence.js'
28
+ import { playWav, stopPreview } from '/web/tts.js'
29
 
30
  // First-run guided tour (in-world copy). Steps advance as the player acts; always skippable.
31
  const TUTORIAL_STEPS = [
 
304
  if (pc?.idle) animateIdleIcon(box, spriteUrl(pc.idle), sizePx)
305
  }
306
  }
307
+ // Play a hero's SAVED voice — the cached quote WAV (IndexedDB) made when the hero was created.
308
+ // Never regenerates; silent no-op for heroes that have no saved voice yet (e.g. the Fighter fallback).
309
+ async function playHeroVoice(hero) {
310
+ if (!hero?.id) return
311
+ try {
312
+ const blob = await getAudio(hero.id)
313
+ if (!blob) return
314
+ try { stopPreview() } catch { /* ignore */ }
315
+ await playWav(await blob.arrayBuffer())
316
+ } catch { /* ignore */ }
317
+ }
318
  // Hero detail page (before spawning): big portrait/idle + name/class/about/quote and a Select button.
319
  // onSelect fires when confirmed; Back / backdrop just close (returns to the picker).
320
  function openHeroDetail(host, persona, chars, onSelect) {
 
333
  const select = document.createElement('button'); select.className = 'hero-detail-select'; select.type = 'button'; select.textContent = 'Select ▶'
334
  foot.append(back, select)
335
  card.append(portrait, info, foot); backdrop.append(card); host.appendChild(backdrop)
336
+ playHeroVoice(persona) // greet the player in the hero's own saved voice when their page opens
337
+ const close = () => { try { stopPreview() } catch { /* ignore */ } backdrop.remove() }
338
  back.addEventListener('click', close)
339
  backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close() })
340
  select.addEventListener('click', () => { close(); onSelect() })
 
439
  // Cinematic: the picker presents a zoomed-out overview; confirming a hero flies the camera DOWN to
440
  // the spawn point and drops them in there.
441
  const flyToOverview = () => { const b = comboCtrl.map.getBounds(); if (b) comboCtrl.map.flyTo((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, OVERVIEW_ZOOM, 700).catch(() => {}) }
442
+ // ── Music: one looping theme that plays at full on the title screen and DUCKS (weaker) once the
443
+ // game starts. Owned here (not in the title sequence) so it survives the hero-pick handoff and can
444
+ // be muted. Autoplay is gated until a user gesture, so we retry on the first interaction. ──
445
+ const music = (() => {
446
+ const MUTE_KEY = 'tinyarmy.musicMuted'
447
+ const FULL = 0.55, DUCK = 0.16
448
+ const a = new Audio('/web/audio/title-theme.mp3'); a.loop = true; a.volume = 0
449
+ let target = FULL, muted = false, fade = null
450
+ try { muted = localStorage.getItem(MUTE_KEY) === '1' } catch { /* ignore */ }
451
+ const fadeTo = (want) => {
452
+ clearInterval(fade)
453
+ fade = setInterval(() => {
454
+ if (Math.abs(a.volume - want) < 0.02) { a.volume = want; clearInterval(fade); return }
455
+ a.volume += (want - a.volume) * 0.18
456
+ }, 60)
457
+ }
458
+ // Apply current state: muted → hard pause (truly stops it); else play + fade to target volume.
459
+ const apply = () => {
460
+ if (muted) { clearInterval(fade); try { a.pause() } catch { /* ignore */ } a.volume = 0 }
461
+ else { a.play().catch(() => {}); fadeTo(target) }
462
+ }
463
+ // Autoplay is gated until a user gesture — start (if not muted) on the first interaction.
464
+ const onGesture = () => { apply(); window.removeEventListener('pointerdown', onGesture); window.removeEventListener('keydown', onGesture) }
465
+ window.addEventListener('pointerdown', onGesture); window.addEventListener('keydown', onGesture)
466
+ const mutedListeners = new Set()
467
+ return {
468
+ full() { target = FULL; apply() },
469
+ duck() { target = DUCK; apply() },
470
+ isMuted: () => muted,
471
+ toggleMute() { muted = !muted; try { localStorage.setItem(MUTE_KEY, muted ? '1' : '0') } catch { /* ignore */ } apply(); for (const fn of mutedListeners) { try { fn(muted) } catch { /* ignore */ } } return muted },
472
+ onMuteChange(fn) { mutedListeners.add(fn) },
473
+ }
474
+ })()
475
+ // ── Cinematic title sequence (ambient camera tour over decorative warbands behind the picker) ──
476
+ let titleSeq = null
477
+ const ROSTER_POOL = Object.values(rosters).flat().filter(Boolean)
478
+ // Build "little armies" at scenic anchors: mostly the anchor-biome roster, with the odd wildcard
479
+ // pulled from the whole pool so groups read as a varied mix of characters and monsters.
480
+ const buildCineGroups = () => {
481
+ if (!ROSTER_POOL.length) return []
482
+ const anchors = comboCtrl.getCineAnchors?.(6) || []
483
+ return anchors.map((an) => {
484
+ const br = (rosters[an.biome] && rosters[an.biome].length) ? rosters[an.biome] : ROSTER_POOL
485
+ const n = 13 + (Math.random() * 5 | 0) // ~15 per group (13–17)
486
+ const units = []
487
+ for (let i = 0; i < n; i++) {
488
+ const src = Math.random() < 0.8 ? br : ROSTER_POOL
489
+ const e = src[Math.random() * src.length | 0]
490
+ if (e) units.push(e)
491
+ }
492
+ return { world: { x: an.x, y: an.y }, units }
493
+ }).filter((g) => g.units.length)
494
+ }
495
+ const startTitle = () => {
496
+ music.full() // theme at full on the title screen
497
+ if (titleSeq) return
498
+ titleSeq = mountTitleSequence(el, comboCtrl, {
499
+ groups: buildCineGroups(),
500
+ title: 'TINY ARMY',
501
+ tagline: 'Pick a hero. Hold the line.',
502
+ })
503
+ }
504
+ const stopTitle = () => { titleSeq?.stop(); titleSeq = null }
505
  const spawnWithFly = (p) => {
506
+ stopTitle()
507
+ music.duck() // theme keeps playing but weaker now the game has started
508
  picker?.remove(); picker = null
509
  // Re-read from the store so we carry live progression (level/xp/skills), not a stale picker copy.
510
  currentHero = (p && p.id && getPersona(p.id)) || p
511
+ playHeroVoice(currentHero) // hero's saved voice as they deploy (no regeneration)
512
  setActiveHeroId(currentHero?.id || null)
513
  runStart = { level: currentHero?.progression?.level || 1 } // for the after-action "levels gained"
514
  deathShown = false
 
577
  return buildHeroPicker(el, personas.length ? personas : [FALLBACK], chars, onPick,
578
  () => openCreateModal(el, spawnWithFly))
579
  }
580
+ // Picker is usable immediately; the title sequence runs the ambient camera tour behind it (so we
581
+ // start that instead of the old static fly-to-overview). flyToOverview stays as a fallback.
582
+ const showPicker = () => { xpHud.style.display = 'none'; if (!picker) { picker = buildPicker(); if (ROSTER_POOL.length) startTitle(); else flyToOverview() } }
583
  // Rebuild the open picker whenever the roster changes — so a hero just created (or removed) shows
584
  // up in the "Choose your hero" bar without waiting for the next time it reopens.
585
  const refreshPicker = () => { if (picker) { picker.remove(); picker = buildPicker() } }
 
589
  const sheetBtn = document.createElement('button'); sheetBtn.type = 'button'; sheetBtn.title = 'Character (C)'; sheetBtn.dataset.tut = 'sheet'
590
  sheetBtn.innerHTML = '☰<span style="font:700 9px var(--tac-font,system-ui);display:block;margin-top:-2px;color:#9aa4b2;letter-spacing:.04em">C</span>'
591
  sheetBtn.style.cssText = 'position:absolute;top:14px;right:14px;z-index:7;width:44px;height:46px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.85);color:#e8e8e8;font:600 17px var(--tac-font,system-ui);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1'
592
+ // Mute toggle for the music, sitting just left of the character-sheet button.
593
+ const muteBtn = document.createElement('button'); muteBtn.type = 'button'
594
+ muteBtn.style.cssText = 'position:absolute;top:14px;right:66px;z-index:7;width:44px;height:46px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.85);color:#e8e8e8;font:600 18px var(--tac-font,system-ui);cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1'
595
+ const renderMute = (m) => { muteBtn.textContent = m ? '🔇' : '🔊'; muteBtn.title = m ? 'Unmute music' : 'Mute music' }
596
+ renderMute(music.isMuted())
597
+ music.onMuteChange(renderMute)
598
+ muteBtn.addEventListener('click', () => renderMute(music.toggleMute()))
599
  const sheet = document.createElement('aside')
600
  sheet.style.cssText = 'position:absolute;top:0;right:0;bottom:0;width:min(300px,82vw);z-index:8;background:rgba(16,20,27,.97);border-left:1px solid #2a3340;box-shadow:-8px 0 24px rgba(0,0,0,.45);transform:translateX(100%);transition:transform .22s ease;overflow-y:auto;color:#e8e8e8;font:13px var(--tac-font,system-ui);box-sizing:border-box;padding:18px 16px'
601
  // A forged-skill detail bottom-sheet: big action illustration (lazy Klein render, cached),
 
850
  const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
851
  if (e.key === 'c' || e.key === 'C') { setSheet(!sheetOpen); e.preventDefault() }
852
  })
853
+ el.append(sheetBtn, muteBtn, sheet)
854
 
855
  // ── XP / level HUD chip (top-left) — always visible while a hero is in play ──
856
  const xpHud = document.createElement('div')
web/titleSequence.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Cinematic title sequence for the hero-select screen. The hero picker is usable immediately;
2
+ // this just runs an ambient camera tour over decorative "warbands" (spawned via
3
+ // comboCtrl.startCinematic) and lays a TINY ARMY wordmark + letterbox/vignette over the map,
4
+ // with an optional looping theme. Everything here is non-blocking (pointer-events:none) and below
5
+ // the picker (z-index 6), so it never gets in the player's way.
6
+ //
7
+ // const seq = mountTitleSequence(host, comboCtrl, { groups, audioUrl, title, tagline })
8
+ // ...later, on hero pick/teardown: seq.stop()
9
+
10
+ export function mountTitleSequence(host, comboCtrl, opts = {}) {
11
+ // Music is owned by the caller (tiny.js) so it can persist (ducked) into gameplay and be muted —
12
+ // this module only drives the camera tour + wordmark overlay.
13
+ const { groups = [], title = 'TINY ARMY', tagline = 'Pick a hero. Hold the line.' } = opts
14
+ let stopped = false
15
+ const timers = new Set()
16
+ const sleep = (ms) => new Promise((res) => { const t = setTimeout(() => { timers.delete(t); res() }, ms); timers.add(t) })
17
+
18
+ // ── Overlay: letterbox bars + vignette + wordmark (all non-interactive) ──
19
+ const layer = document.createElement('div')
20
+ layer.className = 'title-seq'
21
+ layer.style.cssText = 'position:absolute;inset:0;z-index:5;pointer-events:none;overflow:hidden;opacity:0;transition:opacity .8s ease'
22
+ const bar = (edge) => {
23
+ const b = document.createElement('div')
24
+ b.style.cssText = `position:absolute;left:0;right:0;${edge}:0;height:7vh;background:linear-gradient(${edge === 'top' ? '180deg' : '0deg'},rgba(0,0,0,.75),rgba(0,0,0,0));`
25
+ return b
26
+ }
27
+ const vignette = document.createElement('div')
28
+ vignette.style.cssText = 'position:absolute;inset:0;background:radial-gradient(120% 90% at 50% 42%,transparent 55%,rgba(4,6,10,.55) 100%)'
29
+ const wrap = document.createElement('div')
30
+ wrap.style.cssText = 'position:absolute;left:0;right:0;top:13%;text-align:center;padding:0 16px;transition:opacity 1s ease,transform 1s ease'
31
+ const h = document.createElement('div')
32
+ h.textContent = title
33
+ h.style.cssText = 'font:800 clamp(34px,8vw,76px) var(--tac-font,Georgia,serif);letter-spacing:.06em;color:#f4ecd8;text-shadow:0 3px 0 rgba(0,0,0,.35),0 0 26px rgba(255,196,92,.35);line-height:1'
34
+ const sub = document.createElement('div')
35
+ sub.textContent = tagline
36
+ sub.style.cssText = 'margin-top:10px;font:600 clamp(12px,2.4vw,17px) var(--tac-font,system-ui);color:#cdb98e;letter-spacing:.18em;text-transform:uppercase;opacity:.9'
37
+ wrap.append(h, sub)
38
+ layer.append(bar('top'), bar('bottom'), vignette, wrap)
39
+ host.appendChild(layer)
40
+ requestAnimationFrame(() => { layer.style.opacity = '1' })
41
+ // The big wordmark holds, then settles smaller/translucent so it reads as ambient branding
42
+ // rather than a blocking splash (the picker is live the whole time).
43
+ const settle = setTimeout(() => {
44
+ if (stopped) return
45
+ wrap.style.opacity = '.5'
46
+ wrap.style.transform = 'translateY(-4%) scale(.7)'
47
+ }, 4200)
48
+ timers.add(settle)
49
+
50
+ // ── Camera director: tour the warband anchors with push-in / pull-out, looping forever. ──
51
+ const rand = (a, b) => a + Math.random() * (b - a)
52
+ async function fly(x, y, z, ms) {
53
+ if (stopped) return
54
+ try { await comboCtrl.map.flyTo(x, y, z, ms) } catch { /* interrupted */ }
55
+ }
56
+ async function director(anchors) {
57
+ if (!anchors.length) { // nothing spawned — just drift the overview so it's not dead-still
58
+ const b = comboCtrl.map.getBounds()
59
+ while (!stopped) { await fly((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, 0.45, 4000); await sleep(2500) }
60
+ return
61
+ }
62
+ const b = comboCtrl.map.getBounds()
63
+ const mid = { x: (b.x0 + b.x1) / 2, y: (b.y0 + b.y1) / 2 }
64
+ let i = 0
65
+ while (!stopped) {
66
+ const a = anchors[i % anchors.length]
67
+ // Push in on the warband and dwell. Wide enough to show lots of map; the decor sprites are
68
+ // scaled up (CINE_SPRITE_MUL in startCinematic) to compensate, so characters keep their size.
69
+ await fly(a.x, a.y, rand(1.15, 1.4), 2600)
70
+ await sleep(1900)
71
+ // Every third beat, pull out to a wide establishing shot before the next group.
72
+ if (i % 3 === 2) { await fly(mid.x + rand(-400, 400), mid.y + rand(-300, 300), rand(0.4, 0.55), 2300); await sleep(900) }
73
+ i++
74
+ }
75
+ }
76
+
77
+ // Spawn the warbands, then start the tour over wherever they actually landed.
78
+ ;(async () => {
79
+ let anchors = []
80
+ try { anchors = (await comboCtrl.startCinematic({ groups })) || [] } catch { anchors = [] }
81
+ if (stopped) { try { comboCtrl.stopCinematic() } catch {} ; return }
82
+ director(anchors)
83
+ })()
84
+
85
+ function stop() {
86
+ if (stopped) return
87
+ stopped = true
88
+ for (const t of timers) clearTimeout(t)
89
+ timers.clear()
90
+ try { comboCtrl.stopCinematic() } catch { /* ignore */ }
91
+ layer.style.opacity = '0'
92
+ setTimeout(() => { try { layer.remove() } catch {} }, 800)
93
+ }
94
+
95
+ return { stop }
96
+ }