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 = "" @State private var dubSegments: [Int: URL] = [:] @State private var latestScores: [Int: ImitationAssessmentResult] = [:] @State private var mergedDubURL: URL? @State private var dubPlayer: AVPlayer? @State private var dubbingPlayback = false @State private var dubbingStatus = "配音:尚未生成" @State private var isGeneratingDub = false @State private var isSharingDub = false @State private var shareURL: URL? @State private var showShareSheet = false @State private var dubPlaybackObserver: NSObjectProtocol? @State private var testSubtitleVisible = true var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 0) { PlayerSectionView( controller: controller, showsContinuousToggle: false, continuousPlayback: .constant(true), showsSubtitle: $testSubtitleVisible, onFullscreen: onFullscreen ) if testSubtitleVisible { Text(controller.currentSentence?.text ?? "当前句子") .font(.system(size: 15)) .foregroundColor(Theme.textDark) .frame(maxWidth: .infinity, alignment: .leading) } 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) { Toggle("显示字幕", isOn: $testSubtitleVisible) .font(.system(size: 13, weight: .bold)) .foregroundColor(Theme.textDark) .tint(Theme.accent) } 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) HStack(spacing: 8) { dubButton("播放配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) { toggleDubPlayback() } dubButton("生成配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) { generateDubShareAudio() } dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) { shareMergedDub() } } Text(dubbingStatus) .font(.system(size: 13)) .foregroundColor(Theme.accentDeep) .frame(maxWidth: .infinity, alignment: .leading) 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() } .onDisappear { stopDubPlayback(message: nil) } .sheet(isPresented: $showShareSheet) { if let url = shareURL { ActivityShareSheet(items: [url]) } } } 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) } private func dubButton(_ label: String, enabled: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) .font(.system(size: 13, weight: .bold)) .foregroundColor(.white) .frame(maxWidth: .infinity) .frame(height: 38) .background(enabled ? Theme.buttonBlue : Theme.border) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) .disabled(!enabled) } // 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 } guard let sentence = controller.currentSentence else { testStatus = "没有可归属的句子,本次录音已丢弃" try? FileManager.default.removeItem(at: url) return } if let oldURL = dubSegments[sentence.index] { try? FileManager.default.removeItem(at: oldURL) } dubSegments[sentence.index] = url mergedDubURL = nil 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 } let currentRecording = dubSegments[sentence.index] let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖" testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)" if mergedDubURL == nil { dubbingStatus = dubSegments.isEmpty ? "配音:请先录音" : "配音:已保存 \(dubSegments.count) 句,可生成配音" } } // MARK: - 配音 private func toggleDubPlayback() { if dubbingPlayback { stopDubPlayback(message: "已停止配音播放") return } let sentence = controller.currentSentence let audioURL = mergedDubURL ?? sentence.flatMap { dubSegments[$0.index] } guard let audioURL else { return } if mergedDubURL == nil, let sentence = sentence { controller.seek(to: sentence.startMs) } controller.pause() controller.setVolume(0) let player = dubPlayer ?? AVPlayer() dubPlayer = player player.replaceCurrentItem(with: AVPlayerItem(url: audioURL)) dubbingPlayback = true dubbingStatus = mergedDubURL == nil ? "正在播放当前句配音,视频原声静音" : "正在播放整段配音,视频原声静音" player.play() if dubPlaybackObserver == nil { dubPlaybackObserver = NotificationCenter.default.addObserver( forName: AVPlayerItem.didPlayToEndTimeNotification, object: player.currentItem, queue: .main ) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { stopDubPlayback(message: "配音播放完成") } } } } private func stopDubPlayback(message: String?) { dubPlayer?.pause() dubPlayer?.seek(to: .zero) controller.pause() controller.setVolume(1) dubbingPlayback = false if let dubPlaybackObserver { NotificationCenter.default.removeObserver(dubPlaybackObserver) self.dubPlaybackObserver = nil } if let message { dubbingStatus = message } } private func generateDubShareAudio() { guard !dubSegments.isEmpty else { return } let segments = dubSegments.sorted { $0.key < $1.key } let outputURL = FileManager.default.temporaryDirectory .appendingPathComponent("dubbing-\(Int(Date().timeIntervalSince1970 * 1000)).m4a") isGeneratingDub = true dubbingStatus = "正在合成 \(segments.count) 句配音…" Task { do { try await DubbingCompositor.merge(urls: segments.map(\.value), outputURL: outputURL) if let oldURL = mergedDubURL { try? FileManager.default.removeItem(at: oldURL) } mergedDubURL = outputURL dubbingStatus = "已合成 \(segments.count) 句配音,可点击“分享配音”" } catch { dubbingStatus = "合成失败:\(error.localizedDescription)" } isGeneratingDub = false } } private func shareMergedDub() { guard let item = controller.currentItem, mergedDubURL != nil else { return } guard item.id.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else { dubbingStatus = "分享失败:网页分享仅支持云端课程视频" return } isSharingDub = true dubbingStatus = "正在生成分享链接…" let request = DubShareUploadRequest( videoHash: item.id, title: item.title, segments: dubSegments.map { ($0.key, $0.value) }, scores: latestScores ) Task { do { let link = try await ImitationAssessor.uploadDubShare(request: request) shareURL = URL(string: link) showShareSheet = true dubbingStatus = "已生成分享链接:\(link)" } catch { dubbingStatus = "分享失败:\(error.localizedDescription)" } isSharingDub = false } } } struct ActivityShareSheet: UIViewControllerRepresentable { let items: [Any] func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: items, applicationActivities: nil) } func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} }