Skip to content

Commit d0b009a

Browse files
committed
refactor(sfx): consolidate SFX playback into CueSfxManager
Move SFX playback helpers (`play_sfx`, `preview_sfx`, `play_pool`, `fade_out`, preset/folder/video previews) out of `cue_lib.runtime` into a new `CueSfxManager` class, and rename the global handle from `_cue.sfx_manager` to `_cue.sfx`. The manager now owns playback state, volume/context dependencies, and markers wiring, while the library tree state lives in `CueSfxLibraryTree` under `sfx.library`, matching the music manager pattern. Also: - late-bind markers via `bind_markers` to break the construction cycle - move channel-name helpers into `sfx_manager` and `_cue_loop_still_playing` into `trigger.py` - derive relative-volume support from the Ren'Py version property - make the settings page scrollable - update UI, stubs, fakes, and tests for the new object layout
1 parent c06402d commit d0b009a

27 files changed

Lines changed: 725 additions & 611 deletions

cue_lib/_types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ class AutoSpeedKnobs(TypedDict):
166166
# =========================================================================
167167

168168
class AudioTreeFolderNode(TypedDict):
169-
"""Folder node in _cue.sfx_manager.tree / visible_tree."""
169+
"""Folder node in _cue.sfx.library.tree / visible_tree."""
170170
type: str
171171
name: str
172172
full_path: str
@@ -176,7 +176,7 @@ class AudioTreeFolderNode(TypedDict):
176176

177177

178178
class AudioTreeFileNode(TypedDict):
179-
"""File node in _cue.sfx_manager.tree / visible_tree."""
179+
"""File node in _cue.sfx.library.tree / visible_tree."""
180180
type: str
181181
name: str
182182
full_path: str

cue_lib/audio/sfx_manager.py

Lines changed: 223 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,247 @@
11
# -*- coding: utf-8 -*-
2-
# CueSfxManager -- SFX library file scan, folder/file tree UI state, and
3-
# disabled files: expand/collapse, visible tree building, and the audio
4-
# scan that feeds them. The tree/scan/toggle core is inherited from
5-
# CueAudioTreeManager; this class adds the SFX-specific extras (preset
6-
# folders, video preset folders, pool file-list refs, disabled files) and
7-
# the index/enabled fields on each file row.
8-
# Instantiated once at _cue.sfx_manager, lives on the NoRollback _cue object.
2+
# CueSfxManager -- SFX playback (shared _cue_ channels) plus the SFX library
3+
# orchestration around it. The library tree (audio scan, folder/preset tree
4+
# UI state, disabled files) lives in CueSfxLibraryTree, owned here as
5+
# ``library`` -- mirroring how CueMusicManager owns its CueCombinedMusicTree.
6+
# Collaborators (paths/db/volume/ctx) are constructor-injected; markers is
7+
# late-bound via bind_markers (construction cycle with CueMarkerManager).
8+
# Instantiated once at _cue.sfx, lives on the NoRollback _cue object.
99

10+
import random as _random
1011
import renpy
12+
import renpy.audio.music as _music
1113

1214
from cue_lib.audio.audio_tree import CueAudioTreeManager
13-
from cue_lib.util import _cue_resolve_files
15+
from cue_lib.constants import CUE_SFX_CHANNEL_COUNT
16+
from cue_lib.util import (
17+
_cue_log, _cue_resolve_files, _cue_pick_file,
18+
is_vid_key, is_img_key, is_dlg_key,
19+
get_key_file, get_key_dialogue,
20+
)
1421

