Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 25 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: verify

on:
pull_request:

permissions:
contents: read

jobs:
swift-build-and-contracts:
name: Swift build and contract harness
runs-on: macos-26
timeout-minutes: 15
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Report SwiftPM host
run: |
sw_vers
swift --version
xcode-select -p
- name: Swift build
run: swift build
- name: Dependency-free contract harness
run: swift run oigo-spike-contract-tests
35 changes: 35 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// swift-tools-version: 6.3

import PackageDescription

let package = Package(
name: "OigoSpike",
platforms: [
.macOS("26.0")
],
products: [
.library(name: "OigoSpike", targets: ["OigoSpike"]),
.executable(name: "oigo-spike", targets: ["OigoSpikeCLI"]),
.executable(name: "oigo-spike-contract-tests", targets: ["OigoSpikeContractTests"])
],
targets: [
.target(
name: "OigoSpike",
linkerSettings: [
.linkedFramework("AVFAudio"),
.linkedFramework("FoundationModels"),
.linkedFramework("Speech")
]
),
.executableTarget(
name: "OigoSpikeCLI",
dependencies: ["OigoSpike"],
path: "Sources/oigo-spike"
),
.executableTarget(
name: "OigoSpikeContractTests",
dependencies: ["OigoSpike"],
path: "Tests/OigoSpikeContractTests"
)
]
)
147 changes: 147 additions & 0 deletions Sources/OigoSpike/CaptureJournal.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import AVFAudio
import Foundation

public enum CaptureJournalError: Error, Equatable, CustomStringConvertible {
case closed
case missing(URL)

public var description: String {
switch self {
case .closed:
return "capture journal is closed"
case .missing(let url):
return "capture artifact is missing at \(url.path)"
}
}
}

public final class DurableCaptureJournal: @unchecked Sendable {
public let url: URL

private let lock = NSLock()
private var handle: FileHandle?
private(set) public var bytesWritten = 0

public init(url: URL) throws {
self.url = url
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
FileManager.default.createFile(atPath: url.path, contents: nil)
handle = try FileHandle(forWritingTo: url)
}

public func append(_ data: Data) throws {
lock.lock()
defer { lock.unlock() }
guard let handle else {
throw CaptureJournalError.closed
}
try handle.seekToEnd()
try handle.write(contentsOf: data)
try handle.synchronize()
bytesWritten += data.count
}

public func finish() throws {
lock.lock()
defer { lock.unlock() }
guard let handle else {
return
}
try handle.synchronize()
try handle.close()
self.handle = nil
}

public static func recover(_ url: URL) throws -> Data {
guard FileManager.default.fileExists(atPath: url.path) else {
throw CaptureJournalError.missing(url)
}
return try Data(contentsOf: url)
}

deinit {
try? finish()
}
}

@available(macOS 26.0, *)
public final class CAFRecorder: @unchecked Sendable {
public let url: URL

private let lock = NSLock()
private var audioFile: AVAudioFile?

public init(url: URL, format: AVAudioFormat) throws {
self.url = url
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
audioFile = try AVAudioFile(
forWriting: url,
settings: format.settings,
commonFormat: format.commonFormat,
interleaved: format.isInterleaved
)
}

public func append(_ buffer: AVAudioPCMBuffer) throws {
lock.lock()
defer { lock.unlock() }
guard let audioFile else {
throw CaptureJournalError.closed
}
try audioFile.write(from: buffer)
}

public func finish() throws {
lock.lock()
defer { lock.unlock() }
audioFile = nil
}

public static func playableFrameLength(at url: URL) throws -> Int64 {
guard FileManager.default.fileExists(atPath: url.path) else {
throw CaptureJournalError.missing(url)
}
let audioFile = try AVAudioFile(forReading: url)
return Int64(audioFile.length)
}

deinit {
try? finish()
}
}

public struct ForcedRecognitionFailure: Error, Sendable, CustomStringConvertible {
public init() {}

public var description: String {
"forced live recognition failure"
}
}

