From c6faf1e30b029d5acee10215ce517d823b25ec87 Mon Sep 17 00:00:00 2001 From: 1larity Date: Sun, 22 Feb 2026 22:41:22 +0000 Subject: [PATCH 1/5] Fix Gradio audio volume persistence and playback reset --- .../events/results/audio_playback_updates.py | 36 ++ .../results/audio_playback_updates_test.py | 62 ++++ .../gradio/events/results/batch_navigation.py | 13 +- .../gradio/events/results/generation_info.py | 16 +- .../events/results/generation_info_test.py | 47 ++- .../events/results/generation_progress.py | 16 +- acestep/ui/gradio/interfaces/__init__.py | 4 + .../interfaces/audio_player_preferences.py | 328 ++++++++++++++++++ .../audio_player_preferences_test.py | 56 +++ ui/studio.html | 103 +++++- ui/studio_html_test.py | 28 ++ 11 files changed, 689 insertions(+), 20 deletions(-) create mode 100644 acestep/ui/gradio/events/results/audio_playback_updates.py create mode 100644 acestep/ui/gradio/events/results/audio_playback_updates_test.py create mode 100644 acestep/ui/gradio/interfaces/audio_player_preferences.py create mode 100644 acestep/ui/gradio/interfaces/audio_player_preferences_test.py create mode 100644 ui/studio_html_test.py 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..0b50b414 --- /dev/null +++ b/acestep/ui/gradio/events/results/audio_playback_updates_test.py @@ -0,0 +1,62 @@ +"""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.""" + result = build_audio_slot_update( + _FakeGr, + "/tmp/sample.flac", + label="Sample 1 (Ready)", + interactive=False, + ) + self.assertEqual(result["value"], "/tmp/sample.flac") + 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.""" + result = build_audio_slot_update(_FakeGr, "/tmp/sample.flac") + self.assertEqual(result["value"], "/tmp/sample.flac") + 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..2544e2d9 100644 --- a/acestep/ui/gradio/events/results/generation_info.py +++ b/acestep/ui/gradio/events/results/generation_info.py @@ -23,8 +23,20 @@ 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, keep the 8 audio components unchanged via ``gr.skip()`` + and only clear the batch-download file list (9th output). This avoids + replacing player DOM nodes while generation is in progress, which can + reset browser-side volume state. + + 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 Exception: + return (None,) * 9 + return (gr.skip(),) * 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..f8f12277 100644 --- a/acestep/ui/gradio/events/results/generation_info_test.py +++ b/acestep/ui/gradio/events/results/generation_info_test.py @@ -1,14 +1,40 @@ """Unit tests for generation_info module.""" +import importlib.util import os +from pathlib import Path +import sys +import types import unittest +from unittest.mock import patch -from acestep.ui.gradio.events.results.generation_info import ( - DEFAULT_RESULTS_DIR, - PROJECT_ROOT, - clear_audio_outputs_for_new_generation, - _build_generation_info, -) + +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): @@ -38,6 +64,15 @@ def test_returns_nine_nones(self): for item in result: self.assertIsNone(item) + def test_gradio_runtime_skips_audio_clear_but_clears_batch_files(self): + """With Gradio available, audio outputs should be skip updates.""" + fake_gradio = types.SimpleNamespace(skip=lambda: "SKIP") + with patch.dict("sys.modules", {"gradio": fake_gradio}): + result = clear_audio_outputs_for_new_generation() + self.assertEqual(len(result), 9) + self.assertEqual(result[:8], ("SKIP",) * 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.py b/acestep/ui/gradio/interfaces/audio_player_preferences.py new file mode 100644 index 00000000..a8139c2f --- /dev/null +++ b/acestep/ui/gradio/interfaces/audio_player_preferences.py @@ -0,0 +1,328 @@ +"""Frontend audio-player preference helpers for the Gradio UI.""" + + +def get_audio_player_preferences_head() -> str: + """Return head script that persists and syncs HTML audio volume. + + The script keeps a shared preferred volume for Gradio audio players + (native ``audio`` elements and waveform volume sliders), stores it in + ``localStorage``, and reapplies it when components are re-rendered. + """ + return """ + +""".strip() 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..80f631b6 --- /dev/null +++ b/acestep/ui/gradio/interfaces/audio_player_preferences_test.py @@ -0,0 +1,56 @@ +"""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 + + +class AudioPlayerPreferencesHeadTests(unittest.TestCase): + """Tests for browser script generation used by Gradio ``Blocks(head=...)``.""" + + 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(" - \ No newline at end of file + diff --git a/ui/studio_html_test.py b/ui/studio_html_test.py new file mode 100644 index 00000000..99a9968d --- /dev/null +++ b/ui/studio_html_test.py @@ -0,0 +1,28 @@ +"""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) + + +if __name__ == "__main__": + unittest.main() From d803b45ab765b4df0568ecaaefe29532baa3214f Mon Sep 17 00:00:00 2001 From: 1larity Date: Tue, 24 Feb 2026 00:26:54 +0000 Subject: [PATCH 2/5] Stop playback on regenerate without remounting audio --- .../gradio/events/results/generation_info.py | 11 ++-- .../events/results/generation_info_test.py | 8 +-- .../interfaces/audio_player_preferences.py | 54 ++++++++++++++++++- .../audio_player_preferences_test.py | 2 + .../generation_tab_generate_controls.py | 1 + 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/acestep/ui/gradio/events/results/generation_info.py b/acestep/ui/gradio/events/results/generation_info.py index 2544e2d9..cefe4966 100644 --- a/acestep/ui/gradio/events/results/generation_info.py +++ b/acestep/ui/gradio/events/results/generation_info.py @@ -25,10 +25,11 @@ def clear_audio_outputs_for_new_generation(): """Return pre-generation output updates without remounting audio players. - In Gradio runtime, keep the 8 audio components unchanged via ``gr.skip()`` - and only clear the batch-download file list (9th output). This avoids - replacing player DOM nodes while generation is in progress, which can - reset browser-side volume state. + 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. """ @@ -36,7 +37,7 @@ def clear_audio_outputs_for_new_generation(): import gradio as gr # local import keeps headless tests dependency-free except Exception: return (None,) * 9 - return (gr.skip(),) * 8 + (None,) + 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 f8f12277..6b52eadb 100644 --- a/acestep/ui/gradio/events/results/generation_info_test.py +++ b/acestep/ui/gradio/events/results/generation_info_test.py @@ -64,13 +64,13 @@ def test_returns_nine_nones(self): for item in result: self.assertIsNone(item) - def test_gradio_runtime_skips_audio_clear_but_clears_batch_files(self): - """With Gradio available, audio outputs should be skip updates.""" - fake_gradio = types.SimpleNamespace(skip=lambda: "SKIP") + 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], ("SKIP",) * 8) + self.assertEqual(result[:8], ({"playback_position": 0},) * 8) self.assertIsNone(result[8]) diff --git a/acestep/ui/gradio/interfaces/audio_player_preferences.py b/acestep/ui/gradio/interfaces/audio_player_preferences.py index a8139c2f..37591649 100644 --- a/acestep/ui/gradio/interfaces/audio_player_preferences.py +++ b/acestep/ui/gradio/interfaces/audio_player_preferences.py @@ -12,6 +12,7 @@ def get_audio_player_preferences_head() -> str: -""".strip() + 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 index ebcf31cc..32be96b4 100644 --- a/acestep/ui/gradio/interfaces/audio_player_preferences_test.py +++ b/acestep/ui/gradio/interfaces/audio_player_preferences_test.py @@ -16,11 +16,19 @@ def _load_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() diff --git a/ui/studio.html b/ui/studio.html index 19ef443c..f3e1dc32 100644 --- a/ui/studio.html +++ b/ui/studio.html @@ -439,6 +439,7 @@

Date: Wed, 25 Feb 2026 16:35:42 +0000 Subject: [PATCH 5/5] fix(build): include audio_player_preferences.js in wheel --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 693c38d6..b2a75848 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,3 +104,6 @@ dev = [] [tool.hatch.build.targets.wheel] packages = ["acestep"] + +[tool.hatch.build.targets.wheel.force-include] +"acestep/ui/gradio/interfaces/audio_player_preferences.js" = "acestep/ui/gradio/interfaces/audio_player_preferences.js"