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:
Binary file not shown.
@@ -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
|
||||
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'
|
||||
});
|
||||
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!');
|
||||
}
|
||||
}
|
||||
} catch (audioErr) {
|
||||
console.error('[Musicando] Erro na síntese de áudio:', audioErr.message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user