const { onCall, onRequest, HttpsError, } = require("firebase-functions/v2/https"); const axios = require("axios"); const admin = require("firebase-admin"); const { FieldValue } = require("firebase-admin/firestore"); const { logger } = require("firebase-functions/logger"); const { pipeline } = require("stream/promises"); const { randomUUID } = require("crypto"); const { ALERT_TYPE, refList } = require("../index"); const { sendNotification } = require("./notifications"); const { SUNO_API_KEY } = require("../config/keys"); const { createOrderDocument, ORDER_TYPES } = require("./helpers/orders"); const { SUNO_MODEL, SUNO_CALLBACK_URL, SUNO_API_BASE, SUNO_API_PATH, SUNO_STATUS_PATH, } = require("../config/suno"); const MUSIC_GENERATION_CREDIT_COST = 8; const MUSIC_REFUND_SOURCE = "music_generation_refund"; const refundMusicCredits = async ({ projectId, userId, reason = "music_generation_failed", context = {}, }) => { if (!projectId || !userId || MUSIC_GENERATION_CREDIT_COST <= 0) return null; try { const metadata = { source: MUSIC_REFUND_SOURCE, reason, projectId, ...context, }; const { orderId } = await createOrderDocument({ userId, type: ORDER_TYPES.SONG, amount: MUSIC_GENERATION_CREDIT_COST, songId: projectId, createdBy: "system", metadata, }); logger.log("💸 [Music] Crédits remboursés", { projectId, userId, orderId, }); return { orderId }; } catch (error) { logger.error("❌ [Music] Échec remboursement crédits", { projectId, userId, error: error?.message, }); return null; } }; /** * Marque un projet comme échoué suite à une erreur Suno * @param {string} projectId - Identifiant du projet * @param {Object} error - Erreur capturée (axios) */ async function markProjectMusicFailure(projectId, error) { if (!projectId) return; try { const docRef = refList.projects.doc(projectId); const projectSnap = await docRef.get(); const projectData = projectSnap?.data() || {}; const status = error?.response?.status || error?.status || null; const sunoMessage = error?.response?.data?.msg || error?.response?.data?.message || error?.message || "Erreur lors de la génération de musique"; const errorPayload = { source: "SUNO_API", message: sunoMessage, }; if (status) errorPayload.status = status; if (error?.code) errorPayload.code = error.code; const receiverId = sanitizeField(projectData?.userId); const alreadyRefunded = projectData?.musicCreditsRefunded === true || typeof projectData?.musicCreditsRefundOrderId === "string"; let refundResult = null; if (receiverId && !alreadyRefunded) { refundResult = await refundMusicCredits({ projectId, userId: receiverId, reason: sunoMessage, context: { status: status || null, code: error?.code || null, }, }); } const updatePayload = { musicStatus: "FAILED", sunoTaskId: FieldValue.delete(), generationStartAt: FieldValue.delete(), musicError: errorPayload, updatedAt: FieldValue.serverTimestamp(), }; if (refundResult?.orderId) { updatePayload.musicCreditsRefunded = true; updatePayload.musicCreditsRefundOrderId = refundResult.orderId; updatePayload.musicCreditsRefundedAt = FieldValue.serverTimestamp(); } await docRef.set(updatePayload, { merge: true }); if (receiverId) { const projectTitle = sanitizeField(projectData?.title, "ton projet"); const baseMessage = `La génération de musique pour "${projectTitle}" a échoué.`; const message = refundResult?.orderId ? `${baseMessage} Tes crédits ont été remboursés.` : baseMessage; try { await sendNotification({ sender: "SYSTEM", receiver: receiverId, receiverCollection: "users", title: "Génération de musique échouée", message, data: { type: ALERT_TYPE?.MUSIC_GENERATION_FAILED, projectId, projectTitle, error: errorPayload, }, }); } catch (notifyError) { console.error( "[markProjectMusicFailure] Failed to send notification:", notifyError, ); } } } catch (err) { console.error( "❌ [generateMusic] Impossible de marquer le projet en erreur:", err, ); } } /** * Limite la longueur d'une chaîne * @param {string} str - La chaîne à limiter * @param {number} max - La longueur maximale * @return {string} La chaîne limitée */ function clampLen(str = "", max) { if (!max) return str || ""; if (!str) return ""; return str.length <= max ? str : str.slice(0, max); } /** * Déduit des balises vocales explicites reconnues par les modèles (EN) * à partir de la description utilisateur (FR) */ function extractVoiceStrings(voice) { if (!voice) return []; if (typeof voice === "string") return [voice]; if (Array.isArray(voice)) { return voice .map((v) => (typeof v === "string" ? v : v?.value || v?.text || "")) .filter(Boolean); } if (typeof voice === "object") { return Object.values(voice).filter( (v) => typeof v === "string" && v.trim(), ); } return []; } function voiceTagsFromUserChoice(voiceInput = "") { const all = extractVoiceStrings(voiceInput); const tags = []; const addFromText = (txt) => { const v = String(txt || "").toLowerCase(); // Gender / ensemble if (/féminin|feminin|féminine|feminine/.test(v)) tags.push("Female lead vocal"); if (/masculin|masculine/.test(v)) tags.push("Male lead vocal"); if (/deux voix|duo|deux chanteurs/.test(v)) tags.push("Duet (male and female voices)"); if (/choeu?r|gospel/.test(v)) tags.push("Gospel choir vocals"); // Delivery/techniques if (/rap\b/.test(v)) tags.push("Rap vocal"); if (/slam|parl[ée]e|chant parl[ée]/.test(v)) tags.push("Spoken word / slam"); if (/raconte|narrat/.test(v)) tags.push("Narration / spoken narrator"); if (/cri|scream|rugueu|growl/.test(v)) tags.push("Screamed vocals"); if (/sprech/.test(v)) tags.push("Sprechgesang (sung-spoken) style"); // Tone/texture if (/séduis|sensuel/.test(v)) tags.push("Seductive, sensual tone"); if (/profonde|r[ée]sonnan/.test(v)) tags.push("Deep, resonant voice"); if (/l[ée]g[èe]re|a[ée]rienne/.test(v)) tags.push("Light, airy voice"); if (/relaxante|sophistiqu[ée]/.test(v)) tags.push("Smooth, lounge vocal"); if (/synth[ée]tique|robot/.test(v)) tags.push("Synthetic/processed vocal"); if (/d[ée]coup|chopp/.test(v)) tags.push("Chopped vocal samples"); // Choir color if (/c[ée]leste|divin|angel/i.test(v)) tags.push("Ethereal, angelic choir"); }; if (all.length === 0) addFromText(voiceInput); else all.forEach(addFromText); return Array.from(new Set(tags)); } /** * Détecte un genre vocal simple (m/f) pour Suno à partir du texte utilisateur. */ function detectVocalGender(voiceInput = "") { // If array of objects with category, prefer 'base' if (Array.isArray(voiceInput)) { const baseItem = voiceInput.find( (v) => v && (v.category === "base" || v?.category?.toLowerCase() === "base"), ); const text = baseItem ? baseItem.value || baseItem.text || baseItem : null; if (text) return detectVocalGender(text); // Fallback: scan all for (const it of voiceInput) { const g = detectVocalGender(it?.value || it?.text || it); if (g) return g; } return undefined; } if (typeof voiceInput === "object" && voiceInput) { // object keyed by category const base = voiceInput.base || voiceInput["base"]; if (base) return detectVocalGender(base); const vals = Object.values(voiceInput).filter(Boolean); for (const v of vals) { const g = detectVocalGender(v); if (g) return g; } return undefined; } const v = String(voiceInput || "").toLowerCase(); if (!v) return undefined; if (/(féminin|feminin|féminine|feminine|voix\s*f[ée]min)/.test(v)) return "f"; if (/(masculin|masculine|voix\s*mascul)/.test(v)) return "m"; if (/(femme)/.test(v)) return "f"; if (/(homme)/.test(v)) return "m"; return undefined; } const sanitizeField = (value, fallback = null) => { const cleaned = typeof value === "string" ? value.trim() : value; if (typeof cleaned !== "string" || !cleaned) return fallback; return cleaned; }; const sanitizeMusicUrls = (urls = []) => (Array.isArray(urls) ? urls : []) .filter((url) => typeof url === "string" && url.trim()) .map((url) => url.trim()); const formatProjectMeta = (projectData = {}) => { const userId = sanitizeField(projectData?.userId); const projectTitle = sanitizeField(projectData?.title, "ton projet"); return { userId, projectTitle }; }; const parseSunoCallbackPayload = (rawBody = {}) => { const body = rawBody || {}; const code = body.code ?? body.statusCode ?? null; const callbackType = (body?.data?.callbackType || "") .toString() .toLowerCase(); const status = (body.status || body.state || callbackType || "") .toString() .toLowerCase(); const taskId = sanitizeField(body?.data?.task_id); const tracks = Array.isArray(body?.data?.data) ? body.data.data : Array.isArray(body.data) ? body.data : []; return { code, status, taskId, tracks }; }; const extractAudioUrlsFromTracks = (tracks = []) => tracks .map( (t) => t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl, ) .filter(Boolean) .slice(0, 2); const fetchProjectByTaskId = async (taskId) => { const snapshot = await refList.projects .where("sunoTaskId", "==", taskId) .limit(1) .get(); if (snapshot.empty) { throw new Error("PROJECT_NOT_FOUND_FOR_TASK"); } const doc = snapshot.docs[0]; return { projectId: doc.id, projectData: doc.data() || {}, projectRef: doc.ref, }; }; const downloadTrackToStorage = async ( url, { userId, projectId, taskId, bucket, index }, ) => { if (!url) return null; try { console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`); const resp = await axios.get(url, { responseType: "stream" }); const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`; const token = randomUUID(); const file = bucket.file(path); const writeStream = file.createWriteStream({ resumable: false, metadata: { contentType: "audio/mpeg", cacheControl: "public, max-age=31536000", metadata: { firebaseStorageDownloadTokens: token }, }, }); await pipeline(resp.data, writeStream); const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent( path, )}?alt=media&token=${token}`; console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl); return { path, url: downloadUrl }; } catch (error) { console.error( `❌ [SunoCallback] Échec save piste ${index + 1}:`, error.message, ); return null; } }; const saveTracksToStorage = async (audioUrls, meta) => { if (!audioUrls?.length) return []; const bucket = admin.storage().bucket(); console.log("🪣 [SunoCallback] Bucket:", bucket.name); const context = { ...meta, bucket }; const results = await Promise.all( audioUrls.map((url, index) => downloadTrackToStorage(url, { ...context, index }), ), ); return results.filter(Boolean).map((entry) => entry.url); }; const mergeMusicUrls = async (projectRef, newUrls) => { const sanitizedNewUrls = sanitizeMusicUrls(newUrls); console.log("➕ [SunoCallback] Nouveaux morceaux à ajouter:", { count: sanitizedNewUrls.length, urls: sanitizedNewUrls, }); let existingUrls = []; try { const snapshot = await projectRef.get(); existingUrls = sanitizeMusicUrls(snapshot?.data()?.musicUrls); console.log("📦 [SunoCallback] Morceaux déjà stockés:", { count: existingUrls.length, urls: existingUrls, }); } catch (error) { console.error( "⚠️ [SunoCallback] Impossible de récupérer les anciennes musiques:", error, ); } const allUrls = [...existingUrls, ...sanitizedNewUrls]; const dedupedUrls = allUrls.filter( (url, index) => allUrls.indexOf(url) === index, ); console.log("🎶 [SunoCallback] Morceaux conservés après fusion:", { count: dedupedUrls.length, urls: dedupedUrls, }); await projectRef.set( { musicStatus: "GENERATED", musicUrls: dedupedUrls, musicError: FieldValue.delete(), updatedAt: FieldValue.serverTimestamp(), }, { merge: true }, ); return dedupedUrls; }; /** * Construit le style musical à partir des paramètres * @param {Object} params - Les paramètres de style * @return {string} Le style formaté */ function buildStyle({ genres = [], voice = "", instruments = [], tempo = "" }) { const flatGenres = (genres || []).map((g) => ("" + g).trim()).filter(Boolean); const flatInstruments = (instruments || []) .map((i) => ("" + i).trim()) .filter(Boolean); const voiceTags = voiceTagsFromUserChoice(voice); const voiceStrings = extractVoiceStrings(voice); const voiceUserText = voiceStrings.join(" ; "); const styleParts = [ flatGenres.length ? `Genres: ${flatGenres.join(", ")}` : "", tempo ? `Tempo: ${tempo}` : "", flatInstruments.length ? `Instruments: ${flatInstruments.join(", ")}` : "", // Inject tags AND the raw user string to fully convey preference voiceTags.length ? `Vocal: ${voiceTags.join(", ")}` : "", voiceUserText ? `Vocal (user): ${voiceUserText}` : "", "Mix: propre, punchy, large stéréo, radio-ready", ].filter(Boolean); const style = styleParts.join(" ; "); return clampLen(style, 600); } /** * Convertit les paroles en prompt avec tags * @param {Array} lyrics - Tableau des paroles * @return {string} Prompt formaté avec tags */ function lyricsToTaggedPrompt(lyrics = []) { if (!Array.isArray(lyrics) || lyrics.length === 0) { return ""; } const sections = lyrics .map((section) => { if (!section || typeof section !== "object") return ""; const { type = "verse", lyrics: content = "" } = section; if (!content.trim()) return ""; const tag = type.toLowerCase(); return `[${tag}]\n${content.trim()}`; }) .filter(Boolean); return sections.join("\n\n"); } /** * Construit le prompt pour Suno * @param {Object} params - Paramètres du prompt * @return {string} Prompt formaté */ function buildSunoPrompt({ title, audience = "", projectContext = "", emotionGuide = "", styleGuide = "", }) { const parts = [ title ? `Titre: ${title}` : "", audience ? `Public cible: ${audience}` : "", projectContext ? `Contexte: ${projectContext}` : "", emotionGuide ? `Émotion: ${emotionGuide}` : "", styleGuide ? `Style: ${styleGuide}` : "", ].filter(Boolean); return parts.join("\n\n"); } /** * Construit les guides de style et d'émotion * @param {Object} params - Paramètres des guides * @return {Object} Guides formatés */ function buildGuides({ genres = [], tempo = "", voice = "", instruments = [], }) { const styleElements = []; if (genres && genres.length > 0) { styleElements.push(`Genres: ${genres.join(", ")}`); } if (tempo) { const tempoGuide = tempo.toLowerCase().includes("lent") ? "Rythme lent et contemplatif" : tempo.toLowerCase().includes("rapide") ? "Rythme énergique et dynamique" : `Tempo ${tempo.toLowerCase()}.`; styleElements.push(tempoGuide); } if (instruments && instruments.length > 0) { styleElements.push(`Instruments principaux: ${instruments.join(", ")}`); } const voiceStrings = extractVoiceStrings(voice); if (voiceStrings.length) { const tags = voiceTagsFromUserChoice(voice); if (tags.length) styleElements.push(`Vocal direction: ${tags.join(", ")}`); styleElements.push(`User vocal request: ${voiceStrings.join(" ; ")}`); } return { styleGuide: styleElements.join(" "), emotionGuide: "", }; } /** * Fonction principale de génération de musique */ exports.generateMusic = onCall(async ({ data = {} }) => { try { const { title = "", lyrics = [], genres = [], voice = "", instruments = [], tempo = "", audience = "", projectContext = "", } = data; const { styleGuide, emotionGuide } = buildGuides({ genres, voice, instruments, tempo, }); const taggedLyrics = lyricsToTaggedPrompt(lyrics); const safeTitle = clampLen(title, 80); // Construire les informations de contexte pour le style const contextInfo = buildSunoPrompt({ title: safeTitle, audience, projectContext, emotionGuide, styleGuide, }); // Le style combine les informations de genre ET le contexte const baseStyle = buildStyle({ genres, voice, instruments, tempo }); const enhancedStyle = `${baseStyle}. ${contextInfo}`; const payload = { customMode: true, instrumental: false, model: SUNO_MODEL, prompt: clampLen(taggedLyrics, 3000), title: safeTitle, style: clampLen(enhancedStyle, 600), callBackUrl: SUNO_CALLBACK_URL || "", }; // Appliquer la préférence de genre vocal si détectée (doc: vocalGender: "m" | "f") const vGender = detectVocalGender(voice); if (vGender) payload.vocalGender = vGender; console.log("PAYLOAD", payload); const response = await axios.post( `${SUNO_API_BASE}${SUNO_API_PATH}`, payload, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${SUNO_API_KEY}`, }, }, ); const parsed = response.data; return { success: !!parsed?.data?.taskId, request: { model: SUNO_MODEL, instrumental: false, title: safeTitle, style: enhancedStyle, prompt: taggedLyrics, vocalGender: vGender || null, }, response: parsed || {}, }; } catch (error) { console.error("❌ Erreur lors de la génération de musique:", error); try { await markProjectMusicFailure(data?.projectId, error); } catch (markErr) { console.error( "❌ Erreur lors de la mise à jour du statut de projet:", markErr, ); } const status = error?.response?.status || "INTERNAL_ERROR"; const message = error?.response?.data?.msg || error?.response?.data?.message || error?.message || "Erreur lors de la génération de musique avec Suno"; throw new HttpsError("internal", message, { status, source: "SUNO_API", }); } }); /** * Fonction pour suivre le statut d'une génération de musique Suno */ exports.getSunoStatus = onCall(async ({ data = {} }) => { try { const { taskId } = data; if (!taskId) { throw new Error("TaskId manquant"); } console.log("🔍 Vérification du statut Suno", { taskId }); let response; let parsed; try { response = await axios.get( `${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, { headers: { Authorization: `Bearer ${SUNO_API_KEY}`, }, timeout: 30000, // 30 secondes de timeout }, ); parsed = response.data; console.log("📊 Réponse Suno API:", parsed); } catch (error) { console.error("❌ Erreur détaillée API Suno Status:", { message: error.message, status: error.response?.status, statusText: error.response?.statusText, data: error.response?.data, config: { url: error.config?.url, headers: error.config?.headers, }, }); // Gestion spéciale pour les taskId de test if ( error.response && error.response.status === 404 && taskId.includes("test") ) { return { success: true, taskId, data: { status: "not_found", message: "TaskId de test - tâche non trouvée dans l'API Suno", isTestId: true, }, }; } // Retourner une erreur plus descriptive const errorStatus = error.response?.status || "UNKNOWN"; const errorMessage = error.response?.data?.msg || error.response?.data?.message || error.response?.statusText || error.message; return { success: false, taskId, error: { status: errorStatus, message: errorMessage, type: "API_ERROR", }, }; } console.log("✅ Statut Suno récupéré", { taskId, status: parsed?.data?.status || parsed?.status, }); return { success: true, taskId, data: parsed?.data || parsed, }; } catch (error) { console.error("❌ Erreur lors de la vérification du statut:", error); return { success: false, taskId: data?.taskId, error: { message: error.message, type: "INTERNAL_ERROR", }, }; } }); /** * Cloud Function pour recevoir les callbacks de l'API Suno * Cette fonction est appelée par l'API Suno lorsque la génération * de musique est terminée */ exports.sunoCallback = onRequest( { methods: ["POST"], memory: "1GiB" }, async (req, res) => { if (req.method !== "POST") { console.warn("⚠️ [SunoCallback] Méthode non autorisée:", req.method); return res.status(405).json({ error: "Méthode non autorisée" }); } console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body)); const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body); console.log("🎯 [SunoCallback] Détails:", { code, status, taskId, count: tracks.length, }); if (code !== 200 || status !== "complete") { console.log("ℹ️ [SunoCallback] Callback ignoré (code/status)", { code, status, }); return res.status(200).json({ success: true, ignored: true }); } if (!taskId) { console.warn("⚠️ [SunoCallback] taskId manquant dans le callback"); return res.status(200).json({ success: true, ignored: true }); } let projectIdForFailure = null; try { const { projectId, projectData, projectRef } = await fetchProjectByTaskId(taskId); projectIdForFailure = projectId; const { userId, projectTitle } = formatProjectMeta(projectData); const audioUrls = extractAudioUrlsFromTracks(tracks); if (audioUrls.length < 2) { console.warn( "⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback", { found: audioUrls.length, }, ); } const storedUrls = await saveTracksToStorage(audioUrls, { userId, projectId, taskId, }); const musicUrls = await mergeMusicUrls(projectRef, storedUrls); console.log("🏷️ [SunoCallback] Projet marqué GENERATED", { projectId, musicUrlsCount: musicUrls.length, }); if (userId) { const successMessage = musicUrls.length > 0 ? `Ta musique pour "${projectTitle}" est prête.` : `La génération de musique pour "${projectTitle}" est terminée.`; try { await sendNotification({ sender: "SYSTEM", receiver: userId, receiverCollection: "users", title: "Musique prête", message: successMessage, data: { type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS, projectId, projectTitle, musicUrls, taskId, }, }); } catch (notifError) { console.error( "[sunoCallback] Failed to send success notification:", notifError, ); } } return res.status(200).json({ success: true, projectId, savedCount: storedUrls.length, musicUrlsCount: musicUrls.length, }); } catch (error) { const statusCode = error?.message === "PROJECT_NOT_FOUND_FOR_TASK" ? 404 : 500; if (statusCode !== 404 && projectIdForFailure) { try { await markProjectMusicFailure(projectIdForFailure, error); } catch (markError) { console.error( "⚠️ [SunoCallback] Impossible de marquer le projet en échec:", markError, ); } } logger.error("❌ [SunoCallback] Erreur interne:", error); return res.status(statusCode).json({ success: false, error: error?.message || "Erreur interne du serveur", }); } }, );