Skip to content

Commit 4edf346

Browse files
Copilotkangjoseph90
andcommitted
Simplify preview audio volume handling: single max-preview asset strategy
Co-authored-by: kangjoseph90 <83045825+kangjoseph90@users.noreply.github.com> Agent-Logs-Url: https://github.com/kangjoseph90/PictureBookBuilder/sessions/e1035a12-ec86-4aa4-b231-b9a637fbc25d
1 parent d89f710 commit 4edf346

3 files changed

Lines changed: 514 additions & 47 deletions

File tree

src/ui/audio_mixer.py

Lines changed: 66 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ def timeline_duration(self) -> float:
3535
return self.timeline_end - self.timeline_start
3636

3737

38+
# Pre-baked volume for the single max-preview audio asset.
39+
# Clips with volume > 1.0 are always rendered to a file at this fixed amplitude
40+
# and played back with runtime gain = (requested_volume / MAX_PREVIEW_VOLUME) so
41+
# that a single cached file is reused for all volume values in (1.0, 2.0].
42+
MAX_PREVIEW_VOLUME: float = 2.0
43+
44+
3845
class AudioMixer(QObject):
3946
"""
4047
Real-time audio mixer that plays clips at their scheduled timeline positions.
@@ -43,7 +50,15 @@ class AudioMixer(QObject):
4350
1. Maintains a list of scheduled clips with their timeline positions
4451
2. Uses a timer to track current playback position
4552
3. Starts/stops individual audio players as needed based on position
46-
53+
54+
Volume > 1.0 strategy
55+
---------------------
56+
Rather than generating a unique boosted file per requested volume, a single
57+
"max-preview" file is created per (source, offset, duration) segment at
58+
MAX_PREVIEW_VOLUME amplitude. At playback time the runtime gain is scaled
59+
to ``master_volume * clip_volume / MAX_PREVIEW_VOLUME`` so the perceived
60+
level matches the requested volume without re-encoding.
61+
4762
Signals:
4863
position_changed: Emitted with current position in milliseconds
4964
playback_state_changed: Emitted with 'playing', 'paused', or 'stopped'
@@ -75,8 +90,10 @@ def __init__(self, parent=None):
7590
# These are pre-loaded and ready to seek/play instantly
7691
self._player_cache: dict[str, tuple[QMediaPlayer, QAudioOutput, float]] = {}
7792

78-
# Cache for boosted audio temp files
79-
# Key: (source_path, offset, duration, volume) -> temp_file_path
93+
# Cache for boosted audio temp files.
94+
# Key: (source_path, offset, duration) -> temp_file_path
95+
# Volume is NOT part of the key because all boosted clips share the same
96+
# MAX_PREVIEW_VOLUME file; runtime gain handles the per-clip level.
8097
self._boosted_files_cache: dict[tuple, str] = {}
8198

8299
# Map clip_id -> temp_file_path to manage ownership and cleanup
@@ -261,9 +278,9 @@ def set_volume(self, volume: float):
261278

262279
# Logic differs for boosted vs normal clips
263280
if clip.volume > 1.0:
264-
# For boosted clips, the boost is baked into the file.
265-
# So we just set master volume.
266-
audio_output.setVolume(self._volume)
281+
# Max-volume file is used; scale gain to match requested level.
282+
effective_gain = self._volume * clip.volume / MAX_PREVIEW_VOLUME
283+
audio_output.setVolume(effective_gain)
267284
else:
268285
# For normal clips, we multiply
269286
effective_vol = self._volume * clip.volume
@@ -376,25 +393,28 @@ def _get_seek_correction(self, audio_path: str) -> float:
376393
return seek_correction
377394

378395
def _prepare_boosted_audio(self, clip: ScheduledClip) -> Optional[str]:
379-
"""Prepare a temporary boosted audio file for a clip using FFmpeg.
396+
"""Prepare (or return cached) the max-volume preview file for a clip.
397+
398+
A single file at MAX_PREVIEW_VOLUME amplitude is created for each unique
399+
(source_path, offset, duration) segment. The caller is responsible for
400+
scaling playback gain to match the actual requested volume.
380401
381402
Args:
382-
clip: Clip to boost
403+
clip: Clip whose segment should be pre-rendered at max preview volume
383404
384405
Returns:
385-
Path to temporary boosted wav file, or None on failure
406+
Path to the cached max-volume WAV segment, or None on failure
386407
"""
387408
if clip.volume <= 1.0:
388409
return None
389410

390-
# Key includes offset and duration because we extract just the segment
391-
key = (clip.source_path, clip.source_offset, clip.duration, clip.volume)
411+
# Cache key does NOT include volume — all volume > 1.0 clips share the
412+
# same MAX_PREVIEW_VOLUME file for a given segment.
413+
key = (clip.source_path, clip.source_offset, clip.duration)
392414

393415
if key in self._boosted_files_cache:
394416
path = self._boosted_files_cache[key]
395417
if os.path.exists(path):
396-
# Update ownership if needed, or just return
397-
# If this clip already owns a file that is different, we handle that in caller
398418
return path
399419

400420
try:
@@ -405,21 +425,25 @@ def _prepare_boosted_audio(self, clip: ScheduledClip) -> Optional[str]:
405425
fd, temp_path = tempfile.mkstemp(suffix=".wav")
406426
os.close(fd)
407427

