From ea9bfb0f0584a4f744abde3ee24b34bf87601365 Mon Sep 17 00:00:00 2001 From: Thomas Demirdjian Date: Tue, 9 Sep 2025 13:51:20 +0200 Subject: [PATCH] auto timestamp lyricks when song selected --- functions/config/suno.js | 8 ++ functions/index.js | 5 +- functions/src/lyrics.js | 103 ++++++++++++++++++- functions/src/music.js | 169 ++++---------------------------- functions/src/project.js | 20 ++++ src/config/firebase.js | 6 +- src/screens/Studio/SongReady.js | 28 +----- 7 files changed, 158 insertions(+), 181 deletions(-) create mode 100644 functions/config/suno.js create mode 100644 functions/src/project.js diff --git a/functions/config/suno.js b/functions/config/suno.js new file mode 100644 index 0000000..fb9c938 --- /dev/null +++ b/functions/config/suno.js @@ -0,0 +1,8 @@ +exports.SUNO_API_BASE = "https://api.sunoapi.org"; +exports.SUNO_API_PATH = "/api/v1/generate"; +exports.SUNO_STATUS_PATH = "/api/v1/generate/record-info"; +exports.SUNO_TIMESTAMPED_LYRICS_PATH = + "/api/v1/generate/get-timestamped-lyrics"; +exports.SUNO_MODEL = "V4_5"; +exports.SUNO_CALLBACK_URL = + "https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback"; diff --git a/functions/index.js b/functions/index.js index 27e93ed..fe8b6af 100644 --- a/functions/index.js +++ b/functions/index.js @@ -4,9 +4,12 @@ const admin = require("firebase-admin"); // Avoid bundling a service account key and hardcoding project/bucket. admin.initializeApp(); -// Les fonctions IA ont été déplacées vers helpers/gemini +exports.refList = { + projects: admin.firestore().collection("projects"), +}; // Exporter toutes les fonctions exports.music = require("./src/music"); exports.lyrics = require("./src/lyrics"); exports.cover = require("./src/cover"); +exports.projects = require("./src/project"); diff --git a/functions/src/lyrics.js b/functions/src/lyrics.js index 4185335..9001001 100644 --- a/functions/src/lyrics.js +++ b/functions/src/lyrics.js @@ -1,6 +1,14 @@ const { onCall } = require("firebase-functions/v2/https"); const { z } = require("genkit"); const { generateAI, analyseLyrics } = require("../helpers/gemini"); +const { + SUNO_API_BASE, + SUNO_TIMESTAMPED_LYRICS_PATH, +} = require("../config/suno"); +const { SUNO_API_KEY } = require("../config/keys"); +const axios = require("axios"); +const admin = require("firebase-admin"); +const { refList } = require("../index"); exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => { try { @@ -101,14 +109,25 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => { lyrics: z .union([ z.string(), - z.object({ couplet: z.string().optional(), refrain: z.string().optional() }), - z.array(z.object({ type: z.string().optional(), lyrics: z.string().optional() })), + z.object({ + couplet: z.string().optional(), + refrain: z.string().optional(), + }), + z.array( + z.object({ + type: z.string().optional(), + lyrics: z.string().optional(), + }), + ), ]) .describe("Paroles à analyser (formats supportés)"), }); const parsed = requestSchema.parse(data || {}); - const result = await analyseLyrics({ title: parsed.title || "", lyrics: parsed.lyrics }); + const result = await analyseLyrics({ + title: parsed.title || "", + lyrics: parsed.lyrics, + }); // Prépare un message lisible pour le front const highCats = Object.entries(result.categories || {}) @@ -140,8 +159,82 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => { return { success: false, errorCode: "ANALYSE_FAILED", - message: - "Erreur lors de l'analyse de la toxicité. Réessayez plus tard.", + message: "Erreur lors de l'analyse de la toxicité. Réessayez plus tard.", }; } }); + +/** + * Récupère les timestamps (aligned words) pour une génération Suno + * Attend: { projectId: string } + */ +async function getSunoTimestamps(projectId) { + try { + const { sunoTaskId, songIndex } = ( + await refList.projects.doc(projectId).get() + ).data(); + if (!sunoTaskId) { + throw new Error("TaskId manquant"); + } + + if (songIndex < 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", { + sunoTaskId, + songIndex, + }); + + let response; + let parsed; + + response = await axios.post( + `${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`, + { taskId: sunoTaskId, musicIndex: songIndex }, + { + 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); + + const dataToReturn = parsed?.data || parsed || {}; + + await refList.projects.doc(projectId).set( + { + musicTimestamps: { + [songIndex]: dataToReturn, + }, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }, + { merge: true }, + ); + console.log("💾 Timestamps sauvegardés dans Firestore", { + projectId, + songIndex, + }); + return { + success: true, + }; + } catch (error) { + console.error("❌ Erreur interne getSunoTimestamps:", error); + return { + success: false, + error: { + message: error.message, + type: "INTERNAL_ERROR", + }, + }; + } +} + +exports.getSunoTimestamps = getSunoTimestamps; diff --git a/functions/src/music.js b/functions/src/music.js index 68af620..5b3112a 100644 --- a/functions/src/music.js +++ b/functions/src/music.js @@ -3,23 +3,17 @@ const axios = require("axios"); const admin = require("firebase-admin"); const { logger } = require("firebase-functions/logger"); const { SUNO_API_KEY } = require("../config/keys"); +const { + SUNO_MODEL, + SUNO_CALLBACK_URL, + SUNO_API_BASE, + SUNO_API_PATH, + SUNO_STATUS_PATH, +} = require("../config/suno"); -// Initialiser Firestore +// Initialiser Firestorey 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"; -// "https://music-sunocallback-ifdrkc2c3a-uc.a.run.app"; - /** * Limite la longueur d'une chaîne * @param {string} str - La chaîne à limiter @@ -45,7 +39,9 @@ function extractVoiceStrings(voice) { .filter(Boolean); } if (typeof voice === "object") { - return Object.values(voice).filter((v) => typeof v === "string" && v.trim()); + return Object.values(voice).filter( + (v) => typeof v === "string" && v.trim(), + ); } return []; } @@ -57,14 +53,17 @@ function voiceTagsFromUserChoice(voiceInput = "") { const v = String(txt || "").toLowerCase(); // Gender / ensemble - if (/féminin|feminin|féminine|feminine/.test(v)) tags.push("Female lead vocal"); + 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 (/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 (/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"); @@ -93,7 +92,10 @@ function voiceTagsFromUserChoice(voiceInput = "") { 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 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 @@ -430,135 +432,6 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => { } }); -/** - * 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", - }, - }; - } -}); - -// Removed Firestore onUpdate trigger for timestamps: using callable instead. - /** * Cloud Function pour recevoir les callbacks de l'API Suno * Cette fonction est appelée par l'API Suno lorsque la génération diff --git a/functions/src/project.js b/functions/src/project.js new file mode 100644 index 0000000..a451dda --- /dev/null +++ b/functions/src/project.js @@ -0,0 +1,20 @@ +const { onDocumentWritten } = require("firebase-functions/firestore"); +const { getSunoTimestamps } = require("./lyrics"); + +exports.onProjectUpdate = onDocumentWritten( + "projects/{projectId}", + async (projectSnap) => { + try { + const { projectId } = projectSnap.params; + + const currentData = projectSnap?.data?.after?.data() || null; + const previousData = projectSnap?.data?.before?.data() || null; + + if (!previousData?.songUrl && currentData?.songUrl) { + await getSunoTimestamps(projectId); + } + } catch (e) { + console.log(e); + } + }, +); diff --git a/src/config/firebase.js b/src/config/firebase.js index bdab143..0b3ba34 100644 --- a/src/config/firebase.js +++ b/src/config/firebase.js @@ -27,9 +27,9 @@ if (!firebase?.apps?.filter(({ name_ }) => name_ === "[DEFAULT]").length) { persistence: getReactNativePersistence(AsyncStorage), }); - if (__DEV__) { - firebase.functions().useEmulator("localhost", 5001); - } + // if (__DEV__) { + // firebase.functions().useEmulator("localhost", 5001); + // } console.log("Firebase init"); } diff --git a/src/screens/Studio/SongReady.js b/src/screens/Studio/SongReady.js index 7f02d9e..db881e7 100644 --- a/src/screens/Studio/SongReady.js +++ b/src/screens/Studio/SongReady.js @@ -34,10 +34,10 @@ const SongReady = () => { 1: { pos: 0, dur: 0 }, }); const player0 = useAudioPlayer( - musicUrls[0] ? { uri: musicUrls[0] } : undefined + musicUrls[0] ? { uri: musicUrls[0] } : undefined, ); const player1 = useAudioPlayer( - musicUrls[1] ? { uri: musicUrls[1] } : undefined + musicUrls[1] ? { uri: musicUrls[1] } : undefined, ); const wasPlayingBeforeSeek = useRef({ 0: false, 1: false }); @@ -120,26 +120,6 @@ const SongReady = () => { songUrl: url, updatedAt: firebase.firestore.FieldValue.serverTimestamp(), }); - // Déclenche la génération des timestamps immédiatement après avoir fixé la songUrl - try { - console.log("task id is : ", selectedProject?.sunoTaskId); - const taskId = selectedProject?.sunoTaskId || null; - if (taskId) { - const callable = firebase - .functions() - .httpsCallable("music-getSunoTimestamps"); - // Fire-and-forget (ne bloque pas l'UI) - callable({ - taskId, - musicIndex: Number(selectedIndex) || 0, - projectId, - }).catch((e) => - console.log("getSunoTimestamps (non-blocking)", e?.message) - ); - } - } catch (e) { - console.log("getSunoTimestamps init error", e?.message); - } await player0?.pause?.(); await player1?.pause?.(); navigate(Routes.FlowSelection); @@ -157,7 +137,7 @@ const SongReady = () => { player1?.pause?.(); } catch {} }; - }, [player0, player1]) + }, [player0, player1]), ); useEffect(() => { @@ -241,7 +221,7 @@ const SongReady = () => { } catch (e) { console.log( "SongReady pause on seek start", - e?.message + e?.message, ); } }}