Spaces:
Running
Running
feat: remix engine routes dream analysis servisleri ve dataset organization utilities eklendi
Browse files- app/main.py +5 -0
- app/routes/commend/gemini_service.py +6 -1
- app/routes/dreams.py +67 -0
- app/routes/remix.py +120 -0
- app/schemas.py +59 -0
- app/services/dataset_organizer.py +5 -5
- app/services/dream_analyzer.py +139 -0
- app/services/remix_engine.py +136 -0
- tests/test_dreams.py +117 -0
- tests/test_remix.py +195 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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"{
|
| 48 |
if minor_score > best_score:
|
| 49 |
best_score = minor_score
|
| 50 |
-
best_key = f"{
|
| 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 =
|
| 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
|