import AVFoundation import SwiftUI struct TestView: View { @ObservedObject var controller: PlayerController @ObservedObject var historyManager: TrainingHistoryManager let onFullscreen: () -> Void @State private var recordingSession: AudioRecorderSession? @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 dubRawSegments: [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 @State private var loadedCourseHash: String? @State private var remoteHistoryGeneration = 0 var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 0) { PlayerSectionView( controller: controller, showsContinuousToggle: false, continuousPlayback: .constant(false), 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("上一句") { stopDubPlayback(message: nil) controller.seekToPreviousSentence() } sentenceButton("播放原句") { stopDubPlayback(message: nil) controller.playCurrentSentenceAndStop() controller.showTransientMessage("正在播放当前句子,播放完自动停止") } sentenceButton("下一句") { stopDubPlayback(message: nil) 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( dubbingPlayback ? "停止播放" : "播放本句录音", enabled: dubbingPlayback || (controller.currentSentence.flatMap { dubSegments[$0.index] } != nil) ) { toggleDubPlayback() } dubButton("生成配音", enabled: !dubRawSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) { stopDubPlayback(message: nil) generateDubShareAudio() } dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) { stopDubPlayback(message: nil) shareMergedDub() } } Text(dubbingStatus) .font(.system(size: 13)) .foregroundColor(isSharingDub ? Theme.danger : 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) { item in synchronizeHistory(for: item?.id) refreshTestUI() } .onReceive(controller.$currentSentence) { _ in refreshTestUI() } .onDisappear { stopDubPlayback(message: nil) } .onAppear { synchronizeHistory(for: controller.currentItem?.id) refreshTestUI() } .onChange(of: historyManager.historyVersion) { _ in synchronizeHistory(for: controller.currentItem?.id, forceLocalReload: true) refreshTestUI() } .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() { stopDubPlayback(message: nil) 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 newSession = AudioRecorderSession(outputURL: fileURL) do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) try session.setActive(true) guard session.isInputAvailable else { testStatus = microphoneInputUnavailableMessage() return } do { try newSession.start() } catch { try session.setActive(false, options: .notifyOthersOnDeactivation) try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) try session.setActive(true) try newSession.start() } recordingSession = newSession recordingURL = fileURL isRecording = true testStatus = "正在录音…读完当前句子后点击“停止并评测”" } catch AudioRecorderSessionError.inputUnavailable { newSession.cancel() testStatus = microphoneInputUnavailableMessage() } catch AudioRecorderSessionError.startFailed { newSession.cancel() testStatus = recordingStartFailedMessage() } catch { newSession.cancel() testStatus = "录音启动失败:\(error.localizedDescription)" } } private func microphoneInputUnavailableMessage() -> String { #if targetEnvironment(simulator) return "录音启动失败:未检测到麦克风输入。请在模拟器菜单 I/O → Audio Input 中选择输入设备,并允许“模拟器”使用 Mac 麦克风后重试" #else return "录音启动失败:未检测到麦克风输入。请检查 设置 → 隐私与安全性 → 麦克风 是否允许本 App 使用麦克风" #endif } private func recordingStartFailedMessage() -> String { #if targetEnvironment(simulator) return "录音启动失败:无法开始录制。模拟器请确认 I/O → Audio Input 已选择输入设备,再重试" #else return "录音启动失败:无法开始录制。请确认麦克风未被其他应用占用后重试" #endif } private func stopRecordingAndAssess() { guard let currentSession = recordingSession else { return } let duration: TimeInterval do { try currentSession.stop() duration = currentSession.recordDuration } catch { testStatus = "录音结束失败:\(error.localizedDescription)" currentSession.cancel() recordingSession = nil isRecording = false restorePlaybackAudioSession() return } restorePlaybackAudioSession() recordingSession = nil isRecording = false guard let url = recordingURL else { return } recordingURL = nil let rawURL = currentSession.rawRecordingURL if duration < 1.0 { testStatus = "录音太短或无法保存" try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: rawURL) return } guard let sentence = controller.currentSentence else { testStatus = "没有可归属的句子,本次录音已丢弃" try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: rawURL) return } var audioURL = url var rawPersistedURL = rawURL if let item = controller.currentItem { do { let persisted = try historyManager.store.saveSegment( videoHash: item.id, sentenceIndex: sentence.index, audioURL: url, rawURL: rawURL ) audioURL = persisted.audioURL rawPersistedURL = persisted.rawURL } catch { testStatus = "录音已评测,但本地历史保存失败:\(error.localizedDescription)" } } dubSegments[sentence.index] = audioURL dubRawSegments[sentence.index] = rawPersistedURL mergedDubURL = nil submitAssessment(recordingURL: audioURL) } 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) latestScores[sentence.index] = result historyManager.store.saveScores(videoHash: item.id, scores: latestScores) applyAssessmentResult(result) } catch { testStatus = "评测失败:\(error.localizedDescription)" scoreSummary = "评测失败" } isAssessing = false } } 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 } if let score = latestScores[sentence.index] { applyAssessmentResult(score) } else { let currentRecording = dubSegments[sentence.index] let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖" testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)" scoreSummary = "尚未评测" scoreDetail = "" } if mergedDubURL == nil && !dubbingPlayback && !isGeneratingDub { dubbingStatus = dubSegments.isEmpty ? "配音:请先录音" : "配音:已保存 \(dubSegments.count) 句,可生成配音" } } // MARK: - 历史记录 private func synchronizeHistory(for videoHash: String?, forceLocalReload: Bool = false) { guard let videoHash else { loadedCourseHash = nil dubSegments = [:] dubRawSegments = [:] latestScores = [:] return } let normalizedHash = historyManager.store.normalizedVideoHash(videoHash) guard loadedCourseHash != normalizedHash || forceLocalReload else { return } loadedCourseHash = normalizedHash remoteHistoryGeneration += 1 let generation = remoteHistoryGeneration let snapshot = historyManager.store.load(videoHash: videoHash) dubSegments = snapshot.audioSegments dubRawSegments = snapshot.rawSegments for (index, audioURL) in snapshot.audioSegments where dubRawSegments[index] == nil { dubRawSegments[index] = audioURL } latestScores = snapshot.scores mergedDubURL = nil if snapshot.audioSegments.isEmpty && snapshot.scores.isEmpty { dubbingStatus = "正在同步历史配音…" } else { dubbingStatus = "已加载历史配音 \(snapshot.audioSegments.count) 句、评分 \(snapshot.scores.count) 句" } Task { await restoreRemoteHistory(videoHash: videoHash, generation: generation) } } private func restoreRemoteHistory(videoHash: String, generation: Int) async { let token = AppConfig.authToken guard !token.isEmpty else { return } let normalizedHash = historyManager.store.normalizedVideoHash(videoHash) do { let rows = try await UserHistoryAPI.results(token: token) guard remoteHistoryGeneration == generation, loadedCourseHash == normalizedHash else { return } var latest = [Int: UserResultSummary]() rows.filter { historyManager.store.normalizedVideoHash($0.videoHash) == normalizedHash } .sorted { $0.createdAt < $1.createdAt } .forEach { latest[$0.sentenceIndex] = $0 } var addedScores = 0 for (index, row) in latest where latestScores[index] == nil { if let score = decodedScore(payload: [ "overall_score": row.overallScore, "passed": row.passed, "reference_text": row.sentenceText, "feedback": "来自历史测试记录", ]) { latestScores[index] = score addedScores += 1 } } if addedScores > 0 { historyManager.store.saveScores(videoHash: videoHash, scores: latestScores) dubbingStatus = "已同步历史评分 \(latestScores.count) 句" refreshTestUI() } } catch { if remoteHistoryGeneration == generation, latestScores.isEmpty { dubbingStatus = "历史评分同步失败:\(error.localizedDescription)" } } guard dubSegments.isEmpty else { return } do { let shares = try await UserHistoryAPI.dubShares(token: token) guard remoteHistoryGeneration == generation, loadedCourseHash == normalizedHash else { return } guard let share = shares .filter({ historyManager.store.normalizedVideoHash($0.videoHash) == normalizedHash }) .max(by: { $0.createdAt < $1.createdAt }) else { if dubSegments.isEmpty && latestScores.isEmpty { dubbingStatus = "暂无可用的历史配音音频" } return } let detail = try await UserHistoryAPI.dubShareDetail(token: token, shareId: share.shareId) var restored = [Int: URL]() for segment in detail.segments { guard remoteHistoryGeneration == generation else { return } let filename = segment.audioURL.split(separator: "/").last.map(String.init) ?? "" guard !filename.isEmpty else { continue } let destination = historyManager.store.audioDestination( videoHash: videoHash, sentenceIndex: segment.index ) try await UserHistoryAPI.downloadAudio( token: token, audioPath: "api/v1/dub-shares/\(share.shareId)/audio/\(filename)", destination: destination ) restored[segment.index] = destination var payload: [String: Any] = [ "overall_score": segment.overallScore, "feedback": "来自历史配音分享", ] if !segment.recognizedText.isEmpty { payload["recognized_text"] = segment.recognizedText } segment.scoreDetails.forEach { payload[$0.key] = $0.value } if let score = decodedScore(payload: payload), latestScores[segment.index] == nil { latestScores[segment.index] = score } } guard remoteHistoryGeneration == generation, loadedCourseHash == normalizedHash else { return } for (index, audioURL) in restored where dubSegments[index] == nil { dubSegments[index] = audioURL dubRawSegments[index] = audioURL } historyManager.store.saveScores(videoHash: videoHash, scores: latestScores) if !restored.isEmpty { dubbingStatus = "已从云端恢复配音 \(restored.count) 句" } else if dubSegments.isEmpty && latestScores.isEmpty { dubbingStatus = "暂无可用的历史配音音频" } refreshTestUI() } catch { guard remoteHistoryGeneration == generation, dubSegments.isEmpty else { return } dubbingStatus = "历史配音同步失败:\(error.localizedDescription)" } } private func decodedScore(payload: [String: Any]) -> ImitationAssessmentResult? { guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return nil } return try? JSONDecoder().decode(ImitationAssessmentResult.self, from: data) } // MARK: - 配音 private func toggleDubPlayback() { if dubbingPlayback { stopDubPlayback(message: "已停止录音播放") return } guard let sentence = controller.currentSentence else { dubbingStatus = "请先选择要播放的句子" return } guard let audioURL = dubSegments[sentence.index] else { dubbingStatus = "当前句还没有录音,请先录音后再播放" return } // Always play the current sentence recording only (never the merged full track). stopDubPlayback(message: nil) controller.seek(to: sentence.startMs) controller.pause() controller.setVolume(0) let player = dubPlayer ?? AVPlayer() dubPlayer = player let item = AVPlayerItem(url: audioURL) player.replaceCurrentItem(with: item) dubbingPlayback = true dubbingStatus = "正在播放当前句录音" player.play() if let dubPlaybackObserver { NotificationCenter.default.removeObserver(dubPlaybackObserver) self.dubPlaybackObserver = nil } dubPlaybackObserver = NotificationCenter.default.addObserver( forName: AVPlayerItem.didPlayToEndTimeNotification, object: item, 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 !dubRawSegments.isEmpty else { return } let segments = dubRawSegments.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) {} }