Files
musicland/functions/src/lyrics.js
T
Thomas Demirdjian d7b14bd873 clear last tickets
2025-11-26 15:33:22 +01:00

449 lines
15 KiB
JavaScript

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 = [
`<OBJECTIF>${objective || ""}</OBJECTIF>`,
`<CONTEXTE>${context || ""}</CONTEXTE>`,
`<EMOTION>${emotion || ""}</EMOTION>`,
`<STYLE>${style || ""}</STYLE>`,
`<AUDIENCE>${audience || ""}</AUDIENCE>`,
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`,
];
const promptStructure = mapStructureToPrompt(structure);
if (promptStructure.length)
sections.push(`<STRUCTURE>${promptStructure.join(" | ")}</STRUCTURE>`);
return `<BRIEF_UTILISATEUR>\n${sections.join("\n")}\n</BRIEF_UTILISATEUR>`;
};
// --- 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) => ` <SECTION ordre="${index + 1}">${part}</SECTION>`)
.join("\n");
const fallbackStructureTags = [
' <SECTION ordre="1">couplet</SECTION>',
' <SECTION ordre="2">refrain</SECTION>',
].join("\n");
const prompt = `
<BRIEF_CREATIF>
<OBJECTIF>${objective || "Créer une chanson mémorable"}</OBJECTIF>
<CONTEXTE>${context || "Libre interprétation"}</CONTEXTE>
<EMOTION_DOMINANTE>${emotion || "Intense"}</EMOTION_DOMINANTE>
<STYLE_MUSICAL>${style || "Pop Moderne"}</STYLE_MUSICAL>
<CIBLE>${audience || "Tout public"}</CIBLE>
<TYPE_DE_RIMES>${rhymes || "Rimes croisées et riches"}</TYPE_DE_RIMES>
</BRIEF_CREATIF>
<STRUCTURE_IMPOSEE>
${structureTags || fallbackStructureTags}
</STRUCTURE_IMPOSEE>
<CONSIGNES_GENERATION>
1. Génère un **Titre** percutant (moins de 6 mots).
2. Pour chaque section <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.
</CONSIGNES_GENERATION>
`.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;