Files
mediaplayer/ios/OralTrainer/Views/TestView.swift
2026-09-07 11:42:53 +08:00

687 lines
28 KiB
Swift

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(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("上一句") {
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: 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)
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) {}
}