fix: features de audio
All checks were successful
Deploy ASP.NET MVC to OCI / build-and-deploy (push) Successful in 22m1s
All checks were successful
Deploy ASP.NET MVC to OCI / build-and-deploy (push) Successful in 22m1s
This commit is contained in:
parent
665d36d81c
commit
f8f052428f
@ -20,7 +20,9 @@
|
||||
"Bash(dotnet build:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(dotnet add package:*)",
|
||||
"Bash(dir:*)"
|
||||
"Bash(dir:*)",
|
||||
"Bash(where:*)",
|
||||
"Bash(winget install:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
@{
|
||||
ViewData["Title"] = "Áudio para Texto (Transcrição)";
|
||||
var culture = ViewContext.RouteData.Values["culture"] as string ?? "pt-BR";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="text-center mb-5">
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
@{
|
||||
ViewData["Title"] = "Texto para Áudio (Voz)";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="text-center mb-5">
|
||||
@ -31,12 +32,22 @@
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
|
||||
<button type="button" class="btn btn-primary btn-lg px-5" onclick="speak()">
|
||||
<button type="button" class="btn btn-primary btn-lg px-5" id="btnSpeak" onclick="speak()">
|
||||
<i class="bi bi-play-fill me-2"></i>Ouvir
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-lg" onclick="stop()">
|
||||
<button type="button" class="btn btn-outline-danger btn-lg" id="btnStop" onclick="stop()" style="display: none;">
|
||||
<i class="bi bi-stop-fill me-2"></i>Parar
|
||||
</button>
|
||||
<button type="button" class="btn btn-success btn-lg" id="btnDownload" onclick="downloadAudio()">
|
||||
<i class="bi bi-download me-2" id="iconDownload"></i><span id="btnDownloadText">Baixar OGG</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="downloadProgress" class="mt-3" style="display: none;">
|
||||
<div class="progress">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%"></div>
|
||||
</div>
|
||||
<small class="text-muted">Gerando áudio...</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -49,15 +60,15 @@
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
// Variáveis globais
|
||||
const synth = window.speechSynthesis;
|
||||
const voiceSelect = document.querySelector('#voiceSelect');
|
||||
const textInput = document.querySelector('#textInput');
|
||||
const rate = document.querySelector('#rate');
|
||||
const pitch = document.querySelector('#pitch');
|
||||
|
||||
let voices = [];
|
||||
|
||||
// Função para popular lista de vozes
|
||||
function populateVoiceList() {
|
||||
const voiceSelect = document.getElementById('voiceSelect');
|
||||
if (!voiceSelect) return;
|
||||
|
||||
voices = synth.getVoices().sort(function (a, b) {
|
||||
const aname = a.name.toUpperCase();
|
||||
const bname = b.name.toUpperCase();
|
||||
@ -66,8 +77,9 @@
|
||||
else return 0;
|
||||
});
|
||||
|
||||
const selectedIndex = voiceSelect.selectedIndex < 0 ? 0 : voiceSelect.selectedIndex;
|
||||
voiceSelect.innerHTML = '';
|
||||
let ptBrIndex = -1;
|
||||
let ptIndex = -1;
|
||||
|
||||
for (let i = 0; i < voices.length; i++) {
|
||||
const option = document.createElement('option');
|
||||
@ -80,43 +92,205 @@
|
||||
option.setAttribute('data-lang', voices[i].lang);
|
||||
option.setAttribute('data-name', voices[i].name);
|
||||
voiceSelect.appendChild(option);
|
||||
|
||||
// Procurar voz pt-BR (prioridade) ou pt (fallback)
|
||||
if (ptBrIndex === -1 && voices[i].lang.toLowerCase() === 'pt-br') {
|
||||
ptBrIndex = i;
|
||||
} else if (ptIndex === -1 && voices[i].lang.toLowerCase().startsWith('pt')) {
|
||||
ptIndex = i;
|
||||
}
|
||||
voiceSelect.selectedIndex = selectedIndex;
|
||||
}
|
||||
|
||||
// Selecionar voz pt-BR por padrão, ou pt, ou primeira disponível
|
||||
if (ptBrIndex !== -1) {
|
||||
voiceSelect.selectedIndex = ptBrIndex;
|
||||
console.log('Voz pt-BR selecionada por padrão:', voices[ptBrIndex].name);
|
||||
} else if (ptIndex !== -1) {
|
||||
voiceSelect.selectedIndex = ptIndex;
|
||||
console.log('Voz pt selecionada por padrão:', voices[ptIndex].name);
|
||||
} else {
|
||||
voiceSelect.selectedIndex = 0;
|
||||
}
|
||||
|
||||
console.log('Vozes carregadas:', voices.length);
|
||||
}
|
||||
|
||||
// Inicializar vozes
|
||||
populateVoiceList();
|
||||
if (speechSynthesis.onvoiceschanged !== undefined) {
|
||||
speechSynthesis.onvoiceschanged = populateVoiceList;
|
||||
}
|
||||
|
||||
function speak() {
|
||||
if (synth.speaking) {
|
||||
console.error('speechSynthesis.speaking');
|
||||
// Função global para falar - acessível pelo onclick
|
||||
window.speak = function() {
|
||||
console.log('Função speak() chamada');
|
||||
|
||||
const textInput = document.getElementById('textInput');
|
||||
const voiceSelect = document.getElementById('voiceSelect');
|
||||
const rate = document.getElementById('rate');
|
||||
const pitch = document.getElementById('pitch');
|
||||
|
||||
if (!textInput || !voiceSelect) {
|
||||
console.error('Elementos não encontrados');
|
||||
return;
|
||||
}
|
||||
if (textInput.value !== '') {
|
||||
const utterThis = new SpeechSynthesisUtterance(textInput.value);
|
||||
|
||||
if (synth.speaking) {
|
||||
console.log('Já está falando, cancelando...');
|
||||
synth.cancel();
|
||||
}
|
||||
|
||||
const text = textInput.value.trim();
|
||||
if (text === '') {
|
||||
alert('Por favor, digite algum texto para ser lido.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Texto a falar:', text);
|
||||
|
||||
const utterThis = new SpeechSynthesisUtterance(text);
|
||||
|
||||
utterThis.onstart = function(event) {
|
||||
console.log('Iniciou a fala');
|
||||
document.getElementById('btnStop').style.display = 'inline-block';
|
||||
document.getElementById('btnSpeak').disabled = true;
|
||||
};
|
||||
|
||||
utterThis.onend = function(event) {
|
||||
console.log('SpeechSynthesisUtterance.onend');
|
||||
}
|
||||
console.log('Terminou a fala');
|
||||
document.getElementById('btnStop').style.display = 'none';
|
||||
document.getElementById('btnSpeak').disabled = false;
|
||||
};
|
||||
|
||||
utterThis.onerror = function(event) {
|
||||
console.error('SpeechSynthesisUtterance.onerror');
|
||||
}
|
||||
const selectedOption = voiceSelect.selectedOptions[0].getAttribute('data-name');
|
||||
console.error('Erro na fala:', event.error);
|
||||
document.getElementById('btnStop').style.display = 'none';
|
||||
document.getElementById('btnSpeak').disabled = false;
|
||||
};
|
||||
|
||||
// Selecionar voz
|
||||
if (voiceSelect.selectedOptions.length > 0) {
|
||||
const selectedVoiceName = voiceSelect.selectedOptions[0].getAttribute('data-name');
|
||||
for (let i = 0; i < voices.length; i++) {
|
||||
if (voices[i].name === selectedOption) {
|
||||
if (voices[i].name === selectedVoiceName) {
|
||||
utterThis.voice = voices[i];
|
||||
console.log('Voz selecionada:', voices[i].name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
utterThis.pitch = pitch.value;
|
||||
utterThis.rate = rate.value;
|
||||
}
|
||||
|
||||
utterThis.pitch = parseFloat(pitch.value);
|
||||
utterThis.rate = parseFloat(rate.value);
|
||||
|
||||
console.log('Pitch:', utterThis.pitch, 'Rate:', utterThis.rate);
|
||||
|
||||
// Falar
|
||||
synth.speak(utterThis);
|
||||
};
|
||||
|
||||
// Função global para parar
|
||||
window.stop = function() {
|
||||
console.log('Função stop() chamada');
|
||||
synth.cancel();
|
||||
document.getElementById('btnStop').style.display = 'none';
|
||||
document.getElementById('btnSpeak').disabled = false;
|
||||
};
|
||||
|
||||
// Detectar se é dispositivo móvel
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
|
||||
// Atualizar texto do botão para dispositivos móveis
|
||||
if (isMobile && navigator.canShare) {
|
||||
document.getElementById('iconDownload').className = 'bi bi-share me-2';
|
||||
document.getElementById('btnDownloadText').textContent = 'Compartilhar';
|
||||
}
|
||||
|
||||
// Função global para baixar/compartilhar áudio OGG
|
||||
window.downloadAudio = async function() {
|
||||
const textInput = document.getElementById('textInput');
|
||||
const voiceSelect = document.getElementById('voiceSelect');
|
||||
const rate = document.getElementById('rate');
|
||||
const pitch = document.getElementById('pitch');
|
||||
const btnDownload = document.getElementById('btnDownload');
|
||||
const downloadProgress = document.getElementById('downloadProgress');
|
||||
|
||||
const text = textInput.value.trim();
|
||||
if (text === '') {
|
||||
alert('Por favor, digite algum texto para gerar o áudio.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Obter idioma da voz selecionada
|
||||
let lang = 'pt-BR';
|
||||
if (voiceSelect.selectedOptions.length > 0) {
|
||||
lang = voiceSelect.selectedOptions[0].getAttribute('data-lang') || 'pt-BR';
|
||||
}
|
||||
|
||||
// Mostrar progresso
|
||||
btnDownload.disabled = true;
|
||||
downloadProgress.style.display = 'block';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tts/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: text,
|
||||
language: lang,
|
||||
rate: parseFloat(rate.value),
|
||||
pitch: parseFloat(pitch.value)
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error || 'Erro ao gerar áudio');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// Se for mobile e suportar Web Share API, usar compartilhamento nativo
|
||||
if (isMobile && navigator.canShare) {
|
||||
const file = new File([blob], 'audio.ogg', { type: 'audio/ogg' });
|
||||
|
||||
if (navigator.canShare({ files: [file] })) {
|
||||
try {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: 'Áudio gerado',
|
||||
text: 'Áudio gerado pelo Convert-It'
|
||||
});
|
||||
return; // Compartilhamento bem sucedido
|
||||
} catch (shareError) {
|
||||
if (shareError.name !== 'AbortError') {
|
||||
console.log('Compartilhamento cancelado ou falhou, usando download');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
synth.cancel();
|
||||
// Fallback: download tradicional
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'audio.ogg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro:', error);
|
||||
alert('Erro ao gerar áudio: ' + error.message);
|
||||
} finally {
|
||||
btnDownload.disabled = false;
|
||||
downloadProgress.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Script de Text-to-Speech carregado com sucesso!');
|
||||
</script>
|
||||
}
|
||||
|
||||
67
Controllers/TtsApiController.cs
Normal file
67
Controllers/TtsApiController.cs
Normal file
@ -0,0 +1,67 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Convert_It_Online.Services;
|
||||
|
||||
namespace Convert_It_Online.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/tts")]
|
||||
public class TtsApiController : ControllerBase
|
||||
{
|
||||
private readonly ITextToSpeechService _ttsService;
|
||||
private readonly ILogger<TtsApiController> _logger;
|
||||
|
||||
public TtsApiController(ITextToSpeechService ttsService, ILogger<TtsApiController> logger)
|
||||
{
|
||||
_ttsService = ttsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("generate")]
|
||||
public async Task<IActionResult> Generate([FromBody] TtsRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Text))
|
||||
{
|
||||
return BadRequest("Texto é obrigatório.");
|
||||
}
|
||||
|
||||
// Limitar tamanho do texto para evitar abusos
|
||||
if (request.Text.Length > 5000)
|
||||
{
|
||||
return BadRequest("Texto muito longo. Máximo de 5000 caracteres.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Gerando áudio TTS: {Length} caracteres, idioma: {Lang}",
|
||||
request.Text.Length, request.Language);
|
||||
|
||||
var audioBytes = await _ttsService.GenerateAudioAsync(
|
||||
request.Text,
|
||||
request.Language ?? "pt-BR",
|
||||
request.Rate ?? 1.0f,
|
||||
request.Pitch ?? 1.0f
|
||||
);
|
||||
|
||||
return File(audioBytes, "audio/ogg", "audio.ogg");
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "TTS não disponível");
|
||||
return StatusCode(503, "Serviço de síntese de voz não disponível no momento.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Erro ao gerar áudio TTS");
|
||||
return StatusCode(500, "Erro ao gerar áudio.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class TtsRequest
|
||||
{
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public string? Language { get; set; }
|
||||
public float? Rate { get; set; }
|
||||
public float? Pitch { get; set; }
|
||||
}
|
||||
}
|
||||
@ -44,10 +44,11 @@ FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
|
||||
# Instalar ffmpeg e bibliotecas nativas (rodar como root)
|
||||
# Instalar ffmpeg, espeak-ng e bibliotecas nativas (rodar como root)
|
||||
USER root
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ffmpeg \
|
||||
espeak-ng \
|
||||
libc6-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
USER app
|
||||
|
||||
@ -164,6 +164,7 @@ builder.Services.AddLocalization();
|
||||
|
||||
builder.Services.AddSingleton<IUrlTranslationService, UrlTranslationService>();
|
||||
builder.Services.AddSingleton<IAudioTranscriptionService, AudioTranscriptionService>();
|
||||
builder.Services.AddSingleton<ITextToSpeechService, TextToSpeechService>();
|
||||
|
||||
var supportedCultures = new[] { "pt-BR", "es-MX", "es-CL", "es-PY" };
|
||||
builder.Services.Configure<RequestLocalizationOptions>(options =>
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using Whisper.net;
|
||||
using Whisper.net.Ggml;
|
||||
@ -14,6 +16,7 @@ namespace Convert_It_Online.Services
|
||||
private readonly string _modelPath;
|
||||
private readonly ILogger<AudioTranscriptionService> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private static bool _ffmpegConfigured = false;
|
||||
|
||||
public AudioTranscriptionService(ILogger<AudioTranscriptionService> logger)
|
||||
{
|
||||
@ -27,6 +30,123 @@ namespace Convert_It_Online.Services
|
||||
{
|
||||
Directory.CreateDirectory(modelsDir!);
|
||||
}
|
||||
|
||||
// Configurar FFmpeg uma única vez
|
||||
ConfigureFFmpeg();
|
||||
}
|
||||
|
||||
private void ConfigureFFmpeg()
|
||||
{
|
||||
if (_ffmpegConfigured) return;
|
||||
|
||||
try
|
||||
{
|
||||
string? ffmpegPath = null;
|
||||
|
||||
// 1. Primeiro, verificar variável de ambiente (maior prioridade)
|
||||
var envPath = Environment.GetEnvironmentVariable("FFMPEG_PATH");
|
||||
if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath))
|
||||
{
|
||||
ffmpegPath = envPath;
|
||||
_logger.LogInformation("FFmpeg configurado via FFMPEG_PATH: {Path}", ffmpegPath);
|
||||
}
|
||||
|
||||
// 2. Se não encontrou, procurar em locais comuns
|
||||
if (ffmpegPath == null)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// Windows: procurar em locais comuns
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "ffmpeg"),
|
||||
@"C:\Apps\ffmpeg\bin",
|
||||
@"C:\Apps\ffmpeg",
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "ffmpeg", "bin"),
|
||||
@"C:\ffmpeg\bin",
|
||||
@"C:\ffmpeg",
|
||||
@"C:\Program Files\ffmpeg\bin",
|
||||
@"C:\tools\ffmpeg\bin",
|
||||
@"C:\tools\ffmpeg"
|
||||
};
|
||||
|
||||
foreach (var path in possiblePaths)
|
||||
{
|
||||
var ffmpegExe = Path.Combine(path, "ffmpeg.exe");
|
||||
if (File.Exists(ffmpegExe))
|
||||
{
|
||||
ffmpegPath = path;
|
||||
_logger.LogInformation("FFmpeg encontrado em: {Path}", ffmpegPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Tentar encontrar via PATH
|
||||
if (ffmpegPath == null)
|
||||
{
|
||||
ffmpegPath = FindFFmpegInPath("ffmpeg.exe");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Linux: verificar locais padrão
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
"/usr/bin",
|
||||
"/usr/local/bin",
|
||||
"/opt/ffmpeg/bin"
|
||||
};
|
||||
|
||||
foreach (var path in possiblePaths)
|
||||
{
|
||||
var ffmpegExe = Path.Combine(path, "ffmpeg");
|
||||
if (File.Exists(ffmpegExe))
|
||||
{
|
||||
ffmpegPath = path;
|
||||
_logger.LogInformation("FFmpeg encontrado em: {Path}", ffmpegPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ffmpegPath))
|
||||
{
|
||||
FFmpeg.SetExecutablesPath(ffmpegPath);
|
||||
_logger.LogInformation("FFmpeg.SetExecutablesPath configurado: {Path}", ffmpegPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("FFmpeg não encontrado em caminhos conhecidos. A transcrição de áudio pode falhar.");
|
||||
}
|
||||
|
||||
_ffmpegConfigured = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Erro ao configurar FFmpeg");
|
||||
}
|
||||
}
|
||||
|
||||
private string? FindFFmpegInPath(string executable)
|
||||
{
|
||||
var pathEnv = Environment.GetEnvironmentVariable("PATH");
|
||||
if (string.IsNullOrEmpty(pathEnv)) return null;
|
||||
|
||||
var separator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':';
|
||||
var paths = pathEnv.Split(separator);
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var fullPath = Path.Combine(path.Trim(), executable);
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogInformation("FFmpeg encontrado no PATH: {Path}", path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task EnsureModelExistsAsync()
|
||||
|
||||
9
Services/ITextToSpeechService.cs
Normal file
9
Services/ITextToSpeechService.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Convert_It_Online.Services
|
||||
{
|
||||
public interface ITextToSpeechService
|
||||
{
|
||||
Task<byte[]> GenerateAudioAsync(string text, string language = "pt-BR", float rate = 1.0f, float pitch = 1.0f);
|
||||
}
|
||||
}
|
||||
209
Services/TextToSpeechService.cs
Normal file
209
Services/TextToSpeechService.cs
Normal file
@ -0,0 +1,209 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Convert_It_Online.Services
|
||||
{
|
||||
public class TextToSpeechService : ITextToSpeechService
|
||||
{
|
||||
private readonly ILogger<TextToSpeechService> _logger;
|
||||
private readonly string? _espeakPath;
|
||||
private readonly string? _ffmpegPath;
|
||||
|
||||
public TextToSpeechService(ILogger<TextToSpeechService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_espeakPath = FindExecutable("espeak-ng");
|
||||
_ffmpegPath = FindExecutable("ffmpeg");
|
||||
|
||||
if (_espeakPath != null)
|
||||
_logger.LogInformation("espeak-ng encontrado em: {Path}", _espeakPath);
|
||||
else
|
||||
_logger.LogWarning("espeak-ng não encontrado. TTS pode não funcionar.");
|
||||
|
||||
if (_ffmpegPath != null)
|
||||
_logger.LogInformation("FFmpeg encontrado em: {Path}", _ffmpegPath);
|
||||
else
|
||||
_logger.LogWarning("FFmpeg não encontrado. Conversão para OGG pode não funcionar.");
|
||||
}
|
||||
|
||||
private string? FindExecutable(string name)
|
||||
{
|
||||
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
||||
var executable = isWindows ? $"{name}.exe" : name;
|
||||
|
||||
// Caminhos comuns
|
||||
string[] searchPaths;
|
||||
if (isWindows)
|
||||
{
|
||||
searchPaths = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, name),
|
||||
@"C:\Program Files\eSpeak NG",
|
||||
@"C:\Program Files (x86)\eSpeak NG",
|
||||
@"C:\Apps\espeak-ng",
|
||||
@"C:\Apps\ffmpeg\bin",
|
||||
@"C:\ffmpeg\bin",
|
||||
@"C:\Program Files\ffmpeg\bin"
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
searchPaths = new[]
|
||||
{
|
||||
"/usr/bin",
|
||||
"/usr/local/bin",
|
||||
"/opt/homebrew/bin"
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var path in searchPaths)
|
||||
{
|
||||
var fullPath = Path.Combine(path, executable);
|
||||
if (File.Exists(fullPath))
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
// Tentar encontrar via PATH
|
||||
var pathEnv = Environment.GetEnvironmentVariable("PATH");
|
||||
if (!string.IsNullOrEmpty(pathEnv))
|
||||
{
|
||||
var separator = isWindows ? ';' : ':';
|
||||
foreach (var path in pathEnv.Split(separator))
|
||||
{
|
||||
var fullPath = Path.Combine(path.Trim(), executable);
|
||||
if (File.Exists(fullPath))
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<byte[]> GenerateAudioAsync(string text, string language = "pt-BR", float rate = 1.0f, float pitch = 1.0f)
|
||||
{
|
||||
if (_espeakPath == null)
|
||||
throw new InvalidOperationException("espeak-ng não está instalado no servidor.");
|
||||
|
||||
if (_ffmpegPath == null)
|
||||
throw new InvalidOperationException("FFmpeg não está instalado no servidor.");
|
||||
|
||||
var tempWavPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.wav");
|
||||
var tempOggPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.ogg");
|
||||
|
||||
try
|
||||
{
|
||||
// Mapear código de idioma para espeak
|
||||
var espeakLang = MapLanguageCode(language);
|
||||
|
||||
// Calcular velocidade (espeak usa words per minute, padrão ~175)
|
||||
var wordsPerMinute = (int)(175 * rate);
|
||||
if (wordsPerMinute < 80) wordsPerMinute = 80;
|
||||
if (wordsPerMinute > 450) wordsPerMinute = 450;
|
||||
|
||||
// Calcular pitch (espeak usa 0-99, padrão 50)
|
||||
var espeakPitch = (int)(50 * pitch);
|
||||
if (espeakPitch < 0) espeakPitch = 0;
|
||||
if (espeakPitch > 99) espeakPitch = 99;
|
||||
|
||||
// Gerar WAV com espeak-ng
|
||||
var espeakArgs = $"-v {espeakLang} -s {wordsPerMinute} -p {espeakPitch} -w \"{tempWavPath}\" \"{EscapeText(text)}\"";
|
||||
|
||||
_logger.LogInformation("Executando espeak-ng: {Args}", espeakArgs);
|
||||
|
||||
var espeakProcess = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = _espeakPath,
|
||||
Arguments = espeakArgs,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
espeakProcess.Start();
|
||||
await espeakProcess.WaitForExitAsync();
|
||||
|
||||
if (espeakProcess.ExitCode != 0)
|
||||
{
|
||||
var error = await espeakProcess.StandardError.ReadToEndAsync();
|
||||
throw new Exception($"espeak-ng falhou: {error}");
|
||||
}
|
||||
|
||||
if (!File.Exists(tempWavPath))
|
||||
throw new Exception("espeak-ng não gerou o arquivo WAV.");
|
||||
|
||||
// Converter WAV para OGG com FFmpeg
|
||||
var ffmpegArgs = $"-i \"{tempWavPath}\" -c:a libvorbis -q:a 4 -y \"{tempOggPath}\"";
|
||||
|
||||
_logger.LogInformation("Executando FFmpeg: {Args}", ffmpegArgs);
|
||||
|
||||
var ffmpegProcess = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = _ffmpegPath,
|
||||
Arguments = ffmpegArgs,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
ffmpegProcess.Start();
|
||||
await ffmpegProcess.WaitForExitAsync();
|
||||
|
||||
if (ffmpegProcess.ExitCode != 0)
|
||||
{
|
||||
var error = await ffmpegProcess.StandardError.ReadToEndAsync();
|
||||
throw new Exception($"FFmpeg falhou: {error}");
|
||||
}
|
||||
|
||||
if (!File.Exists(tempOggPath))
|
||||
throw new Exception("FFmpeg não gerou o arquivo OGG.");
|
||||
|
||||
return await File.ReadAllBytesAsync(tempOggPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Limpar arquivos temporários
|
||||
if (File.Exists(tempWavPath))
|
||||
File.Delete(tempWavPath);
|
||||
if (File.Exists(tempOggPath))
|
||||
File.Delete(tempOggPath);
|
||||
}
|
||||
}
|
||||
|
||||
private string MapLanguageCode(string language)
|
||||
{
|
||||
// Mapear códigos de idioma para vozes espeak-ng
|
||||
return language.ToLower() switch
|
||||
{
|
||||
"pt-br" => "pt-br",
|
||||
"pt" => "pt",
|
||||
"pt-pt" => "pt-pt",
|
||||
"es" or "es-mx" or "es-cl" or "es-py" or "es-es" => "es",
|
||||
"en" or "en-us" => "en-us",
|
||||
"en-gb" => "en-gb",
|
||||
_ => "pt-br" // Padrão
|
||||
};
|
||||
}
|
||||
|
||||
private string EscapeText(string text)
|
||||
{
|
||||
// Escapar aspas e caracteres especiais para linha de comando
|
||||
return text
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"")
|
||||
.Replace("\n", " ")
|
||||
.Replace("\r", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user