feat: try lipsinck

This commit is contained in:
2026-09-03 11:06:08 +02:00
parent 99431f9ae4
commit 7521f5939f
9 changed files with 196 additions and 88 deletions
+41 -6
View File
@@ -259,7 +259,7 @@ ${structureTags || fallbackStructureTags}
0. ${languageInstruction} 0. ${languageInstruction}
1. Génère un **Titre** percutant (moins de 6 mots). 1. Génère un **Titre** percutant (moins de 6 mots).
2. Pour chaque section <SECTION>, écris le contenu adapté : 2. Pour chaque section <SECTION>, écris le contenu adapté :
- Si c'est "Instrumental" ou "Solo" : laisse le champ 'lyrics' vide ou mets une brève indication d'ambiance entre parenthèses ex: "(Solo de guitare déchirant)". - Si c'est "Instrumental", "Intro", "Pont" ou "Solo" : le champ 'lyrics' doit être strictement vide. N'ajoute aucune indication entre parenthèses.
- Si c'est "Couplet/Refrain" : Écris 4 à 12 vers. - Si c'est "Couplet/Refrain" : Écris 4 à 12 vers.
3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre. 3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre.
</CONSIGNES_GENERATION> </CONSIGNES_GENERATION>
@@ -428,15 +428,16 @@ async function getSunoTimestamps(projectId) {
const docSnap = await refList.projects.doc(projectId).get() const docSnap = await refList.projects.doc(projectId).get()
if (!docSnap.exists) throw new Error('Projet introuvable') if (!docSnap.exists) throw new Error('Projet introuvable')
const { sunoTaskId, songIndex } = docSnap.data() const { songSunoTaskId, sunoAudioId, songIndex } = docSnap.data()
if (!sunoTaskId) throw new Error('TaskId manquant') if (!songSunoTaskId) throw new Error('TaskId de la piste manquant')
if (!sunoAudioId) throw new Error('AudioId Suno manquant')
if (songIndex === undefined || songIndex < 0) throw new Error('musicIndex invalide') if (songIndex === undefined || songIndex < 0) throw new Error('musicIndex invalide')
console.log('🔎 getSunoTimestamps', { sunoTaskId, songIndex }) console.log('🔎 getSunoTimestamps', { songSunoTaskId, sunoAudioId, songIndex })
const response = await axios.post( const response = await axios.post(
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`, `${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
{ taskId: sunoTaskId, musicIndex: songIndex }, { taskId: songSunoTaskId, audioId: sunoAudioId },
{ {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -446,11 +447,32 @@ async function getSunoTimestamps(projectId) {
} }
) )
const dataToReturn = response.data?.data || response.data || {} if (response.data?.code !== 200) {
throw new Error(response.data?.msg || 'Récupération des timestamps refusée par Suno')
}
const dataToReturn = response.data?.data
if (!Array.isArray(dataToReturn?.alignedWords) || dataToReturn.alignedWords.length === 0) {
throw new Error('Aucune parole synchronisée retournée par Suno')
}
const failedWordCount = dataToReturn.alignedWords.filter(
(word) => word?.success === false
).length
const hootCer = Number(dataToReturn.hootCer)
await refList.projects.doc(projectId).set( await refList.projects.doc(projectId).set(
{ {
musicTimestamps: { [songIndex]: dataToReturn }, musicTimestamps: { [songIndex]: dataToReturn },
lyricsSyncStatus: 'READY',
lyricsSyncError: FieldValue.delete(),
lyricsSyncDiagnostics: {
alignedWordCount: dataToReturn.alignedWords.length,
failedWordCount,
successfulWordRatio:
(dataToReturn.alignedWords.length - failedWordCount) /
dataToReturn.alignedWords.length,
hootCer: Number.isFinite(hootCer) ? hootCer : null,
},
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
{ merge: true } { merge: true }
@@ -459,6 +481,19 @@ async function getSunoTimestamps(projectId) {
return { success: true } return { success: true }
} catch (error) { } catch (error) {
console.error('❌ getSunoTimestamps Error:', error.message) console.error('❌ getSunoTimestamps Error:', error.message)
if (projectId) {
await refList.projects
.doc(projectId)
.set(
{
lyricsSyncStatus: 'FAILED',
lyricsSyncError: error.message,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
.catch(() => {})
}
return { return {
success: false, success: false,
error: { message: error.message, type: 'INTERNAL_ERROR' }, error: { message: error.message, type: 'INTERNAL_ERROR' },
+126 -75
View File
@@ -19,6 +19,8 @@ const {
const MUSIC_GENERATION_CREDIT_COST = 8 const MUSIC_GENERATION_CREDIT_COST = 8
const MUSIC_REFUND_SOURCE = 'music_generation_refund' const MUSIC_REFUND_SOURCE = 'music_generation_refund'
const SUNO_LYRICS_PROMPT_MAX_LENGTH = 5000
const SUNO_TITLE_MAX_LENGTH = 100
// ========================================== // ==========================================
// 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO) // 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO)
@@ -161,30 +163,54 @@ function parseVoiceTags(voiceSelections = []) {
/** /**
* Convertit les phrases de structure custom en Balises Suno * Convertit les phrases de structure custom en Balises Suno
*/ */
function mapStructureToTag(description) { const STRUCTURE_TAGS = {
const d = description.toLowerCase() couplet: '[Verse]',
refrain: '[Chorus]',
short_intro: '[Short Instrumental Intro]',
long_intro: '[Long Instrumental Intro]',
pre_refrain_instrumental: '[Instrumental Pre-Chorus]',
pont: '[Instrumental Bridge]',
solo_de_guitare: '[Guitar Solo]',
solo_de_guitare_electrique: '[Electric Guitar Solo]',
solo_de_batterie: '[Drum Solo]',
solo_de_saxophone: '[Saxophone Solo]',
solo_de_violon: '[Violin Solo]',
break: '[Instrumental Break]',
interlude: '[Instrumental Interlude]',
interlude_melodique: '[Melodic Instrumental Interlude]',
final_apogee: '[Big Finish]',
arret_net: '[Sudden End]',
fade_out: '[Fade Out]',
transition_douce: '[Fade to Silence]',
}
if (d.includes('intro') && d.includes('court')) return '[Short Intro]' const INSTRUMENTAL_STRUCTURE_TYPES = new Set([
if (d.includes('intro')) return '[Intro]' 'short_intro',
if (d.includes('pré-refrain')) return '[Pre-Chorus]' 'long_intro',
if (d.includes('solo guitare électrique')) return '[Electric Guitar Solo]' 'pre_refrain_instrumental',
if (d.includes('solo guitare')) return '[Guitar Solo]' 'pont',
if (d.includes('solo batterie')) return '[Drum Solo]' 'solo_de_guitare',
if (d.includes('solo saxo')) return '[Saxophone Solo]' 'solo_de_guitare_electrique',
if (d.includes('solo violon')) return '[Violin Solo]' 'solo_de_batterie',
if (d.includes('interlude')) return '[Instrumental Interlude]' 'solo_de_saxophone',
if (d.includes('apogée') || d.includes('finition')) return '[Big Finish]' 'solo_de_violon',
if (d.includes('arrêt net')) return '[Sudden End]' 'break',
if (d.includes('baissant le volume') || d.includes('fade')) return '[Fade Out]' 'interlude',
if (d.includes('silence')) return '[Fade to Silence]' 'interlude_melodique',
if (d.includes('break') || d.includes('pause')) return '[Break]' 'final_apogee',
'arret_net',
'fade_out',
'transition_douce',
])
// Mapping standard des types lyrics function mapStructureToTag(type) {
if (d === 'couplet' || d === 'verse') return '[Verse]' return (
if (d === 'refrain' || d === 'chorus') return '[Chorus]' STRUCTURE_TAGS[
if (d === 'pont' || d === 'bridge') return '[Bridge]' String(type || '')
.trim()
return null // Pas de tag trouvé .toLowerCase()
] || null
)
} }
// ========================================== // ==========================================
@@ -244,44 +270,24 @@ function buildFormattedLyrics(lyricsArray) {
if (!Array.isArray(lyricsArray) || lyricsArray.length === 0) return '' if (!Array.isArray(lyricsArray) || lyricsArray.length === 0) return ''
const formattedLines = lyricsArray.map((section) => { const formattedLines = lyricsArray.map((section) => {
// Le frontend peut envoyer soit { type: "...", lyrics: "..." } soit juste une string description pour les instrumentaux const type = String(section?.type || '')
const typeOrDescription = section.type || section.description || '' .trim()
const textContent = section.lyrics || '' .toLowerCase()
const textContent = typeof section?.lyrics === 'string' ? section.lyrics.trim() : ''
const tag = mapStructureToTag(type)
// 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 (!tag) {
if (typeOrDescription.toLowerCase().includes('couplet')) tag = '[Verse]' throw new HttpsError('invalid-argument', `Structure musicale non reconnue : ${type}.`)
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 (INSTRUMENTAL_STRUCTURE_TYPES.has(type) || !textContent) {
if (!textContent.trim()) {
return `\n${tag}\n` return `\n${tag}\n`
} }
return `\n${tag}\n${textContent.trim()}` return `\n${tag}\n${textContent}`
}) })
// Sécurité: Ajouter Intro et Outro si absents (Suno best practice) return formattedLines.join('\n').trim()
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()
} }
// ========================================== // ==========================================
@@ -324,10 +330,14 @@ const parseSunoCallbackPayload = (rawBody = {}) => {
return { code, status, taskId, tracks } return { code, status, taskId, tracks }
} }
const extractAudioUrlsFromTracks = (tracks = []) => const extractGeneratedTracks = (tracks = []) =>
tracks tracks
.map((t) => t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl) .map((track) => ({
.filter(Boolean) audioId: typeof track?.id === 'string' ? track.id.trim() : '',
audioUrl: typeof track?.audio_url === 'string' ? track.audio_url.trim() : '',
durationS: Number(track?.duration),
}))
.filter((track) => track.audioId && track.audioUrl)
.slice(0, 2) .slice(0, 2)
const fetchProjectByTaskId = async (taskId) => { const fetchProjectByTaskId = async (taskId) => {
@@ -437,31 +447,62 @@ const downloadTrackToStorage = async (url, { userId, projectId, taskId, bucket,
} }
} }
const saveTracksToStorage = async (audioUrls, meta) => { const saveTracksToStorage = async (tracks, meta) => {
if (!audioUrls?.length) return [] if (!tracks?.length) return []
const bucket = admin.storage().bucket() const bucket = admin.storage().bucket()
const results = await Promise.all( const results = await Promise.all(
audioUrls.map((url, index) => downloadTrackToStorage(url, { ...meta, bucket, index })) tracks.map(async (track, index) => {
const stored = await downloadTrackToStorage(track.audioUrl, { ...meta, bucket, index })
if (!stored) return null
return {
url: stored.url,
audioId: track.audioId,
durationS: Number.isFinite(track.durationS) && track.durationS > 0 ? track.durationS : null,
}
})
) )
return results.filter(Boolean).map((entry) => entry.url) return results.filter(Boolean)
} }
const mergeMusicUrls = async (projectRef, newUrls) => { const saveGeneratedTracks = async (projectRef, tracks, taskId) => {
const sanitized = sanitizeMusicUrls(newUrls) const projectData = (await projectRef.get())?.data() || {}
let existing = [] const existingUrls = sanitizeMusicUrls(projectData.musicUrls)
try { const existingAudioIds = Array.isArray(projectData.musicAudioIds) ? projectData.musicAudioIds : []
existing = sanitizeMusicUrls((await projectRef.get())?.data()?.musicUrls) const existingDurations = Array.isArray(projectData.musicDurations)
} catch (e) { } ? projectData.musicDurations
const final = [...new Set([...existing, ...sanitized])] : []
const existingTaskIds = Array.isArray(projectData.musicTaskIds) ? projectData.musicTaskIds : []
const mergedTracks = existingUrls.map((url, index) => ({
url,
audioId: existingAudioIds[index] || null,
durationS: existingDurations[index] || null,
taskId: existingTaskIds[index] || null,
}))
const knownAudioIds = new Set(mergedTracks.map((track) => track.audioId).filter(Boolean))
tracks.forEach((track) => {
if (knownAudioIds.has(track.audioId)) return
mergedTracks.push({ ...track, taskId })
knownAudioIds.add(track.audioId)
})
const musicUrls = mergedTracks.map((track) => track.url)
const musicAudioIds = mergedTracks.map((track) => track.audioId)
const musicDurations = mergedTracks.map((track) => track.durationS)
const musicTaskIds = mergedTracks.map((track) => track.taskId)
await projectRef.set( await projectRef.set(
{ {
musicStatus: 'GENERATED', musicStatus: 'GENERATED',
musicUrls: final, musicUrls,
musicAudioIds,
musicDurations,
musicTaskIds,
musicError: FieldValue.delete(), musicError: FieldValue.delete(),
}, },
{ merge: true } { merge: true }
) )
return final return musicUrls
} }
// ========================================== // ==========================================
@@ -478,7 +519,7 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
songStyle = '', // "Upbeat" songStyle = '', // "Upbeat"
voice = [], // ["Une voix féminine...", "Un cri brut..."] (array ou objet) voice = [], // ["Une voix féminine...", "Un cri brut..."] (array ou objet)
instruments = [], // ["Piano", "Violon"] instruments = [], // ["Piano", "Violon"]
rhythm = '', // "Très rapide" tempo = '', // "Très rapide"
// Les champs suivants (step 1) ne sont plus utilisés dans le style Suno direct pour éviter le bruit, // 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). // mais ont déjà servi à générer les paroles (lyrics).
// audience, context, objective, etc. // audience, context, objective, etc.
@@ -506,18 +547,27 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
songStyle, songStyle,
voiceData, voiceData,
instruments, instruments,
rhythm, rhythm: tempo,
}) })
// 2. Construction du PROMPT (Paroles + Structure) // 2. Construction du PROMPT (Paroles + Structure)
const formattedPrompt = buildFormattedLyrics(lyrics) const formattedPrompt = buildFormattedLyrics(lyrics)
const safeTitle = clampLen(title, 80) if (!formattedPrompt) {
throw new HttpsError('invalid-argument', 'Les paroles à mettre en musique sont vides.')
}
if (formattedPrompt.length > SUNO_LYRICS_PROMPT_MAX_LENGTH) {
throw new HttpsError(
'invalid-argument',
`Les paroles dépassent la limite de ${SUNO_LYRICS_PROMPT_MAX_LENGTH} caractères du modèle ${SUNO_MODEL}.`
)
}
const safeTitle = clampLen(title, SUNO_TITLE_MAX_LENGTH)
// 3. Détection du genre vocal pour le param vocalGender (optimisation v3.5) // 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 // On regarde si on trouve 'male' ou 'female' dans les tags générés
let vGender = null let vGender = null
if (optimizedStyle.includes('Female')) vGender = 'female' if (optimizedStyle.includes('Female')) vGender = 'f'
else if (optimizedStyle.includes('Male')) vGender = 'male' else if (optimizedStyle.includes('Male')) vGender = 'm'
console.log('🎵 [Suno Optimisation] Result:', { console.log('🎵 [Suno Optimisation] Result:', {
style: optimizedStyle, style: optimizedStyle,
@@ -531,7 +581,7 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
customMode: true, customMode: true,
instrumental: false, instrumental: false,
model: SUNO_MODEL || 'chirp-v3-5', // Toujours viser le dernier modèle model: SUNO_MODEL || 'chirp-v3-5', // Toujours viser le dernier modèle
prompt: clampLen(formattedPrompt, 3000), prompt: formattedPrompt,
title: safeTitle, title: safeTitle,
style: optimizedStyle, style: optimizedStyle,
callBackUrl: SUNO_CALLBACK_URL || '', callBackUrl: SUNO_CALLBACK_URL || '',
@@ -607,12 +657,13 @@ exports.sunoCallback = onRequest({ methods: ['POST'], memory: '1GiB' }, async (r
const { projectId, projectData, projectRef } = await fetchProjectByTaskId(taskId) const { projectId, projectData, projectRef } = await fetchProjectByTaskId(taskId)
const { userId, projectTitle } = formatProjectMeta(projectData) const { userId, projectTitle } = formatProjectMeta(projectData)
const storedUrls = await saveTracksToStorage(extractAudioUrlsFromTracks(tracks), { const storedTracks = await saveTracksToStorage(extractGeneratedTracks(tracks), {
userId, userId,
projectId, projectId,
taskId, taskId,
}) })
const musicUrls = await mergeMusicUrls(projectRef, storedUrls) if (!storedTracks.length) throw new Error('SUNO_TRACK_DOWNLOAD_FAILED')
const musicUrls = await saveGeneratedTracks(projectRef, storedTracks, taskId)
if (userId) { if (userId) {
try { try {
+6 -1
View File
@@ -39,7 +39,12 @@ exports.onProjectWritten = onDocumentWritten(PROJECT_FUNCTION_OPTIONS, async (pr
} else if (!beforeData) { } else if (!beforeData) {
//song create //song create
} else { } else {
if (!beforeData?.songUrl && afterData?.songUrl) { const selectedSongChanged =
!!afterData?.songUrl &&
(beforeData?.songUrl !== afterData.songUrl ||
beforeData?.sunoAudioId !== afterData?.sunoAudioId)
if (selectedSongChanged) {
await getSunoTimestamps(projectId) await getSunoTimestamps(projectId)
await refList.projects.doc(projectId).update({ hasSong: true }) await refList.projects.doc(projectId).update({ hasSong: true })
} }
+1 -1
View File
@@ -374,7 +374,7 @@ const PlayerProvider = ({ children }) => {
return currentTrack.source return currentTrack.source
}, [currentTrack?.source]) }, [currentTrack?.source])
const player = useAudioPlayer(source || null, 200) const player = useAudioPlayer(source || null, 100)
const status = useAudioPlayerStatus(player) const status = useAudioPlayerStatus(player)
const shouldHideOnRoute = activeRouteName ? HIDDEN_ROUTE_NAMES.has(activeRouteName) : false const shouldHideOnRoute = activeRouteName ? HIDDEN_ROUTE_NAMES.has(activeRouteName) : false
const isStatusLoaded = useMemo(() => { const isStatusLoaded = useMemo(() => {
+1 -1
View File
@@ -138,7 +138,7 @@ const MusicDetails = ({ route }) => {
}, [project?.musicTimestamps, project?.songIndex]) }, [project?.musicTimestamps, project?.songIndex])
const flatLines = useMemo(() => sections.flatMap((s) => s.lines), [sections]) const flatLines = useMemo(() => sections.flatMap((s) => s.lines), [sections])
const visibleTimeS = (positionMs || 0) / 1000 + 0.3 // Offset de 300ms pour fluidité visuelle const visibleTimeS = (positionMs || 0) / 1000
const currentLineIdx = useMemo(() => { const currentLineIdx = useMemo(() => {
if (!flatLines.length) return -1 if (!flatLines.length) return -1
+1 -2
View File
@@ -548,8 +548,7 @@ const MusicDetails = ({ route }) => {
// Current time in seconds for highlighting // Current time in seconds for highlighting
const currentTimeS = useMemo(() => Math.max(0, (positionMs || 0) / 1000), [positionMs]) const currentTimeS = useMemo(() => Math.max(0, (positionMs || 0) / 1000), [positionMs])
// Preview lead: show words 0.5s earlier const visibleTimeS = currentTimeS
const visibleTimeS = useMemo(() => Math.max(0, currentTimeS + 0.5), [currentTimeS])
// Helpers to group aligned words into sections and timed lines // Helpers to group aligned words into sections and timed lines
const SECTION_TAG_REGEX = /^\s*\[([^\]]+)\]\s*$/i const SECTION_TAG_REGEX = /^\s*\[([^\]]+)\]\s*$/i
const SECTION_TAG_LEADING_REGEX = /^\s*\[([^\]]+)\]\s*/i const SECTION_TAG_LEADING_REGEX = /^\s*\[([^\]]+)\]\s*/i
+7
View File
@@ -148,6 +148,13 @@ const ComposeSong = () => {
if (!isRegenerationFlow) { if (!isRegenerationFlow) {
payload.musicUrls = firebase.firestore.FieldValue.delete() payload.musicUrls = firebase.firestore.FieldValue.delete()
payload.musicAudioIds = firebase.firestore.FieldValue.delete()
payload.musicDurations = firebase.firestore.FieldValue.delete()
payload.musicTaskIds = firebase.firestore.FieldValue.delete()
payload.musicTimestamps = firebase.firestore.FieldValue.delete()
payload.lyricsSyncStatus = firebase.firestore.FieldValue.delete()
payload.lyricsSyncError = firebase.firestore.FieldValue.delete()
payload.lyricsSyncDiagnostics = firebase.firestore.FieldValue.delete()
} }
await updateProjectData(payload) await updateProjectData(payload)
+7
View File
@@ -204,6 +204,13 @@ const ComposeSong = () => {
if (!isRegenerationFlow) { if (!isRegenerationFlow) {
payload.musicUrls = firebase.firestore.FieldValue.delete() payload.musicUrls = firebase.firestore.FieldValue.delete()
payload.musicAudioIds = firebase.firestore.FieldValue.delete()
payload.musicDurations = firebase.firestore.FieldValue.delete()
payload.musicTaskIds = firebase.firestore.FieldValue.delete()
payload.musicTimestamps = firebase.firestore.FieldValue.delete()
payload.lyricsSyncStatus = firebase.firestore.FieldValue.delete()
payload.lyricsSyncError = firebase.firestore.FieldValue.delete()
payload.lyricsSyncDiagnostics = firebase.firestore.FieldValue.delete()
} }
await updateProjectData(payload) await updateProjectData(payload)
+4
View File
@@ -118,6 +118,10 @@ const SongReady = () => {
await updateProjectData({ await updateProjectData({
songIndex: selectedIndex, songIndex: selectedIndex,
songUrl: url, songUrl: url,
sunoAudioId: selectedProject?.musicAudioIds?.[selectedIndex] || null,
songSunoTaskId: selectedProject?.musicTaskIds?.[selectedIndex] || null,
lyricsSyncStatus: 'PENDING',
lyricsSyncError: null,
cover: null, cover: null,
coverUrl: null, coverUrl: null,
coverStatus: null, coverStatus: null,