Aprimoramento de síntese musical: voz infantil real com afinação prosódica, canto melódico, chorus de estúdio e novas opções de voz (menina e coral)
This commit is contained in:
Binary file not shown.
+11
-6
@@ -875,10 +875,12 @@
|
||||
<!-- VOZ -->
|
||||
<div class="settings-group">
|
||||
<label style="color: var(--text-secondary); font-size: 0.82rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">1. Escolha a Voz do Canto</label>
|
||||
<div class="music-option-grid" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 4px;">
|
||||
<button type="button" class="btn-music-option active" data-voice="mulher">👩 Mulher</button>
|
||||
<button type="button" class="btn-music-option" data-voice="homem">👨 Homem</button>
|
||||
<div class="music-option-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(105px, 1fr)); gap: 8px; margin-top: 4px;">
|
||||
<button type="button" class="btn-music-option active" data-voice="mulher">👩 Professora</button>
|
||||
<button type="button" class="btn-music-option" data-voice="homem">👨 Professor</button>
|
||||
<button type="button" class="btn-music-option" data-voice="crianca">🧒 Criança</button>
|
||||
<button type="button" class="btn-music-option" data-voice="menina">👧 Menininha</button>
|
||||
<button type="button" class="btn-music-option" data-voice="coral">🎶 Coral Infantil</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1080,9 +1082,11 @@
|
||||
<div class="settings-group" style="flex: 1; min-width: 140px;">
|
||||
<label style="color: var(--text-secondary); font-size: 0.72rem; font-weight: 600; text-transform: uppercase;">Voz Cantada</label>
|
||||
<select id="musicaVozSelect" class="obs-select" style="width: 100%; margin-top: 4px;">
|
||||
<option value="mulher" selected>👩 Voz Feminina (Acolhedora)</option>
|
||||
<option value="homem">👨 Voz Masculina (Gentil)</option>
|
||||
<option value="crianca">🧒 Voz de Criança (Lúdica)</option>
|
||||
<option value="mulher" selected>👩 Professora Cantora (Doce & Acolhedora)</option>
|
||||
<option value="homem">👨 Professor Cantor (Gentil & Melódico)</option>
|
||||
<option value="crianca">🧒 Voz de Criança (Lúdica & Alegre)</option>
|
||||
<option value="menina">👧 Menininha (Voz Infantil Meiga)</option>
|
||||
<option value="coral">🎶 Coral das Crianças (Vozes em Coro)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-group" style="flex: 1; min-width: 140px;">
|
||||
@@ -1727,6 +1731,7 @@
|
||||
<option value="mulher" selected>👩 Professora Camila (Acolhedora)</option>
|
||||
<option value="homem">👨 Professor (Gentil & Firme)</option>
|
||||
<option value="crianca">🧒 Criança (Lúdica & Espontânea)</option>
|
||||
<option value="menina">👧 Menininha (Doce & Expressiva)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user