d3ro-voice/apps/api-server/Services/SttProxyService.cs
2026-08-29 18:33:45 +09:00

1181 lines
44 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Dtos;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace D3ROVoice.Api.Services;
public interface ISttProxyService
{
Task<SttTranscribeResponse> TranscribeAsync(
int userId,
string userEmail,
SttTranscribeRequest request,
byte[]? audioBytes = null,
string? contentType = null,
string? fileName = null,
bool recordUsage = true
);
Task<SttTestResultDto> TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null);
Task<List<SttProviderEndpointDto>> GetAllEndpointsAsync();
Task<SttProviderEndpointDto> CreateEndpointAsync(CreateSttEndpointDto dto);
Task<SttProviderEndpointDto> UpdateEndpointAsync(int id, UpdateSttEndpointDto dto);
Task<bool> DeleteEndpointAsync(int id);
Task<bool> SetDefaultEndpointAsync(int id);
Task<SttUsageReportDto> GetUsageReportAsync();
}
public sealed class SttProviderUnavailableException : Exception
{
public bool ProviderAttempted { get; }
public SttProviderUnavailableException(bool providerAttempted)
: base("STT service is temporarily unavailable.")
{
ProviderAttempted = providerAttempted;
}
}
public class SttProxyService : ISttProxyService
{
private readonly AppDbContext _db;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<SttProxyService> _logger;
public SttProxyService(AppDbContext db, IHttpClientFactory httpClientFactory, ILogger<SttProxyService> logger)
{
_db = db;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<SttTranscribeResponse> TranscribeAsync(
int userId,
string userEmail,
SttTranscribeRequest request,
byte[]? audioBytes = null,
string? contentType = null,
string? fileName = null,
bool recordUsage = true
)
{
// 1. Resolve Audio Bytes
byte[] finalAudioBytes;
if (audioBytes != null && audioBytes.Length > 0)
{
finalAudioBytes = audioBytes;
}
else if (!string.IsNullOrWhiteSpace(request.AudioBase64))
{
try
{
var cleanBase64 = request.AudioBase64;
var commaIdx = cleanBase64.IndexOf(',');
if (commaIdx >= 0)
{
cleanBase64 = cleanBase64.Substring(commaIdx + 1);
}
finalAudioBytes = Convert.FromBase64String(cleanBase64);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to decode base64 audio data");
throw new ArgumentException("Invalid AudioBase64 data format.", ex);
}
}
else
{
throw new ArgumentException("No audio data provided. Either AudioBase64 or file upload is required.");
}
var effectiveContentType = contentType ?? DetectContentType(finalAudioBytes, fileName);
var effectiveFileName = fileName ?? GetDefaultFileName(effectiveContentType);
var durationSeconds = EstimateAudioDuration(finalAudioBytes, effectiveContentType);
// 2. Resolve Candidate Endpoints (Primary + Fallbacks)
var candidates = await GetCandidateEndpointsAsync(request.Provider, request.ModelId);
if (candidates.Count == 0)
{
throw new SttProviderUnavailableException(providerAttempted: false);
}
var totalSw = Stopwatch.StartNew();
Exception? lastException = null;
var providerAttempted = false;
// 3. Failover Execution Chain
foreach (var endpoint in candidates)
{
var epSw = Stopwatch.StartNew();
try
{
if (!HasRequiredProviderConfiguration(endpoint))
{
_logger.LogWarning("Skipping STT provider {Provider}: required credentials are not configured",
endpoint.ProviderType);
continue;
}
providerAttempted = true;
_logger.LogInformation("Attempting STT transcription via provider {Provider} ({Name}, Model: {Model})",
endpoint.ProviderType, endpoint.Name, endpoint.ModelId);
var (transcript, confidence, lang, detectedDuration) = await ExecuteProviderTranscriptionAsync(
endpoint,
finalAudioBytes,
effectiveContentType,
effectiveFileName,
request
);
epSw.Stop();
totalSw.Stop();
var finalDuration = detectedDuration > 0 ? detectedDuration : durationSeconds;
var durationMinutes = (decimal)(finalDuration / 60.0);
var cost = Math.Max(0.000001m, durationMinutes * endpoint.CostPerMinute);
// Supabase stt-proxy owns user quota and usage for internal
// provider calls. Direct legacy callers may still record here.
if (recordUsage)
{
try
{
var usageLog = new SttUsageLog
{
UserId = userId,
UserEmail = userEmail,
EndpointId = endpoint.Id,
Provider = endpoint.ProviderType,
ModelId = endpoint.ModelId,
AudioDurationSeconds = Math.Round(finalDuration, 2),
CalculatedCost = cost,
LatencyMs = (int)epSw.ElapsedMilliseconds,
StatusCode = 200,
TranscriptPreview = transcript.Length > 200 ? transcript.Substring(0, 200) + "..." : transcript,
CreatedAt = DateTime.UtcNow
};
_db.SttUsageLogs.Add(usageLog);
await _db.SaveChangesAsync();
}
catch (Exception dbEx)
{
_logger.LogWarning(dbEx, "Failed to save STT usage log to database");
}
}
return new SttTranscribeResponse(
Text: transcript,
Confidence: confidence > 0 ? confidence : 0.98,
Language: lang ?? endpoint.Language ?? "ko",
DurationSeconds: Math.Round(finalDuration, 2),
Provider: endpoint.ProviderType,
ModelId: endpoint.ModelId,
LatencyMs: epSw.ElapsedMilliseconds,
Cost: cost
);
}
catch (Exception ex)
{
epSw.Stop();
lastException = ex;
var failureType = GetSafeFailureType(ex);
_logger.LogWarning("Provider {Provider} transcription failed after {Ms}ms ({FailureType}). Trying fallback...",
endpoint.ProviderType, epSw.ElapsedMilliseconds, failureType);
// Record error to ErrorLogs
try
{
_db.ErrorLogs.Add(new ServerErrorLog
{
ErrorType = "SttProviderError",
Message = $"STT provider request failed ({failureType}).",
StackTrace = null,
Endpoint = null,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
}
catch
{
// ignore error log DB failure
}
}
}
totalSw.Stop();
_logger.LogError("All STT candidate endpoints failed. Total duration: {Ms}ms; last failure type: {FailureType}",
totalSw.ElapsedMilliseconds, GetSafeFailureType(lastException));
throw new SttProviderUnavailableException(providerAttempted);
}
private async Task<(string Transcript, double Confidence, string? Language, double Duration)> ExecuteProviderTranscriptionAsync(
SttProviderEndpoint endpoint,
byte[] audioBytes,
string contentType,
string fileName,
SttTranscribeRequest request
)
{
var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(90);
var providerType = endpoint.ProviderType.ToLowerInvariant();
var apiKey = endpoint.ApiKey?.Trim() ?? "";
if (RequiresApiKey(providerType) && string.IsNullOrWhiteSpace(apiKey))
{
throw new InvalidOperationException("STT provider credentials are not configured.");
}
switch (providerType)
{
case "groq":
case "openai":
case "custom":
{
return await CallOpenAiCompatibleSttAsync(client, endpoint, audioBytes, contentType, fileName, request);
}
case "deepgram":
{
return await CallDeepgramSttAsync(client, endpoint, audioBytes, contentType, request);
}
case "google":
{
return await CallGoogleSttAsync(client, endpoint, audioBytes, contentType, request);
}
case "assemblyai":
{
return await CallAssemblyAiSttAsync(client, endpoint, audioBytes, contentType, request);
}
case "azure":
{
return await CallAzureSttAsync(client, endpoint, audioBytes, contentType, request);
}
case "local-sidecar":
{
return await CallLocalSidecarSttAsync(client, endpoint, audioBytes, contentType, fileName, request);
}
default:
{
return await CallOpenAiCompatibleSttAsync(client, endpoint, audioBytes, contentType, fileName, request);
}
}
}
private async Task<(string, double, string?, double)> CallOpenAiCompatibleSttAsync(
HttpClient client,
SttProviderEndpoint endpoint,
byte[] audioBytes,
string contentType,
string fileName,
SttTranscribeRequest request
)
{
using var form = new MultipartFormDataContent();
var audioContent = new ByteArrayContent(audioBytes);
audioContent.Headers.ContentType = new MediaTypeHeaderValue(contentType);
form.Add(audioContent, "file", fileName);
form.Add(new StringContent(endpoint.ModelId), "model");
var language = request.Language ?? endpoint.Language ?? "ko";
if (!string.IsNullOrWhiteSpace(language) && language != "auto")
{
form.Add(new StringContent(language), "language");
}
var prompt = request.InitialPrompt ?? endpoint.Prompt;
if (!string.IsNullOrWhiteSpace(prompt))
{
form.Add(new StringContent(prompt), "prompt");
}
var temp = request.Temperature ?? endpoint.Temperature;
form.Add(new StringContent(temp.ToString("0.0")), "temperature");
form.Add(new StringContent("verbose_json"), "response_format");
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
{
Content = form
};
if (!string.IsNullOrWhiteSpace(endpoint.ApiKey))
{
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", endpoint.ApiKey);
}
ApplyExtraHeaders(httpRequest, endpoint.ExtraHeadersJson);
var httpResponse = await client.SendAsync(httpRequest);
var responseBody = await httpResponse.Content.ReadAsStringAsync();
if (!httpResponse.IsSuccessStatusCode)
{
throw new HttpRequestException($"STT upstream returned HTTP {httpResponse.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
if (!root.TryGetProperty("text", out var textProp) || textProp.ValueKind != JsonValueKind.String)
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
var text = textProp.GetString() ?? "";
var duration = 0.0;
if (root.TryGetProperty("duration", out var durProp))
{
duration = durProp.GetDouble();
}
var detectedLang = language;
if (root.TryGetProperty("language", out var langProp))
{
detectedLang = langProp.GetString() ?? language;
}
return (text.Trim(), 0.98, detectedLang, duration);
}
private async Task<(string, double, string?, double)> CallDeepgramSttAsync(
HttpClient client,
SttProviderEndpoint endpoint,
byte[] audioBytes,
string contentType,
SttTranscribeRequest request
)
{
var lang = request.Language ?? endpoint.Language ?? "ko";
var model = endpoint.ModelId ?? "nova-3";
var baseUrl = endpoint.EndpointUrl.TrimEnd('/');
var url = baseUrl.Contains("?")
? $"{baseUrl}&model={model}&language={lang}&smart_format=true&punctuate=true"
: $"{baseUrl}?model={model}&language={lang}&smart_format=true&punctuate=true";
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new ByteArrayContent(audioBytes)
};
httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
if (!string.IsNullOrWhiteSpace(endpoint.ApiKey))
{
httpRequest.Headers.Add("Authorization", $"Token {endpoint.ApiKey}");
}
ApplyExtraHeaders(httpRequest, endpoint.ExtraHeadersJson);
var response = await client.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
string transcript;
var confidence = 0.95;
var duration = 0.0;
if (!root.TryGetProperty("results", out var results) ||
!results.TryGetProperty("channels", out var channels) ||
channels.GetArrayLength() == 0 ||
!channels[0].TryGetProperty("alternatives", out var alternatives) ||
alternatives.GetArrayLength() == 0 ||
!alternatives[0].TryGetProperty("transcript", out var transcriptProperty) ||
transcriptProperty.ValueKind != JsonValueKind.String)
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
transcript = transcriptProperty.GetString() ?? "";
if (alternatives[0].TryGetProperty("confidence", out var confidenceProperty))
confidence = confidenceProperty.GetDouble();
if (root.TryGetProperty("metadata", out var meta) && meta.TryGetProperty("duration", out var dProp))
{
duration = dProp.GetDouble();
}
return (transcript.Trim(), confidence, lang, duration);
}
private async Task<(string, double, string?, double)> CallGoogleSttAsync(
HttpClient client,
SttProviderEndpoint endpoint,
byte[] audioBytes,
string contentType,
SttTranscribeRequest request
)
{
var base64Audio = Convert.ToBase64String(audioBytes);
var lang = request.Language ?? endpoint.Language ?? "ko";
// If endpoint is Gemini Flash Multimodal API
if (endpoint.EndpointUrl.Contains("generativelanguage.googleapis.com") || endpoint.ModelId.Contains("gemini"))
{
var apiKey = endpoint.ApiKey;
var geminiPayload = new
{
contents = new[]
{
new
{
parts = new object[]
{
new
{
inline_data = new
{
mime_type = contentType,
data = base64Audio
}
},
new
{
text = $"Transcribe this audio recording accurately in {lang}. Output only the transcribed plain text without any introductory or concluding comments."
}
}
}
}
};
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
{
Content = new StringContent(JsonSerializer.Serialize(geminiPayload), Encoding.UTF8, "application/json")
};
httpRequest.Headers.Add("X-Goog-Api-Key", apiKey);
var response = await client.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
if (!root.TryGetProperty("candidates", out var cands) ||
cands.GetArrayLength() == 0 ||
!cands[0].TryGetProperty("content", out var content) ||
!content.TryGetProperty("parts", out var parts) ||
parts.GetArrayLength() == 0 ||
!parts[0].TryGetProperty("text", out var textProperty) ||
textProperty.ValueKind != JsonValueKind.String)
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
var text = textProperty.GetString() ?? "";
return (text.Trim(), 0.98, lang, EstimateAudioDuration(audioBytes, contentType));
}
else
{
// Google Cloud Speech-to-Text v1
var apiKey = endpoint.ApiKey;
var gcpPayload = new
{
config = new
{
encoding = contentType.Contains("wav") ? "LINEAR16" : "WEBM_OPUS",
sampleRateHertz = 16000,
languageCode = lang == "ko" ? "ko-KR" : lang,
enableAutomaticPunctuation = true
},
audio = new
{
content = base64Audio
}
};
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
{
Content = new StringContent(JsonSerializer.Serialize(gcpPayload), Encoding.UTF8, "application/json")
};
httpRequest.Headers.Add("X-Goog-Api-Key", apiKey);
var response = await client.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
var transcript = "";
var confidence = 0.95;
if (!root.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array)
{
throw new JsonException("STT upstream response did not contain transcription results.");
}
if (results.GetArrayLength() > 0)
{
var sb = new StringBuilder();
foreach (var res in results.EnumerateArray())
{
if (res.TryGetProperty("alternatives", out var alts) && alts.GetArrayLength() > 0)
{
var alt = alts[0];
if (alt.TryGetProperty("transcript", out var t)) sb.Append(t.GetString()).Append(' ');
if (alt.TryGetProperty("confidence", out var c)) confidence = c.GetDouble();
}
}
transcript = sb.ToString().Trim();
}
return (transcript, confidence, lang, EstimateAudioDuration(audioBytes, contentType));
}
}
private async Task<(string, double, string?, double)> CallAssemblyAiSttAsync(
HttpClient client,
SttProviderEndpoint endpoint,
byte[] audioBytes,
string contentType,
SttTranscribeRequest request
)
{
// 1. Upload audio
var uploadRequest = new HttpRequestMessage(HttpMethod.Post, "https://api.assemblyai.com/v2/upload")
{
Content = new ByteArrayContent(audioBytes)
};
uploadRequest.Headers.Add("Authorization", endpoint.ApiKey);
var uploadResp = await client.SendAsync(uploadRequest);
var uploadBody = await uploadResp.Content.ReadAsStringAsync();
if (!uploadResp.IsSuccessStatusCode)
{
throw new HttpRequestException($"STT upstream upload returned HTTP {uploadResp.StatusCode}.");
}
using var uploadDoc = JsonDocument.Parse(uploadBody);
var uploadUrl = uploadDoc.RootElement.GetProperty("upload_url").GetString()!;
// 2. Submit transcript job
var lang = request.Language ?? endpoint.Language ?? "ko";
var transcriptPayload = new
{
audio_url = uploadUrl,
language_code = lang,
punctuate = true,
format_text = true
};
var transRequest = new HttpRequestMessage(HttpMethod.Post, "https://api.assemblyai.com/v2/transcript")
{
Content = new StringContent(JsonSerializer.Serialize(transcriptPayload), Encoding.UTF8, "application/json")
};
transRequest.Headers.Add("Authorization", endpoint.ApiKey);
var transResp = await client.SendAsync(transRequest);
var transBody = await transResp.Content.ReadAsStringAsync();
if (!transResp.IsSuccessStatusCode)
{
throw new HttpRequestException($"STT upstream job returned HTTP {transResp.StatusCode}.");
}
using var transDoc = JsonDocument.Parse(transBody);
var id = transDoc.RootElement.GetProperty("id").GetString()!;
// 3. Poll for result (up to 20 seconds)
for (int i = 0; i < 20; i++)
{
await Task.Delay(1000);
var pollReq = new HttpRequestMessage(HttpMethod.Get, $"https://api.assemblyai.com/v2/transcript/{id}");
pollReq.Headers.Add("Authorization", endpoint.ApiKey);
var pollResp = await client.SendAsync(pollReq);
var pollBody = await pollResp.Content.ReadAsStringAsync();
if (!pollResp.IsSuccessStatusCode)
{
throw new HttpRequestException($"STT upstream poll returned HTTP {pollResp.StatusCode}.");
}
using var pollDoc = JsonDocument.Parse(pollBody);
var status = pollDoc.RootElement.GetProperty("status").GetString();
if (status == "completed")
{
if (!pollDoc.RootElement.TryGetProperty("text", out var textProperty) ||
textProperty.ValueKind != JsonValueKind.String)
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
var text = textProperty.GetString() ?? "";
var confidence = 0.95;
if (pollDoc.RootElement.TryGetProperty("confidence", out var c)) confidence = c.GetDouble();
var duration = 0.0;
if (pollDoc.RootElement.TryGetProperty("audio_duration", out var d)) duration = d.GetDouble();
return (text, confidence, lang, duration);
}
if (status == "error")
{
throw new HttpRequestException("STT upstream processing failed.");
}
}
throw new TimeoutException("AssemblyAI transcript timed out.");
}
private async Task<(string, double, string?, double)> CallAzureSttAsync(
HttpClient client,
SttProviderEndpoint endpoint,
byte[] audioBytes,
string contentType,
SttTranscribeRequest request
)
{
var lang = request.Language ?? endpoint.Language ?? "ko-KR";
if (lang == "ko") lang = "ko-KR";
var url = endpoint.EndpointUrl.Contains("?")
? $"{endpoint.EndpointUrl}&language={lang}&format=detailed"
: $"{endpoint.EndpointUrl}?language={lang}&format=detailed";
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new ByteArrayContent(audioBytes)
};
httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType.Contains("wav") ? "audio/wav" : "audio/webm");
if (!string.IsNullOrWhiteSpace(endpoint.ApiKey))
{
httpRequest.Headers.Add("Ocp-Apim-Subscription-Key", endpoint.ApiKey);
}
var response = await client.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
string text;
var confidence = 0.95;
if (root.TryGetProperty("DisplayText", out var dt) && dt.ValueKind == JsonValueKind.String)
{
text = dt.GetString() ?? "";
}
else if (root.TryGetProperty("NBest", out var nbest) && nbest.GetArrayLength() > 0)
{
var best = nbest[0];
if (!best.TryGetProperty("Display", out var display) || display.ValueKind != JsonValueKind.String)
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
text = display.GetString() ?? "";
if (best.TryGetProperty("Confidence", out var c)) confidence = c.GetDouble();
}
else
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
return (text, confidence, lang, EstimateAudioDuration(audioBytes, contentType));
}
private async Task<(string, double, string?, double)> CallLocalSidecarSttAsync(
HttpClient client,
SttProviderEndpoint endpoint,
byte[] audioBytes,
string contentType,
string fileName,
SttTranscribeRequest request
)
{
using var form = new MultipartFormDataContent();
var audioContent = new ByteArrayContent(audioBytes);
audioContent.Headers.ContentType = new MediaTypeHeaderValue(contentType);
form.Add(audioContent, "file", fileName);
form.Add(new StringContent(endpoint.ModelId), "model");
form.Add(new StringContent(request.Language ?? endpoint.Language ?? "ko"), "language");
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
{
Content = form
};
if (!string.IsNullOrWhiteSpace(endpoint.ApiKey))
{
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", endpoint.ApiKey);
}
var response = await client.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
if (!root.TryGetProperty("text", out var tProp) || tProp.ValueKind != JsonValueKind.String)
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
var text = tProp.GetString() ?? "";
var duration = EstimateAudioDuration(audioBytes, contentType);
if (root.TryGetProperty("duration", out var dProp)) duration = dProp.GetDouble();
return (text, 0.99, endpoint.Language, duration);
}
public async Task<SttTestResultDto> TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null)
{
SttProviderEndpoint? endpoint = null;
if (endpointId > 0)
{
endpoint = await _db.SttProviderEndpoints.FindAsync(endpointId);
}
if (endpoint == null)
{
endpoint = new SttProviderEndpoint
{
Id = 0,
Name = "Direct Endpoint Test",
ProviderType = "groq",
EndpointUrl = testEndpointUrl ?? "https://api.groq.com/openai/v1/audio/transcriptions",
ApiKey = testApiKey ?? "",
ModelId = "whisper-large-v3-turbo",
Method = "multipart",
Language = "ko",
CostPerMinute = 0.000500m
};
}
else
{
if (testApiKey != null) endpoint.ApiKey = testApiKey;
if (testEndpointUrl != null) endpoint.EndpointUrl = testEndpointUrl;
}
var sw = Stopwatch.StartNew();
try
{
// Generate a synthetic 0.5-second silent 16kHz mono WAV sample for testing connection
var syntheticWav = GenerateSyntheticTestWav(0.5);
var (transcript, _, _, _) = await ExecuteProviderTranscriptionAsync(
endpoint,
syntheticWav,
"audio/wav",
"test_audio.wav",
new SttTranscribeRequest(Language: endpoint.Language ?? "ko")
);
sw.Stop();
return new SttTestResultDto(
Success: true,
Message: $"연결 및 인증 성공 (응답 시간: {sw.ElapsedMilliseconds}ms)",
LatencyMs: sw.ElapsedMilliseconds,
TranscriptPreview: string.IsNullOrWhiteSpace(transcript) ? "[무음 또는 성공적 응답 수신]" : transcript,
Provider: endpoint.ProviderType,
ModelId: endpoint.ModelId
);
}
catch (Exception ex)
{
sw.Stop();
return new SttTestResultDto(
Success: false,
Message: $"연결 실패: {GetSafeFailureType(ex)}",
LatencyMs: sw.ElapsedMilliseconds,
TranscriptPreview: null,
Provider: endpoint.ProviderType,
ModelId: endpoint.ModelId
);
}
}
public async Task<List<SttProviderEndpointDto>> GetAllEndpointsAsync()
{
var endpoints = await _db.SttProviderEndpoints
.OrderBy(e => e.FallbackPriority)
.ThenByDescending(e => e.IsDefault)
.ToListAsync();
return endpoints.Select(MapToDto).ToList();
}
public async Task<SttProviderEndpointDto> CreateEndpointAsync(CreateSttEndpointDto dto)
{
if (dto.IsDefault)
{
// Unset previous defaults
var existingDefaults = await _db.SttProviderEndpoints.Where(e => e.IsDefault).ToListAsync();
foreach (var ep in existingDefaults) ep.IsDefault = false;
}
var endpoint = new SttProviderEndpoint
{
Name = dto.Name.Trim(),
ProviderType = dto.ProviderType.Trim().ToLowerInvariant(),
EndpointUrl = dto.EndpointUrl.Trim(),
ApiKey = dto.ApiKey?.Trim() ?? "",
ModelId = string.IsNullOrWhiteSpace(dto.ModelId) ? "whisper-large-v3-turbo" : dto.ModelId.Trim(),
Method = string.IsNullOrWhiteSpace(dto.Method) ? "multipart" : dto.Method.Trim(),
Language = string.IsNullOrWhiteSpace(dto.Language) ? "ko" : dto.Language.Trim(),
Prompt = dto.Prompt?.Trim(),
Temperature = dto.Temperature,
CostPerMinute = dto.CostPerMinute,
CostPerSecond = dto.CostPerSecond > 0 ? dto.CostPerSecond : dto.CostPerMinute / 60m,
IsDefault = dto.IsDefault,
IsActive = dto.IsActive,
FallbackPriority = dto.FallbackPriority,
ExtraHeadersJson = dto.ExtraHeadersJson,
CreatedAt = DateTime.UtcNow
};
_db.SttProviderEndpoints.Add(endpoint);
await _db.SaveChangesAsync();
return MapToDto(endpoint);
}
public async Task<SttProviderEndpointDto> UpdateEndpointAsync(int id, UpdateSttEndpointDto dto)
{
var endpoint = await _db.SttProviderEndpoints.FindAsync(id);
if (endpoint == null)
{
throw new KeyNotFoundException($"STT Endpoint with ID {id} not found.");
}
if (dto.IsDefault && !endpoint.IsDefault)
{
var existingDefaults = await _db.SttProviderEndpoints.Where(e => e.IsDefault && e.Id != id).ToListAsync();
foreach (var ep in existingDefaults) ep.IsDefault = false;
}
endpoint.Name = dto.Name.Trim();
endpoint.ProviderType = dto.ProviderType.Trim().ToLowerInvariant();
endpoint.EndpointUrl = dto.EndpointUrl.Trim();
if (dto.ApiKey != null) endpoint.ApiKey = dto.ApiKey.Trim();
endpoint.ModelId = dto.ModelId.Trim();
endpoint.Method = dto.Method.Trim();
endpoint.Language = dto.Language?.Trim() ?? "ko";
endpoint.Prompt = dto.Prompt?.Trim();
endpoint.Temperature = dto.Temperature;
endpoint.CostPerMinute = dto.CostPerMinute;
endpoint.CostPerSecond = dto.CostPerSecond > 0 ? dto.CostPerSecond : dto.CostPerMinute / 60m;
endpoint.IsDefault = dto.IsDefault;
endpoint.IsActive = dto.IsActive;
endpoint.FallbackPriority = dto.FallbackPriority;
endpoint.ExtraHeadersJson = dto.ExtraHeadersJson;
endpoint.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return MapToDto(endpoint);
}
public async Task<bool> DeleteEndpointAsync(int id)
{
var endpoint = await _db.SttProviderEndpoints.FindAsync(id);
if (endpoint == null) return false;
_db.SttProviderEndpoints.Remove(endpoint);
await _db.SaveChangesAsync();
return true;
}
public async Task<bool> SetDefaultEndpointAsync(int id)
{
var allEndpoints = await _db.SttProviderEndpoints.ToListAsync();
var target = allEndpoints.FirstOrDefault(e => e.Id == id);
if (target == null) return false;
foreach (var ep in allEndpoints)
{
ep.IsDefault = (ep.Id == id);
}
await _db.SaveChangesAsync();
return true;
}
public async Task<SttUsageReportDto> GetUsageReportAsync()
{
var logs = await _db.SttUsageLogs.AsNoTracking().ToListAsync();
var totalTranscriptions = logs.Count;
var totalAudioMinutes = logs.Sum(l => l.AudioDurationSeconds) / 60.0;
var totalCost = logs.Sum(l => l.CalculatedCost);
var avgLatencyMs = logs.Count > 0 ? logs.Average(l => l.LatencyMs) : 0;
var providerSummaries = logs
.GroupBy(l => new { l.Provider, l.ModelId })
.Select(g => new SttProviderUsageSummaryDto(
Provider: g.Key.Provider,
ModelId: g.Key.ModelId,
TotalRequests: g.Count(),
TotalAudioMinutes: Math.Round(g.Sum(x => x.AudioDurationSeconds) / 60.0, 2),
TotalCost: g.Sum(x => x.CalculatedCost),
AvgLatencyMs: Math.Round(g.Average(x => x.LatencyMs), 1)
))
.OrderByDescending(p => p.TotalCost)
.ToList();
var userSummaries = logs
.GroupBy(l => new { l.UserId, l.UserEmail })
.Select(g => new SttUserUsageSummaryDto(
UserId: g.Key.UserId,
Email: g.Key.UserEmail,
TotalRequests: g.Count(),
TotalAudioMinutes: Math.Round(g.Sum(x => x.AudioDurationSeconds) / 60.0, 2),
TotalCost: g.Sum(x => x.CalculatedCost)
))
.OrderByDescending(u => u.TotalCost)
.ToList();
return new SttUsageReportDto(
TotalTranscriptions: totalTranscriptions,
TotalAudioMinutes: Math.Round(totalAudioMinutes, 2),
TotalCost: totalCost,
AvgLatencyMs: Math.Round(avgLatencyMs, 1),
ProviderSummaries: providerSummaries,
UserSummaries: userSummaries
);
}
private async Task<List<SttProviderEndpoint>> GetCandidateEndpointsAsync(string? requestedProvider, string? requestedModel)
{
var query = _db.SttProviderEndpoints.Where(e => e.IsActive);
if (!string.IsNullOrWhiteSpace(requestedProvider))
{
var match = await query.FirstOrDefaultAsync(e => e.ProviderType.ToLower() == requestedProvider.ToLower());
if (match != null)
{
var others = await query.Where(e => e.Id != match.Id).OrderBy(e => e.FallbackPriority).ToListAsync();
others.Insert(0, match);
return others;
}
}
if (!string.IsNullOrWhiteSpace(requestedModel))
{
var match = await query.FirstOrDefaultAsync(e => e.ModelId.ToLower() == requestedModel.ToLower());
if (match != null)
{
var others = await query.Where(e => e.Id != match.Id).OrderBy(e => e.FallbackPriority).ToListAsync();
others.Insert(0, match);
return others;
}
}
// Ordered by isDefault first, then FallbackPriority
return await query
.OrderByDescending(e => e.IsDefault)
.ThenBy(e => e.FallbackPriority)
.ToListAsync();
}
private static bool HasRequiredProviderConfiguration(SttProviderEndpoint endpoint)
{
if (string.IsNullOrWhiteSpace(endpoint.EndpointUrl)) return false;
var providerType = endpoint.ProviderType.Trim().ToLowerInvariant();
return !RequiresApiKey(providerType) || !string.IsNullOrWhiteSpace(endpoint.ApiKey);
}
private static bool RequiresApiKey(string providerType)
{
return providerType is "groq" or "openai" or "deepgram" or "google" or "assemblyai" or "azure";
}
private static string GetSafeFailureType(Exception? exception)
{
return exception switch
{
TimeoutException or TaskCanceledException => "timeout",
HttpRequestException => "upstream_http_error",
JsonException => "invalid_upstream_response",
InvalidOperationException => "provider_not_configured",
null => "not_configured",
_ => "provider_error"
};
}
private static void ApplyExtraHeaders(HttpRequestMessage req, string? extraHeadersJson)
{
if (string.IsNullOrWhiteSpace(extraHeadersJson)) return;
try
{
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(extraHeadersJson);
if (dict != null)
{
foreach (var (k, v) in dict)
{
req.Headers.TryAddWithoutValidation(k, v);
}
}
}
catch
{
// ignore invalid header json
}
}
private static string DetectContentType(byte[] audioBytes, string? fileName)
{
if (!string.IsNullOrWhiteSpace(fileName))
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
if (ext == ".wav") return "audio/wav";
if (ext == ".mp3") return "audio/mp3";
if (ext == ".ogg") return "audio/ogg";
if (ext == ".m4a") return "audio/m4a";
if (ext == ".flac") return "audio/flac";
if (ext == ".webm") return "audio/webm";
}
if (audioBytes.Length >= 12)
{
var header = Encoding.ASCII.GetString(audioBytes, 0, 4);
if (header == "RIFF") return "audio/wav";
if (header == "OggS") return "audio/ogg";
if (audioBytes[0] == 0x1A && audioBytes[1] == 0x45 && audioBytes[2] == 0xDF && audioBytes[3] == 0xA3)
return "audio/webm";
if (audioBytes[0] == 0xFF && (audioBytes[1] & 0xE0) == 0xE0)
return "audio/mp3";
}
return "audio/webm";
}
private static string GetDefaultFileName(string contentType)
{
if (contentType.Contains("wav")) return "recording.wav";
if (contentType.Contains("mp3")) return "recording.mp3";
if (contentType.Contains("ogg")) return "recording.ogg";
return "recording.webm";
}
private static double EstimateAudioDuration(byte[] bytes, string contentType)
{
if (bytes == null || bytes.Length == 0) return 0.0;
// If standard WAV header
if (bytes.Length >= 44 && Encoding.ASCII.GetString(bytes, 0, 4) == "RIFF")
{
try
{
var byteRate = BitConverter.ToInt32(bytes, 28);
if (byteRate > 0)
{
var dataLength = bytes.Length - 44;
return Math.Max(0.1, (double)dataLength / byteRate);
}
}
catch
{
// fallback
}
}
// Standard 16kHz 16-bit mono PCM assumption (~32,000 bytes/sec) or 32kbps opus (~4,000 bytes/sec)
if (contentType.Contains("wav"))
{
return Math.Max(0.5, (double)bytes.Length / 32000.0);
}
else
{
// WebM / MP3 compressed audio (~4000 bytes per second average)
return Math.Max(0.5, (double)bytes.Length / 4000.0);
}
}
private static byte[] GenerateSyntheticTestWav(double seconds)
{
int sampleRate = 16000;
int numSamples = (int)(sampleRate * seconds);
int subChunk2Size = numSamples * 2;
int chunkSize = 36 + subChunk2Size;
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
// RIFF header
bw.Write(Encoding.ASCII.GetBytes("RIFF"));
bw.Write(chunkSize);
bw.Write(Encoding.ASCII.GetBytes("WAVE"));
// fmt subchunk
bw.Write(Encoding.ASCII.GetBytes("fmt "));
bw.Write(16); // Subchunk1Size for PCM
bw.Write((short)1); // AudioFormat 1 = PCM
bw.Write((short)1); // NumChannels = 1 (Mono)
bw.Write(sampleRate); // SampleRate
bw.Write(sampleRate * 2); // ByteRate
bw.Write((short)2); // BlockAlign
bw.Write((short)16); // BitsPerSample
// data subchunk
bw.Write(Encoding.ASCII.GetBytes("data"));
bw.Write(subChunk2Size);
// Write silence / low pulse
for (int i = 0; i < numSamples; i++)
{
short sample = (short)(Math.Sin(2 * Math.PI * 440 * i / sampleRate) * 500); // gentle 440Hz test tone
bw.Write(sample);
}
return ms.ToArray();
}
private static SttProviderEndpointDto MapToDto(SttProviderEndpoint e)
{
return new SttProviderEndpointDto(
Id: e.Id,
Name: e.Name,
ProviderType: e.ProviderType,
EndpointUrl: e.EndpointUrl,
ApiKey: string.IsNullOrEmpty(e.ApiKey) ? "" : "••••••••",
ModelId: e.ModelId,
Method: e.Method,
Language: e.Language,
Prompt: e.Prompt,
Temperature: e.Temperature,
CostPerMinute: e.CostPerMinute,
CostPerSecond: e.CostPerSecond,
IsDefault: e.IsDefault,
IsActive: e.IsActive,
FallbackPriority: e.FallbackPriority,
ExtraHeadersJson: e.ExtraHeadersJson,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt
);
}
}