diff --git a/acestep/ui/gradio/events/results/audio_playback_updates.py b/acestep/ui/gradio/events/results/audio_playback_updates.py
new file mode 100644
index 00000000..d8478ebf
--- /dev/null
+++ b/acestep/ui/gradio/events/results/audio_playback_updates.py
@@ -0,0 +1,36 @@
+"""Audio playback update helpers for Gradio result players.
+
+These helpers standardize audio update payloads so playback always rewinds to
+the start of the track when a new value is assigned.
+"""
+
+from typing import Any, Optional
+
+
+def build_audio_slot_update(
+ gr_module: Any,
+ audio_path: Optional[str],
+ *,
+ label: Optional[str] = None,
+ interactive: Optional[bool] = None,
+) -> Any:
+ """Build an audio slot update that always rewinds playback to 0.
+
+ Args:
+ gr_module: Module/object exposing ``update(**kwargs)``.
+ audio_path: Filepath for the audio component, or ``None`` to clear.
+ label: Optional component label override.
+ interactive: Optional component interactivity override.
+
+ Returns:
+ The framework-specific update object returned by ``gr_module.update``.
+ """
+ update_kwargs = {
+ "value": audio_path,
+ "playback_position": 0,
+ }
+ if label is not None:
+ update_kwargs["label"] = label
+ if interactive is not None:
+ update_kwargs["interactive"] = interactive
+ return gr_module.update(**update_kwargs)
diff --git a/acestep/ui/gradio/events/results/audio_playback_updates_test.py b/acestep/ui/gradio/events/results/audio_playback_updates_test.py
new file mode 100644
index 00000000..38b7c501
--- /dev/null
+++ b/acestep/ui/gradio/events/results/audio_playback_updates_test.py
@@ -0,0 +1,64 @@
+"""Unit tests for audio_playback_updates helpers."""
+
+import importlib.util
+from pathlib import Path
+import unittest
+
+
+def _load_module():
+ """Load the target module directly by file path for isolated testing."""
+ module_path = Path(__file__).with_name("audio_playback_updates.py")
+ spec = importlib.util.spec_from_file_location("audio_playback_updates", module_path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module) # type: ignore[union-attr]
+ return module
+
+
+_MODULE = _load_module()
+build_audio_slot_update = _MODULE.build_audio_slot_update
+
+
+class _FakeGr:
+ """Minimal Gradio-like stub exposing ``update``."""
+
+ @staticmethod
+ def update(**kwargs):
+ """Return kwargs for direct assertion in tests."""
+ return kwargs
+
+
+class AudioPlaybackUpdatesTests(unittest.TestCase):
+ """Behavior tests for audio playback update builders."""
+
+ def test_build_audio_slot_update_sets_value_and_rewinds(self):
+ """Success path: slot update should carry path and playback reset."""
+ sample_path = "sample.flac"
+ result = build_audio_slot_update(
+ _FakeGr,
+ sample_path,
+ label="Sample 1 (Ready)",
+ interactive=False,
+ )
+ self.assertEqual(result["value"], sample_path)
+ self.assertEqual(result["playback_position"], 0)
+ self.assertEqual(result["label"], "Sample 1 (Ready)")
+ self.assertFalse(result["interactive"])
+
+ def test_build_audio_slot_update_clear_path_rewinds(self):
+ """Regression path: clearing a slot should still force playback to start."""
+ result = build_audio_slot_update(_FakeGr, None)
+ self.assertEqual(result["value"], None)
+ self.assertEqual(result["playback_position"], 0)
+
+ def test_build_audio_slot_update_without_optional_flags_preserves_defaults(self):
+ """Non-target behavior: no label/interactivity overrides unless explicitly passed."""
+ sample_path = "sample.flac"
+ result = build_audio_slot_update(_FakeGr, sample_path)
+ self.assertEqual(result["value"], sample_path)
+ self.assertEqual(result["playback_position"], 0)
+ self.assertNotIn("label", result)
+ self.assertNotIn("interactive", result)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/acestep/ui/gradio/events/results/batch_navigation.py b/acestep/ui/gradio/events/results/batch_navigation.py
index 39ed187c..863f815d 100644
--- a/acestep/ui/gradio/events/results/batch_navigation.py
+++ b/acestep/ui/gradio/events/results/batch_navigation.py
@@ -12,6 +12,9 @@
import gradio as gr
from acestep.ui.gradio.i18n import t
+from acestep.ui.gradio.events.results.audio_playback_updates import (
+ build_audio_slot_update,
+)
from acestep.ui.gradio.events.results.batch_queue import (
update_batch_indicator,
update_navigation_buttons,
@@ -41,8 +44,9 @@ def navigate_to_previous_batch(current_batch_index, batch_queue):
real_audio_paths = [p for p in audio_paths if not p.lower().endswith('.json')]
audio_updates = [
- gr.update(value=real_audio_paths[i].replace("\\", "/")) if i < len(real_audio_paths)
- else gr.update(value=None)
+ build_audio_slot_update(gr, real_audio_paths[i].replace("\\", "/"))
+ if i < len(real_audio_paths)
+ else build_audio_slot_update(gr, None)
for i in range(8)
]
@@ -108,8 +112,9 @@ def navigate_to_next_batch(autogen_enabled, current_batch_index, total_batches,
real_audio_paths = [p for p in audio_paths if not p.lower().endswith('.json')]
audio_updates = [
- gr.update(value=real_audio_paths[i].replace("\\", "/")) if i < len(real_audio_paths)
- else gr.update(value=None)
+ build_audio_slot_update(gr, real_audio_paths[i].replace("\\", "/"))
+ if i < len(real_audio_paths)
+ else build_audio_slot_update(gr, None)
for i in range(8)
]
diff --git a/acestep/ui/gradio/events/results/generation_info.py b/acestep/ui/gradio/events/results/generation_info.py
index 4bb34f68..8231130d 100644
--- a/acestep/ui/gradio/events/results/generation_info.py
+++ b/acestep/ui/gradio/events/results/generation_info.py
@@ -23,8 +23,21 @@
def clear_audio_outputs_for_new_generation():
- """Return None for all 9 audio outputs so Gradio clears them and stops playback."""
- return (None,) * 9
+ """Return pre-generation output updates without remounting audio players.
+
+ In Gradio runtime, ask each audio component to seek to the start via
+ ``gr.update(playback_position=0)`` and clear only the batch-download file
+ list (9th output). This rewinds playback on regenerate without assigning
+ ``value=None`` (which would remount players and risk browser-side volume
+ resets).
+
+ In non-Gradio test environments, gracefully fall back to 9 ``None`` values.
+ """
+ try:
+ import gradio as gr # local import keeps headless tests dependency-free
+ except (ModuleNotFoundError, ImportError):
+ return (None,) * 9
+ return tuple(gr.update(playback_position=0) for _ in range(8)) + (None,)
def _build_generation_info(
diff --git a/acestep/ui/gradio/events/results/generation_info_test.py b/acestep/ui/gradio/events/results/generation_info_test.py
index 89c7fdb2..2cbbac6f 100644
--- a/acestep/ui/gradio/events/results/generation_info_test.py
+++ b/acestep/ui/gradio/events/results/generation_info_test.py
@@ -1,14 +1,41 @@
"""Unit tests for generation_info module."""
+import importlib.util
import os
+from pathlib import Path
+import sys
+import types
import unittest
-
-from acestep.ui.gradio.events.results.generation_info import (
- DEFAULT_RESULTS_DIR,
- PROJECT_ROOT,
- clear_audio_outputs_for_new_generation,
- _build_generation_info,
-)
+import builtins
+from unittest.mock import patch
+
+
+def _load_module():
+ """Load target module directly by file path for isolated testing."""
+ # Stub i18n package path so importing generation_info.py does not require
+ # importing the full Gradio UI package in headless test environments.
+ acestep_pkg = sys.modules.setdefault("acestep", types.ModuleType("acestep"))
+ ui_pkg = sys.modules.setdefault("acestep.ui", types.ModuleType("acestep.ui"))
+ gradio_pkg = sys.modules.setdefault("acestep.ui.gradio", types.ModuleType("acestep.ui.gradio"))
+ i18n_mod = types.ModuleType("acestep.ui.gradio.i18n")
+ i18n_mod.t = lambda key, **_kwargs: key
+ sys.modules["acestep.ui.gradio.i18n"] = i18n_mod
+ acestep_pkg.ui = ui_pkg
+ ui_pkg.gradio = gradio_pkg
+ gradio_pkg.i18n = i18n_mod
+
+ module_path = Path(__file__).with_name("generation_info.py")
+ spec = importlib.util.spec_from_file_location("generation_info", module_path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module) # type: ignore[union-attr]
+ return module
+
+
+_MODULE = _load_module()
+DEFAULT_RESULTS_DIR = _MODULE.DEFAULT_RESULTS_DIR
+PROJECT_ROOT = _MODULE.PROJECT_ROOT
+clear_audio_outputs_for_new_generation = _MODULE.clear_audio_outputs_for_new_generation
+_build_generation_info = _MODULE._build_generation_info
class ConstantsTests(unittest.TestCase):
@@ -31,13 +58,30 @@ class ClearAudioOutputsTests(unittest.TestCase):
"""Tests for clear_audio_outputs_for_new_generation."""
def test_returns_nine_nones(self):
- """Should return a tuple of 9 None values."""
- result = clear_audio_outputs_for_new_generation()
+ """Should return a tuple of 9 None values when Gradio import fails."""
+ real_import = builtins.__import__
+
+ def _mocked_import(name, *args, **kwargs):
+ if name == "gradio":
+ raise ImportError("simulated missing gradio")
+ return real_import(name, *args, **kwargs)
+
+ with patch("builtins.__import__", side_effect=_mocked_import):
+ result = clear_audio_outputs_for_new_generation()
self.assertIsInstance(result, tuple)
self.assertEqual(len(result), 9)
for item in result:
self.assertIsNone(item)
+ def test_gradio_runtime_rewinds_audio_but_clears_batch_files(self):
+ """With Gradio available, audio outputs should rewind without remounting."""
+ fake_gradio = types.SimpleNamespace(update=lambda **kwargs: kwargs)
+ with patch.dict("sys.modules", {"gradio": fake_gradio}):
+ result = clear_audio_outputs_for_new_generation()
+ self.assertEqual(len(result), 9)
+ self.assertEqual(result[:8], ({"playback_position": 0},) * 8)
+ self.assertIsNone(result[8])
+
class BuildGenerationInfoTests(unittest.TestCase):
"""Tests for _build_generation_info."""
diff --git a/acestep/ui/gradio/events/results/generation_progress.py b/acestep/ui/gradio/events/results/generation_progress.py
index 56e77ddb..dc7f2f6e 100644
--- a/acestep/ui/gradio/events/results/generation_progress.py
+++ b/acestep/ui/gradio/events/results/generation_progress.py
@@ -25,6 +25,9 @@
DEFAULT_RESULTS_DIR,
_build_generation_info,
)
+from acestep.ui.gradio.events.results.audio_playback_updates import (
+ build_audio_slot_update,
+)
from acestep.ui.gradio.events.results.scoring import calculate_score_handler
from acestep.ui.gradio.events.results.lrc_utils import lrc_to_vtt_file
@@ -204,11 +207,12 @@ def generate_with_progress(
clear_codes = [gr.update(value="", visible=True) for _ in range(8)]
clear_lrcs = [gr.update(value="", visible=True) for _ in range(8)]
clear_accordions = [gr.skip() for _ in range(8)]
- dump_audio = [gr.update(value=None, subtitles=None, playback_position=0) for _ in range(8)]
+ # Keep existing players mounted during generation to avoid browser volume reset.
+ dump_audio = [gr.skip()] * 8
yield (
*dump_audio,
- None, generation_info, "Clearing previous results...", gr.skip(),
+ None, generation_info, "Preparing generation...", gr.skip(),
*clear_scores, *clear_codes, *clear_accordions, *clear_lrcs,
lm_generated_metadata, is_format_caption, None, None,
)
@@ -276,7 +280,7 @@ def generate_with_progress(
# STEP 1: yield audio + clear LRC
cur_audio = [gr.skip()] * 8
- cur_audio[i] = audio_path
+ cur_audio[i] = build_audio_slot_update(gr, audio_path)
cur_codes = [gr.skip()] * 8
cur_codes[i] = gr.update(value=code_str, visible=True)
cur_accordions = [gr.skip()] * 8
@@ -329,12 +333,10 @@ def generate_with_progress(
for idx in range(8):
path = audio_outputs[idx]
if path:
- audio_playback_updates.append(
- gr.update(value=path, label=f"Sample {idx + 1} (Ready)", interactive=False)
- )
+ audio_playback_updates.append(build_audio_slot_update(gr, path))
logger.info(f"[generate_with_progress] Audio {idx + 1} path: {path}")
else:
- audio_playback_updates.append(gr.update(value=None, label="None", interactive=False))
+ audio_playback_updates.append(build_audio_slot_update(gr, None))
final_codes_display = [gr.skip()] * 8
final_accordions = [gr.skip()] * 8
diff --git a/acestep/ui/gradio/interfaces/__init__.py b/acestep/ui/gradio/interfaces/__init__.py
index 1a49bdf0..059b6c4e 100644
--- a/acestep/ui/gradio/interfaces/__init__.py
+++ b/acestep/ui/gradio/interfaces/__init__.py
@@ -28,6 +28,9 @@
create_advanced_settings_section,
create_generation_tab_section,
)
+from acestep.ui.gradio.interfaces.audio_player_preferences import (
+ get_audio_player_preferences_head,
+)
from acestep.ui.gradio.interfaces.result import create_results_section
from acestep.ui.gradio.interfaces.training import create_training_section
from acestep.ui.gradio.events import setup_event_handlers, setup_training_event_handlers
@@ -58,6 +61,7 @@ def create_gradio_interface(dit_handler, llm_handler, dataset_handler, init_para
with gr.Blocks(
title=t("app.title"),
theme=gr.themes.Soft(),
+ head=get_audio_player_preferences_head(),
css="""
.main-header {
text-align: center;
diff --git a/acestep/ui/gradio/interfaces/audio_player_preferences.js b/acestep/ui/gradio/interfaces/audio_player_preferences.js
new file mode 100644
index 00000000..76717944
--- /dev/null
+++ b/acestep/ui/gradio/interfaces/audio_player_preferences.js
@@ -0,0 +1,378 @@
+(() => {
+ const STORAGE_KEY = "acestep.ui.audio.volume";
+ const GENERATE_BUTTON_ID = "acestep-generate-btn";
+ const DEFAULT_VOLUME = 0.5;
+ const EPSILON = 0.001;
+ const STARTUP_RESYNC_WINDOW_MS = 3000;
+ const STARTUP_RESYNC_INTERVAL_MS = 120;
+ const seenPlayers = new WeakSet();
+ const seenVolumeSliders = new WeakSet();
+ const sliderSyncSuppressedUntil = new WeakMap();
+ const observedRoots = new WeakSet();
+ const knownRoots = [];
+ const readyForPersistence = new WeakMap();
+ const wasReadyBeforeLoad = new WeakMap();
+ let scanScheduled = false;
+ let preferredVolume = null;
+ let startupResyncTimer = null;
+
+ const 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;
+ };
+
+ const loadPreferredVolume = () => {
+ try {
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+ return clampVolume(stored);
+ } catch (_error) {
+ return null;
+ }
+ };
+
+ const storePreferredVolume = (value) => {
+ const clamped = clampVolume(value);
+ if (clamped === null) {
+ return;
+ }
+ preferredVolume = clamped;
+ try {
+ window.localStorage.setItem(STORAGE_KEY, String(clamped));
+ } catch (_error) {
+ // Ignore storage failures (private mode / blocked storage).
+ }
+ };
+
+ const isTrustedUserEvent = (event) => Boolean(event && event.isTrusted);
+
+ const applyPreferredVolume = (player) => {
+ if (!player || preferredVolume === null) {
+ return;
+ }
+ if (Math.abs(player.volume - preferredVolume) <= EPSILON) {
+ return;
+ }
+ player.volume = preferredVolume;
+ };
+
+ const forEachAudioPlayer = (callback) => {
+ for (let i = 0; i < knownRoots.length; i += 1) {
+ const root = knownRoots[i];
+ if (!root || !root.querySelectorAll) {
+ continue;
+ }
+ root.querySelectorAll("audio").forEach((player) => callback(player));
+ }
+ };
+
+ const forEachVolumeSlider = (callback) => {
+ for (let i = 0; i < knownRoots.length; i += 1) {
+ const root = knownRoots[i];
+ if (!root || !root.querySelectorAll) {
+ continue;
+ }
+ root.querySelectorAll("input.volume-slider[type='range'], input#volume[type='range']").forEach(
+ (slider) => callback(slider)
+ );
+ }
+ };
+
+ const applyPreferredVolumeToSlider = (slider) => {
+ if (!slider || preferredVolume === null) {
+ return;
+ }
+ const current = clampVolume(slider.value);
+ if (current !== null && Math.abs(current - preferredVolume) <= EPSILON) {
+ return;
+ }
+ sliderSyncSuppressedUntil.set(slider, Date.now() + 250);
+ slider.value = String(preferredVolume);
+ slider.dispatchEvent(new Event("input", { bubbles: true }));
+ };
+
+ const syncAllVolumeControlsToPreferred = (sourcePlayer = null, sourceSlider = null) => {
+ if (preferredVolume === null) {
+ return;
+ }
+ forEachAudioPlayer((player) => {
+ if (!player) {
+ return;
+ }
+ if (sourcePlayer && player !== sourcePlayer) {
+ if (Math.abs(player.volume - preferredVolume) > EPSILON) {
+ player.volume = preferredVolume;
+ }
+ return;
+ }
+ applyPreferredVolume(player);
+ });
+ forEachVolumeSlider((slider) => {
+ if (!slider || slider === sourceSlider) {
+ return;
+ }
+ applyPreferredVolumeToSlider(slider);
+ });
+ };
+
+ const stopAndRewindAllPlayers = () => {
+ discoverRoots();
+ forEachAudioPlayer((player) => {
+ if (!player) {
+ return;
+ }
+ try {
+ player.pause();
+ } catch (_error) {
+ // Ignore pause failures from detached or blocked media elements.
+ }
+ if (player.currentTime > 0) {
+ player.currentTime = 0;
+ }
+ applyPreferredVolume(player);
+ });
+ };
+
+ const isGenerateButtonClick = (event) => {
+ if (!event) {
+ return false;
+ }
+ const selector = `#${GENERATE_BUTTON_ID}`;
+ const target = event.target;
+ if (target && typeof target.closest === "function" && target.closest(selector)) {
+ return true;
+ }
+ if (typeof event.composedPath !== "function") {
+ return false;
+ }
+ const path = event.composedPath();
+ for (let i = 0; i < path.length; i += 1) {
+ const node = path[i];
+ if (node && node.id === GENERATE_BUTTON_ID) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ const handleDocumentClick = (event) => {
+ if (!isGenerateButtonClick(event)) {
+ return;
+ }
+ stopAndRewindAllPlayers();
+ };
+
+ const stopStartupResync = () => {
+ if (startupResyncTimer === null) {
+ return;
+ }
+ window.clearInterval(startupResyncTimer);
+ startupResyncTimer = null;
+ };
+
+ const beginStartupResync = () => {
+ stopStartupResync();
+ if (preferredVolume === null) {
+ return;
+ }
+
+ const startedAt = Date.now();
+ startupResyncTimer = window.setInterval(() => {
+ scanPlayers();
+ syncAllVolumeControlsToPreferred();
+ if (Date.now() - startedAt >= STARTUP_RESYNC_WINDOW_MS) {
+ stopStartupResync();
+ }
+ }, STARTUP_RESYNC_INTERVAL_MS);
+ };
+
+ const observeRoot = (root) => {
+ if (!root || observedRoots.has(root)) {
+ return;
+ }
+ observedRoots.add(root);
+ knownRoots.push(root);
+
+ const observeTarget = root === document ? document.documentElement : root;
+ if (!observeTarget) {
+ return;
+ }
+
+ new MutationObserver(() => {
+ scheduleScan();
+ }).observe(observeTarget, { childList: true, subtree: true });
+ };
+
+ const scheduleScan = () => {
+ if (scanScheduled) {
+ return;
+ }
+ scanScheduled = true;
+ requestAnimationFrame(() => {
+ scanScheduled = false;
+ scanPlayers();
+ });
+ };
+
+ const discoverRoots = () => {
+ const queue = [document];
+ const visited = new WeakSet();
+
+ while (queue.length > 0) {
+ const root = queue.pop();
+ if (!root || visited.has(root)) {
+ continue;
+ }
+ visited.add(root);
+ observeRoot(root);
+
+ if (!root.querySelectorAll) {
+ continue;
+ }
+ root.querySelectorAll("*").forEach((node) => {
+ if (node && node.shadowRoot) {
+ queue.push(node.shadowRoot);
+ }
+ });
+ }
+ };
+
+ const registerPlayer = (player) => {
+ if (!player || seenPlayers.has(player)) {
+ return;
+ }
+ seenPlayers.add(player);
+ readyForPersistence.set(player, player.readyState > 0);
+ wasReadyBeforeLoad.set(player, false);
+ applyPreferredVolume(player);
+
+ player.addEventListener("volumechange", (event) => {
+ const next = clampVolume(player.volume);
+ if (next === null) {
+ return;
+ }
+
+ if (!isTrustedUserEvent(event)) {
+ applyPreferredVolume(player);
+ return;
+ }
+
+ if (preferredVolume !== null && Math.abs(next - preferredVolume) <= EPSILON) {
+ return;
+ }
+
+ if (
+ readyForPersistence.get(player) !== true
+ && wasReadyBeforeLoad.get(player) !== true
+ ) {
+ // Ignore mount/load reset events before media metadata is ready.
+ applyPreferredVolume(player);
+ return;
+ }
+
+ storePreferredVolume(next);
+ syncAllVolumeControlsToPreferred(player, null);
+ }, { passive: true });
+
+ const markReadyForPersistence = () => {
+ readyForPersistence.set(player, true);
+ wasReadyBeforeLoad.set(player, false);
+ applyPreferredVolume(player);
+ if (player.currentTime > 0) {
+ player.currentTime = 0;
+ }
+ };
+
+ player.addEventListener("loadedmetadata", () => {
+ markReadyForPersistence();
+ }, { passive: true });
+
+ player.addEventListener("loadstart", () => {
+ wasReadyBeforeLoad.set(player, readyForPersistence.get(player) === true);
+ readyForPersistence.set(player, false);
+ applyPreferredVolume(player);
+ if (player.currentTime > 0) {
+ player.currentTime = 0;
+ }
+ }, { passive: true });
+
+ if (player.readyState > 0) {
+ markReadyForPersistence();
+ }
+ };
+
+ const registerVolumeSlider = (slider) => {
+ if (!slider || seenVolumeSliders.has(slider)) {
+ return;
+ }
+ seenVolumeSliders.add(slider);
+ applyPreferredVolumeToSlider(slider);
+
+ const onSliderInput = (event) => {
+ const suppressedUntil = sliderSyncSuppressedUntil.get(slider) || 0;
+ if (Date.now() <= suppressedUntil) {
+ return;
+ }
+
+ if (!isTrustedUserEvent(event)) {
+ applyPreferredVolumeToSlider(slider);
+ return;
+ }
+
+ const next = clampVolume(slider.value);
+ if (next === null) {
+ return;
+ }
+ if (preferredVolume !== null && Math.abs(next - preferredVolume) <= EPSILON) {
+ return;
+ }
+ storePreferredVolume(next);
+ syncAllVolumeControlsToPreferred(null, slider);
+ };
+
+ slider.addEventListener("input", onSliderInput, { passive: true });
+ slider.addEventListener("change", onSliderInput, { passive: true });
+ };
+
+ const scanPlayers = () => {
+ discoverRoots();
+ forEachAudioPlayer(registerPlayer);
+ forEachVolumeSlider(registerVolumeSlider);
+ syncAllVolumeControlsToPreferred();
+ };
+
+ const start = () => {
+ preferredVolume = loadPreferredVolume();
+ if (preferredVolume === null) {
+ // First run (or invalid storage): seed a sane audible default.
+ storePreferredVolume(DEFAULT_VOLUME);
+ if (preferredVolume === null) {
+ preferredVolume = DEFAULT_VOLUME;
+ }
+ }
+ document.addEventListener("click", handleDocumentClick, true);
+ scanPlayers();
+ beginStartupResync();
+ window.addEventListener("beforeunload", () => {
+ stopStartupResync();
+ document.removeEventListener("click", handleDocumentClick, true);
+ }, { once: true });
+ };
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", start, { once: true });
+ } else {
+ start();
+ }
+})();
diff --git a/acestep/ui/gradio/interfaces/audio_player_preferences.py b/acestep/ui/gradio/interfaces/audio_player_preferences.py
new file mode 100644
index 00000000..a74ba965
--- /dev/null
+++ b/acestep/ui/gradio/interfaces/audio_player_preferences.py
@@ -0,0 +1,27 @@
+"""Frontend audio-player preference helpers for the Gradio UI."""
+
+from pathlib import Path
+
+
+_ASSET_FILENAME = "audio_player_preferences.js"
+
+
+def _load_preferences_script() -> str:
+ """Load the external audio-preferences JavaScript asset.
+
+ Returns:
+ JavaScript source text used by the Gradio ``head`` injection.
+ """
+ asset_path = Path(__file__).with_name(_ASSET_FILENAME)
+ return asset_path.read_text(encoding="utf-8").strip()
+
+
+def get_audio_player_preferences_head() -> str:
+ """Return Gradio head HTML that injects audio preference behavior.
+
+ Returns:
+ HTML snippet with a single ``"
diff --git a/acestep/ui/gradio/interfaces/audio_player_preferences_test.py b/acestep/ui/gradio/interfaces/audio_player_preferences_test.py
new file mode 100644
index 00000000..32be96b4
--- /dev/null
+++ b/acestep/ui/gradio/interfaces/audio_player_preferences_test.py
@@ -0,0 +1,74 @@
+"""Unit tests for audio player preference head-script generation."""
+
+import importlib.util
+from pathlib import Path
+import unittest
+
+
+def _load_module():
+ """Load the target module directly by file path for isolated testing."""
+ module_path = Path(__file__).with_name("audio_player_preferences.py")
+ spec = importlib.util.spec_from_file_location("audio_player_preferences", module_path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module) # type: ignore[union-attr]
+ return module
+
+
+_MODULE = _load_module()
+get_audio_player_preferences_head = _MODULE.get_audio_player_preferences_head
+_load_preferences_script = _MODULE._load_preferences_script
+_SCRIPT_PATH = Path(__file__).with_name("audio_player_preferences.js")
+
+
+class AudioPlayerPreferencesHeadTests(unittest.TestCase):
+ """Tests for browser script generation used by Gradio ``Blocks(head=...)``."""
+
+ def test_external_script_asset_exists(self):
+ """The externalized JavaScript asset should exist and be non-empty."""
+ self.assertTrue(_SCRIPT_PATH.is_file())
+ script_asset = _load_preferences_script()
+ self.assertTrue(script_asset)
+
+ def test_script_contains_volume_persistence_and_sync_hooks(self):
+ """Success path: script should include storage and volume-change handling."""
+ script = get_audio_player_preferences_head()
+ self.assertIn("