Files
musicland/functions/src/music.js
T
2025-11-17 14:27:15 +01:00

874 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
});
}
},
);