fix function and music generation

This commit is contained in:
Thomas Demirdjian
2025-08-11 11:55:54 +02:00
parent 2a5a0149e8
commit f8c321b351
9 changed files with 5168 additions and 279 deletions
-25
View File
@@ -1,25 +0,0 @@
module.exports = {
env: {
es6: true,
node: true,
},
parserOptions: {
ecmaVersion: 2018,
},
extends: ["eslint:recommended", "google"],
rules: {
"no-restricted-globals": ["error", "name", "length"],
"prefer-arrow-callback": "error",
quotes: ["error", "double", { allowTemplateLiterals: true }],
},
overrides: [
{
files: ["**/*.spec.*"],
env: {
mocha: true,
},
rules: {},
},
],
globals: {},
};
+69
View File
@@ -0,0 +1,69 @@
# Dependencies
node_modules/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
logs/
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# Firebase
.firebase/
# Service account key
serviceAccountKey.json
# API keys
config/keys.js
# Build files
dist/
build/
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
+39
View File
@@ -0,0 +1,39 @@
const admin = require("firebase-admin");
const serviceAccount = require("./serviceAccountKey.json");
const { googleAI, gemini25FlashLite } = require("@genkit-ai/googleai");
const { GEMINI_API_KEY } = require("./config/keys");
const { genkit } = require("genkit");
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
storageBucket: "musicland-d33f9.firebasestorage.app",
});
exports.generateAI = async ({ system = "", prompt = "", schema }) => {
const genkitInstance = genkit({
plugins: [
googleAI({
apiKey: GEMINI_API_KEY,
}),
],
model: gemini25FlashLite,
});
if (prompt?.length < 1) {
throw new Error(
"Vous devez spécifier un prompt pour effectuer cette action.",
);
}
const { output } = await genkitInstance.generate({
system,
prompt,
output: { schema },
});
return output;
};
// Exporter toutes les fonctions
exports.music = require("./src/music");
exports.lyrics = require("./src/lyrics");
+4770
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "functions",
"description": "Cloud Functions for Firebase",
"scripts": {
"lint": "eslint .",
"serve": "firebase emulators:start --only functions",
"shell": "firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"engines": {
"node": "18"
},
"main": "index.js",
"dependencies": {
"@genkit-ai/googleai": "^1.16.0",
"axios": "^1.6.0",
"firebase-admin": "^12.1.0",
"firebase-functions": "^5.0.0",
"genkit": "^1.16.0"
},
"devDependencies": {
"eslint": "^8.15.0",
"eslint-config-google": "^0.14.0"
},
"private": true
}
+28 -26
View File
@@ -1,8 +1,8 @@
const {onCall} = require("firebase-functions/https"); const { onCall } = require("firebase-functions/v2/https");
const {z} = require("genkit"); const { z } = require("genkit");
const {generateAI} = require("../index"); const { generateAI } = require("../index");
exports.generateLyrics = onCall({}, async ({auth = {}, data = {}}) => { exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
try { try {
const { const {
objective = "Célébrer l'agence Minuit et mettre en avant son " + objective = "Célébrer l'agence Minuit et mettre en avant son " +
@@ -24,7 +24,8 @@ exports.generateLyrics = onCall({}, async ({auth = {}, data = {}}) => {
console.log("Function generate lyrics start with data", data); console.log("Function generate lyrics start with data", data);
const system = ` const system =
`
Tu es un parolier expert en chanson française moderne et populaire. Tu es un parolier expert en chanson française moderne et populaire.
Tu écris des chansons marquantes, accessibles, sur des thématiques ` + Tu écris des chansons marquantes, accessibles, sur des thématiques ` +
`d'innovation, d'esprit d'équipe et de créativité digitale. `d'innovation, d'esprit d'équipe et de créativité digitale.
@@ -39,7 +40,8 @@ Respecte ` +
`.trim(); `.trim();
// Construction du prompt utilisateur // Construction du prompt utilisateur
const prompt = ` const prompt =
`
Objectif : ${objective} Objectif : ${objective}
Contexte : ${context} Contexte : ${context}
Émotion : ${emotion} Émotion : ${emotion}
@@ -61,26 +63,26 @@ Pour chaque ` +
const lyricsSchema = z.object({ const lyricsSchema = z.object({
title: z.string().describe("Titre de la chanson, court et accrocheur"), title: z.string().describe("Titre de la chanson, court et accrocheur"),
lyrics: z lyrics: z
.array( .array(
z.object({ z.object({
type: z type: z
.string() .string()
.describe( .describe(
"Type de section : \"couplet\" ou \"refrain\" " + 'Type de section : "couplet" ou "refrain" ' +
"selon la structure", "selon la structure",
), ),
lyrics: z lyrics: z
.string() .string()
.describe( .describe(
"Paroles de la section, chaque ligne séparée " + "Paroles de la section, chaque ligne séparée " +
"par un retour à la ligne", "par un retour à la ligne",
), ),
}), }),
) )
.describe( .describe(
"Paroles de la chanson sous forme de tableau de sections " + "Paroles de la chanson sous forme de tableau de sections " +
"structurées.", "structurées.",
), ),
success: z.boolean().describe("Indique si la génération a réussi"), success: z.boolean().describe("Indique si la génération a réussi"),
}); });
+231 -225
View File
@@ -1,5 +1,5 @@
const { onCall, onRequest } = require("firebase-functions/v2/https"); const { onCall, onRequest } = require("firebase-functions/v2/https");
const fetch = require("node-fetch"); const axios = require("axios");
const admin = require("firebase-admin"); const admin = require("firebase-admin");
const { logger } = require("firebase-functions/logger"); const { logger } = require("firebase-functions/logger");
const { SUNO_API_KEY } = require("../config/keys"); const { SUNO_API_KEY } = require("../config/keys");
@@ -14,7 +14,7 @@ const SUNO_API_BASE = "https://api.sunoapi.org";
const SUNO_API_PATH = "/api/v1/generate"; const SUNO_API_PATH = "/api/v1/generate";
const SUNO_STATUS_PATH = "/api/v1/generate/record-info"; const SUNO_STATUS_PATH = "/api/v1/generate/record-info";
const SUNO_MODEL = "V4_5"; const SUNO_MODEL = "V4_5";
const SUNO_INSTRUMENTAL = false; // const SUNO_MODEL = "V3_5";
const SUNO_CALLBACK_URL = const SUNO_CALLBACK_URL =
"https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback"; "https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback";
@@ -66,7 +66,7 @@ function lyricsToTaggedPrompt(lyrics = []) {
const sections = lyrics const sections = lyrics
.map((section) => { .map((section) => {
if (!section || typeof section !== "object") return ""; if (!section || typeof section !== "object") return "";
const { type = "verse", content = "" } = section; const { type = "verse", lyrics: content = "" } = section;
if (!content.trim()) return ""; if (!content.trim()) return "";
const tag = type.toLowerCase(); const tag = type.toLowerCase();
@@ -88,7 +88,6 @@ function buildSunoPrompt({
projectContext = "", projectContext = "",
emotionGuide = "", emotionGuide = "",
styleGuide = "", styleGuide = "",
taggedLyrics = "",
}) { }) {
const parts = [ const parts = [
title ? `Titre: ${title}` : "", title ? `Titre: ${title}` : "",
@@ -96,7 +95,6 @@ function buildSunoPrompt({
projectContext ? `Contexte: ${projectContext}` : "", projectContext ? `Contexte: ${projectContext}` : "",
emotionGuide ? `Émotion: ${emotionGuide}` : "", emotionGuide ? `Émotion: ${emotionGuide}` : "",
styleGuide ? `Style: ${styleGuide}` : "", styleGuide ? `Style: ${styleGuide}` : "",
taggedLyrics ? `Paroles:\n${taggedLyrics}` : "",
].filter(Boolean); ].filter(Boolean);
return parts.join("\n\n"); return parts.join("\n\n");
@@ -145,197 +143,186 @@ function buildGuides({
/** /**
* Fonction principale de génération de musique * Fonction principale de génération de musique
*/ */
exports.generateMusic = onCall( exports.generateMusic = onCall(async ({ data = {} }) => {
{ region: "europe-west1" }, try {
async ({ data = {} }) => { const {
try { title = "",
const { lyrics = [],
title = "", genres = [],
lyrics = [], voice = "",
genres = [], instruments = [],
voice = "", tempo = "",
instruments = [], audience = "",
tempo = "", projectContext = "",
audience = "", } = data;
projectContext = "",
} = data;
const { styleGuide, emotionGuide } = buildGuides({ const { styleGuide, emotionGuide } = buildGuides({
genres, genres,
voice, voice,
instruments, instruments,
tempo, tempo,
}); });
const taggedLyrics = lyricsToTaggedPrompt(lyrics); const taggedLyrics = lyricsToTaggedPrompt(lyrics);
const safeTitle = clampLen(title, 80); const safeTitle = clampLen(title, 80);
// Construire les informations de contexte pour le style (sans les paroles) // Construire les informations de contexte pour le style
const contextInfo = buildSunoPrompt({ const contextInfo = buildSunoPrompt({
title: safeTitle, title: safeTitle,
audience, audience,
projectContext, projectContext,
emotionGuide, emotionGuide,
styleGuide, styleGuide,
taggedLyrics: "", // Ne pas inclure les paroles dans le contexte });
});
// Le style combine les informations de genre ET le contexte // Le style combine les informations de genre ET le contexte
const baseStyle = buildStyle({ genres, voice, instruments, tempo }); const baseStyle = buildStyle({ genres, voice, instruments, tempo });
const enhancedStyle = contextInfo const enhancedStyle = `${baseStyle}. ${contextInfo}`;
? `${baseStyle}. ${contextInfo}`
: baseStyle;
const payload = { const payload = {
customMode: true, customMode: true,
model: SUNO_MODEL, instrumental: false,
title: safeTitle, model: SUNO_MODEL,
style: clampLen(enhancedStyle, 600), prompt: clampLen(taggedLyrics, 3000),
instrumental: SUNO_INSTRUMENTAL, title: safeTitle,
callBackUrl: SUNO_CALLBACK_URL || "", style: clampLen(enhancedStyle, 600),
}; callBackUrl: SUNO_CALLBACK_URL || "",
};
// Les paroles vont dans le prompt, pas les instructions de style console.log("PAYLOAD", payload);
if (!SUNO_INSTRUMENTAL && taggedLyrics) {
payload.prompt = clampLen(taggedLyrics, 3000);
}
const response = await fetch(`${SUNO_API_BASE}${SUNO_API_PATH}`, { const response = await axios.post(
method: "POST", `${SUNO_API_BASE}${SUNO_API_PATH}`,
payload,
{
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${SUNO_API_KEY}`, Authorization: `Bearer ${SUNO_API_KEY}`,
}, },
body: JSON.stringify(payload), },
}); );
const raw = await response.text(); const parsed = response.data;
let parsed;
try { return {
parsed = JSON.parse(raw); success: !!parsed?.data?.taskId,
} catch (parseError) { request: {
console.error("❌ Erreur de parsing JSON:", parseError); model: SUNO_MODEL,
const { HttpsError } = require("firebase-functions/v2/https"); instrumental: false,
throw new HttpsError( title: safeTitle,
"internal", style: enhancedStyle,
`Réponse API invalide: ${raw.substring(0, 200)}...`, prompt: taggedLyrics,
); },
} response: parsed || {},
};
if (!response.ok) { } catch (error) {
console.error("❌ Erreur API Suno:", { console.error("❌ Erreur lors de la génération de musique:", error);
status: response.status, throw new Error(
statusText: response.statusText, `Erreur lors de la génération de musique: ${error.message}`,
body: parsed || raw, );
}); }
throw new Error( });
`Erreur API Suno (${response.status}): ${
(parsed && parsed.error) || response.statusText
}`,
);
}
return {
success: true,
request: {
model: SUNO_MODEL,
instrumental: SUNO_INSTRUMENTAL,
title: safeTitle,
style: enhancedStyle,
prompt: SUNO_INSTRUMENTAL ? undefined : taggedLyrics,
},
response: parsed || { raw },
};
} 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 * Fonction pour suivre le statut d'une génération de musique Suno
*/ */
exports.getSunoStatus = onCall( exports.getSunoStatus = onCall(async ({ data = {} }) => {
{ region: "europe-west1" }, try {
async ({ data = {} }) => { const { taskId } = data;
if (!taskId) {
throw new Error("TaskId manquant");
}
console.log("🔍 Vérification du statut Suno", { taskId });
let response;
let parsed;
try { try {
const { taskId } = data; response = await axios.get(
if (!taskId) {
throw new Error("TaskId manquant");
}
logger.info("🔍 Vérification du statut Suno", { taskId });
const response = await fetch(
`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, `${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`,
{ {
method: "GET",
headers: { headers: {
Authorization: `Bearer ${SUNO_API_KEY}`, Authorization: `Bearer ${SUNO_API_KEY}`,
}, },
timeout: 30000, // 30 secondes de timeout
}, },
); );
parsed = response.data;
const raw = await response.text(); console.log("📊 Réponse Suno API:", parsed);
let parsed; } catch (error) {
console.error("❌ Erreur détaillée API Suno Status:", {
try { message: error.message,
parsed = JSON.parse(raw); status: error.response?.status,
} catch (parseError) { statusText: error.response?.statusText,
console.error("❌ Erreur de parsing JSON:", parseError); data: error.response?.data,
throw new Error(`Réponse API invalide: ${raw.substring(0, 200)}...`); config: {
} url: error.config?.url,
headers: error.config?.headers,
if (!response.ok) { },
console.error("❌ Erreur API Suno Status:", {
status: response.status,
statusText: response.statusText,
body: parsed || raw,
});
// Gestion spéciale pour les taskId de test
if (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,
},
};
}
throw new Error(
`Erreur API Suno Status (${response.status}): ${
(parsed && parsed.error) || response.statusText
}`,
);
}
logger.info("✅ Statut Suno récupéré", {
taskId,
status: parsed && parsed.status,
}); });
// 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 { return {
success: true, success: false,
taskId, taskId,
data: parsed, error: {
status: errorStatus,
message: errorMessage,
type: "API_ERROR",
},
}; };
} catch (error) {
console.error("❌ Erreur lors de la vérification du statut:", error);
throw new Error(
`Erreur lors de la vérification du statut: ${error.message}`,
);
} }
},
); 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 * Cloud Function pour recevoir les callbacks de l'API Suno
@@ -344,23 +331,24 @@ exports.getSunoStatus = onCall(
*/ */
exports.sunoCallback = onRequest( exports.sunoCallback = onRequest(
{ {
region: "europe-west1",
cors: true,
methods: ["POST"], methods: ["POST"],
}, },
async (req, res) => { async (req, res) => {
try { try {
logger.info("🎵 Callback Suno reçu", { data: req.body }); console.log("🎵 Received music generation callback body:", req.body);
// Vérifier que c'est une requête POST
if (req.method !== "POST") { if (req.method !== "POST") {
logger.warn("⚠️ Méthode non autorisée:", req.method); console.warn("⚠️ Méthode non autorisée:", req.method);
return res.status(405).json({ error: "Méthode non autorisée" }); return res.status(405).json({ error: "Méthode non autorisée" });
} }
// Extraire les données du callback const { code, msg, data } = req.body || {};
console.log("🎵 Callback details:", { code, msg, data });
// Extraire les données du callback - peut être dans data ou directement dans req.body
const callbackData = data || req.body || {};
const { const {
id, id,
taskId,
status, status,
audio_url: audioUrl, audio_url: audioUrl,
video_url: videoUrl, video_url: videoUrl,
@@ -370,69 +358,87 @@ exports.sunoCallback = onRequest(
tags, tags,
duration, duration,
error_message: errorMessage, error_message: errorMessage,
} = req.body; } = callbackData;
// Vérifier que l'ID est présent // Normaliser le statut global et le taskId
if (!id) { const overallStatus = status || code || callbackData?.status || null;
logger.error("❌ ID manquant dans le callback"); const overallTaskId = callbackData?.taskId || callbackData?.task_id || null;
return res.status(400).json({ error: "ID manquant" });
// Déterminer les éléments piste(s) à traiter
const items = Array.isArray(callbackData?.response?.data)
? callbackData.response.data
: Array.isArray(callbackData?.sunoData)
? callbackData.sunoData
: id || audioUrl || imageUrl || title || tags || duration
? [callbackData]
: [];
if (!items.length) {
// Aucun contenu piste à mettre à jour: acquitter le callback sans erreur
return res.status(200).json({
success: true,
message: "Callback reçu (aucune piste à mettre à jour)",
taskId: overallTaskId,
status: overallStatus,
});
} }
// Chercher le document correspondant dans Firestore const updates = [];
const musicRef = db.collection("music").doc(id);
const musicDoc = await musicRef.get();
if (!musicDoc.exists) { for (const item of items) {
logger.error("❌ Document non trouvé pour l'ID:", id); const trackId = item.id || item.musicId || item.audioId || item.audio_id;
return res.status(404).json({ error: "Document non trouvé" }); if (!trackId) {
continue;
}
// Chercher le document correspondant dans Firestore
const musicRef = db.collection("music").doc(trackId);
const musicDoc = await musicRef.get();
if (!musicDoc.exists) {
// Si le doc n'existe pas, ignorer silencieusement cet item
continue;
}
const updateData = {
status: item.status || overallStatus,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
};
const audioU = item.audio_url || item.audioUrl || item.streamAudioUrl;
const videoU = item.video_url || item.videoUrl;
const imageU = item.image_url || item.imageUrl;
const lyricText = item.lyric || item.prompt;
const titleText = item.title;
const tagsText = item.tags;
const durationVal = item.duration;
const errMsg = item.error_message || item.errorMessage;
if (audioU) updateData.audioUrl = audioU;
if (videoU) updateData.videoUrl = videoU;
if (imageU) updateData.imageUrl = imageU;
if (lyricText) updateData.lyrics = lyricText;
if (titleText) updateData.title = titleText;
if (tagsText) updateData.style = tagsText;
if (durationVal) updateData.duration = durationVal;
if (errMsg) updateData.errorMessage = errMsg;
updates.push(
musicRef
.update(updateData)
.then(() => ({ id: trackId, ok: true }))
.catch(() => ({ id: trackId, ok: false })),
);
} }
// Préparer les données de mise à jour const results = await Promise.all(updates);
const updateData = { const updated = results.filter((r) => r.ok).map((r) => r.id);
status: status,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
};
// Ajouter les URLs si disponibles return res.status(200).json({
if (audioUrl) {
updateData.audioUrl = audioUrl;
}
if (videoUrl) {
updateData.videoUrl = videoUrl;
}
if (imageUrl) {
updateData.imageUrl = imageUrl;
}
if (lyric) {
updateData.lyrics = lyric;
}
if (title) {
updateData.title = title;
}
if (tags) {
updateData.style = tags;
}
if (duration) {
updateData.duration = duration;
}
if (errorMessage) {
updateData.errorMessage = errorMessage;
}
// Mettre à jour le document dans Firestore
await musicRef.update(updateData);
logger.info("✅ Document mis à jour avec succès", {
id: id,
status: status,
});
// Répondre avec succès
res.status(200).json({
success: true, success: true,
message: "Callback traité avec succès", message: "Callback traité avec succès",
id: id, updated,
status: status, taskId: overallTaskId,
status: overallStatus,
}); });
} catch (error) { } catch (error) {
logger.error("❌ Erreur lors du traitement du callback:", error); logger.error("❌ Erreur lors du traitement du callback:", error);
+1 -1
View File
@@ -28,7 +28,7 @@ if (!firebase?.apps?.filter(({ name_ }) => name_ === "[DEFAULT]").length) {
}); });
if (__DEV__) { if (__DEV__) {
firebase.functions().useEmulator("192.168.68.98", 5001); firebase.functions().useEmulator("192.168.1.81", 5001);
} }
console.log("Firebase init"); console.log("Firebase init");
} }
+2 -2
View File
@@ -7,8 +7,8 @@ import { navigate } from "../../navigation/NavigationService";
import { Routes } from "../../navigation"; import { Routes } from "../../navigation";
import { gutters } from "../../styles"; import { gutters } from "../../styles";
import firebase from "../../config/firebase"; import firebase from "../../config/firebase";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { responsiveHeight } from "react-native-responsive-dimensions"; import { responsiveHeight } from "react-native-responsive-dimensions";
import useMinuit from "react-native-minuit/src/hooks/useMinuit";
const Studio = () => { const Studio = () => {
const { setIsLoading } = useMinuit(); const { setIsLoading } = useMinuit();
@@ -70,7 +70,7 @@ const Studio = () => {
const { data } = await firebase const { data } = await firebase
.functions() .functions()
.httpsCallable("music-getSunoStatus")({ .httpsCallable("music-getSunoStatus")({
taskId: "1b04b24a9b95a46125a73f25144b74c2", taskId: "2c9f00d27a30ace15b1b7cbc36886bff",
}); });
console.log("🎵 Status data:", data); console.log("🎵 Status data:", data);