275 lines
10 KiB
Swift
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) 句)"
|
|
}
|
|
}
|