using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; using DramaLing.Api.Models.DTOs; using DramaLing.Api.Contracts.Services.Speech; using DramaLing.Api.Services; namespace DramaLing.Api.Controllers; [Route("api/speech")] [AllowAnonymous] // 暫時開放測試,之後可以加上認證 [ApiExplorerSettings(IgnoreApi = true)] // 暫時從 Swagger 排除,避免 IFormFile 相關問題 public class SpeechController : BaseController { private readonly IPronunciationAssessmentService _assessmentService; public SpeechController( IPronunciationAssessmentService assessmentService, IAuthService authService, ILogger logger) : base(logger, authService) { _assessmentService = assessmentService; } /// /// 發音評估 - 上傳音頻檔案並獲得 AI 發音評估結果 /// /// 音頻檔案 (WAV/WebM/MP3 格式,最大 10MB) /// 參考文本 - 用戶應該說出的目標句子 /// 詞卡 ID /// 語言代碼 (預設: en-US) /// 包含準確度、流暢度等多維度評分的評估結果 [HttpPost("pronunciation-assessment")] [Consumes("multipart/form-data")] [ProducesResponseType(typeof(PronunciationResult), 200)] [ProducesResponseType(400)] [ProducesResponseType(500)] [DisableRequestSizeLimit] // 允許大檔案上傳 public async Task EvaluatePronunciation( [FromForm] IFormFile audio, [FromForm] string referenceText, [FromForm] string flashcardId, [FromForm] string language = "en-US") { _logger.LogInformation("✅ Controller Action 開始執行 - FlashcardId: {FlashcardId}, ReferenceText: {ReferenceText}", flashcardId ?? "NULL", referenceText?.Substring(0, Math.Min(50, referenceText?.Length ?? 0)) ?? "NULL"); // 檢查 ModelState 是否有效 if (!ModelState.IsValid) { _logger.LogWarning("ModelState 驗證失敗:"); foreach (var modelError in ModelState.Where(m => m.Value.Errors.Count > 0)) { foreach (var error in modelError.Value.Errors) { _logger.LogWarning(" {Key}: {Error}", modelError.Key, error.ErrorMessage); } } return ErrorResponse("MODEL_VALIDATION_ERROR", "請求參數驗證失敗", ModelState, 400); } try { // 1. 驗證請求 if (audio == null || audio.Length == 0) { return ErrorResponse("AUDIO_REQUIRED", "音頻檔案不能為空", null, 400); } if (audio.Length > 10 * 1024 * 1024) // 10MB 限制 { return ErrorResponse("AUDIO_TOO_LARGE", "音頻檔案過大,請限制在 10MB 以內", new { maxSize = "10MB", actualSize = $"{audio.Length / 1024 / 1024}MB" }, 400); } if (string.IsNullOrWhiteSpace(referenceText)) { return ErrorResponse("REFERENCE_TEXT_REQUIRED", "參考文本不能為空", null, 400); } if (string.IsNullOrWhiteSpace(flashcardId)) { return ErrorResponse("FLASHCARD_ID_REQUIRED", "詞卡 ID 不能為空", null, 400); } // 2. 驗證音頻格式 - 支援更多格式 var contentType = audio.ContentType?.ToLowerInvariant(); var allowedTypes = new[] { "audio/wav", "audio/webm", "audio/mp3", "audio/mpeg", "audio/ogg", "audio/mp4", "audio/x-wav", "audio/wave" }; _logger.LogInformation("接收到音頻檔案: ContentType={ContentType}, Size={Size}bytes, FileName={FileName}", contentType, audio.Length, audio.FileName); // 如果沒有 Content-Type 或者不在允許列表中,記錄但不立即拒絕 if (string.IsNullOrEmpty(contentType) || !allowedTypes.Contains(contentType)) { _logger.LogWarning("音頻格式可能不支援: ContentType={ContentType}, 將嘗試處理", contentType); // 註解掉嚴格驗證,讓 Azure Speech Services 自己處理 // return ErrorResponse("INVALID_AUDIO_FORMAT", "不支援的音頻格式", // new { supportedFormats = allowedTypes }, 400); } // 3. 驗證音頻時長 (簡單檢查檔案大小作為時長估算) if (audio.Length < 100) // 降低到 100 bytes,允許短小的測試檔案 { return ErrorResponse("AUDIO_TOO_SHORT", "錄音時間太短或檔案損壞", new { minSize = "100 bytes", actualSize = $"{audio.Length} bytes", fileName = audio.FileName, contentType = contentType }, 400); } _logger.LogInformation("開始處理發音評估: FlashcardId={FlashcardId}, Size={Size}MB", flashcardId, audio.Length / 1024.0 / 1024.0); // 4. 處理音頻流並呼叫 Azure Speech Services using var audioStream = audio.OpenReadStream(); var result = await _assessmentService.EvaluatePronunciationAsync( audioStream, referenceText, flashcardId, language); _logger.LogInformation("發音評估完成: Score={Score}, ProcessingTime={Time}ms", result.Scores.Overall, result.ProcessingTime); return SuccessResponse(result, "發音評估完成"); } catch (InvalidOperationException ex) { _logger.LogWarning(ex, "發音評估業務邏輯錯誤: FlashcardId={FlashcardId}", flashcardId); return ErrorResponse("SPEECH_PROCESSING_ERROR", ex.Message, null, 400); } catch (Exception ex) { _logger.LogError(ex, "發音評估系統錯誤: FlashcardId={FlashcardId}", flashcardId); return ErrorResponse("INTERNAL_ERROR", "發音評估失敗,請稍後再試", null, 500); } } /// /// 測試用的簡化發音評估 endpoint - 用於除錯 model binding 問題 /// [HttpPost("test-pronunciation")] [Consumes("multipart/form-data")] [ProducesResponseType(200)] [ProducesResponseType(400)] [DisableRequestSizeLimit] public async Task TestPronunciation() { _logger.LogInformation("🔧 測試 endpoint 開始執行"); try { // 直接使用 Request.Form 避開 model binding var form = await Request.ReadFormAsync(); _logger.LogInformation("📝 Form 讀取成功,包含 {Count} 個欄位", form.Count); // 記錄所有 form fields foreach (var field in form) { _logger.LogInformation(" Field: {Key} = {Value}", field.Key, field.Value.ToString()); } // 記錄所有 files if (form.Files.Count > 0) { _logger.LogInformation("📁 找到 {Count} 個檔案", form.Files.Count); foreach (var file in form.Files) { _logger.LogInformation(" 檔案: {Name}, 大小: {Size}bytes, 類型: {Type}", file.Name, file.Length, file.ContentType); } } else { _logger.LogWarning("⚠️ 沒有找到檔案"); } return SuccessResponse(new { FormFieldCount = form.Count, FileCount = form.Files.Count, Fields = form.ToDictionary(f => f.Key, f => f.Value.ToString()), Files = form.Files.Select(f => new { f.Name, f.Length, f.ContentType }) }, "測試成功"); } catch (Exception ex) { _logger.LogError(ex, "❌ 測試 endpoint 錯誤"); return ErrorResponse("TEST_ERROR", ex.Message, null, 500); } } /// /// 檢查語音服務狀態 /// /// Azure Speech Services 的可用性狀態 [HttpGet("service-status")] [ProducesResponseType(typeof(object), 200)] [ProducesResponseType(500)] public async Task GetServiceStatus() { try { var isAvailable = await _assessmentService.IsServiceAvailableAsync(); var status = new { IsAvailable = isAvailable, ServiceName = "Azure Speech Services", CheckTime = DateTime.UtcNow, Message = isAvailable ? "服務正常運行" : "服務不可用" }; return SuccessResponse(status); } catch (Exception ex) { _logger.LogError(ex, "檢查語音服務狀態時發生錯誤"); return ErrorResponse("SERVICE_CHECK_ERROR", "無法檢查服務狀態", null, 500); } } }