Files
mediaplayer/ios/OralTrainer/Views/TestView.swift
2026-08-17 20:13:28 +08:00

275 lines
10 KiB
Swift

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) 句)"
}
}