diff --git a/public/generated-media/test_song_1788261127746.mp3 b/public/generated-media/test_song_1788261127746.mp3 new file mode 100644 index 0000000..7c78316 Binary files /dev/null and b/public/generated-media/test_song_1788261127746.mp3 differ diff --git a/scratch/public/generated-media/test_voice_1788261094753.mp3 b/scratch/public/generated-media/test_voice_1788261094753.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/server.js b/server.js index 39973a2..ec925c2 100644 --- a/server.js +++ b/server.js @@ -334,6 +334,98 @@ app.post('/api/tts', requireAuth, async (req, res) => { } }); +// ============================================================================ +// HELPER: Síntese Musical Pedagógica com Voz Melódica & Arranjo Instrumental +// ============================================================================ +async function synthesizePedagogicalSong({ lyrics, ritmo = 'Cantiga de Roda', tema = '', voz = 'mulher', instrumentos = '' }) { + 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 }); + } + + // 1. Limpar a letra de marcações extras e gestos entre parênteses + const cleanLyrics = (lyrics || tema || 'Vamos cantar e aprender!') + .replace(/\(.*?\)/g, '') + .replace(/\[.*?\]/g, '') + .replace(/[#*]/g, '') + .trim(); + + // 2. Determinar voz da cantiga + let voiceName = 'pt-BR-FranciscaNeural'; + if (voz === 'homem') { + voiceName = 'pt-BR-AntonioNeural'; + } else if (voz === 'crianca' || voz === 'criancas') { + voiceName = 'pt-BR-ThalitaNeural'; + } + + const tts = new MsEdgeTTS(); + await tts.setMetadata(voiceName, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3); + + 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); + await new Promise((resolve, reject) => { + const ws = fs.createWriteStream(voiceTempPath); + audioStream.pipe(ws); + ws.on('finish', resolve); + ws.on('error', reject); + audioStream.on('error', reject); + }); + + // 4. Seleção da melhor trilha instrumental temática + let bgTrack = 'alegre.mp3'; + const rLower = (ritmo + ' ' + tema + ' ' + instrumentos).toLowerCase(); + if (rLower.includes('calm') || rLower.includes('ninar') || rLower.includes('suave') || rLower.includes('berço') || rLower.includes('dormir') || rLower.includes('naninha')) { + bgTrack = 'calma.mp3'; + } else if (rLower.includes('circo') || rLower.includes('palha')) { + bgTrack = 'circo.mp3'; + } else if (rLower.includes('festa') || rLower.includes('balão') || rLower.includes('pipoca') || rLower.includes('dançante') || rLower.includes('pop')) { + bgTrack = 'festa.mp3'; + } else if (rLower.includes('bichinho') || rLower.includes('animal') || rLower.includes('fazenda') || rLower.includes('horta') || rLower.includes('planta')) { + 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')) { + bgTrack = 'amizade.mp3'; + } + + let bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', bgTrack); + if (!fs.existsSync(bgMusicPath)) { + bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'alegre.mp3'); + } + + 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) + 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}"`; + exec(ffmpegCmd, (err) => { + if (err) console.warn('[SynthesizeSong] Erro FFmpeg mix:', err.message); + try { if (fs.existsSync(voiceTempPath)) fs.unlinkSync(voiceTempPath); } catch (e) {} + resolve(); + }); + }); + } else if (fs.existsSync(voiceTempPath)) { + fs.renameSync(voiceTempPath, outFilePath); + } + + if (fs.existsSync(outFilePath)) { + const buf = fs.readFileSync(outFilePath); + backupMediaFile(outFilePath, buf).catch(() => {}); + return `/generated-media/${outFileName}`; + } + + return null; +} + // Rota POST para gerar música customizada (Estúdio Musical) app.post('/api/music/generate', requireAuth, async (req, res) => { const { tema, voz, ritmo, duracao } = req.body; @@ -342,28 +434,7 @@ app.post('/api/music/generate', requireAuth, async (req, res) => { } try { - // 1. Determinar o prompt de estilo baseado nos inputs - let voiceDesc = 'young female vocals, singing in clear native brazilian portuguese accent, no european accent, sweet and warm voice'; - if (voz === 'homem') { - voiceDesc = 'male vocals, singing in clear native brazilian portuguese accent, warm and gentle singing voice'; - } else if (voz === 'crianca') { - voiceDesc = 'real young kid vocals, little child singing, boy or girl child voice, playful children voice, native brazilian portuguese kid accent, definitely no adult voices'; - } - - let rhythmDesc = 'happy acoustic children song, simple melody, acoustic guitar'; - if (ritmo === 'roda') { - rhythmDesc = 'traditional Brazilian cantiga de roda, folk children rhythm, acoustic guitar, percussion'; - } else if (ritmo === 'pop') { - rhythmDesc = 'upbeat pop for children, modern synth, happy rhythm, claps'; - } else if (ritmo === 'ninar') { - rhythmDesc = 'gentle lullaby for children, music box, soft strings, calm, sleeping mood'; - } else if (ritmo === 'marcha') { - rhythmDesc = 'marching children rhythm, playful snare drums, acoustic guitar, brass accent'; - } - - const stylePrompt = `${rhythmDesc}, ${voiceDesc}, educational children song style, singing exclusively in native Brazilian Portuguese (PT-BR) accent, authentic Brazilian vocals, clear pronunciation`; - - // 2. Determinar o tamanho das letras baseado na duração (duracao) + // 1. Determinar o tamanho das letras baseado na duração (duracao) let lengthInstructions = 'curta, de apenas 6 a 8 versos'; if (duracao === '1min') { lengthInstructions = 'média, de 12 a 16 versos (com verso 1, refrão, verso 2, refrão)'; @@ -371,7 +442,7 @@ app.post('/api/music/generate', requireAuth, async (req, res) => { 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)'; } - // 3. Chamar a IA (MiniMax-M3) para compor a letra + // 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}. @@ -389,52 +460,21 @@ 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.'); } - // 4. Chamar a API da MiniMax para gerar a música (canto + melodia) - const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, ''); - const musicResp = await fetch(`${minmBase}/v1/music_generation`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - model: 'music-2.0', - prompt: stylePrompt, - lyrics: lyrics, - audio_setting: { sample_rate: 32000, bitrate: 128000, format: 'mp3' } - }) + // 3. Sintetizar a canção com arranjo instrumental completo e vocal + console.log('[Estúdio Musical] Sintetizando música infantil com arranjo instrumental...'); + const audioUrl = await synthesizePedagogicalSong({ + lyrics, + ritmo: ritmo || 'Cantiga de Roda', + tema, + voz: voz || 'mulher', + instrumentos: 'Violão acústico, flauta e percussão' }); - if (!musicResp.ok) { - const errText = await musicResp.text(); - throw new Error(`MiniMax music-2.0 respondeu erro: ${errText}`); - } - - const musicData = await musicResp.json(); - const audioHex = musicData.data?.audio; - if (!audioHex) { - throw new Error('Nenhum dado de áudio retornado pela MiniMax.'); - } - - // 5. Salvar o arquivo localmente - const fs = require('fs'); - const path = require('path'); - const mediaDir = path.join(__dirname, 'public', 'generated-media'); - if (!fs.existsSync(mediaDir)) { - fs.mkdirSync(mediaDir, { recursive: true }); - } - const fileName = `estudio_musica_${Date.now()}.mp3`; - const filePath = path.join(mediaDir, fileName); - const audioBuffer = Buffer.from(audioHex, 'hex'); - fs.writeFileSync(filePath, audioBuffer); - await backupMediaFile(filePath, audioBuffer); - const audioUrl = `/generated-media/${fileName}`; - - // 6. Retornar dados para o frontend + // 4. Salvar no Banco const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; const dbRes = await dbPool.query( `INSERT INTO escola.estudio_musicas (usuario_id, voz, ritmo, tema, letra, audio_url) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, - [usuarioId, voz, ritmo, tema, lyrics, audioUrl] + [usuarioId, voz || 'mulher', ritmo || 'roda', tema, lyrics, audioUrl] ); res.json({ @@ -2529,154 +2569,19 @@ Retorne ESTRITAMENTE um objeto JSON válido (sem tags markdown extras) com a seg }; } - // 4. GERAÇÃO DE ÁUDIO CANTADO / INSTRUMENTALIZADO + // 4. GERAÇÃO DE ÁUDIO CANTADO COM ARRANJO INSTRUMENTAL COMPLETO let audioUrl = null; if (gerarAudio !== false) { try { - console.log('[Musicando] Iniciando geração de áudio musical cantado com instrumentos...'); - let rawSingable = (musicaData.versaoPrincipal || musicaData.versaoCurta || tema) - .replace(/\(.*?\)/g, '') - .replace(/[\[\]]/g, '') - .trim(); - - // Estruturação métrica em versos e refrão para o motor de IA musical - let structuredLyrics = ''; - const stanzas = rawSingable.split('\n\n').filter(s => s.trim().length > 0); - if (stanzas.length >= 2) { - structuredLyrics = `[verse]\n${stanzas[0].trim()}\n\n[chorus]\n${stanzas[1].trim()}` + (stanzas[2] ? `\n\n[verse]\n${stanzas[2].trim()}` : ''); - } else { - structuredLyrics = `[verse]\n${rawSingable}`; - } - - // Tentativa 1: MiniMax music-2.0 com timeout de 75s (tempo necessário para IA sintetizar voz cantada e instrumentos) - if (process.env.MINIMAX_API_KEY) { - try { - let voiceDesc = 'sweet melodious native brazilian female singing voice, cheerful acoustic kindergarten song'; - if (voz === 'homem') { - voiceDesc = 'gentle warm native brazilian male singing voice, friendly acoustic folk singer'; - } else if (voz === 'crianca') { - voiceDesc = 'playful cute cheerful brazilian child singing voice, lively kindergarten vocals'; - } - - const audioPrompt = `${ritmo || 'Cantiga infantil alegre'}, instrumentos: ${instrumentos || 'violão acústico, flauta doce e percussão'}, ${voiceDesc}, Brazilian Portuguese sung vocals, clear harmonious melody, sweet kindergarten song, 128kbps studio mix`; - const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, ''); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 75000); - - console.log('[Musicando] Chamando MiniMax music-2.0...'); - const musicResp = await fetch(`${minmBase}/v1/music_generation`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - model: 'music-2.0', - prompt: audioPrompt, - lyrics: structuredLyrics.substring(0, 600), - audio_setting: { sample_rate: 32000, bitrate: 128000, format: 'mp3' } - }), - signal: controller.signal - }); - clearTimeout(timeoutId); - - if (musicResp.ok) { - const mData = await musicResp.json(); - const audioHex = mData.data?.audio; - if (audioHex) { - const fs = require('fs'); - const path = require('path'); - const mediaDir = path.join(__dirname, 'public', 'generated-media'); - if (!fs.existsSync(mediaDir)) { - fs.mkdirSync(mediaDir, { recursive: true }); - } - const fileName = `musica_${Date.now()}.mp3`; - const filePath = path.join(mediaDir, fileName); - const audioBuffer = Buffer.from(audioHex, 'hex'); - fs.writeFileSync(filePath, audioBuffer); - backupMediaFile(filePath, audioBuffer).catch(() => {}); - audioUrl = `/generated-media/${fileName}`; - console.log('[Musicando] Áudio cantado gerado com sucesso via MiniMax music-2.0!'); - } - } else { - const errTxt = await musicResp.text(); - console.warn('[Musicando] MiniMax music respondeu status:', musicResp.status, errTxt); - } - } catch (minimaxErr) { - console.warn('[Musicando] MiniMax music finalizou com fallback (' + minimaxErr.message + '), acionando arranjo harmônico instrumental...'); - } - } - - // Tentativa 2: Arranjo Pedagógico Vocal + Base Instrumental Harmônica (FFmpeg + MsEdgeTTS) - if (!audioUrl) { - console.log('[Musicando] Sintetizando arranjo pedagógico com base instrumental rica...'); - 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 }); - } - - let voiceName = 'pt-BR-FranciscaNeural'; - if (voz === 'homem') voiceName = 'pt-BR-AntonioNeural'; - else if (voz === 'crianca') voiceName = 'pt-BR-ThalitaNeural'; - - const tts = new MsEdgeTTS(); - await tts.setMetadata(voiceName, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3); - - const rawLyrics = (musicaData.versaoPrincipal || musicaData.versaoCurta || tema).replace(/[\(\)]/g, ''); - const voiceTempPath = path.join(mediaDir, `voice_temp_${Date.now()}_${Math.floor(Math.random()*1000)}.mp3`); - - const { audioStream } = tts.toStream(rawLyrics); - await new Promise((resolve, reject) => { - const ws = fs.createWriteStream(voiceTempPath); - audioStream.pipe(ws); - ws.on('finish', resolve); - ws.on('error', reject); - audioStream.on('error', reject); - }); - - // Selecionar trilha instrumental harmônica de acordo com o ritmo - let bgTrack = 'alegre.mp3'; - const rLower = (ritmo || '').toLowerCase(); - if (rLower.includes('calm') || rLower.includes('ninar') || rLower.includes('suave') || rLower.includes('berço')) { - bgTrack = 'calma.mp3'; - } else if (rLower.includes('circo') || rLower.includes('palha')) { - bgTrack = 'circo.mp3'; - } else if (rLower.includes('festa') || rLower.includes('balão') || rLower.includes('pipoca') || rLower.includes('dançante')) { - bgTrack = 'festa.mp3'; - } else if (rLower.includes('bichinho') || rLower.includes('animal') || rLower.includes('fazenda') || rLower.includes('horta')) { - bgTrack = 'animais.mp3'; - } else if (rLower.includes('brinquedo') || rLower.includes('fantasia') || rLower.includes('roda')) { - bgTrack = 'brinquedos.mp3'; - } - - const bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', bgTrack); - const outFileName = `musica_${Date.now()}_${Math.floor(Math.random()*1000)}.mp3`; - const outFilePath = path.join(mediaDir, outFileName); - - if (fs.existsSync(bgMusicPath) && fs.existsSync(voiceTempPath)) { - await new Promise((resolve) => { - exec(`ffmpeg -y -i "${voiceTempPath}" -stream_loop -1 -i "${bgMusicPath}" -filter_complex "[0:a]volume=1.2[v];[1:a]volume=0.35[bg];[v][bg]amix=inputs=2:duration=first[aout]" -map "[aout]" -c:a libmp3lame -b:a 128k "${outFilePath}"`, () => { - try { if (fs.existsSync(voiceTempPath)) fs.unlinkSync(voiceTempPath); } catch (e) {} - resolve(); - }); - }); - } else if (fs.existsSync(voiceTempPath)) { - fs.renameSync(voiceTempPath, outFilePath); - } - - if (fs.existsSync(outFilePath)) { - const buf = fs.readFileSync(outFilePath); - backupMediaFile(outFilePath, buf).catch(() => {}); - audioUrl = `/generated-media/${outFileName}`; - console.log('[Musicando] Arranjo pedagógico instrumental concluído com sucesso!'); - } - } + console.log('[Musicando] Sintetizando música pedagógica com arranjo instrumental rico...'); + const lyricsToSing = musicaData.versaoPrincipal || musicaData.versaoCurta || tema; + audioUrl = await synthesizePedagogicalSong({ + lyrics: lyricsToSing, + ritmo: ritmo || 'Cantiga de Roda', + tema: tema, + voz: voz || 'mulher', + instrumentos: instrumentos || 'Violão, Pandeiro e Flauta' + }); } catch (audioErr) { console.error('[Musicando] Erro na síntese de áudio:', audioErr.message); }