File size: 4,980 Bytes
64f7370
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import Foundation
import CryptoKit

/// Verified model-asset bundle. Owns path resolution, schema-version gating,
/// and SHA-256 integrity checks for every asset listed in the manifest.
///
/// Layout on disk (a directory, conventionally named `ASRModels.bundle`):
///   manifest.json          - schema version, asset table with hashes
///   *.mlmodelc / *.onnx / *.bin / *.data
public struct AssetBundle: Sendable {
    /// Manifest schema versions this SpeechKit build can read.
    public static let supportedSchemas = 2...2

    public struct AssetEntry: Codable, Sendable {
        public let file: String
        public let sha256: String
        public let sizeBytes: Int64
    }

    public struct BundleManifest: Codable, Sendable {
        public let schemaVersion: Int
        public let bundleVersion: String
        public let assets: [String: AssetEntry]   // logical name -> entry
        public let extra: [String: String]?
    }

    public let root: URL
    public let manifest: BundleManifest

    /// Opens and validates a bundle directory.
    /// - Parameters:
    ///   - url: bundle directory URL
    ///   - verifyHashes: full SHA-256 verification of every asset (~1-2 s for
    ///     400 MB). Recommended once per install, not per launch; pass
    ///     `false` after a successful first-run check.
    public init(at url: URL, verifyHashes: Bool = false) throws {
        root = url
        let manifestURL = url.appendingPathComponent("manifest.json")
        guard FileManager.default.fileExists(atPath: manifestURL.path) else {
            throw SpeechError.assetBundleNotFound(url.path)
        }
        do {
            manifest = try JSONDecoder().decode(
                BundleManifest.self, from: Data(contentsOf: manifestURL))
        } catch {
            throw SpeechError.assetMissing("manifest.json (unreadable: \(error.localizedDescription))")
        }
        guard Self.supportedSchemas.contains(manifest.schemaVersion) else {
            throw SpeechError.assetSchemaUnsupported(
                found: manifest.schemaVersion, supported: Self.supportedSchemas)
        }
        for (name, entry) in manifest.assets {
            let assetURL = root.appendingPathComponent(entry.file)
            guard FileManager.default.fileExists(atPath: assetURL.path) else {
                throw SpeechError.assetMissing(name)
            }
            if verifyHashes {
                let actual = try Self.sha256(of: assetURL)
                guard actual == entry.sha256 else {
                    throw SpeechError.assetCorrupted(
                        name: name, expectedSHA256: entry.sha256, actualSHA256: actual)
                }
            }
        }
    }

    /// Resolves a logical asset name (e.g. "tower", "lm_prefill") to a URL.
    public func url(_ logicalName: String) throws -> URL {
        guard let entry = manifest.assets[logicalName] else {
            throw SpeechError.assetMissing(logicalName)
        }
        return root.appendingPathComponent(entry.file)
    }

    /// Convenience: locates `<name>.bundle` inside a host bundle's resources.
    public static func named(_ name: String, in hostBundle: Bundle = .main,
                             verifyHashes: Bool = false) throws -> AssetBundle {
        guard let url = hostBundle.url(forResource: name, withExtension: "bundle") else {
            throw SpeechError.assetBundleNotFound("\(name).bundle in \(hostBundle.bundlePath)")
        }
        return try AssetBundle(at: url, verifyHashes: verifyHashes)
    }

    /// Hash of a file, or of a directory (sorted relative paths + contents,
    /// matching Scripts/make_asset_bundle.py).
    static func sha256(of url: URL) throws -> String {
        var isDir: ObjCBool = false
        FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir)
        var hasher = SHA256()
        if isDir.boolValue {
            let files = (FileManager.default.subpaths(atPath: url.path) ?? [])
                .sorted()
                .map { url.appendingPathComponent($0) }
                .filter { (try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true }
            for f in files {
                let rel = f.path.replacingOccurrences(of: url.path + "/", with: "")
                hasher.update(data: Data(rel.utf8))
                try hashFileContents(f, into: &hasher)
            }
        } else {
            try hashFileContents(url, into: &hasher)
        }
        return hasher.finalize().map { String(format: "%02x", $0) }.joined()
    }

    private static func hashFileContents(_ url: URL, into hasher: inout SHA256) throws {
        let handle = try FileHandle(forReadingFrom: url)
        defer { try? handle.close() }
        while autoreleasepool(invoking: {
            let chunk = handle.readData(ofLength: 8 << 20)
            if chunk.isEmpty { return false }
            hasher.update(data: chunk)
            return true
        }) {}
    }
}