Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4872810
docs: granite shadow transcription design spec
nicw Jul 10, 2026
c601a6d
docs: ASR model ROI research memo (six verified tracks)
nicw Jul 10, 2026
51a586a
docs: spec amendment — Phase 0 public-corpus benchmark before shadow …
nicw Jul 10, 2026
dee2a61
docs: granite shadow implementation plan (13 TDD tasks, Friday-mornin…
nicw Jul 10, 2026
859584c
feat: granite shadow setup script (llama.cpp check + GGUF download vi…
nicw Jul 10, 2026
c1dee38
docs: spec — phase 0 gates block flag-enable, not build (Friday deadl…
nicw Jul 10, 2026
f60063f
feat: pin granite llama-server request template + python client
nicw Jul 10, 2026
992c488
feat: ASRBench manifest mode + BenchSupport library
nicw Jul 10, 2026
77052bf
feat: phase 0 ASR benchmark harness
nicw Jul 10, 2026
d82f8b7
feat: ShadowConfig (hidden granite shadow flag)
nicw Jul 10, 2026
299d3f8
fix: mirror upstream TED-LIUM ignore-marker set completely in bench.py
nicw Jul 10, 2026
7e8a15f
refactor: extract SegmentAudio merge/pad/read (shared with granite sh…
nicw Jul 10, 2026
aba717a
fix: readSegment propagates read errors (placeholder-path parity)
nicw Jul 10, 2026
cb02533
feat: AudioWAVExport (16 kHz mono PCM16 WAV for granite sidecar)
nicw Jul 10, 2026
891d086
feat: GraniteRequest pinned to granite_request.md template
nicw Jul 10, 2026
4b199b8
chore: drop stray .pyc, ignore __pycache__
nicw Jul 10, 2026
d4c0302
feat: GraniteSidecar actor (spawn-per-job llama-server lifecycle)
nicw Jul 10, 2026
b665333
fix: AudioWAVExport drain loop + output-length guard (large-buffer sa…
nicw Jul 10, 2026
7b4bd4d
fix: GraniteSidecar stop-vs-relaunch generation guard + reentrancy tests
nicw Jul 10, 2026
da2c40c
feat: granite shadow runner, artifacts, phase orchestration
nicw Jul 10, 2026
bfaaba0
fix: shadow runner breaks early on requestFailed too
nicw Jul 10, 2026
e4a68df
feat: wire granite shadow phase into PostProcessingJob (hidden flag)
nicw Jul 10, 2026
9cff10b
feat: granite shadow HTML comparison report (stdlib)
nicw Jul 10, 2026
f879620
docs: granite phase 0 results — all three gates PASS, GO for shadow e…
nicw Jul 10, 2026
497c5c5
test: granite shadow live smoke on real session
nicw Jul 10, 2026
89fb584
fix: process-global sidecar registry, killed on app quit
nicw Jul 10, 2026
2fa0c3c
fix: sidecar start() guards + post() status handling + ctx-size
nicw Jul 10, 2026
53e16fd
fix: pairing key collision + WAV drain loop shape
nicw Jul 10, 2026
be684b7
fix: ride-alongs — report script resilience + logged skips
nicw Jul 10, 2026
fc8ab15
fix: quitting gate closes kill-vs-relaunch race; probe honors stop ge…
nicw Jul 10, 2026
2b9c2f1
docs: reinstall note — final-review fix build live
nicw Jul 10, 2026
92aecba
fix: preload+reuse VAD model; curl fallback for Whisper model download
nicw Jul 10, 2026
8ff256b
fix: curl fetcher — cancellation terminates the child, size-check ski…
nicw Jul 13, 2026
ef8b7d0
fix: log curl-fetcher outcome to the unified log — failures were Sett…
nicw Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ AGENTS.md
# Misc
*.spec
.signing/
__pycache__/
*.pyc
10 changes: 9 additions & 1 deletion Tome/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,20 @@ let package = Package(
],
path: "Sources/VoiceprintAudit"
),
// JSONL manifest parse/emit shared by ASRBench (manifest mode) and its
// tests. Plain library, no ASR deps — kept separate so TomeTests can
// depend on it without pulling in FluidAudio/WhisperKit.
.target(
name: "BenchSupport",
path: "Sources/BenchSupport"
),
// ASR load-test harness comparing Parakeet vs Whisper latency (see
// docs/superpowers/specs/2026-07-08-*.md §8). Not part of the app;
// never run in CI (downloads GBs of models, needs ANE).
.executableTarget(
name: "ASRBench",
dependencies: [
"BenchSupport",
.product(name: "FluidAudio", package: "FluidAudio"),
.product(name: "WhisperKit", package: "argmax-oss-swift"),
],
Expand All @@ -60,7 +68,7 @@ let package = Package(
// Nothing here touches audio devices, permissions, or the ASR models.
.testTarget(
name: "TomeTests",
dependencies: ["Tome"],
dependencies: ["Tome", "BenchSupport"],
path: "Tests/TomeTests"
),
]
Expand Down
92 changes: 90 additions & 2 deletions Tome/Sources/ASRBench/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// Usage: swift run -c release ASRBench <wav/m4a...> [--json out.json]

import AVFoundation
import BenchSupport
import Foundation
import FluidAudio
import WhisperKit
Expand All @@ -25,6 +26,36 @@ let sampleRate = 16_000.0
let maxChunkSamples = 480_000
let minChunkSamples = 8_000

// Manifest mode: ASRBench --manifest in.jsonl --backend parakeet|whisper --out hyp.jsonl
// Reuses the same hand-mirrored model config as the bench functions below (keep in sync).
if let mi = CommandLine.arguments.firstIndex(of: "--manifest") {
let args = CommandLine.arguments
guard args.count > mi + 1,
let bi = args.firstIndex(of: "--backend"), args.count > bi + 1,
let oi = args.firstIndex(of: "--out"), args.count > oi + 1 else {
FileHandle.standardError.write(Data("usage: ASRBench --manifest in.jsonl --backend parakeet|whisper --out hyp.jsonl\n".utf8))
exit(2)
}
let entries = try BenchManifest.parse(String(contentsOfFile: args[mi + 1], encoding: .utf8))
let backend = args[bi + 1]
// Extract the model-loading half of benchParakeet()/benchWhisper() into
// loadParakeet() / loadWhisper() helpers returning a `(String) async throws -> String`
// transcribe closure (WAV path in, text out), reusing the existing sample-loading
// code these bench functions already use for their own WAVs.
let transcribe: (String) async throws -> String = backend == "whisper"
? try await loadWhisper()
: try await loadParakeet()
var hyps: [HypothesisEntry] = []
for (i, e) in entries.enumerated() {
let text = (try? await transcribe(e.wav)) ?? ""
hyps.append(HypothesisEntry(id: e.id, text: text))
if i % 50 == 0 { print("[\(backend)] \(i)/\(entries.count)") }
}
try BenchManifest.emit(hyps).write(toFile: args[oi + 1], atomically: true, encoding: .utf8)
print("[\(backend)] wrote \(hyps.count) hypotheses → \(args[oi + 1])")
exit(0)
}

var argv = Array(CommandLine.arguments.dropFirst())
var jsonOut: String?
if let i = argv.firstIndex(of: "--json"), i + 1 < argv.count {
Expand Down Expand Up @@ -80,7 +111,15 @@ func peakRSSMB() -> Double {
func now() -> Double { CFAbsoluteTimeGetCurrent() }

// --- Parakeet ---
func benchParakeet(chunks: [[Float]]) async throws -> BackendReport {

// Model-loading half of benchParakeet(): downloads (if needed), does a cold
// load then a warm load (mirroring the bench's cold/warm timing dance), and
// hands back the warm-loaded manager plus the report fields benchParakeet()
// needs (download/load timings, cache dir). Also used directly by manifest
// mode via the `transcribe` closure it derives its own wrapper from.
func loadParakeetManager() async throws -> (
asr: AsrManager, downloadSeconds: Double?, dir: URL, loadCold: Double, loadWarm: Double
) {
let cached = AsrModels.modelsExist(
at: AsrModels.defaultCacheDirectory(for: .v3), version: .v3)
let tDownload = now()
Expand All @@ -99,6 +138,26 @@ func benchParakeet(chunks: [[Float]]) async throws -> BackendReport {
try await asr.loadModels(warmModels)
let loadWarm = now() - tWarm

return (asr, downloadSeconds, dir, loadCold, loadWarm)
}

// Manifest-mode helper: loads Parakeet and returns a (WAV path in, text out)
// transcribe closure, reusing loadParakeetManager()'s load path and the same
// AudioProcessor.loadAudioAsFloatArray sample-loading the top-level bench
// driver uses for its own files.
func loadParakeet() async throws -> (String) async throws -> String {
let (asr, _, _, _, _) = try await loadParakeetManager()
return { path in
let samples = try AudioProcessor.loadAudioAsFloatArray(fromPath: path)
var state = TdtDecoderState.make()
let result = try await asr.transcribe(samples, decoderState: &state, language: .english)
return result.text
}
}

func benchParakeet(chunks: [[Float]]) async throws -> BackendReport {
let (asr, downloadSeconds, dir, loadCold, loadWarm) = try await loadParakeetManager()

func transcribe(_ samples: [Float]) async throws -> Double {
var state = TdtDecoderState.make()
let t = now()
Expand All @@ -121,7 +180,14 @@ func benchParakeet(chunks: [[Float]]) async throws -> BackendReport {
}

// --- Whisper ---
func benchWhisper(chunks: [[Float]], variant: String, base: URL) async throws -> BackendReport {

// Model-loading half of benchWhisper(): resolves/downloads the model folder,
// does a cold load then a warm load (mirroring the bench's cold/warm timing
// dance), and hands back the warm-loaded kit plus the report fields
// benchWhisper() needs (download timing, folder, variant).
func loadWhisperKit(variant: String, base: URL) async throws -> (
kit: WhisperKit, downloadSeconds: Double?, folder: URL, loadCold: Double, loadWarm: Double
) {
let expectedFolder = base.appendingPathComponent(
"models/argmaxinc/whisperkit-coreml/\(variant)", isDirectory: true)
let cached = FileManager.default.fileExists(
Expand Down Expand Up @@ -151,6 +217,28 @@ func benchWhisper(chunks: [[Float]], variant: String, base: URL) async throws ->
let kit = try await WhisperKit(config)
let loadWarm = now() - tWarm

return (kit, downloadSeconds, folder, loadCold, loadWarm)
}

// Manifest-mode helper: loads Whisper (default family/base, same as the top-
// level bench driver uses) and returns a (WAV path in, text out) transcribe
// closure, reusing loadWhisperKit()'s load path and the existing
// AudioProcessor.loadAudioAsFloatArray sample-loading code.
func loadWhisper() async throws -> (String) async throws -> String {
let (kit, _, _, _, _) = try await loadWhisperKit(variant: whisperVariant, base: whisperBase)
return { path in
let samples = try AudioProcessor.loadAudioAsFloatArray(fromPath: path)
let results = try await kit.transcribe(
audioArray: samples,
decodeOptions: DecodingOptions(task: .transcribe, language: "en"))
return results.map(\.text).joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

func benchWhisper(chunks: [[Float]], variant: String, base: URL) async throws -> BackendReport {
let (kit, downloadSeconds, folder, loadCold, loadWarm) = try await loadWhisperKit(variant: variant, base: base)

func transcribe(_ samples: [Float]) async throws -> Double {
let t = now()
_ = try await kit.transcribe(
Expand Down
27 changes: 27 additions & 0 deletions Tome/Sources/BenchSupport/BenchManifest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation

public struct ManifestEntry: Codable, Equatable, Sendable {
public let id: String
public let wav: String
public init(id: String, wav: String) { self.id = id; self.wav = wav }
}

public struct HypothesisEntry: Codable, Equatable, Sendable {
public let id: String
public let text: String
public init(id: String, text: String) { self.id = id; self.text = text }
}

public enum BenchManifest {
public static func parse(_ jsonl: String) throws -> [ManifestEntry] {
try jsonl.split(separator: "\n", omittingEmptySubsequences: true)
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
.map { try JSONDecoder().decode(ManifestEntry.self, from: Data($0.utf8)) }
}
public static func emit(_ hyps: [HypothesisEntry]) -> String {
let enc = JSONEncoder()
enc.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
return hyps.map { String(data: try! enc.encode($0), encoding: .utf8)! }
.joined(separator: "\n") + (hyps.isEmpty ? "" : "\n")
}
}
9 changes: 9 additions & 0 deletions Tome/Sources/Tome/App/TomeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

let response = alert.runModal()
if response == .alertSecondButtonReturn {
// A shadow-phase sidecar may still be running in the queue's
// in-flight job; nothing outside Transcription/ holds a reference
// to it, so this process-global kill is the only way to
// guarantee it doesn't outlive Tome.
SidecarRegistry.killAll()
queue.shutdown()
return .terminateNow
}
Expand All @@ -256,6 +261,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
while queue.isAnyJobRunning && Date() < deadline {
try? await Task.sleep(for: .milliseconds(250))
}
// Same backstop as the "Quit Anyway" branch above: the 60s cap
// may expire with a shadow sidecar still live in the job that's
// about to be torn down by shutdown().
SidecarRegistry.killAll()
queue.shutdown()
NSApp.reply(toApplicationShouldTerminate: true)
}
Expand Down
70 changes: 70 additions & 0 deletions Tome/Sources/Tome/Transcription/AudioWAVExport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import AVFoundation

/// Converts arbitrary PCM buffers to the 16 kHz mono PCM16 WAV bytes the
/// granite sidecar consumes (granite_request.md pins format: "wav").
enum AudioWAVExport {
enum ExportError: Error { case formatUnavailable, conversionFailed }

static func wav16kMonoPCM16(from buffer: AVAudioPCMBuffer) throws -> Data {
guard let outFmt = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000,
channels: 1, interleaved: true),
let converter = AVAudioConverter(from: buffer.format, to: outFmt)
else { throw ExportError.formatUnavailable }
let ratio = 16000.0 / buffer.format.sampleRate
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 4096
guard let out = AVAudioPCMBuffer(pcmFormat: outFmt, frameCapacity: capacity)
else { throw ExportError.conversionFailed }
var fed = false
var totalFrames: AVAudioFrameCount = 0
var pcmData = Data()

// Drain loop: keep converting until converter exhausts input/output or
// reports error. Each iteration's produced bytes are appended to
// `pcmData` immediately, before `out` is reset for the next iteration —
// correct regardless of whether convert() ever splits its output across
// multiple .haveData chunks (a single iteration is the common case
// here, since `capacity` is sized to fit the whole conversion up
// front, but nothing below depends on that). The previous version
// copied bytes only once, after the loop, from whatever `out` held on
// the FINAL iteration — correct only by the single-iteration
// assumption; a real multi-chunk conversion would have discarded the
// earlier chunks' samples while still counting their frames.
while true {
var drainError: NSError?
let status = converter.convert(to: out, error: &drainError) { _, status in
if fed { status.pointee = .endOfStream; return nil }
fed = true; status.pointee = .haveData; return buffer
}
if let drainError { throw drainError }
if status == .error { throw ExportError.conversionFailed }
if out.frameLength > 0 {
pcmData.append(Data(bytes: out.int16ChannelData![0], count: Int(out.frameLength) * 2))
totalFrames += out.frameLength
}
if status == .endOfStream || out.frameLength == 0 { break }
out.frameLength = 0 // Reset for next iteration
}

// Sanity check: output length should be ~expected; silent truncation is an error
let expected = Double(buffer.frameLength) * 16000.0 / buffer.format.sampleRate
if Double(totalFrames) < expected - 4096 {
throw ExportError.conversionFailed
}

var data = riffHeader(dataByteCount: pcmData.count)
data.append(pcmData)
return data
}

static func riffHeader(dataByteCount: Int) -> Data {
var d = Data()
func le32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } }
func le16(_ v: UInt16) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } }
d.append(contentsOf: "RIFF".utf8); le32(UInt32(36 + dataByteCount))
d.append(contentsOf: "WAVE".utf8)
d.append(contentsOf: "fmt ".utf8); le32(16); le16(1) /* PCM */; le16(1) /* mono */
le32(16000); le32(16000 * 2) /* byte rate */; le16(2) /* block align */; le16(16)
d.append(contentsOf: "data".utf8); le32(UInt32(dataByteCount))
return d
}
}
Loading