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 TranscribeAsync( int userId, string userEmail, SttTranscribeRequest request, byte[]? audioBytes = null, string? contentType = null, string? fileName = null, bool recordUsage = true ); Task TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null); Task> GetAllEndpointsAsync(); Task CreateEndpointAsync(CreateSttEndpointDto dto); Task UpdateEndpointAsync(int id, UpdateSttEndpointDto dto); Task DeleteEndpointAsync(int id); Task SetDefaultEndpointAsync(int id); Task 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 _logger; public SttProxyService(AppDbContext db, IHttpClientFactory httpClientFactory, ILogger logger) { _db = db; _httpClientFactory = httpClientFactory; _logger = logger; } public async Task 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 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> GetAllEndpointsAsync() { var endpoints = await _db.SttProviderEndpoints .OrderBy(e => e.FallbackPriority) .ThenByDescending(e => e.IsDefault) .ToListAsync(); return endpoints.Select(MapToDto).ToList(); } public async Task 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 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 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 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 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> 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>(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 ); } }