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 { REGION, 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',
}
const LYRICS_FUNCTION_OPTIONS = {
region: REGION,
cors: true,
invoker: 'public',
secrets: [GEMINI_API_KEY],
}
// --- 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(LYRICS_FUNCTION_OPTIONS, 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 closed)', moderationError)
// La modération essaie plusieurs modèles stables avant d'arriver ici.
// Si tous sont indisponibles, on bloque 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) => ` `)
.join('\n')
const fallbackStructureTags = [
' ',
' ',
].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", "Intro", "Pont" ou "Solo" : le champ 'lyrics' doit être strictement vide. N'ajoute aucune indication entre parenthèses.
- 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(LYRICS_FUNCTION_OPTIONS, 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 { songSunoTaskId, sunoAudioId, songIndex } = docSnap.data()
if (!songSunoTaskId) throw new Error('TaskId de la piste manquant')
if (!sunoAudioId) throw new Error('AudioId Suno manquant')
if (songIndex === undefined || songIndex < 0) throw new Error('musicIndex invalide')
console.log('🔎 getSunoTimestamps', { songSunoTaskId, sunoAudioId, songIndex })
const response = await axios.post(
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
{ taskId: songSunoTaskId, audioId: sunoAudioId },
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${SUNO_API_KEY.value()}`,
},
timeout: 30000,
}
)
if (response.data?.code !== 200) {
throw new Error(response.data?.msg || 'Récupération des timestamps refusée par Suno')
}
const dataToReturn = response.data?.data
if (!Array.isArray(dataToReturn?.alignedWords) || dataToReturn.alignedWords.length === 0) {
throw new Error('Aucune parole synchronisée retournée par Suno')
}
const failedWordCount = dataToReturn.alignedWords.filter(
(word) => word?.success === false
).length
const hootCer = Number(dataToReturn.hootCer)
await refList.projects.doc(projectId).set(
{
musicTimestamps: { [songIndex]: dataToReturn },
lyricsSyncStatus: 'READY',
lyricsSyncError: FieldValue.delete(),
lyricsSyncDiagnostics: {
alignedWordCount: dataToReturn.alignedWords.length,
failedWordCount,
successfulWordRatio:
(dataToReturn.alignedWords.length - failedWordCount) /
dataToReturn.alignedWords.length,
hootCer: Number.isFinite(hootCer) ? hootCer : null,
},
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
return { success: true }
} catch (error) {
console.error('❌ getSunoTimestamps Error:', error.message)
if (projectId) {
await refList.projects
.doc(projectId)
.set(
{
lyricsSyncStatus: 'FAILED',
lyricsSyncError: error.message,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
.catch(() => {})
}
return {
success: false,
error: { message: error.message, type: 'INTERNAL_ERROR' },
}
}
}
exports.getSunoTimestamps = getSunoTimestamps