added ios module
This commit is contained in:
274
ios/OralTrainer/Views/TestView.swift
Normal file
274
ios/OralTrainer/Views/TestView.swift
Normal file
@@ -0,0 +1,274 @@
|
||||
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) 句)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user