karaoke-gen 0.90.1__py3-none-any.whl → 0.96.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,294 @@
1
+ """
2
+ Tests for audio_analysis_service.py - Audio analysis for backing vocals.
3
+
4
+ These tests mock the storage service and shared karaoke_gen modules to verify:
5
+ - GCS file download/upload operations
6
+ - Audio analysis delegation to shared AudioAnalyzer
7
+ - Waveform generation delegation to shared WaveformGenerator
8
+ """
9
+ import pytest
10
+ from unittest.mock import Mock, MagicMock, patch, ANY
11
+ import tempfile
12
+ import os
13
+
14
+
15
+ class TestAudioAnalysisServiceInit:
16
+ """Test AudioAnalysisService initialization."""
17
+
18
+ @patch("backend.services.audio_analysis_service.StorageService")
19
+ @patch("backend.services.audio_analysis_service.AudioAnalyzer")
20
+ @patch("backend.services.audio_analysis_service.WaveformGenerator")
21
+ def test_init_creates_dependencies(
22
+ self, mock_waveform_class, mock_analyzer_class, mock_storage_class
23
+ ):
24
+ """Test initialization creates storage service and analyzers."""
25
+ from backend.services.audio_analysis_service import AudioAnalysisService
26
+
27
+ service = AudioAnalysisService()
28
+
29
+ mock_storage_class.assert_called_once()
30
+ mock_analyzer_class.assert_called_once_with(
31
+ silence_threshold_db=-40.0,
32
+ min_segment_duration_ms=100,
33
+ )
34
+ mock_waveform_class.assert_called_once()
35
+
36
+ @patch("backend.services.audio_analysis_service.StorageService")
37
+ @patch("backend.services.audio_analysis_service.AudioAnalyzer")
38
+ @patch("backend.services.audio_analysis_service.WaveformGenerator")
39
+ def test_init_with_custom_params(
40
+ self, mock_waveform_class, mock_analyzer_class, mock_storage_class
41
+ ):
42
+ """Test initialization with custom threshold and duration."""
43
+ from backend.services.audio_analysis_service import AudioAnalysisService
44
+
45
+ service = AudioAnalysisService(
46
+ silence_threshold_db=-50.0,
47
+ min_segment_duration_ms=200,
48
+ )
49
+
50
+ mock_analyzer_class.assert_called_once_with(
51
+ silence_threshold_db=-50.0,
52
+ min_segment_duration_ms=200,
53
+ )
54
+
55
+ @patch("backend.services.audio_analysis_service.AudioAnalyzer")
56
+ @patch("backend.services.audio_analysis_service.WaveformGenerator")
57
+ def test_init_with_provided_storage_service(
58
+ self, mock_waveform_class, mock_analyzer_class
59
+ ):
60
+ """Test initialization with externally provided storage service."""
61
+ from backend.services.audio_analysis_service import AudioAnalysisService
62
+
63
+ mock_storage = Mock()
64
+ service = AudioAnalysisService(storage_service=mock_storage)
65
+
66
+ assert service.storage_service is mock_storage
67
+
68
+
69
+ class TestAnalyzeBackingVocals:
70
+ """Test analyze_backing_vocals method."""
71
+
72
+ @patch("backend.services.audio_analysis_service.StorageService")
73
+ @patch("backend.services.audio_analysis_service.AudioAnalyzer")
74
+ @patch("backend.services.audio_analysis_service.WaveformGenerator")
75
+ def test_analyze_downloads_and_analyzes(
76
+ self, mock_waveform_class, mock_analyzer_class, mock_storage_class
77
+ ):
78
+ """Test analyze_backing_vocals downloads file and runs analysis."""
79
+ from backend.services.audio_analysis_service import AudioAnalysisService
80
+
81
+ # Setup mocks
82
+ mock_storage = Mock()
83
+ mock_storage_class.return_value = mock_storage
84
+
85
+ mock_result = Mock()
86
+ mock_result.has_audible_content = True
87
+ mock_result.segment_count = 5
88
+ mock_result.recommended_selection = Mock(value="with_backing")
89
+
90
+ mock_analyzer = Mock()
91
+ mock_analyzer.analyze.return_value = mock_result
92
+ mock_analyzer_class.return_value = mock_analyzer
93
+
94
+ service = AudioAnalysisService()
95
+
96
+ # Call method
97
+ result = service.analyze_backing_vocals(
98
+ gcs_audio_path="uploads/job123/backing_vocals.flac",
99
+ job_id="job123",
100
+ )
101
+
102
+ # Verify storage download was called
103
+ mock_storage.download_file.assert_called_once()
104
+ call_args = mock_storage.download_file.call_args
105
+ assert call_args[0][0] == "uploads/job123/backing_vocals.flac"
106
+
107
+ # Verify analyzer was called
108
+ mock_analyzer.analyze.assert_called_once()
109
+
110
+ # Verify result
111
+ assert result is mock_result
112
+ assert result.has_audible_content is True
113
+
114
+ @patch("backend.services.audio_analysis_service.StorageService")
115
+ @patch("backend.services.audio_analysis_service.AudioAnalyzer")
116
+ @patch("backend.services.audio_analysis_service.WaveformGenerator")
117
+ def test_analyze_cleans_up_temp_files(
118
+ self, mock_waveform_class, mock_analyzer_class, mock_storage_class
119
+ ):
120
+ """Test that temp files are cleaned up after analysis."""
121
+ from backend.services.audio_analysis_service import AudioAnalysisService
122
+
123
+ mock_storage = Mock()
124
+ mock_storage_class.return_value = mock_storage
125
+
126
+ mock_result = Mock()
127
+ mock_result.has_audible_content = False
128
+ mock_result.segment_count = 0
129
+ mock_result.recommended_selection = Mock(value="clean")
130
+
131
+ mock_analyzer = Mock()
132
+ mock_analyzer.analyze.return_value = mock_result
133
+ mock_analyzer_class.return_value = mock_analyzer
134
+
135
+ service = AudioAnalysisService()
136
+
137
+ # Call method
138
+ service.analyze_backing_vocals(
139
+ gcs_audio_path="uploads/job123/backing.flac",
140
+ job_id="job123",
141
+ )
142
+
143
+ # Temp directory should be cleaned up (no assertion needed,
144
+ # TemporaryDirectory context manager handles this)
145
+ # Just verify the method completes without error
146
+
147
+
148
+ class TestAnalyzeAndGenerateWaveform:
149
+ """Test analyze_and_generate_waveform method."""
150
+
151
+ @patch("backend.services.audio_analysis_service.StorageService")
152
+ @patch("backend.services.audio_analysis_service.AudioAnalyzer")
153
+ @patch("backend.services.audio_analysis_service.WaveformGenerator")
154
+ def test_analyze_and_generate_waveform(
155
+ self, mock_waveform_class, mock_analyzer_class, mock_storage_class
156
+ ):
157
+ """Test analyze_and_generate_waveform does both operations."""
158
+ from backend.services.audio_analysis_service import AudioAnalysisService
159
+
160
+ mock_storage = Mock()
161
+ mock_storage_class.return_value = mock_storage
162
+
163
+ mock_result = Mock()
164
+ mock_result.has_audible_content = True
165
+ mock_result.audible_segments = [Mock(), Mock()]
166
+
167
+ mock_analyzer = Mock()
168
+ mock_analyzer.analyze.return_value = mock_result
169
+ mock_analyzer.silence_threshold_db = -40.0
170
+ mock_analyzer_class.return_value = mock_analyzer
171
+
172
+ mock_waveform = Mock()
173
+ mock_waveform_class.return_value = mock_waveform
174
+
175
+ service = AudioAnalysisService()
176
+
177
+ # Call method
178
+ result, waveform_path = service.analyze_and_generate_waveform(
179
+ gcs_audio_path="uploads/job123/backing.flac",
180
+ job_id="job123",
181
+ gcs_waveform_destination="uploads/job123/waveform.png",
182
+ )
183
+
184
+ # Verify analysis was performed
185
+ mock_analyzer.analyze.assert_called_once()
186
+
187
+ # Verify waveform was generated
188
+ mock_waveform.generate.assert_called_once()
189
+ call_kwargs = mock_waveform.generate.call_args.kwargs
190
+ assert call_kwargs["segments"] == mock_result.audible_segments
191
+ assert call_kwargs["show_time_axis"] is True
192
+
193
+ # Verify waveform was uploaded
194
+ assert mock_storage.upload_file.call_count == 1
195
+
196
+ # Verify return values
197
+ assert result is mock_result
198
+ assert waveform_path == "uploads/job123/waveform.png"
199
+
200
+
201
+ class TestGetWaveformData:
202
+ """Test get_waveform_data method."""
203
+
204
+ @patch("backend.services.audio_analysis_service.StorageService")
205
+ @patch("backend.services.audio_analysis_service.AudioAnalyzer")
206
+ @patch("backend.services.audio_analysis_service.WaveformGenerator")
207
+ def test_get_waveform_data(
208
+ self, mock_waveform_class, mock_analyzer_class, mock_storage_class
209
+ ):
210
+ """Test get_waveform_data returns amplitude data."""
211
+ from backend.services.audio_analysis_service import AudioAnalysisService
212
+
213
+ mock_storage = Mock()
214
+ mock_storage_class.return_value = mock_storage
215
+
216
+ mock_waveform = Mock()
217
+ mock_waveform.generate_data_only.return_value = (
218
+ [0.1, 0.3, 0.5, 0.3, 0.1], # amplitudes
219
+ 180.5, # duration
220
+ )
221
+ mock_waveform_class.return_value = mock_waveform
222
+
223
+ service = AudioAnalysisService()
224
+
225
+ amplitudes, duration = service.get_waveform_data(
226
+ gcs_audio_path="uploads/job123/backing.flac",
227
+ job_id="job123",
228
+ num_points=500,
229
+ )
230
+
231
+ # Verify storage download
232
+ mock_storage.download_file.assert_called_once()
233
+
234
+ # Verify waveform generator called
235
+ mock_waveform.generate_data_only.assert_called_once()
236
+ call_kwargs = mock_waveform.generate_data_only.call_args.kwargs
237
+ assert call_kwargs["num_points"] == 500
238
+
239
+ # Verify return values
240
+ assert amplitudes == [0.1, 0.3, 0.5, 0.3, 0.1]
241
+ assert duration == 180.5
242
+
243
+
244
+ class TestGenerateWaveformWithMutes:
245
+ """Test generate_waveform_with_mutes method."""
246
+
247
+ @patch("backend.services.audio_analysis_service.StorageService")
248
+ @patch("backend.services.audio_analysis_service.AudioAnalyzer")
249
+ @patch("backend.services.audio_analysis_service.WaveformGenerator")
250
+ def test_generate_waveform_with_mutes(
251
+ self, mock_waveform_class, mock_analyzer_class, mock_storage_class
252
+ ):
253
+ """Test generate_waveform_with_mutes highlights mute regions."""
254
+ from backend.services.audio_analysis_service import AudioAnalysisService
255
+
256
+ mock_storage = Mock()
257
+ mock_storage_class.return_value = mock_storage
258
+
259
+ mock_result = Mock()
260
+ mock_result.audible_segments = [Mock(), Mock()]
261
+
262
+ mock_analyzer = Mock()
263
+ mock_analyzer.analyze.return_value = mock_result
264
+ mock_analyzer_class.return_value = mock_analyzer
265
+
266
+ mock_waveform = Mock()
267
+ mock_waveform_class.return_value = mock_waveform
268
+
269
+ # Create mock mute regions
270
+ mock_mute1 = Mock()
271
+ mock_mute2 = Mock()
272
+ mute_regions = [mock_mute1, mock_mute2]
273
+
274
+ service = AudioAnalysisService()
275
+
276
+ result_path = service.generate_waveform_with_mutes(
277
+ gcs_audio_path="uploads/job123/backing.flac",
278
+ job_id="job123",
279
+ gcs_waveform_destination="uploads/job123/waveform_muted.png",
280
+ mute_regions=mute_regions,
281
+ )
282
+
283
+ # Verify waveform generator was called with mute_regions
284
+ mock_waveform.generate.assert_called_once()
285
+ call_kwargs = mock_waveform.generate.call_args.kwargs
286
+ assert call_kwargs["mute_regions"] == mute_regions
287
+ assert call_kwargs["segments"] == mock_result.audible_segments
288
+
289
+ # Verify upload
290
+ mock_storage.upload_file.assert_called_once()
291
+
292
+ # Verify return
293
+ assert result_path == "uploads/job123/waveform_muted.png"
294
+
@@ -0,0 +1,386 @@
1
+ """
2
+ Tests for audio_editing_service.py - Custom instrumental creation.
3
+
4
+ These tests mock the storage service and shared karaoke_gen modules to verify:
5
+ - GCS file download/upload operations
6
+ - Audio editing delegation to shared AudioEditor
7
+ - Mute region validation
8
+ """
9
+ import pytest
10
+ from unittest.mock import Mock, MagicMock, patch, ANY
11
+
12
+
13
+ class TestAudioEditingServiceInit:
14
+ """Test AudioEditingService initialization."""
15
+
16
+ @patch("backend.services.audio_editing_service.StorageService")
17
+ @patch("backend.services.audio_editing_service.AudioEditor")
18
+ def test_init_creates_dependencies(self, mock_editor_class, mock_storage_class):
19
+ """Test initialization creates storage service and editor."""
20
+ from backend.services.audio_editing_service import AudioEditingService
21
+
22
+ service = AudioEditingService()
23
+
24
+ mock_storage_class.assert_called_once()
25
+ mock_editor_class.assert_called_once_with(output_format="flac")
26
+
27
+ @patch("backend.services.audio_editing_service.StorageService")
28
+ @patch("backend.services.audio_editing_service.AudioEditor")
29
+ def test_init_with_custom_format(self, mock_editor_class, mock_storage_class):
30
+ """Test initialization with custom output format."""
31
+ from backend.services.audio_editing_service import AudioEditingService
32
+
33
+ service = AudioEditingService(output_format="wav")
34
+
35
+ mock_editor_class.assert_called_once_with(output_format="wav")
36
+
37
+ @patch("backend.services.audio_editing_service.AudioEditor")
38
+ def test_init_with_provided_storage_service(self, mock_editor_class):
39
+ """Test initialization with externally provided storage service."""
40
+ from backend.services.audio_editing_service import AudioEditingService
41
+
42
+ mock_storage = Mock()
43
+ service = AudioEditingService(storage_service=mock_storage)
44
+
45
+ assert service.storage_service is mock_storage
46
+
47
+
48
+ class TestCreateCustomInstrumental:
49
+ """Test create_custom_instrumental method."""
50
+
51
+ @patch("backend.services.audio_editing_service.StorageService")
52
+ @patch("backend.services.audio_editing_service.AudioEditor")
53
+ def test_create_custom_instrumental_success(
54
+ self, mock_editor_class, mock_storage_class
55
+ ):
56
+ """Test creating a custom instrumental successfully."""
57
+ from backend.services.audio_editing_service import AudioEditingService
58
+
59
+ mock_storage = Mock()
60
+ mock_storage_class.return_value = mock_storage
61
+
62
+ mock_result = Mock()
63
+ mock_result.total_muted_duration_seconds = 15.5
64
+ mock_result.mute_regions_applied = [Mock(), Mock(), Mock()]
65
+ mock_result.output_path = None # Will be set by service
66
+
67
+ mock_editor = Mock()
68
+ mock_editor.create_custom_instrumental.return_value = mock_result
69
+ mock_editor_class.return_value = mock_editor
70
+
71
+ service = AudioEditingService()
72
+
73
+ # Create mock mute regions
74
+ mock_mute1 = Mock()
75
+ mock_mute2 = Mock()
76
+ mute_regions = [mock_mute1, mock_mute2]
77
+
78
+ result = service.create_custom_instrumental(
79
+ gcs_clean_instrumental_path="uploads/job123/clean.flac",
80
+ gcs_backing_vocals_path="uploads/job123/backing.flac",
81
+ mute_regions=mute_regions,
82
+ gcs_output_path="uploads/job123/custom.flac",
83
+ job_id="job123",
84
+ )
85
+
86
+ # Verify downloads
87
+ assert mock_storage.download_file.call_count == 2
88
+
89
+ # Verify editor was called
90
+ mock_editor.create_custom_instrumental.assert_called_once()
91
+ call_kwargs = mock_editor.create_custom_instrumental.call_args.kwargs
92
+ assert call_kwargs["mute_regions"] == mute_regions
93
+
94
+ # Verify upload
95
+ mock_storage.upload_file.assert_called_once()
96
+
97
+ # Verify result path was updated
98
+ assert result.output_path == "uploads/job123/custom.flac"
99
+
100
+ @patch("backend.services.audio_editing_service.StorageService")
101
+ @patch("backend.services.audio_editing_service.AudioEditor")
102
+ def test_create_custom_instrumental_downloads_both_files(
103
+ self, mock_editor_class, mock_storage_class
104
+ ):
105
+ """Test that both input files are downloaded."""
106
+ from backend.services.audio_editing_service import AudioEditingService
107
+
108
+ mock_storage = Mock()
109
+ mock_storage_class.return_value = mock_storage
110
+
111
+ mock_result = Mock()
112
+ mock_result.total_muted_duration_seconds = 0
113
+ mock_result.mute_regions_applied = []
114
+
115
+ mock_editor = Mock()
116
+ mock_editor.create_custom_instrumental.return_value = mock_result
117
+ mock_editor_class.return_value = mock_editor
118
+
119
+ service = AudioEditingService()
120
+
121
+ service.create_custom_instrumental(
122
+ gcs_clean_instrumental_path="uploads/job123/clean.flac",
123
+ gcs_backing_vocals_path="uploads/job123/backing.flac",
124
+ mute_regions=[],
125
+ gcs_output_path="uploads/job123/output.flac",
126
+ job_id="job123",
127
+ )
128
+
129
+ # Verify both files were downloaded
130
+ download_calls = mock_storage.download_file.call_args_list
131
+ assert len(download_calls) == 2
132
+
133
+ # First call should be clean instrumental
134
+ assert download_calls[0][0][0] == "uploads/job123/clean.flac"
135
+ # Second call should be backing vocals
136
+ assert download_calls[1][0][0] == "uploads/job123/backing.flac"
137
+
138
+
139
+ class TestCreatePreview:
140
+ """Test create_preview method."""
141
+
142
+ @patch("pydub.AudioSegment")
143
+ @patch("backend.services.audio_editing_service.StorageService")
144
+ @patch("backend.services.audio_editing_service.AudioEditor")
145
+ def test_create_preview_success(
146
+ self, mock_editor_class, mock_storage_class, mock_audio_segment
147
+ ):
148
+ """Test creating a preview successfully."""
149
+ from backend.services.audio_editing_service import AudioEditingService
150
+
151
+ mock_storage = Mock()
152
+ mock_storage_class.return_value = mock_storage
153
+
154
+ mock_preview = Mock()
155
+ mock_preview.export = Mock()
156
+ mock_preview.__getitem__ = Mock(return_value=mock_preview)
157
+
158
+ mock_editor = Mock()
159
+ mock_editor.preview_with_mutes.return_value = mock_preview
160
+ mock_editor_class.return_value = mock_editor
161
+
162
+ service = AudioEditingService()
163
+
164
+ result = service.create_preview(
165
+ gcs_clean_instrumental_path="uploads/job123/clean.flac",
166
+ gcs_backing_vocals_path="uploads/job123/backing.flac",
167
+ mute_regions=[Mock(), Mock()],
168
+ gcs_preview_path="uploads/job123/preview.flac",
169
+ job_id="job123",
170
+ )
171
+
172
+ # Verify editor was called
173
+ mock_editor.preview_with_mutes.assert_called_once()
174
+
175
+ # Verify result
176
+ assert result == "uploads/job123/preview.flac"
177
+
178
+ @patch("pydub.AudioSegment")
179
+ @patch("backend.services.audio_editing_service.StorageService")
180
+ @patch("backend.services.audio_editing_service.AudioEditor")
181
+ def test_create_preview_with_duration_limit(
182
+ self, mock_editor_class, mock_storage_class, mock_audio_segment
183
+ ):
184
+ """Test creating a preview with duration limit."""
185
+ from backend.services.audio_editing_service import AudioEditingService
186
+
187
+ mock_storage = Mock()
188
+ mock_storage_class.return_value = mock_storage
189
+
190
+ mock_preview = Mock()
191
+ mock_truncated = Mock()
192
+ mock_preview.__getitem__ = Mock(return_value=mock_truncated)
193
+ mock_truncated.export = Mock()
194
+
195
+ mock_editor = Mock()
196
+ mock_editor.preview_with_mutes.return_value = mock_preview
197
+ mock_editor_class.return_value = mock_editor
198
+
199
+ service = AudioEditingService()
200
+
201
+ service.create_preview(
202
+ gcs_clean_instrumental_path="uploads/job123/clean.flac",
203
+ gcs_backing_vocals_path="uploads/job123/backing.flac",
204
+ mute_regions=[],
205
+ gcs_preview_path="uploads/job123/preview.flac",
206
+ job_id="job123",
207
+ preview_duration_seconds=30.0,
208
+ )
209
+
210
+ # Verify preview was truncated (30 seconds = 30000ms)
211
+ mock_preview.__getitem__.assert_called_once_with(slice(None, 30000))
212
+
213
+
214
+ class TestMuteBackingVocalsOnly:
215
+ """Test mute_backing_vocals_only method."""
216
+
217
+ @patch("backend.services.audio_editing_service.StorageService")
218
+ @patch("backend.services.audio_editing_service.AudioEditor")
219
+ def test_mute_backing_vocals_only(self, mock_editor_class, mock_storage_class):
220
+ """Test muting backing vocals without combining."""
221
+ from backend.services.audio_editing_service import AudioEditingService
222
+
223
+ mock_storage = Mock()
224
+ mock_storage_class.return_value = mock_storage
225
+
226
+ mock_editor = Mock()
227
+ mock_editor_class.return_value = mock_editor
228
+
229
+ service = AudioEditingService()
230
+
231
+ mute_regions = [Mock(), Mock()]
232
+
233
+ result = service.mute_backing_vocals_only(
234
+ gcs_backing_vocals_path="uploads/job123/backing.flac",
235
+ mute_regions=mute_regions,
236
+ gcs_output_path="uploads/job123/muted_backing.flac",
237
+ job_id="job123",
238
+ )
239
+
240
+ # Verify only one file was downloaded
241
+ mock_storage.download_file.assert_called_once()
242
+ assert mock_storage.download_file.call_args[0][0] == "uploads/job123/backing.flac"
243
+
244
+ # Verify editor method was called
245
+ mock_editor.apply_mute_to_single_track.assert_called_once()
246
+ call_kwargs = mock_editor.apply_mute_to_single_track.call_args.kwargs
247
+ assert call_kwargs["mute_regions"] == mute_regions
248
+
249
+ # Verify upload
250
+ mock_storage.upload_file.assert_called_once()
251
+
252
+ # Verify result
253
+ assert result == "uploads/job123/muted_backing.flac"
254
+
255
+
256
+ class TestValidateMuteRegions:
257
+ """Test validate_mute_regions method.
258
+
259
+ Note: MuteRegion uses pydantic validation that rejects invalid values at
260
+ creation time. These tests use Mock objects to test the validation logic
261
+ in the service, simulating edge cases that would normally be rejected.
262
+ """
263
+
264
+ @patch("backend.services.audio_editing_service.StorageService")
265
+ @patch("backend.services.audio_editing_service.AudioEditor")
266
+ def test_valid_mute_regions(self, mock_editor_class, mock_storage_class):
267
+ """Test validation passes for valid mute regions."""
268
+ from backend.services.audio_editing_service import AudioEditingService
269
+ from karaoke_gen.instrumental_review import MuteRegion
270
+
271
+ service = AudioEditingService()
272
+
273
+ mute_regions = [
274
+ MuteRegion(start_seconds=10.0, end_seconds=15.0),
275
+ MuteRegion(start_seconds=30.0, end_seconds=45.0),
276
+ ]
277
+
278
+ errors = service.validate_mute_regions(mute_regions, total_duration_seconds=180.0)
279
+
280
+ assert errors == []
281
+
282
+ @patch("backend.services.audio_editing_service.StorageService")
283
+ @patch("backend.services.audio_editing_service.AudioEditor")
284
+ def test_negative_start_seconds(self, mock_editor_class, mock_storage_class):
285
+ """Test validation catches negative start time.
286
+
287
+ Uses Mock since MuteRegion pydantic model rejects negative values.
288
+ """
289
+ from backend.services.audio_editing_service import AudioEditingService
290
+
291
+ service = AudioEditingService()
292
+
293
+ # Use mock to simulate invalid region (pydantic would reject this)
294
+ mock_region = Mock()
295
+ mock_region.start_seconds = -5.0
296
+ mock_region.end_seconds = 10.0
297
+
298
+ errors = service.validate_mute_regions([mock_region], total_duration_seconds=180.0)
299
+
300
+ assert len(errors) == 1
301
+ assert "cannot be negative" in errors[0]
302
+
303
+ @patch("backend.services.audio_editing_service.StorageService")
304
+ @patch("backend.services.audio_editing_service.AudioEditor")
305
+ def test_end_before_start(self, mock_editor_class, mock_storage_class):
306
+ """Test validation catches end time before start time."""
307
+ from backend.services.audio_editing_service import AudioEditingService
308
+
309
+ service = AudioEditingService()
310
+
311
+ # Use mock to simulate invalid region
312
+ mock_region = Mock()
313
+ mock_region.start_seconds = 30.0
314
+ mock_region.end_seconds = 20.0
315
+
316
+ errors = service.validate_mute_regions([mock_region], total_duration_seconds=180.0)
317
+
318
+ assert len(errors) == 1
319
+ assert "must be after" in errors[0]
320
+
321
+ @patch("backend.services.audio_editing_service.StorageService")
322
+ @patch("backend.services.audio_editing_service.AudioEditor")
323
+ def test_start_exceeds_duration(self, mock_editor_class, mock_storage_class):
324
+ """Test validation catches start time exceeding audio duration."""
325
+ from backend.services.audio_editing_service import AudioEditingService
326
+ from karaoke_gen.instrumental_review import MuteRegion
327
+
328
+ service = AudioEditingService()
329
+
330
+ mute_regions = [
331
+ MuteRegion(start_seconds=200.0, end_seconds=210.0),
332
+ ]
333
+
334
+ errors = service.validate_mute_regions(mute_regions, total_duration_seconds=180.0)
335
+
336
+ assert len(errors) == 1
337
+ assert "exceeds audio duration" in errors[0]
338
+
339
+ @patch("backend.services.audio_editing_service.StorageService")
340
+ @patch("backend.services.audio_editing_service.AudioEditor")
341
+ def test_end_exceeds_duration_is_warning_not_error(
342
+ self, mock_editor_class, mock_storage_class
343
+ ):
344
+ """Test that end time exceeding duration is logged as warning, not error."""
345
+ from backend.services.audio_editing_service import AudioEditingService
346
+ from karaoke_gen.instrumental_review import MuteRegion
347
+
348
+ service = AudioEditingService()
349
+
350
+ mute_regions = [
351
+ MuteRegion(start_seconds=170.0, end_seconds=200.0), # End exceeds 180s
352
+ ]
353
+
354
+ errors = service.validate_mute_regions(mute_regions, total_duration_seconds=180.0)
355
+
356
+ # Should not be an error (will be clamped)
357
+ assert len(errors) == 0
358
+
359
+ @patch("backend.services.audio_editing_service.StorageService")
360
+ @patch("backend.services.audio_editing_service.AudioEditor")
361
+ def test_multiple_errors(self, mock_editor_class, mock_storage_class):
362
+ """Test validation reports multiple errors."""
363
+ from backend.services.audio_editing_service import AudioEditingService
364
+ from karaoke_gen.instrumental_review import MuteRegion
365
+
366
+ service = AudioEditingService()
367
+
368
+ # Use mocks for invalid regions
369
+ mock_region1 = Mock()
370
+ mock_region1.start_seconds = -5.0
371
+ mock_region1.end_seconds = 10.0
372
+
373
+ mock_region2 = Mock()
374
+ mock_region2.start_seconds = 30.0
375
+ mock_region2.end_seconds = 20.0 # End before start
376
+
377
+ mock_region3 = Mock()
378
+ mock_region3.start_seconds = 200.0 # Exceeds duration
379
+ mock_region3.end_seconds = 210.0
380
+
381
+ mute_regions = [mock_region1, mock_region2, mock_region3]
382
+
383
+ errors = service.validate_mute_regions(mute_regions, total_duration_seconds=180.0)
384
+
385
+ assert len(errors) == 3
386
+