Files
musicland/functions/src/music.js
T
2025-09-03 11:32:56 +02:00

624 lines
17 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 } = 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 });
}
});