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.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +742 -0
- backend/api/routes/audio_search.py +903 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2076 -0
- backend/api/routes/health.py +344 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1610 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1014 -0
- backend/config.py +172 -0
- backend/main.py +133 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +405 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +842 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +275 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +88 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +339 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +273 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +525 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {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
|
+
|