Files
mediaplayer/ios/OralTrainer/Views/TestView.swift
2026-08-26 18:56:04 +08:00

501 lines
20 KiB
Swift

import AVFoundation
import SwiftUI
struct TestView: View {
@ObservedObject var controller: PlayerController
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
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: !dubRawSegments.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 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
}
if let oldURL = dubSegments[sentence.index] {
try? FileManager.default.removeItem(at: oldURL)
}
if let oldRawURL = dubRawSegments[sentence.index] {
try? FileManager.default.removeItem(at: oldRawURL)
}
dubSegments[sentence.index] = url
dubRawSegments[sentence.index] = rawURL
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
}
}
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 !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) {}
}