624 lines
17 KiB
JavaScript
624 lines
17 KiB
JavaScript
const { onCall, onRequest } = require("firebase-functions/v2/https");
|
||
const axios = require("axios");
|
||
const admin = require("firebase-admin");
|
||
const { logger } = require("firebase-functions/logger");
|
||
const { SUNO_API_KEY } = require("../config/keys");
|
||
|
||
// Initialiser Firestore
|
||
const db = admin.firestore();
|
||
|
||
/**
|
||
* ===== CONFIG GLOBALE =====
|
||
*/
|
||
const SUNO_API_BASE = "https://api.sunoapi.org";
|
||
const SUNO_API_PATH = "/api/v1/generate";
|
||
const SUNO_STATUS_PATH = "/api/v1/generate/record-info";
|
||
const SUNO_TIMESTAMPED_LYRICS_PATH = "/api/v1/generate/get-timestamped-lyrics";
|
||
const SUNO_MODEL = "V4_5";
|
||
// const SUNO_MODEL = "V3_5";
|
||
const SUNO_CALLBACK_URL =
|
||
"https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback";
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
|
||
/**
|
||
* 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 styleParts = [
|
||
flatGenres.length ? `Genres: ${flatGenres.join(", ")}` : "",
|
||
tempo ? `Tempo: ${tempo}` : "",
|
||
flatInstruments.length ? `Instruments: ${flatInstruments.join(", ")}` : "",
|
||
voice ? `Vocal: ${voice}` : "",
|
||
"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(", ")}`);
|
||
}
|
||
|
||
if (voice) {
|
||
styleElements.push(`Style vocal: ${voice}`);
|
||
}
|
||
|
||
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 || "",
|
||
};
|
||
|
||
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,
|
||
},
|
||
response: parsed || {},
|
||
};
|
||
} catch (error) {
|
||
console.error("❌ Erreur lors de la génération de musique:", error);
|
||
throw new Error(
|
||
`Erreur lors de la génération de musique: ${error.message}`
|
||
);
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 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",
|
||
},
|
||
};
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Récupère les timestamps (aligned words) pour une génération Suno
|
||
* Attend: { taskId: string, musicIndex: number, projectId: string }
|
||
*/
|
||
exports.getSunoTimestamps = onCall(async ({ data = {} }) => {
|
||
try {
|
||
const { taskId, musicIndex, projectId } = data || {};
|
||
|
||
if (!taskId) {
|
||
throw new Error("TaskId manquant");
|
||
}
|
||
|
||
const index = Number(musicIndex);
|
||
if (!Number.isFinite(index) || index < 0) {
|
||
throw new Error("musicIndex invalide");
|
||
}
|
||
|
||
if (!projectId || typeof projectId !== "string" || !projectId.trim()) {
|
||
throw new Error("projectId manquant ou invalide");
|
||
}
|
||
|
||
console.log("🔎 Récupération des timestamps Suno", {
|
||
taskId,
|
||
musicIndex: index,
|
||
});
|
||
|
||
let response;
|
||
let parsed;
|
||
|
||
try {
|
||
response = await axios.post(
|
||
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
|
||
{ taskId, musicIndex: index },
|
||
{
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${SUNO_API_KEY}`,
|
||
},
|
||
timeout: 30000,
|
||
}
|
||
);
|
||
parsed = response.data;
|
||
console.log("📊 Réponse timestamps Suno API:", parsed?.code, parsed?.msg);
|
||
} catch (error) {
|
||
console.error("❌ Erreur API Suno Timestamps:", {
|
||
message: error.message,
|
||
status: error.response?.status,
|
||
statusText: error.response?.statusText,
|
||
data: error.response?.data,
|
||
config: {
|
||
url: error.config?.url,
|
||
headers: error.config?.headers
|
||
? { ...error.config.headers, Authorization: "[redacted]" }
|
||
: undefined,
|
||
},
|
||
});
|
||
|
||
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,
|
||
musicIndex: index,
|
||
error: {
|
||
status: errorStatus,
|
||
message: errorMessage,
|
||
type: "API_ERROR",
|
||
},
|
||
};
|
||
}
|
||
|
||
const dataToReturn = parsed?.data || parsed || {};
|
||
|
||
// Sauvegarde obligatoire dans le document projet
|
||
let saved = false;
|
||
try {
|
||
await db
|
||
.collection("projects")
|
||
.doc(projectId)
|
||
.set(
|
||
{
|
||
musicTimestamps: {
|
||
[musicIndex]: dataToReturn,
|
||
},
|
||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||
},
|
||
{ merge: true }
|
||
);
|
||
saved = true;
|
||
console.log("💾 Timestamps sauvegardés dans Firestore", {
|
||
projectId,
|
||
index,
|
||
});
|
||
} catch (e) {
|
||
console.error(
|
||
"❌ Échec sauvegarde timestamps Firestore:",
|
||
e?.message || e
|
||
);
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
taskId,
|
||
musicIndex: index,
|
||
projectId,
|
||
saved,
|
||
musicTimestamps: dataToReturn,
|
||
};
|
||
} catch (error) {
|
||
console.error("❌ Erreur interne getSunoTimestamps:", error);
|
||
return {
|
||
success: false,
|
||
taskId: data?.taskId,
|
||
musicIndex: data?.musicIndex,
|
||
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"] }, async (req, res) => {
|
||
try {
|
||
console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body));
|
||
|
||
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" });
|
||
}
|
||
|
||
const body = req.body || {};
|
||
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 =
|
||
body.taskId ||
|
||
body.task_id ||
|
||
body?.data?.taskId ||
|
||
body?.data?.task_id ||
|
||
null;
|
||
const tracks = Array.isArray(body?.data?.data)
|
||
? body.data.data
|
||
: Array.isArray(body.data)
|
||
? body.data
|
||
: [];
|
||
|
||
console.log("🎯 [SunoCallback] Détails:", {
|
||
code,
|
||
status,
|
||
callbackType,
|
||
taskId,
|
||
count: tracks.length,
|
||
});
|
||
|
||
// On n'agit que si code === 200 et status === "complete"
|
||
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 });
|
||
}
|
||
|
||
// 1) Récupérer le projectId associé au taskId
|
||
let projectId = null;
|
||
try {
|
||
const projSnap = await db
|
||
.collection("projects")
|
||
.where("sunoTaskId", "==", taskId)
|
||
.limit(1)
|
||
.get();
|
||
if (!projSnap.empty) {
|
||
projectId = projSnap.docs[0].id;
|
||
}
|
||
} catch (e) {
|
||
console.error("❌ [SunoCallback] Erreur lookup project par taskId:", e);
|
||
}
|
||
|
||
if (!projectId) {
|
||
console.warn("⚠️ [SunoCallback] Aucun projet trouvé pour", { taskId });
|
||
return res.status(200).json({ success: true, ignored: true });
|
||
}
|
||
|
||
// 2) Extraire jusqu'à 2 URLs audio
|
||
const audioUrls = tracks
|
||
.map(
|
||
(t) =>
|
||
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl
|
||
)
|
||
.filter(Boolean)
|
||
.slice(0, 2);
|
||
|
||
if (audioUrls.length < 2) {
|
||
console.warn(
|
||
"⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback",
|
||
{
|
||
found: audioUrls.length,
|
||
tracksSample: tracks.map((t) => ({
|
||
id: t.id,
|
||
has_audio_url: !!t.audio_url,
|
||
has_stream_audio_url: !!t.stream_audio_url,
|
||
})),
|
||
}
|
||
);
|
||
}
|
||
|
||
// 3) Télécharger et sauvegarder dans Cloud Storage + récupérer download URLs
|
||
const bucket = admin.storage().bucket();
|
||
console.log("🪣 [SunoCallback] Bucket:", bucket.name);
|
||
const saveOne = async (url, index) => {
|
||
if (!url) return null;
|
||
try {
|
||
console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`);
|
||
const resp = await axios.get(url, { responseType: "arraybuffer" });
|
||
const buffer = Buffer.from(resp.data);
|
||
const path = `musics/${projectId}/sound${index + 1}.mp3`;
|
||
const token = require("crypto").randomUUID();
|
||
const file = bucket.file(path);
|
||
await file.save(buffer, {
|
||
resumable: false,
|
||
metadata: {
|
||
contentType: "audio/mpeg",
|
||
cacheControl: "public, max-age=31536000",
|
||
metadata: { firebaseStorageDownloadTokens: token },
|
||
},
|
||
});
|
||
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 (e) {
|
||
console.error(
|
||
`❌ [SunoCallback] Échec save piste ${index + 1}:`,
|
||
e.message
|
||
);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const [p1, p2] = await Promise.all([
|
||
saveOne(audioUrls[0], 0),
|
||
saveOne(audioUrls[1], 1),
|
||
]);
|
||
|
||
// 4) Mettre à jour le statut du projet
|
||
try {
|
||
const musicUrls = [p1?.url, p2?.url].filter(Boolean);
|
||
await db.collection("projects").doc(projectId).set(
|
||
{
|
||
musicStatus: "GENERATED",
|
||
musicUrls,
|
||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||
},
|
||
{ merge: true }
|
||
);
|
||
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
|
||
projectId,
|
||
musicUrlsCount: musicUrls.length,
|
||
});
|
||
} catch (e) {
|
||
console.error("❌ [SunoCallback] Erreur maj projet:", e.message);
|
||
}
|
||
|
||
return res.status(200).json({
|
||
success: true,
|
||
projectId,
|
||
saved: [p1, p2].filter(Boolean),
|
||
});
|
||
} catch (error) {
|
||
logger.error("❌ [SunoCallback] Erreur interne:", error);
|
||
res
|
||
.status(500)
|
||
.json({ error: "Erreur interne du serveur", message: error.message });
|
||
}
|
||
});
|