mirror of
https://github.com/ace-step/ACE-Step-1.5.git
synced 2026-07-02 16:37:04 +00:00
docs: remove static studio UI (#1178)
This commit is contained in:
parent
2ee15f717a
commit
8ac1be141c
7 changed files with 6 additions and 999 deletions
|
|
@ -191,7 +191,6 @@ LANGUAGE=en
|
|||
|--------|-------------|---------------|
|
||||
| 🖥️ **Gradio Web UI** | Interactive web interface for music generation | [Guide](./docs/en/GRADIO_GUIDE.md) |
|
||||
| 🧭 **UI Support Baseline** | Supported UI boundary and future UI parity checklist | [Guide](./docs/en/UI_SUPPORT.md) |
|
||||
| 🎚️ **Studio UI** | Optional HTML frontend (DAW-like) | [Guide](./docs/en/studio.md) |
|
||||
| 🎛️ **VST3 Plugin** | Standalone VST3 plugin (C++/GGML) for DAW integration | [acestep.vst3](https://github.com/ace-step/acestep.vst3) |
|
||||
| 🐍 **Python API** | Programmatic access for integration | [Guide](./docs/en/INFERENCE.md) |
|
||||
| 🌐 **REST API** | HTTP-based async API for services | [Guide](./docs/en/API.md) |
|
||||
|
|
|
|||
|
|
@ -587,8 +587,8 @@ async def release_task(request: Request, authorization: Optional[str] = Header(N
|
|||
|
||||
|
||||
# Origins that are expected to call the API:
|
||||
# - "null" → studio.html opened via file:// protocol
|
||||
# - http://localhost:* → local dev servers / Gradio UI
|
||||
# - "null" → local files opened via file:// protocol
|
||||
# - http://localhost:* → local dev servers / Gradio UI
|
||||
# - http://127.0.0.1:* → same, numeric form
|
||||
_CORS_KWARGS = dict(
|
||||
allow_origins=["null", "http://localhost", "http://127.0.0.1"],
|
||||
|
|
@ -599,7 +599,7 @@ _CORS_KWARGS = dict(
|
|||
|
||||
|
||||
def _add_cors_middleware(app):
|
||||
"""Add CORS middleware so browser-based frontends (e.g. studio.html via file://) can call the API."""
|
||||
"""Add CORS middleware so browser-based local frontends can call the API."""
|
||||
app.add_middleware(CORSMiddleware, **_CORS_KWARGS)
|
||||
|
||||
|
||||
|
|
@ -651,4 +651,3 @@ def setup_api_routes(demo, dit_handler, llm_handler, api_key: Optional[str] = No
|
|||
app.state.dit_handler = dit_handler
|
||||
app.state.llm_handler = llm_handler
|
||||
app.include_router(router)
|
||||
|
||||
|
|
|
|||
|
|
@ -155,7 +155,6 @@ function sidebarEN() {
|
|||
{ text: 'Gradio UI Guide', link: '/en/GRADIO_GUIDE' },
|
||||
{ text: 'UI Support Baseline', link: '/en/UI_SUPPORT' },
|
||||
{ text: 'CLI', link: '/en/CLI' },
|
||||
{ text: 'Studio', link: '/en/studio' },
|
||||
{ text: "Musician's Guide", link: '/en/ace_step_musicians_guide' },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,16 +15,15 @@ multiple overlapping frontends alive.
|
|||
| OpenRouter-compatible API server | `acestep-openrouter`, `openrouter/openrouter_api_server.py` | Supported API-only surface | OpenAI/OpenRouter-compatible integration path. Not a UI. |
|
||||
| Generation CLI | `cli.py`, `acestep` console script | Supported command-line workflow | Useful for scripting, configuration, and non-browser generation. Not a replacement for the web UI. |
|
||||
| Side-Step training CLI/wizard | `train.py`, `acestep/training_v2/ui` | Supported or separately scoped training workflow | Rich terminal workflow for training. Do not remove until Gradio training parity and Side-Step ownership are explicitly reviewed. |
|
||||
| Static Studio HTML UI | `ui/studio.html` | Experimental; removal candidate | Frontend-only prototype that calls REST endpoints directly. It duplicates UI surface area and should be removed or formally deprecated before new UI work. |
|
||||
| Static Studio HTML UI | `ui/studio.html` | Removed | The experimental frontend-only prototype was removed to avoid duplicating product UI surface area before new UI work. |
|
||||
| Streamlit UI | `acestep/ui/streamlit` | Experimental; removal candidate | Independent prototype with its own model cache, navigation, settings, project storage, and docs. It duplicates product UI responsibilities. |
|
||||
|
||||
## Near-Term Cleanup Plan
|
||||
|
||||
1. Keep Gradio as the supported product UI while the next UI is designed.
|
||||
2. Keep API servers as integration surfaces, not as UI cleanup targets.
|
||||
3. Remove the static Studio HTML UI in a focused PR.
|
||||
4. Remove the Streamlit UI in a separate focused PR.
|
||||
5. Defer any CLI or Side-Step training wizard decisions until feature parity is reviewed.
|
||||
3. Remove the Streamlit UI in a separate focused PR.
|
||||
4. Defer any CLI or Side-Step training wizard decisions until feature parity is reviewed.
|
||||
|
||||
## Gradio Feature Coverage Matrix
|
||||
|
||||
|
|
@ -72,4 +71,3 @@ friendlier defaults and progressive disclosure.
|
|||
- Training should remain available, but separated from first-run generation.
|
||||
- API services should remain framework-neutral so future UI experiments do not duplicate model
|
||||
loading, generation, dataset, or training logic.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
# Experimental Studio UI
|
||||
|
||||
ACE-Step includes an optional, experimental HTML-based Studio UI for users who want a more structured, DAW-like interface.
|
||||
|
||||
This UI:
|
||||
|
||||
- Is frontend-only
|
||||
- Talks to the same REST API (`/release_task`, `/query_result`)
|
||||
- Does not change model behavior
|
||||
|
||||
For the standalone VST3 plugin, see [acestep.vst3](https://github.com/ace-step/acestep.vst3).
|
||||
|
||||
## How to use
|
||||
|
||||
1. Start the ACE-Step API server (e.g. `uv run acestep --enable-api --port 8001` or your usual API launch command).
|
||||
2. Open `ui/studio.html` in a browser (double-click or `file:///path/to/ACE-Step-1.5/ui/studio.html`).
|
||||
3. Set the API base URL if needed (default: `http://localhost:8001`).
|
||||
4. Enter prompt and options, then click **Generate**. The UI will poll for results and display audio when ready.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Optional:** The default way to use ACE-Step remains the Gradio Web UI.
|
||||
- **No backend changes:** This UI uses the existing REST API only.
|
||||
- **Experimental:** Layout and features may change based on community feedback.
|
||||
929
ui/studio.html
929
ui/studio.html
|
|
@ -1,929 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>ACE-Step Studio (Pro)</title>
|
||||
|
||||
<script src="https://unpkg.com/wavesurfer.js@7"></script>
|
||||
<script src="https://unpkg.com/wavesurfer.js@7/dist/plugins/regions.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #080808;
|
||||
--card-bg: #141414;
|
||||
--panel-bg: #1e1e1e;
|
||||
--accent: #1DB954;
|
||||
--accent-hover: #1ed760;
|
||||
--text: #ffffff;
|
||||
--muted: #b3b3b3;
|
||||
--border: #333;
|
||||
--input-bg: #2a2a2a;
|
||||
--danger: #ff4444;
|
||||
--gold: #FFD700;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Header --- */
|
||||
header {
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
h1 { margin: 0; font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: 10px; white-space: nowrap; }
|
||||
.badge { font-size: 0.65rem; background: var(--accent); color: black; padding: 2px 6px; border-radius: 4px; font-weight: 800; }
|
||||
|
||||
.connection-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* --- Layout --- */
|
||||
.main-container {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Sidebar (Controls) --- */
|
||||
.sidebar {
|
||||
background: var(--panel-bg);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.control-group { margin-bottom: 0; }
|
||||
label { display: block; font-size: 0.75rem; font-weight: 700; color: var(--muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
input[type="text"], input[type="number"], input[type="url"], select, textarea {
|
||||
width: 100%;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
textarea { resize: vertical; min-height: 80px; font-family: monospace; }
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
|
||||
.row { display: flex; gap: 12px; }
|
||||
.row > div { flex: 1; }
|
||||
|
||||
.range-container { display: flex; align-items: center; gap: 10px; padding: 5px 0; }
|
||||
input[type="range"] { flex: 1; accent-color: var(--accent); height: 20px; }
|
||||
.range-val { width: 35px; text-align: right; font-size: 0.8rem; color: var(--accent); }
|
||||
|
||||
.checkbox-group { display: flex; align-items: center; gap: 12px; cursor: pointer; padding: 5px 0; }
|
||||
input[type="checkbox"] { width: 20px; height: 20px; accent-color: var(--accent); margin: 0; }
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
transition: 0.2s;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: black; box-shadow: 0 4px 15px rgba(29, 185, 84, 0.3); }
|
||||
.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); }
|
||||
.btn-primary:active { transform: translateY(1px); }
|
||||
.btn-primary:disabled { background: #333; color: #666; cursor: not-allowed; box-shadow: none; transform: none; }
|
||||
|
||||
.file-upload { position: relative; display: inline-block; width: 100%; }
|
||||
.file-upload input[type="file"] { display: none; }
|
||||
.file-upload-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px dashed var(--muted);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
transition: 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
.file-upload-label:hover { border-color: var(--accent); color: var(--accent); background: rgba(29, 185, 84, 0.05); }
|
||||
|
||||
/* --- Content Area --- */
|
||||
.content-area {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(135deg, #101010 0%, #080808 100%);
|
||||
}
|
||||
|
||||
#waveform-wrapper {
|
||||
background: #111;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
border: 1px solid #333;
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
#waveform-wrapper.empty { display: none; }
|
||||
#waveform { width: 100%; }
|
||||
|
||||
.region-info {
|
||||
font-size: 0.8rem;
|
||||
color: var(--gold);
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.transport-controls { display: flex; align-items: center; gap: 8px; }
|
||||
.btn-icon {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; transition: 0.2s; font-size: 0.9rem; padding: 0;
|
||||
}
|
||||
.btn-icon:hover { background: rgba(255,255,255,0.2); border-color: var(--accent); }
|
||||
.btn-icon.active { background: var(--accent); color: black; border-color: var(--accent); }
|
||||
.btn-icon.main { width: 44px; height: 44px; font-size: 1.1rem; background: var(--accent); color: black; border:none; }
|
||||
.btn-icon.main:hover { transform: scale(1.05); filter: brightness(1.1); }
|
||||
|
||||
/* --- Logs & Smart Progress --- */
|
||||
.log-console {
|
||||
background: rgba(0,0,0,0.5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #ccc;
|
||||
height: 120px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.log-info { color: #888; }
|
||||
.log-success { color: var(--accent); }
|
||||
.log-error { color: var(--danger); }
|
||||
.log-progress { color: var(--gold); }
|
||||
|
||||
.progress-container {
|
||||
height: 8px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
display: none; /* Hidden until needed */
|
||||
position: relative;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, var(--accent) 0%, var(--accent-hover) 100%);
|
||||
transition: width 0.2s linear; /* Smooth linear movement */
|
||||
box-shadow: 0 0 15px rgba(29, 185, 84, 0.5);
|
||||
}
|
||||
|
||||
.progress-text-overlay {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
.result-card {
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.result-card:hover { border-color: var(--accent); }
|
||||
.result-meta { font-size: 0.8rem; color: var(--muted); margin-bottom: 10px; line-height: 1.4; }
|
||||
audio { width: 100%; margin-top: 5px; height: 40px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body { height: auto; min-height: 100vh; overflow-y: auto; }
|
||||
.main-container { display: flex; flex-direction: column; overflow: visible; height: auto; }
|
||||
header { position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 10px rgba(0,0,0,0.5); }
|
||||
.sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); overflow: visible; padding: 20px 15px; }
|
||||
.content-area { overflow: visible; padding: 15px; min-height: 500px; }
|
||||
.connection-bar { width: 100%; margin-top: 5px; }
|
||||
.connection-bar label { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>ACE-Step Studio <span class="badge">PRO</span></h1>
|
||||
<div class="connection-bar">
|
||||
<input type="url" id="apiBase" value="http://localhost:8001" placeholder="API URL (e.g. http://localhost:8001)">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-container">
|
||||
<aside class="sidebar">
|
||||
<!-- Audio Input -->
|
||||
<div class="control-group">
|
||||
<label>1. Source Audio (Reference)</label>
|
||||
<div class="file-upload">
|
||||
<label for="audioUpload" class="file-upload-label" id="uploadLabel">📁 Tap to Upload Audio</label>
|
||||
<input type="file" id="audioUpload" accept="audio/*">
|
||||
</div>
|
||||
<div style="font-size: 0.7rem; color: #666; margin-top: 5px;">Required for Cover, Repaint, Lego & Extract.</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Configuration -->
|
||||
<div class="control-group">
|
||||
<label for="taskType">2. Task Mode</label>
|
||||
<select id="taskType">
|
||||
<option value="text2music">Text to Music (Generate)</option>
|
||||
<option value="cover">Style Transfer (Cover)</option>
|
||||
<option value="repaint">In-Painting (Repaint)</option>
|
||||
<option value="lego">Add Layer (Lego)</option>
|
||||
<option value="extract">Isolate Layer (Extract)</option>
|
||||
<option value="complete">Complete / Extend</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group" id="trackNameGroup" style="display:none;">
|
||||
<label for="trackName">Target Layer</label>
|
||||
<select id="trackName">
|
||||
<option value="vocals">Vocals</option>
|
||||
<option value="drums">Drums</option>
|
||||
<option value="bass">Bass</option>
|
||||
<option value="guitar">Guitar</option>
|
||||
<option value="piano">Piano</option>
|
||||
<option value="synth">Synth</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="prompt">3. Style / Description</label>
|
||||
<textarea id="prompt" placeholder="e.g. 80s pop song, upbeat, female vocals"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="lyrics">Lyrics (Optional)</label>
|
||||
<textarea id="lyrics" placeholder="[Verse 1] ..." style="min-height: 60px;"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>4. Parameters</label>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Language</label>
|
||||
<input type="text" id="vocalLanguage" value="en" placeholder="en/nl/fr">
|
||||
</div>
|
||||
<div>
|
||||
<label>Duration (s)</label>
|
||||
<input type="number" id="duration" value="30" min="10" max="300">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 10px;">
|
||||
<div>
|
||||
<label>Steps</label>
|
||||
<input type="number" id="steps" value="8" min="1" max="50">
|
||||
</div>
|
||||
<div>
|
||||
<label>CFG Scale</label>
|
||||
<input type="number" id="guidance" value="7.0" step="0.5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group" id="strengthGroup" style="display:none;">
|
||||
<label>Audio Influence</label>
|
||||
<div class="range-container">
|
||||
<span style="font-size:0.7rem">Creative</span>
|
||||
<input type="range" id="strength" min="0" max="100" value="40">
|
||||
<span class="range-val" id="strengthVal">0.4</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="thinking" checked>
|
||||
<label for="thinking" style="margin:0; color: #fff; font-size: 0.9rem;">Use Reasoning (Thinking)</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="submitBtn" class="btn btn-primary">✨ Generate Music</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<div id="waveform-wrapper" class="empty">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; flex-wrap: wrap; gap: 10px;">
|
||||
<h3 style="margin: 0; font-size: 0.9rem; color: #888;">Reference Audio</h3>
|
||||
<div class="transport-controls">
|
||||
<button id="wsLoopBtn" class="btn-icon" title="Toggle Loop">🔁</button>
|
||||
<button id="wsStopBtn" class="btn-icon" title="Stop">⏹</button>
|
||||
<button id="wsPlayBtn" class="btn-icon main" title="Play/Pause">▶</button>
|
||||
<span id="wsTimeDisplay" style="font-family:monospace; font-size:0.8rem; color:var(--accent); margin-left:8px; min-width: 80px; text-align:right;">0:00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="waveform"></div>
|
||||
<div class="region-info">
|
||||
<span id="regionText" style="flex: 1;">No region selected</span>
|
||||
<button id="clearRegionBtn" style="background:none; border:1px solid #444; color:#888; border-radius:4px; cursor:pointer; font-size:0.7rem; padding: 4px 8px;">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="regionStart" value="0">
|
||||
<input type="hidden" id="regionEnd" value="-1">
|
||||
|
||||
<div id="statusArea">
|
||||
<div class="progress-container" id="progressContainer">
|
||||
<span id="progressText" class="progress-text-overlay">0%</span>
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<div class="log-console" id="logConsole">
|
||||
<div>Welcome to ACE-Step Studio. Ready.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style="border-bottom: 1px solid #333; padding-bottom: 10px; margin-bottom: 15px; font-size: 1rem;">Generated Results</h3>
|
||||
<div id="resultsGrid" class="results-grid"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- Configuration ---
|
||||
const elements = {
|
||||
apiBase: document.getElementById('apiBase'),
|
||||
fileInput: document.getElementById('audioUpload'),
|
||||
uploadLabel: document.getElementById('uploadLabel'),
|
||||
taskType: document.getElementById('taskType'),
|
||||
prompt: document.getElementById('prompt'),
|
||||
lyrics: document.getElementById('lyrics'),
|
||||
language: document.getElementById('vocalLanguage'),
|
||||
duration: document.getElementById('duration'),
|
||||
steps: document.getElementById('steps'),
|
||||
guidance: document.getElementById('guidance'),
|
||||
strength: document.getElementById('strength'),
|
||||
strengthVal: document.getElementById('strengthVal'),
|
||||
trackName: document.getElementById('trackName'),
|
||||
trackNameGroup: document.getElementById('trackNameGroup'),
|
||||
strengthGroup: document.getElementById('strengthGroup'),
|
||||
thinking: document.getElementById('thinking'),
|
||||
submitBtn: document.getElementById('submitBtn'),
|
||||
logConsole: document.getElementById('logConsole'),
|
||||
resultsGrid: document.getElementById('resultsGrid'),
|
||||
waveformWrapper: document.getElementById('waveform-wrapper'),
|
||||
regionStart: document.getElementById('regionStart'),
|
||||
regionEnd: document.getElementById('regionEnd'),
|
||||
regionText: document.getElementById('regionText'),
|
||||
clearRegionBtn: document.getElementById('clearRegionBtn'),
|
||||
progressContainer: document.getElementById('progressContainer'),
|
||||
progressFill: document.getElementById('progressFill'),
|
||||
progressText: document.getElementById('progressText'),
|
||||
wsPlayBtn: document.getElementById('wsPlayBtn'),
|
||||
wsStopBtn: document.getElementById('wsStopBtn'),
|
||||
wsLoopBtn: document.getElementById('wsLoopBtn'),
|
||||
wsTimeDisplay: document.getElementById('wsTimeDisplay')
|
||||
};
|
||||
|
||||
let wavesurfer = null;
|
||||
let wsRegions = null;
|
||||
let activeRegion = null;
|
||||
let uploadedFile = null;
|
||||
const DEFAULT_AUDIO_VOLUME = 0.5;
|
||||
const AUDIO_VOLUME_STORAGE_KEY = "acestep.studio.audio.volume";
|
||||
const AUDIO_EPSILON = 0.001;
|
||||
const managedAudioElements = new WeakSet();
|
||||
let preferredAudioVolume = null;
|
||||
|
||||
// --- SMART PROGRESS CONFIGURATION (Ported from Hitster) ---
|
||||
// This allows the bar to move smoothly even when server is thinking
|
||||
const ESTIMATED_DURATION_SEC = 300; // 5 Minutes baseline
|
||||
const UPDATE_INTERVAL_MS = 200; // Update animation every 200ms
|
||||
const BASE_INCREMENT = 100 / ((ESTIMATED_DURATION_SEC * 1000) / UPDATE_INTERVAL_MS);
|
||||
|
||||
// Mappings from server logs to percentages
|
||||
const progressSteps = {
|
||||
"queued": 0,
|
||||
"initializing": 2,
|
||||
"llm usage": 5,
|
||||
"processing source": 10,
|
||||
"cuda gpu": 15,
|
||||
"loading text_encoder": 18,
|
||||
"loaded model": 22,
|
||||
"dit diffusion": 50, // The long wait starts here
|
||||
"offloading model": 80,
|
||||
"tiled_decode": 90,
|
||||
"decoding": 92,
|
||||
"downloading": 95,
|
||||
"saving": 98,
|
||||
"done": 100
|
||||
};
|
||||
|
||||
// State object for the smart progress bar
|
||||
let progressState = {
|
||||
current: 0,
|
||||
target: 0,
|
||||
speedMod: 1.0,
|
||||
timerId: null
|
||||
};
|
||||
|
||||
function clampVolume(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
if (parsed < 0) return 0;
|
||||
if (parsed > 1) return 1;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isTrustedUserEvent(event) {
|
||||
return Boolean(event && event.isTrusted);
|
||||
}
|
||||
|
||||
function loadPreferredAudioVolume() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(AUDIO_VOLUME_STORAGE_KEY);
|
||||
if (raw === null || raw === undefined || raw === '') return DEFAULT_AUDIO_VOLUME;
|
||||
const parsed = clampVolume(raw);
|
||||
return parsed === null ? DEFAULT_AUDIO_VOLUME : parsed;
|
||||
} catch (_error) {
|
||||
return DEFAULT_AUDIO_VOLUME;
|
||||
}
|
||||
}
|
||||
|
||||
function savePreferredAudioVolume(value) {
|
||||
const clamped = clampVolume(value);
|
||||
if (clamped === null) return;
|
||||
preferredAudioVolume = clamped;
|
||||
try {
|
||||
window.localStorage.setItem(AUDIO_VOLUME_STORAGE_KEY, String(clamped));
|
||||
} catch (_error) {
|
||||
// Ignore storage failures (private mode / blocked storage).
|
||||
}
|
||||
if (wavesurfer && typeof wavesurfer.setVolume === 'function') {
|
||||
wavesurfer.setVolume(clamped);
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreferredVolumeToAudio(audioEl) {
|
||||
if (!audioEl || preferredAudioVolume === null) return;
|
||||
if (Math.abs(audioEl.volume - preferredAudioVolume) <= AUDIO_EPSILON) return;
|
||||
audioEl.volume = preferredAudioVolume;
|
||||
}
|
||||
|
||||
function syncAllAudioVolumes(sourceAudio = null) {
|
||||
if (preferredAudioVolume === null) return;
|
||||
document.querySelectorAll('audio').forEach((audioEl) => {
|
||||
if (!audioEl) return;
|
||||
if (sourceAudio && audioEl === sourceAudio) {
|
||||
applyPreferredVolumeToAudio(audioEl);
|
||||
return;
|
||||
}
|
||||
if (Math.abs(audioEl.volume - preferredAudioVolume) > AUDIO_EPSILON) {
|
||||
audioEl.volume = preferredAudioVolume;
|
||||
}
|
||||
});
|
||||
if (wavesurfer && typeof wavesurfer.setVolume === 'function') {
|
||||
wavesurfer.setVolume(preferredAudioVolume);
|
||||
}
|
||||
}
|
||||
|
||||
function registerAudioElement(audioEl) {
|
||||
if (!audioEl || managedAudioElements.has(audioEl)) return;
|
||||
managedAudioElements.add(audioEl);
|
||||
applyPreferredVolumeToAudio(audioEl);
|
||||
|
||||
audioEl.addEventListener('volumechange', (event) => {
|
||||
if (!isTrustedUserEvent(event)) {
|
||||
applyPreferredVolumeToAudio(audioEl);
|
||||
return;
|
||||
}
|
||||
const next = clampVolume(audioEl.volume);
|
||||
if (next === null) return;
|
||||
if (preferredAudioVolume !== null && Math.abs(next - preferredAudioVolume) <= AUDIO_EPSILON) return;
|
||||
savePreferredAudioVolume(next);
|
||||
syncAllAudioVolumes(audioEl);
|
||||
}, { passive: true });
|
||||
|
||||
audioEl.addEventListener('loadedmetadata', () => {
|
||||
applyPreferredVolumeToAudio(audioEl);
|
||||
if (audioEl.currentTime > 0) audioEl.currentTime = 0;
|
||||
}, { passive: true });
|
||||
|
||||
audioEl.addEventListener('loadstart', () => {
|
||||
applyPreferredVolumeToAudio(audioEl);
|
||||
if (audioEl.currentTime > 0) audioEl.currentTime = 0;
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function scanAndRegisterAudioPlayers() {
|
||||
document.querySelectorAll('audio').forEach(registerAudioElement);
|
||||
syncAllAudioVolumes();
|
||||
}
|
||||
|
||||
preferredAudioVolume = loadPreferredAudioVolume();
|
||||
savePreferredAudioVolume(preferredAudioVolume);
|
||||
scanAndRegisterAudioPlayers();
|
||||
|
||||
// --- UI Listeners ---
|
||||
elements.strength.addEventListener('input', (e) => {
|
||||
elements.strengthVal.innerText = (e.target.value / 100).toFixed(2);
|
||||
});
|
||||
|
||||
elements.taskType.addEventListener('change', (e) => {
|
||||
const type = e.target.value;
|
||||
elements.strengthGroup.style.display = (type === 'cover' || type === 'lego') ? 'block' : 'none';
|
||||
elements.trackNameGroup.style.display = (type === 'lego' || type === 'extract') ? 'block' : 'none';
|
||||
if(['lego', 'extract', 'repaint', 'complete'].includes(type)) {
|
||||
elements.thinking.checked = false;
|
||||
} else {
|
||||
elements.thinking.checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// --- WaveSurfer Logic ---
|
||||
function initWaveSurfer() {
|
||||
if(wavesurfer) return;
|
||||
|
||||
wavesurfer = WaveSurfer.create({
|
||||
container: '#waveform',
|
||||
waveColor: '#1DB954',
|
||||
progressColor: '#1ed760',
|
||||
height: 80,
|
||||
cursorColor: '#FF6B35',
|
||||
cursorWidth: 2,
|
||||
barWidth: 3,
|
||||
barGap: 2,
|
||||
barRadius: 3,
|
||||
normalize: true,
|
||||
});
|
||||
|
||||
wavesurfer.on('ready', () => {
|
||||
const dur = wavesurfer.getDuration();
|
||||
elements.wsTimeDisplay.innerText = `0:00 / ${formatTime(dur)}`;
|
||||
if (preferredAudioVolume !== null && typeof wavesurfer.setVolume === 'function') {
|
||||
wavesurfer.setVolume(preferredAudioVolume);
|
||||
}
|
||||
});
|
||||
|
||||
wavesurfer.on('audioprocess', (time) => {
|
||||
const dur = wavesurfer.getDuration();
|
||||
elements.wsTimeDisplay.innerText = `${formatTime(time)} / ${formatTime(dur)}`;
|
||||
});
|
||||
|
||||
wavesurfer.on('play', () => elements.wsPlayBtn.innerText = "⏸");
|
||||
wavesurfer.on('pause', () => elements.wsPlayBtn.innerText = "▶");
|
||||
wavesurfer.on('finish', () => {
|
||||
elements.wsPlayBtn.innerText = "▶";
|
||||
if(elements.wsLoopBtn.classList.contains('active')) wavesurfer.play();
|
||||
});
|
||||
|
||||
wsRegions = wavesurfer.registerPlugin(WaveSurfer.Regions.create());
|
||||
wsRegions.enableDragSelection({ color: 'rgba(29, 185, 84, 0.3)' });
|
||||
|
||||
wsRegions.on('region-created', (region) => {
|
||||
if (activeRegion) { activeRegion.remove(); }
|
||||
activeRegion = region;
|
||||
updateRegionInputs(region);
|
||||
});
|
||||
|
||||
wsRegions.on('region-updated', (region) => updateRegionInputs(region));
|
||||
wsRegions.on('region-out', (region) => {
|
||||
if (elements.wsLoopBtn.classList.contains('active')) region.play();
|
||||
});
|
||||
wsRegions.on('region-clicked', (region, e) => {
|
||||
e.stopPropagation();
|
||||
region.play();
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
}
|
||||
|
||||
function updateRegionInputs(region) {
|
||||
elements.regionStart.value = region.start.toFixed(2);
|
||||
elements.regionEnd.value = region.end.toFixed(2);
|
||||
elements.regionText.innerText = `Selected: ${region.start.toFixed(1)}s - ${region.end.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
// --- Transport & File ---
|
||||
elements.wsPlayBtn.addEventListener('click', () => { if(wavesurfer) wavesurfer.playPause(); });
|
||||
elements.wsStopBtn.addEventListener('click', () => {
|
||||
if(wavesurfer) {
|
||||
wavesurfer.stop();
|
||||
elements.wsTimeDisplay.innerText = `0:00 / ${formatTime(wavesurfer.getDuration())}`;
|
||||
}
|
||||
});
|
||||
elements.wsLoopBtn.addEventListener('click', () => { elements.wsLoopBtn.classList.toggle('active'); });
|
||||
elements.clearRegionBtn.addEventListener('click', () => {
|
||||
if(wsRegions) wsRegions.clearRegions();
|
||||
elements.regionStart.value = 0;
|
||||
elements.regionEnd.value = -1;
|
||||
elements.regionText.innerText = "No region selected";
|
||||
activeRegion = null;
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => { if(wavesurfer) setTimeout(() => {}, 100); });
|
||||
|
||||
elements.fileInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
uploadedFile = file;
|
||||
elements.uploadLabel.innerText = "✅ " + file.name;
|
||||
elements.uploadLabel.style.borderColor = "var(--accent)";
|
||||
elements.uploadLabel.style.color = "var(--accent)";
|
||||
elements.waveformWrapper.classList.remove('empty');
|
||||
initWaveSurfer();
|
||||
wavesurfer.load(URL.createObjectURL(file));
|
||||
});
|
||||
|
||||
// --- SMART PROGRESS LOGIC ---
|
||||
|
||||
function startSmartProgressBar() {
|
||||
if (progressState.timerId) clearInterval(progressState.timerId);
|
||||
|
||||
progressState.current = 0;
|
||||
progressState.target = 0;
|
||||
progressState.speedMod = 1.0;
|
||||
|
||||
progressState.timerId = setInterval(() => {
|
||||
const gap = progressState.target - progressState.current;
|
||||
|
||||
// 1. Determine Speed
|
||||
if (gap > 0) {
|
||||
// Behind target? Speed up to catch up
|
||||
progressState.speedMod = 2.0 + (gap * 2);
|
||||
} else if (gap < -5) {
|
||||
// Way ahead? Slow down significantly
|
||||
progressState.speedMod = 0.1;
|
||||
} else {
|
||||
// Cruise speed (simulates slow processing)
|
||||
progressState.speedMod = 1.0;
|
||||
}
|
||||
|
||||
// 2. Increment
|
||||
progressState.current += BASE_INCREMENT * progressState.speedMod;
|
||||
|
||||
// 3. Clamp
|
||||
// If target is 100, allow completion. Else cap at 99.5%
|
||||
const hardLimit = (progressState.target >= 100) ? 100 : 99.5;
|
||||
if (progressState.current > hardLimit) progressState.current = hardLimit;
|
||||
|
||||
// 4. Render
|
||||
elements.progressFill.style.width = `${progressState.current}%`;
|
||||
elements.progressText.innerText = `${progressState.current.toFixed(1)}%`;
|
||||
|
||||
}, UPDATE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopSmartProgressBar() {
|
||||
if (progressState.timerId) clearInterval(progressState.timerId);
|
||||
elements.progressFill.style.width = '100%';
|
||||
elements.progressText.innerText = '100%';
|
||||
}
|
||||
|
||||
function updateProgressTarget(text) {
|
||||
if (!text) return;
|
||||
|
||||
const lastLog = elements.logConsole.lastElementChild;
|
||||
if (!lastLog || lastLog.innerText.indexOf(text) === -1) {
|
||||
log(text, "log-progress");
|
||||
}
|
||||
|
||||
const t = text.toLowerCase();
|
||||
let foundPercent = 0;
|
||||
|
||||
// Check against the Hitster map
|
||||
for (const [key, percent] of Object.entries(progressSteps)) {
|
||||
if (t.includes(key) && percent > foundPercent) {
|
||||
foundPercent = percent;
|
||||
}
|
||||
}
|
||||
|
||||
// Only update if greater (never move target backwards)
|
||||
if (foundPercent > progressState.target) {
|
||||
progressState.target = foundPercent;
|
||||
}
|
||||
}
|
||||
|
||||
// --- API Logic ---
|
||||
|
||||
function log(msg, type = 'log-info') {
|
||||
const div = document.createElement('div');
|
||||
div.className = type;
|
||||
div.innerText = `[${new Date().toLocaleTimeString()}] ${msg}`;
|
||||
elements.logConsole.appendChild(div);
|
||||
elements.logConsole.scrollTop = elements.logConsole.scrollHeight;
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
return elements.apiBase.value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
elements.submitBtn.addEventListener('click', async () => {
|
||||
const taskType = elements.taskType.value;
|
||||
if (taskType !== 'text2music' && !uploadedFile) {
|
||||
alert("Upload an audio file first!");
|
||||
return;
|
||||
}
|
||||
|
||||
elements.submitBtn.disabled = true;
|
||||
elements.progressContainer.style.display = 'block';
|
||||
elements.logConsole.innerHTML = '';
|
||||
log("Preparing task...", "log-info");
|
||||
|
||||
// Start the smart animator
|
||||
startSmartProgressBar();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('task_type', taskType);
|
||||
formData.append('prompt', elements.prompt.value);
|
||||
formData.append('lyrics', elements.lyrics.value);
|
||||
formData.append('vocal_language', elements.language.value);
|
||||
formData.append('thinking', elements.thinking.checked);
|
||||
formData.append('batch_size', 1);
|
||||
formData.append('inference_steps', elements.steps.value);
|
||||
formData.append('guidance_scale', elements.guidance.value);
|
||||
formData.append('audio_duration', elements.duration.value);
|
||||
|
||||
if (taskType === 'cover' || taskType === 'lego') {
|
||||
formData.append('audio_cover_strength', elements.strength.value / 100);
|
||||
} else {
|
||||
formData.append('audio_cover_strength', 1.0);
|
||||
}
|
||||
|
||||
if (taskType === 'lego' || taskType === 'extract') {
|
||||
formData.append('track_name', elements.trackName.value);
|
||||
}
|
||||
|
||||
if (uploadedFile) {
|
||||
formData.append('src_audio', uploadedFile);
|
||||
}
|
||||
|
||||
if (taskType === 'repaint' || taskType === 'lego') {
|
||||
formData.append('repainting_start', elements.regionStart.value);
|
||||
formData.append('repainting_end', elements.regionEnd.value);
|
||||
}
|
||||
|
||||
log("Uploading...", "log-info");
|
||||
const releaseRes = await fetch(`${getBaseUrl()}/release_task`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const releaseData = await releaseRes.json();
|
||||
|
||||
if (releaseData.code !== 200 || !releaseData.data || !releaseData.data.task_id) {
|
||||
throw new Error(releaseData.error || 'Failed to start task');
|
||||
}
|
||||
|
||||
const taskId = releaseData.data.task_id;
|
||||
log(`Task Started: ${taskId}`, "log-success");
|
||||
startPolling(taskId);
|
||||
|
||||
} catch (e) {
|
||||
log(`Error: ${e.message}`, "log-error");
|
||||
stopSmartProgressBar();
|
||||
elements.submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function startPolling(taskId) {
|
||||
const pollInterval = 1500;
|
||||
const maxTime = 900000; // 15 mins (increased for diffusion)
|
||||
const startTime = Date.now();
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
if (Date.now() - startTime > maxTime) {
|
||||
clearInterval(timer);
|
||||
log("Timed out.", "log-error");
|
||||
stopSmartProgressBar();
|
||||
elements.submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${getBaseUrl()}/query_result`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id_list: [taskId] })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code === 200 && data.data && data.data.length > 0) {
|
||||
const task = data.data[0];
|
||||
|
||||
// Update Smart Target
|
||||
updateProgressTarget(task.progress_text);
|
||||
|
||||
if (task.status === 1) { // Success
|
||||
clearInterval(timer);
|
||||
stopSmartProgressBar(); // Force to 100%
|
||||
log("Done!", "log-success");
|
||||
handleSuccess(task);
|
||||
elements.submitBtn.disabled = false;
|
||||
} else if (task.status === 2) { // Failed
|
||||
clearInterval(timer);
|
||||
stopSmartProgressBar();
|
||||
log("Task Failed.", "log-error");
|
||||
elements.submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Poll error", e);
|
||||
}
|
||||
}, pollInterval);
|
||||
}
|
||||
|
||||
function handleSuccess(task) {
|
||||
try {
|
||||
let result = task.result;
|
||||
if (typeof result === 'string') result = JSON.parse(result);
|
||||
if (Array.isArray(result)) result = result[0];
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
const fileUrl = result.url || result.file;
|
||||
const fullUrl = fileUrl.startsWith('http') ? fileUrl : `${baseUrl}/${fileUrl.replace(/^\//, '')}`;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'result-card';
|
||||
|
||||
const metas = result.metas || {};
|
||||
const metaHtml = `
|
||||
<div class="result-meta">
|
||||
<strong>${(result.prompt || elements.prompt.value).substring(0, 50)}...</strong><br>
|
||||
<span>BPM: ${metas.bpm || '-'} | Key: ${metas.keyscale || '-'} | Dur: ${metas.duration || '-'}s</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.innerHTML = `
|
||||
${metaHtml}
|
||||
<audio controls src="${fullUrl}"></audio>
|
||||
<div style="margin-top:12px; display:flex; gap:10px;">
|
||||
<a href="${fullUrl}" download class="btn" style="padding:10px; font-size:0.8rem; background:#333; text-decoration:none; color:white; text-align:center;">Download</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
elements.resultsGrid.prepend(card);
|
||||
scanAndRegisterAudioPlayers();
|
||||
} catch (e) {
|
||||
log("Parsing error: " + e.message, "log-error");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
"""Unit tests for studio HTML audio volume persistence guards."""
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
class StudioHtmlVolumeGuardTests(unittest.TestCase):
|
||||
"""Tests for trusted-event gating in studio volume persistence logic."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Load studio HTML content once for all assertions."""
|
||||
cls._html = Path(__file__).with_name("studio.html").read_text(encoding="utf-8")
|
||||
|
||||
def test_contains_trusted_event_helper(self):
|
||||
"""Success path: trusted-event helper should exist."""
|
||||
self.assertIn("function isTrustedUserEvent(event)", self._html)
|
||||
self.assertIn("event && event.isTrusted", self._html)
|
||||
|
||||
def test_volumechange_listener_guards_non_trusted_events(self):
|
||||
"""Regression path: listener should reject non-user volumechange events."""
|
||||
self.assertIn("audioEl.addEventListener('volumechange', (event) => {", self._html)
|
||||
self.assertIn("if (!isTrustedUserEvent(event)) {", self._html)
|
||||
self.assertIn("applyPreferredVolumeToAudio(audioEl);", self._html)
|
||||
|
||||
def test_volume_defaults_on_missing_storage(self):
|
||||
"""Missing localStorage value should seed and persist a sane default volume."""
|
||||
self.assertIn("const DEFAULT_AUDIO_VOLUME = 0.5;", self._html)
|
||||
self.assertIn("const raw = window.localStorage.getItem(AUDIO_VOLUME_STORAGE_KEY);", self._html)
|
||||
self.assertIn("if (raw === null || raw === undefined || raw === '') return DEFAULT_AUDIO_VOLUME;", self._html)
|
||||
self.assertIn("savePreferredAudioVolume(preferredAudioVolume);", self._html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Reference in a new issue