460 lines
18 KiB
Swift
460 lines
18 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 = ""
|
|
@State private var dubSegments: [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: !dubSegments.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 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
|
|
}
|
|
guard let sentence = controller.currentSentence else {
|
|
testStatus = "没有可归属的句子,本次录音已丢弃"
|
|
try? FileManager.default.removeItem(at: url)
|
|
return
|
|
}
|
|
if let oldURL = dubSegments[sentence.index] {
|
|
try? FileManager.default.removeItem(at: oldURL)
|
|
}
|
|
dubSegments[sentence.index] = url
|
|
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
|
|
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
|
|
}
|
|
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 !dubSegments.isEmpty else { return }
|
|
let segments = dubSegments.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) {}
|
|
}
|