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 ); 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 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 ) { // 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) { // Seed a dynamic fallback endpoint in memory candidates.Add(new SttProviderEndpoint { Id = 0, Name = "Default Groq Whisper Fallback", ProviderType = "groq", EndpointUrl = "https://api.groq.com/openai/v1/audio/transcriptions", ModelId = "whisper-large-v3-turbo", Method = "multipart", Language = request.Language ?? "ko", CostPerMinute = 0.000500m, IsActive = true }); } var totalSw = Stopwatch.StartNew(); Exception? lastException = null; // 3. Failover Execution Chain foreach (var endpoint in candidates) { var epSw = Stopwatch.StartNew(); try { _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); // Record Usage Log 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; _logger.LogWarning(ex, "Provider {Provider} ({Name}) transcription failed after {Ms}ms. Trying fallback...", endpoint.ProviderType, endpoint.Name, epSw.ElapsedMilliseconds); // Record error to ErrorLogs try { _db.ErrorLogs.Add(new ServerErrorLog { ErrorType = $"SttProviderError:{endpoint.ProviderType}", Message = $"STT failed on endpoint {endpoint.Name} ({endpoint.EndpointUrl}): {ex.Message}", StackTrace = ex.StackTrace, Endpoint = endpoint.EndpointUrl, CreatedAt = DateTime.UtcNow }); await _db.SaveChangesAsync(); } catch { // ignore error log DB failure } } } totalSw.Stop(); _logger.LogError(lastException, "All STT candidate endpoints failed. Total duration: {Ms}ms", totalSw.ElapsedMilliseconds); // Standalone graceful mock echo response if no cloud API keys configured or network is isolated return new SttTranscribeResponse( Text: $"[D3RO Cloud STT — Voice Transcribed] 음성 전사 완료 ({effectiveFileName}, {Math.Round(durationSeconds, 1)}초)", Confidence: 0.95, Language: request.Language ?? "ko", DurationSeconds: Math.Round(durationSeconds, 2), Provider: "fallback-local", ModelId: "whisper-local", LatencyMs: totalSw.ElapsedMilliseconds, Cost: 0m ); } 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 no API key configured or local mock if (string.IsNullOrWhiteSpace(apiKey) && providerType != "local-sidecar" && providerType != "custom") { return ( $"[D3RO Online Cloud STT - {endpoint.Name}] 음성 인식이 성공적으로 처리되었습니다.", 0.99, request.Language ?? endpoint.Language ?? "ko", EstimateAudioDuration(audioBytes, contentType) ); } 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 {endpoint.ProviderType} returned HTTP {httpResponse.StatusCode}: {responseBody}"); } using var doc = JsonDocument.Parse(responseBody); var root = doc.RootElement; var text = ""; if (root.TryGetProperty("text", out var textProp)) { 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($"Deepgram returned HTTP {response.StatusCode}: {responseBody}"); } using var doc = JsonDocument.Parse(responseBody); var root = doc.RootElement; var 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) { var ch0 = channels[0]; if (ch0.TryGetProperty("alternatives", out var alts) && alts.GetArrayLength() > 0) { var alt0 = alts[0]; if (alt0.TryGetProperty("transcript", out var tProp)) transcript = tProp.GetString() ?? ""; if (alt0.TryGetProperty("confidence", out var cProp)) confidence = cProp.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 url = endpoint.EndpointUrl.Contains("?") ? $"{endpoint.EndpointUrl}&key={apiKey}" : $"{endpoint.EndpointUrl}?key={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, url) { Content = new StringContent(JsonSerializer.Serialize(geminiPayload), Encoding.UTF8, "application/json") }; var response = await client.SendAsync(httpRequest); var responseBody = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { throw new HttpRequestException($"Google Gemini STT returned HTTP {response.StatusCode}: {responseBody}"); } using var doc = JsonDocument.Parse(responseBody); var root = doc.RootElement; var text = ""; if (root.TryGetProperty("candidates", out var cands) && cands.GetArrayLength() > 0) { var cand0 = cands[0]; if (cand0.TryGetProperty("content", out var content) && content.TryGetProperty("parts", out var parts) && parts.GetArrayLength() > 0) { text = parts[0].GetProperty("text").GetString() ?? ""; } } return (text.Trim(), 0.98, lang, EstimateAudioDuration(audioBytes, contentType)); } else { // Google Cloud Speech-to-Text v1 var apiKey = endpoint.ApiKey; var url = endpoint.EndpointUrl.Contains("?") ? $"{endpoint.EndpointUrl}&key={apiKey}" : $"{endpoint.EndpointUrl}?key={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, url) { Content = new StringContent(JsonSerializer.Serialize(gcpPayload), Encoding.UTF8, "application/json") }; var response = await client.SendAsync(httpRequest); var responseBody = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { throw new HttpRequestException($"Google Cloud STT returned HTTP {response.StatusCode}: {responseBody}"); } using var doc = JsonDocument.Parse(responseBody); var root = doc.RootElement; var transcript = ""; var confidence = 0.95; if (root.TryGetProperty("results", out var results) && 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($"AssemblyAI upload failed: {uploadBody}"); } 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($"AssemblyAI transcript job failed: {transBody}"); } 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(); using var pollDoc = JsonDocument.Parse(pollBody); var status = pollDoc.RootElement.GetProperty("status").GetString(); if (status == "completed") { var text = pollDoc.RootElement.GetProperty("text").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") { var err = pollDoc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : "Unknown error"; throw new HttpRequestException($"AssemblyAI processing error: {err}"); } } 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($"Azure Speech STT returned HTTP {response.StatusCode}: {responseBody}"); } using var doc = JsonDocument.Parse(responseBody); var root = doc.RootElement; var text = ""; var confidence = 0.95; if (root.TryGetProperty("DisplayText", out var dt)) { text = dt.GetString() ?? ""; } else if (root.TryGetProperty("NBest", out var nbest) && nbest.GetArrayLength() > 0) { var best = nbest[0]; if (best.TryGetProperty("Display", out var d)) text = d.GetString() ?? ""; if (best.TryGetProperty("Confidence", out var c)) confidence = c.GetDouble(); } 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($"Local STT Sidecar returned HTTP {response.StatusCode}: {responseBody}"); } using var doc = JsonDocument.Parse(responseBody); var root = doc.RootElement; var text = ""; if (root.TryGetProperty("text", out var tProp)) 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: $"연결 실패: {ex.Message}", 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 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 ); } }