初始提交:3 款纯音频游戏 + 共享引擎 + 完整文档与资产管线

- 共享引擎(app/src/main):GestureEngine 6 手势识别、AudioEngine 三层音频
  (ExoPlayer BGM 交叉淡入 / 对白 ducking / SoundPool 低延迟)、Game/GameEngine
  状态机接口、MainActivity 全屏封面 + TalkBack
- 《声命线》storyline:末世互动广播剧,3 幕 8 抉择点 4 结局
- 《声东击西》reflex:听声辨位反应,12 波战斗 + BGM 三档 BPM 变速
- 《暗室》escape:3x3 网格密室逃脱,钥匙→铁柜→保险丝→电箱→大门谜题链
- 文档:setup(从零搭建)/ architecture(架构)/ engine-api(引擎 API)/
  asset-pipeline(资产流水线)/ games/*(三款游戏设计文档)
- 资产管线:Qwen3-TTS 对白 + 程序合成音效/BGM + Mureka BGM 的 gen 脚本与台账,
  全部 ogg 资产入库可复现
- env.d 环境脚本:android/tts 入库;sfx/mureka 含密钥不入库,仅提交 .example.sh 模板
This commit is contained in:
SoundGame Dev
2026-08-29 13:00:34 +08:00
commit f0f0a9b50e
446 changed files with 2627836 additions and 0 deletions
@@ -0,0 +1,310 @@
"""Backend-agnostic gradio UI helpers, copied from optimized/mlx/scripts/sa3_gradio.py.
Forked rather than shared: the MLX and TensorRT trees install independently, with their own
requirements, and a common module would couple two deployment units that today have no reason to
know about each other. Nothing in here touches a backend -- these are player/history/queue
renderers and filename helpers -- so the two copies only need to be reconciled when the UI
itself changes.
"""
from __future__ import annotations
import html as html_lib
import re
import time
import urllib.parse
import wave
SAMPLE_RATE = 44100 # every SA3 codec is 44.1 kHz stereo
_JS_FIX_SLIDERS = (
"() => setTimeout(() => {"
"document.querySelectorAll('input[type=range]').forEach(r => {"
"const min = +r.min || 0, max = +r.max || 100, v = +r.value;"
"if (max > min) r.style.setProperty('--range_progress',"
"((v - min) / (max - min) * 100) + '%');"
"});}, 150)"
)
_JS_PAUSE_OTHERS = (
"if(this.isConnected){"
"var t=this;var L=window._sa3All=(window._sa3All||[]);"
"if(L.indexOf(t)<0)L.push(t);"
"L.forEach(function(o){if(o!==t){try{o.pause()}catch(e){}}});"
"document.querySelectorAll('audio').forEach(function(o){if(o!==t)o.pause();});"
"window._sa3All=L.filter(function(o){return o===t||o.isConnected;});}"
"else{this.pause();}")
_JS_PLAYHEAD = ("var p=this.closest('.blk').querySelector('.ph');"
"if(p&&this.duration)p.style.left=(this.currentTime/this.duration*100)+'%';")
# Main-player position ledger (for Hotswap): every timeupdate/play/pause stamps
# the current position so a freshly swapped-in element can resume exactly there.
# Guards (all verified via CDP against the live page):
# - isConnected: teardown fires a pause AND a final timeupdate on the removed
# element — detached elements never write the ledger.
# - unresumed hotswap candidates (data-hs set, hsd not yet) don't write either:
# with autoplay, the NEW element's 'play' event fires BEFORE loadedmetadata
# and would stamp t≈0 over the old clip's position right before the resume
# handler reads it.
_JS_POS_RECORD = ("if(this.isConnected&&!(this.dataset.hs==='1'&&!this.dataset.hsd))"
"{window._sa3Pos={t:this.currentTime,playing:!this.paused,ts:Date.now()};}")
# timeupdate variant: a PAUSED element only fires timeupdate on seeks — e.g. the
# resume handler's own currentTime assignment. Stamping playing:false there
# poisons the ledger for any second render of the same clip (gradio sometimes
# renders a component update twice), which froze the handoff chain. While
# paused, only real pause events may write.
_JS_POS_RECORD_TU = ("if(this.isConnected&&!this.paused&&"
"!(this.dataset.hs==='1'&&!this.dataset.hsd))"
"{window._sa3Pos={t:this.currentTime,playing:true,ts:Date.now()};}")
# Resilient play: Chrome's autoplay policy can reject programmatic play() once
# the transient user activation from the Generate click has expired (seconds),
# leaving the clip correctly positioned but paused. Try, retry shortly after,
# and as a last resort arm a one-shot listener so the user's next click or
# keypress anywhere resumes playback.
# Every attempt re-checks isConnected: a superseded (detached) element must
# never revive itself from a queued retry and fight the current player.
_JS_TRY_PLAY = (
"var A=this;A.play().catch(function(){setTimeout(function(){"
"if(!A.isConnected)return;"
"A.play().catch(function(){console.warn('play blocked by autoplay policy — "
"will resume on next interaction');"
"var f=function(){if(A.isConnected)A.play();"
"document.removeEventListener('pointerdown',f,true);"
"document.removeEventListener('keydown',f,true);};"
"document.addEventListener('pointerdown',f,true);"
"document.addEventListener('keydown',f,true);});},150);});")
# Hotswap resume: if the previous main audio was playing when this one arrived,
# jump to its position (+ the split-second since the last stamp) and keep going;
# beyond the new clip's duration -> start at zero. Guarded (hsd) so it applies
# once, on whichever of loadedmetadata/canplay fires first.
_JS_HOTSWAP = ("if(this.isConnected&&this.dataset.hs==='1'&&!this.dataset.hsd&&this.duration){"
"this.dataset.hsd=1;var s=window._sa3Pos;"
"this.dataset.dbg=s?(s.playing?'playing@'+s.t.toFixed(2):'notplaying@'+s.t.toFixed(2)):'noledger';"
"if(s&&s.playing){var tt=s.t+(Date.now()-s.ts)/1000;"
"this.currentTime=(tt<this.duration)?tt:0;" + _JS_TRY_PLAY + "}}")
_JS_SEEK = ("var a=this.closest('.blk').querySelector('audio');"
"var r=this.getBoundingClientRect();"
"if(a&&a.duration){a.currentTime=(event.clientX-r.left)/r.width*a.duration;a.play();}")
_JS_PROMOTE = ("var b=document.getElementById('sa3-promote');"
"if(b){(b.tagName==='BUTTON'?b:b.querySelector('button')||b).click();}")
# Scroll anchoring for the history panel: onscroll continuously records which
# item sits at the viewport top (+offset); after every re-render a hidden
# bootstrap <img onerror> restores that anchor — stick-to-top when at top,
# otherwise keep hovering over the same old item as new ones prepend.
_JS_SCROLL_RECORD = (
"var c=this,k=null,off=0,ch=c.querySelectorAll('[data-key]');"
"for(var i=0;i<ch.length;i++){if(ch[i].offsetTop+ch[i].offsetHeight>c.scrollTop)"
"{k=ch[i].getAttribute('data-key');off=ch[i].offsetTop-c.scrollTop;break}}"
"window._sa3S={atTop:c.scrollTop<8,key:k,off:off};")
_JS_SCROLL_RESTORE = (
"var c=document.getElementById('sa3-hist');var s=window._sa3S||{};"
"if(c){var el=s.key?c.querySelector('[data-key=&quot;'+s.key+'&quot;]'):null;"
"if(el&&!s.atTop){c.scrollTop=el.offsetTop-(s.off||0)}else{c.scrollTop=0}}"
"this.remove();")
def _ago(ts) -> str:
if not ts:
return ""
d = max(0.0, time.time() - ts)
if d < 10:
return "just now"
if d < 60:
return f"{int(d)}s ago"
if d < 3600:
return f"{int(d // 60)}m ago"
if d < 86400:
return f"{int(d // 3600)}h ago"
return f"{int(d // 86400)}d ago"
def condense_prompt(prompt: str) -> str:
"""Prompt → filename fragment (the main repo's verbose-naming rule):
filesystem-special characters become hyphens, capped at 150 chars."""
prompt = re.sub(r'[\\/:*?"<>|]', '-', prompt)[:150]
return prompt or "_"
def verbose_basename(prompt, negative_prompt, cfg, sigma_max, seed) -> str:
"""prompt[.neg-…].cfg{scale}[.smx{σ}].{seed} — matches the main repo's
gradio 'verbose' file naming (cfg segment only when cfg != 1)."""
base = condense_prompt(prompt)
if negative_prompt and negative_prompt.strip():
base += ".neg-" + condense_prompt(negative_prompt.strip())
if cfg != 1.0:
base += f".cfg{cfg:g}"
if sigma_max != 1.0:
base += f".smx{sigma_max:g}"
return f"{base}.{seed}"
def _save_wav(pcm_int16, out_path):
"""pcm_int16: (T, 2) int16 interleaved."""
with wave.open(str(out_path), "wb") as w:
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(SAMPLE_RATE)
w.writeframes(pcm_int16.tobytes())
def _meta_suffix(entry) -> str:
"""' · cfg 1.5 · noise 0.92 · audio2audio · inpainting' — only the non-defaults.
The sigma tag reads 'noise' for a2a runs (it IS the init_noise_level there),
'smx' otherwise."""
parts = []
cfg = entry.get("cfg", 1.0)
smx = entry.get("smx", 1.0)
mode = entry.get("mode", "")
is_a2a = "a2a" in mode or mode == "audio-to-audio"
dit = entry.get("dit", "")
if dit:
parts.append({"medium": "med", "sm-music": "sm-mus", "sm-sfx": "sm-sfx"}.get(dit, dit))
if cfg != 1.0:
parts.append(f"cfg {cfg:g}")
if smx != 1.0:
parts.append(f"{'noise' if is_a2a else 'smx'} {smx:g}")
if is_a2a:
parts.append("audio2audio")
if "inpaint" in mode:
parts.append("inpainting")
if entry.get("lora"):
parts.append(f"lora {entry['lora']}")
return "".join(f" · {p}" for p in parts)
def _neg_disp(entry) -> str:
"""Labeled negative prompt, shown only when it actually acted (cfg != 1)."""
neg = (entry.get("neg") or "").strip()
if neg and entry.get("cfg", 1.0) != 1.0:
return f' · <span style="opacity:0.75">neg: {html_lib.escape(neg)}</span>'
return ""
def render_player(entry, *, small=False, autoplay=False, autodl=False, radio=False,
bg=None, advance=False, loop=False, hotswap=False):
"""One self-contained player block: audio + caption + seekable spectrogram
with playhead. Global one-at-a-time playback via onplay pause-others.
small: audio + spectrogram side by side (half width each), 'Xm ago' caption.
advance: on ended, hop to the next history item's audio (Auto-play).
loop: native loop attribute — finished audio restarts (suppresses ended).
hotswap: main slot only — resume at the previous clip's position on arrival."""
# assemble per-event handler bodies (a duplicated attribute name would
# silently drop one handler, so each event is emitted exactly once)
on_canplay = []
if small:
attrs = f'onplay="{_JS_PAUSE_OTHERS}" ontimeupdate="{_JS_PLAYHEAD}"'
else:
attrs = (f'onplay="{_JS_PAUSE_OTHERS}{_JS_POS_RECORD}" '
f'onpause="{_JS_POS_RECORD}" '
f'ontimeupdate="{_JS_PLAYHEAD}{_JS_POS_RECORD_TU}"')
if hotswap and not small:
attrs += f' data-hs="1" onloadedmetadata="{_JS_HOTSWAP}"'
on_canplay.append(_JS_HOTSWAP) # fallback if loadedmetadata already passed
if autoplay and not small:
# the autoplay attribute is subject to the same policy — rescue a
# policy-paused clip (radio transitions, long generations)
on_canplay.append("if(this.paused&&!(this.dataset.hs==='1'&&!this.dataset.hsd))"
"{" + _JS_TRY_PLAY + "}")
if autodl:
js_name = entry["name"].replace("\\", "").replace("'", "\\'")
on_canplay.append("if(!this.dataset.dld){this.dataset.dld=1;"
"var l=document.createElement('a');l.href=this.src;"
f"l.download='{js_name}';l.click();}}")
if on_canplay:
attrs += f' oncanplay="{"".join(on_canplay)}"'
if loop:
attrs += " loop"
elif radio:
attrs += f' onended="{_JS_PROMOTE}"'
elif advance:
attrs += (' onended="var b=this.closest(\'.blk\');'
"var n=b?b.nextElementSibling:null;"
"while(n&&!n.classList.contains('blk'))n=n.nextElementSibling;"
"if(n){var a=n.querySelector('audio');if(a)a.play();}\"")
auto = "autoplay " if autoplay else ""
# Serve audio via gradio's file route instead of a data: URI — the URL ends
# with the real (verbose) filename, so right-click "Save audio as…" offers
# it instead of download.mp3, and the page stays light as history grows.
src = "gradio_api/file=" + urllib.parse.quote(entry["path"], safe="/")
audio_el = (f'<audio controls {auto}style="width:100%" {attrs} '
f'src="{src}"></audio>')
prompt_disp = html_lib.escape(entry["prompt"]) or "<i>(no prompt)</i>"
spec_core = ""
if entry.get("spec_b64"):
height = "height:56px;" if small else ""
tip = ("3-band tinted stereo mel · red=bass / green=mid / blue=high · "
"L top, R bottom · click to seek")
spec_core = (f'<div style="position:relative; cursor:pointer" title="{tip}" '
f'onclick="{_JS_SEEK}">'
f'<img src="data:image/png;base64,{entry["spec_b64"]}" '
f'style="width:100%; {height} display:block; image-rendering:pixelated; '
f'border:1px solid #333" alt="spectrogram"/>'
f'<div class="ph" style="position:absolute; top:0; bottom:0; left:0%; '
f'width:2px; background:#fff; pointer-events:none; '
f'box-shadow:0 0 4px rgba(0,0,0,.8)"></div>'
f'</div>')
if small:
row = (f'<div style="display:flex; gap:8px; align-items:center">'
f'<div style="flex:1; min-width:0">{audio_el}</div>'
f'<div style="flex:1; min-width:0">{spec_core}</div></div>')
cap = (f'<div style="font-size:0.8em; margin:2px 0; color:#888">'
f'{_ago(entry.get("ts"))} · {prompt_disp}{_neg_disp(entry)} · seed {entry["seed"]}'
f'{_meta_suffix(entry)}</div>')
style = f"padding:6px 8px;{' background:' + bg + ';' if bg else ''}"
return (f'<div class="blk" data-key="{entry["key"]}" style="{style}">'
f'{row}{cap}</div>')
return (f'<div class="blk" data-key="{entry["key"]}">'
f'{audio_el}{spec_core}</div>')
def render_history(hist, advance=False, loop=False):
if not hist:
return ""
# zebra striping instead of separators: light grey vs medium grey rows
items = "".join(
render_player(e, small=True, advance=advance, loop=loop,
bg="rgba(127,127,127,0.24)" if i % 2 else "rgba(127,127,127,0.08)")
for i, e in enumerate(hist))
boot = f'<img src="data:," style="display:none" onerror="{_JS_SCROLL_RESTORE}"/>'
return (f'<div style="font-weight:600; margin-top:14px">Previous generations ({len(hist)})</div>'
f'<div id="sa3-hist" onscroll="{_JS_SCROLL_RECORD}" '
f'style="max-height:480px; overflow-y:auto; position:relative; margin-top:6px; '
f'padding-right:6px">{boot}{items}</div>')
def render_queue_status(entry=None, generating=False):
"""Status chip for the pre-rendered next take.
Once a take is ready the chip is a CONTROL, not a label: clicking it promotes the
queued render and plays it immediately. It fires the same hidden #sa3-promote button
radio's onended uses, so both routes go through one handler — and because the click
is a real user gesture, the browser's autoplay policy lets the audio start.
While a take is still rendering there is nothing to promote, so the chip stays inert.
"""
if generating:
body, ready = "generating…", False
elif entry is not None:
p = html_lib.escape(entry["prompt"]) or "<i>(no prompt)</i>"
body = f"ready — {p}{_neg_disp(entry)} · seed {entry['seed']}{_meta_suffix(entry)}"
ready = True
else:
return ""
style = ("margin-top:2px; padding:6px 10px; background:rgba(127,127,127,0.12); "
"border-radius:6px; color:#888; font-size:0.85em")
if not ready:
return f'<div style="{style}"><b>Queued next</b> · {body}</div>'
# Enter/Space as well as click, so it behaves like the button it now is.
keys = ("if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();"
+ _JS_PROMOTE + "}")
return (f'<div class="sa3-queued" role="button" tabindex="0" '
f'title="Play this take now" '
f'style="{style}; cursor:pointer; user-select:none" '
f'onclick="{_JS_PROMOTE}" onkeydown="{keys}">'
f'<b>Queued next</b> · {body}'
f'<span style="float:right; opacity:0.75">▶ play now</span></div>')
# ── Gradio UI ──────────────────────────────────────────────────────────────