1522
MYPY = False
1623
if MYPY:
17-
from typing import Any, Dict, List, Optional, Set
18-
from cue_lib.paths import CuePaths # pyright: ignore[reportUnusedImport]
24+
from typing import Any, Dict, List, Optional, Set # pyright: ignore[reportUnusedImport]
25+
from cue_lib._types import MarkerEntry, PoolDict # pyright: ignore[reportUnusedImport]
1926
from cue_lib.db import CueDatabase # pyright: ignore[reportUnusedImport]
27+
from cue_lib.markers import CueMarkerManager # pyright: ignore[reportUnusedImport]
28+
from cue_lib.paths import CuePaths # pyright: ignore[reportUnusedImport]
29+
from cue_lib.state import CueContext # pyright: ignore[reportUnusedImport]
30+
from cue_lib.volume import CueVolumeManager # pyright: ignore[reportUnusedImport]
31+
32+
33+
# Quick cross-fade duration for exclusive cut-in sweeps.
34+
CUE_EXCLUSIVE_FADE = 0.1
35+
36+
37+
def _cue_sfx_channel_name(index):
38+
# type: (int) -> str
39+
"""Channel name for a 1-based index into the shared _cue_ SFX channels."""
40+
return "_cue_{}".format(index)
41+
42+
43+
def _cue_sfx_channel_index(ch_name):
44+
# type: (str) -> int
45+
"""Reverse of _cue_sfx_channel_name: parse the 1-based index from a
46+
shared _cue_ SFX channel name."""
47+
return int(ch_name.split("_")[-1])
48+
49+
50+
class CueSfxManager(object):
51+
"""SFX playback + library orchestration.
52+
53+
Owns the SFX library tree (CueSfxLibraryTree) and the playback state
54+
and methods that drive the shared _cue_ channels. Playback methods are
55+
callable via Function() from screen actions; trigger.py calls play_pool
56+
/ fade_out for exclusive cut-ins."""
57+
58+
def __init__(self, paths, db, volume, ctx, supports_relative_volume):
59+
# type: (CuePaths, CueDatabase, CueVolumeManager, CueContext, bool) -> None
60+
self.library = CueSfxLibraryTree(paths, db)
61+
self._paths = paths
62+
self._db = db
63+
self._volume = volume
64+
self._ctx = ctx
65+
self._supports_relative_volume = supports_relative_volume
66+
self._markers = None # type: Optional[CueMarkerManager]
67+
68+
# SFX playback state
69+
self._next_sfx_channel = 0 # round-robin fallback when all channels are busy
70+
self._preview_channel = None # channel currently playing a preview
71+
72+
def bind_markers(self, markers):
73+
# type: (CueMarkerManager) -> None
74+
"""Late-bind markers -- CueMarkerManager takes sfx_manager (its
75+
library) at construction, so this breaks the two-way construction
76+
cycle. Called by cue_z.rpy once markers exists; playback methods
77+
read it at call time."""
78+
self._markers = markers
79+
80+
def _markers_ctx(self):
81+
# type: () -> CueMarkerManager
82+
"""The bound marker manager. Always set by the time playback runs
83+
(cue_z.rpy calls bind_markers at init); a missing bind is a wiring
84+
bug, so fail loudly rather than skip playback silently."""
85+
if self._markers is None:
86+
raise RuntimeError("CueSfxManager markers not bound (bind_markers never called)")
87+
return self._markers
88+
89+
# ------------------------------------------------------------------
90+
# Playback
91+
# ------------------------------------------------------------------
92+
93+
def play_pool(self, entry, key, pool, pool_index, file=None, avoid_repeats=True):
94+
# type: (Optional[MarkerEntry], str, PoolDict, int, Optional[str], bool) -> Optional[str]
95+
resolved = self._markers_ctx().resolve_pool(pool)
96+
files = _cue_resolve_files(resolved.files)
97+
if not files:
98+
return None
99+
f = file if file is not None else _cue_pick_file(files, avoid_repeats=avoid_repeats) # type: Any
100+
vol = self._volume.get_effective(entry, key, pool_index=pool_index)
101+
return self.play_sfx(f, key, volume=vol)
102+
103+
def play_sfx(self, filename, source="", volume=1.0):
104+
# type: (str, str, float) -> Optional[str]
20105

106+
# Apply +-10% volume jitter for natural variation
107+
MAX_JITTER = 0.1
108+
jitter = _random.uniform(1.0 - MAX_JITTER, 1.0 + MAX_JITTER)
109+
volume = volume * jitter
21110

