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(" - \ 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..5b845fa6 --- /dev/null +++ b/ui/studio_html_test.py @@ -0,0 +1,35 @@ +"""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()