public enum SavedAudioRetry {
public static func retry<T>(
url: URL,
transcribe: (URL) throws -> T
) throws -> T {
guard FileManager.default.fileExists(atPath: url.path) else {
throw CaptureJournalError.missing(url)
}
return try transcribe(url)
}

public static func retryAfterFailure<T>(
url: URL,
liveFailure: Error,
transcribe: (URL) throws -> T
) throws -> T {
_ = liveFailure
_ = try CAFRecorder.playableFrameLength(at: url)
return try retry(url: url, transcribe: transcribe)
}
}
139 changes: 139 additions & 0 deletions Sources/OigoSpike/Cleanup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import Foundation
import FoundationModels

private final class CleanupResolution: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<CleanupRecord, Never>?
private var resolvedRecord: CleanupRecord?
private var resolved = false

func install(_ continuation: CheckedContinuation<CleanupRecord, Never>) {
lock.lock()
if let resolvedRecord {
lock.unlock()
continuation.resume(returning: resolvedRecord)
return
}
self.continuation = continuation
lock.unlock()
}

func resolve(_ record: CleanupRecord) {
lock.lock()
guard !resolved else {
lock.unlock()
return
}
resolved = true
resolvedRecord = record
let continuation = self.continuation
lock.unlock()
continuation?.resume(returning: record)
}
}

public enum CleanupStatus: Equatable, Sendable {
case cleaned
case unavailable(String)
case timedOut
case failed(String)
}

public struct CleanupRecord: Equatable, Sendable {
public let rawText: String
public let cleanedText: String?
public let status: CleanupStatus

public init(rawText: String, cleanedText: String?, status: CleanupStatus) {
self.rawText = rawText
self.cleanedText = cleanedText
self.status = status
}

public var displayedText: String {
cleanedText ?? rawText
}
}

public enum CleanupFallback {
public static func unavailable(rawText: String, reason: String) -> CleanupRecord {
CleanupRecord(rawText: rawText, cleanedText: nil, status: .unavailable(reason))
}

public static func timedOut(rawText: String) -> CleanupRecord {
CleanupRecord(rawText: rawText, cleanedText: nil, status: .timedOut)
}

public static func failed(rawText: String, error: Error) -> CleanupRecord {
CleanupRecord(rawText: rawText, cleanedText: nil, status: .failed(String(describing: error)))
}

public static func run(
rawText: String,
timeoutNanoseconds: UInt64,
operation: @escaping @Sendable () async throws -> String
) async -> CleanupRecord {
let resolution = CleanupResolution()
let responseTask = Task {
do {
let cleanedText = try await operation()
resolution.resolve(
CleanupRecord(
rawText: rawText,
cleanedText: cleanedText,
status: .cleaned
)
)
} catch {
resolution.resolve(CleanupFallback.failed(rawText: rawText, error: error))
}
}
let timeoutTask = Task {
do {
try await Task.sleep(nanoseconds: timeoutNanoseconds)
resolution.resolve(CleanupFallback.timedOut(rawText: rawText))
} catch {
}
}
let result = await withCheckedContinuation { (continuation: CheckedContinuation<CleanupRecord, Never>) in
resolution.install(continuation)
}
responseTask.cancel()
timeoutTask.cancel()
return result
}
}

@available(macOS 26.0, *)
public final class FoundationModelsCleaner: @unchecked Sendable {
private let signposts: PipelineSignposts?

public init(signposts: PipelineSignposts? = nil) {
self.signposts = signposts
}

public func clean(
rawText: String,
timeoutNanoseconds: UInt64 = 2_000_000_000
) async -> CleanupRecord {
defer { signposts?.mark(.cleanup) }
let model = SystemLanguageModel.default
guard model.isAvailable else {
return CleanupFallback.unavailable(
rawText: rawText,
reason: String(describing: model.availability)
)
}

let instructions = "Rewrite the transcript as a strict meaning-preserving cleanup. Do not add, remove, infer, or normalize technical terms. Return only the cleaned transcript."
let session = LanguageModelSession(model: model, instructions: instructions)
return await CleanupFallback.run(
rawText: rawText,
timeoutNanoseconds: timeoutNanoseconds
) {
let response = try await session.respond(to: rawText)
return response.content
}
}

}
Loading
Loading