168 lines
6.9 KiB
Swift
168 lines
6.9 KiB
Swift
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
|
|
}
|
|
}
|