22-
class CueSfxManager(CueAudioTreeManager):
111+
full_path = self._paths.audio_dir + filename
112+
113+
target_ch = None
114+
for i in range(1, CUE_SFX_CHANNEL_COUNT + 1):
115+
ch_name = _cue_sfx_channel_name(i)
116+
if not _music.is_playing(channel=ch_name):
117+
target_ch = ch_name
118+
break
119+
120+
if target_ch is None:
121+
idx = self._next_sfx_channel
122+
target_ch = _cue_sfx_channel_name(idx + 1)
123+
self._next_sfx_channel = (idx + 1) % CUE_SFX_CHANNEL_COUNT
124+
else:
125+
ch_num = _cue_sfx_channel_index(target_ch)
126+
self._next_sfx_channel = ch_num % CUE_SFX_CHANNEL_COUNT
127+
128+
try:
129+
curr_file = self._ctx.current_file
130+
warn = None
131+
if is_vid_key(source):
132+
expected_vid = get_key_file(source)
133+
if expected_vid and curr_file and expected_vid != curr_file:
134+
warn = "expected vid={} actual vid={}".format(expected_vid, curr_file)
135+
elif is_img_key(source):
136+
expected_img = get_key_file(source)
137+
if expected_img and curr_file and expected_img != curr_file:
138+
warn = "expected img={} actual img={}".format(expected_img, curr_file)
139+
elif is_dlg_key(source):
140+
expected_img = get_key_file(source)
141+
expected_dlg = get_key_dialogue(source)
142+
cur_dlg = (self._ctx.current_dialogue or "")[:40]
143+
if expected_img != curr_file or expected_dlg != cur_dlg:
144+
warn = "expected img={}|{} actual img={}|{}".format(
145+
expected_img, expected_dlg, curr_file, cur_dlg)
146+
if warn:
147+
_cue_log("WARN CTX-MISMATCH file={} src={} {}".format(
148+
filename.rsplit("/", 1)[-1], source, warn))
149+
150+
if self._supports_relative_volume:
151+
_music.play(full_path, channel=target_ch, loop=False, relative_volume=volume)
152+
else:
153+
_music.play(full_path, channel=target_ch, loop=False)
154+
_music.set_volume(volume, delay=0, channel=target_ch)
155+
156+
_cue_log("PLAY-SFX file={} src={} ch={} jitter={} vol={:.2f}".format(
157+
filename.rsplit("/", 1)[-1], source, target_ch, jitter, volume))
158+
159+
return target_ch
160+
except Exception:
161+
_cue_log("PLAY-SFX: exception during playback of {}".format(full_path))
162+
return None
163+
164+
def preview_sfx(self, filename, volume=1.0):
165+
# type: (str, float) -> None
166+
prev_ch = self._preview_channel
167+
if prev_ch is not None and _music.is_playing(channel=prev_ch):
168+
_music.stop(channel=prev_ch, fadeout=0)
169+
self._preview_channel = self.play_sfx(filename, "preview", volume=volume)
170+
171+
# ------------------------------------------------------------------
172+
# Library previews
173+
# ------------------------------------------------------------------
174+
175+
def preview_preset(self, preset_name):
176+
# type: (str) -> None
177+
preset = self._markers_ctx().get_preset(preset_name)
178+
if preset is None:
179+
return
180+
files = _cue_resolve_files(preset.get("files", []))
181+
if files:
182+
f = _random.choice(files)
183+
self.preview_sfx(f)
184+
185+
def preview_folder(self, folder_path, volume=1.0):
186+
# type: (str, float) -> None
187+
"""Preview a random file from an SFX Library folder."""
188+
files = _cue_resolve_files([folder_path])
189+
if files:
190+
f = _random.choice(files)
191+
self.preview_sfx(f, volume=volume)
192+
193+
def preview_video_preset(self, preset_name):
194+
# type: (str) -> None
195+
"""Preview a random file from a video preset (across all pools)."""
196+
preset = self._markers_ctx().get_video_preset(preset_name)
197+
if preset is None:
198+
return
199+
all_files = []
200+
for pool in preset.get("pools", []):
201+
all_files.extend(pool.get("files", []))
202+
resolved = _cue_resolve_files(all_files)
203+
if resolved:
204+
f = _random.choice(resolved)
205+
self.preview_sfx(f)
206+
207+
def fade_out(self, exclude_channels=None, only_channels=None):
208+
# type: (Optional[List[str]], Optional[List[str]]) -> int
209+
"""Quickly fade out SFX on the shared _cue_ channels.
210+
211+
``exclude_channels`` are same-group channels to spare; ``only_channels``
212+
restricts the sweep to a single domain (loops fade only loops, one-shots
213+
fade only one-shots). Returns the number of channels faded."""
214+
excluded = set(exclude_channels) if exclude_channels else set()
215+
only = set(only_channels) if only_channels is not None else None
216+
faded = 0
217+
for i in range(1, CUE_SFX_CHANNEL_COUNT + 1):
218+
ch_name = _cue_sfx_channel_name(i)
219+
if only is not None and ch_name not in only:
220+
continue
221+
if ch_name in excluded:
222+
continue
223+
if _music.is_playing(channel=ch_name):
224+
_music.stop(channel=ch_name, fadeout=CUE_EXCLUSIVE_FADE)
225+
faded += 1
226+
return faded
227+
228+
229+
class CueSfxLibraryTree(CueAudioTreeManager):
23230
"""SFX library audio tree state, expand/collapse, disabled files, and scan.
24231
25232
Owns all UI state for the SFX Library audio tree, preset folders,
26233
video preset folders, section frames, and pool file-list folder refs.
27234
The audio file caches (files / tree / scan_error) and the scan that
28235
builds them live in CueAudioTreeManager. Provides toggle methods
29-
callable via Function() from screen actions."""
236+
callable via Function() from screen actions. Owned by CueSfxManager
237+
as its ``library`` attribute."""
30238

31239
_scan_label = "audio folder"
32240
_log_tag = "AUDIO"
33241

34242
def __init__(self, paths, db):
35243
# type: (CuePaths, CueDatabase) -> None
36-
super(CueSfxManager, self).__init__()
244+
super(CueSfxLibraryTree, self).__init__()
37245
self._paths = paths
38246
self._db = db
39247

@@ -54,10 +262,6 @@ def __init__(self, paths, db):
54262
# Overlay mode: SFX Library section floats at 50% height
55263
self.overlay_mode = False
56264

57-
# SFX playback state
58-
self._next_sfx_channel = 0 # round-robin fallback when all channels are busy
59-
self._preview_channel = None # channel currently playing a preview
60-
61265
# ------------------------------------------------------------------
62266
# Scanning
63267
# ------------------------------------------------------------------
@@ -70,7 +274,7 @@ def _discover(self, results_set):
70274
def _file_node(self, item, full, depth):
71275
# type: (Dict[str, Any], str, int) -> Dict[str, Any]
72276
"""File row with index/enabled for the SFX Library."""
73-
node = super(CueSfxManager, self)._file_node(item, full, depth)
277+
node = super(CueSfxLibraryTree, self)._file_node(item, full, depth)
74278
node["index"] = self._file_index.get(full, -1)
75279
node["enabled"] = full not in self.disabled_files
76280
return node
@@ -159,7 +363,6 @@ def toggle_overlay_mode(self):
159363
"""Toggle overlay mode for the SFX Library section.
160364
Enabling overlay mode collapses the section if expanded.
161365
Exiting overlay mode expands the section if collapsed."""
162-
was_overlay = self.overlay_mode
163-
self.overlay_mode = not was_overlay
366+
self.overlay_mode = not self.overlay_mode
164367

165368
renpy.restart_interaction()

cue_lib/audio/sfx_manager.pyi

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,24 @@ from typing import Dict, List, Optional, Set
33

44
from cue_lib.audio.audio_tree import CueAudioTreeManager
55
from cue_lib.db import CueDatabase
6+
from cue_lib.markers import CueMarkerManager
67
from cue_lib.paths import CuePaths
8+
from cue_lib.state import CueContext
9+
from cue_lib.volume import CueVolumeManager
10+
from cue_lib._types import MarkerEntry, PoolDict
711

8-
class CueSfxManager(CueAudioTreeManager):
12+
13+
def _cue_sfx_channel_name(index: int) -> str: ...
14+
def _cue_sfx_channel_index(ch_name: str) -> int: ...
15+
16+
class CueSfxLibraryTree(CueAudioTreeManager):
917
expanded_file_refs: Dict[str, bool]
1018
presets_expanded: bool
1119
expanded_presets: Dict[str, bool]
1220
video_presets_expanded: bool
1321
expanded_video_presets: Dict[str, bool]
1422
disabled_files: Set[str]
1523
overlay_mode: bool
16-
_next_sfx_channel: int
17-
_preview_channel: Optional[str]
1824

1925
def __init__(self, paths: CuePaths, db: CueDatabase) -> None: ...
2026
def toggle_file_enabled(self, full_path: str) -> None: ...
@@ -29,3 +35,41 @@ class CueSfxManager(CueAudioTreeManager):
2935
def toggle_video_presets_expand(self) -> None: ...
3036
def toggle_video_preset_expand(self, preset_name: str) -> None: ...
3137
def toggle_overlay_mode(self) -> None: ...
38+
39+
class CueSfxManager(object):
40+
library: CueSfxLibraryTree
41+
_paths: CuePaths
42+
_db: CueDatabase
43+
_volume: CueVolumeManager
44+
_ctx: CueContext
45+
_supports_relative_volume: bool
46+
_markers: Optional[CueMarkerManager]
47+
_next_sfx_channel: int
48+
_preview_channel: Optional[str]
49+
50+
def __init__(
51+
self,
52+
paths: CuePaths,
53+
db: CueDatabase,
54+
volume: CueVolumeManager,
55+
ctx: CueContext,
56+
supports_relative_volume: bool) -> None: ...
57+
def bind_markers(self, markers: CueMarkerManager) -> None: ...
58+
def _markers_ctx(self) -> CueMarkerManager: ...
59+
def play_sfx(self, filename: str, source: str = "", volume: float = 1.0) -> Optional[str]: ...
60+
def preview_sfx(self, filename: str, volume: float = 1.0) -> None: ...
61+
def play_pool(
62+
self,
63+
entry: Optional[MarkerEntry],
64+
key: str,
65+
pool: PoolDict,
66+
pool_index: int,
67+
file: Optional[str] = None,
68+
avoid_repeats: bool = True) -> Optional[str]: ...
69+
def fade_out(
70+
self,
71+
exclude_channels: Optional[List[str]] = None,
72+
only_channels: Optional[List[str]] = None) -> int: ...
73+
def preview_preset(self, preset_name: str) -> None: ...
74+
def preview_folder(self, folder_path: str, volume: float = 1.0) -> None: ...
75+
def preview_video_preset(self, preset_name: str) -> None: ...

0 commit comments

Comments
 (0)