408-
# Use FFmpeg to extract segment and apply volume.
428+
# Use FFmpeg to extract segment and apply MAX_PREVIEW_VOLUME.
409429
# Keep -ss AFTER -i for accurate seek on compressed/VBR sources.
410430
# (This intentionally favors slower decode-then-seek behavior over
411431
# fast but potentially imprecise input seeking.)
412432
# -i: input file
413433
# -ss: start time
414434
# -t: duration
415-
# -filter:a "volume=X"
435+
# -filter:a "volume=X,alimiter": first boost amplitude to
436+
# MAX_PREVIEW_VOLUME, then hard-limit peaks at 0.99 FS to
437+
# prevent clipping. level=disabled means the limiter does not
438+
# apply further gain normalisation — it only clips peaks above
439+
# the threshold.
416440
# -y: overwrite
417441
cmd = [
418442
'ffmpeg',
419443
'-i', clip.source_path,
420444
'-ss', str(clip.source_offset),
421445
'-t', str(clip.duration),
422-
'-filter:a', f'volume={clip.volume}',
446+
'-filter:a', f'volume={MAX_PREVIEW_VOLUME},alimiter=limit=0.99:level=disabled',
423447
'-y',
424448
temp_path
425449
]
@@ -429,18 +453,6 @@ def _prepare_boosted_audio(self, clip: ScheduledClip) -> Optional[str]:
429453

430454
# Update caches
431455
self._boosted_files_cache[key] = temp_path
432-
433-
# Cleanup old file for this clip if it exists and is different
434-
if clip.clip_id in self._clip_boosted_file:
435-
old_path = self._clip_boosted_file[clip.clip_id]
436-
if old_path != temp_path:
437-
# Only delete if no other clip uses it (simple ref counting impossible with tuple keys)
438-
# For simplicity in this session: we trust the cache key logic.
439-
# We only delete if it's NOT in the cache anymore (replaced)?
440-
# Actually, if the key changed (volume changed), the old key is still in cache.
441-
# We should remove old key from cache to prevent unlimited growth?
442-
pass
443-
444456
self._clip_boosted_file[clip.clip_id] = temp_path
445457

446458
return temp_path
@@ -453,15 +465,15 @@ def _get_or_create_boosted_player(self, clip_id: str, boosted_path: str) -> tupl
453465
"""Get or create a dedicated player for a boosted clip."""
454466
if clip_id in self._boosted_players:
455467
player, output = self._boosted_players[clip_id]
456-
# Verify source is correct (might have changed volume/file)
468+
# Verify source is correct (may have changed if segment params changed)
457469
current_source = player.source().toLocalFile()
458470
if current_source != boosted_path:
459471
player.setSource(QUrl.fromLocalFile(boosted_path))
460472
return player, output
461473

462474
# Create new
463475
audio_output = QAudioOutput()
464-
audio_output.setVolume(self._volume) # Master volume only
476+
audio_output.setVolume(self._volume) # Caller sets exact gain after creation
465477

466478
player = QMediaPlayer()
467479
player.setAudioOutput(audio_output)
@@ -487,8 +499,10 @@ def _start_clip(self, clip: ScheduledClip, current_position: float):
487499
# can under-seek (e.g. 5s -> ~2.5s) on some backends.
488500
# Keep boosted-path seek in direct timeline milliseconds.
489501

490-
# Master volume only (boost baked in)
491-
audio_output.setVolume(self._volume)
502+
# Scale runtime gain so perceived level matches requested volume:
503+
# effectiveGain = master_volume * clip_volume / MAX_PREVIEW_VOLUME
504+
effective_gain = self._volume * clip.volume / MAX_PREVIEW_VOLUME
505+
audio_output.setVolume(effective_gain)
492506

493507
# For boosted clips, source is the EXTRACTED segment (offset 0)
494508
# Time into clip determines position in temp file
@@ -657,22 +671,27 @@ def update_clip(self, clip: ScheduledClip):
657671

658672
elif needs_boost:
659673
# Boosted -> Boosted.
660-
# Check if we need to regenerate due to volume change
661-
# We do this by checking if the _prepare_boosted_audio returns a different path
662-
# (since key includes volume) or simply if we assume it changed.
663-
664-
# Ideally we only restart if the path changed.
665-
# But calculating path requires calling _prepare_boosted_audio (which might run ffmpeg).
666-
# Since we already optimized UI to NOT call this during drag, we can assume
667-
# any call here is a committed change (or legitimate update).
668-
669-
# So we restart to be safe and apply new file.
670-
if self._playing:
671-
self._stop_clip(clip.clip_id)
672-
if clip.timeline_start <= self._position < clip.timeline_end:
673-
self._start_clip(clip, self._position)
674+
# All boosted clips share the same MAX_PREVIEW_VOLUME file for a given
675+
# (source, offset, duration). Check whether the cached file still
676+
# matches; if yes, only the runtime gain needs updating (no re-encode).
677+
expected_key = (clip.source_path, clip.source_offset, clip.duration)
678+
cached_path = self._boosted_files_cache.get(expected_key)
679+
current_player, current_output = self._active_players[clip.clip_id]
680+
current_source = current_player.source().toLocalFile()
681+
682+
if cached_path and os.path.exists(cached_path) and current_source == cached_path:
683+
# Same segment file — volume change only; update gain in-place.
684+
effective_gain = self._volume * clip.volume / MAX_PREVIEW_VOLUME
685+
current_output.setVolume(effective_gain)
674686
else:
675-
self._stop_clip(clip.clip_id)
687+
# Segment parameters changed (source/offset/duration); must restart
688+
# so that _start_clip builds a new max-volume file.
689+
if self._playing:
690+
self._stop_clip(clip.clip_id)
691+
if clip.timeline_start <= self._position < clip.timeline_end:
692+
self._start_clip(clip, self._position)
693+
else:
694+
self._stop_clip(clip.clip_id)
676695

677696
else:
678697
# Normal -> Normal. Just update volume multiplier.

tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)