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(dotnet build:*)",
|
||||||
"Bash(rm:*)",
|
"Bash(rm:*)",
|
||||||
"Bash(dotnet add package:*)",
|
"Bash(dotnet add package:*)",
|
||||||
"Bash(dir:*)"
|
"Bash(dir:*)",
|
||||||
|
"Bash(where:*)",
|
||||||
|
"Bash(winget install:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Áudio para Texto (Transcrição)";
|
ViewData["Title"] = "Áudio para Texto (Transcrição)";
|
||||||
var culture = ViewContext.RouteData.Values["culture"] as string ?? "pt-BR";
|
var culture = ViewContext.RouteData.Values["culture"] as string ?? "pt-BR";
|
||||||
|
Layout = "_Layout";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="text-center mb-5">
|
<div class="text-center mb-5">
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Texto para Áudio (Voz)";
|
ViewData["Title"] = "Texto para Áudio (Voz)";
|
||||||
|
Layout = "_Layout";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="text-center mb-5">
|
<div class="text-center mb-5">
|
||||||
@ -31,12 +32,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
|
<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
|
<i class="bi bi-play-fill me-2"></i>Ouvir
|
||||||
</button>
|
</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
|
<i class="bi bi-stop-fill me-2"></i>Parar
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -49,15 +60,15 @@
|
|||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script>
|
<script>
|
||||||
|
// Variáveis globais
|
||||||
const synth = window.speechSynthesis;
|
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 = [];
|
let voices = [];
|
||||||
|
|
||||||
|
// Função para popular lista de vozes
|
||||||
function populateVoiceList() {
|
function populateVoiceList() {
|
||||||
|
const voiceSelect = document.getElementById('voiceSelect');
|
||||||
|
if (!voiceSelect) return;
|
||||||
|
|
||||||
voices = synth.getVoices().sort(function (a, b) {
|
voices = synth.getVoices().sort(function (a, b) {
|
||||||
const aname = a.name.toUpperCase();
|
const aname = a.name.toUpperCase();
|
||||||
const bname = b.name.toUpperCase();
|
const bname = b.name.toUpperCase();
|
||||||
@ -66,8 +77,9 @@
|
|||||||
else return 0;
|
else return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedIndex = voiceSelect.selectedIndex < 0 ? 0 : voiceSelect.selectedIndex;
|
|
||||||
voiceSelect.innerHTML = '';
|
voiceSelect.innerHTML = '';
|
||||||
|
let ptBrIndex = -1;
|
||||||
|
let ptIndex = -1;
|
||||||
|
|
||||||
for (let i = 0; i < voices.length; i++) {
|
for (let i = 0; i < voices.length; i++) {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
@ -80,43 +92,205 @@
|
|||||||
option.setAttribute('data-lang', voices[i].lang);
|
option.setAttribute('data-lang', voices[i].lang);
|
||||||
option.setAttribute('data-name', voices[i].name);
|
option.setAttribute('data-name', voices[i].name);
|
||||||
voiceSelect.appendChild(option);
|
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();
|
populateVoiceList();
|
||||||
if (speechSynthesis.onvoiceschanged !== undefined) {
|
if (speechSynthesis.onvoiceschanged !== undefined) {
|
||||||
speechSynthesis.onvoiceschanged = populateVoiceList;
|
speechSynthesis.onvoiceschanged = populateVoiceList;
|
||||||
}
|
}
|
||||||
|
|
||||||
function speak() {
|
// Função global para falar - acessível pelo onclick
|
||||||
if (synth.speaking) {
|
window.speak = function() {
|
||||||
console.error('speechSynthesis.speaking');
|
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;
|
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) {
|
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) {
|
utterThis.onerror = function(event) {
|
||||||
console.error('SpeechSynthesisUtterance.onerror');
|
console.error('Erro na fala:', event.error);
|
||||||
}
|
document.getElementById('btnStop').style.display = 'none';
|
||||||
const selectedOption = voiceSelect.selectedOptions[0].getAttribute('data-name');
|
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++) {
|
for (let i = 0; i < voices.length; i++) {
|
||||||
if (voices[i].name === selectedOption) {
|
if (voices[i].name === selectedVoiceName) {
|
||||||
utterThis.voice = voices[i];
|
utterThis.voice = voices[i];
|
||||||
|
console.log('Voz selecionada:', voices[i].name);
|
||||||
break;
|
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);
|
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() {
|
// Fallback: download tradicional
|
||||||
synth.cancel();
|
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>
|
</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
|
WORKDIR /app
|
||||||
COPY --from=publish /app/publish .
|
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
|
USER root
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
|
espeak-ng \
|
||||||
libc6-dev \
|
libc6-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
USER app
|
USER app
|
||||||
|
|||||||
@ -164,6 +164,7 @@ builder.Services.AddLocalization();
|
|||||||
|
|
||||||
builder.Services.AddSingleton<IUrlTranslationService, UrlTranslationService>();
|
builder.Services.AddSingleton<IUrlTranslationService, UrlTranslationService>();
|
||||||
builder.Services.AddSingleton<IAudioTranscriptionService, AudioTranscriptionService>();
|
builder.Services.AddSingleton<IAudioTranscriptionService, AudioTranscriptionService>();
|
||||||
|
builder.Services.AddSingleton<ITextToSpeechService, TextToSpeechService>();
|
||||||
|
|
||||||
var supportedCultures = new[] { "pt-BR", "es-MX", "es-CL", "es-PY" };
|
var supportedCultures = new[] { "pt-BR", "es-MX", "es-CL", "es-PY" };
|
||||||
builder.Services.Configure<RequestLocalizationOptions>(options =>
|
builder.Services.Configure<RequestLocalizationOptions>(options =>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Whisper.net;
|
using Whisper.net;
|
||||||
using Whisper.net.Ggml;
|
using Whisper.net.Ggml;
|
||||||
@ -14,6 +16,7 @@ namespace Convert_It_Online.Services
|
|||||||
private readonly string _modelPath;
|
private readonly string _modelPath;
|
||||||
private readonly ILogger<AudioTranscriptionService> _logger;
|
private readonly ILogger<AudioTranscriptionService> _logger;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
private static bool _ffmpegConfigured = false;
|
||||||
|
|
||||||
public AudioTranscriptionService(ILogger<AudioTranscriptionService> logger)
|
public AudioTranscriptionService(ILogger<AudioTranscriptionService> logger)
|
||||||
{
|
{
|
||||||
@ -27,6 +30,123 @@ namespace Convert_It_Online.Services
|
|||||||
{
|
{
|
||||||
Directory.CreateDirectory(modelsDir!);
|
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()
|
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