Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions acestep/ui/gradio/events/results/audio_playback_updates.py
Original file line number Diff line number Diff line change
@@ -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)
64 changes: 64 additions & 0 deletions acestep/ui/gradio/events/results/audio_playback_updates_test.py
Original file line number Diff line number Diff line change
@@ -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()
13 changes: 9 additions & 4 deletions acestep/ui/gradio/events/results/batch_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
]

Expand Down Expand Up @@ -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)
]

Expand Down
17 changes: 15 additions & 2 deletions acestep/ui/gradio/events/results/generation_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
62 changes: 53 additions & 9 deletions acestep/ui/gradio/events/results/generation_info_test.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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."""
Expand Down
16 changes: 9 additions & 7 deletions acestep/ui/gradio/events/results/generation_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions acestep/ui/gradio/interfaces/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading