Rthur2003 commited on
Commit
e49c006
·
1 Parent(s): 405f6d3

feat: remix engine routes dream analysis servisleri ve dataset organization utilities eklendi

Browse files
app/main.py CHANGED
@@ -16,6 +16,8 @@ from .routes.data_processing import router as data_processing_router
16
  from .routes.analyze import router as analyze_router
17
  from .routes.commend import router as commend_router
18
  from .routes.reservations import router as reservations_router
 
 
19
  from .services.logging_config import setup_logging, get_logger
20
 
21
 
@@ -89,6 +91,7 @@ app.add_middleware(
89
  allow_credentials="*" not in _origins,
90
  allow_methods=["GET", "POST", "OPTIONS"],
91
  allow_headers=["Content-Type", "Accept"],
 
92
  )
93
 
94
  app.include_router(health_router)
@@ -97,5 +100,7 @@ app.include_router(data_processing_router)
97
  app.include_router(analyze_router)
98
  app.include_router(commend_router)
99
  app.include_router(reservations_router)
 
 
100
 
101
  logger.info("CrownCode backend API initialized")
 
16
  from .routes.analyze import router as analyze_router
17
  from .routes.commend import router as commend_router
18
  from .routes.reservations import router as reservations_router
19
+ from .routes.dreams import router as dreams_router
20
+ from .routes.remix import router as remix_router
21
  from .services.logging_config import setup_logging, get_logger
22
 
23
 
 
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)
 
100
  app.include_router(analyze_router)
101
  app.include_router(commend_router)
102
  app.include_router(reservations_router)
103
+ app.include_router(dreams_router)
104
+ app.include_router(remix_router)
105
 
106
  logger.info("CrownCode backend API initialized")
