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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ let package = Package(
dependencies: [
.package(
url: "https://github.com/FluidInference/FluidAudio.git",
.upToNextMinor(from: "0.12.6")
.upToNextMinor(from: "0.15.5")
),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1"),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ extension AppState {
instruction: transcription.text,
selection: context.selectedText,
settings: customEditProviderSettings)
case .gpt5Mini, .claudeHaiku45:
case .gpt56Luna, .claudeHaiku45:
edited = try await editModeProvider.editText(
instruction: transcription.text,
selection: context.selectedText,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ extension AppState {
}
cleaned = try await customEditProvider.cleanupTranscript(
rawText, settings: customEditProviderSettings)
case .gpt5Mini, .claudeHaiku45:
case .gpt56Luna, .claudeHaiku45:
cleaned = try await editModeProvider.cleanupTranscript(
rawText, model: model)
}
Expand Down
8 changes: 4 additions & 4 deletions Sources/MiniWhisper/Models/EditModeSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Foundation
/// `~/.codex/auth.json`); `.custom` routes through `CustomEditProvider`
/// to a user-supplied OpenAI-compatible chat-completions endpoint.
enum EditModeModel: String, Codable, CaseIterable, Sendable {
case gpt5Mini = "gpt-5.4-mini"
case gpt56Luna = "gpt-5.6-luna"
case claudeHaiku45 = "claude-haiku-4-5"
case custom = "custom"

Expand All @@ -15,7 +15,7 @@ enum EditModeModel: String, Codable, CaseIterable, Sendable {
var backend: EditModeBackend {
switch self {
case .claudeHaiku45: return .claudeCli
case .gpt5Mini: return .codexCli
case .gpt56Luna: return .codexCli
case .custom: return .customApi
}
}
Expand All @@ -25,7 +25,7 @@ enum EditModeModel: String, Codable, CaseIterable, Sendable {
var oauthProvider: String {
switch self {
case .claudeHaiku45: return "anthropic"
case .gpt5Mini: return "openai-codex"
case .gpt56Luna: return "openai-codex"
case .custom: return ""
}
}
Expand All @@ -38,7 +38,7 @@ enum EditModeModel: String, Codable, CaseIterable, Sendable {
var reasoningEffort: String? {
switch self {
case .claudeHaiku45: return nil
case .gpt5Mini: return "none"
case .gpt56Luna: return "none"
case .custom: return nil
}
}
Expand Down
8 changes: 5 additions & 3 deletions Sources/MiniWhisper/Services/ParakeetProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ final class ParakeetProvider {
}
)
let manager = AsrManager(config: .default)
try await manager.initialize(models: models)
try await manager.loadModels(models)
asrManager = manager
}
initializationTask = task
Expand All @@ -54,7 +54,9 @@ final class ParakeetProvider {

let startTime = Date()

let result = try await manager.transcribe(audioURL, source: .microphone)
// Reusing state leaks token context from the previous recording.
var decoderState = try TdtDecoderState()
let result = try await manager.transcribe(audioURL, decoderState: &decoderState)
let processingTime = Date().timeIntervalSince(startTime)

let segments = convertToSegments(result)
Expand All @@ -68,7 +70,7 @@ final class ParakeetProvider {
)
}

nonisolated private static func mapProgress(_ progress: DownloadUtils.DownloadProgress) -> ModelLoadProgress {
nonisolated private static func mapProgress(_ progress: DownloadProgress) -> ModelLoadProgress {
switch progress.phase {
case .listing:
return ModelLoadProgress(phase: .checking, progress: nil)
Expand Down
6 changes: 3 additions & 3 deletions Sources/MiniWhisper/Views/ModelPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,11 @@ struct ModelPickerView: View {
ModelRow(
icon: "bolt.horizontal.fill",
title: "Codex CLI",
subtitle: "OpenAI · \(EditModeModel.gpt5Mini.rawValue)",
subtitle: "OpenAI · \(EditModeModel.gpt56Luna.rawValue)",
badge: nil,
isSelected: editModel == .gpt5Mini
isSelected: editModel == .gpt56Luna
) {
selectEditModel(.gpt5Mini)
selectEditModel(.gpt56Luna)
}

ModelRow(
Expand Down
33 changes: 33 additions & 0 deletions Sources/MiniWhisperCLI/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ enum CLI {
return 0
case "transcribe":
return await TranscribeCommand.run(arguments: rest)
case "diarize":
return await DiarizeCommand.run(arguments: rest)
case "models":
return await ModelsCommand.run(arguments: rest)
case "paths":
Expand Down Expand Up @@ -96,6 +98,7 @@ enum Help {

Common commands:
transcribe <audio> Transcribe audio with Parakeet or Whisper
diarize <audio> Label who spoke when, as RTTM
models status Show local model readiness
models install parakeet Install the default Parakeet model
models install whisper Install the optional Whisper model
Expand Down Expand Up @@ -147,6 +150,7 @@ enum Help {

Commands:
transcribe <audio> Transcribe audio with Parakeet or Whisper
diarize <audio> Label who spoke when, as RTTM
models Show local model readiness
models status [--json] Show local model readiness
models install <parakeet|whisper> Install or verify a model
Expand Down Expand Up @@ -199,6 +203,35 @@ enum Help {
)
}

static func printDiarize() {
Console.out(
"""
MiniWhisper CLI

Usage:
miniwhispercli diarize <audio> [options]

Labels who spoke when and writes RTTM. Does not transcribe; join the
output against a transcript on timestamps.

Options:
-o, --output <path> Write RTTM to a file instead of stdout
--speakers <n> Expected speaker count (default: automatic)
--threshold <n> Clustering threshold, 0.5-0.9. Lower finds more
speakers (default: 0.6). Ignored when
--speakers is set
--channel <mix|0|1> Which channel to read. Two-source recordings put
one speaker per channel (default: mix)
-q, --quiet Suppress progress on stderr
-h, --help Show this help

Examples:
miniwhispercli diarize recording.wav
miniwhispercli diarize recording.wav --speakers 3 -o recording.rttm
"""
)
}

static func printTranscribe() {
Console.out(
"""
Expand Down
168 changes: 168 additions & 0 deletions Sources/MiniWhisperCLI/DiarizeCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import Foundation
@preconcurrency import FluidAudio

/// Speaker diarization: who spoke when, without transcribing.
///
/// Emits RTTM because that is the interchange format diarization tooling reads,
/// and it joins cleanly against a separate transcript on timestamps.
enum DiarizeCommand {
static func run(arguments: [String]) async -> Int32 {
do {
let options = try DiarizeOptions.parse(arguments)
if options.showHelp {
Help.printDiarize()
return 0
}
return try await execute(options)
} catch let error as CLIError {
Console.error(error.errorDescription ?? "Invalid arguments.")
return 2
} catch {
Console.error("Diarization failed: \(error.localizedDescription)")
return 1
}
}

private static func execute(_ options: DiarizeOptions) async throws -> Int32 {
let audioURL = PathResolver.fileURL(for: options.audioPath)
guard FileManager.default.fileExists(atPath: audioURL.path) else {
throw CLIError.runtime("Audio file not found: \(audioURL.path)")
}

let samples = try WhisperCLITranscriber.resampleTo16kHz(
audioURL: audioURL, channel: options.channel)
guard !samples.isEmpty else {
throw CLIError.runtime("Audio file decoded to zero samples: \(audioURL.path)")
}
let duration = Double(samples.count) / 16_000

// DiarizerManager ignores numSpeakers; OfflineDiarizerManager honours it.
var config = OfflineDiarizerConfig.default
if let threshold = options.clusteringThreshold {
config.clustering.threshold = Double(threshold)
}
if let speakers = options.numSpeakers {
config.clustering.numSpeakers = speakers
}

if !options.quiet {
Console.error("Loading diarizer models...")
}
let manager = OfflineDiarizerManager(config: config)
try await manager.prepareModels()

if !options.quiet {
let target = options.numSpeakers.map(String.init) ?? "auto"
Console.error(
"Diarizing \(audioURL.lastPathComponent) "
+ "(\(String(format: "%.1f", duration))s, speakers: \(target))...")
}

let started = Date()
let result = try await manager.process(audio: samples)
let elapsed = Date().timeIntervalSince(started)

// RTTM identifies segments by recording id, not by path.
let recordingID = audioURL.deletingPathExtension().lastPathComponent
let rttm = renderRTTM(segments: result.segments, recordingID: recordingID)

if let output = options.outputPath {
try TextFileWriter.write(rttm, to: output)
if !options.quiet {
Console.error("Wrote \(result.segments.count) segments to \(output)")
}
} else {
Console.write(rttm)
}

if !options.quiet {
let speakers = Set(result.segments.map(\.speakerId)).count
let speech = result.segments.reduce(0.0) { $0 + Double($1.durationSeconds) }
Console.error(
"\(speakers) speakers, \(result.segments.count) segments, "
+ "\(String(format: "%.1f", speech))s speech in "
+ "\(String(format: "%.1f", elapsed))s "
+ "(\(String(format: "%.0f", duration / max(elapsed, 0.001)))x realtime)")
}

return 0
}

/// NIST RTTM: ten space-separated fields, one SPEAKER line per segment.
/// Field 4 is onset and field 5 is duration, both in seconds — not an end time.
static func renderRTTM(segments: [TimedSpeakerSegment], recordingID: String) -> String {
var lines: [String] = []
for segment in segments.sorted(by: { $0.startTimeSeconds < $1.startTimeSeconds }) {
let start = String(format: "%.3f", segment.startTimeSeconds)
let duration = String(format: "%.3f", segment.durationSeconds)
lines.append(
"SPEAKER \(recordingID) 1 \(start) \(duration) <NA> <NA> \(segment.speakerId) <NA> <NA>")
}
return lines.joined(separator: "\n") + (lines.isEmpty ? "" : "\n")
}
}

struct DiarizeOptions {
var audioPath: String = ""
var outputPath: String?
var numSpeakers: Int?
var clusteringThreshold: Float?
var channel: AudioChannelSelection = .mix
var quiet: Bool = false
var showHelp: Bool = false

static func parse(_ arguments: [String]) throws -> DiarizeOptions {
var options = DiarizeOptions()
var index = 0

while index < arguments.count {
let argument = arguments[index]
switch argument {
case "-h", "--help":
options.showHelp = true
return options
case "-o", "--output":
index += 1
guard index < arguments.count else {
throw CLIError.runtime("Missing value for \(argument).")
}
options.outputPath = arguments[index]
case "--speakers":
index += 1
guard index < arguments.count, let value = Int(arguments[index]), value > 0 else {
throw CLIError.runtime("--speakers needs a positive integer.")
}
options.numSpeakers = value
case "--threshold":
index += 1
guard index < arguments.count, let value = Float(arguments[index]) else {
throw CLIError.runtime("--threshold needs a number.")
}
options.clusteringThreshold = value
case "--channel":
index += 1
guard index < arguments.count,
let channel = AudioChannelSelection.parse(arguments[index]) else {
throw CLIError.runtime("--channel needs mix or a channel index like 0 or 1.")
}
options.channel = channel
case "-q", "--quiet":
options.quiet = true
default:
guard !argument.hasPrefix("-") else {
throw CLIError.runtime("Unknown option: \(argument)")
}
guard options.audioPath.isEmpty else {
throw CLIError.runtime("Unexpected extra argument: \(argument)")
}
options.audioPath = argument
}
index += 1
}

if options.audioPath.isEmpty && !options.showHelp {
throw CLIError.runtime("Missing audio file. Run `miniwhispercli diarize --help`.")
}
return options
}
}
6 changes: 6 additions & 0 deletions Sources/MiniWhisperCLI/SkillsCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ enum RuntimeSkills {
RuntimeSkillInfo(
name: "timestamps",
description: "Timed transcripts, subtitles, captions, overlays, and edit-boundary timing"
),
RuntimeSkillInfo(
name: "speakers",
description: "Speaker attribution: who spoke when, RTTM output, and per-channel splitting"
)
]

Expand All @@ -25,6 +29,7 @@ enum RuntimeSkills {
switch name {
case "core": return CoreSkill.text
case "timestamps": return TimestampsSkill.text
case "speakers": return SpeakersSkill.text
default: return nil
}
}
Expand All @@ -47,6 +52,7 @@ enum CoreSkill {
Start here:
miniwhispercli skills get core
miniwhispercli skills get timestamps # for subtitles, captions, overlays, or edit timing
miniwhispercli skills get speakers # for who spoke when, or speaker-labelled transcripts
miniwhispercli skills list --json

Version-matched guidance lives in the installed binary.
Expand Down
Loading
Loading