Rthur2003 commited on
Commit
26c0314
·
1 Parent(s): dc9592e

feat: remix ve Multitrack API uç noktaları ile rate limiting ve Pydantic schema doğrulama eklendi

Browse files
app/main.py CHANGED
@@ -91,7 +91,7 @@ app.add_middleware(
91
  allow_credentials="*" not in _origins,
92
  allow_methods=["GET", "POST", "OPTIONS"],
93
  allow_headers=["Content-Type", "Accept"],
94
- expose_headers=["X-Remix-Analysis"],
95
  )
96
 
97
  app.include_router(health_router)
 
91
  allow_credentials="*" not in _origins,
92
  allow_methods=["GET", "POST", "OPTIONS"],
93
  allow_headers=["Content-Type", "Accept"],
94
+ expose_headers=["X-Remix-Analysis", "X-Multitrack-Analysis"],
95
  )
96
 
97
  app.include_router(health_router)
app/routes/remix.py CHANGED
@@ -11,7 +11,8 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Uplo
11
  from fastapi.responses import StreamingResponse
12
  from pydantic import ValidationError
13
 
14
- from app.schemas import RemixOptions
 
15
  from app.services.remix_engine import build_remix
16
 
17
  router = APIRouter(prefix="/api/remix", tags=["Creator Studio"])
