Files
musicland/functions/src/music.js
T
2026-07-20 15:23:16 +02:00

643 lines
21 KiB
JavaScript

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 })
}
})