Gamahea commited on
Commit
add80a6
·
verified ·
1 Parent(s): 4fd5bca

Fix crossfade to use proper equal-power overlap instead of fade-out-then-in

Browse files
Files changed (1) hide show
  1. backend/services/export_service.py +175 -167
backend/services/export_service.py CHANGED
@@ -1,167 +1,175 @@
1
- """
2
- Export and merge service
3
- """
4
- import os
5
- import logging
6
- from typing import Optional, List
7
- import numpy as np
8
- import soundfile as sf
9
- from services.timeline_service import TimelineService
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
- class ExportService:
14
- """Service for exporting and merging audio"""
15
-
16
- def __init__(self):
17
- """Initialize export service"""
18
- self.timeline_service = TimelineService()
19
- logger.info("Export service initialized")
20
-
21
- def merge_clips(
22
- self,
23
- filename: str = "output",
24
- export_format: str = "wav"
25
- ) -> Optional[str]:
26
- """
27
- Merge all timeline clips into a single file
28
-
29
- Args:
30
- filename: Output filename (without extension)
31
- export_format: Output format (wav, mp3, flac)
32
-
33
- Returns:
34
- Path to merged file, or None if no clips
35
- """
36
- try:
37
- clips = self.timeline_service.get_all_clips()
38
-
39
- if not clips:
40
- logger.warning("No clips to merge")
41
- return None
42
-
43
- logger.info(f"Merging {len(clips)} clips")
44
-
45
- # Load all clips
46
- audio_data = []
47
- sample_rate = None
48
-
49
- for clip in clips:
50
- audio, sr = sf.read(clip['file_path'])
51
-
52
- if sample_rate is None:
53
- sample_rate = sr
54
- elif sr != sample_rate:
55
- logger.warning(f"Sample rate mismatch: {sr} vs {sample_rate}")
56
- # Could resample here if needed
57
-
58
- audio_data.append(audio)
59
-
60
- # Apply crossfading between clips (2 second overlap)
61
- crossfade_duration = 2.0 # seconds
62
- crossfade_samples = int(crossfade_duration * sample_rate)
63
-
64
- if len(audio_data) == 1:
65
- # Single clip, no crossfading needed
66
- merged_audio = audio_data[0]
67
- else:
68
- # Start with first clip
69
- merged_audio = audio_data[0].copy()
70
-
71
- # Crossfade each subsequent clip
72
- for i in range(1, len(audio_data)):
73
- current_clip = audio_data[i]
74
-
75
- # Calculate overlap region
76
- overlap_samples = min(crossfade_samples, len(merged_audio), len(current_clip))
77
-
78
- if overlap_samples > 0:
79
- # Create fade out curve for end of previous audio
80
- fade_out = np.linspace(1.0, 0.0, overlap_samples)
81
- # Create fade in curve for start of current clip
82
- fade_in = np.linspace(0.0, 1.0, overlap_samples)
83
-
84
- # Handle stereo vs mono
85
- if merged_audio.ndim == 2:
86
- fade_out = fade_out[:, np.newaxis]
87
- fade_in = fade_in[:, np.newaxis]
88
-
89
- # Apply crossfade
90
- merged_audio[-overlap_samples:] = (
91
- merged_audio[-overlap_samples:] * fade_out +
92
- current_clip[:overlap_samples] * fade_in
93
- )
94
-
95
- # Append the rest of the current clip
96
- if len(current_clip) > overlap_samples:
97
- merged_audio = np.concatenate([
98
- merged_audio,
99
- current_clip[overlap_samples:]
100
- ])
101
-
102
- logger.info(f"Applied {crossfade_duration}s crossfade between clips {i-1} and {i}")
103
- else:
104
- # No overlap possible, just concatenate
105
- merged_audio = np.concatenate([merged_audio, current_clip])
106
-
107
- # Normalize
108
- max_val = np.abs(merged_audio).max()
109
- if max_val > 0:
110
- merged_audio = merged_audio / max_val * 0.95
111
-
112
- # Save merged file
113
- output_dir = 'outputs'
114
- os.makedirs(output_dir, exist_ok=True)
115
-
116
- output_path = os.path.join(output_dir, f"{filename}.{export_format}")
117
-
118
- sf.write(output_path, merged_audio, sample_rate)
119
-
120
- logger.info(f"Clips merged successfully: {output_path}")
121
- return output_path
122
-
123
- except Exception as e:
124
- logger.error(f"Failed to merge clips: {str(e)}", exc_info=True)
125
- raise
126
-
127
- def export_clip(
128
- self,
129
- clip_id: str,
130
- export_format: str = "wav"
131
- ) -> Optional[str]:
132
- """
133
- Export a single clip
134
-
135
- Args:
136
- clip_id: ID of clip to export
137
- export_format: Output format
138
-
139
- Returns:
140
- Path to exported file, or None if clip not found
141
- """
142
- try:
143
- clip = self.timeline_service.get_clip(clip_id)
144
-
145
- if not clip:
146
- logger.warning(f"Clip not found: {clip_id}")
147
- return None
148
-
149
- logger.info(f"Exporting clip: {clip_id}")
150
-
151
- # Load clip
152
- audio, sr = sf.read(clip.file_path)
153
-
154
- # Export with requested format
155
- output_dir = 'outputs'
156
- os.makedirs(output_dir, exist_ok=True)
157
-
158
- output_path = os.path.join(output_dir, f"{clip_id}.{export_format}")
159
-
160
- sf.write(output_path, audio, sr)
161
-
162
- logger.info(f"Clip exported: {output_path}")
163
- return output_path
164
-
165
- except Exception as e:
166
- logger.error(f"Failed to export clip: {str(e)}", exc_info=True)
167
- raise
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Export and merge service
3
+ """
4
+ import os
5
+ import logging
6
+ from typing import Optional, List
7
+ import numpy as np
8
+ import soundfile as sf
9
+ from services.timeline_service import TimelineService
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class ExportService:
14
+ """Service for exporting and merging audio"""
15
+
16
+ def __init__(self):
17
+ """Initialize export service"""
18
+ self.timeline_service = TimelineService()
19
+ logger.info("Export service initialized")
20
+
21
+ def merge_clips(
22
+ self,
23
+ filename: str = "output",
24
+ export_format: str = "wav"
25
+ ) -> Optional[str]:
26
+ """
27
+ Merge all timeline clips into a single file
28
+
29
+ Args:
30
+ filename: Output filename (without extension)
31
+ export_format: Output format (wav, mp3, flac)
32
+
33
+ Returns:
34
+ Path to merged file, or None if no clips
35
+ """
36
+ try:
37
+ clips = self.timeline_service.get_all_clips()
38
+
39
+ if not clips:
40
+ logger.warning("No clips to merge")
41
+ return None
42
+
43
+ logger.info(f"Merging {len(clips)} clips")
44
+
45
+ # Load all clips
46
+ audio_data = []
47
+ sample_rate = None
48
+
49
+ for clip in clips:
50
+ audio, sr = sf.read(clip['file_path'])
51
+
52
+ if sample_rate is None:
53
+ sample_rate = sr
54
+ elif sr != sample_rate:
55
+ logger.warning(f"Sample rate mismatch: {sr} vs {sample_rate}")
56
+ # Could resample here if needed
57
+
58
+ audio_data.append(audio)
59
+
60
+ # Apply industry-standard crossfading between clips
61
+ # Uses equal-power crossfading for smooth transitions
62
+ crossfade_duration = 2.0 # seconds overlap
63
+ crossfade_samples = int(crossfade_duration * sample_rate)
64
+
65
+ if len(audio_data) == 1:
66
+ # Single clip, no crossfading needed
67
+ merged_audio = audio_data[0]
68
+ else:
69
+ # Start with first clip (keep lead-out intact for crossfade)
70
+ merged_audio = audio_data[0].copy()
71
+
72
+ # Crossfade each subsequent clip with proper overlap
73
+ for i in range(1, len(audio_data)):
74
+ current_clip = audio_data[i]
75
+
76
+ # Calculate actual overlap (limited by clip lengths)
77
+ overlap_samples = min(crossfade_samples, len(merged_audio), len(current_clip))
78
+
79
+ if overlap_samples > 0:
80
+ # Equal-power crossfade curves (sqrt for energy preservation)
81
+ # This creates a smooth, professional-sounding crossfade
82
+ fade_out = np.sqrt(np.linspace(1.0, 0.0, overlap_samples))
83
+ fade_in = np.sqrt(np.linspace(0.0, 1.0, overlap_samples))
84
+
85
+ # Handle stereo vs mono
86
+ if merged_audio.ndim == 2:
87
+ fade_out = fade_out[:, np.newaxis]
88
+ fade_in = fade_in[:, np.newaxis]
89
+
90
+ # CRITICAL: Remove the overlap region from merged_audio end
91
+ # so we actually overlap, not append
92
+ merged_audio = merged_audio[:-overlap_samples]
93
+
94
+ # Create the crossfaded overlap region
95
+ # This mixes the last overlap_samples of previous clip with
96
+ # the first overlap_samples of current clip
97
+ overlap_region = (
98
+ audio_data[i-1][-overlap_samples:] * fade_out +
99
+ current_clip[:overlap_samples] * fade_in
100
+ )
101
+
102
+ # Append the crossfaded overlap and rest of current clip
103
+ merged_audio = np.concatenate([
104
+ merged_audio,
105
+ overlap_region,
106
+ current_clip[overlap_samples:]
107
+ ])
108
+
109
+ logger.info(f"Applied {crossfade_duration}s equal-power crossfade between clips {i-1} and {i} (overlap: {overlap_samples} samples)")
110
+ else:
111
+ # No overlap possible, just concatenate
112
+ merged_audio = np.concatenate([merged_audio, current_clip])
113
+ logger.warning(f"Clips {i-1} and {i} too short for crossfade, concatenating instead")
114
+
115
+ # Normalize
116
+ max_val = np.abs(merged_audio).max()
117
+ if max_val > 0:
118
+ merged_audio = merged_audio / max_val * 0.95
119
+
120
+ # Save merged file
121
+ output_dir = 'outputs'
122
+ os.makedirs(output_dir, exist_ok=True)
123
+
124
+ output_path = os.path.join(output_dir, f"{filename}.{export_format}")
125
+
126
+ sf.write(output_path, merged_audio, sample_rate)
127
+
128
+ logger.info(f"Clips merged successfully: {output_path}")
129
+ return output_path
130
+
131
+ except Exception as e:
132
+ logger.error(f"Failed to merge clips: {str(e)}", exc_info=True)
133
+ raise
134
+
135
+ def export_clip(
136
+ self,
137
+ clip_id: str,
138
+ export_format: str = "wav"
139
+ ) -> Optional[str]:
140
+ """
141
+ Export a single clip
142
+
143
+ Args:
144
+ clip_id: ID of clip to export
145
+ export_format: Output format
146
+
147
+ Returns:
148
+ Path to exported file, or None if clip not found
149
+ """
150
+ try:
151
+ clip = self.timeline_service.get_clip(clip_id)
152
+
153
+ if not clip:
154
+ logger.warning(f"Clip not found: {clip_id}")
155
+ return None
156
+
157
+ logger.info(f"Exporting clip: {clip_id}")
158
+
159
+ # Load clip
160
+ audio, sr = sf.read(clip.file_path)
161
+
162
+ # Export with requested format
163
+ output_dir = 'outputs'
164
+ os.makedirs(output_dir, exist_ok=True)
165
+
166
+ output_path = os.path.join(output_dir, f"{clip_id}.{export_format}")
167
+
168
+ sf.write(output_path, audio, sr)
169
+
170
+ logger.info(f"Clip exported: {output_path}")
171
+ return output_path
172
+
173
+ except Exception as e:
174
+ logger.error(f"Failed to export clip: {str(e)}", exc_info=True)
175
+ raise