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
1011import renpy
12+ import renpy .audio .music as _music
1113
1214from 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
1522MYPY = False
1623if 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 ()
0 commit comments