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
}
+6 -4
View File
@@ -1,4 +1,4 @@
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");
@@ -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}
@@ -66,7 +68,7 @@ Pour chaque ` +
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
+151 -145
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,9 +143,7 @@ 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" },
async ({ data = {} }) => {
try { try {
const { const {
title = "", title = "",
@@ -170,82 +166,54 @@ exports.generateMusic = onCall(
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,
instrumental: false,
model: SUNO_MODEL, model: SUNO_MODEL,
prompt: clampLen(taggedLyrics, 3000),
title: safeTitle, title: safeTitle,
style: clampLen(enhancedStyle, 600), style: clampLen(enhancedStyle, 600),
instrumental: SUNO_INSTRUMENTAL,
callBackUrl: SUNO_CALLBACK_URL || "", 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();
let parsed;
try {
parsed = JSON.parse(raw);
} catch (parseError) {
console.error("❌ Erreur de parsing JSON:", parseError);
const { HttpsError } = require("firebase-functions/v2/https");
throw new HttpsError(
"internal",
`Réponse API invalide: ${raw.substring(0, 200)}...`,
); );
}
if (!response.ok) { const parsed = response.data;
console.error("❌ Erreur API Suno:", {
status: response.status,
statusText: response.statusText,
body: parsed || raw,
});
throw new Error(
`Erreur API Suno (${response.status}): ${
(parsed && parsed.error) || response.statusText
}`,
);
}
return { return {
success: true, success: !!parsed?.data?.taskId,
request: { request: {
model: SUNO_MODEL, model: SUNO_MODEL,
instrumental: SUNO_INSTRUMENTAL, instrumental: false,
title: safeTitle, title: safeTitle,
style: enhancedStyle, style: enhancedStyle,
prompt: SUNO_INSTRUMENTAL ? undefined : taggedLyrics, prompt: taggedLyrics,
}, },
response: parsed || { raw }, response: parsed || {},
}; };
} catch (error) { } catch (error) {
console.error("❌ Erreur lors de la génération de musique:", error); console.error("❌ Erreur lors de la génération de musique:", error);
@@ -253,15 +221,12 @@ exports.generateMusic = onCall(
`Erreur lors de la génération de musique: ${error.message}`, `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" },
async ({ data = {} }) => {
try { try {
const { taskId } = data; const { taskId } = data;
@@ -269,37 +234,42 @@ exports.getSunoStatus = onCall(
throw new Error("TaskId manquant"); throw new Error("TaskId manquant");
} }
logger.info("🔍 Vérification du statut Suno", { taskId }); console.log("🔍 Vérification du statut Suno", { taskId });
const response = await fetch( let response;
`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${SUNO_API_KEY}`,
},
},
);
const raw = await response.text();
let parsed; let parsed;
try { try {
parsed = JSON.parse(raw); response = await axios.get(
} catch (parseError) { `${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`,
console.error("❌ Erreur de parsing JSON:", parseError); {
throw new Error(`Réponse API invalide: ${raw.substring(0, 200)}...`); headers: {
} Authorization: `Bearer ${SUNO_API_KEY}`,
},
timeout: 30000, // 30 secondes de timeout
},
);
parsed = response.data;
if (!response.ok) { console.log("📊 Réponse Suno API:", parsed);
console.error("❌ Erreur API Suno Status:", { } catch (error) {
status: response.status, console.error("❌ Erreur détaillée API Suno Status:", {
statusText: response.statusText, message: error.message,
body: parsed || raw, 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 // Gestion spéciale pour les taskId de test
if (response.status === 404 && taskId.includes("test")) { if (
error.response &&
error.response.status === 404 &&
taskId.includes("test")
) {
return { return {
success: true, success: true,
taskId, taskId,
@@ -311,31 +281,48 @@ exports.getSunoStatus = onCall(
}; };
} }
throw new Error( // Retourner une erreur plus descriptive
`Erreur API Suno Status (${response.status}): ${ const errorStatus = error.response?.status || "UNKNOWN";
(parsed && parsed.error) || response.statusText 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",
},
};
} }
logger.info("✅ Statut Suno récupéré", { console.log("✅ Statut Suno récupéré", {
taskId, taskId,
status: parsed && parsed.status, status: parsed?.data?.status || parsed?.status,
}); });
return { return {
success: true, success: true,
taskId, taskId,
data: parsed, data: parsed?.data || parsed,
}; };
} catch (error) { } catch (error) {
console.error("❌ Erreur lors de la vérification du statut:", 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}`, 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,
});
}
const updates = [];
for (const item of items) {
const trackId = item.id || item.musicId || item.audioId || item.audio_id;
if (!trackId) {
continue;
} }
// Chercher le document correspondant dans Firestore // Chercher le document correspondant dans Firestore
const musicRef = db.collection("music").doc(id); const musicRef = db.collection("music").doc(trackId);
const musicDoc = await musicRef.get(); const musicDoc = await musicRef.get();
if (!musicDoc.exists) { if (!musicDoc.exists) {
logger.error("❌ Document non trouvé pour l'ID:", id); // Si le doc n'existe pas, ignorer silencieusement cet item
return res.status(404).json({ error: "Document non trouvé" }); continue;
} }
// Préparer les données de mise à jour
const updateData = { const updateData = {
status: status, status: item.status || overallStatus,
updatedAt: admin.firestore.FieldValue.serverTimestamp(), updatedAt: admin.firestore.FieldValue.serverTimestamp(),
}; };
// Ajouter les URLs si disponibles const audioU = item.audio_url || item.audioUrl || item.streamAudioUrl;
if (audioUrl) { const videoU = item.video_url || item.videoUrl;
updateData.audioUrl = audioUrl; const imageU = item.image_url || item.imageUrl;
} const lyricText = item.lyric || item.prompt;
if (videoUrl) { const titleText = item.title;
updateData.videoUrl = videoUrl; const tagsText = item.tags;
} const durationVal = item.duration;
if (imageUrl) { const errMsg = item.error_message || item.errorMessage;
updateData.imageUrl = imageUrl;
} if (audioU) updateData.audioUrl = audioU;
if (lyric) { if (videoU) updateData.videoUrl = videoU;
updateData.lyrics = lyric; if (imageU) updateData.imageUrl = imageU;
} if (lyricText) updateData.lyrics = lyricText;
if (title) { if (titleText) updateData.title = titleText;
updateData.title = title; if (tagsText) updateData.style = tagsText;
} if (durationVal) updateData.duration = durationVal;
if (tags) { if (errMsg) updateData.errorMessage = errMsg;
updateData.style = tags;
} updates.push(
if (duration) { musicRef
updateData.duration = duration; .update(updateData)
} .then(() => ({ id: trackId, ok: true }))
if (errorMessage) { .catch(() => ({ id: trackId, ok: false })),
updateData.errorMessage = errorMessage; );
} }
// Mettre à jour le document dans Firestore const results = await Promise.all(updates);
await musicRef.update(updateData); const updated = results.filter((r) => r.ok).map((r) => r.id);
logger.info("✅ Document mis à jour avec succès", { return res.status(200).json({
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);