const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https') const axios = require('axios') const admin = require('firebase-admin') const { FieldValue } = require('firebase-admin/firestore') const { logger } = require('firebase-functions/logger') const { pipeline } = require('stream/promises') const { randomUUID } = require('crypto') const { ALERT_TYPE, refList } = require('../index') const { sendNotification } = require('./notifications') const { createOrderDocument, ORDER_TYPES } = require('./helpers/orders') const { SUNO_API_KEY } = require('../config/secrets') const { SUNO_MODEL, SUNO_CALLBACK_URL, SUNO_API_BASE, SUNO_API_PATH, SUNO_STATUS_PATH, } = require('../config/suno') const MUSIC_GENERATION_CREDIT_COST = 8 const MUSIC_REFUND_SOURCE = 'music_generation_refund' // ========================================== // 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO) // ========================================== const STYLE_MAP = { // Mapping du SONG_STYLE Upbeat: 'Upbeat, Happy, Feel-good', 'Grandios(e)': 'Grand, Cinematic, Orchestral', Chill: 'Chill, Relaxed, Downtempo', Epic: 'Epic, Heroic, Trailer Music', Dramatic: 'Dramatic, Intense, Theatrical', Comedic: 'Comedy, Novelty, Funny', Theatrical: 'Musical Theater, Broadway, Storytelling', Flamboyant: 'Flamboyant, Glam, Exuberant', Mélancolique: 'Melancholic, Sad, Emotional', Introspective: 'Introspective, Deep, Thoughtful', 'Urban Tragedy': 'Urban, Dark, Grit, Cinematic', Eerie: 'Eerie, Haunting, Spooky', Mysterious: 'Mysterious, Enigmatic, Suspenseful', } const GENRE_MAP = { // Mapping du CHOOSE_GENRE Pop: 'Pop', 'Hip Hop/Rap': 'Hip Hop, Rap', Soul: 'Soul, Neo-Soul', Blues: 'Blues, Delta Blues', Folk: 'Folk, Acoustic', Punk: 'Punk Rock, High Energy', Dance: 'Dance, Club', Grunge: 'Grunge, Distorted', EDM: 'EDM, Electronic', Trap: 'Trap, 808s', Latino: 'Latin, Reggaeton', Dancehall: 'Dancehall, Island', 'Latin Pop': 'Latin Pop', Raggaeton: 'Reggaeton, Urbano', Rock: 'Rock', 'Rock progressif': 'Prog Rock, Complex', 'Hard Rock': 'Hard Rock', Metal: 'Heavy Metal', 'R&B': 'R&B, Contemporary R&B', Phonk: 'Phonk, Memphis Rap, Drift', House: 'House, Deep House', Alternative: 'Alternative Rock, Indie', Indie: 'Indie Pop', Country: 'Country, Americana', Synthwave: 'Synthwave, Retrowave, 80s', Afrobeat: 'Afrobeat, African Rhythms', 'K-Pop': 'K-Pop, Idol', Techno: 'Techno, Minimal', Funk: 'Funk, Groove', Disco: 'Disco, Nu-Disco', 'New wave': 'New Wave, Post-Punk', Jazz: 'Jazz, Smooth Jazz', 'Lo-Fi': 'Lo-Fi, Chillhop', 'Bedroom Pop': 'Bedroom Pop, Dreamy', Ambient: 'Ambient, Atmospheric', 'Dream Pop': 'Dream Pop, Shoegaze', Grime: 'Grime, UK Rap', Hyperpop: 'Hyperpop, Glitch', Gospel: 'Gospel, Spiritual', } const INSTRUMENT_MAP = { 'Piano classique': 'Grand Piano', 'Piano éléctrique': 'Electric Piano, Rhodes', Synthétiseur: 'Synthesizer', 'Guitare acoustique': 'Acoustic Guitar', 'Guitare électrique': 'Electric Guitar', Batterie: 'Drums', Banjo: 'Banjo', Violon: 'Violin, Strings', Saxophone: 'Saxophone', 'Saxophone alto': 'Alto Sax', Trompette: 'Trumpet', Flûte: 'Flute', Clarinette: 'Clarinet', Djembe: 'Percussion, Djembe', Bongos: 'Bongos', Congas: 'Congas', Harmonica: 'Harmonica', Handpan: 'Handpan', Harpe: 'Harp', Xylophone: 'Xylophone, Mallets', Mandoline: 'Mandolin', Accordéon: 'Accordion', Orgue: 'Organ', Electronique: 'Electronic Fx', } const RHYTHM_MAP = { 'Très rapide': 'Very Fast Tempo, High BPM', Rapide: 'Fast Tempo', Normal: 'Mid-tempo', Lent: 'Slow Tempo, Downtempo', 'Très lent': 'Very Slow, Ballad', } // ========================================== // 2. FONCTIONS DE PARSING (VOIX & STRUCTURE) // ========================================== /** * Analyse les phrases longues du frontend pour extraire les tags vocaux Suno */ function parseVoiceTags(voiceSelections = []) { if (!Array.isArray(voiceSelections)) return [] // Concatène tout pour recherche regex (Base + Sensibilité + Technique) const fullText = voiceSelections.join(' ').toLowerCase() const tags = [] // Genre if (fullText.includes('féminine') || fullText.includes('femme')) tags.push('Female Vocals') if (fullText.includes('masculine') || fullText.includes('homme')) tags.push('Male Vocals') if (fullText.includes('deux voix') || fullText.includes('duo')) tags.push('Duet') if (fullText.includes('chœur') || fullText.includes('gospel')) tags.push('Choir, Backing Vocals') if (fullText.includes('enfant')) tags.push('Youthful Vocals') // Style / Technique if (fullText.includes('rap') || fullText.includes('slam')) tags.push('Rapping, Flow') if (fullText.includes('parlé') || fullText.includes('raconte')) tags.push('Spoken Word, Narration') if (fullText.includes('cri') || fullText.includes('scream')) tags.push('Screaming, Aggressive') if (fullText.includes('murmure') || fullText.includes('chuchote')) tags.push('Whispering, Intimate') if (fullText.includes('robot') || fullText.includes('synthétique')) tags.push('Autotune, Robotic') if (fullText.includes('puissant')) tags.push('Powerful, Belting') if (fullText.includes('aérienne') || fullText.includes('légère')) tags.push('Airy, Ethereal') if (fullText.includes('rauque') || fullText.includes('granuleuse')) tags.push('Raspy, Gritty') if (fullText.includes('opéra') || fullText.includes('soprano')) tags.push('Operatic') if (fullText.includes('sensuelle') || fullText.includes('séduisante')) tags.push('Seductive, Breathless') return [...new Set(tags)] } /** * Convertit les phrases de structure custom en Balises Suno */ function mapStructureToTag(description) { const d = description.toLowerCase() if (d.includes('intro') && d.includes('court')) return '[Short Intro]' if (d.includes('intro')) return '[Intro]' if (d.includes('pré-refrain')) return '[Pre-Chorus]' if (d.includes('solo guitare électrique')) return '[Electric Guitar Solo]' if (d.includes('solo guitare')) return '[Guitar Solo]' if (d.includes('solo batterie')) return '[Drum Solo]' if (d.includes('solo saxo')) return '[Saxophone Solo]' if (d.includes('solo violon')) return '[Violin Solo]' if (d.includes('interlude')) return '[Instrumental Interlude]' if (d.includes('apogée') || d.includes('finition')) return '[Big Finish]' if (d.includes('arrêt net')) return '[Sudden End]' if (d.includes('baissant le volume') || d.includes('fade')) return '[Fade Out]' if (d.includes('silence')) return '[Fade to Silence]' if (d.includes('break') || d.includes('pause')) return '[Break]' // Mapping standard des types lyrics if (d === 'couplet' || d === 'verse') return '[Verse]' if (d === 'refrain' || d === 'chorus') return '[Chorus]' if (d === 'pont' || d === 'bridge') return '[Bridge]' return null // Pas de tag trouvé } // ========================================== // 3. CONSTRUCTION DU STYLE ET DU PROMPT // ========================================== /** * Génère la chaîne de style optimisée (max ~200 chars) */ function buildSunoStyle({ genres, songStyle, voiceData, instruments, rhythm }) { const parts = [] // 1. Genres (Priorité 1) if (Array.isArray(genres)) { genres.forEach((g) => { if (GENRE_MAP[g]) parts.push(GENRE_MAP[g]) else parts.push(g) }) } // 2. Song Style / Vibe (Priorité 2) if (songStyle && STYLE_MAP[songStyle]) { parts.push(STYLE_MAP[songStyle]) } else if (songStyle && songStyle !== 'Autre') { parts.push(songStyle) } // 3. Rhythm (Priorité 3) if (rhythm && RHYTHM_MAP[rhythm]) { parts.push(RHYTHM_MAP[rhythm]) } // 4. Instruments if (Array.isArray(instruments)) { instruments.slice(0, 3).forEach((i) => { // Max 3 instruments pour ne pas diluer if (INSTRUMENT_MAP[i]) parts.push(INSTRUMENT_MAP[i]) }) } // 5. Vocals const vocalTags = parseVoiceTags(voiceData) parts.push(...vocalTags) // Tags de qualité technique (toujours ajoutés) parts.push('High Fidelity', 'Stereo') // Déduplication et join const uniqueStyle = [...new Set(parts)] return clampLen(uniqueStyle.join(', '), 250) } /** * Construit le texte final des paroles avec les balises de structure */ function buildFormattedLyrics(lyricsArray) { if (!Array.isArray(lyricsArray) || lyricsArray.length === 0) return '' const formattedLines = lyricsArray.map((section) => { // Le frontend peut envoyer soit { type: "...", lyrics: "..." } soit juste une string description pour les instrumentaux const typeOrDescription = section.type || section.description || '' const textContent = section.lyrics || '' // Essayer de trouver un tag spécial (ex: "Introduction instrumentale") let tag = mapStructureToTag(typeOrDescription) // Fallback si pas de tag spécial trouvé mais type standard if (!tag) { if (typeOrDescription.toLowerCase().includes('couplet')) tag = '[Verse]' else if (typeOrDescription.toLowerCase().includes('refrain')) tag = '[Chorus]' else tag = `[${typeOrDescription}]` // Fallback générique } // Si c'est une section instrumentale (pas de lyrics) if (!textContent.trim()) { return `\n${tag}\n` } return `\n${tag}\n${textContent.trim()}` }) // Sécurité: Ajouter Intro et Outro si absents (Suno best practice) const fullText = formattedLines.join('\n') let finalPrompt = fullText if (!fullText.includes('[Intro]')) { finalPrompt = '[Intro]\n' + finalPrompt } if ( !fullText.includes('[Outro]') && !fullText.includes('[Fade Out]') && !fullText.includes('[Sudden End]') ) { finalPrompt = finalPrompt + '\n\n[Outro]' } return finalPrompt.trim() } // ========================================== // 4. FONCTIONS UTILITAIRES DE BASE (GARDÉES) // ========================================== function clampLen(str = '', max) { if (!max) return str || '' if (!str) return '' return str.length <= max ? str : str.slice(0, max) } const sanitizeField = (value, fallback = null) => { const cleaned = typeof value === 'string' ? value.trim() : value return typeof cleaned !== 'string' || !cleaned ? fallback : cleaned } const sanitizeMusicUrls = (urls = []) => (Array.isArray(urls) ? urls : []) .filter((url) => typeof url === 'string' && url.trim()) .map((url) => url.trim()) const formatProjectMeta = (projectData = {}) => { const userId = sanitizeField(projectData?.userId) const projectTitle = sanitizeField(projectData?.title, 'ton projet') return { userId, projectTitle } } const parseSunoCallbackPayload = (rawBody = {}) => { const body = rawBody || {} const code = body.code ?? body.statusCode ?? null const callbackType = (body?.data?.callbackType || '').toString().toLowerCase() const status = (body.status || body.state || callbackType || '').toString().toLowerCase() const taskId = sanitizeField(body?.data?.task_id) const tracks = Array.isArray(body?.data?.data) ? body.data.data : Array.isArray(body.data) ? body.data : [] return { code, status, taskId, tracks } } const extractAudioUrlsFromTracks = (tracks = []) => tracks .map((t) => t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl) .filter(Boolean) .slice(0, 2) const fetchProjectByTaskId = async (taskId) => { const snapshot = await refList.projects.where('sunoTaskId', '==', taskId).limit(1).get() if (snapshot.empty) throw new Error('PROJECT_NOT_FOUND_FOR_TASK') const doc = snapshot.docs[0] return { projectId: doc.id, projectData: doc.data() || {}, projectRef: doc.ref, } } // ... (Garde refundMusicCredits, markProjectMusicFailure, downloadTrackToStorage, saveTracksToStorage, mergeMusicUrls inchangés) ... // Je remets les versions raccourcies pour l'exemple mais utilise tes fonctions existantes pour le stockage/db const refundMusicCredits = async ({ projectId, userId, reason = 'music_generation_failed', context = {}, }) => { if (!projectId || !userId || MUSIC_GENERATION_CREDIT_COST <= 0) return null try { const metadata = { source: MUSIC_REFUND_SOURCE, reason, projectId, ...context, } const { orderId } = await createOrderDocument({ userId, type: ORDER_TYPES.SONG, amount: MUSIC_GENERATION_CREDIT_COST, songId: projectId, createdBy: 'system', metadata, }) logger.log('💸 [Music] Crédits remboursés', { projectId, userId, orderId }) return { orderId } } catch (error) { logger.error('❌ [Music] Échec remboursement', { projectId, userId, error: error?.message, }) return null } } async function markProjectMusicFailure(projectId, error) { if (!projectId) return try { const docRef = refList.projects.doc(projectId) const projectSnap = await docRef.get() const projectData = projectSnap?.data() || {} const status = error?.response?.status || error?.status || null const sunoMessage = error?.response?.data?.msg || error?.response?.data?.message || error?.message || 'Erreur' const errorPayload = { source: 'SUNO_API', message: sunoMessage } const receiverId = sanitizeField(projectData?.userId) const alreadyRefunded = projectData?.musicCreditsRefunded === true let refundResult = null if (receiverId && !alreadyRefunded) { refundResult = await refundMusicCredits({ projectId, userId: receiverId, reason: sunoMessage, }) } await docRef.set( { musicStatus: 'FAILED', musicError: errorPayload, updatedAt: FieldValue.serverTimestamp(), }, { merge: true } ) // (Notification logic here...) } catch (err) { console.error('Error marking failure', err) } } const downloadTrackToStorage = async (url, { userId, projectId, taskId, bucket, index }) => { if (!url) return null try { const resp = await axios.get(url, { responseType: 'stream' }) const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3` const token = randomUUID() const file = bucket.file(path) const writeStream = file.createWriteStream({ resumable: false, metadata: { contentType: 'audio/mpeg', metadata: { firebaseStorageDownloadTokens: token }, }, }) await pipeline(resp.data, writeStream) return { path, url: `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`, } } catch (error) { return null } } const saveTracksToStorage = async (audioUrls, meta) => { if (!audioUrls?.length) return [] const bucket = admin.storage().bucket() const results = await Promise.all( audioUrls.map((url, index) => downloadTrackToStorage(url, { ...meta, bucket, index })) ) return results.filter(Boolean).map((entry) => entry.url) } const mergeMusicUrls = async (projectRef, newUrls) => { const sanitized = sanitizeMusicUrls(newUrls) let existing = [] try { existing = sanitizeMusicUrls((await projectRef.get())?.data()?.musicUrls) } catch (e) { } const final = [...new Set([...existing, ...sanitized])] await projectRef.set( { musicStatus: 'GENERATED', musicUrls: final, musicError: FieldValue.delete(), }, { merge: true } ) return final } // ========================================== // 5. FONCTIONS PRINCIPALES (CLOUD FUNCTIONS) // ========================================== exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }) => { try { // Extraction des données du Frontend const { title = '', lyrics = [], // Tableau d'objets {type, lyrics} ou strings genres = [], // ["Pop", "Rock"] songStyle = '', // "Upbeat" voice = [], // ["Une voix féminine...", "Un cri brut..."] (array ou objet) instruments = [], // ["Piano", "Violon"] rhythm = '', // "Très rapide" // Les champs suivants (step 1) ne sont plus utilisés dans le style Suno direct pour éviter le bruit, // mais ont déjà servi à générer les paroles (lyrics). // audience, context, objective, etc. } = data // Normalisation input Voix (peut être array, string ou objet selon ta spec) let voiceData = [] if (Array.isArray(voice)) { voiceData = voice .map((v) => { if (typeof v === 'object' && v !== null && v.value) return v.value if (typeof v === 'string') return v return '' }) .filter(Boolean) } else if (typeof voice === 'object' && voice !== null) { voiceData = Object.values(voice).flat() } else if (typeof voice === 'string') { voiceData = [voice] } // 1. Construction du STYLE MUSICAL (Tags) const optimizedStyle = buildSunoStyle({ genres, songStyle, voiceData, instruments, rhythm, }) // 2. Construction du PROMPT (Paroles + Structure) const formattedPrompt = buildFormattedLyrics(lyrics) const safeTitle = clampLen(title, 80) // 3. Détection du genre vocal pour le param vocalGender (optimisation v3.5) // On regarde si on trouve 'male' ou 'female' dans les tags générés let vGender = null if (optimizedStyle.includes('Female')) vGender = 'female' else if (optimizedStyle.includes('Male')) vGender = 'male' console.log('🎵 [Suno Optimisation] Result:', { style: optimizedStyle, gender: vGender, title: safeTitle, promptStructure: formattedPrompt.substring(0, 150) + '...', // Aperçu }) // 4. Appel API const payload = { customMode: true, instrumental: false, model: SUNO_MODEL || 'chirp-v3-5', // Toujours viser le dernier modèle prompt: clampLen(formattedPrompt, 3000), title: safeTitle, style: optimizedStyle, callBackUrl: SUNO_CALLBACK_URL || '', } if (vGender) payload.vocalGender = vGender const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${SUNO_API_KEY.value()}`, }, }) const parsed = response.data return { success: !!parsed?.data?.taskId, request: { model: payload.model, title: safeTitle, style: optimizedStyle, promptPreview: formattedPrompt.substring(0, 50) + '...', }, response: parsed || {}, } } catch (error) { console.error('❌ Erreur generateMusic:', error) try { await markProjectMusicFailure(data?.projectId, error) } catch (e) { } const status = error?.response?.status || 'INTERNAL_ERROR' const message = error?.response?.data?.msg || error?.message || 'Erreur Suno' throw new HttpsError('internal', message, { status, source: 'SUNO_API' }) } }) // GET STATUS (inchangé mais inclus pour complétude) exports.getSunoStatus = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }) => { const { taskId } = data if (!taskId) throw new Error('TaskId manquant') try { const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, { headers: { Authorization: `Bearer ${SUNO_API_KEY.value()}` }, timeout: 30000, }) return { success: true, taskId, data: response.data?.data || response.data, } } catch (error) { if (taskId.includes('test')) return { success: true, taskId, data: { status: 'not_found', isTestId: true }, } return { success: false, taskId, error: { message: error.message } } } }) // CALLBACK (Standard) exports.sunoCallback = onRequest({ methods: ['POST'], memory: '1GiB' }, async (req, res) => { if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }) const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body) if (code !== 200 || status !== 'complete' || !taskId) return res.status(200).json({ success: true, ignored: true }) try { const { projectId, projectData, projectRef } = await fetchProjectByTaskId(taskId) const { userId, projectTitle } = formatProjectMeta(projectData) const storedUrls = await saveTracksToStorage(extractAudioUrlsFromTracks(tracks), { userId, projectId, taskId, }) const musicUrls = await mergeMusicUrls(projectRef, storedUrls) if (userId) { try { await sendNotification({ sender: 'SYSTEM', receiver: userId, receiverCollection: 'users', title: 'Musique prête', message: `Ta musique pour "${projectTitle}" est prête.`, data: { type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS, projectId, projectTitle, musicUrls, taskId, }, }) } catch (e) { console.error(e) } } return res.status(200).json({ success: true, projectId }) } catch (error) { // Gestion erreur silencieuse pour le webhook return res.status(500).json({ error: error.message }) } })