auto timestamp lyricks when song selected

This commit is contained in:
Thomas Demirdjian
2025-09-09 13:51:20 +02:00
parent 9a9de33ef2
commit ea9bfb0f05
7 changed files with 158 additions and 181 deletions
+8
View File
@@ -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";
+4 -1
View File
@@ -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");
+98 -5
View File
@@ -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;
+21 -148
View File
@@ -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
+20
View File
@@ -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);
}
},
);
+3 -3
View File
@@ -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");
}
+4 -24
View File
@@ -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,
);
}
}}