""" routes/suno.py — Suno AI Song Generation Blueprint Provides endpoints for generating songs via Suno API (sunoapi.org). Generated songs land in generated_music/ and show up in the music player. Endpoints: GET/POST /api/suno (action: generate|jingle|sfx|status|list|credits) POST /api/suno/callback (webhook from sunoapi.org) GET/POST /api/suno/completed (frontend polls for completed songs) Agent trigger: Include [SUNO_GENERATE:prompt text here] in a response to kick off generation. The frontend detects the tag, calls /api/suno?action=generate, and polls for completion. """ import hashlib import hmac import ipaddress import json import logging import os import socket import threading import time import uuid from datetime import datetime, timezone from pathlib import Path from urllib.parse import urlparse import requests as http_requests from flask import Blueprint, jsonify, request # --------------------------------------------------------------------------- # Paths & config # --------------------------------------------------------------------------- from services.paths import GENERATED_MUSIC_DIR from services import suno_lyrics GENERATED_MUSIC_DIR.mkdir(parents=True, exist_ok=True) GENERATED_METADATA_FILE = GENERATED_MUSIC_DIR / 'generated_metadata.json' # SFX (action=sfx) land in a dedicated subdir so they are NOT mixed in with the # music library/player. Kept under generated_music/ so it's already mounted + # web-served (/generated_music/sfx/) with no compose/mount changes. The # music list (_action_list) and music metadata/queue intentionally skip these. GENERATED_SOUNDS_DIR = GENERATED_MUSIC_DIR / 'sfx' GENERATED_SOUNDS_DIR.mkdir(parents=True, exist_ok=True) SUNO_API_KEY = os.environ.get('SUNO_API_KEY', '') SUNO_API_BASE = 'https://api.sunoapi.org' # File Upload API lives on a DIFFERENT host (same bearer auth). It hosts a remote # file and returns a public URL used as uploadUrl/voiceUrl/verifyUrl elsewhere. SUNO_FILE_UPLOAD_BASE = 'https://sunoapiorg.redpandaai.co' SUNO_WEBHOOK_SECRET = os.environ.get('SUNO_WEBHOOK_SECRET', '') SUNO_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 # 50 MB cap on audio downloads # Callback URL: explicit > auto-derived from DOMAIN > empty # sunoapi.org requires callBackUrl; auto-derive from DOMAIN if not set explicitly. _domain = os.environ.get('DOMAIN', '') SUNO_CALLBACK_URL = ( os.environ.get('SUNO_CALLBACK_URL') or (f'https://{_domain}/api/suno/callback' if _domain else '') ) # --------------------------------------------------------------------------- # In-memory job tracking (single-worker deployment) # --------------------------------------------------------------------------- suno_jobs: dict = {} # job_id -> {status, prompt, title, style, created_at, task_id, ...} completed_songs_queue: list = [] # [{song_id, title, job_id, completed_at, url}, ...] failed_songs_queue: list = [] # [{job_id, kind, brand, reason, failed_at}, ...] _suno_lock = threading.Lock() logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Per-call provider receipt (JamFlow Watch live pulse) # # Host-side provider calls emit one JSONL row per call via provider-call-log.sh # so the Watch map lights the provider node the instant a call happens. This # container can't reach the host's canonical provider-calls dir, but the # monitoring-events mount (host: /home/user/monitoring/events) is RW — receipts # drop into a provider-calls/ subdir there, same one-line schema, and the Watch # tailer scans that drop dir too. Best-effort: NEVER blocks or fails a # generation; on containers without the mount the write is invisible/harmless. # --------------------------------------------------------------------------- PROVIDER_RECEIPTS_DIR = Path(os.environ.get( 'PROVIDER_RECEIPTS_DIR', '/app/runtime/monitoring-events/provider-calls')) _RECEIPT_TENANT = os.environ.get('JAMBOT_TENANT') or os.environ.get('HOST_TENANT', '') or 'unknown' def _provider_receipt(op: str, units: str = '1') -> None: """Append one {ts,provider,tenant,op,units} receipt row — fire-and-forget.""" try: PROVIDER_RECEIPTS_DIR.mkdir(parents=True, exist_ok=True) now = datetime.now(timezone.utc) row = json.dumps({'ts': now.isoformat(timespec='seconds'), 'provider': 'suno', 'tenant': _RECEIPT_TENANT, 'op': op, 'units': units}) with open(PROVIDER_RECEIPTS_DIR / f"suno-{now.strftime('%Y-%m-%d')}.jsonl", 'a') as f: f.write(row + '\n') except Exception: # never let telemetry touch the generation path pass # JamBot Books: also record as an api_call to the host-tailed books queue # (the receipt path above is unmounted on most containers; this one isn't). try: from services.jambot_books_hook import record_provider_call record_provider_call('suno', endpoint='/generate', op=op, units=units) except Exception: pass def _task_log(event: str, **fields) -> None: """Durable append-only Suno task ledger — generated_music/suno-task-log.jsonl. Every submission gets a 'submitted' row (task_id + op + prompt/title) and every completion a 'clip' row PER CLIP Suno returns — including the second clip we don't download — so task_id ↔ suno_id ↔ filename is always recoverable from disk. Born 2026-07-17 after ~150 pre-07-14 tracks lost their task ids forever (container logs rotate, metadata never stored them, sunoapi.org has no listing API). Rows may duplicate across the callback and poller workers — dedupe on read, never on write. Fire-and-forget. """ try: row = {'ts': datetime.now(timezone.utc).isoformat(timespec='seconds'), 'event': event} row.update({k: v for k, v in fields.items() if v not in (None, '')}) with open(GENERATED_MUSIC_DIR / 'suno-task-log.jsonl', 'a') as f: f.write(json.dumps(row) + '\n') except Exception: # never let telemetry touch the generation path pass def _schedule_lyrics_fetch(task_id: str, audio_id: str, filename: str, lyrics: str = '', instrumental: bool = False, kind: str = 'song') -> None: """LYRICS-AT-GENERATION (2026-10-12): pull Suno word timings ONCE, the moment a song completes, in the background. Called from BOTH completion legs (status poll + webhook); services/suno_lyrics.py's per-audioId marker makes the second leg a no-op. Every get-timestamped-lyrics call is paid — see that module's header.""" try: suno_lyrics.schedule(task_id=task_id, audio_id=audio_id, filename=filename, lyrics=lyrics or '', instrumental=bool(instrumental), kind=kind, api_key=SUNO_API_KEY, receipt=_provider_receipt) except Exception as exc: # never let this touch song delivery logger.warning(f'suno lyrics scheduling failed for {filename}: {exc}') # --------------------------------------------------------------------------- # SUNO-REJECT-SURFACED (2026-09-29) — a generation refused AT SUBMIT must reach the agent. # # failed_songs_queue only ever received failures AFTER Suno accepted a task (status poll + # callback). A refusal at submit — `{"code":429,"msg":"The current credits are insufficient"}` # wrapped in HTTP 200, a missing key, a network error — returned {'action':'error'} to the # browser, which flashed a 5-second banner and nothing else. Measured 2026-09-28 03:50Z on # tenant-g: the agent told a client his son's song would be ready in "45 seconds", Suno refused # it for credits, and neither the agent nor the client was ever told. No task-log row either. # This hook sits on the blueprint so EVERY submit path (including ones added later) is covered # without each handler remembering to call it. # --------------------------------------------------------------------------- _GENERATION_ACTIONS = frozenset({ 'generate', 'jingle', 'sfx', 'extend', 'cover', 'add_vocals', 'add_instrumental', 'replace_section', 'voice_generate', }) _FAILED_QUEUE_MAX = 10 def _record_submit_rejection(kind: str, reason: str, title: str = '', brand: str = '', prompt: str = '') -> None: """Queue a submit-time refusal for the agent's next turn + write a durable task-log row.""" entry = { 'job_id': f'submit-{uuid.uuid4().hex[:12]}', 'stage': 'submit', 'kind': kind, 'brand': brand, 'title': title, 'reason': reason, 'failed_at': datetime.now().isoformat(), } with _suno_lock: failed_songs_queue.append(entry) del failed_songs_queue[:-_FAILED_QUEUE_MAX] _task_log('rejected', op=kind, title=title, brand=brand, prompt=(prompt or '')[:300], reason=(reason or '')[:300]) logger.warning(f'Suno {kind} refused at submit: {reason[:200]}') def _surface_submit_rejection(response): try: if request.path != '/api/suno': return response body = (request.get_json(silent=True) or {}) if request.method == 'POST' else {} action = body.get('action') or request.args.get('action', 'list') if action not in _GENERATION_ACTIONS: return response data = response.get_json(silent=True) if not isinstance(data, dict) or data.get('action') != 'error': return response def _field(k: str) -> str: return str(body.get(k) or request.args.get(k) or '') _record_submit_rejection(action, str(data.get('response') or 'refused'), title=_field('title'), brand=_field('brand'), prompt=_field('prompt')) except Exception as exc: # telemetry must never break the response logger.debug(f'submit-rejection surfacing skipped: {exc}') return response # --------------------------------------------------------------------------- # Jingle style presets — proven 2026-05-05. The recipe is: # customMode: false # instrumental: false # model: V5 # prompt: " vocal logo jingle, , [Intro" # The truncated "[Intro" (no closing bracket) signals "this is just an intro" # so Suno renders ~10-15s instead of a full song. # --------------------------------------------------------------------------- JINGLE_STYLE_PRESETS = { # `keyword` = 1-3 word adjective inserted before "vocal logo jingle" in v4 recipe. # Minimal style signal that adds variety without lengthening the prompt enough # to break the truncated `, [Intro` short-form trigger. 'rock': {'keyword': 'rock'}, 'country': {'keyword': 'country'}, 'cinematic': {'keyword': 'cinematic trailer'}, 'gospel': {'keyword': 'gospel'}, 'lofi-hiphop': {'keyword': 'lo-fi hip hop'}, 'jazz': {'keyword': 'jazz'}, 'edm': {'keyword': 'EDM festival'}, 'bluegrass': {'keyword': 'bluegrass'}, 'synthwave': {'keyword': '80s synthwave'}, 'latin': {'keyword': 'Latin bossa'}, 'reggae': {'keyword': 'reggae'}, 'gritty-blues': {'keyword': 'delta blues'}, 'orchestral': {'keyword': 'epic orchestral'}, 'punk': {'keyword': 'punk rock'}, 'rnb': {'keyword': 'R&B'}, } _JINGLE_GENDER_WORD = {'m': 'male', 'f': 'female'} def _is_safe_download_url(url: str) -> bool: """Reject URLs that point to private/reserved IP ranges (SSRF protection).""" try: parsed = urlparse(url) hostname = parsed.hostname if not hostname or parsed.scheme not in ('http', 'https'): return False # Resolve hostname to IP and check if private/reserved for info in socket.getaddrinfo(hostname, parsed.port or 443, proto=socket.IPPROTO_TCP): addr = info[4][0] ip = ipaddress.ip_address(addr) if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local: logger.warning(f'SSRF blocked: {url} resolves to private IP {addr}') return False return True except (ValueError, socket.gaierror, OSError) as exc: logger.warning(f'SSRF check failed for {url}: {exc}') return False # --------------------------------------------------------------------------- # Blueprint # --------------------------------------------------------------------------- suno_bp = Blueprint('suno', __name__) suno_bp.after_request(_surface_submit_rejection) # SUNO-REJECT-SURFACED — see above # --------------------------------------------------------------------------- # Metadata helpers # --------------------------------------------------------------------------- def _load_generated_metadata() -> dict: if GENERATED_METADATA_FILE.exists(): try: with open(GENERATED_METADATA_FILE) as f: return json.load(f) except Exception: return {} return {} def _save_generated_metadata(metadata: dict) -> None: """Persist generated music metadata (atomic write).""" tmp = GENERATED_METADATA_FILE.with_suffix('.tmp') tmp.write_text(json.dumps(metadata, indent=2)) tmp.replace(GENERATED_METADATA_FILE) def _add_song_to_metadata(filename: str, title: str, prompt: str, style: str, duration: float = 0, song_id: str = '', kind: str = 'song', extra: dict = None, task_id: str = '') -> None: """Write a new song entry to generated_metadata.json in the format music.py expects. `kind` is 'song' (full track) or 'jingle' (10-15s logo jingle). `extra` is merged into the entry — used by jingles to record brand, style_key, vocal_gender. """ metadata = _load_generated_metadata() entry = { 'title': title, 'artist': 'Clawdbot AI', 'description': prompt[:200] if prompt else 'AI-generated track', 'genre': _guess_genre(style or prompt), 'energy': 'high', 'duration_seconds': round(duration, 1) if duration else 0, 'fun_facts': [], 'dj_intro_hints': [], 'dj_backstory': f'Generated by Clawdbot from prompt: {prompt[:100]}' if prompt else '', 'made_by': 'Clawdbot', 'created_date': datetime.now().strftime('%Y-%m-%d'), 'suno_id': song_id, 'task_id': task_id, 'kind': kind, } if extra: entry.update(extra) # NEVER lose Suno ids: if a previous entry for this filename already carries a # non-empty task_id / suno_id and the incoming write would blank it, keep the # old value. (2026-07-14: a metadata rewrite path blanked task_id on same-day # tracks, killing vocal-stem separation for them.) prev = metadata.get(filename) or {} for id_key in ('task_id', 'suno_id'): if not entry.get(id_key) and prev.get(id_key): entry[id_key] = prev[id_key] if not entry.get('task_id'): logger.warning(f'_add_song_to_metadata: {filename} written WITHOUT task_id ' f'(song_id={song_id!r}, kind={kind}) — vocal separation will be unavailable') metadata[filename] = entry _save_generated_metadata(metadata) # Durable id ledger — append-only, survives any metadata rewrite. stem_separate # falls back to this when metadata is missing the ids. try: if entry.get('suno_id'): with open(GENERATED_MUSIC_DIR / 'suno-task-log.jsonl', 'a') as _tl: _tl.write(json.dumps({'ts': datetime.now(timezone.utc).isoformat(timespec='seconds'), 'filename': filename, 'title': title, 'task_id': entry.get('task_id', ''), 'suno_id': entry.get('suno_id', ''), 'kind': kind}) + '\n') except Exception: pass def _is_uuid(s: str) -> bool: """Check if string looks like a UUID (hex-hex-hex-hex-hex pattern).""" import re return bool(re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', s.strip(), re.IGNORECASE)) def _slugify_title(title: str) -> str: """Convert a song title to a safe filename slug (no extension).""" import re import unicodedata # If the title is a UUID (Suno sometimes returns song ID as title), reject it if _is_uuid(title): return 'generated-track' # Normalize unicode (e.g., smart quotes → ascii) s = unicodedata.normalize('NFKD', title).encode('ascii', 'ignore').decode('ascii') # Lowercase, replace non-alnum with hyphens, collapse multiples, strip edges s = re.sub(r'[^a-z0-9]+', '-', s.lower()).strip('-') return s[:80] or 'generated-track' def _unique_filename(directory: Path, base: str, ext: str = '.mp3') -> str: """Return a unique filename in directory, appending -2, -3, etc. if needed.""" candidate = f'{base}{ext}' if not (directory / candidate).exists(): return candidate counter = 2 while (directory / f'{base}-{counter}{ext}').exists(): counter += 1 return f'{base}-{counter}{ext}' def _guess_genre(text: str) -> str: """Rough genre guess from prompt keywords.""" if not text: return 'Unknown' t = text.lower() for genre, keywords in [ ('Hip-Hop', ['hip hop', 'hiphop', 'rap', 'trap', 'beats']), ('Electronic', ['electronic', 'edm', 'techno', 'house', 'synth', 'dance']), ('Rock', ['rock', 'metal', 'guitar', 'punk', 'grunge']), ('Pop', ['pop', 'catchy', 'radio', 'chorus']), ('Country', ['country', 'western', 'cowboy', 'twang']), ('Reggae', ['reggae', 'ska', 'dub', 'jamaican']), ('Jazz', ['jazz', 'blues', 'soul', 'funk', 'groove']), ]: if any(kw in t for kw in keywords): return genre return 'Unknown' # --------------------------------------------------------------------------- # Main endpoint # --------------------------------------------------------------------------- @suno_bp.route('/api/suno', methods=['GET', 'POST']) def handle_suno(): """ Unified Suno endpoint. action=generate — Submit a generation job action=status — Poll job status (downloads when ready) action=list — List generated songs action=credits — Check API credits """ if request.method == 'POST': body = request.get_json(silent=True) or {} action = body.get('action') or request.args.get('action', 'list') # Allow POST body params to override query params _q = lambda k, default='': body.get(k) or request.args.get(k, default) else: body = {} action = request.args.get('action', 'list') _q = lambda k, default='': request.args.get(k, default) if not SUNO_API_KEY: return jsonify({'action': 'error', 'response': 'SUNO_API_KEY not configured — add it to .env'}) try: # ------------------------------------------------------------------ if action == 'list': return _action_list() elif action == 'generate': return _action_generate(_q, body) elif action == 'jingle': return _action_jingle(_q, body) elif action == 'sfx': return _action_sfx(_q, body) elif action == 'list_jingles': return _action_list_jingles() elif action == 'jingle_styles': return jsonify({ 'action': 'jingle_styles', 'styles': sorted(JINGLE_STYLE_PRESETS.keys()), 'previews': {k: v['keyword'] for k, v in JINGLE_STYLE_PRESETS.items()}, }) elif action == 'status': return _action_status(_q('job_id') or _q('song_id')) elif action == 'credits': return _action_credits() # --- Studio editing / processing actions (2026-06-25) --------------- elif action == 'extend': return _action_extend(_q, body) elif action == 'cover': return _action_cover(_q, body) elif action == 'add_vocals': return _action_add_vocals(_q, body) elif action == 'add_instrumental': return _action_add_instrumental(_q, body) elif action == 'replace_section': return _action_replace_section(_q, body) elif action == 'generate_lyrics': return _action_generate_lyrics(_q, body) elif action == 'timestamped_lyrics': return _action_timestamped_lyrics(_q, body) elif action == 'wav_convert': return _action_wav_convert(_q, body) elif action == 'stem_separate': return _action_stem_separate(_q, body) elif action == 'stem_result': return _action_stem_result(_q, body) elif action == 'style_boost': return _action_style_boost(_q, body) elif action == 'music_video': return _action_music_video(_q, body) elif action == 'song_details': return _action_song_details(_q, body) # --- Custom voice cloning + persona + file hosting (2026-07-14) ------- elif action == 'generate_persona': return _action_generate_persona(_q, body) elif action == 'voice_validate': return _action_voice_validate(_q, body) elif action == 'voice_validate_info': return _action_voice_validate_info(_q, body) elif action == 'voice_generate': return _action_voice_generate(_q, body) elif action == 'voice_info': return _action_voice_info(_q, body) elif action == 'voice_check': return _action_voice_check(_q, body) elif action == 'file_upload': return _action_file_upload(_q, body) else: return jsonify({'action': 'error', 'response': f"Unknown action '{action}'. Use: generate, jingle, sfx, list_jingles, jingle_styles, status, list, credits, extend, cover, add_vocals, add_instrumental, replace_section, generate_lyrics, timestamped_lyrics, wav_convert, stem_separate, stem_result, style_boost, music_video, song_details, generate_persona, voice_validate, voice_validate_info, voice_generate, voice_info, voice_check, file_upload"}) except Exception as exc: logger.exception('Suno endpoint error') return jsonify({'action': 'error', 'response': f'Suno error: {exc}'}), 500 # --------------------------------------------------------------------------- # Action handlers # --------------------------------------------------------------------------- def _action_list(): """Return all generated songs with metadata.""" metadata = _load_generated_metadata() songs = [] for f in sorted(GENERATED_MUSIC_DIR.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True): if f.suffix.lower() in {'.mp3', '.wav', '.ogg', '.m4a'}: meta = metadata.get(f.name, {}) songs.append({ 'filename': f.name, 'title': meta.get('title', f.stem), 'genre': meta.get('genre', 'Unknown'), 'description': meta.get('description', ''), 'duration_seconds': meta.get('duration_seconds', 0), 'made_by': meta.get('made_by', 'Clawdbot'), 'created_date': meta.get('created_date', ''), 'url': f'/generated_music/{f.name}', 'size_bytes': f.stat().st_size, # Sing-along support: plain lyrics for estimated timing, and # whether Suno per-word timestamps are fetchable (needs both ids). 'lyrics': meta.get('lyrics', ''), 'has_word_sync': bool(meta.get('task_id') and meta.get('suno_id')), }) return jsonify({ 'action': 'list', 'count': len(songs), 'songs': songs, 'response': f'Got {len(songs)} AI-generated tracks in the vault!', }) def _action_generate(_q, body: dict): """Submit a song generation job to Suno API.""" prompt = _q('prompt') or body.get('prompt', '') style = _q('style') or body.get('style', '') title = _q('title') or body.get('title', '') lyrics = _q('lyrics') or body.get('lyrics', '') # str(): a JSON POST carries a real bool, and True.lower() raised AttributeError (-> 500). instrumental = str(_q('instrumental') or body.get('instrumental', 'false')).lower() in ('true', '1', 'yes') vocal_gender = _q('vocal_gender') or body.get('vocal_gender', 'm') # Persona / custom-voice passthrough (2026-07-14). personaId can be a STYLE # persona (from generate-persona) or a cloned VOICE id (from the Suno Voice # API). personaModel: 'style_persona' (default) or 'voice_persona' (a cloned # voice — REQUIRES custom mode, i.e. lyrics). Accept snake_case or camelCase. persona_id = (_q('persona_id') or body.get('persona_id') or body.get('personaId') or '').strip() persona_model = (_q('persona_model') or body.get('persona_model') or body.get('personaModel') or '').strip() # Voice-track flag: the frontend [SUNO_GENERATE:...|voice] tag (or voice=1) # asks to sing in THIS tenant's cloned voice. The voiceId lives server-side # in the active profile (voice_persona_id) so the browser never handles it — # we attach it here. This lets a voice track ride the normal frontend popup / # player / "your track is ready" flow instead of a bare direct API call. use_voice = str(_q('voice') or body.get('voice', '')).lower() in ('1', 'true', 'yes') if use_voice and not persona_id: try: import json as _jv from routes.profiles import _active_profile_id as _apid _pf = f'/app/runtime/profiles/{_apid}.json' if os.path.exists(_pf): _pc = _jv.load(open(_pf)) _vpid = (_pc.get('voice_persona_id') or (_pc.get('conversation') or {}).get('voice_persona_id') or '').strip() if _vpid: persona_id = _vpid persona_model = 'voice_persona' logger.info(f'Suno generate: voice flag → profile {_apid} voiceId {_vpid[:12]}…') except Exception as _ve: logger.warning(f'Suno voice-flag profile lookup failed: {_ve}') if not prompt and not lyrics and not style: return jsonify({'action': 'error', 'response': 'Need a prompt, lyrics, or style — tell me what kind of song to make.'}) # Determine mode: custom (explicit lyrics) vs description (Suno writes lyrics) if lyrics: song_prompt = lyrics has_lyrics = True elif (use_voice or persona_model == 'voice_persona') and prompt: # Voice track: the prompt IS the lyrics to sing in the cloned voice — go # straight to custom mode (do NOT munge it into a description). Lets the # frontend tag pass plain-line lyrics (structural [Verse] tags can't ride # the [SUNO_GENERATE:...] tag — bracket collision — so plain lines only). song_prompt = prompt has_lyrics = True elif '[Verse' in prompt or '[Chorus' in prompt or '[Hook' in prompt or '[Bridge' in prompt: song_prompt = prompt has_lyrics = True else: # Description mode — Suno auto-generates lyrics from the description combined = f'{style}. {prompt}' if style and prompt else (style or prompt) song_prompt = combined[:500] has_lyrics = False # Build Suno API request if has_lyrics: request_body = { 'prompt': song_prompt, 'customMode': True, 'instrumental': instrumental, 'model': 'V5_5', 'vocalGender': vocal_gender, 'negativeTags': 'low quality, mumbling, distorted, off-key', 'style': style or 'Catchy, Radio-friendly, Professional', } if title: request_body['title'] = title else: request_body = { 'prompt': song_prompt, 'customMode': False, 'instrumental': instrumental, 'model': 'V5_5', 'vocalGender': vocal_gender, } # Attach persona / cloned-voice if provided. voice_persona only works in # custom mode — if a cloned voice was requested in description mode, promote # to custom mode (song_prompt then acts as the lyrics) so the voiceId is honored. if persona_id: request_body['personaId'] = persona_id request_body['personaModel'] = persona_model or 'style_persona' if request_body['personaModel'] == 'voice_persona' and not request_body.get('customMode'): request_body['customMode'] = True logger.info(f'Suno generate: persona {request_body["personaModel"]} id={persona_id[:12]}…') if SUNO_CALLBACK_URL: request_body['callBackUrl'] = SUNO_CALLBACK_URL logger.info(f'Suno generate: mode={"custom" if has_lyrics else "auto"} prompt={song_prompt[:80]}') try: resp = http_requests.post( f'{SUNO_API_BASE}/api/v1/generate', headers={'Authorization': f'Bearer {SUNO_API_KEY}', 'Content-Type': 'application/json'}, json=request_body, timeout=30, ) logger.info(f'Suno API response: {resp.status_code} {resp.text[:300]}') if resp.status_code == 200: data = resp.json() if data.get('code') == 200 and data.get('data', {}).get('taskId'): task_id = data['data']['taskId'] job_id = str(uuid.uuid4()) suno_jobs[job_id] = { 'status': 'generating', 'prompt': prompt, 'title': title, 'style': style, 'task_id': task_id, 'created_at': time.time(), # Read at completion: an instrumental gets NO paid lyrics call. 'instrumental': instrumental, } _provider_receipt('song') # live Watch pulse — task accepted, credits committed _task_log('submitted', op='song', task_id=task_id, title=title, style=style, prompt=(prompt or '')[:300]) return jsonify({ 'action': 'generating', 'job_id': job_id, 'task_id': task_id, 'response': f"Cooking! '{title or 'your track'}' is being generated — check back in 30-60 seconds.", 'estimated_seconds': 45, }) else: return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) else: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) def _action_sfx(_q, body: dict): """Generate a short non-vocal sound effect / ambient stinger. Wraps sunoapi.org's "Sounds Generation (V5)" endpoint (POST /api/v1/generate/sounds, 2.5 credits). This is NOT a jingle or a song — it produces game SFX, UI blips, stingers, ambient beds, etc. with no vocals. Returns a taskId in the same shape as /generate, so the normal `action=status` poller downloads + saves the clip when ready. Inputs: prompt — required, what the sound should be (max 500 chars). e.g. "retro 8-bit coin pickup blip", "wooden mallet thwack", "ominous low brass sting", "arcade game-over jingle no vocals" title — optional label used for the saved filename + metadata. loop — optional bool; soundLoop (seamless looping bed). Default false. tempo — optional int 1-300; soundTempo (BPM). Omit for auto. key — optional musical key (Any, Cm, C#m, ... B). Omit for Any. """ prompt = (_q('prompt') or body.get('prompt', '')).strip() if not prompt: return jsonify({'action': 'error', 'response': "Need a description of the sound — e.g. 'retro 8-bit coin pickup blip'."}) prompt = prompt[:500] title = (_q('title') or body.get('title', '')).strip() loop_raw = _q('loop') or body.get('loop', False) if isinstance(loop_raw, bool): sound_loop = loop_raw else: sound_loop = str(loop_raw).lower() in ('true', '1', 'yes') request_body = { 'prompt': prompt, # V5_5 matches the proven story.py path (routes/story.py gen_suno_sound, # shipped 2026-05-28). sunoapi.org docs say "V5 only" for this endpoint # but production uses V5_5 successfully with better quality — match it. 'model': 'V5_5', 'soundLoop': sound_loop, } # Optional tempo (BPM 1-300) tempo_raw = _q('tempo') or body.get('tempo', '') if tempo_raw not in (None, ''): try: tempo = int(tempo_raw) if 1 <= tempo <= 300: request_body['soundTempo'] = tempo except (TypeError, ValueError): pass # Optional musical key key = (_q('key') or body.get('key', '')).strip() if key and key.lower() != 'any': request_body['soundKey'] = key # NOTE: deliberately NO callBackUrl for SFX. SFX complete via polling # (action=status), which routes them to the sounds subdir and keeps them out # of the music library. The webhook callback path registers results as music, # so skipping it prevents SFX from leaking into the music player. logger.info(f'Suno sfx: loop={sound_loop} prompt={prompt[:80]}') try: resp = http_requests.post( f'{SUNO_API_BASE}/api/v1/generate/sounds', headers={'Authorization': f'Bearer {SUNO_API_KEY}', 'Content-Type': 'application/json'}, json=request_body, timeout=30, ) logger.info(f'Suno sfx response: {resp.status_code} {resp.text[:300]}') if resp.status_code == 200: data = resp.json() if data.get('code') == 200 and data.get('data', {}).get('taskId'): task_id = data['data']['taskId'] job_id = str(uuid.uuid4()) suno_jobs[job_id] = { 'status': 'generating', 'prompt': prompt, 'title': title or prompt[:60], 'style': 'sfx', 'kind': 'sfx', 'task_id': task_id, 'created_at': time.time(), } _provider_receipt('sfx') # live Watch pulse — task accepted, credits committed _task_log('submitted', op='sfx', task_id=task_id, title=title or prompt[:60], prompt=(prompt or '')[:300]) return jsonify({ 'action': 'generating', 'job_id': job_id, 'task_id': task_id, 'kind': 'sfx', 'response': f"Generating sound: '{title or prompt[:40]}' — check back in ~20-40 seconds.", 'estimated_seconds': 30, }) else: return jsonify({'action': 'error', 'response': f"Suno SFX error: {data.get('msg', 'Unknown error')}"}) else: return jsonify({'action': 'error', 'response': f'Suno SFX HTTP {resp.status_code}: {resp.text[:200]}'}) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) # ── JINGLE LENGTH HONESTY (the host agent 2026-09-06, Mike's request) ────────────── # Suno's jingle recipe is NOT length-deterministic. Measured over the whole task # log: 13 jingle requests -> 72 generations, only 16 came back <=20s. That is a # 22% hit rate, with the SAME parameters returning 9.2s and 167.8s on adjacent # attempts. The skill doc's own row calls this recipe "v1 untested". # # The defect was ours, not Suno's: nothing checked the length, so a 44s and a # 139s clip were both saved and announced as finished jingles. Mike had to be the # duration check himself, by listening, four times in a row. A system that # reports success on output that misses the spec it was handed is the same # failure as a form POST returning 200 on a lead it never stored. # # We do NOT auto-retry — each attempt costs credits, and at a 22% hit rate that # is ~4 generations per usable jingle. We tell the truth and OFFER. JINGLE_TARGET_MAX_S = 20.0 def _jingle_length_note(kind: str, duration, title: str) -> str: """Return '' when the clip is a usable jingle, else an honest note + retry offer.""" if kind != 'jingle': return '' try: secs = float(duration or 0) except (TypeError, ValueError): return '' if secs <= 0 or secs <= JINGLE_TARGET_MAX_S: return '' return (f" Heads up: this one came back {secs:.0f}s, longer than the short jingle" f" you asked for. Suno doesn't always hit an exact length — it's a bit of a" f" lottery on short clips. Want me to try again?") def _action_jingle(_q, body: dict): """Generate a 10-15 second vocal-logo jingle of a brand name. Uses the proven recipe (2026-05-05): customMode=false, instrumental=false, model=V5, prompt ending in literal `, [Intro` (truncated half-tag) — that suffix tells Suno "this is just an intro" so it stops at ~10-15s instead of rendering a full song. Inputs: brand — required, the brand/company name to be sung style — preset key (see JINGLE_STYLE_PRESETS) OR freetext descriptor. Random preset chosen if omitted. vocal_gender — m | f (default m). Ignored if instrumental=true. instrumental — true to render the jingle as an instrumental audio-logo. """ import random brand = (_q('brand') or body.get('brand', '')).strip() if not brand: return jsonify({'action': 'error', 'response': "Need a brand/company name to sing."}) style_in = (_q('style') or body.get('style', '')).strip() vocal_gender = (_q('vocal_gender') or body.get('vocal_gender', 'm')).lower() if vocal_gender not in ('m', 'f'): vocal_gender = 'm' instrumental_raw = _q('instrumental') or body.get('instrumental', False) if isinstance(instrumental_raw, bool): instrumental = instrumental_raw else: instrumental = str(instrumental_raw).lower() in ('true', '1', 'yes') # repeat = how many times the brand is sung (1 = shorter ~8-12s, 2 = ~12-15s, default 2) repeat_raw = _q('repeat') or body.get('repeat', 2) try: repeat = max(1, min(3, int(repeat_raw))) except (TypeError, ValueError): repeat = 2 # Resolve style: preset key, freetext, random preset, or BARE (no style at all). # `bare` flag forces zero style insertion — produces shortest-possible output. bare_raw = _q('bare') or body.get('bare', False) bare = bool(bare_raw) if isinstance(bare_raw, bool) else str(bare_raw).lower() in ('true', '1', 'yes') style_key = '' if bare: style_in = '' # force empty so style_keyword resolves to '' elif style_in and style_in.lower() in JINGLE_STYLE_PRESETS: style_key = style_in.lower() elif not style_in: style_key = random.choice(list(JINGLE_STYLE_PRESETS.keys())) # Resolve short style keyword (1-3 words). For freetext, use as-is but trim hard. if style_key: style_keyword = JINGLE_STYLE_PRESETS[style_key]['keyword'] else: # freetext — strip to first ~3 words to keep the prompt minimal style_keyword = ' '.join(style_in.split()[:3]) if style_in else '' style_descriptor = style_keyword # for metadata logging # Recipe v4 (2026-05-05): minimalist — matches Mike's verified-working pattern from # 2026-05-05 with a single short style keyword inserted before "vocal logo jingle". # The truncated `, [Intro` (no closing bracket) signals "this is just an intro" so # Suno renders 10-15s instead of a full song. Recipe history: # v1 — multi-clause style descriptor → 30-141s (too long, descriptor inflated output) # v2 — customMode=true skeleton lyrics → 531 refunded (lyrics too short) # v3 — customMode=false with inline [Intro]...[End] block → 400 malformed # v4 — minimal keyword + Mike's exact `, [Intro` truncation negative_tags = 'verse, chorus, full song, long intro, padding, extended outro, lengthy, repeat hook, second verse' # v5 uses model V5 (not V5.5) — V5.5 tends to render fuller/longer outputs; # V5 hits the 10-15s short-form sweet spot more reliably with the truncated # `, [Intro` trick. # Brand-repeat: 1× sends the brand once (shortest), 2× duplicates it (slightly # longer), 3× three times. Each extra brand mention adds brand-word-count words # to the prompt — keep total ≤5 words for the [Intro recipe to hold (verified # 2026-05-07: prompt ≤5 words = 10-30s output, ≥7 = 60s+). brand_phrase = ' '.join([brand] * max(1, repeat)) if instrumental: kw_phrase = f'{style_keyword} ' if style_keyword else '' jingle_prompt = f'{brand_phrase} {kw_phrase}instrumental audio logo, [Intro' request_body = { 'prompt': jingle_prompt, 'customMode': False, 'instrumental': True, 'model': 'V5', 'negativeTags': negative_tags + ', vocals, lyrics, singing', } else: kw_phrase = f'{style_keyword} ' if style_keyword else '' jingle_prompt = f'{brand_phrase} {kw_phrase}vocal logo jingle, [Intro' request_body = { 'prompt': jingle_prompt, 'customMode': False, 'instrumental': False, 'model': 'V5', 'vocalGender': vocal_gender, 'negativeTags': negative_tags, } if SUNO_CALLBACK_URL: request_body['callBackUrl'] = SUNO_CALLBACK_URL logger.info(f'Suno jingle: brand={brand!r} style_key={style_key!r} ' f'gender={vocal_gender} instrumental={instrumental} repeat={repeat} ' f'prompt={jingle_prompt[:120]!r}') try: resp = http_requests.post( f'{SUNO_API_BASE}/api/v1/generate', headers={'Authorization': f'Bearer {SUNO_API_KEY}', 'Content-Type': 'application/json'}, json=request_body, timeout=30, ) logger.info(f'Suno jingle API response: {resp.status_code} {resp.text[:300]}') if resp.status_code != 200: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200 or not data.get('data', {}).get('taskId'): return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) task_id = data['data']['taskId'] job_id = str(uuid.uuid4()) suno_jobs[job_id] = { 'status': 'generating', 'prompt': jingle_prompt, 'title': f'{brand} Jingle', 'style': style_descriptor, 'task_id': task_id, 'created_at': time.time(), 'kind': 'jingle', 'brand': brand, 'style_key': style_key, 'vocal_gender': vocal_gender, 'instrumental': instrumental, 'repeat': repeat, } _provider_receipt('jingle') # live Watch pulse — task accepted, credits committed _task_log('submitted', op='jingle', task_id=task_id, title=f'{brand} Jingle', style=style_descriptor, prompt=(jingle_prompt or '')[:300]) return jsonify({ 'action': 'generating', 'job_id': job_id, 'task_id': task_id, 'kind': 'jingle', 'brand': brand, 'style_key': style_key, 'response': f"Cooking your '{brand}' jingle ({style_key or 'custom'}) — ready in ~45-60s.", 'estimated_seconds': 45, }) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) def _action_list_jingles(): """Return only generations tagged kind=jingle, newest first.""" metadata = _load_generated_metadata() jingles = [] for f in sorted(GENERATED_MUSIC_DIR.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True): if f.suffix.lower() not in {'.mp3', '.wav', '.ogg', '.m4a'}: continue meta = metadata.get(f.name, {}) if meta.get('kind') != 'jingle': continue jingles.append({ 'filename': f.name, 'title': meta.get('title', f.stem), 'brand': meta.get('brand', ''), 'style_key': meta.get('style_key', ''), 'vocal_gender': meta.get('vocal_gender', ''), 'instrumental': meta.get('instrumental', False), 'duration_seconds': meta.get('duration_seconds', 0), 'created_date': meta.get('created_date', ''), 'description': meta.get('description', ''), 'url': f'/generated_music/{f.name}', 'size_bytes': f.stat().st_size, }) return jsonify({ 'action': 'list_jingles', 'count': len(jingles), 'jingles': jingles, 'response': f'Got {len(jingles)} jingles in the vault.', }) def _action_status(job_id: str): """Poll generation status; downloads song when Suno reports SUCCESS.""" if not job_id: # Return status of most recent job if suno_jobs: job_id = max(suno_jobs.keys()) else: return jsonify({'action': 'status', 'status': 'no_jobs', 'response': 'No songs cooking right now.'}) if job_id not in suno_jobs: return jsonify({'action': 'status', 'status': 'not_found', 'response': "Can't find that job."}) job = suno_jobs[job_id] # WAV / video / lyrics jobs produce a file or text, not a song: their result # lives behind a different record-info endpoint (see _PROCESS_RESULTS). if job.get('kind') in _PROCESS_RESULTS: return _process_status(job_id, job) # If already complete, just report if job.get('status') == 'complete': return jsonify({ 'action': 'complete', 'status': 'complete', 'job_id': job_id, 'song_id': job.get('song_id', ''), 'title': job.get('title', 'Generated Track'), 'url': job.get('url', ''), 'response': (f"Done! '{job.get('title', 'your track')}' is ready to spin!" + _jingle_length_note(job.get('kind', 'song'), job.get('duration', 0), job.get('title', 'your track'))), }) elapsed = time.time() - job['created_at'] # Don't bother polling Suno for the first 20 seconds if elapsed < 20: return jsonify({ 'action': 'status', 'status': 'generating', 'elapsed_seconds': int(elapsed), 'response': f'Still cooking — about {max(0, 30 - int(elapsed))} more seconds...', }) task_id = job.get('task_id') if not task_id: return jsonify({'action': 'status', 'status': 'generating', 'elapsed_seconds': int(elapsed), 'response': 'Generating...'}) try: check = http_requests.get( f'{SUNO_API_BASE}/api/v1/generate/record-info', headers={'Authorization': f'Bearer {SUNO_API_KEY}'}, params={'taskId': task_id}, timeout=15, ) logger.debug(f'Suno status check: {check.status_code} {check.text[:300]}') if check.status_code == 200: cdata = check.json() if cdata.get('code') == 200: status_data = cdata.get('data', {}) gen_status = status_data.get('status', '') if gen_status == 'SUCCESS': songs = status_data.get('response', {}).get('sunoData', []) # Durable ledger: record EVERY clip id (incl. the second one # we don't download) so task_id ↔ suno_id never gets lost. for _clip in songs: _task_log('clip', task_id=task_id, suno_id=_clip.get('id', ''), title=_clip.get('title', ''), duration=_clip.get('duration', 0), leg='poller') # Suno returns 2 clips per generation — only take the first one songs = songs[:1] if songs else [] for song in songs: # sourceAudioUrl is the original/high-quality URL the # sounds endpoint returns (see routes/story.py); kept as # a fallback so SFX jobs download correctly. Additive — # songs/jingles still prefer audioUrl, unchanged. audio_url = song.get('audioUrl') or song.get('audio_url') or song.get('sourceAudioUrl') if not audio_url: continue song_id = song.get('id', task_id) _raw_title = song.get('title') or '' # Suno API sometimes returns song ID as title — reject UUIDs if _raw_title and not _is_uuid(_raw_title): song_title = _raw_title else: song_title = job.get('title') or job.get('prompt', '')[:60] or 'Generated Track' duration = song.get('duration', 0) slug = _slugify_title(song_title) # SFX go to the dedicated sounds subdir, separate from music. _is_sfx = job.get('kind') == 'sfx' _dir = GENERATED_SOUNDS_DIR if _is_sfx else GENERATED_MUSIC_DIR _url_base = '/generated_music/sfx' if _is_sfx else '/generated_music' # Cross-worker dedupe: the webhook callback and this poller # both download "the first clip" from separate gunicorn # workers (suno_jobs is per-worker memory), and # _unique_filename never reuses a name — so the second # writer saved the SAME clip again as -2.mp3. # The shared metadata file is the cross-worker truth: # if this suno_id is already saved, reuse that file. filename = '' if song_id and not _is_sfx: filename = next( (fn for fn, md in _load_generated_metadata().items() if isinstance(md, dict) and md.get('suno_id') == song_id and (_dir / fn).exists()), '') if not filename: filename = _unique_filename(_dir, slug) save_path = _dir / filename if not save_path.exists(): if not _is_safe_download_url(audio_url): continue audio_resp = http_requests.get(audio_url, timeout=60, stream=True) if audio_resp.status_code == 200: content_length = int(audio_resp.headers.get('Content-Length', 0)) if content_length > SUNO_MAX_DOWNLOAD_BYTES: logger.warning(f'Suno download rejected: Content-Length {content_length} exceeds limit') continue chunks = [] total = 0 for chunk in audio_resp.iter_content(chunk_size=65536): total += len(chunk) if total > SUNO_MAX_DOWNLOAD_BYTES: logger.warning(f'Suno download aborted: exceeded {SUNO_MAX_DOWNLOAD_BYTES} bytes') break chunks.append(chunk) else: save_path.write_bytes(b''.join(chunks)) logger.info(f'Suno downloaded: {song_title} → {filename}') else: logger.warning(f'Suno download failed: {audio_resp.status_code}') continue kind = job.get('kind', 'song') song_lyrics = song.get('prompt', '') or song.get('lyrics', '') # SFX are NOT music — keep them out of the music metadata # AND the music player's completed-songs queue so they # don't pollute the music library. The status response # below still returns the URL so the caller gets the clip. if not _is_sfx: extra = {} if kind == 'jingle': extra = { 'brand': job.get('brand', ''), 'style_key': job.get('style_key', ''), 'vocal_gender': job.get('vocal_gender', ''), 'instrumental': job.get('instrumental', False), } if song_lyrics: extra['lyrics'] = song_lyrics _add_song_to_metadata( filename=filename, title=song_title, prompt=job.get('prompt', ''), style=job.get('style', ''), duration=duration, song_id=song_id, kind=kind, extra=extra, task_id=task_id, ) # audioId is the clip's own id — never the task_id fallback. _schedule_lyrics_fetch(task_id, song.get('id', ''), filename, lyrics=song_lyrics, instrumental=job.get('instrumental', False), kind=kind) # Update job job['status'] = 'complete' job['song_id'] = song_id job['title'] = song_title job['url'] = f'{_url_base}/{filename}' # Notify frontend poller — music only (SFX skip the music queue) if not _is_sfx: completed_songs_queue.append({ 'song_id': song_id, 'filename': filename, 'title': song_title, 'job_id': job_id, 'kind': kind, 'url': f'{_url_base}/{filename}', 'completed_at': datetime.now().isoformat(), 'prompt': job.get('prompt', ''), 'lyrics': song_lyrics, }) return jsonify({ 'action': 'complete', 'status': 'complete', 'job_id': job_id, 'song_id': song_id, 'title': song_title, 'url': f'{_url_base}/{filename}', 'response': (f"Done! '{song_title}' is ready to spin!" + _jingle_length_note(kind, duration, song_title)), 'duration_seconds': round(float(duration or 0), 1), }) return jsonify({'action': 'status', 'status': 'complete_no_audio', 'response': 'Song generated but audio unavailable.'}) elif gen_status in ('PENDING', 'TEXT_SUCCESS', 'FIRST_SUCCESS'): return jsonify({ 'action': 'status', 'status': 'generating', 'elapsed_seconds': int(elapsed), 'response': f'Still cooking ({gen_status})...', }) else: # Anything not in (SUCCESS, PENDING, TEXT_SUCCESS, FIRST_SUCCESS) is a failure reason = status_data.get('errorMessage') or status_data.get('msg') or gen_status or 'generation failed' job['status'] = 'failed' job['error'] = reason failed_songs_queue.append({ 'job_id': job_id, 'task_id': task_id, 'kind': job.get('kind', 'song'), 'brand': job.get('brand', ''), 'title': job.get('title', ''), 'reason': reason, 'failed_at': datetime.now().isoformat(), }) logger.warning(f'Suno job {job_id} failed: {reason}') return jsonify({ 'action': 'failed', 'status': 'failed', 'job_id': job_id, 'kind': job.get('kind', 'song'), 'reason': reason, 'response': f"Suno couldn't make the song: {reason}", }) except Exception as exc: logger.warning(f'Suno status poll error: {exc}') return jsonify({ 'action': 'status', 'status': 'generating', 'elapsed_seconds': int(elapsed), 'response': f'Still working... ({int(elapsed)}s elapsed)', }) def _action_credits(): """Check remaining Suno API credits.""" try: # sunoapi.org's credits endpoint is /api/v1/generate/credit and answers # {"code": 200, "data": <number>}. The old /api/v1/account/credits path is a 404, # so this check always reported an error (measured 2026-10-12). resp = http_requests.get( f'{SUNO_API_BASE}/api/v1/generate/credit', headers={'Authorization': f'Bearer {SUNO_API_KEY}'}, timeout=10, ) if resp.status_code == 200: data = resp.json() if isinstance(data, dict) and data.get('code') not in (None, 200): return jsonify({'action': 'error', 'response': f"Credits check failed: {data.get('msg') or data.get('code')}"}) d = data.get('data') if isinstance(data, dict) else None credits = d.get('credits', '?') if isinstance(d, dict) else (d if d is not None else '?') return jsonify({'action': 'credits', 'credits': credits, 'response': f'Suno credits remaining: {credits}'}) return jsonify({'action': 'error', 'response': f'Credits check failed: HTTP {resp.status_code}'}) except Exception as exc: return jsonify({'action': 'error', 'response': f'Credits check error: {exc}'}) # --------------------------------------------------------------------------- # Studio editing / processing helpers + handlers (2026-06-25) # # These wrap sunoapi.org edit/process endpoints (extend, cover, add vocals, # add/strip instrumental, replace section, lyrics, timestamped lyrics, WAV, # stem separation, style boost, music video, song details). Endpoint paths # confirmed against docs.sunoapi.org (2026-06-25). They follow the same # job-tracking pattern as _action_generate: POST → store taskId in suno_jobs → # the caller polls action=status. Operations that produce audio (extend, cover, # add_vocals, add_instrumental, replace_section) return a taskId in the standard # /generate shape, so the existing _action_status poller downloads + saves them. # # Identity resolution: most edit/process endpoints need a Suno {taskId, audioId} # pair. We resolve these from generated_metadata.json (suno_id == audioId, # task_id == parent taskId, persisted since 2026-06-25). Upload-based endpoints # (cover, add_vocals, add_instrumental) instead take a public uploadUrl, which # we build from the song's web URL via PUBLIC_BASE_URL/DOMAIN. # --------------------------------------------------------------------------- # Public base used to build absolute uploadUrl values that sunoapi.org can fetch. _PUBLIC_BASE_URL = ( os.environ.get('PUBLIC_BASE_URL') or (f'https://{_domain}' if _domain else '') ).rstrip('/') def _suno_headers() -> dict: return {'Authorization': f'Bearer {SUNO_API_KEY}', 'Content-Type': 'application/json'} def _resolve_song(_q, body: dict) -> dict: """Resolve a song reference from request params into a normalized dict. Accepts any of: filename, audio_id (suno_id), task_id, song_url. Returns {filename, audio_id, task_id, title, style, lyrics, upload_url, meta}. Looks up generated_metadata.json by filename when available to backfill audio_id / task_id / title / style / lyrics. """ filename = (_q('filename') or body.get('filename', '')).strip() audio_id = (_q('audio_id') or body.get('audio_id', '')).strip() task_id = (_q('task_id') or body.get('task_id', '')).strip() song_url = (_q('song_url') or body.get('song_url', '')).strip() meta = {} metadata = _load_generated_metadata() # If we only got a URL, derive the filename from its last path segment. if not filename and song_url: filename = song_url.rstrip('/').split('/')[-1] if filename and filename in metadata: meta = metadata[filename] audio_id = audio_id or meta.get('suno_id', '') task_id = task_id or meta.get('task_id', '') # Build an absolute uploadUrl Suno can fetch. upload_url = '' if song_url.startswith('http'): upload_url = song_url elif filename: web_path = f'/generated_music/{filename}' if _PUBLIC_BASE_URL: upload_url = f'{_PUBLIC_BASE_URL}{web_path}' return { 'filename': filename, 'audio_id': audio_id, 'task_id': task_id, 'title': meta.get('title', '') or (Path(filename).stem if filename else ''), 'style': meta.get('style', '') or meta.get('genre', ''), 'lyrics': meta.get('lyrics', ''), 'upload_url': upload_url, 'meta': meta, } def _submit_suno_job(endpoint: str, request_body: dict, kind: str, job_extra: dict = None, op: str = None, est_seconds: int = 60, response_msg: str = None): """POST a generation-style request, register a job, return the standard JSON. `endpoint` is the path under SUNO_API_BASE. Used by all audio-producing studio actions (extend, cover, add_vocals, add_instrumental, replace_section) which return a taskId in the /generate shape the action=status poller reads. """ if SUNO_CALLBACK_URL: request_body.setdefault('callBackUrl', SUNO_CALLBACK_URL) logger.info(f'Suno {kind}: POST {endpoint} body={json.dumps(request_body)[:300]}') try: resp = http_requests.post( f'{SUNO_API_BASE}{endpoint}', headers=_suno_headers(), json=request_body, timeout=30, ) logger.info(f'Suno {kind} response: {resp.status_code} {resp.text[:300]}') if resp.status_code != 200: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200 or not data.get('data', {}).get('taskId'): return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) task_id = data['data']['taskId'] job_id = str(uuid.uuid4()) job = { 'status': 'generating', 'prompt': request_body.get('prompt', ''), 'title': request_body.get('title', '') or kind.title(), 'style': request_body.get('style', '') or request_body.get('tags', ''), 'task_id': task_id, 'created_at': time.time(), 'kind': kind, 'instrumental': bool(request_body.get('instrumental', False)), } if job_extra: job.update(job_extra) suno_jobs[job_id] = job _provider_receipt(op or kind) _task_log('submitted', op=op or kind, task_id=task_id, title=job.get('title', ''), style=job.get('style', ''), prompt=(job.get('prompt', '') or '')[:300]) return jsonify({ 'action': 'generating', 'job_id': job_id, 'task_id': task_id, 'kind': kind, 'response': response_msg or f'{kind.replace("_", " ").title()} started — check back shortly.', 'estimated_seconds': est_seconds, }) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) def _action_extend(_q, body: dict): """Extend/continue an existing song. POST /api/v1/generate/extend.""" src = _resolve_song(_q, body) if not src['audio_id']: return jsonify({'action': 'error', 'response': 'Need a song with a known Suno audio id to extend.'}) prompt = (_q('prompt') or body.get('prompt', '')).strip() style = (_q('style') or body.get('style', '')).strip() or src['style'] or 'Same style as original' title = (_q('title') or body.get('title', '')).strip() or src['title'] or 'Extended Track' model = (_q('model') or body.get('model', '')).strip() or 'V5_5' continue_at_raw = _q('continue_at') or body.get('continue_at', '') request_body = { 'audioId': src['audio_id'], 'model': model, } if src['task_id']: request_body['taskId'] = src['task_id'] # defaultParamFlag=true → we provide prompt/style/title/continueAt. if prompt or continue_at_raw not in (None, ''): request_body['defaultParamFlag'] = True request_body['prompt'] = prompt or 'Continue the song naturally' request_body['style'] = style request_body['title'] = title try: request_body['continueAt'] = float(continue_at_raw) except (TypeError, ValueError): request_body['continueAt'] = 0 else: request_body['defaultParamFlag'] = False return _submit_suno_job( '/api/v1/generate/extend', request_body, 'extend', job_extra={'title': title, 'style': style}, op='extend', response_msg=f"Extending '{title}' — check back in ~60s.", ) def _action_cover(_q, body: dict): """Generate a cover/remix in a new style. POST /api/v1/generate/upload-cover.""" src = _resolve_song(_q, body) if not src['upload_url']: return jsonify({'action': 'error', 'response': 'Need a public song URL to cover — set PUBLIC_BASE_URL/DOMAIN or pass song_url.'}) prompt = (_q('prompt') or body.get('prompt', '')).strip() style = (_q('style') or body.get('style', '')).strip() title = (_q('title') or body.get('title', '')).strip() or (f"{src['title']} (Cover)" if src['title'] else 'Cover') model = (_q('model') or body.get('model', '')).strip() or 'V5_5' instrumental_raw = _q('instrumental') or body.get('instrumental', False) instrumental = instrumental_raw if isinstance(instrumental_raw, bool) else str(instrumental_raw).lower() in ('true', '1', 'yes') # Custom mode when an explicit style is supplied (lets us steer the remix). custom_mode = bool(style) request_body = { 'uploadUrl': src['upload_url'], 'customMode': custom_mode, 'instrumental': instrumental, 'model': model, } if custom_mode: request_body['style'] = style request_body['title'] = title if not instrumental: request_body['prompt'] = prompt or 'Reimagine this song in the new style' else: request_body['prompt'] = (prompt or 'Cover version')[:500] return _submit_suno_job( '/api/v1/generate/upload-cover', request_body, 'cover', job_extra={'title': title, 'style': style}, op='cover', response_msg=f"Generating cover of '{src['title'] or 'track'}' — check back in ~60s.", ) def _action_add_vocals(_q, body: dict): """Add vocals to an instrumental track. POST /api/v1/generate/add-vocals.""" src = _resolve_song(_q, body) if not src['upload_url']: return jsonify({'action': 'error', 'response': 'Need a public song URL to add vocals — set PUBLIC_BASE_URL/DOMAIN or pass song_url.'}) prompt = (_q('prompt') or body.get('prompt', '')).strip() if not prompt: return jsonify({'action': 'error', 'response': 'Describe the vocals/lyrics to add (prompt is required).'}) style = (_q('style') or body.get('style', '')).strip() or src['style'] or 'Pop' title = (_q('title') or body.get('title', '')).strip() or (f"{src['title']} (Vocals)" if src['title'] else 'Vocal Version') model = (_q('model') or body.get('model', '')).strip() or 'V5_5' vocal_gender = (_q('vocal_gender') or body.get('vocal_gender', '')).strip().lower() request_body = { 'uploadUrl': src['upload_url'], 'prompt': prompt, 'title': title, 'style': style, 'negativeTags': (_q('negative_tags') or body.get('negative_tags', '')) or 'low quality, off-key, distorted', 'model': model, } if vocal_gender in ('m', 'f'): request_body['vocalGender'] = vocal_gender return _submit_suno_job( '/api/v1/generate/add-vocals', request_body, 'add_vocals', job_extra={'title': title, 'style': style}, op='add_vocals', response_msg=f"Adding vocals to '{src['title'] or 'track'}' — check back in ~60s.", ) def _action_add_instrumental(_q, body: dict): """Generate an instrumental backing for an a-cappella/vocal track. POST /api/v1/generate/add-instrumental.""" src = _resolve_song(_q, body) if not src['upload_url']: return jsonify({'action': 'error', 'response': 'Need a public song URL — set PUBLIC_BASE_URL/DOMAIN or pass song_url.'}) tags = (_q('tags') or body.get('tags', '')).strip() or (_q('style') or body.get('style', '')).strip() or src['style'] or 'instrumental backing' title = (_q('title') or body.get('title', '')).strip() or (f"{src['title']} (Instrumental)" if src['title'] else 'Instrumental Version') model = (_q('model') or body.get('model', '')).strip() or 'V5_5' request_body = { 'uploadUrl': src['upload_url'], 'title': title, 'tags': tags, 'negativeTags': (_q('negative_tags') or body.get('negative_tags', '')) or 'vocals, singing, lyrics', 'model': model, } return _submit_suno_job( '/api/v1/generate/add-instrumental', request_body, 'add_instrumental', job_extra={'title': title, 'style': tags}, op='add_instrumental', response_msg=f"Building instrumental for '{src['title'] or 'track'}' — check back in ~60s.", ) def _action_replace_section(_q, body: dict): """Replace a section of a song. POST /api/v1/generate/replace-section.""" src = _resolve_song(_q, body) if not (src['audio_id'] and src['task_id']): return jsonify({'action': 'error', 'response': 'Replace section needs a song with both Suno task id and audio id (re-generate or pick a newer track).'}) prompt = (_q('prompt') or body.get('prompt', '')).strip() if not prompt: return jsonify({'action': 'error', 'response': 'Describe the replacement content (prompt is required).'}) title = (_q('title') or body.get('title', '')).strip() or src['title'] or 'Edited Track' tags = (_q('tags') or body.get('tags', '')).strip() or (_q('style') or body.get('style', '')).strip() or src['style'] or 'Same style' full_lyrics = (_q('full_lyrics') or body.get('full_lyrics', '')) or src['lyrics'] or prompt try: start_s = round(float(_q('start_time') or body.get('start_time', 0)), 2) end_s = round(float(_q('end_time') or body.get('end_time', 0)), 2) except (TypeError, ValueError): return jsonify({'action': 'error', 'response': 'start_time and end_time must be numbers (seconds).'}) if not (end_s > start_s): return jsonify({'action': 'error', 'response': 'end_time must be greater than start_time.'}) request_body = { 'taskId': src['task_id'], 'audioId': src['audio_id'], 'prompt': prompt, 'tags': tags, 'title': title, 'infillStartS': start_s, 'infillEndS': end_s, 'fullLyrics': full_lyrics, } return _submit_suno_job( '/api/v1/generate/replace-section', request_body, 'replace_section', job_extra={'title': title, 'style': tags}, op='replace_section', response_msg=f"Replacing {start_s}-{end_s}s of '{title}' — check back in ~60s.", ) def _action_generate_lyrics(_q, body: dict): """Generate lyrics from a prompt. POST /api/v1/lyrics. Lyrics come back asynchronously (callback, or GET /api/v1/lyrics/record-info). A process job is registered so `action=status&job_id=...` fetches and returns them (and saves them under generated_music/lyrics/). No audio is produced. """ prompt = (_q('prompt') or body.get('prompt', '')).strip() if not prompt: return jsonify({'action': 'error', 'response': 'Need a prompt describing the lyrics you want.'}) request_body = {'prompt': prompt[:200]} if SUNO_CALLBACK_URL: request_body['callBackUrl'] = SUNO_CALLBACK_URL try: resp = http_requests.post( f'{SUNO_API_BASE}/api/v1/lyrics', headers=_suno_headers(), json=request_body, timeout=30, ) logger.info(f'Suno generate_lyrics response: {resp.status_code} {resp.text[:300]}') if resp.status_code != 200: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200 or not data.get('data', {}).get('taskId'): return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) task_id = data['data']['taskId'] job_id = str(uuid.uuid4()) suno_jobs[job_id] = { 'status': 'generating', 'title': 'Lyrics', 'prompt': prompt[:200], 'task_id': task_id, 'created_at': time.time(), 'kind': 'lyrics', 'process_only': True, } _provider_receipt('generate_lyrics') _task_log('submitted', op='generate_lyrics', task_id=task_id, prompt=prompt[:300]) return jsonify({ 'action': 'lyrics_generating', 'job_id': job_id, 'task_id': task_id, 'kind': 'lyrics', 'response': 'Writing lyrics — poll action=status with this job_id for the text.', 'estimated_seconds': 15, }) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) def _action_timestamped_lyrics(_q, body: dict): """Get timestamped (per-word) lyrics for an existing track. POST /api/v1/generate/get-timestamped-lyrics.""" src = _resolve_song(_q, body) task_id = src['task_id'] or (_q('task_id') or body.get('task_id', '')).strip() audio_id = src['audio_id'] or (_q('audio_id') or body.get('audio_id', '')).strip() if not (task_id and audio_id): return jsonify({'action': 'error', 'response': 'Timestamped lyrics need both Suno task id and audio id for the track.'}) # PAID CALL. An instrumental has nothing to time, and the generation-time fetch # (or the host sync) already recorded that — don't buy the same empty answer again # every time someone opens the track in the sing-along page. if suno_lyrics.known_instrumental(src['filename'], audio_id): return jsonify({'action': 'error', 'instrumental': True, 'response': 'This track is instrumental — there are no lyrics to time.'}) _log = {'via': 'action', 'task_id': task_id, 'audio_id': audio_id, 'filename': src['filename'] or None} # Serialize per clip: the second of two concurrent requests then sees the first's saved result. with suno_lyrics.audio_lock(audio_id): # REUSE BEFORE BUYING (2026-10-18, mike): this path used to call Suno unconditionally, so # a clip the generation leg had just paid for was charged again (both clips of two # generations, 0.5 credit each). Word timings never expire and are kept at the first fetch. have = suno_lyrics.saved_result(src['filename'], audio_id) if have: logger.info(f'Suno timestamped_lyrics: {audio_id[:8]} already owned - no paid call') return jsonify({'action': 'timestamped_lyrics', 'cached': True, 'data': have, 'response': 'Got timestamped lyrics (saved copy, no new charge).'}) if suno_lyrics.fetch_in_flight(audio_id): return jsonify({'action': 'error', 'in_flight': True, 'response': 'Word timings for this track are being fetched right now - try again in a moment.'}) return _paid_timestamped_lyrics(task_id, audio_id, src, _log) def _paid_timestamped_lyrics(task_id: str, audio_id: str, src: dict, _log: dict): """The ONE paid explicit fetch. Caller holds suno_lyrics.audio_lock(audio_id).""" try: resp = suno_lyrics.post_timestamped_lyrics(task_id, audio_id, SUNO_API_KEY) logger.info(f'Suno timestamped_lyrics response: {resp.status_code} {resp.text[:300]}') if resp.status_code != 200: suno_lyrics.log_call({**_log, 'result': 'failed', 'http_status': resp.status_code}) return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200: suno_lyrics.log_call({**_log, 'result': 'failed', 'http_status': 200, 'suno_code': data.get('code'), 'credits': suno_lyrics.credits_from(data), 'error': f"suno code={data.get('code')} msg={data.get('msg')!r}"[:200]}) return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) _d = data.get('data') or {} _aw = _d.get('alignedWords') or _d.get('aligned_words') or [] suno_lyrics.log_call({**_log, 'result': 'ok' if _aw else 'instrumental', 'http_status': 200, 'suno_code': 200, 'aligned_words': len(_aw) or None, 'credits': suno_lyrics.credits_from(data)}) suno_lyrics.record_paid_result(task_id=task_id, audio_id=audio_id, filename=src['filename'], aligned=_aw, via='action') _provider_receipt('timestamped_lyrics') return jsonify({ 'action': 'timestamped_lyrics', 'data': data.get('data', {}), 'response': 'Got timestamped lyrics.', }) except http_requests.RequestException as exc: suno_lyrics.log_call({**_log, 'result': 'failed', 'error': f'{type(exc).__name__}: {exc}'[:200]}) return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) def _action_wav_convert(_q, body: dict): """Convert an existing track to lossless WAV. POST /api/v1/wav/generate.""" src = _resolve_song(_q, body) task_id = src['task_id'] or (_q('task_id') or body.get('task_id', '')).strip() audio_id = src['audio_id'] or (_q('audio_id') or body.get('audio_id', '')).strip() if not (task_id and audio_id): return jsonify({'action': 'error', 'response': 'WAV conversion needs both Suno task id and audio id for the track.'}) request_body = {'taskId': task_id, 'audioId': audio_id} if SUNO_CALLBACK_URL: request_body['callBackUrl'] = SUNO_CALLBACK_URL return _submit_process_job( '/api/v1/wav/generate', request_body, 'wav_convert', op='wav_convert', est_seconds=20, response_msg='Converting to WAV — poll action=status with this job_id for the file.', job_extra={'src_filename': src['filename']}, ) # --------------------------------------------------------------------------- # Stem separation persistence layer # # Every vocal-removal call is BILLED by sunoapi.org with no server-side cache, # so a lost result = money burned (it happened twice on 2026-07-14: an Errno 13 # on the stems dir, then a Clerk token expiring mid-poll → the browser's poll # loop 401'd while the paid stems evaporated). Three defenses: # 1. stem_separate is idempotent: stems already on disk → instant complete; # a fresh pending marker → the existing paid task is reused, never resubmitted. # 2. A background thread polls Suno server-side and lands the stems on disk # even if the browser dies, times out, or loses auth. # 3. The webhook callback also persists vocal_removal_info payloads. # --------------------------------------------------------------------------- _STEM_PENDING_TTL = 30 * 60 # reuse a pending task for 30 min before allowing a re-buy _STEM_POLL_SECONDS = 10 _STEM_POLL_DEADLINE = 15 * 60 _stem_pollers = set() # task_ids with a live background poller _stem_pollers_lock = threading.Lock() def _stems_dir() -> Path: from services.paths import CANVAS_PAGES_DIR return CANVAS_PAGES_DIR / '_data' / 'stems' def _stem_name_for(src_name: str, task_id: str) -> str: name_stem = Path(src_name).stem if src_name else task_id[:12] return ''.join(ch for ch in name_stem if ch.isalnum() or ch in '-_')[:60] or task_id[:12] def _stem_disk_urls(name_stem: str) -> dict: """URLs for stems already persisted to disk ({} if the vocal is missing).""" found = {} for kind_key in ('vocal', 'instrumental'): p = _stems_dir() / f'{name_stem}-{kind_key}.mp3' if p.exists() and p.stat().st_size > 0: found[kind_key] = f'/canvas-data/stems/{p.name}' return found if found.get('vocal') else {} def _stem_pending_path(name_stem: str) -> Path: return _stems_dir() / f'{name_stem}.pending.json' def _stem_read_pending(name_stem: str) -> dict: """Fresh pending marker for this track, or {} (stale/missing/unreadable).""" p = _stem_pending_path(name_stem) try: info = json.loads(p.read_text()) if info.get('task_id') and (time.time() - float(info.get('ts', 0))) < _STEM_PENDING_TTL: return info except (OSError, ValueError): pass return {} def _stem_write_pending(name_stem: str, task_id: str, sep_type: str): try: _stem_pending_path(name_stem).write_text(json.dumps( {'task_id': task_id, 'ts': time.time(), 'type': sep_type})) except OSError as exc: logger.warning(f'stem pending marker write failed ({name_stem}): {exc}') def _stem_clear_pending(name_stem: str): try: _stem_pending_path(name_stem).unlink(missing_ok=True) except OSError: pass def _stem_name_from_marker(task_id: str) -> str: """Reverse-lookup a track name from pending markers (webhook has only the task id).""" try: for p in _stems_dir().glob('*.pending.json'): try: if json.loads(p.read_text()).get('task_id') == task_id: return p.name[:-len('.pending.json')] except (OSError, ValueError): continue except OSError: pass return '' def _find_stem_urls(obj, found: dict): """Walk a Suno payload for vocal/instrumental URLs (key-name variants: vocal_url / vocalUrl / instrumental_url / ...).""" if isinstance(obj, dict): for k, v in obj.items(): lk = k.lower().replace('_', '') if isinstance(v, str) and v.startswith('http'): if lk == 'vocalurl': found['vocal'] = v elif lk == 'instrumentalurl': found['instrumental'] = v else: _find_stem_urls(v, found) elif isinstance(obj, list): for item in obj: _find_stem_urls(item, found) def _persist_stem_urls(urls: dict, name_stem: str) -> dict: """Download remote stem URLs into the stems dir. Returns local URLs saved.""" stems_dir = _stems_dir() stems_dir.mkdir(parents=True, exist_ok=True) saved = {} for kind_key, url in urls.items(): if not _is_safe_download_url(url) or not url.lower().startswith('https://'): continue p = stems_dir / f'{name_stem}-{kind_key}.mp3' if not p.exists(): try: r = http_requests.get(url, timeout=120) if r.status_code == 200 and 0 < len(r.content) <= SUNO_MAX_DOWNLOAD_BYTES: p.write_bytes(r.content) logger.info(f'stems: downloaded {kind_key} → {p.name} ({len(r.content)} bytes)') except Exception as exc: logger.warning(f'stems: download failed ({kind_key}): {exc}') continue if p.exists() and p.stat().st_size > 0: saved[kind_key] = f'/canvas-data/stems/{p.name}' if saved.get('vocal'): _stem_clear_pending(name_stem) return saved def _fetch_stems_once(task_id: str, name_stem: str) -> dict: """One record-info poll + persist attempt. No Flask objects — safe in threads. Returns {'status': 'complete'|'pending'|'failed', 'urls': {...}, 'error': str}.""" disk = _stem_disk_urls(name_stem) if disk: _stem_clear_pending(name_stem) return {'status': 'complete', 'urls': disk, 'cached': True} resp = http_requests.get( f'{SUNO_API_BASE}/api/v1/vocal-removal/record-info', headers=_suno_headers(), params={'taskId': task_id}, timeout=30) if resp.status_code != 200: return {'status': 'pending', 'error': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'} data = resp.json().get('data') or {} urls = {} _find_stem_urls(data, urls) if not urls.get('vocal'): status = str(data.get('successFlag', '') or data.get('status', '')).upper() if status in ('CREATE_TASK_FAILED', 'GENERATE_AUDIO_FAILED', 'CALLBACK_EXCEPTION', 'SENSITIVE_WORD_ERROR', 'FAIL', 'FAILED'): _stem_clear_pending(name_stem) return {'status': 'failed', 'error': f'Stem separation failed: {status}'} return {'status': 'pending'} saved = _persist_stem_urls(urls, name_stem) if not saved.get('vocal'): return {'status': 'pending', 'error': 'stems ready upstream but the vocal download failed'} return {'status': 'complete', 'urls': saved} def _spawn_stem_poller(task_id: str, name_stem: str): """Server-side completion: poll Suno until the paid stems land on disk, so a browser timeout / expired session can never lose a billed separation.""" with _stem_pollers_lock: if task_id in _stem_pollers: return _stem_pollers.add(task_id) def _run(): try: deadline = time.time() + _STEM_POLL_DEADLINE while time.time() < deadline: time.sleep(_STEM_POLL_SECONDS) try: res = _fetch_stems_once(task_id, name_stem) except Exception as exc: logger.warning(f'stem poller {task_id[:12]}: {exc}') continue if res['status'] == 'complete': logger.info(f'stem poller {task_id[:12]}: stems persisted ({name_stem})') return if res['status'] == 'failed': logger.warning(f'stem poller {task_id[:12]}: {res.get("error")}') return logger.warning(f'stem poller {task_id[:12]}: gave up after {_STEM_POLL_DEADLINE}s ' f'(webhook callback may still deliver)') finally: with _stem_pollers_lock: _stem_pollers.discard(task_id) threading.Thread(target=_run, name=f'stem-poller-{task_id[:8]}', daemon=True).start() def _action_stem_separate(_q, body: dict): """Separate vocals from music (or full stem split). POST /api/v1/vocal-removal/generate. Idempotent: cached stems return instantly; a fresh pending task is reused instead of resubmitting (every submit is billed).""" src = _resolve_song(_q, body) task_id = src['task_id'] or (_q('task_id') or body.get('task_id', '')).strip() audio_id = src['audio_id'] or (_q('audio_id') or body.get('audio_id', '')).strip() if not (task_id and audio_id): # Self-heal: the append-only suno-task-log.jsonl records ids at generation # time — recover from it when the metadata entry lost them. try: log_path = GENERATED_MUSIC_DIR / 'suno-task-log.jsonl' if log_path.exists(): for line in log_path.read_text().splitlines(): try: row = json.loads(line) except Exception: continue if src['filename'] and row.get('filename') == src['filename'] and row.get('task_id'): task_id = task_id or row['task_id'] audio_id = audio_id or row.get('suno_id', '') elif audio_id and row.get('suno_id') == audio_id and row.get('task_id'): task_id = task_id or row['task_id'] except Exception: pass if not (task_id and audio_id): # Legacy tracks (pre task-id capture in generated_metadata.json) can't be # separated — the vocal-removal API only works on a Suno taskId+audioId # pair, and there is no reverse lookup by audio id. Distinct code so UIs # can degrade gracefully instead of retrying. return jsonify({'action': 'error', 'code': 'stem_ids_missing', 'response': "This track was generated before Suno task tracking, so the API can't " "separate its vocals. Newly generated songs support voice lock automatically."}) sep_type = (_q('type') or body.get('type', '')).strip() or 'separate_vocal' if sep_type not in ('separate_vocal', 'split_stem', 'split_stem_advanced'): sep_type = 'separate_vocal' name_stem = _stem_name_for(src['filename'], task_id) try: _stems_dir().mkdir(parents=True, exist_ok=True) except OSError as exc: return jsonify({'action': 'error', 'response': f'Stems folder is not writable on the server ({exc}). ' 'Fix permissions on canvas-pages/_data/stems before separating (each attempt is billed).'}) # Already separated → free, instant. disk = _stem_disk_urls(name_stem) if disk: return jsonify({'action': 'complete', 'task_id': 'cached', 'vocal_url': disk.get('vocal'), 'instrumental_url': disk.get('instrumental'), 'response': 'Stems already separated (cached).'}) # A paid separation is already in flight → reuse it, don't buy another. pending = _stem_read_pending(name_stem) if pending: _spawn_stem_poller(pending['task_id'], name_stem) return jsonify({'action': 'generating', 'task_id': pending['task_id'], 'kind': 'stem_separate', 'reused': True, 'response': 'Separation already in progress — resuming the existing task (not re-billed).'}) # stemName is required by the API schema for ALL types (only meaningful for # split_stem_advanced, which isolates ONE named instrument). Default it so the # existing separate_vocal / split_stem behavior keeps working unchanged. stem_name = (_q('stem_name') or body.get('stem_name', '')).strip() or 'Lead Vocal' request_body = {'taskId': task_id, 'audioId': audio_id, 'type': sep_type, 'stemName': stem_name} if SUNO_CALLBACK_URL: request_body['callBackUrl'] = SUNO_CALLBACK_URL def _on_task_id(new_task_id): _stem_write_pending(name_stem, new_task_id, sep_type) _spawn_stem_poller(new_task_id, name_stem) return _submit_process_job( '/api/v1/vocal-removal/generate', request_body, 'stem_separate', op='stem_separate', est_seconds=40, response_msg=(f"Isolating '{stem_name}' stem — results delivered when ready." if sep_type == 'split_stem_advanced' else 'Splitting into stems — results delivered when ready.' if sep_type == 'split_stem' else 'Separating vocals from music — results delivered when ready.'), on_task_id=_on_task_id, ) def _action_stem_result(_q, body: dict): """Fetch vocal-separation results and persist the stems locally. GET /api/v1/vocal-removal/record-info?taskId=... Downloads the vocal (and instrumental) tracks into canvas-pages/_data/stems/ (served via /canvas-data/stems/<file> — the suno-audio-editor stem bridge dir) so lip-sync tools like Character Stage can drive mouths off the isolated vocal while the full mix plays. Idempotent per taskId: re-calls return the already-downloaded files without re-fetching. Poll this after stem_separate. task_id='cached' (from an idempotent stem_separate hit) is served straight from disk. Params: task_id (required), filename (optional — names the stems).""" task_id = (_q('task_id') or body.get('task_id', '')).strip() if not task_id: return jsonify({'action': 'error', 'response': 'stem_result needs the task_id returned by stem_separate.'}) src_name = (_q('filename') or body.get('filename', '')).strip() name_stem = _stem_name_for(src_name, task_id) try: _stems_dir().mkdir(parents=True, exist_ok=True) except OSError as exc: return jsonify({'action': 'error', 'response': f'Stems folder is not writable on the server ({exc}).'}) if task_id == 'cached': disk = _stem_disk_urls(name_stem) if disk: return jsonify({'action': 'complete', 'task_id': task_id, 'vocal_url': disk.get('vocal'), 'instrumental_url': disk.get('instrumental'), 'response': 'Stems ready (cached).'}) return jsonify({'action': 'error', 'response': 'No cached stems found for this track.'}) try: res = _fetch_stems_once(task_id, name_stem) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) if res['status'] == 'complete': urls = res['urls'] return jsonify({'action': 'complete', 'task_id': task_id, 'vocal_url': urls.get('vocal'), 'instrumental_url': urls.get('instrumental'), 'response': 'Stems ready (cached).' if res.get('cached') else 'Stems ready.'}) if res['status'] == 'failed': return jsonify({'action': 'error', 'response': res.get('error', 'Stem separation failed.')}) return jsonify({'action': 'generating', 'task_id': task_id, 'response': res.get('error') or 'Stems not ready yet — poll again in a few seconds.'}) def _action_style_boost(_q, body: dict): """Boost/enhance a style description. POST /api/v1/style/generate. Synchronous-ish: returns an enhanced style string. Takes free text in `content`/`style`/`prompt`. """ content = (_q('content') or body.get('content', '') or _q('style') or body.get('style', '') or _q('prompt') or body.get('prompt', '')).strip() if not content: return jsonify({'action': 'error', 'response': 'Need a style description to boost.'}) request_body = {'content': content[:200]} try: resp = http_requests.post( f'{SUNO_API_BASE}/api/v1/style/generate', headers=_suno_headers(), json=request_body, timeout=30, ) logger.info(f'Suno style_boost response: {resp.status_code} {resp.text[:300]}') if resp.status_code != 200: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200: return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) result = data.get('data', {}) boosted = result.get('result') or result.get('content') or result _provider_receipt('style_boost') return jsonify({ 'action': 'style_boost', 'data': result, 'boosted_style': boosted, 'response': 'Style enhanced.', }) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) def _action_music_video(_q, body: dict): """Generate a music video for a track. POST /api/v1/mp4/generate.""" src = _resolve_song(_q, body) task_id = src['task_id'] or (_q('task_id') or body.get('task_id', '')).strip() audio_id = src['audio_id'] or (_q('audio_id') or body.get('audio_id', '')).strip() if not (task_id and audio_id): return jsonify({'action': 'error', 'response': 'Music video needs both Suno task id and audio id for the track.'}) request_body = {'taskId': task_id, 'audioId': audio_id} author = (_q('author') or body.get('author', '')).strip() domain_name = (_q('domain_name') or body.get('domain_name', '')).strip() if author: request_body['author'] = author[:50] if domain_name: request_body['domainName'] = domain_name[:50] if SUNO_CALLBACK_URL: request_body['callBackUrl'] = SUNO_CALLBACK_URL return _submit_process_job( '/api/v1/mp4/generate', request_body, 'music_video', op='music_video', est_seconds=90, response_msg='Rendering music video — this can take a couple minutes. ' 'Poll action=status with this job_id for the file.', job_extra={'src_filename': src['filename']}, ) def _action_song_details(_q, body: dict): """Get full Suno metadata for a generation task. GET /api/v1/generate/record-info?taskId=...""" src = _resolve_song(_q, body) task_id = src['task_id'] or (_q('task_id') or body.get('task_id', '')).strip() if not task_id: return jsonify({'action': 'error', 'response': 'Need a Suno task id (or a song that has one) for details.'}) try: resp = http_requests.get( f'{SUNO_API_BASE}/api/v1/generate/record-info', headers={'Authorization': f'Bearer {SUNO_API_KEY}'}, params={'taskId': task_id}, timeout=15, ) if resp.status_code != 200: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200: return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) return jsonify({ 'action': 'song_details', 'data': data.get('data', {}), 'response': 'Fetched song details.', }) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) def _submit_process_job(endpoint: str, request_body: dict, kind: str, op: str = None, est_seconds: int = 30, response_msg: str = None, on_task_id=None, job_extra: dict = None): """POST a process request (WAV/stem/video) that returns a taskId but whose output is NOT a normal song download. Registers a lightweight job so the UI can show it in the queue; results are delivered via callback / song_details. on_task_id(task_id) fires after a successful submit (stem pending markers). """ logger.info(f'Suno {kind}: POST {endpoint} body={json.dumps(request_body)[:300]}') try: resp = http_requests.post( f'{SUNO_API_BASE}{endpoint}', headers=_suno_headers(), json=request_body, timeout=30, ) logger.info(f'Suno {kind} response: {resp.status_code} {resp.text[:300]}') if resp.status_code != 200: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200 or not data.get('data', {}).get('taskId'): return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) task_id = data['data']['taskId'] if on_task_id: try: on_task_id(task_id) except Exception as exc: logger.warning(f'{kind} on_task_id hook failed: {exc}') job_id = str(uuid.uuid4()) suno_jobs[job_id] = { 'status': 'generating', 'title': kind.replace('_', ' ').title(), 'task_id': task_id, 'created_at': time.time(), 'kind': kind, 'process_only': True, # status poller won't try to download as music **(job_extra or {}), } _provider_receipt(op or kind) _task_log('submitted', op=op or kind, task_id=task_id, src_task_id=request_body.get('taskId', ''), src_audio_id=request_body.get('audioId', '')) return jsonify({ 'action': 'generating', 'job_id': job_id, 'task_id': task_id, 'kind': kind, 'response': response_msg or f'{kind.replace("_", " ").title()} started.', 'estimated_seconds': est_seconds, }) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) # --------------------------------------------------------------------------- # Results of process jobs that are not songs (WAV conversion, music video, lyrics) # # These were submitted and paid for, but nothing ever fetched their output: the # status poller asked /api/v1/generate/record-info (the SONG endpoint) and the # webhook only understood song payloads, so a WAV, a video or a set of lyrics # never reached the user. Each kind has its own record-info endpoint and its own # callback shape (sunoapi.org docs: wav/mp4 callbacks are snake_case `task_id` # with `audioWavUrl` / `video_url`; lyrics callbacks are camelCase `taskId` with # `callbackType` and a `data` list of {text, title, status}). # Files land in generated_music/<subdir>/ and are served by routes/music.py at # /generated_music/<subdir>/<file>. Lyrics are saved as JSON (not served). # --------------------------------------------------------------------------- _PROCESS_RESULTS = { 'wav_convert': {'record': '/api/v1/wav/record-info', 'flag': 'successFlag', 'url_keys': ('audioWavUrl', 'audio_wav_url'), 'subdir': 'wav', 'ext': '.wav', 'noun': 'WAV file'}, 'music_video': {'record': '/api/v1/mp4/record-info', 'flag': 'successFlag', 'url_keys': ('videoUrl', 'video_url'), 'subdir': 'video', 'ext': '.mp4', 'noun': 'music video'}, 'lyrics': {'record': '/api/v1/lyrics/record-info', 'flag': 'status', 'subdir': 'lyrics', 'ext': '.json', 'noun': 'lyrics'}, } _PROCESS_PENDING_FLAGS = ('', 'PENDING', 'TEXT_SUCCESS', 'FIRST_SUCCESS') def _process_dir(kind: str) -> Path: return GENERATED_MUSIC_DIR / _PROCESS_RESULTS[kind]['subdir'] def _process_tid(task_id: str) -> str: return ''.join(ch for ch in (task_id or '') if ch.isalnum())[:16] or 'task' def _existing_process_file(kind: str, task_id: str): """A result already saved for this task (the callback and the poller can both land it).""" spec = _PROCESS_RESULTS[kind] d = _process_dir(kind) suffix = f'-{_process_tid(task_id)}{spec["ext"]}' if d.is_dir(): for f in d.iterdir(): if f.is_file() and f.name.endswith(suffix): return f return None def _download_capped(url: str, save_path: Path, require_https: bool = False) -> bool: """Download url to save_path with the same SSRF check and size cap as song downloads.""" if not url or not _is_safe_download_url(url): return False if require_https and not url.lower().startswith('https://'): logger.warning('Unsigned Suno callback: rejecting non-https result URL') return False try: r = http_requests.get(url, timeout=120, stream=True) if r.status_code != 200: logger.warning(f'Suno result download failed: HTTP {r.status_code}') return False if int(r.headers.get('Content-Length', 0) or 0) > SUNO_MAX_DOWNLOAD_BYTES: logger.warning('Suno result download rejected: over size limit') return False chunks, total = [], 0 for chunk in r.iter_content(chunk_size=65536): total += len(chunk) if total > SUNO_MAX_DOWNLOAD_BYTES: logger.warning('Suno result download aborted: over size limit') return False chunks.append(chunk) save_path.parent.mkdir(parents=True, exist_ok=True) save_path.write_bytes(b''.join(chunks)) return True except (http_requests.RequestException, OSError, ValueError) as exc: logger.warning(f'Suno result download error: {exc}') return False def _save_process_result(kind: str, task_id: str, payload, src_filename: str = '', require_https: bool = False): """Persist a finished process result. Returns the public URL (media) or the lyrics list (lyrics), or None when nothing could be saved.""" spec = _PROCESS_RESULTS[kind] existing = _existing_process_file(kind, task_id) if kind == 'lyrics': items = [{'title': (i.get('title') or '').strip(), 'text': i.get('text') or ''} for i in (payload or []) if isinstance(i, dict) and i.get('text') and str(i.get('status', 'complete')).lower() != 'failed'] if not items: return None if not existing: try: d = _process_dir(kind) d.mkdir(parents=True, exist_ok=True) (d / f'lyrics-{_process_tid(task_id)}.json').write_text( json.dumps({'task_id': task_id, 'lyrics': items}, indent=2)) except OSError as exc: logger.warning(f'Could not save lyrics for {task_id}: {exc}') return items if not existing: base = _slugify_title(Path(src_filename).stem) if src_filename else kind.replace('_', '-') save_path = _process_dir(kind) / f'{base}-{_process_tid(task_id)}{spec["ext"]}' if not _download_capped(payload, save_path, require_https=require_https): return None existing = save_path logger.info(f'Suno {kind} saved: {existing.name}') return f'/generated_music/{spec["subdir"]}/{existing.name}' def _finish_process_job(job: dict, kind: str, task_id: str, result) -> None: job['status'] = 'complete' if kind == 'lyrics': job['lyrics'] = result else: job['url'] = result _task_log('result', op=kind, task_id=task_id, url=result if isinstance(result, str) else '', lyrics=len(result) if isinstance(result, list) else None) def _process_complete_response(job_id: str, job: dict) -> dict: kind = job.get('kind') noun = _PROCESS_RESULTS[kind]['noun'] out = {'action': 'complete', 'status': 'complete', 'job_id': job_id, 'kind': kind, 'task_id': job.get('task_id', '')} if kind == 'lyrics': out['lyrics'] = job.get('lyrics', []) out['response'] = f"Lyrics ready ({len(out['lyrics'])} version(s))." else: out['url'] = job.get('url', '') out['download_url'] = out['url'] + '?download=1' if out['url'] else '' out['response'] = f"Your {noun} is ready: {out['url']}" return out def _process_status(job_id: str, job: dict): """Poll a WAV / video / lyrics task and save its result when it is ready.""" kind = job.get('kind') spec = _PROCESS_RESULTS[kind] if job.get('status') == 'complete': return jsonify(_process_complete_response(job_id, job)) if job.get('status') == 'failed': return jsonify({'action': 'failed', 'status': 'failed', 'job_id': job_id, 'kind': kind, 'reason': job.get('error', ''), 'response': f"Suno couldn't make the {spec['noun']}: {job.get('error', '')}"}) elapsed = int(time.time() - job.get('created_at', time.time())) still = {'action': 'status', 'status': 'generating', 'job_id': job_id, 'kind': kind, 'elapsed_seconds': elapsed, 'response': f"Still working on the {spec['noun']}..."} task_id = job.get('task_id') if not task_id: return jsonify(still) try: check = http_requests.get(f"{SUNO_API_BASE}{spec['record']}", headers={'Authorization': f'Bearer {SUNO_API_KEY}'}, params={'taskId': task_id}, timeout=15) if check.status_code != 200: return jsonify(still) cdata = check.json() if cdata.get('code') != 200: return jsonify(still) data = cdata.get('data') or {} flag = str(data.get(spec['flag']) or '').upper() if flag == 'SUCCESS': response = data.get('response') or {} if kind == 'lyrics': payload = response.get('data') or [] else: payload = next((response.get(k) for k in spec['url_keys'] if response.get(k)), '') result = _save_process_result(kind, task_id, payload, job.get('src_filename', '')) if result is None: still['response'] = (f"The {spec['noun']} is ready at Suno but could not be " 'saved yet; poll again.') return jsonify(still) _finish_process_job(job, kind, task_id, result) return jsonify(_process_complete_response(job_id, job)) if flag in _PROCESS_PENDING_FLAGS: return jsonify(still) reason = data.get('errorMessage') or flag job['status'] = 'failed' job['error'] = reason _task_log('failed', op=kind, task_id=task_id, reason=reason) return jsonify({'action': 'failed', 'status': 'failed', 'job_id': job_id, 'kind': kind, 'reason': reason, 'response': f"Suno couldn't make the {spec['noun']}: {reason}"}) except (http_requests.RequestException, ValueError) as exc: logger.warning(f'Suno {kind} status poll error: {exc}') return jsonify(still) def _process_callback(data: dict, require_https: bool) -> bool: """Handle a webhook for a WAV / video / lyrics task. True when it was one.""" d = data.get('data') if isinstance(data.get('data'), dict) else {} task_id = d.get('task_id') or d.get('taskId') or '' items = d.get('data') if 'audioWavUrl' in d or 'audio_wav_url' in d: kind, payload = 'wav_convert', d.get('audioWavUrl') or d.get('audio_wav_url') elif 'video_url' in d or 'videoUrl' in d: kind, payload = 'music_video', d.get('video_url') or d.get('videoUrl') elif (isinstance(items, list) and items and all(isinstance(i, dict) and 'text' in i and not (i.get('audioUrl') or i.get('audio_url')) for i in items)): kind, payload = 'lyrics', items else: return False job = next((j for j in suno_jobs.values() if j.get('task_id') == task_id), None) if data.get('code') != 200 or not payload: if job and job.get('status') != 'complete': job['status'] = 'failed' job['error'] = data.get('msg') or 'Suno reported an error' _task_log('failed', op=kind, task_id=task_id, reason=data.get('msg', ''), leg='callback') return True result = _save_process_result(kind, task_id, payload, (job or {}).get('src_filename', ''), require_https=require_https) if result is not None and job: _finish_process_job(job, kind, task_id, result) elif result is not None: _task_log('result', op=kind, task_id=task_id, leg='callback', url=result if isinstance(result, str) else '') return True # --------------------------------------------------------------------------- # Custom voice cloning + persona + file hosting (2026-07-14) # # Suno Voice API (same SUNO_API_BASE + bearer auth) mints a reusable `voiceId` # from a short source recording, then a user-sung verification of a phrase: # file_upload → voice_validate → voice_validate_info → (record phrase) → # file_upload → voice_generate → voice_info → voice_check. # The resulting voiceId is used in _action_generate as personaId + # personaModel='voice_persona'. `generate_persona` is the separate STYLE-persona # path (personaModel='style_persona'). All of these are direct calls that return # the parsed Suno JSON (they carry taskId/phrase/voiceId/isAvailable payloads the # caller needs), matching the _action_style_boost / _action_song_details shape. # --------------------------------------------------------------------------- def _action_generate_persona(_q, body: dict): """Create a STYLE persona from a generated track's vocal segment. POST /api/v1/generate/generate-persona — SYNC (no callBackUrl); the response carries the new personaId. Returns the parsed Suno JSON.""" src = _resolve_song(_q, body) task_id = src['task_id'] or (_q('task_id') or body.get('task_id', '')).strip() audio_id = src['audio_id'] or (_q('audio_id') or body.get('audio_id', '')).strip() if not (task_id and audio_id): return jsonify({'action': 'error', 'response': 'Persona creation needs both Suno task id and audio id for the source track.'}) name = (_q('name') or body.get('name', '')).strip() description = (_q('description') or body.get('description', '')).strip() if not name or not description: return jsonify({'action': 'error', 'response': 'Persona needs both a name and a description.'}) style = (_q('style') or body.get('style', '')).strip() vocal_start_raw = _q('vocalStart') or body.get('vocalStart') or _q('vocal_start') or body.get('vocal_start') vocal_end_raw = _q('vocalEnd') or body.get('vocalEnd') or _q('vocal_end') or body.get('vocal_end') try: vocal_start = float(vocal_start_raw) if vocal_start_raw not in (None, '') else 0.0 except (TypeError, ValueError): vocal_start = 0.0 try: vocal_end = float(vocal_end_raw) if vocal_end_raw not in (None, '') else 30.0 except (TypeError, ValueError): vocal_end = 30.0 request_body = { 'taskId': task_id, 'audioId': audio_id, 'name': name, 'description': description, 'vocalStart': vocal_start, 'vocalEnd': vocal_end, } if style: request_body['style'] = style logger.info(f'Suno generate_persona: name={name!r} audio_id={audio_id} ' f'seg={vocal_start}-{vocal_end}') try: resp = http_requests.post( f'{SUNO_API_BASE}/api/v1/generate/generate-persona', headers=_suno_headers(), json=request_body, timeout=30, ) logger.info(f'Suno generate_persona response: {resp.status_code} {resp.text[:300]}') if resp.status_code != 200: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200: return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) _provider_receipt('generate_persona') return jsonify({ 'action': 'ok', 'data': data.get('data', {}), 'response': 'Persona created.', }) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) def _action_voice_validate(_q, body: dict): """Voice cloning step 1: validate a source recording + get the phrase to sing. POST /api/v1/voice/validate — async (returns taskId + a validation phrase). Returns the parsed Suno JSON.""" voice_url = (_q('voiceUrl') or body.get('voiceUrl') or _q('voice_url') or body.get('voice_url', '')).strip() if not voice_url: return jsonify({'action': 'error', 'response': 'Need a hosted voiceUrl to validate (use action=file_upload first).'}) start_raw = _q('vocalStartS') or body.get('vocalStartS') or _q('vocal_start_s') or body.get('vocal_start_s', '') end_raw = _q('vocalEndS') or body.get('vocalEndS') or _q('vocal_end_s') or body.get('vocal_end_s', '') try: start_s = int(start_raw) end_s = int(end_raw) except (TypeError, ValueError): return jsonify({'action': 'error', 'response': 'vocalStartS and vocalEndS must be integers (seconds).'}) if not (end_s > start_s): return jsonify({'action': 'error', 'response': 'vocalEndS must be greater than vocalStartS.'}) request_body = {'voiceUrl': voice_url, 'vocalStartS': start_s, 'vocalEndS': end_s} language = (_q('language') or body.get('language', '')).strip() if language: request_body['language'] = language if SUNO_CALLBACK_URL: request_body['callBackUrl'] = SUNO_CALLBACK_URL logger.info(f'Suno voice_validate: seg={start_s}-{end_s} url={voice_url[:80]}') try: resp = http_requests.post( f'{SUNO_API_BASE}/api/v1/voice/validate', headers=_suno_headers(), json=request_body, timeout=30, ) logger.info(f'Suno voice_validate response: {resp.status_code} {resp.text[:300]}') if resp.status_code != 200: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200: return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) _provider_receipt('voice_validate') return jsonify({ 'action': 'voice_validate', 'data': data.get('data', {}), 'response': 'Voice validation started — poll action=voice_validate_info for the phrase.', }) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) def _action_voice_validate_info(_q, body: dict): """Voice cloning step 2: fetch the validation phrase + status. GET /api/v1/voice/validate-info?taskId=... — returns the parsed Suno JSON.""" task_id = (_q('task_id') or body.get('task_id', '')).strip() if not task_id: return jsonify({'action': 'error', 'response': 'Need a Suno task id from action=voice_validate.'}) try: resp = http_requests.get( f'{SUNO_API_BASE}/api/v1/voice/validate-info', headers={'Authorization': f'Bearer {SUNO_API_KEY}'}, params={'taskId': task_id}, timeout=15, ) logger.info(f'Suno voice_validate_info response: {resp.status_code} {resp.text[:300]}') if resp.status_code != 200: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200: return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) return jsonify({ 'action': 'voice_validate_info', 'data': data.get('data', {}), 'response': 'Fetched voice validation info.', }) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) def _action_voice_generate(_q, body: dict): """Voice cloning step 5: mint the cloned voiceId from a verified recording. POST /api/v1/voice/generate — async (returns taskId → voiceId). Returns the parsed Suno JSON.""" task_id = (_q('task_id') or body.get('task_id', '')).strip() verify_url = (_q('verifyUrl') or body.get('verifyUrl') or _q('verify_url') or body.get('verify_url', '')).strip() if not task_id: return jsonify({'action': 'error', 'response': 'Need the Suno task id from action=voice_validate.'}) if not verify_url: return jsonify({'action': 'error', 'response': 'Need a hosted verifyUrl (the recording of the phrase).'}) request_body = {'taskId': task_id, 'verifyUrl': verify_url} voice_name = (_q('voiceName') or body.get('voiceName') or _q('voice_name') or body.get('voice_name', '')).strip() if voice_name: request_body['voiceName'] = voice_name description = (_q('description') or body.get('description', '')).strip() if description: request_body['description'] = description style = (_q('style') or body.get('style', '')).strip() if style: request_body['style'] = style skill = (_q('singerSkillLevel') or body.get('singerSkillLevel') or _q('singer_skill_level') or body.get('singer_skill_level', '')).strip() or 'beginner' request_body['singerSkillLevel'] = skill if SUNO_CALLBACK_URL: request_body['callBackUrl'] = SUNO_CALLBACK_URL logger.info(f'Suno voice_generate: task_id={task_id} skill={skill} name={voice_name!r}') try: resp = http_requests.post( f'{SUNO_API_BASE}/api/v1/voice/generate', headers=_suno_headers(), json=request_body, timeout=30, ) logger.info(f'Suno voice_generate response: {resp.status_code} {resp.text[:300]}') if resp.status_code != 200: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200: return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) _provider_receipt('voice_generate') return jsonify({ 'action': 'voice_generate', 'data': data.get('data', {}), 'response': 'Voice generation started — poll action=voice_info for the voiceId.', }) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) def _action_voice_info(_q, body: dict): """Voice cloning step 6: fetch the minted voiceId + status. GET /api/v1/voice/record-info?taskId=... — returns the parsed Suno JSON.""" task_id = (_q('task_id') or body.get('task_id', '')).strip() if not task_id: return jsonify({'action': 'error', 'response': 'Need a Suno task id from action=voice_generate.'}) try: resp = http_requests.get( f'{SUNO_API_BASE}/api/v1/voice/record-info', headers={'Authorization': f'Bearer {SUNO_API_KEY}'}, params={'taskId': task_id}, timeout=15, ) logger.info(f'Suno voice_info response: {resp.status_code} {resp.text[:300]}') if resp.status_code != 200: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200: return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) return jsonify({ 'action': 'voice_info', 'data': data.get('data', {}), 'response': 'Fetched voice record info.', }) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) def _action_voice_check(_q, body: dict): """Check whether a cloned voice is available for use. POST /api/v1/voice/check-voice — this endpoint is inconsistent: the body uses snake_case `task_id` (NOT taskId). Returns the parsed Suno JSON (data.isAvailable).""" task_id = (_q('task_id') or body.get('task_id', '')).strip() if not task_id: return jsonify({'action': 'error', 'response': 'Need a Suno task id (or voice task id) to check.'}) # NOTE: snake_case `task_id` is intentional — this endpoint does not accept taskId. request_body = {'task_id': task_id} try: resp = http_requests.post( f'{SUNO_API_BASE}/api/v1/voice/check-voice', headers=_suno_headers(), json=request_body, timeout=15, ) logger.info(f'Suno voice_check response: {resp.status_code} {resp.text[:300]}') if resp.status_code != 200: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200: return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) return jsonify({ 'action': 'voice_check', 'data': data.get('data', {}), 'response': 'Checked voice availability.', }) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) def _action_file_upload(_q, body: dict): """Host a remote file on the Suno File Upload API (DIFFERENT host) so other endpoints can fetch it. POST {SUNO_FILE_UPLOAD_BASE}/api/file-url-upload — returns the hosted URL (used as uploadUrl/voiceUrl/verifyUrl). Same bearer auth.""" file_url = (_q('fileUrl') or body.get('fileUrl') or _q('file_url') or body.get('file_url', '')).strip() if not file_url: return jsonify({'action': 'error', 'response': 'Need a fileUrl to host.'}) upload_path = (_q('uploadPath') or body.get('uploadPath') or _q('upload_path') or body.get('upload_path', '')).strip() or 'uploads' file_name = (_q('fileName') or body.get('fileName') or _q('file_name') or body.get('file_name', '')).strip() request_body = {'fileUrl': file_url, 'uploadPath': upload_path} if file_name: request_body['fileName'] = file_name logger.info(f'Suno file_upload: path={upload_path} url={file_url[:80]}') try: resp = http_requests.post( f'{SUNO_FILE_UPLOAD_BASE}/api/file-url-upload', headers=_suno_headers(), json=request_body, timeout=30, ) logger.info(f'Suno file_upload response: {resp.status_code} {resp.text[:300]}') if resp.status_code != 200: return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'}) data = resp.json() if data.get('code') != 200: return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"}) _provider_receipt('file_upload') return jsonify({ 'action': 'file_upload', 'data': data.get('data', {}), 'response': 'File hosted.', }) except http_requests.RequestException as exc: return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"}) # --------------------------------------------------------------------------- # Webhook callback (sunoapi.org POSTs here when song is done) # --------------------------------------------------------------------------- @suno_bp.route('/api/suno/callback', methods=['POST']) def suno_callback(): """Webhook from sunoapi.org — downloads song and queues frontend notification.""" try: # Verify HMAC signature when a webhook secret is configured. # NOTE: SUNO_WEBHOOK_SECRET is NOT provisioned in the fleet .env template # yet (verified 2026-07-11), so we cannot fail-closed without breaking # every tenant's song delivery. Until the secret is provisioned # fleet-wide, an unsigned callback is accepted but constrained: the # download is SSRF-checked, restricted to https, and hard-capped at # SUNO_MAX_DOWNLOAD_BYTES (50 MB). TODO: add SUNO_WEBHOOK_SECRET to # templates/.env then flip this to a 503 fail-closed. _require_https = not SUNO_WEBHOOK_SECRET if SUNO_WEBHOOK_SECRET: sig_header = request.headers.get('X-Suno-Signature', '') payload = request.get_data() expected = hmac.new(SUNO_WEBHOOK_SECRET.encode(), payload, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig_header, expected): logger.warning('Suno callback rejected: invalid signature') return jsonify({'status': 'forbidden'}), 403 else: logger.warning( 'Suno callback accepted UNSIGNED — SUNO_WEBHOOK_SECRET not configured. ' 'Download restricted to https + %d byte cap. Provision the secret ' 'fleet-wide to fail closed.', SUNO_MAX_DOWNLOAD_BYTES ) data = request.json or {} logger.info(f'Suno callback: {json.dumps(data, indent=2)[:500]}') # Vocal-removal completion (different shape: data.vocal_removal_info, # snake_case task_id). Persist the paid stems server-side — the browser # may have timed out or lost its session while waiting. vri = (data.get('data') or {}).get('vocal_removal_info') if data.get('code') == 200 and vri: cb_task = (data.get('data') or {}).get('task_id') or (data.get('data') or {}).get('taskId', '') name_stem = _stem_name_from_marker(cb_task) or _stem_name_for('', cb_task or 'stems') urls = {} _find_stem_urls(vri, urls) try: saved = _persist_stem_urls(urls, name_stem) logger.info(f'Suno callback: persisted vocal-removal stems for {name_stem}: {list(saved)}') except Exception as exc: logger.warning(f'Suno callback: stem persist failed ({name_stem}): {exc}') return jsonify({'status': 'ok'}) # WAV conversion / music video / lyrics results (not songs). if _process_callback(data, _require_https): return jsonify({'status': 'ok'}) if data.get('code') == 200: callback_type = data.get('data', {}).get('callbackType', '') # BOTH CASINGS (2026-08-24). Suno's webhook delivers this field as snake_case # `task_id` on some callback types and camelCase `taskId` on others. This line read # ONLY camelCase, so every snake_case callback wrote its song to metadata with an # EMPTY task_id — measured by a host agent: 62 callback rows, ZERO task_ids, # while the poller leg was 36/36 clean. # # Why that is expensive rather than untidy: a Suno taskId+audioId pair is the key to # timestamped lyrics, stems, extends, covers and video. Without it the track is a # dead audio file. Suno keeps log data only ~2 MONTHS, so a dropped id has an expiry: # of 101 test-dev tracks missing the pair, 78 are already past recovery and 13 expire # 2026-09-20. # # The codebase already knew: `cb_task` twelve lines above does the both-casings read # correctly for the vocal-removal path. This is the same payload; the difference was # that the variable used downstream only checked one spelling. task_id = ((data.get('data') or {}).get('taskId') or (data.get('data') or {}).get('task_id', '')) # Failure callback — surface to user + agent if callback_type == 'error': reason = data.get('data', {}).get('errorMessage') or data.get('msg') or 'Suno reported an error' _job_id = None _job = None for jid, job in suno_jobs.items(): if job.get('task_id') == task_id: _job_id = jid _job = job job['status'] = 'failed' job['error'] = reason break failed_songs_queue.append({ 'job_id': _job_id or task_id, 'task_id': task_id, 'kind': (_job or {}).get('kind', 'song'), 'brand': (_job or {}).get('brand', ''), 'title': (_job or {}).get('title', ''), 'reason': reason, 'failed_at': datetime.now().isoformat(), }) logger.warning(f'Suno callback reported error for task {task_id}: {reason}') return jsonify({'status': 'ok'}) # sunoapi.org sends: "text" (lyrics ready), "first"/"second" (audio ready), "complete" # Only process 'complete' to avoid duplicates (first/second are partial deliveries # of the same songs that appear again in complete). if callback_type == 'complete' or ( callback_type not in ('text', 'first', 'second') and data.get('data', {}).get('data') ): songs = data.get('data', {}).get('data', []) # Durable ledger: record EVERY clip id (incl. the second one # we don't download) so task_id ↔ suno_id never gets lost. for _clip in songs: _task_log('clip', task_id=task_id, suno_id=_clip.get('id', ''), title=_clip.get('title', ''), duration=_clip.get('duration', 0), leg='callback') # Suno returns 2 clips per generation — only take the first one # (user asked for 1 song, not 2 variations) songs = songs[:1] if songs else [] for song in songs: audio_url = song.get('audioUrl') or song.get('audio_url') if not audio_url: continue # "text" callback — lyrics only, no audio yet song_id = song.get('id', task_id) _raw_cb_title = song.get('title', '') if _raw_cb_title and not _is_uuid(_raw_cb_title): song_title = _raw_cb_title else: song_title = 'Generated Track' duration = song.get('duration', 0) slug = _slugify_title(song_title) # Cross-worker dedupe (mirror of _action_status): if the # status poller already saved this suno_id, reuse its file # instead of minting a -2 duplicate of the same clip. filename = '' if song_id: filename = next( (fn for fn, md in _load_generated_metadata().items() if isinstance(md, dict) and md.get('suno_id') == song_id and (GENERATED_MUSIC_DIR / fn).exists()), '') if not filename: filename = _unique_filename(GENERATED_MUSIC_DIR, slug) save_path = GENERATED_MUSIC_DIR / filename if audio_url and not save_path.exists(): if not _is_safe_download_url(audio_url): continue # Unsigned callbacks: only https audio URLs are allowed. if _require_https and not audio_url.lower().startswith('https://'): logger.warning('Unsigned Suno callback: rejecting non-https audioUrl') continue try: audio_resp = http_requests.get(audio_url, timeout=60, stream=True) if audio_resp.status_code == 200: content_length = int(audio_resp.headers.get('Content-Length', 0)) if content_length > SUNO_MAX_DOWNLOAD_BYTES: logger.warning(f'Callback download rejected: size {content_length} exceeds limit') continue chunks = [] total = 0 for chunk in audio_resp.iter_content(chunk_size=65536): total += len(chunk) if total > SUNO_MAX_DOWNLOAD_BYTES: logger.warning(f'Callback download aborted: exceeded {SUNO_MAX_DOWNLOAD_BYTES} bytes') break chunks.append(chunk) else: save_path.write_bytes(b''.join(chunks)) logger.info(f'Callback downloaded: {song_title} → {filename}') # Find matching job prompt = '' style = '' job_id = None kind = 'song' extra = {} job_instrumental = False for jid, job in suno_jobs.items(): if job.get('task_id') == task_id: job['status'] = 'complete' job['song_id'] = song_id job['url'] = f'/generated_music/{filename}' prompt = job.get('prompt', '') style = job.get('style', '') job_id = jid kind = job.get('kind', 'song') job_instrumental = bool(job.get('instrumental', False)) if kind == 'jingle': extra = { 'brand': job.get('brand', ''), 'style_key': job.get('style_key', ''), 'vocal_gender': job.get('vocal_gender', ''), 'instrumental': job.get('instrumental', False), } break # Capture lyrics from Suno response (description-mode jingles # have Suno-written lyrics; custom-mode songs have provided lyrics) song_lyrics = song.get('prompt', '') or song.get('lyrics', '') if song_lyrics: extra['lyrics'] = song_lyrics _add_song_to_metadata(filename, song_title, prompt, style, duration, song_id, kind=kind, extra=extra, task_id=task_id) # The job may live in another worker's memory; then the # "[Instrumental]" lyrics are the instrumental signal. _schedule_lyrics_fetch(task_id, song.get('id', ''), filename, lyrics=song_lyrics, instrumental=job_instrumental, kind=kind) completed_songs_queue.append({ 'song_id': song_id, 'filename': filename, 'title': song_title, 'job_id': job_id or task_id, 'kind': kind, 'url': f'/generated_music/{filename}', 'completed_at': datetime.now().isoformat(), 'prompt': prompt, 'lyrics': song_lyrics, }) except Exception as exc: logger.warning(f'Callback download error: {exc}') return jsonify({'status': 'ok'}) except Exception as exc: logger.error(f'Suno callback error: {exc}') logger.error('Suno callback error: %s', exc) return jsonify({'status': 'error', 'message': 'Internal server error'}) # --------------------------------------------------------------------------- # Completed songs queue (frontend polls this) # --------------------------------------------------------------------------- @suno_bp.route('/api/suno/completed', methods=['GET', 'POST']) def suno_completed(): """ GET — Returns completed songs waiting for notification. POST — Clears specific song (or all) from queue after UI has shown it. """ global completed_songs_queue if request.method == 'POST': song_id = request.args.get('song_id') or (request.get_json(silent=True) or {}).get('song_id') if song_id: completed_songs_queue = [s for s in completed_songs_queue if s['song_id'] != song_id] else: completed_songs_queue = [] return jsonify({'status': 'ok', 'cleared': True}) if completed_songs_queue: return jsonify({'has_completed': True, 'songs': completed_songs_queue, 'count': len(completed_songs_queue)}) return jsonify({'has_completed': False, 'songs': [], 'count': 0}) @suno_bp.route('/api/suno/failed', methods=['GET', 'POST']) def suno_failed(): """ GET — Returns failed Suno jobs waiting for notification. POST — Clears specific job (or all) from the failed queue after UI showed it. """ global failed_songs_queue if request.method == 'POST': job_id = request.args.get('job_id') or (request.get_json(silent=True) or {}).get('job_id') if job_id: failed_songs_queue = [f for f in failed_songs_queue if f.get('job_id') != job_id] else: failed_songs_queue = [] return jsonify({'status': 'ok', 'cleared': True}) if failed_songs_queue: return jsonify({'has_failed': True, 'failures': failed_songs_queue, 'count': len(failed_songs_queue)}) return jsonify({'has_failed': False, 'failures': [], 'count': 0}) @suno_bp.route('/api/suno/song/<filename>', methods=['DELETE']) def delete_song(filename): """Delete a generated song file. Renames to .deleted (never truly removes).""" import re # Validate filename — only allow safe characters if not re.match(r'^[\w\-. ]+\.(mp3|wav|ogg|m4a)$', filename): return jsonify({'error': 'Invalid filename'}), 400 target = GENERATED_MUSIC_DIR / filename if not target.exists(): return jsonify({'error': 'File not found'}), 404 # Rename to .deleted instead of removing (NEVER DELETE rule) renamed = target.with_suffix(target.suffix + '.deleted') try: target.rename(renamed) logger.info(f'[suno] Archived song: {filename} -> {renamed.name}') return jsonify({'ok': True, 'archived': renamed.name}) except Exception as e: logger.error(f'[suno] Failed to archive {filename}: {e}') return jsonify({'error': str(e)}), 500