|
| 1 | +import Foundation |
| 2 | + |
| 3 | +/// Server deployment configuration — a port of `src/hark/config.py`. |
| 4 | +/// |
| 5 | +/// Defaults describe the single-machine setup: bind to loopback, expose |
| 6 | +/// nothing. `~/.config/hark/config.toml` (outside the repo) overrides, exactly |
| 7 | +/// as it does for the Python server. A missing file is the ordinary case; a |
| 8 | +/// malformed one raises rather than silently falling back to defaults (that |
| 9 | +/// could bind the service somewhere the user did not ask for). |
| 10 | +public struct HarkConfig { |
| 11 | + public var bindHost: String // server.bind, default 127.0.0.1 |
| 12 | + public var harkPort: Int // server.port, default 8911 |
| 13 | + public var whisperHost: String // always 127.0.0.1 (not configurable) |
| 14 | + public var whisperPort: Int // whisper.port, default 8910 |
| 15 | + public var modelPath: String // whisper.model |
| 16 | + public var vocabPrompt: String // whisper.prompt |
| 17 | + public var silenceRMSThreshold: Double // audio.silence_rms_threshold, default 150.0 |
| 18 | + public var transcribeTimeout: Double // 60 |
| 19 | + public var connectTimeout: Double // 5 |
| 20 | + |
| 21 | + public init(bindHost: String = "127.0.0.1", |
| 22 | + harkPort: Int = 8911, |
| 23 | + whisperPort: Int = 8910, |
| 24 | + modelPath: String = "~/.local/share/whisper-cpp/ggml-large-v3-turbo.bin", |
| 25 | + vocabPrompt: String = "", |
| 26 | + silenceRMSThreshold: Double = 150.0, |
| 27 | + transcribeTimeout: Double = 60.0, |
| 28 | + connectTimeout: Double = 5.0) { |
| 29 | + self.bindHost = bindHost |
| 30 | + self.harkPort = harkPort |
| 31 | + self.whisperHost = "127.0.0.1" |
| 32 | + self.whisperPort = whisperPort |
| 33 | + self.modelPath = modelPath |
| 34 | + self.vocabPrompt = vocabPrompt |
| 35 | + self.silenceRMSThreshold = silenceRMSThreshold |
| 36 | + self.transcribeTimeout = transcribeTimeout |
| 37 | + self.connectTimeout = connectTimeout |
| 38 | + } |
| 39 | + |
| 40 | + public static var configFile: URL { |
| 41 | + if let env = ProcessInfo.processInfo.environment["HARK_CONFIG"] { |
| 42 | + return URL(fileURLWithPath: env) |
| 43 | + } |
| 44 | + return FileManager.default.homeDirectoryForCurrentUser |
| 45 | + .appendingPathComponent(".config/hark/config.toml") |
| 46 | + } |
| 47 | + |
| 48 | + public static func load() -> HarkConfig { |
| 49 | + var cfg = HarkConfig() |
| 50 | + guard let text = try? String(contentsOf: configFile, encoding: .utf8) else { |
| 51 | + return cfg |
| 52 | + } |
| 53 | + do { |
| 54 | + let parsed = try MiniTOML.parse(text) |
| 55 | + if let s = parsed.section("server") { |
| 56 | + cfg.bindHost = s.string("bind") ?? cfg.bindHost |
| 57 | + cfg.harkPort = s.int("port") ?? cfg.harkPort |
| 58 | + } |
| 59 | + if let w = parsed.section("whisper") { |
| 60 | + cfg.whisperPort = w.int("port") ?? cfg.whisperPort |
| 61 | + cfg.modelPath = w.string("model") ?? cfg.modelPath |
| 62 | + cfg.vocabPrompt = w.string("prompt") ?? cfg.vocabPrompt |
| 63 | + } |
| 64 | + if let a = parsed.section("audio") { |
| 65 | + cfg.silenceRMSThreshold = a.double("silence_rms_threshold") ?? cfg.silenceRMSThreshold |
| 66 | + } |
| 67 | + } catch { |
| 68 | + fatalError("malformed hark config at \(configFile.path): \(error)") |
| 69 | + } |
| 70 | + return cfg |
| 71 | + } |
| 72 | + |
| 73 | + public var whisperURL: String { "http://\(whisperHost):\(whisperPort)" } |
| 74 | +} |
| 75 | + |
| 76 | +// MARK: - Minimal TOML subset parser |
| 77 | + |
| 78 | +/// Parses just the shape `config.example.toml` uses: `[section]` headers, |
| 79 | +/// `key = value` pairs (string / integer / float / boolean), `#` comments. |
| 80 | +/// Unknown keys and sections are ignored so a config written for the Python |
| 81 | +/// server keeps working. Values that do not parse raise, never silently drop. |
| 82 | +struct MiniTOML { |
| 83 | + struct Section { |
| 84 | + let name: String |
| 85 | + var values: [String: String] = [:] |
| 86 | + func string(_ key: String) -> String? { values[key] } |
| 87 | + func int(_ key: String) -> Int? { |
| 88 | + guard let v = values[key], let i = Int(v) else { return nil } |
| 89 | + return i |
| 90 | + } |
| 91 | + func double(_ key: String) -> Double? { |
| 92 | + guard let v = values[key] else { return nil } |
| 93 | + if let i = Int(v) { return Double(i) } |
| 94 | + return Double(v) |
| 95 | + } |
| 96 | + } |
| 97 | + var sections: [String: Section] = [:] |
| 98 | + |
| 99 | + func section(_ name: String) -> Section? { sections[name] } |
| 100 | + |
| 101 | + static func parse(_ text: String) throws -> MiniTOML { |
| 102 | + var result = MiniTOML() |
| 103 | + var current: String = "" |
| 104 | + for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) { |
| 105 | + var line = String(rawLine) |
| 106 | + if let hash = line.firstIndex(of: "#") { line = String(line[..<hash]) } |
| 107 | + line = line.trimmingCharacters(in: .whitespaces) |
| 108 | + if line.isEmpty { continue } |
| 109 | + if line.hasPrefix("[") && line.hasSuffix("]") { |
| 110 | + current = String(line.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces) |
| 111 | + result.sections[current] = Section(name: current) |
| 112 | + continue |
| 113 | + } |
| 114 | + guard let eq = line.firstIndex(of: "=") else { |
| 115 | + throw TOMLError.malformed(line) |
| 116 | + } |
| 117 | + let key = String(line[..<eq]).trimmingCharacters(in: .whitespaces) |
| 118 | + var value = String(line[line.index(after: eq)...]).trimmingCharacters(in: .whitespaces) |
| 119 | + if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { |
| 120 | + value = String(value.dropFirst().dropLast()) |
| 121 | + } |
| 122 | + result.sections[current, default: Section(name: current)].values[key] = value |
| 123 | + } |
| 124 | + return result |
| 125 | + } |
| 126 | +} |
| 127 | + |
| 128 | +enum TOMLError: Error, CustomStringConvertible { |
| 129 | + case malformed(String) |
| 130 | + var description: String { "malformed TOML line: \(self)" } |
| 131 | +} |
0 commit comments