fix: Migração definitiva da síntese musical para motor de arranjo instrumental harmônico com masterização FFmpeg e eliminação de erros de API descontinuada

This commit is contained in:
2026-09-01 11:13:30 +00:00
parent 57d5e6e3e1
commit 376cf27439
3 changed files with 114 additions and 209 deletions
+114 -209
View File
@@ -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);
}