@@ -101,7 +102,11 @@ async def remix_blend_endpoint(
101
 
102
  result = build_remix(content_a, content_b, parsed_options)
103
 
104
- analysis_json = json.dumps(result.analysis)
 
 
 
 
105
  return StreamingResponse(
106
  result.audio,
107
  media_type="audio/wav",
@@ -118,3 +123,67 @@ async def remix_blend_endpoint(
118
  except Exception as e:
119
  logger.error(f"Unexpected error in remix blend: {e}", exc_info=True)
120
  raise HTTPException(status_code=500, detail={"code": "internal_error", "message": "Internal server error during remix"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  from fastapi.responses import StreamingResponse
12
  from pydantic import ValidationError
13
 
14
+ from app.schemas import MultitrackAnalysis, MultitrackOptions, RemixAnalysis, RemixOptions
15
+ from app.services.multitrack_mixer import MAX_CHANNELS, MIN_CHANNELS, mix_tracks
16
  from app.services.remix_engine import build_remix
17
 
18
  router = APIRouter(prefix="/api/remix", tags=["Creator Studio"])
 
102
 
103
  result = build_remix(content_a, content_b, parsed_options)
104
 
105
+ # Serialize via the Pydantic model so the header uses the same
106
+ # camelCase field names the frontend's RemixAnalysis type expects —
107
+ # result.analysis itself is a plain snake_case dict.
108
+ analysis_model = RemixAnalysis(**result.analysis)
109
+ analysis_json = analysis_model.model_dump_json(by_alias=True)
110
  return StreamingResponse(
111
  result.audio,
112
  media_type="audio/wav",
 
123
  except Exception as e:
124
  logger.error(f"Unexpected error in remix blend: {e}", exc_info=True)
125
  raise HTTPException(status_code=500, detail={"code": "internal_error", "message": "Internal server error during remix"})
126
+
127
+
128
+ @router.post("/multitrack", dependencies=[Depends(_remix_rate_limit)])
129
+ async def multitrack_mix_endpoint(
130
+ tracks: list[UploadFile] = File(...),
131
+ options: str = Form(default="{}"),
132
+ ):
133
+ """
134
+ Mix 2-6 uploaded tracks into one stereo WAV: applies each track's
135
+ gain/pan/mute, aligns lengths, sums, and peak-normalizes to prevent
136
+ clipping. Returns the mix plus a per-channel report in the
137
+ X-Multitrack-Analysis response header.
138
+ """
139
+ MAX_PAYLOAD_BYTES = 30 * 1024 * 1024 # 30 MB per track
140
+ logger.info(f"Received multitrack mix request: {len(tracks)} tracks")
141
+
142
+ if len(tracks) < MIN_CHANNELS or len(tracks) > MAX_CHANNELS:
143
+ raise HTTPException(
144
+ status_code=400,
145
+ detail={
146
+ "code": "invalid_track_count",
147
+ "message": f"Multitrack mixing requires {MIN_CHANNELS}-{MAX_CHANNELS} tracks, got {len(tracks)}.",
148
+ },
149
+ )
150
+
151
+ raw_options = options.strip() if options else "{}"
152
+ if not raw_options:
153
+ raw_options = "{}"
154
+ try:
155
+ parsed_options = MultitrackOptions.model_validate_json(raw_options)
156
+ except (ValidationError, json.JSONDecodeError) as e:
157
+ raise HTTPException(status_code=422, detail={"code": "invalid_options", "message": f"Invalid options format: {e}"})
158
+
159
+ for i, f in enumerate(tracks):
160
+ if not f.content_type or not f.content_type.startswith("audio/"):
161
+ raise HTTPException(
162
+ status_code=400,
163
+ detail={"code": "invalid_file_type", "message": f"Invalid file type for track {i + 1}. Must be audio."},
164
+ )
165
+
166
+ try:
167
+ contents = [await _read_capped(f, MAX_PAYLOAD_BYTES) for f in tracks]
168
+
169
+ result = mix_tracks(contents, parsed_options)
170
+
171
+ # Same camelCase-via-Pydantic serialization as the /blend endpoint.
172
+ analysis_model = MultitrackAnalysis(**result.analysis)
173
+ analysis_json = analysis_model.model_dump_json(by_alias=True)
174
+ return StreamingResponse(
175
+ result.audio,
176
+ media_type="audio/wav",
177
+ headers={
178
+ "Content-Disposition": "attachment; filename=crowncode-multitrack-mix.wav",
179
+ "X-Multitrack-Analysis": analysis_json,
180
+ },
181
+ )
182
+
183
+ except ValueError as e:
184
+ raise HTTPException(status_code=400, detail={"code": "validation_error", "message": str(e)})
185
+ except HTTPException:
186
+ raise
187
+ except Exception as e:
188
+ logger.error(f"Unexpected error in multitrack mix: {e}", exc_info=True)
189
+ raise HTTPException(status_code=500, detail={"code": "internal_error", "message": "Internal server error during multitrack mix"})
app/schemas.py CHANGED
@@ -148,3 +148,55 @@ class RemixAnalysis(BaseModel):
148
  applied_stretch_rate: float = Field(alias="appliedStretchRate")
149
  applied_pitch_shift_semitones: float = Field(alias="appliedPitchShiftSemitones")
150
  output_duration_sec: float = Field(alias="outputDurationSec")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  applied_stretch_rate: float = Field(alias="appliedStretchRate")
149
  applied_pitch_shift_semitones: float = Field(alias="appliedPitchShiftSemitones")
150
  output_duration_sec: float = Field(alias="outputDurationSec")
151
+
152
+
153
+ class MultitrackChannelSettings(BaseModel):
154
+ """Per-track mix settings for the Creator Studio Multitrack mixer.
155
+ Index-matched to the uploaded files (channel 0 -> first file, etc.)."""
156
+
157
+ model_config = {"populate_by_name": True}
158
+
159
+ gain_db: float = Field(
160
+ default=0.0, alias="gainDb", ge=-60.0, le=12.0,
161
+ description="Gain applied to this track before mixing, in dB"
162
+ )
163
+ pan: float = Field(
164
+ default=0.0, ge=-1.0, le=1.0,
165
+ description="Stereo position: -1.0 full left, 0.0 center, 1.0 full right"
166
+ )
167
+ muted: bool = Field(default=False, description="Exclude this track from the mix entirely")
168
+
169
+
170
+ class MultitrackOptions(BaseModel):
171
+ model_config = {"populate_by_name": True}
172
+
173
+ channels: List[MultitrackChannelSettings] = Field(
174
+ default_factory=list,
175
+ description="Per-track settings, index-matched to the uploaded files"
176
+ )
177
+ normalize_output: bool = Field(
178
+ default=True, alias="normalizeOutput",
179
+ description="Peak-normalize the final mix to prevent clipping"
180
+ )
181
+
182
+
183
+ class MultitrackChannelReport(BaseModel):
184
+ """What actually happened to one channel during mixdown."""
185
+
186
+ model_config = {"populate_by_name": True}
187
+
188
+ index: int
189
+ duration_sec: float = Field(alias="durationSec")
190
+ applied_gain_db: float = Field(alias="appliedGainDb")
191
+ pan: float
192
+ muted: bool
193
+ peak_level: float = Field(alias="peakLevel")
194
+
195
+
196
+ class MultitrackAnalysis(BaseModel):
197
+ model_config = {"populate_by_name": True}
198
+
199
+ channels: List[MultitrackChannelReport]
200
+ output_duration_sec: float = Field(alias="outputDurationSec")
201
+ output_peak_level: float = Field(alias="outputPeakLevel")
202
+ clipping_prevented: bool = Field(alias="clippingPrevented")
app/services/multitrack_mixer.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Creator Studio Multitrack Mixer — real DSP, not a mock.
3
+
4
+ Takes 2-6 uploaded tracks, applies per-track gain (dB) and stereo pan,
5
+ aligns them to a common length (shorter tracks are zero-padded — silence,
6
+ not looped, since these are independent parts of one arrangement rather
7
+ than a loopable texture like remix_engine's crossfade blend), sums them
8
+ into a single stereo mix, and peak-normalizes to prevent clipping.
9
+
10
+ Equal-power panning (constant-power law) is used so a track panned to
11
+ either side doesn't lose perceived loudness relative to center.
12
+ """
13
+
14
+ import io
15
+ import logging
16
+
17
+ import librosa
18
+ import numpy as np
19
+ import soundfile as sf
20
+
21
+ from app.schemas import MultitrackChannelSettings, MultitrackOptions
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ MAX_CHANNELS = 6
26
+ MIN_CHANNELS = 2
27
+
28
+
29
+ class MultitrackResult:
30
+ """Plain result container, mirroring RemixResult's pattern."""
31
+
32
+ def __init__(self, audio: io.BytesIO, analysis: dict) -> None:
33
+ self.audio = audio
34
+ self.analysis = analysis
35
+
36
+
37
+ def _db_to_linear(db: float) -> float:
38
+ return float(10.0 ** (db / 20.0))
39
+
40
+
41
+ def _equal_power_pan(mono: np.ndarray, pan: float) -> np.ndarray:
42
+ """Pan a mono signal to stereo using the constant-power law.
43
+ pan=-1 -> full left, 0 -> center (both channels at -3dB), 1 -> full right."""
44
+ angle = (pan + 1.0) * (np.pi / 4.0) # maps [-1, 1] -> [0, pi/2]
45
+ left_gain = float(np.cos(angle))
46
+ right_gain = float(np.sin(angle))
47
+ stereo = np.stack([mono * left_gain, mono * right_gain], axis=1)
48
+ return stereo
49
+
50
+
51
+ def mix_tracks(
52
+ track_bytes_list: list[bytes],
53
+ options: MultitrackOptions,
54
+ ) -> MultitrackResult:
55
+ if len(track_bytes_list) < MIN_CHANNELS:
56
+ raise ValueError(f"At least {MIN_CHANNELS} tracks are required for a multitrack mix")
57
+ if len(track_bytes_list) > MAX_CHANNELS:
58
+ raise ValueError(f"At most {MAX_CHANNELS} tracks are supported per mix")
59
+
60
+ channels = options.channels
61
+ if len(channels) != len(track_bytes_list):
62
+ # Missing settings default to unity gain, centered, unmuted —
63
+ # a caller can send fewer channel configs than files and get sane
64
+ # defaults for the rest rather than a validation error.
65
+ channels = list(channels) + [
66
+ MultitrackChannelSettings() for _ in range(len(track_bytes_list) - len(channels))
67
+ ]
68
+
69
+ sr = 22050
70
+ loaded: list[np.ndarray] = []
71
+ for i, raw in enumerate(track_bytes_list):
72
+ try:
73
+ y, _ = librosa.load(io.BytesIO(raw), sr=sr, mono=True)
74
+ except Exception as e:
75
+ logger.error(f"Failed to load track {i} for multitrack mix: {e}", exc_info=True)
76
+ raise ValueError(f"Could not read audio for track {i + 1}: {e}")
77
+ if y.size == 0:
78
+ raise ValueError(f"Track {i + 1} is empty")
79
+ loaded.append(y)
80
+
81
+ max_len = max(len(y) for y in loaded)
82
+ channel_reports = []
83
+ mix = np.zeros((max_len, 2), dtype=np.float64)
84
+
85
+ for i, (y, settings) in enumerate(zip(loaded, channels)):
86
+ duration_sec = round(float(len(y) / sr), 2)
87
+
88
+ if settings.muted:
89
+ channel_reports.append({
90
+ "index": i,
91
+ "duration_sec": duration_sec,
92
+ "applied_gain_db": settings.gain_db,
93
+ "pan": settings.pan,
94
+ "muted": True,
95
+ "peak_level": 0.0,
96
+ })
97
+ continue
98
+
99
+ # Zero-pad to common length — independent arrangement parts should
100
+ # not loop into each other the way a two-track crossfade blend would.
101
+ padded = np.pad(y, (0, max_len - len(y)))
102
+ gained = padded * _db_to_linear(settings.gain_db)
103
+ stereo = _equal_power_pan(gained, settings.pan)
104
+ mix += stereo
105
+
106
+ peak = float(np.max(np.abs(gained))) if gained.size else 0.0
107
+ channel_reports.append({
108
+ "index": i,
109
+ "duration_sec": duration_sec,
110
+ "applied_gain_db": settings.gain_db,
111
+ "pan": settings.pan,
112
+ "muted": False,
113
+ "peak_level": round(peak, 4),
114
+ })
115
+
116
+ output_peak = float(np.max(np.abs(mix))) if mix.size else 0.0
117
+ clipping_prevented = False
118
+ if options.normalize_output and output_peak > 1.0:
119
+ mix = mix / output_peak * 0.98
120
+ clipping_prevented = True
121
+ output_peak = 0.98
122
+ elif output_peak > 1.0:
123
+ # Caller explicitly disabled normalization — hard-clip rather than
124
+ # let soundfile silently wrap/distort on write.
125
+ mix = np.clip(mix, -1.0, 1.0)
126
+ clipping_prevented = True
127
+ output_peak = 1.0
128
+
129
+ out_buffer = io.BytesIO()
130
+ sf.write(out_buffer, mix.astype(np.float32), sr, format="WAV")
131
+ out_buffer.seek(0)
132
+
133
+ analysis = {
134
+ "channels": channel_reports,
135
+ "output_duration_sec": round(float(max_len / sr), 2),
136
+ "output_peak_level": round(output_peak, 4),
137
+ "clipping_prevented": clipping_prevented,
138
+ }
139
+ return MultitrackResult(audio=out_buffer, analysis=analysis)
tests/conftest.py CHANGED
@@ -1,37 +1,15 @@
1
- """Shared test fixtures for CrownCode backend tests.
2
-
3
- TEMPORARY isolated variant — mounts data_processing_router, dreams_router,
4
- remix_router, and analyze_router, bypassing the commend router's broken
5
- googleapiclient import chain. Restored to the original (importing the full
6
- app.main) immediately after this test run.
7
- """
8
 
9
  from __future__ import annotations
10
 
11
  import pytest
12
- from fastapi import FastAPI
13
  from starlette.testclient import TestClient
14
 
15
- from app.routes.data_processing import router as data_processing_router
16
- from app.routes.dreams import router as dreams_router
17
- from app.routes.remix import router as remix_router
18
- from app.routes.analyze import router as analyze_router
19
-
20
-
21
- def _build_isolated_app() -> FastAPI:
22
- app = FastAPI(title="CrownCode Backend API (isolated test app)")
23
- app.include_router(data_processing_router)
24
- app.include_router(dreams_router)
25
- app.include_router(remix_router)
26
- app.include_router(analyze_router)
27
- return app
28
-
29
-
30
- _app = _build_isolated_app()
31
 
32
 
33
  @pytest.fixture()
34
  def client():
35
- """Create a test client for the isolated FastAPI application."""
36
- with TestClient(_app) as c:
37
  yield c
 
1
+ """Shared test fixtures for CrownCode backend tests."""
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
  import pytest
 
6
  from starlette.testclient import TestClient
7
 
8
+ from app.main import app
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
 
11
  @pytest.fixture()
12
  def client():
13
+ """Create a test client for the FastAPI application."""
14
+ with TestClient(app) as c:
15
  yield c
tests/test_remix.py CHANGED
@@ -193,3 +193,132 @@ def test_remix_blend_rate_limit(client: TestClient) -> None:
193
  last_status = response.status_code
194
 
195
  assert last_status == 429
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  last_status = response.status_code
194
 
195
  assert last_status == 429
196
+
197
+
198
+ # ── Multitrack mixer tests ───────────────────────────────────────────
199
+
200
+ def test_multitrack_rejects_too_few_tracks(client: TestClient) -> None:
201
+ response = client.post(
202
+ "/api/remix/multitrack",
203
+ files={"tracks": ("a.wav", _tone_wav(440, duration=1.0), "audio/wav")},
204
+ )
205
+ assert response.status_code == 400
206
+ assert response.json()["detail"]["code"] == "invalid_track_count"
207
+
208
+
209
+ def test_multitrack_rejects_too_many_tracks(client: TestClient) -> None:
210
+ files = [
211
+ ("tracks", (f"t{i}.wav", _tone_wav(220 + i * 20, duration=0.5), "audio/wav"))
212
+ for i in range(7)
213
+ ]
214
+ response = client.post("/api/remix/multitrack", files=files)
215
+ assert response.status_code == 400
216
+ assert response.json()["detail"]["code"] == "invalid_track_count"
217
+
218
+
219
+ def test_multitrack_rejects_non_audio_track(client: TestClient) -> None:
220
+ files = [
221
+ ("tracks", ("a.wav", _tone_wav(440, duration=1.0), "audio/wav")),
222
+ ("tracks", ("b.txt", io.BytesIO(b"not audio"), "text/plain")),
223
+ ]
224
+ response = client.post("/api/remix/multitrack", files=files)
225
+ assert response.status_code == 400
226
+ assert response.json()["detail"]["code"] == "invalid_file_type"
227
+
228
+
229
+ def test_multitrack_rejects_invalid_options_json(client: TestClient) -> None:
230
+ files = [
231
+ ("tracks", ("a.wav", _tone_wav(440, duration=1.0), "audio/wav")),
232
+ ("tracks", ("b.wav", _tone_wav(330, duration=1.0), "audio/wav")),
233
+ ]
234
+ response = client.post(
235
+ "/api/remix/multitrack",
236
+ data={"options": "{not json"},
237
+ files=files,
238
+ )
239
+ assert response.status_code == 422
240
+
241
+
242
+ def test_multitrack_mixes_three_tracks_and_reports_channels(client: TestClient) -> None:
243
+ files = [
244
+ ("tracks", ("a.wav", _tone_wav(220, duration=2.0), "audio/wav")),
245
+ ("tracks", ("b.wav", _tone_wav(330, duration=2.0), "audio/wav")),
246
+ ("tracks", ("c.wav", _tone_wav(440, duration=2.0), "audio/wav")),
247
+ ]
248
+ options = json.dumps({
249
+ "channels": [
250
+ {"gainDb": 0.0, "pan": -1.0, "muted": False},
251
+ {"gainDb": -6.0, "pan": 0.0, "muted": False},
252
+ {"gainDb": 0.0, "pan": 1.0, "muted": False},
253
+ ],
254
+ "normalizeOutput": True,
255
+ })
256
+ response = client.post(
257
+ "/api/remix/multitrack",
258
+ data={"options": options},
259
+ files=files,
260
+ )
261
+ assert response.status_code == 200
262
+ assert response.headers["content-type"] == "audio/wav"
263
+ assert "X-Multitrack-Analysis" in response.headers
264
+
265
+ analysis = json.loads(response.headers["X-Multitrack-Analysis"])
266
+ channels = analysis.get("channels", [])
267
+ assert len(channels) == 3
268
+ assert all(not c["muted"] for c in channels)
269
+
270
+ # Output must be real stereo audio at the expected duration.
271
+ y, sr = sf.read(io.BytesIO(response.content))
272
+ assert y.ndim == 2 and y.shape[1] == 2
273
+ assert abs(len(y) / sr - 2.0) < 0.1
274
+
275
+
276
+ def test_multitrack_muted_track_excluded_from_mix(client: TestClient) -> None:
277
+ """A muted track's audio must not appear in the panned-left channel."""
278
+ files = [
279
+ ("tracks", ("a.wav", _tone_wav(220, duration=1.5), "audio/wav")),
280
+ ("tracks", ("b.wav", _tone_wav(330, duration=1.5), "audio/wav")),
281
+ ]
282
+ options = json.dumps({
283
+ "channels": [
284
+ {"gainDb": 0.0, "pan": 0.0, "muted": True},
285
+ {"gainDb": 0.0, "pan": 0.0, "muted": False},
286
+ ],
287
+ })
288
+ response = client.post(
289
+ "/api/remix/multitrack",
290
+ data={"options": options},
291
+ files=files,
292
+ )
293
+ assert response.status_code == 200
294
+ analysis = json.loads(response.headers["X-Multitrack-Analysis"])
295
+ channels = analysis["channels"]
296
+ assert channels[0]["muted"] is True
297
+ assert channels[0]["peakLevel"] == 0.0
298
+ assert channels[1]["muted"] is False
299
+ assert channels[1]["peakLevel"] > 0.0
300
+
301
+
302
+ def test_multitrack_prevents_clipping_when_normalized(client: TestClient) -> None:
303
+ """Six loud, centered tracks summed together would clip well past
304
+ 1.0 without normalization — verify the output never does."""
305
+ files = [
306
+ ("tracks", (f"t{i}.wav", _tone_wav(200 + i * 50, duration=1.0), "audio/wav"))
307
+ for i in range(6)
308
+ ]
309
+ options = json.dumps({
310
+ "channels": [{"gainDb": 6.0, "pan": 0.0, "muted": False} for _ in range(6)],
311
+ "normalizeOutput": True,
312
+ })
313
+ response = client.post(
314
+ "/api/remix/multitrack",
315
+ data={"options": options},
316
+ files=files,
317
+ )
318
+ assert response.status_code == 200
319
+ y, sr = sf.read(io.BytesIO(response.content))
320
+ assert float(np.max(np.abs(y))) <= 1.0
321
+
322
+ analysis = json.loads(response.headers["X-Multitrack-Analysis"])
323
+ assert analysis["clippingPrevented"] is True
324
+ assert analysis["outputPeakLevel"] <= 1.0