const { onCall, HttpsError } = require("firebase-functions/v2/https"); const { z } = require("genkit"); const { generateAI, analyseLyrics } = require("../helpers/gemini"); const { SUNO_API_BASE, SUNO_TIMESTAMPED_LYRICS_PATH, } = require("../config/suno"); const { SUNO_API_KEY } = require("../config/keys"); const axios = require("axios"); const { FieldValue } = require("firebase-admin/firestore"); const { refList } = require("../index"); // --- CONSTANTES DE STRUCTURE --- // On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur const STRUCTURE_PROMPT_LABELS = { couplet: "couplet", refrain: "refrain", short_intro: "introduction instrumentale courte", long_intro: "introduction instrumentale longue", pre_refrain_instrumental: "pré-refrain instrumental", pont: "pont", solo_de_guitare: "solo de guitare", solo_de_guitare_electrique: "solo de guitare électrique", solo_de_batterie: "solo de batterie", solo_de_saxophone: "solo de saxophone", solo_de_violon: "solo de violon", break: "break", interlude: "interlude", interlude_melodique: "interlude mélodique", final_apogee: "final apogée", arret_net: "arrêt net", fade_out: "fade out", transition_douce: "transition douce vers le silence", }; const STRUCTURE_ALIASES = { "short intro": "short_intro", "introduction instrumentale courte": "short_intro", "intro instrumentale courte": "short_intro", "long intro": "long_intro", "introduction instrumentale longue": "long_intro", "intro instrumentale longue": "long_intro", "pré-refrain": "pre_refrain_instrumental", "pre-refrain": "pre_refrain_instrumental", pre_refrain: "pre_refrain_instrumental", "pre chorus": "pre_refrain_instrumental", "pre-chorus": "pre_refrain_instrumental", prechorus: "pre_refrain_instrumental", "pré-refrain instrumental": "pre_refrain_instrumental", "pre-refrain instrumental": "pre_refrain_instrumental", "instrumental pre-chorus": "pre_refrain_instrumental", "instrumental pre chorus": "pre_refrain_instrumental", bridge: "pont", guitar_solo: "solo_de_guitare", electric_guitar_solo: "solo_de_guitare_electrique", drum_solo: "solo_de_batterie", sax_solo: "solo_de_saxophone", violin_solo: "solo_de_violon", melodic_interlude: "interlude_melodique", grand_finale: "final_apogee", sudden_stop: "arret_net", soft_transition: "transition_douce", }; // --- UTILITAIRES --- const normalizeStructureValue = (value) => { const raw = String(value || "") .trim() .toLowerCase(); if (!raw) return ""; if (STRUCTURE_PROMPT_LABELS[raw]) return raw; if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw]; const sanitized = raw.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); return STRUCTURE_PROMPT_LABELS[sanitized] ? sanitized : STRUCTURE_ALIASES[sanitized] || sanitized; }; // Nettoyage simplifié : on laisse l'IA gérer la logique musicale plutôt que de supprimer brutalement. // On s'assure juste que les clés sont propres. const sanitizeStructureEntries = (structure = []) => { if (!Array.isArray(structure)) return []; return structure.map(normalizeStructureValue).filter(Boolean); }; const mapStructureToPrompt = (structure = []) => sanitizeStructureEntries(structure).map( (entry) => STRUCTURE_PROMPT_LABELS[entry] || entry, ); // --- MODERATION --- const buildModerationBrief = ({ objective, context, style, audience, emotion, structure, rhymes, }) => { const sections = [ `${objective || ""}`, `${context || ""}`, `${emotion || ""}`, ``, `${audience || ""}`, `${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}`, ]; const promptStructure = mapStructureToPrompt(structure); if (promptStructure.length) sections.push(`${promptStructure.join(" | ")}`); return `\n${sections.join("\n")}\n`; }; // --- GENERATION DE PAROLES (MAIN) --- exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => { try { const { objective = "", context = "", style = "", audience = "", emotion = "", structure: rawStructure = ["couplet", "refrain", "couplet", "refrain"], rhymes = "", } = data; // 1. Préparation Structure const sanitizedStructure = sanitizeStructureEntries(rawStructure); const promptStructure = sanitizedStructure.length ? sanitizedStructure.map( (entry) => STRUCTURE_PROMPT_LABELS[entry] || entry, ) : []; // 2. Modération "Pro" (Via Gemini 1.5 Pro) // On supprime les regex manuelles obsolètes. const moderationPayload = { objective, context, style, audience, emotion, structure: promptStructure, rhymes, }; const moderationInput = buildModerationBrief(moderationPayload); try { const moderation = await analyseLyrics({ title: "Brief utilisateur (Pré-génération)", lyrics: moderationInput, }); // Si Gemini dit "Blocked", on bloque. C'est la seule autorité. if (moderation?.blocked === true) { console.warn("⛔ generateLyrics blocked by Gemini Pro", { reasons: moderation.reasons, }); const summary = Array.isArray(moderation.reasons) ? moderation.reasons.slice(0, 3).join(", ") : "Contenu non conforme"; throw new HttpsError( "invalid-argument", `Demande refusée par la modération : ${summary}.`, ); } } catch (moderationError) { if (moderationError instanceof HttpsError) throw moderationError; console.error("⚠️ Moderation check error (fail open)", moderationError); // On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique. // Ici je fail closed par sécurité pour une app publique. throw new HttpsError( "internal", "Vérification de sécurité indisponible.", ); } console.log("🎵 generateLyrics: Start generation", { style, emotion }); // 3. Le Prompt "Hit Maker" const system = ` Tu es le meilleur parolier musical actuel, expert en "Songwriting" pour les IA génératives audio (Suno/Udio). Ta spécialité est la **Prosodie** (le rythme naturel des mots) et l'impact émotionnel. TES RÈGLES D'OR : 1. **MÉTRIQUE & RYTHME** : Tes vers doivent être "chantables". Compte les syllabes pour qu'elles collent au style musical demandé. Évite les phrases trop longues ou imprononçables rapidement. 2. **RIMES RICHES** : Évite les rimes faciles (amour/toujours). Cherche des assonances et des rimes internes. 3. **SHOW, DON'T TELL** : N'écris pas "Je suis triste", écris "La pluie brouille mes carreaux". Utilise des détails sensoriels. 4. **STRUCTURE STRICTE** : Respecte exactement l'ordre des sections demandées. - **Couplet** : Raconte l'histoire, pose le décor. - **Refrain** : Le "Hook". Doit être simple, répétitif, accrocheur et résumer l'émotion principale. - **Pont** : Change de rythme ou de perspective, crée une tension avant le dernier refrain. 5. **VOCABULAIRE** : Adapte le niveau de langue au style (Argot pour le Rap, Soutenu pour la Chanson Française, Simple pour la Pop). Retourne UNIQUEMENT un JSON valide. `.trim(); const structureTags = ( Array.isArray(promptStructure) ? promptStructure : [] ) .map((part, index) => `
${part}
`) .join("\n"); const fallbackStructureTags = [ '
couplet
', '
refrain
', ].join("\n"); const prompt = ` ${objective || "Créer une chanson mémorable"} ${context || "Libre interprétation"} ${emotion || "Intense"} ${style || "Pop Moderne"} ${audience || "Tout public"} ${rhymes || "Rimes croisées et riches"} ${structureTags || fallbackStructureTags} 1. Génère un **Titre** percutant (moins de 6 mots). 2. Pour chaque section
, écris le contenu adapté : - Si c'est "Instrumental" ou "Solo" : laisse le champ 'lyrics' vide ou mets une brève indication d'ambiance entre parenthèses ex: "(Solo de guitare déchirant)". - Si c'est "Couplet/Refrain" : Écris 4 à 12 vers. 3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre. `.trim(); const lyricsSchema = z.object({ title: z.string().describe("Titre de la chanson, court et accrocheur"), lyrics: z .array( z.object({ type: z .string() .describe( "Type de section (copier exactement la demande structure)", ), lyrics: z .string() .describe( "Les paroles. Pour les sections instrumentales, laisser vide ou décrire l'ambiance.", ), }), ) .describe("La structure complète de la chanson"), lyricsDescription: z .string() .describe( "Un pitch de 2 phrases décrivant l'ambiance et le thème de la chanson pour Suno.", ), success: z.boolean(), }); // Appel Gemini avec le nouveau modèle Pro configuré dans helpers/gemini return await generateAI({ system, prompt, schema: lyricsSchema, }); } catch (e) { console.error("❌ generateLyrics Error:", e); // Remontée d'erreur propre if (e instanceof HttpsError) throw e; throw new HttpsError( "internal", "Erreur lors de la génération des paroles.", ); } }); // --- ANALYSE TOXICITÉ (EXISTANTE, NETTOYÉE) --- // Cette fonction reste utile pour l'analyse POST-création ou pour l'interface UI const severityScore = (severity = "") => { const normalized = String(severity || "").toLowerCase(); if (normalized === "critical") return 4; if (normalized === "high") return 3; if (normalized === "medium") return 2; if (normalized === "low") return 1; return 0; }; const softenModerationDecision = (rawResult = {}) => { const result = { ...rawResult }; const excerpts = Array.isArray(result.excerpts) ? result.excerpts : []; const reasons = Array.isArray(result.reasons) ? result.reasons : []; const highestSeverity = excerpts.reduce( (max, excerpt) => Math.max(max, severityScore(excerpt?.severity)), 0, ); const score = typeof result.score === "number" && !Number.isNaN(result.score) ? Math.min(Math.max(result.score, 0), 1) : 0; // Détection de contexte narratif pour être plus indulgent const narrativeHint = reasons.some((reason) => /narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test( reason || "", ), ); const adjustments = []; // Logique d'adoucissement : Si c'est "High" severity mais narratif, on peut parfois débloquer (selon ta politique). // Ici on reste prudent sur le block, mais on adoucit le flag. if (result.blocked) { // Débloque uniquement les erreurs manifestes (score bas mais blocked true par erreur) if (highestSeverity <= 1 && score < 0.65) { result.blocked = false; adjustments.push("auto-unblock-low-severity"); } } if (!result.blocked && result.flagged) { const lowSignal = highestSeverity <= 1 && score < 0.4; const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55; if (lowSignal) { result.flagged = false; adjustments.push("drop-flag-low-signal"); } else if (contextual) { result.flagged = false; adjustments.push("drop-flag-contextual"); } } return { ...result, moderationAdjustments: adjustments, moderationCalibration: { highestSeverity, score }, }; }; exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => { try { const requestSchema = z.object({ title: z.string().optional(), lyrics: z .union([ z.string(), z.object({ couplet: z.string().optional(), refrain: z.string().optional(), }), z.array( z.object({ type: z.string().optional(), lyrics: z.string().optional(), }), ), ]) .describe("Paroles à analyser"), }); const parsed = requestSchema.parse(data || {}); const aiResult = await analyseLyrics({ title: parsed.title || "", lyrics: parsed.lyrics, }); const result = softenModerationDecision(aiResult); const highCats = Object.entries(result.categories || {}) // Note: categories n'est pas dans le schema Zod actuel de Gemini, à vérifier si besoin .filter(([, v]) => (typeof v === "number" ? v : 0) >= 0.5) .map(([k]) => k) .slice(0, 5); let errorCode = "OK"; let message = "Analyse effectuée: aucun blocage."; if (result.blocked) { errorCode = "TOXIC_CONTENT_BLOCKED"; message = `Contenu bloqué par sécurité.`; } else if (result.flagged) { errorCode = "TOXIC_CONTENT_FLAGGED"; message = `Attention: contenu sensible détecté.`; } return { success: !result.blocked, errorCode, message, result }; } catch (err) { console.error("analyseLyricsToxicity failed", err?.message || err); return { success: false, errorCode: "ANALYSE_FAILED", message: "Erreur analyse toxicité.", }; } }); // --- SUNO TIMESTAMPS (EXISTANT) --- async function getSunoTimestamps(projectId) { try { if (!projectId || typeof projectId !== "string") throw new Error("projectId invalide"); const docSnap = await refList.projects.doc(projectId).get(); if (!docSnap.exists) throw new Error("Projet introuvable"); const { sunoTaskId, songIndex } = docSnap.data(); if (!sunoTaskId) throw new Error("TaskId manquant"); if (songIndex === undefined || songIndex < 0) throw new Error("musicIndex invalide"); console.log("🔎 getSunoTimestamps", { sunoTaskId, songIndex }); const response = await axios.post( `${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`, { taskId: sunoTaskId, musicIndex: songIndex }, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${SUNO_API_KEY}`, }, timeout: 30000, }, ); const dataToReturn = response.data?.data || response.data || {}; await refList.projects.doc(projectId).set( { musicTimestamps: { [songIndex]: dataToReturn }, updatedAt: FieldValue.serverTimestamp(), }, { merge: true }, ); return { success: true }; } catch (error) { console.error("❌ getSunoTimestamps Error:", error.message); return { success: false, error: { message: error.message, type: "INTERNAL_ERROR" }, }; } } exports.getSunoTimestamps = getSunoTimestamps;