fixed some bugs

This commit is contained in:
2026-09-07 11:42:53 +08:00
parent aca36c78c1
commit 9230db3b69
12 changed files with 883 additions and 14 deletions

View File

@@ -0,0 +1,167 @@
import Foundation
struct PersistedRecording {
let audioURL: URL
let rawURL: URL
}
struct TrainingHistorySnapshot {
var audioSegments: [Int: URL] = [:]
var rawSegments: [Int: URL] = [:]
var scores: [Int: ImitationAssessmentResult] = [:]
}
@MainActor
final class TrainingHistoryManager: ObservableObject {
@Published private(set) var historyVersion = 0
let store = TrainingHistoryStore()
func notifyHistoryChanged() {
historyVersion += 1
}
}
final class TrainingHistoryStore {
private let root: URL
init() {
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
root = documents.appendingPathComponent("TrainingHistory", isDirectory: true)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
func normalizedVideoHash(_ videoHash: String) -> String {
let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
let filtered = videoHash.lowercased().unicodeScalars.filter { allowed.contains($0) }
return String(String.UnicodeScalarView(filtered))
}
private var userDirectory: URL {
let identifier = normalizedVideoHash(AppConfig.currentUserId)
return root.appendingPathComponent(identifier.isEmpty ? "anonymous" : identifier, isDirectory: true)
}
func load(videoHash: String) -> TrainingHistorySnapshot {
let directory = courseDirectory(videoHash)
var snapshot = TrainingHistorySnapshot()
for index in 0..<500 {
let audio = directory.appendingPathComponent(String(format: "segment-%04d.m4a", index))
let raw = directory.appendingPathComponent(String(format: "raw-%04d.caf", index))
if FileManager.default.fileExists(atPath: audio.path) {
snapshot.audioSegments[index] = audio
}
if FileManager.default.fileExists(atPath: raw.path) {
snapshot.rawSegments[index] = raw
}
}
let scoreFile = directory.appendingPathComponent("scores.json")
if let data = try? Data(contentsOf: scoreFile),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
for (key, value) in object {
guard let index = Int(key), index >= 0 else { continue }
if let payload = value as? [String: Any],
let payloadData = try? JSONSerialization.data(withJSONObject: payload),
let score = try? JSONDecoder().decode(ImitationAssessmentResult.self, from: payloadData) {
snapshot.scores[index] = score
}
}
}
return snapshot
}
func saveSegment(
videoHash: String,
sentenceIndex: Int,
audioURL: URL,
rawURL: URL
) throws -> PersistedRecording {
let directory = courseDirectory(videoHash)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let savedAudio = directory.appendingPathComponent(
String(format: "segment-%04d.m4a", sentenceIndex)
)
let savedRaw = directory.appendingPathComponent(
String(format: "raw-%04d.caf", sentenceIndex)
)
try? FileManager.default.removeItem(at: savedAudio)
try? FileManager.default.removeItem(at: savedRaw)
try FileManager.default.moveItem(at: audioURL, to: savedAudio)
try FileManager.default.moveItem(at: rawURL, to: savedRaw)
return PersistedRecording(audioURL: savedAudio, rawURL: savedRaw)
}
func audioDestination(videoHash: String, sentenceIndex: Int) -> URL {
courseDirectory(videoHash).appendingPathComponent(
String(format: "segment-%04d.m4a", sentenceIndex)
)
}
func saveScores(
videoHash: String,
scores: [Int: ImitationAssessmentResult]
) {
let directory = courseDirectory(videoHash)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
var object: [String: Any] = [:]
for (index, score) in scores {
object[String(index)] = Self.payload(score)
}
let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
try data.write(
to: directory.appendingPathComponent("scores.json"),
options: [.atomic]
)
} catch {
// History persistence is best-effort; the server remains the source of record.
}
}
func removeScore(videoHash: String, sentenceIndex: Int) {
var snapshot = load(videoHash: videoHash)
snapshot.scores[sentenceIndex] = nil
saveScores(videoHash: videoHash, scores: snapshot.scores)
}
func removeAllAudio(videoHash: String) {
let snapshot = load(videoHash: videoHash)
for url in snapshot.audioSegments.values {
try? FileManager.default.removeItem(at: url)
}
for url in snapshot.rawSegments.values {
try? FileManager.default.removeItem(at: url)
}
}
private func courseDirectory(_ videoHash: String) -> URL {
userDirectory.appendingPathComponent(normalizedVideoHash(videoHash), isDirectory: true)
}
private static func payload(_ score: ImitationAssessmentResult) -> [String: Any] {
var value: [String: Any] = ["overall_score": score.overallScore]
if let item = score.passed { value["passed"] = item }
if let item = score.passScore { value["pass_score"] = item }
if let item = score.contentScore { value["content_score"] = item }
if let item = score.completenessScore { value["completeness_score"] = item }
if let item = score.fluencyScore { value["fluency_score"] = item }
if let item = score.pronunciationScore { value["pronunciation_score"] = item }
if let item = score.prosodyScore { value["prosody_score"] = item }
if let item = score.durationScore { value["duration_score"] = item }
if let item = score.pauseScore { value["pause_score"] = item }
if let item = score.speechRateScore { value["speech_rate_score"] = item }
if let item = score.referenceText { value["reference_text"] = item }
if let item = score.recognizedText { value["recognized_text"] = item }
if let item = score.durationRatio { value["duration_ratio"] = item }
if !score.missingTokens.isEmpty { value["missing_tokens"] = score.missingTokens }
if !score.extraTokens.isEmpty { value["extra_tokens"] = score.extraTokens }
if !score.substitutions.isEmpty {
value["substitutions"] = score.substitutions.map {
["expected": $0.expected, "actual": $0.actual]
}
}
if let item = score.feedback { value["feedback"] = item }
if !score.details.isEmpty { value["details"] = score.details }
return value
}
}