diff --git a/functions/.gitignore b/functions/.gitignore index 3a42a3f..605e0aa 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -42,12 +42,6 @@ jspm_packages/ # Firebase .firebase/ -# Service account key -serviceAccountKey.json - -# API keys -config/keys.js - # Build files dist/ build/ diff --git a/functions/config/keys.js b/functions/config/keys.js new file mode 100644 index 0000000..63ffad5 --- /dev/null +++ b/functions/config/keys.js @@ -0,0 +1,3 @@ +exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y"; +// exports.SUNO_API_KEY = "543b5650b30d93e98d8046a830244c80"; client api key +exports.SUNO_API_KEY = "138bace8405b869f917156440acb6fed"; // perso api key diff --git a/functions/serviceAccountKey.json b/functions/serviceAccountKey.json new file mode 100644 index 0000000..d448922 --- /dev/null +++ b/functions/serviceAccountKey.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "musicland-d33f9", + "private_key_id": "c1110a2aa31eb3ac7cf42d931249a2572bf68e37", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCZKlW0nTIg4Il2\nPtr/BYi247mWp8pxpCwS2ggwGBU7iO44jLQAgLWtZdHrQ7kpRfjVuceJDrkLrS82\ntkt8wQpytCFKfZjil9fdyS1hrVIQdfaPppovbRtzDpe0varA8XxgWXG8L+CQtCQd\nNhcixKONFhgOWTKB/tvEtGEExtpRRTb7y21o/EFbfpZqSbGEPcota2DPPvNh1r1y\nnhF7AcboGFTouDaTVL/oFIYYm3eWQP6nJRiM15xPqEhar+2z3lG9Y6HoRkkynFFj\nwvhQ6AUidA4A1ZW90HC33jsY5u7MAbzXY/QZ2MC82gaf26rda/7hThmk0RUSEaOv\n/G2f2lQBAgMBAAECggEAKnssdEDZQ3cjiTFbyjm9toQo2FlHdAeF1eC1PqmfUfjj\nobmcvzrzqNlBxM/uzjzDDHxPlHsJ5ewt2S3sd/iOrku5pBB70/UrgS8oMBxDU590\nOFmMEDVh1KKqfw3QC12vEoYcXoak8V2VWqjC9/i0KBCFsY0ZziZizAb/lbSVns52\nuJQhjvZURHQQOQmrZDm5mhklKCjE6qDxAMjL0yIhDQLAATzqeVPPeP8Rvq6u7yiB\noiB40OGiGHLdt28Q7IAXSQ+/Vqig7aw/qwrfZrFqgbgCYJKo1GiYFaDgRbH6GiaE\nhBJg7LtM0EaDR5G0raBWv5szqpfOb2DC6K802dcQRQKBgQDQm7HoJjmvEu9a6hxF\nVwS3rdJO67dTtxFMZM0HespQu659NT8f21QsRf/2JIcwktTnGzSeVTca0GZ4z8de\nkP9Wid0VPcA6Y3U7yplBf7Xh/5KW7/O6iSCEN03Y3/lH6vK6tBe5xCT3kfYKkrAd\n/xePLLBx++dAL5YFixMzBFfI5wKBgQC79i2S6Bcu6WQc0yykVHkAMStPz4gsAYA5\nqqCuWEwFcgEEY5AiP/oH056Hu2Z2HJHPp4OKYWSbqdxkNM214vysO0yhrF59BjQX\nyo+hwQfBqS0JfH7bJgUjNuDjP947qGtRXVo2i3FRJuZ4NmS2cGC9gT00p6rZRrbL\nJkaKSuVW1wKBgH3xsgxVn7fqd6Xs99qJOejnQbSyLUGWohP8O6jLYoAxf5xKXG0Q\nTxZXwLxLu28BBxbsXu1Ex3sh3OwNjfGzGuWVYGvllzvpNis+Gx7nStlCIq+L+Rti\nlMYeWZqZRng8CgXcmwJlwGfTsLcbbsb9bqzbLz/x9ODUmf+IwrOnaJR7AoGBALA7\nPjm6YFo8vAAGw5mEFjtm3owjMjLIaXgoEiXFZenJHUkHLNHgGEcUVFRBEe12FgST\ntFKfxFvrJFfT5ng+TdgSdWQ0oBcrJNaVtpvbx7D9NNvrHWnOOQzuXGbVsEFRROXx\n2C7CCUokZfyaS01/CkVEEqX2+spjceoBhiwN0v/bAoGBAK4sYw+3o3yjH26/M/Ni\nn5hwxofTktvAkYi7GlSFaRf2QwwxMBeA6qM/AJn7rer6pRzWwApbae/kOyfZfivq\nLnfpwloJMtds/Mm2egz0zoX7WIZ15uEx4vha2IUmi4Qz92kUzgaD1jTN8ZLUCypn\npR/zlJVkLv5BFqOtfX8shz1T\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-fbsvc@musicland-d33f9.iam.gserviceaccount.com", + "client_id": "114560176426659117925", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40musicland-d33f9.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/functions/src/lyrics.js b/functions/src/lyrics.js index 5223488..849cc3d 100644 --- a/functions/src/lyrics.js +++ b/functions/src/lyrics.js @@ -69,19 +69,19 @@ Pour chaque ` + .string() .describe( 'Type de section : "couplet" ou "refrain" ' + - "selon la structure", + "selon la structure" ), lyrics: z .string() .describe( "Paroles de la section, chaque ligne séparée " + - "par un retour à la ligne", + "par un retour à la ligne" ), - }), + }) ) .describe( "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"), }); diff --git a/functions/src/music.js b/functions/src/music.js index 723af71..178b407 100644 --- a/functions/src/music.js +++ b/functions/src/music.js @@ -13,6 +13,7 @@ const db = admin.firestore(); 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 = @@ -199,7 +200,7 @@ exports.generateMusic = onCall(async ({ data = {} }) => { "Content-Type": "application/json", Authorization: `Bearer ${SUNO_API_KEY}`, }, - }, + } ); const parsed = response.data; @@ -218,7 +219,7 @@ exports.generateMusic = onCall(async ({ data = {} }) => { } 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}`, + `Erreur lors de la génération de musique: ${error.message}` ); } }); @@ -247,7 +248,7 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => { Authorization: `Bearer ${SUNO_API_KEY}`, }, timeout: 30000, // 30 secondes de timeout - }, + } ); parsed = response.data; @@ -324,6 +325,131 @@ 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.${index}`]: 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 @@ -340,7 +466,9 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => { const body = req.body || {}; const code = body.code ?? body.statusCode ?? null; - const callbackType = (body?.data?.callbackType || "").toString().toLowerCase(); + const callbackType = (body?.data?.callbackType || "") + .toString() + .toLowerCase(); const status = (body.status || body.state || callbackType) .toString() .toLowerCase(); @@ -402,10 +530,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => { const audioUrls = tracks .map( (t) => - t.audio_url || - t.audioUrl || - t.stream_audio_url || - t.streamAudioUrl, + t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl ) .filter(Boolean) .slice(0, 2); @@ -420,7 +545,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => { has_audio_url: !!t.audio_url, has_stream_audio_url: !!t.stream_audio_url, })), - }, + } ); } @@ -445,14 +570,14 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => { }, }); const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent( - path, + 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, + e.message ); return null; } @@ -472,7 +597,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => { musicUrls, updatedAt: admin.firestore.FieldValue.serverTimestamp(), }, - { merge: true }, + { merge: true } ); console.log("🏷️ [SunoCallback] Projet marqué GENERATED", { projectId, diff --git a/src/screens/Playback/Playback.js b/src/screens/Playback/Playback.js index 9790bfd..0e4fc32 100644 --- a/src/screens/Playback/Playback.js +++ b/src/screens/Playback/Playback.js @@ -1,9 +1,11 @@ -import React from "react"; -import { Image, StyleSheet, View } from "react-native"; +import React, { useCallback } from "react"; +import { Alert, Image, StyleSheet, View } from "react-native"; +import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; import { ai, background } from "../../assets"; import BorderGradientButton from "../../components/BorderGradientButton"; import GradientButton from "../../components/GradientButton"; import MusicLandHeader from "../../components/MusicLandHeader"; +import firebase from "../../config/firebase"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; @@ -11,6 +13,53 @@ import { gutters } from "../../styles"; const Playback = ({ route }) => { const { project } = route.params; + const projectId = project?.id; + const songIndex = project?.songIndex; + const sunoTaskId = project?.sunoTaskId; + const { setIsLoading } = useMinuit(); + + const onPressRecord = useCallback(async () => { + try { + // If we lack required fields, skip generation but continue UX + if (!projectId || songIndex === undefined || !sunoTaskId) { + console.log("Missing data for timestamp generation", { + projectId, + songIndex, + sunoTaskId, + }); + // return navigate(Routes.RecordPlayback, { project }); + } + + await setIsLoading(true); + const callable = firebase + .functions() + .httpsCallable("music-getSunoTimestamps"); + const { data } = await callable({ + taskId: sunoTaskId, + musicIndex: Number(songIndex) || 0, + projectId: projectId, + }); + + if (!data?.success) { + console.log("getSunoTimestamps failed", data?.error || data); + Alert.alert( + "Timestamps", + "Impossible de récupérer les timestamps pour ce playback pour le moment. Tu peux quand même continuer." + ); + } + } catch (e) { + console.log("getSunoTimestamps error", e?.message || e); + Alert.alert( + "Timestamps", + "Une erreur est survenue lors de la génération des timestamps. Tu peux quand même continuer." + ); + } finally { + await setIsLoading(false); + navigate(Routes.RecordPlayback, { project }); + } + }, [projectId, songIndex, sunoTaskId, project]); + + console.log("Playback render", { projectId, songIndex, sunoTaskId }); return ( @@ -27,7 +76,7 @@ const Playback = ({ route }) => { {/* */} navigate(Routes.RecordPlayback, { project })} + onPress={onPressRecord} /> diff --git a/src/screens/Profile/Profile.js b/src/screens/Profile/Profile.js index bae65f9..1c48854 100644 --- a/src/screens/Profile/Profile.js +++ b/src/screens/Profile/Profile.js @@ -245,7 +245,7 @@ const Profile = () => { ))} {selected === "Playbacks" && ( - diff --git a/src/screens/Studio/SongReady.js b/src/screens/Studio/SongReady.js index 9988aa4..584e7db 100644 --- a/src/screens/Studio/SongReady.js +++ b/src/screens/Studio/SongReady.js @@ -1,21 +1,21 @@ -import React, { useEffect, useRef, useState } from "react"; -import Page from "../../layouts/Page"; -import { background, icons } from "../../assets"; -import MusicLandHeader from "../../components/MusicLandHeader"; -import { goBack, navigate } from "../../navigation/NavigationService"; -import Slider from "../../components/Slider"; -import { Image, Platform, Pressable, View, Text } from "react-native"; -import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; +import { useAudioPlayer } from "expo-audio"; import { BlurView } from "expo-blur"; -import { Style } from "../../styles"; -import { gutters, size } from "../../styles/Style"; +import React, { useEffect, useRef, useState } from "react"; +import { Image, Platform, Pressable, Text, View } from "react-native"; +import { responsiveHeight } from "react-native-responsive-dimensions"; +import { background, icons } from "../../assets"; import BorderGradientButton from "../../components/BorderGradientButton"; import GradientButton from "../../components/GradientButton"; -import { Routes } from "../../navigation"; import ValidateModal from "../../components/modal/ValidateModal"; +import MusicLandHeader from "../../components/MusicLandHeader"; +import Slider from "../../components/Slider"; import firebase, { projectsRef } from "../../config/firebase"; -import { useAudioPlayer } from "expo-audio"; -import { responsiveHeight } from "react-native-responsive-dimensions"; +import Page from "../../layouts/Page"; +import { Routes } from "../../navigation"; +import { goBack, navigate } from "../../navigation/NavigationService"; +import { Style } from "../../styles"; +import { gutters, size } from "../../styles/Style"; +import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; const SongReady = ({ route }) => { const { projectId = null } = route?.params || {}; @@ -28,10 +28,10 @@ const SongReady = ({ route }) => { 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 }); @@ -119,10 +119,11 @@ const SongReady = ({ route }) => { if (!projectId || !url) return; await projectsRef.doc(projectId).set( { + songIndex: selectedIndex, songUrl: url, updatedAt: firebase.firestore.FieldValue.serverTimestamp(), }, - { merge: true }, + { merge: true } ); navigate(Routes.FlowSelection, { projectId }); } catch (e) { @@ -206,7 +207,7 @@ const SongReady = ({ route }) => { } catch (e) { console.log( "SongReady pause on seek start", - e?.message, + e?.message ); } }}