import AVFoundation import SwiftUI struct TestView: View { @ObservedObject var controller: PlayerController let onFullscreen: () -> Void @State private var recorder: AVAudioRecorder? @State private var recordingURL: URL? @State private var isRecording = false @State private var isAssessing = false @State private var testStatus = "请先在训练模块选择一个云端课程" @State private var scoreSummary = "尚未评测" @State private var scoreDetail = "" var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 0) { PlayerSectionView( controller: controller, showsContinuousToggle: false, continuousPlayback: .constant(true), onFullscreen: onFullscreen ) VStack(alignment: .leading, spacing: 10) { Text("朗读评测(测试)") .font(.system(size: 16, weight: .bold)) .foregroundColor(Theme.textDark) Text(testStatus) .font(.system(size: 13)) .foregroundColor(Theme.accentDeep) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 8) { sentenceButton("上一句") { controller.seekToPreviousSentence() } sentenceButton("播放原句") { controller.playCurrentSentenceAndStop() controller.showTransientMessage("正在播放当前句子,播放完自动停止") } sentenceButton("下一句") { controller.seekToNextSentence() } } Button(action: toggleRecording) { Text(isRecording ? "停止并评测" : "开始录音") .font(.system(size: 14, weight: .bold)) .foregroundColor(.white) .frame(maxWidth: .infinity) .frame(height: 44) .background(isRecording ? Theme.buttonBlue : Theme.accent) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) .disabled(isAssessing) Text(scoreSummary) .font(.system(size: 22, weight: .bold)) .foregroundColor(Theme.textDark) Text(scoreDetail) .font(.system(size: 13)) .foregroundColor(Theme.textSubtle) .lineSpacing(4) .frame(maxWidth: .infinity, alignment: .leading) } .padding(18) .background(Color.white) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Theme.lightBorder, lineWidth: 1) ) .padding(16) } } .onReceive(controller.$currentItem) { _ in refreshTestUI() } .onReceive(controller.$currentSentence) { _ in refreshTestUI() } } private func sentenceButton(_ label: String, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) .font(.system(size: 13, weight: .bold)) .foregroundColor(.white) .frame(maxWidth: .infinity) .frame(height: 40) .background(Theme.buttonBlue) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) } // MARK: - 录音 private func toggleRecording() { if isRecording { stopRecordingAndAssess() } else { startRecording() } } private func startRecording() { controller.pause() requestMicrophonePermission { granted in guard granted else { testStatus = "需要麦克风权限才能进行录音评测" return } beginRecording() } } private func requestMicrophonePermission(completion: @escaping (Bool) -> Void) { if #available(iOS 17.0, *) { AVAudioApplication.requestRecordPermission { granted in DispatchQueue.main.async { completion(granted) } } } else { AVAudioSession.sharedInstance().requestRecordPermission { granted in DispatchQueue.main.async { completion(granted) } } } } private func beginRecording() { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("attempt-\(Int(Date().timeIntervalSince1970 * 1000)).m4a") let settings: [String: Any] = [ AVFormatIDKey: Int(kAudioFormatMPEG4AAC), AVSampleRateKey: 16_000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 96_000, AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue, ] do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) try session.setActive(true) let newRecorder = try AVAudioRecorder(url: fileURL, settings: settings) guard newRecorder.record() else { testStatus = "录音启动失败:无法开始录制" return } recorder = newRecorder recordingURL = fileURL isRecording = true testStatus = "正在录音…读完当前句子后点击“停止并评测”" } catch { testStatus = "录音启动失败:\(error.localizedDescription)" } } private func stopRecordingAndAssess() { guard let currentRecorder = recorder else { return } let duration = currentRecorder.currentTime currentRecorder.stop() restorePlaybackAudioSession() recorder = nil isRecording = false guard let url = recordingURL else { return } recordingURL = nil if duration < 1.0 { testStatus = "录音太短或无法保存" try? FileManager.default.removeItem(at: url) return } submitAssessment(recordingURL: url) } private func restorePlaybackAudioSession() { let session = AVAudioSession.sharedInstance() try? session.setCategory(.playback, mode: .moviePlayback) try? session.setActive(true) } // MARK: - 评测 private func submitAssessment(recordingURL url: URL) { guard let item = controller.currentItem, let sentence = controller.currentSentence else { testStatus = "没有正在学习的句子,无法评测" return } guard item.id.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else { testStatus = "仅云端课程支持朗读评测(本地视频请先上传到服务器)" return } guard !AppConfig.assessmentAPIKey.isEmpty, !AppConfig.assessmentAPIKey.hasPrefix("replace-") else { testStatus = "未配置评分密钥:请在 Config.swift 填入服务器 .env 的 CLIENT_API_KEY" return } testStatus = "正在评测第 \(sentence.index + 1) 句,请稍候…" scoreSummary = "评测中…" scoreDetail = "" isAssessing = true let request = ImitationAssessmentRequest( mediaId: item.id, sentence: sentence, recordingURL: url, locale: sentence.language?.isEmpty == false ? sentence.language : nil ) Task { do { let result = try await ImitationAssessor.assess(request: request) applyAssessmentResult(result) } catch { testStatus = "评测失败:\(error.localizedDescription)" scoreSummary = "评测失败" } isAssessing = false try? FileManager.default.removeItem(at: url) } } private func applyAssessmentResult(_ result: ImitationAssessmentResult) { testStatus = result.passed == true ? "已通过" : "未通过" scoreSummary = String(format: "总分 %.1f", result.overallScore) var lines: [String] = [] if let value = result.contentScore { lines.append(String(format: "内容分 %.1f", value)) } if let value = result.fluencyScore { lines.append(String(format: "流畅度 %.1f", value)) } if let value = result.durationScore { lines.append(String(format: "时长分 %.1f", value)) } if let value = result.pauseScore { lines.append(String(format: "停顿分 %.1f", value)) } if let value = result.speechRateScore { lines.append(String(format: "语速分 %.1f", value)) } if let text = result.referenceText, !text.isEmpty { lines.append("参考:\(text)") } if let text = result.recognizedText, !text.isEmpty { lines.append("识别:\(text)") } if !result.missingTokens.isEmpty { lines.append("漏读:\(result.missingTokens.joined(separator: "、"))") } if !result.extraTokens.isEmpty { lines.append("多读:\(result.extraTokens.joined(separator: "、"))") } if let feedback = result.feedback, !feedback.isEmpty { lines.append(feedback) } scoreDetail = lines.joined(separator: "\n") } private func refreshTestUI() { guard !isRecording, !isAssessing else { return } guard let item = controller.currentItem, let sentence = controller.currentSentence else { testStatus = "请先在训练模块选择一个云端课程" return } testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)" } }