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 { GEMINI_API_KEY, SUNO_API_KEY } = require('../config/secrets') 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) const RHYMES_PRESETS = [ { matchers: ['avec rimes', 'avec des rimes', 'rimes'], prompt: 'Rimes riches et variées (rimes finales + internes). Évite les rimes faciles.', }, { matchers: ['sans rimes', 'sans rime', 'pas de rimes', 'pas de rime'], prompt: "Vers libres sans rimes finales ni schéma de rimes. Évite les rimes intentionnelles, privilégie le rythme et l'imagerie.", }, { matchers: ['mélange des deux', 'melange des deux', 'mixte'], prompt: 'Mélange: alterne sections rimées et sections non rimées. Au moins la moitié des lignes sans rime finale. Quand tu rimes, rimes riches et variées.', }, ] const resolveRhymesInstruction = (value) => { if (typeof value === 'string') { const trimmed = value.trim() if (!trimmed) return '' const key = trimmed.toLowerCase() const preset = RHYMES_PRESETS.find((option) => option.matchers.includes(key)) return preset ? preset.prompt : trimmed } if (Array.isArray(value)) return value.join(', ') return '' } // --- 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({ secrets: [GEMINI_API_KEY] }, async ({ auth = {}, data = {} }) => { try { const { lang: rawLang, objective = '', context = '', style = '', audience = '', emotion = '', structure: rawStructure = ['couplet', 'refrain', 'couplet', 'refrain'], rhymes = '', } = data if (!['fr', 'en'].includes(rawLang)) { throw new HttpsError('invalid-argument', 'La langue doit être "fr" ou "en".') } const lang = rawLang const languageLabel = lang === 'en' ? 'English' : 'français' const languageInstruction = lang === 'en' ? 'Write the title, lyrics and description in English.' : 'Rédige le titre, les paroles et la description en français.' const rhymesInstruction = resolveRhymesInstruction(rhymes) // 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: rhymesInstruction, } 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 : 0. **LANGUE** : ${languageInstruction} 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** : Suis strictement du brief. Si "Sans rimes" ou "Mélange des deux" est demandé, respecte l'absence/alternance de rimes. Si rien n'est indiqué, fais des rimes riches et évite les rimes faciles (amour/toujours). 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 = ` ${languageLabel} ${objective || 'Créer une chanson mémorable'} ${context || 'Libre interprétation'} ${emotion || 'Intense'} ${style || 'Pop Moderne'} ${audience || 'Tout public'} ${rhymesInstruction || 'Rimes croisées et riches'} ${structureTags || fallbackStructureTags} 0. ${languageInstruction} 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({ secrets: [GEMINI_API_KEY] }, 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.value()}`, }, 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