app/routes/commend/gemini_service.py CHANGED
@@ -115,7 +115,12 @@ Comment:"""
115
  logger.error(f"Generate error: {e}")
116
  err = str(e).lower()
117
 
118
- if "api key" in err:
 
 
 
 
 
119
  return None, "Invalid API key"
120
  elif "quota" in err or "limit" in err or "resource" in err:
121
  return None, "API quota exceeded"
 
115
  logger.error(f"Generate error: {e}")
116
  err = str(e).lower()
117
 
118
+ # Match "api key" / "api_key" / "apikey" — the local ValueError from
119
+ # _get_client() reads "..._API_KEY required" (underscore), which the
120
+ # space-only check below never matched, so a missing key fell
121
+ # through to the generic "Generation failed" message instead of
122
+ # "Invalid API key".
123
+ if "api key" in err or "api_key" in err or "apikey" in err:
124
  return None, "Invalid API key"
125
  elif "quota" in err or "limit" in err or "resource" in err:
126
  return None, "API quota exceeded"
app/routes/dreams.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Routes for Crown Dreams — real AI-powered dream analysis.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ from collections import defaultdict
9
+
10
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
11
+
12
+ from app.schemas import DreamAnalysisRequest, DreamAnalysisResponse
13
+ from app.services.dream_analyzer import analyze_dream
14
+ from app.services.logging_config import get_logger
15
+
16
+ router = APIRouter(prefix="/api/dreams", tags=["Dreams"])
17
+ logger = get_logger(__name__)
18
+
19
+ # Same bounded in-memory rate limiter pattern used by data_processing.py
20
+ # and commend/router.py — one call to an LLM per request is expensive
21
+ # enough to need this even for a read-only analysis endpoint.
22
+ _rate_store: dict[str, list[float]] = defaultdict(list)
23
+ _RATE_WINDOW = 60
24
+ _RATE_MAX = 8
25
+ _MAX_TRACKED_IPS = 10_000
26
+
27
+
28
+ async def _rate_limit(request: Request) -> None:
29
+ client_ip = request.client.host if request.client else "unknown"
30
+ now = time.time()
31
+ cutoff = now - _RATE_WINDOW
32
+
33
+ if len(_rate_store) > _MAX_TRACKED_IPS:
34
+ stale = [ip for ip, ts in _rate_store.items() if not ts or all(t <= cutoff for t in ts)]
35
+ for ip in stale:
36
+ del _rate_store[ip]
37
+
38
+ hits = [t for t in _rate_store[client_ip] if t > cutoff]
39
+ if len(hits) >= _RATE_MAX:
40
+ raise HTTPException(
41
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
42
+ detail={"code": "rate_limit_exceeded", "message": "Too many analysis requests. Please wait."},
43
+ )
44
+ hits.append(now)
45
+ _rate_store[client_ip] = hits
46
+
47
+
48
+ @router.post("/analyze", response_model=DreamAnalysisResponse, dependencies=[Depends(_rate_limit)])
49
+ async def analyze_dream_endpoint(payload: DreamAnalysisRequest) -> DreamAnalysisResponse:
50
+ """
51
+ Analyze a user-submitted dream description with Gemini: detected
52
+ emotions, themes, recurring symbols, and a grounded interpretation.
53
+ """
54
+ logger.info(f"Dream analysis request ({len(payload.dream_text)} chars, lang={payload.language})")
55
+
56
+ result, error = analyze_dream(payload.dream_text, payload.language)
57
+
58
+ if error is not None:
59
+ if "too short" in error.lower():
60
+ raise HTTPException(status_code=400, detail={"code": "dream_too_short", "message": error})
61
+ if "api key" in error.lower():
62
+ raise HTTPException(status_code=503, detail={"code": "service_not_configured", "message": "Dream analysis is not configured."})
63
+ if "quota" in error.lower():
64
+ raise HTTPException(status_code=503, detail={"code": "service_unavailable", "message": error})
65
+ raise HTTPException(status_code=502, detail={"code": "analysis_failed", "message": error})
66
+
67
+ return DreamAnalysisResponse(**result)
app/routes/remix.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Routes for the Creator Studio AI Remix engine.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from collections import defaultdict
9
+
10
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
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"])
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Same sliding-window rate limiter pattern as data_processing.py — remixing
21
+ # is CPU-heavy (two beat-tracking passes plus a phase-vocoder stretch), so it
22
+ # gets its own store rather than sharing the general processing budget.
23
+ _remix_rate_store: dict[str, list[float]] = defaultdict(list)
24
+ _REMIX_RATE_WINDOW = 60
25
+ _REMIX_RATE_MAX = 6
26
+ _REMIX_MAX_IPS = 10_000
27
+
28
+
29
+ async def _remix_rate_limit(request: Request) -> None:
30
+ client_ip = request.client.host if request.client else "unknown"
31
+ now = time.time()
32
+ cutoff = now - _REMIX_RATE_WINDOW
33
+
34
+ if len(_remix_rate_store) > _REMIX_MAX_IPS:
35
+ stale = [ip for ip, ts in _remix_rate_store.items() if not ts or all(t <= cutoff for t in ts)]
36
+ for ip in stale:
37
+ del _remix_rate_store[ip]
38
+
39
+ hits = [t for t in _remix_rate_store[client_ip] if t > cutoff]
40
+ if len(hits) >= _REMIX_RATE_MAX:
41
+ raise HTTPException(
42
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
43
+ detail={"code": "rate_limit_exceeded", "message": "Too many remix requests. Please wait."},
44
+ )
45
+ hits.append(now)
46
+ _remix_rate_store[client_ip] = hits
47
+
48
+
49
+ async def _read_capped(file: UploadFile, max_bytes: int) -> bytes:
50
+ chunks = []
51
+ total_read = 0
52
+ chunk_size = 1024 * 1024
53
+ while True:
54
+ chunk = await file.read(chunk_size)
55
+ if not chunk:
56
+ break
57
+ total_read += len(chunk)
58
+ if total_read > max_bytes:
59
+ raise HTTPException(
60
+ status_code=413,
61
+ detail={"code": "file_too_large", "message": f"File too large. Maximum size is {max_bytes // (1024*1024)} MB."},
62
+ )
63
+ chunks.append(chunk)
64
+ return b"".join(chunks)
65
+
66
+
67
+ @router.post("/blend", dependencies=[Depends(_remix_rate_limit)])
68
+ async def remix_blend_endpoint(
69
+ track_a: UploadFile = File(...),
70
+ track_b: UploadFile = File(...),
71
+ options: str = Form(default="{}"),
72
+ ):
73
+ """
74
+ Blend two uploaded tracks into one: detects real BPM/key for each,
75
+ optionally time-stretches/pitch-shifts track B to match track A, and
76
+ crossfades them together. Returns the mixed WAV plus the detected
77
+ analysis in the X-Remix-Analysis response header (JSON) so the caller
78
+ gets both the audio and the real numbers behind it in one round trip.
79
+ """
80
+ MAX_PAYLOAD_BYTES = 30 * 1024 * 1024 # 30 MB per track
81
+ logger.info(f"Received remix blend request: {track_a.filename} + {track_b.filename}")
82
+
83
+ raw_options = options.strip() if options else "{}"
84
+ if not raw_options:
85
+ raw_options = "{}"
86
+ try:
87
+ parsed_options = RemixOptions.model_validate_json(raw_options)
88
+ except (ValidationError, json.JSONDecodeError) as e:
89
+ raise HTTPException(status_code=422, detail={"code": "invalid_options", "message": f"Invalid options format: {e}"})
90
+
91
+ for f, label in ((track_a, "track_a"), (track_b, "track_b")):
92
+ if not f.content_type or not f.content_type.startswith("audio/"):
93
+ raise HTTPException(
94
+ status_code=400,
95
+ detail={"code": "invalid_file_type", "message": f"Invalid file type for {label}. Must be audio."},
96
+ )
97
+
98
+ try:
99
+ content_a = await _read_capped(track_a, MAX_PAYLOAD_BYTES)
100
+ content_b = await _read_capped(track_b, MAX_PAYLOAD_BYTES)
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",
108
+ headers={
109
+ "Content-Disposition": "attachment; filename=crowncode-remix.wav",
110
+ "X-Remix-Analysis": analysis_json,
111
+ },
112
+ )
113
+
114
+ except ValueError as e:
115
+ raise HTTPException(status_code=400, detail={"code": "validation_error", "message": str(e)})
116
+ except HTTPException:
117
+ raise
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"})
app/schemas.py CHANGED
@@ -89,3 +89,62 @@ class DatasetEntryMetadata(BaseModel):
89
  key: str
90
  loudness_db: float = Field(alias="loudnessDb")
91
  tags: List[str]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  key: str
90
  loudness_db: float = Field(alias="loudnessDb")
91
  tags: List[str]
92
+
93
+
94
+ class DreamAnalysisRequest(BaseModel):
95
+ dream_text: str = Field(..., alias="dreamText", min_length=1, max_length=4000)
96
+ language: str = Field(default="English", description="Output language for the interpretation")
97
+
98
+ model_config = {"populate_by_name": True}
99
+
100
+
101
+ class DreamAnalysisResponse(BaseModel):
102
+ model_config = {"populate_by_name": True}
103
+
104
+ emotions: List[str]
105
+ themes: List[str]
106
+ symbols: List[str]
107
+ interpretation: str
108
+ lucidity_indicator: bool = Field(alias="lucidityIndicator")
109
+
110
+
111
+ RemixCrossfadeCurve = Literal["equal_power", "linear"]
112
+
113
+
114
+ class RemixOptions(BaseModel):
115
+ """Options for the Creator Studio AI Remix engine — auto tempo/key
116
+ matching plus a crossfade blend between two uploaded tracks."""
117
+
118
+ model_config = {"populate_by_name": True}
119
+
120
+ match_tempo: bool = Field(
121
+ default=True, alias="matchTempo",
122
+ description="Time-stretch the second track to the first track's detected BPM"
123
+ )
124
+ match_key: bool = Field(
125
+ default=False, alias="matchKey",
126
+ description="Pitch-shift the second track to the first track's detected key"
127
+ )
128
+ crossfade_seconds: float = Field(
129
+ default=4.0, alias="crossfadeSeconds", ge=0.5, le=30.0,
130
+ description="Length of the crossfade transition between the two tracks"
131
+ )
132
+ crossfade_curve: RemixCrossfadeCurve = Field(
133
+ default="equal_power", alias="crossfadeCurve",
134
+ description="Crossfade power curve — equal_power avoids a volume dip at the midpoint"
135
+ )
136
+
137
+
138
+ class RemixAnalysis(BaseModel):
139
+ """Reports what the remix engine actually detected/did to each track,
140
+ so the UI can show real numbers instead of a black box."""
141
+
142
+ model_config = {"populate_by_name": True}
143
+
144
+ track_a_bpm: float = Field(alias="trackABpm")
145
+ track_b_bpm: float = Field(alias="trackBBpm")
146
+ track_a_key: str = Field(alias="trackAKey")
147
+ track_b_key: str = Field(alias="trackBKey")
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")
app/services/dataset_organizer.py CHANGED
@@ -18,7 +18,7 @@ import numpy as np
18
 
19
  logger = logging.getLogger(__name__)
20
 
21
- _PITCH_CLASSES = [
22
  "C", "C#", "D", "D#", "E", "F",
23
  "F#", "G", "G#", "A", "A#", "B",
24
  ]
@@ -33,7 +33,7 @@ _MINOR_PROFILE = np.array(
33
  )
34
 
35
 
36
- def _detect_key(chroma_mean: np.ndarray) -> str:
37
  """Correlate the mean chroma vector against all 24 rotated key profiles."""
38
  best_score = -np.inf
39
  best_key = "C major"
@@ -44,10 +44,10 @@ def _detect_key(chroma_mean: np.ndarray) -> str:
44
  minor_score = float(np.corrcoef(chroma_mean, minor_rot)[0, 1])
45
  if major_score > best_score:
46
  best_score = major_score
47
- best_key = f"{_PITCH_CLASSES[shift]} major"
48
  if minor_score > best_score:
49
  best_score = minor_score
50
- best_key = f"{_PITCH_CLASSES[shift]} minor"
51
  return best_key
52
 
53
 
@@ -141,7 +141,7 @@ def analyze_for_organization(file_bytes: bytes) -> DatasetEntryMetadata:
141
  tempo_bpm = round(float(np.atleast_1d(tempo)[0]), 1)
142
 
143
  chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
144
- key = _detect_key(np.mean(chroma, axis=1))
145
 
146
  loudness_db = _loudness_lufs_approx(y)
147
  rms_mean = float(np.mean(librosa.feature.rms(y=y)[0]))
 
18
 
19
  logger = logging.getLogger(__name__)
20
 
21
+ PITCH_CLASSES = [
22
  "C", "C#", "D", "D#", "E", "F",
23
  "F#", "G", "G#", "A", "A#", "B",
24
  ]
 
33
  )
34
 
35
 
36
+ def detect_key(chroma_mean: np.ndarray) -> str:
37
  """Correlate the mean chroma vector against all 24 rotated key profiles."""
38
  best_score = -np.inf
39
  best_key = "C major"
 
44
  minor_score = float(np.corrcoef(chroma_mean, minor_rot)[0, 1])
45
  if major_score > best_score:
46
  best_score = major_score
47
+ best_key = f"{PITCH_CLASSES[shift]} major"
48
  if minor_score > best_score:
49
  best_score = minor_score
50
+ best_key = f"{PITCH_CLASSES[shift]} minor"
51
  return best_key
52
 
53
 
 
141
  tempo_bpm = round(float(np.atleast_1d(tempo)[0]), 1)
142
 
143
  chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
144
+ key = detect_key(np.mean(chroma, axis=1))
145
 
146
  loudness_db = _loudness_lufs_approx(y)
147
  rms_mean = float(np.mean(librosa.feature.rms(y=y)[0]))
app/services/dream_analyzer.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dream Analysis Service — real Gemini-powered analysis of a user-submitted
3
+ dream description: detected emotions, recurring symbols, likely themes,
4
+ and a genuine interpretive summary.
5
+
6
+ Mirrors the established Gemini client pattern from
7
+ app/routes/commend/gemini_service.py (same lazy client, same env var
8
+ naming convention, same error classification) rather than inventing a
9
+ second way to talk to the same API.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ from google import genai
20
+
21
+ from .logging_config import get_logger
22
+
23
+ logger = get_logger(__name__)
24
+
25
+ _client = None
26
+
27
+ _MAX_DREAM_CHARS = 4000
28
+ _MIN_DREAM_CHARS = 10
29
+
30
+
31
+ def _get_client():
32
+ global _client
33
+ if _client is not None:
34
+ return _client
35
+
36
+ api_key = os.getenv("DREAMS_GEMINI_API_KEY") or os.getenv("COMMEND_GEMINI_API_KEY")
37
+ if not api_key:
38
+ raise ValueError("DREAMS_GEMINI_API_KEY (or COMMEND_GEMINI_API_KEY) required")
39
+
40
+ try:
41
+ _client = genai.Client(api_key=api_key)
42
+ logger.info("Gemini initialized for dream analysis")
43
+ return _client
44
+ except Exception as e:
45
+ logger.error(f"Gemini init failed: {e}")
46
+ raise ValueError(f"Gemini failed: {e}")
47
+
48
+
49
+ _ALLOWED_EMOTIONS = {
50
+ "joy", "fear", "anxiety", "sadness", "anger", "confusion",
51
+ "peace", "excitement", "nostalgia", "wonder", "shame", "love",
52
+ }
53
+
54
+
55
+ def _extract_json(text: str) -> Optional[Dict[str, Any]]:
56
+ """Gemini sometimes wraps JSON in a ```json fence despite instructions
57
+ not to — strip that before parsing rather than fail the whole request."""
58
+ cleaned = text.strip()
59
+ fence_match = re.match(r"^```(?:json)?\s*(.*?)\s*```$", cleaned, re.DOTALL)
60
+ if fence_match:
61
+ cleaned = fence_match.group(1)
62
+ try:
63
+ return json.loads(cleaned)
64
+ except json.JSONDecodeError:
65
+ return None
66
+
67
+
68
+ def analyze_dream(
69
+ dream_text: str,
70
+ language: str = "English",
71
+ ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
72
+ """
73
+ Analyze a dream description and return structured findings:
74
+ {
75
+ "emotions": ["fear", "wonder"],
76
+ "themes": ["falling", "being chased"],
77
+ "symbols": ["water", "flying"],
78
+ "interpretation": "...",
79
+ "lucidityIndicator": bool
80
+ }
81
+ Returns (result, None) on success or (None, error_message) on failure.
82
+ """
83
+ text = dream_text.strip()
84
+ if len(text) < _MIN_DREAM_CHARS:
85
+ return None, "Dream description is too short to analyze."
86
+ if len(text) > _MAX_DREAM_CHARS:
87
+ text = text[:_MAX_DREAM_CHARS]
88
+
89
+ try:
90
+ client = _get_client()
91
+
92
+ prompt = f"""You are a dream analyst. Analyze this dream description and respond with ONLY a raw JSON object (no markdown fence, no commentary) with this exact shape:
93
+ {{"emotions": string[] (2-4 items from: joy, fear, anxiety, sadness, anger, confusion, peace, excitement, nostalgia, wonder, shame, love), "themes": string[] (1-3 short phrases describing narrative themes, e.g. "being chased", "falling", "losing teeth"), "symbols": string[] (1-4 concrete recurring symbols/objects mentioned or implied, e.g. "water", "flying", "a locked door"), "interpretation": string (2-3 sentences, a grounded psychological reading, not mystical fortune-telling, in {language}), "lucidityIndicator": boolean (true only if the dreamer describes realizing they were dreaming)}}
94
+
95
+ Dream description:
96
+ {text}
97
+
98
+ JSON:"""
99
+
100
+ response = client.models.generate_content(
101
+ model="gemini-2.5-flash-lite",
102
+ contents=prompt,
103
+ )
104
+
105
+ raw = response.text.strip() if response.text else ""
106
+ parsed = _extract_json(raw)
107
+ if parsed is None:
108
+ logger.error(f"Dream analysis returned non-JSON: {raw[:200]}")
109
+ return None, "Analysis produced an unexpected response format."
110
+
111
+ emotions = [e for e in parsed.get("emotions", []) if e in _ALLOWED_EMOTIONS][:4]
112
+ themes = [str(t)[:60] for t in parsed.get("themes", [])][:3]
113
+ symbols = [str(s)[:40] for s in parsed.get("symbols", [])][:4]
114
+ interpretation = str(parsed.get("interpretation", ""))[:800]
115
+ lucidity_indicator = bool(parsed.get("lucidityIndicator", False))
116
+
117
+ if not emotions or not interpretation:
118
+ return None, "Analysis was incomplete. Please try again."
119
+
120
+ return {
121
+ "emotions": emotions,
122
+ "themes": themes,
123
+ "symbols": symbols,
124
+ "interpretation": interpretation,
125
+ "lucidityIndicator": lucidity_indicator,
126
+ }, None
127
+
128
+ except Exception as e:
129
+ logger.error(f"Dream analysis error: {e}")
130
+ err = str(e).lower()
131
+ # Match "api key", "api_key", and "apikey" — the local ValueError
132
+ # from _get_client() reads "..._api_key required" (underscore), and
133
+ # Gemini's own errors tend to read "API key not valid" (space), so
134
+ # a plain substring check on either form alone misses the other.
135
+ if "api key" in err or "api_key" in err or "apikey" in err:
136
+ return None, "Invalid API key"
137
+ if "quota" in err or "limit" in err or "resource" in err:
138
+ return None, "API quota exceeded"
139
+ return None, f"Analysis failed: {str(e)}"
app/services/remix_engine.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Creator Studio AI Remix engine — real DSP, not a mock.
3
+
4
+ Takes two uploaded tracks, detects each one's tempo (via librosa beat
5
+ tracking) and musical key (via the same Krumhansl-Schmuckler chroma
6
+ correlation used by dataset_organizer.py), time-stretches/pitch-shifts the
7
+ second track to match the first, and blends them with a crossfade so the
8
+ end of track A overlaps into the start of track B.
9
+
10
+ This is deliberately a "remix as a smooth two-track blend" rather than a
11
+ full beat-synced stem remix (that needs source separation, which this
12
+ codebase doesn't ship) — but every number it reports (BPM, key, applied
13
+ stretch rate) comes from real analysis of the real uploaded audio.
14
+ """
15
+
16
+ import io
17
+ import logging
18
+
19
+ import librosa
20
+ import numpy as np
21
+ import soundfile as sf
22
+
23
+ from app.schemas import RemixOptions
24
+ from app.services.dataset_organizer import PITCH_CLASSES, detect_key
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _MAX_STRETCH_RATE = 1.6
29
+ _MIN_STRETCH_RATE = 0.625 # 1 / _MAX_STRETCH_RATE, kept symmetric in log-space
30
+
31
+
32
+ class RemixResult:
33
+ """Plain result container, mirroring DatasetEntryMetadata's pattern —
34
+ keeps this module free of a pydantic dependency."""
35
+
36
+ def __init__(self, audio: io.BytesIO, analysis: dict) -> None:
37
+ self.audio = audio
38
+ self.analysis = analysis
39
+
40
+
41
+ def _analyze_track(y: np.ndarray, sr: int) -> tuple[float, str]:
42
+ tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
43
+ bpm = round(float(np.atleast_1d(tempo)[0]), 1)
44
+ chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
45
+ key = detect_key(np.mean(chroma, axis=1))
46
+ return bpm, key
47
+
48
+
49
+ def _key_root_index(key: str) -> int:
50
+ root = key.split(" ")[0]
51
+ return PITCH_CLASSES.index(root) if root in PITCH_CLASSES else 0
52
+
53
+
54
+ def _semitone_distance(from_key: str, to_key: str) -> float:
55
+ """Shortest signed semitone distance between two detected keys' roots,
56
+ e.g. "C major" -> "A major" is -3 (down 3), not +9 (up 9)."""
57
+ diff = (_key_root_index(to_key) - _key_root_index(from_key)) % 12
58
+ if diff > 6:
59
+ diff -= 12
60
+ return float(diff)
61
+
62
+
63
+ def build_remix(
64
+ track_a_bytes: bytes,
65
+ track_b_bytes: bytes,
66
+ options: RemixOptions,
67
+ ) -> RemixResult:
68
+ try:
69
+ y_a, sr = librosa.load(io.BytesIO(track_a_bytes), sr=22050, mono=True)
70
+ y_b, _ = librosa.load(io.BytesIO(track_b_bytes), sr=sr, mono=True)
71
+ except Exception as e:
72
+ logger.error(f"Failed to load audio for remix: {e}", exc_info=True)
73
+ raise ValueError(f"Could not read audio file: {e}")
74
+
75
+ if y_a.size == 0 or float(np.max(np.abs(y_a))) < 1e-6:
76
+ raise ValueError("Track A is empty or silent")
77
+ if y_b.size == 0 or float(np.max(np.abs(y_b))) < 1e-6:
78
+ raise ValueError("Track B is empty or silent")
79
+
80
+ bpm_a, key_a = _analyze_track(y_a, sr)
81
+ bpm_b, key_b = _analyze_track(y_b, sr)
82
+
83
+ applied_rate = 1.0
84
+ if options.match_tempo and bpm_a >= 1 and bpm_b >= 1:
85
+ raw_rate = bpm_b / bpm_a
86
+ # Clamp to a musically sane range — librosa's phase vocoder degrades
87
+ # badly outside roughly 0.6x-1.6x, and a tempo detector octave error
88
+ # (BPM doubled/halved) would otherwise send this to an extreme.
89
+ applied_rate = float(np.clip(raw_rate, _MIN_STRETCH_RATE, _MAX_STRETCH_RATE))
90
+ y_b = librosa.effects.time_stretch(y_b, rate=applied_rate)
91
+ logger.info(f"Remix: stretched track B by {applied_rate:.3f}x ({bpm_b}->{bpm_a} bpm)")
92
+
93
+ applied_semitones = 0.0
94
+ if options.match_key:
95
+ applied_semitones = _semitone_distance(key_b, key_a)
96
+ if abs(applied_semitones) > 0.01:
97
+ y_b = librosa.effects.pitch_shift(y_b, sr=sr, n_steps=applied_semitones)
98
+ logger.info(f"Remix: pitch-shifted track B by {applied_semitones:+.1f} semitones ({key_b}->{key_a})")
99
+
100
+ crossfade_samples = int(options.crossfade_seconds * sr)
101
+ crossfade_samples = min(crossfade_samples, len(y_a), len(y_b))
102
+
103
+ if crossfade_samples <= 0:
104
+ # No usable overlap — just concatenate.
105
+ mixed = np.concatenate([y_a, y_b])
106
+ else:
107
+ t = np.linspace(0.0, 1.0, crossfade_samples, dtype=np.float64)
108
+ if options.crossfade_curve == "equal_power":
109
+ fade_out = np.cos(t * np.pi / 2)
110
+ fade_in = np.sin(t * np.pi / 2)
111
+ else:
112
+ fade_out = 1.0 - t
113
+ fade_in = t
114
+
115
+ head = y_a[:-crossfade_samples]
116
+ tail_a = y_a[-crossfade_samples:]
117
+ overlap = tail_a * fade_out.astype(np.float32) + y_b[:crossfade_samples] * fade_in.astype(np.float32)
118
+ rest_b = y_b[crossfade_samples:]
119
+ mixed = np.concatenate([head, overlap, rest_b])
120
+
121
+ mixed = librosa.util.normalize(mixed)
122
+
123
+ out_buffer = io.BytesIO()
124
+ sf.write(out_buffer, mixed, sr, format="WAV")
125
+ out_buffer.seek(0)
126
+
127
+ analysis = {
128
+ "track_a_bpm": bpm_a,
129
+ "track_b_bpm": bpm_b,
130
+ "track_a_key": key_a,
131
+ "track_b_key": key_b,
132
+ "applied_stretch_rate": round(applied_rate, 3),
133
+ "applied_pitch_shift_semitones": applied_semitones,
134
+ "output_duration_sec": round(float(len(mixed) / sr), 2),
135
+ }
136
+ return RemixResult(audio=out_buffer, analysis=analysis)
tests/test_dreams.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for /api/dreams/analyze endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+ from fastapi.testclient import TestClient
10
+
11
+ from app.routes.dreams import _rate_store
12
+
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def _reset_rate_limiter():
16
+ _rate_store.clear()
17
+ yield
18
+ _rate_store.clear()
19
+
20
+
21
+ def _mock_gemini_response(**overrides: object) -> MagicMock:
22
+ defaults = {
23
+ "emotions": ["fear", "wonder"],
24
+ "themes": ["being chased"],
25
+ "symbols": ["water"],
26
+ "interpretation": "This reflects anxiety about a real-life situation.",
27
+ "lucidityIndicator": False,
28
+ }
29
+ defaults.update(overrides)
30
+ response = MagicMock()
31
+ response.text = json.dumps(defaults)
32
+ return response
33
+
34
+
35
+ def test_analyze_valid_dream_returns_structured_result(client: TestClient) -> None:
36
+ mock_client = MagicMock()
37
+ mock_client.models.generate_content.return_value = _mock_gemini_response()
38
+ with patch("app.services.dream_analyzer._get_client", return_value=mock_client):
39
+ response = client.post(
40
+ "/api/dreams/analyze",
41
+ json={"dreamText": "I was being chased through a dark forest and found a river.", "language": "English"},
42
+ )
43
+ assert response.status_code == 200
44
+ body = response.json()
45
+ assert body["emotions"] == ["fear", "wonder"]
46
+ assert body["themes"] == ["being chased"]
47
+ assert body["lucidityIndicator"] is False
48
+
49
+
50
+ def test_analyze_rejects_too_short_dream(client: TestClient) -> None:
51
+ response = client.post("/api/dreams/analyze", json={"dreamText": "hi"})
52
+ assert response.status_code == 400
53
+ assert response.json()["detail"]["code"] == "dream_too_short"
54
+
55
+
56
+ def test_analyze_rejects_empty_dream_text(client: TestClient) -> None:
57
+ response = client.post("/api/dreams/analyze", json={"dreamText": ""})
58
+ assert response.status_code == 422
59
+
60
+
61
+ def test_analyze_rejects_missing_dream_text(client: TestClient) -> None:
62
+ response = client.post("/api/dreams/analyze", json={})
63
+ assert response.status_code == 422
64
+
65
+
66
+ def test_analyze_handles_missing_api_key(client: TestClient) -> None:
67
+ with patch("app.services.dream_analyzer._get_client", side_effect=ValueError("DREAMS_GEMINI_API_KEY required")):
68
+ response = client.post(
69
+ "/api/dreams/analyze",
70
+ json={"dreamText": "A perfectly normal dream about flying over mountains."},
71
+ )
72
+ assert response.status_code == 503
73
+ assert response.json()["detail"]["code"] == "service_not_configured"
74
+
75
+
76
+ def test_analyze_handles_malformed_gemini_output(client: TestClient) -> None:
77
+ mock_client = MagicMock()
78
+ bad_response = MagicMock()
79
+ bad_response.text = "I refuse to analyze this."
80
+ mock_client.models.generate_content.return_value = bad_response
81
+ with patch("app.services.dream_analyzer._get_client", return_value=mock_client):
82
+ response = client.post(
83
+ "/api/dreams/analyze",
84
+ json={"dreamText": "A perfectly normal dream about flying over mountains."},
85
+ )
86
+ assert response.status_code == 502
87
+ assert response.json()["detail"]["code"] == "analysis_failed"
88
+
89
+
90
+ def test_analyze_filters_invalid_emotions_from_gemini(client: TestClient) -> None:
91
+ """Gemini could hallucinate an emotion outside the allowed set — the
92
+ service must filter it rather than pass it straight through."""
93
+ mock_client = MagicMock()
94
+ mock_client.models.generate_content.return_value = _mock_gemini_response(
95
+ emotions=["fear", "not_a_real_emotion", "joy"]
96
+ )
97
+ with patch("app.services.dream_analyzer._get_client", return_value=mock_client):
98
+ response = client.post(
99
+ "/api/dreams/analyze",
100
+ json={"dreamText": "A perfectly normal dream about flying over mountains."},
101
+ )
102
+ assert response.status_code == 200
103
+ assert "not_a_real_emotion" not in response.json()["emotions"]
104
+
105
+
106
+ def test_analyze_enforces_rate_limit(client: TestClient) -> None:
107
+ mock_client = MagicMock()
108
+ mock_client.models.generate_content.return_value = _mock_gemini_response()
109
+ with patch("app.services.dream_analyzer._get_client", return_value=mock_client):
110
+ last_status = 200
111
+ for _ in range(10):
112
+ r = client.post(
113
+ "/api/dreams/analyze",
114
+ json={"dreamText": "A perfectly normal dream about flying over mountains."},
115
+ )
116
+ last_status = r.status_code
117
+ assert last_status == 429
tests/test_remix.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for the Creator Studio AI Remix engine (/api/remix/blend).
3
+
4
+ Uses real synthesized audio — a pure tone for basic plumbing/error-path
5
+ tests, and a click track (periodic impulses) for tempo-detection tests,
6
+ since librosa.beat.beat_track needs actual rhythmic structure to lock onto.
7
+ """
8
+
9
+ import io
10
+ import json
11
+
12
+ import numpy as np
13
+ import pytest
14
+ import soundfile as sf
15
+ from fastapi.testclient import TestClient
16
+
17
+ from app.routes.remix import _remix_rate_store
18
+
19
+
20
+ @pytest.fixture(autouse=True)
21
+ def _reset_remix_rate_limiter():
22
+ """The rate limiter's store is module-level and shared across every
23
+ test in this file (6 requests/60s per IP, and TestClient always uses
24
+ the same fake client IP) — without resetting it, tests that pass in
25
+ isolation start failing with 429 once enough tests run before them in
26
+ the same process."""
27
+ _remix_rate_store.clear()
28
+ yield
29
+ _remix_rate_store.clear()
30
+
31
+
32
+ def _tone_wav(freq: float = 440.0, duration: float = 3.0, sr: int = 22050) -> io.BytesIO:
33
+ t = np.linspace(0, duration, int(sr * duration))
34
+ y = (0.3 * np.sin(2 * np.pi * freq * t)).astype(np.float32)
35
+ buf = io.BytesIO()
36
+ sf.write(buf, y, sr, format="WAV")
37
+ buf.seek(0)
38
+ return buf
39
+
40
+
41
+ def _click_track_wav(bpm: float, duration: float = 6.0, sr: int = 22050) -> io.BytesIO:
42
+ """A percussive click every beat at the given BPM — enough rhythmic
43
+ structure for librosa's onset-based beat tracker to detect real tempo."""
44
+ n_samples = int(sr * duration)
45
+ y = np.zeros(n_samples, dtype=np.float32)
46
+ beat_interval_sec = 60.0 / bpm
47
+ click_len = int(sr * 0.02)
48
+ decay = np.exp(-np.linspace(0, 12, click_len)).astype(np.float32)
49
+ t = 0.0
50
+ while t < duration:
51
+ start = int(t * sr)
52
+ end = min(start + click_len, n_samples)
53
+ y[start:end] += decay[: end - start]
54
+ t += beat_interval_sec
55
+ buf = io.BytesIO()
56
+ sf.write(buf, y, sr, format="WAV")
57
+ buf.seek(0)
58
+ return buf
59
+
60
+
61
+ def _valid_options(**overrides) -> str:
62
+ base = {"matchTempo": True, "matchKey": False, "crossfadeSeconds": 2.0, "crossfadeCurve": "equal_power"}
63
+ base.update(overrides)
64
+ return json.dumps(base)
65
+
66
+
67
+ def test_remix_blend_rejects_non_audio_track_a(client: TestClient) -> None:
68
+ response = client.post(
69
+ "/api/remix/blend",
70
+ data={"options": _valid_options()},
71
+ files={
72
+ "track_a": ("a.txt", io.BytesIO(b"not audio"), "text/plain"),
73
+ "track_b": ("b.wav", _tone_wav(440), "audio/wav"),
74
+ },
75
+ )
76
+ assert response.status_code == 400
77
+ assert response.json()["detail"]["code"] == "invalid_file_type"
78
+
79
+
80
+ def test_remix_blend_rejects_non_audio_track_b(client: TestClient) -> None:
81
+ response = client.post(
82
+ "/api/remix/blend",
83
+ data={"options": _valid_options()},
84
+ files={
85
+ "track_a": ("a.wav", _tone_wav(440), "audio/wav"),
86
+ "track_b": ("b.bin", io.BytesIO(b"not audio"), "application/octet-stream"),
87
+ },
88
+ )
89
+ assert response.status_code == 400
90
+ assert response.json()["detail"]["code"] == "invalid_file_type"
91
+
92
+
93
+ def test_remix_blend_rejects_invalid_options_json(client: TestClient) -> None:
94
+ response = client.post(
95
+ "/api/remix/blend",
96
+ data={"options": "{not json"},
97
+ files={
98
+ "track_a": ("a.wav", _tone_wav(440), "audio/wav"),
99
+ "track_b": ("b.wav", _tone_wav(220), "audio/wav"),
100
+ },
101
+ )
102
+ assert response.status_code == 422
103
+
104
+
105
+ def test_remix_blend_rejects_silent_track(client: TestClient) -> None:
106
+ silent = io.BytesIO()
107
+ sf.write(silent, np.zeros(22050, dtype=np.float32), 22050, format="WAV")
108
+ silent.seek(0)
109
+
110
+ response = client.post(
111
+ "/api/remix/blend",
112
+ data={"options": _valid_options()},
113
+ files={
114
+ "track_a": ("a.wav", silent, "audio/wav"),
115
+ "track_b": ("b.wav", _tone_wav(440), "audio/wav"),
116
+ },
117
+ )
118
+ assert response.status_code == 400
119
+ assert response.json()["detail"]["code"] == "validation_error"
120
+
121
+
122
+ def test_remix_blend_returns_wav_with_analysis_header(client: TestClient) -> None:
123
+ response = client.post(
124
+ "/api/remix/blend",
125
+ data={"options": _valid_options(matchTempo=False)},
126
+ files={
127
+ "track_a": ("a.wav", _tone_wav(440, duration=2.0), "audio/wav"),
128
+ "track_b": ("b.wav", _tone_wav(330, duration=2.0), "audio/wav"),
129
+ },
130
+ )
131
+ assert response.status_code == 200
132
+ assert response.headers["content-type"] == "audio/wav"
133
+ assert "X-Remix-Analysis" in response.headers
134
+
135
+ analysis = json.loads(response.headers["X-Remix-Analysis"])
136
+ assert "trackABpm" in analysis or "track_a_bpm" in analysis # camelCase alias in JSON
137
+ assert response.content[:4] == b"RIFF"
138
+
139
+
140
+ def test_remix_blend_output_is_real_blended_audio_not_passthrough(client: TestClient) -> None:
141
+ """The output WAV must actually contain a crossfaded blend — verify via
142
+ duration: two 2s tracks with a 1s crossfade should yield ~3s of audio,
143
+ not 2s (passthrough) or 4s (naive concatenation)."""
144
+ response = client.post(
145
+ "/api/remix/blend",
146
+ data={"options": _valid_options(matchTempo=False, crossfadeSeconds=1.0)},
147
+ files={
148
+ "track_a": ("a.wav", _tone_wav(440, duration=2.0), "audio/wav"),
149
+ "track_b": ("b.wav", _tone_wav(330, duration=2.0), "audio/wav"),
150
+ },
151
+ )
152
+ assert response.status_code == 200
153
+ y, sr = sf.read(io.BytesIO(response.content))
154
+ duration = len(y) / sr
155
+ assert 2.8 < duration < 3.2
156
+
157
+
158
+ def test_remix_blend_matches_tempo_with_click_tracks(client: TestClient) -> None:
159
+ """Two click tracks at different real BPMs: with matchTempo on, the
160
+ engine must detect both tempos and report a non-trivial stretch rate."""
161
+ response = client.post(
162
+ "/api/remix/blend",
163
+ data={"options": _valid_options(matchTempo=True, crossfadeSeconds=1.0)},
164
+ files={
165
+ "track_a": ("a.wav", _click_track_wav(120.0), "audio/wav"),
166
+ "track_b": ("b.wav", _click_track_wav(140.0), "audio/wav"),
167
+ },
168
+ )
169
+ assert response.status_code == 200
170
+ analysis_raw = response.headers["X-Remix-Analysis"]
171
+ analysis = json.loads(analysis_raw)
172
+
173
+ bpm_a = analysis.get("track_a_bpm", analysis.get("trackABpm"))
174
+ bpm_b = analysis.get("track_b_bpm", analysis.get("trackBBpm"))
175
+ stretch = analysis.get("applied_stretch_rate", analysis.get("appliedStretchRate"))
176
+
177
+ assert bpm_a > 1 # real beat detected, not the "beatless" 0 fallback
178
+ assert bpm_b > 1
179
+ assert stretch != 1.0 # tempos differed, so a real stretch must have been applied
180
+
181
+
182
+ def test_remix_blend_rate_limit(client: TestClient) -> None:
183
+ last_status = None
184
+ for _ in range(7):
185
+ response = client.post(
186
+ "/api/remix/blend",
187
+ data={"options": _valid_options(matchTempo=False)},
188
+ files={
189
+ "track_a": ("a.wav", _tone_wav(440, duration=0.5), "audio/wav"),
190
+ "track_b": ("b.wav", _tone_wav(330, duration=0.5), "audio/wav"),
191
+ },
192
+ )
193
+ last_status = response.status_code
194
+
195
+ assert last_status == 429