diff --git a/public/generated-media/test_song_1788261127746.mp3 b/public/generated-media/test_song_1788261127746.mp3 deleted file mode 100644 index 7c78316..0000000 Binary files a/public/generated-media/test_song_1788261127746.mp3 and /dev/null differ diff --git a/public/index.html b/public/index.html index a83983f..1eba270 100644 --- a/public/index.html +++ b/public/index.html @@ -875,10 +875,12 @@
-
- - +
+ + + +
@@ -1080,9 +1082,11 @@
@@ -1727,6 +1731,7 @@ +
diff --git a/server.js b/server.js index ec925c2..a05fc64 100644 --- a/server.js +++ b/server.js @@ -335,7 +335,7 @@ app.post('/api/tts', requireAuth, async (req, res) => { }); // ============================================================================ -// HELPER: Síntese Musical Pedagógica com Voz Melódica & Arranjo Instrumental +// HELPER: Síntese Musical Pedagógica com Voz Melódica, Canto & Arranjo Instrumental // ============================================================================ async function synthesizePedagogicalSong({ lyrics, ritmo = 'Cantiga de Roda', tema = '', voz = 'mulher', instrumentos = '' }) { const fs = require('fs'); @@ -348,19 +348,55 @@ async function synthesizePedagogicalSong({ lyrics, ritmo = 'Cantiga de Roda', te fs.mkdirSync(mediaDir, { recursive: true }); } - // 1. Limpar a letra de marcações extras e gestos entre parênteses - const cleanLyrics = (lyrics || tema || 'Vamos cantar e aprender!') + // 1. Limpar a letra de marcações extras de texto e formatar métrica rítmica + let cleanLyrics = (lyrics || tema || 'Vamos cantar e aprender!') .replace(/\(.*?\)/g, '') .replace(/\[.*?\]/g, '') .replace(/[#*]/g, '') .trim(); - // 2. Determinar voz da cantiga + // Ajustar pontuação para cadência musical e respiração rítmica + const rawLines = cleanLyrics.split('\n').map(l => l.trim()).filter(l => l.length > 0); + if (rawLines.length > 0) { + cleanLyrics = rawLines + .map(line => { + if (!/[.!?,]$/.test(line)) return line + '!'; + return line; + }) + .join(' '); + } + + // 2. Configurações de voz e afinação (Pitch / Rate) para canto autêntico let voiceName = 'pt-BR-FranciscaNeural'; - if (voz === 'homem') { - voiceName = 'pt-BR-AntonioNeural'; - } else if (voz === 'crianca' || voz === 'criancas') { + let ttsOptions = { pitch: '+8%', rate: '+4%' }; + let isChildVoice = false; + let isChoir = false; + + const vozNorm = (voz || 'mulher').toLowerCase(); + + if (vozNorm === 'crianca' || vozNorm === 'menino' || vozNorm === 'ludica') { voiceName = 'pt-BR-ThalitaNeural'; + // Afinação e modulação infantil real (timbre agudo, alegre e infantilizado) + ttsOptions = { pitch: '+44%', rate: '+6%' }; + isChildVoice = true; + } else if (vozNorm === 'menina' || vozNorm === 'doce') { + voiceName = 'pt-BR-ThalitaNeural'; + // Timbre infantil meigo e doce + ttsOptions = { pitch: '+50%', rate: '+4%' }; + isChildVoice = true; + } else if (vozNorm === 'coral' || vozNorm === 'criancas' || vozNorm === 'coro') { + voiceName = 'pt-BR-ThalitaNeural'; + // Coral infantil polifônico + ttsOptions = { pitch: '+42%', rate: '+5%' }; + isChildVoice = true; + isChoir = true; + } else if (vozNorm === 'homem' || vozNorm === 'professor') { + voiceName = 'pt-BR-AntonioNeural'; + ttsOptions = { pitch: '+0%', rate: '+2%' }; + } else { + // Mulher / Professora cantora + voiceName = 'pt-BR-FranciscaNeural'; + ttsOptions = { pitch: '+8%', rate: '+4%' }; } const tts = new MsEdgeTTS(); @@ -368,8 +404,8 @@ async function synthesizePedagogicalSong({ lyrics, ritmo = 'Cantiga de Roda', te const voiceTempPath = path.join(mediaDir, `voice_temp_${Date.now()}_${Math.floor(Math.random() * 10000)}.mp3`); - // 3. Sintetizar stream vocal da cantiga - const { audioStream } = tts.toStream(cleanLyrics); + // 3. Sintetizar stream vocal da cantiga com prosódia musical + const { audioStream } = await tts.toStream(cleanLyrics, ttsOptions); await new Promise((resolve, reject) => { const ws = fs.createWriteStream(voiceTempPath); audioStream.pipe(ws); @@ -391,8 +427,10 @@ async function synthesizePedagogicalSong({ lyrics, ritmo = 'Cantiga de Roda', te bgTrack = 'animais.mp3'; } else if (rLower.includes('brinquedo') || rLower.includes('fantasia') || rLower.includes('bonec')) { bgTrack = 'brinquedos.mp3'; - } else if (rLower.includes('amig') || rLower.includes('escola') || rLower.includes('roda')) { + } else if (rLower.includes('amig') || rLower.includes('escola') || rLower.includes('roda') || rLower.includes('cordão')) { bgTrack = 'amizade.mp3'; + } else if (rLower.includes('aventura') || rLower.includes('marcha') || rLower.includes('soldad')) { + bgTrack = 'aventura.mp3'; } let bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', bgTrack); @@ -403,10 +441,25 @@ async function synthesizePedagogicalSong({ lyrics, ritmo = 'Cantiga de Roda', te const outFileName = `musica_${Date.now()}_${Math.floor(Math.random() * 10000)}.mp3`; const outFilePath = path.join(mediaDir, outFileName); - // 5. Mixagem e Masterização com FFmpeg (Vocal em destaque com reverb acústico + Trilha instrumental viva 0.48) + // 5. Cadeia de Efeitos Vocais Musicais com FFmpeg (Harmonia, Micro-Vibrato, Chorus de Estúdio, Reverb Espacial e Mixagem) + let vocalFilter = ''; + if (isChoir) { + // Efeito Coral Infantil de Sala de Aula (Múltiplas vozes de crianças em uníssono) + vocalFilter = '[0:a]volume=1.35,highpass=f=130,treble=g=4:f=3600,vibrato=f=4.5:d=0.18,chorus=0.6:0.9:40|55|70:0.4|0.3|0.25:0.25|0.2|0.18:3,aecho=0.8:0.7:35|55:0.25|0.18[v]'; + } else if (isChildVoice) { + // Voz infantil solo: cristalina, afinada, com micro-vibrato musical e chorus duplo de estúdio + vocalFilter = '[0:a]volume=1.32,highpass=f=140,treble=g=4:f=3800,vibrato=f=4.2:d=0.15,chorus=0.7:0.9:42:0.35:0.25:2,aecho=0.8:0.7:30|50:0.22|0.15[v]'; + } else { + // Voz adulta (Professora / Professor): vocal de estúdio com brilho e reverberação acústica + vocalFilter = '[0:a]volume=1.28,highpass=f=100,treble=g=2.5:f=3200,vibrato=f=3.8:d=0.12,chorus=0.7:0.9:45:0.3:0.22:2,aecho=0.8:0.7:32|52:0.22|0.15[v]'; + } + + const bgFilter = '[1:a]volume=0.38[bg]'; + const mixFilter = '[v][bg]amix=inputs=2:duration=first:dropout_transition=2[aout]'; + if (fs.existsSync(bgMusicPath) && fs.existsSync(voiceTempPath)) { await new Promise((resolve) => { - const ffmpegCmd = `ffmpeg -y -i "${voiceTempPath}" -stream_loop -1 -i "${bgMusicPath}" -filter_complex "[0:a]volume=1.25,highpass=f=100,aecho=0.8:0.7:30|50:0.3|0.2[v];[1:a]volume=0.48[bg];[v][bg]amix=inputs=2:duration=first:dropout_transition=2[aout]" -map "[aout]" -c:a libmp3lame -b:a 128k "${outFilePath}"`; + const ffmpegCmd = `ffmpeg -y -i "${voiceTempPath}" -stream_loop -1 -i "${bgMusicPath}" -filter_complex "${vocalFilter};${bgFilter};${mixFilter}" -map "[aout]" -c:a libmp3lame -b:a 128k "${outFilePath}"`; exec(ffmpegCmd, (err) => { if (err) console.warn('[SynthesizeSong] Erro FFmpeg mix:', err.message); try { if (fs.existsSync(voiceTempPath)) fs.unlinkSync(voiceTempPath); } catch (e) {} @@ -435,21 +488,23 @@ app.post('/api/music/generate', requireAuth, async (req, res) => { try { // 1. Determinar o tamanho das letras baseado na duração (duracao) - let lengthInstructions = 'curta, de apenas 6 a 8 versos'; + let lengthInstructions = 'curta, de apenas 6 a 8 versos cantados'; if (duracao === '1min') { - lengthInstructions = 'média, de 12 a 16 versos (com verso 1, refrão, verso 2, refrão)'; + lengthInstructions = 'média, de 12 a 16 versos cantados (com verso 1, refrão marcante, verso 2 e refrão final)'; } else if (duracao === '2min') { - lengthInstructions = 'completa e longa, de 24 a 32 versos (com estrutura completa: verso 1, refrão, verso 2, refrão, ponte e refrão final)'; + lengthInstructions = 'completa e longa, de 20 a 28 versos cantados (com estrutura completa: verso 1, refrão, verso 2, refrão, ponte e refrão final)'; } - // 2. Chamar a IA para compor a letra - const lyricsPrompt = `Gere uma letra de cantiga escolar infantil sobre o tema: "${tema}". -A letra deve ser escrita para o público infantil (linguagem lúdica, alegre e educativa). -A estrutura de versos deve ser: ${lengthInstructions}. -Retorne APENAS a letra formatada em versos com quebras de linha. Não adicione notas, comentários ou títulos.`; + // 2. Chamar a IA para compor a letra musicalmente rimada e rítmica + const lyricsPrompt = `Componha uma letra de CANTIGA INFANTIL CANTO E RITMO sobre o tema: "${tema}". +IMPORTANTE PARA A MUSICALIDADE E CANTO: +- Estrutura de versos curtos e fáceis de cantar (4 a 6 palavras por verso), com métrica rítmica e rimas sonoras adequadas para Educação Infantil. +- Inclua refrão cantável, alegre e cativante com exclamações musicais (ex: "Lá, lá, lá!", "Vem brincar!", "Bate palminha!"). +- Tamanho: ${lengthInstructions}. +- Retorne APENAS os versos da letra da música para serem cantados. Não coloque títulos, numerações ou anotações entre parênteses.`; const aiResponse = await callMinimax({ - system: "Você é um compositor pedagógico infantil que escreve letras de músicas educativas em português brasileiro. Retorne estritamente a letra da música, sem introduções ou observações extras.", + system: "Você é um compositor musical infantil brasileiro de excelência. Escreva letras de cantigas que sejam naturalmente ritmadas e cantáveis para crianças da Educação Infantil. Retorne estritamente os versos cantados da música, sem introduções ou explicações.", messages: [{ role: 'user', content: lyricsPrompt }], temperature: 0.7, max_tokens: 1000 @@ -460,8 +515,8 @@ Retorne APENAS a letra formatada em versos com quebras de linha. Não adicione n throw new Error('Falha ao compor a letra da música.'); } - // 3. Sintetizar a canção com arranjo instrumental completo e vocal - console.log('[Estúdio Musical] Sintetizando música infantil com arranjo instrumental...'); + // 3. Sintetizar a canção com arranjo instrumental completo e vocal de canto + console.log('[Estúdio Musical] Sintetizando música infantil com arranjo instrumental e voz melódica...'); const audioUrl = await synthesizePedagogicalSong({ lyrics, ritmo: ritmo || 'Cantiga de Roda', @@ -1840,15 +1895,15 @@ Retorne ESTRITAMENTE um JSON válido (sem texto fora do JSON): }; } - // GERAÇÃO DE ÁUDIO TTS / RECITADO (MiniMax) + // GERAÇÃO DE ÁUDIO TTS / RECITADO (MiniMax + Fallback MsEdgeTTS com ambiência poética) let audioUrl = null; if (gerarAudio !== false && process.env.MINIMAX_API_KEY) { try { - console.log('[Poesia] Gerando áudio recitado com MiniMax...'); + console.log('[Poesia] Tentando áudio recitado com MiniMax...'); let voiceDesc = 'sweet gentle native brazilian female teacher voice reciting warm nursery poem, calm clear tempo'; if (voz === 'homem') { voiceDesc = 'warm friendly native brazilian male educator voice storytelling poem, soft and clear'; - } else if (voz === 'crianca') { + } else if (voz === 'crianca' || voz === 'menina') { voiceDesc = 'cute enthusiastic young brazilian child voice reciting poetry, joyful'; } @@ -1889,7 +1944,86 @@ Retorne ESTRITAMENTE um JSON válido (sem texto fora do JSON): } } } catch (audioErr) { - console.error('[Poesia] Erro ao gerar áudio recitado:', audioErr.message); + console.error('[Poesia] Erro ao gerar áudio recitado MiniMax:', audioErr.message); + } + } + + // Fallback garantido: Síntese de Recitação com MsEdgeTTS + Trilha Sonora Acústica + if (!audioUrl && gerarAudio !== false) { + try { + console.log('[Poesia] Sintetizando recitação poética com MsEdgeTTS...'); + const fs = require('fs'); + const path = require('path'); + const { exec } = require('child_process'); + const { MsEdgeTTS, OUTPUT_FORMAT } = require('msedge-tts'); + + const mediaDir = path.join(__dirname, 'public', 'generated-media'); + if (!fs.existsSync(mediaDir)) fs.mkdirSync(mediaDir, { recursive: true }); + + const poemText = (poesiaData.poesia || tema) + .replace(/\(.*?\)/g, '') + .replace(/\[.*?\]/g, '') + .replace(/[*#]/g, '') + .trim(); + + let vName = 'pt-BR-FranciscaNeural'; + let ttsOpt = { pitch: '+0%', rate: '-4%' }; + let isChild = false; + + const vNorm = (voz || 'mulher').toLowerCase(); + if (vNorm === 'crianca' || vNorm === 'menino') { + vName = 'pt-BR-ThalitaNeural'; + ttsOpt = { pitch: '+42%', rate: '+2%' }; + isChild = true; + } else if (vNorm === 'menina') { + vName = 'pt-BR-ThalitaNeural'; + ttsOpt = { pitch: '+48%', rate: '+0%' }; + isChild = true; + } else if (vNorm === 'homem') { + vName = 'pt-BR-AntonioNeural'; + ttsOpt = { pitch: '+0%', rate: '-4%' }; + } + + const tts = new MsEdgeTTS(); + await tts.setMetadata(vName, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3); + const voiceTemp = path.join(mediaDir, `poesia_voice_${Date.now()}.mp3`); + + const { audioStream } = await tts.toStream(poemText, ttsOpt); + await new Promise((resolve, reject) => { + const ws = fs.createWriteStream(voiceTemp); + audioStream.pipe(ws); + ws.on('finish', resolve); + ws.on('error', reject); + audioStream.on('error', reject); + }); + + const bgPoesia = path.join(__dirname, 'public', 'assets', 'audio', 'calma.mp3'); + const fileName = `poesia_${Date.now()}.mp3`; + const outPoesiaPath = path.join(mediaDir, fileName); + + if (fs.existsSync(bgPoesia) && fs.existsSync(voiceTemp)) { + const vFilter = isChild + ? '[0:a]volume=1.30,highpass=f=130,treble=g=3:f=3500,aecho=0.8:0.7:30|50:0.2|0.12[v]' + : '[0:a]volume=1.25,highpass=f=100,aecho=0.8:0.7:35|55:0.2|0.15[v]'; + const ffmpegCmd = `ffmpeg -y -i "${voiceTemp}" -stream_loop -1 -i "${bgPoesia}" -filter_complex "${vFilter};[1:a]volume=0.25[bg];[v][bg]amix=inputs=2:duration=first:dropout_transition=2[aout]" -map "[aout]" -c:a libmp3lame -b:a 128k "${outPoesiaPath}"`; + await new Promise((resFf) => { + exec(ffmpegCmd, (e) => { + if (e) console.warn('[Poesia] Erro FFmpeg mix:', e.message); + try { if (fs.existsSync(voiceTemp)) fs.unlinkSync(voiceTemp); } catch (e) {} + resFf(); + }); + }); + } else if (fs.existsSync(voiceTemp)) { + fs.renameSync(voiceTemp, outPoesiaPath); + } + + if (fs.existsSync(outPoesiaPath)) { + const buf = fs.readFileSync(outPoesiaPath); + backupMediaFile(outPoesiaPath, buf).catch(() => {}); + audioUrl = `/generated-media/${fileName}`; + } + } catch (eTTS) { + console.error('[Poesia] Erro fallback MsEdgeTTS:', eTTS.message); } }