@@ -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+
3845class 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.
0 commit comments