Native Swift speaker diarization using CoreML and Accelerate. Runs on-device on macOS 14+ and iOS 17+ with zero external dependencies.
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/AXI-Labs/AxiiDiarization.git", from: "1.0.0"),
]
Two CoreML models must be converted from their source checkpoints:
# Install NeMo environment
python3 -m venv .venv_nemo
.venv_nemo/bin/pip install nemo_toolkit[asr] coremltools
# Sortformer v2.1 segmentation (batch=4, 10s windows)
.venv_nemo/bin/python3 eval/convert_sortformer_coreml.py --batch-size 4 --mel-frames 1024
# ResNet34 speaker embeddings
.venv_nemo/bin/python3 eval/convert_resnet34_coreml.py
This creates:
Models/sortformer_4spk_v21.mlpackage (441 MB, NVIDIA Open Model License)Models/wespeaker_resnet34.mlpackage (25 MB, Apache 2.0)Conversion options: --batch-size (default 4), --mel-frames (1024 for 10s windows, 3000 for 30s).
# Library + CLI (from Tools/)
cd Tools && xcodebuild -scheme DiarCLI -derivedDataPath ../.build/xcode -destination 'platform=macOS' -configuration Release build
# Binary location
.build/xcode/Build/Products/Release/DiarCLI
Use Release builds for evaluation and benchmarks. Debug builds are 5-10x slower.
python3 -m venv .venv
.venv/bin/pip install -r eval/requirements.txt
CLI=.build/xcode/Build/Products/Release/DiarCLI
# Full diarization pipeline (default models, 10s window recommended)
$CLI run <audio.wav> --window-duration 10
# Custom model paths
$CLI run <audio.wav> --sortformer-model Models/sortformer_4spk_v21.mlpackage --emb-model Models/wespeaker_resnet34.mlpackage
Audio must be 16kHz mono 16-bit PCM WAV.
# Run with profile matching
$CLI run podcast.wav --profiles profiles.json
# Name a speaker from the last run
$CLI profile set --profiles profiles.json --speaker SPEAKER_00 --name "Alice"
# List known profiles
$CLI profile list --profiles profiles.json
Re-run on a different recording with the same --profiles file to auto-match speakers.
let pipeline = try DiarizationPipeline(
sortformerModelPath: "Models/sortformer_4spk_v21.mlpackage",
embModelPath: "Models/wespeaker_resnet34.mlpackage",
windowDuration: 10.0
)
let result = pipeline.run(audio: samples) // [Float] at 16kHz
for seg in result.segments {
print("\(seg.speaker.label): \(seg.start)s - \(seg.end)s")
}
let session = pipeline.createSession()
session.addAudio(chunk1)
let partial = session.process() // segments so far
session.addAudio(chunk2)
let updated = session.process() // re-clustered with all data
let final = session.finalize()
Streaming sessions track speaker identity across process() calls. The library handles re-clustering internally — apps work with stable speaker identities, not raw cluster labels.
// Define your profile type (library only needs id + embeddings)
struct MyProfile: SpeakerProfile, Codable {
var id: String
var name: String // app-owned metadata, not part of protocol
var embeddings: [[Float]]
}
// Pre-load known speakers into a session
let saved: [MyProfile] = loadFromDisk()
let session = pipeline.createSession(knownSpeakers: saved)
session.addAudio(chunk)
let result = session.process()
// Each segment tells you how the speaker was identified
for seg in result.segments {
switch seg.identification {
case .unknown:
print("\(seg.speaker.label): \(seg.start)s - \(seg.end)s")
case .autoMatched(let speakerID, let confidence):
let name = saved.first { $0.id == speakerID }?.name ?? speakerID
print("\(name) (\(Int(confidence * 100))%): \(seg.start)s - \(seg.end)s")
case .pinned(let speakerID):
let name = saved.first { $0.id == speakerID }?.name ?? speakerID
print("\(name) [pinned]: \(seg.start)s - \(seg.end)s")
}
}
// Pin segments to a speaker (captures embeddings, survives re-clustering)
let snapshot = result.segments
.filter { $0.speaker.label == "SPEAKER_02" }
.map { ($0.start, $0.end) }
session.pinSegments(snapshot, toSpeaker: myProfile)
// Export enriched embeddings after session
let final = session.finalize()
if let embs = final.enrichedEmbeddings(for: myProfile) {
myProfile.embeddings = embs
saveToDisk(myProfile)
}
For batch (non-streaming) diarization, match speakers against profiles directly:
let matches = SpeakerMatcher.match(speakers: result.speakers, against: profiles)
for match in matches {
if let profile = match.profile {
print("\(match.speaker.label) → \(profile.name) (confidence: \(match.confidence))")
}
}
// Embedding utilities
let sim = SpeakerMatcher.cosineSimilarity(a, b)
let centroid = SpeakerMatcher.centroid(from: embeddings)
Sources/AxiiDiarization/
Audio/FBank.swift Kaldi-compatible mel filterbank (80 bins, Accelerate/vDSP)
Audio/NeMoFBank.swift NeMo-compatible mel filterbank for Sortformer (128 bins)
Models/ResNet34CoreML.swift ResNet34 CoreML wrapper (Apple Neural Engine)
Models/SortformerSegmentation.swift Sortformer v2.1 CoreML segmentation
Pipeline/DiarizationPipeline.swift Types, clustering, reconstruction
Pipeline/DiarizationPipeline+Steps.swift Segmentation + embedding extraction
Pipeline/DiarizationSession.swift Streaming session (incremental processing)
Pipeline/Clustering.swift AHC with centroid linkage
Speaker/SpeakerMatcher.swift SpeakerProfile protocol + matching + embedding math
Tools/Sources/DiarCLI/
DiarCLI.swift CLI (run, profile)
Tools/Sources/DiarUI/
DiarUIApp.swift SwiftUI app with AppDelegate window activation
AudioService.swift Audio loading (any format) + AVAudioPlayer playback
TimelineView.swift Canvas-based segment visualization with playhead
DiarUIViewModel.swift Pipeline + streaming + FluidAudio transcription + sync
ContentView.swift Toolbar, timeline, transcript, speaker panel
Tests/AxiiDiarizationTests/
ClusteringTests.swift AHC clustering unit tests
HelperTests.swift Pipeline helper tests (activationsToSegments, maskAudio)
SpeakerMatcherTests.swift Matching + embedding math tests
SpeakerTrackingTests.swift Identity tracking (pin, auto-match, enriched embeddings)
eval/
eval_swift.py DER benchmark against VoxConverse
eval_embeddings.py Within-recording embedding quality (EER)
eval_cross_recording.py Cross-recording speaker ID with AMI corpus
convert_resnet34_coreml.py ResNet34 -> CoreML conversion
convert_sortformer_coreml.py Sortformer -> CoreML conversion
requirements.txt Python dependencies
Place 7 VoxConverse test files (.wav + .rttm) into TestData/{simple,medium,hard}/. These are gitignored due to size.
# All 7 files with 10s window (recommended)
.venv/bin/python3 eval/eval_swift.py --window-duration 10
# Quick smoke test, specific files, save baseline
.venv/bin/python3 eval/eval_swift.py --category simple
.venv/bin/python3 eval/eval_swift.py --files akthc,ampme
.venv/bin/python3 eval/eval_swift.py --save-baseline
Results (Sortformer v2.1, 10s window, CoreML):
| File | Category | DER | Speakers (pred/gt) | Time |
|---|---|---|---|---|
| akthc (114s) | simple | 3.8% | 2/2 | 0.6s |
| ampme (168s) | simple | 2.5% | 3/3 | 0.6s |
| afjiv (156s) | medium | 7.7% | 5/5 | 0.6s |
| aisvi (510s) | medium | 3.0% | 8/8 | 1.3s |
| cjfer (600s) | hard | 11.1% | 19/15 | 1.6s |
| ldnro (1095s) | hard | 4.0% | 17/15 | 2.5s |
| wewoz (147s) | hard | 5.2% | 13/12 | 0.6s |
| Mean | 5.3% |
Window duration comparison:
| Window | Mean DER | Speaker count correct |
|---|---|---|
| 5s | 6.4% | 0/7 |
| 10s | 5.3% | 4/7 |
| 15s | 5.8% | 4/7 |
| 30s | 10.0% | 4/7 |
# Within-recording speaker verification (EER)
python3 eval/eval_embeddings.py
# Cross-recording speaker identification (AMI corpus)
python3 eval/eval_cross_recording.py --max-groups 10
| Metric | Value | Notes |
|---|---|---|
| Within-recording EER | 1.89% | 40 speakers, 400K+ pairs |
| Cross-recording identification | 94.8% | 41 speakers, 10 meeting groups, 480 tests |
| Mean profile stability | 0.71 | Cosine sim of same speaker across meetings |
The cross-recording evaluation uses the AMI Meeting Corpus Mix-Headset audio (all microphones mixed), representing a worst case for speaker separation. The script loads actual participant IDs from meetings.xml to handle label inconsistencies across meetings.
xcodebuild test -scheme AxiiDiarization -derivedDataPath .build/xcode -destination 'platform=macOS'
50 tests covering clustering, pipeline helpers, speaker matching (protocol, greedy assignment, embedding math), and speaker identity tracking (identification types, enriched embeddings, registered speakers). No model files required.
SwiftUI app for validating diarization quality. Loads any audio format (MP3/M4A/WAV), visualizes speaker segments on a timeline, plays audio with synchronized transcript scrolling, and transcribes using FluidAudio (Parakeet TDT v3).
# Build (use Release for performance)
cd Tools && xcodebuild -scheme DiarUI -derivedDataPath ../.build/xcode -destination 'platform=macOS' -configuration Release build
.build/xcode/Build/Products/Release/DiarUI
Features:
First run downloads the Parakeet model (~600MB). Profiles are saved to ~/Library/Application Support/DiarUI/profiles.json.
MIT License. See LICENSE for details.
Model weights have separate licenses:
18 commits
Swift
50.1%
Python
49.9%
Native Swift speaker diarization using CoreML and Accelerate. Runs on-device on macOS 14+ and iOS 17+ with zero external dependencies.
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/AXI-Labs/AxiiDiarization.git", from: "1.0.0"),
]
Two CoreML models must be converted from their source checkpoints:
# Install NeMo environment
python3 -m venv .venv_nemo
.venv_nemo/bin/pip install nemo_toolkit[asr] coremltools
# Sortformer v2.1 segmentation (batch=4, 10s windows)
.venv_nemo/bin/python3 eval/convert_sortformer_coreml.py --batch-size 4 --mel-frames 1024
# ResNet34 speaker embeddings
.venv_nemo/bin/python3 eval/convert_resnet34_coreml.py
This creates:
Models/sortformer_4spk_v21.mlpackage (441 MB, NVIDIA Open Model License)Models/wespeaker_resnet34.mlpackage (25 MB, Apache 2.0)Conversion options: --batch-size (default 4), --mel-frames (1024 for 10s windows, 3000 for 30s).
# Library + CLI (from Tools/)
cd Tools && xcodebuild -scheme DiarCLI -derivedDataPath ../.build/xcode -destination 'platform=macOS' -configuration Release build
# Binary location
.build/xcode/Build/Products/Release/DiarCLI
Use Release builds for evaluation and benchmarks. Debug builds are 5-10x slower.
python3 -m venv .venv
.venv/bin/pip install -r eval/requirements.txt
CLI=.build/xcode/Build/Products/Release/DiarCLI
# Full diarization pipeline (default models, 10s window recommended)
$CLI run <audio.wav> --window-duration 10
# Custom model paths
$CLI run <audio.wav> --sortformer-model Models/sortformer_4spk_v21.mlpackage --emb-model Models/wespeaker_resnet34.mlpackage
Audio must be 16kHz mono 16-bit PCM WAV.
# Run with profile matching
$CLI run podcast.wav --profiles profiles.json
# Name a speaker from the last run
$CLI profile set --profiles profiles.json --speaker SPEAKER_00 --name "Alice"
# List known profiles
$CLI profile list --profiles profiles.json
Re-run on a different recording with the same --profiles file to auto-match speakers.
let pipeline = try DiarizationPipeline(
sortformerModelPath: "Models/sortformer_4spk_v21.mlpackage",
embModelPath: "Models/wespeaker_resnet34.mlpackage",
windowDuration: 10.0
)
let result = pipeline.run(audio: samples) // [Float] at 16kHz
for seg in result.segments {
print("\(seg.speaker.label): \(seg.start)s - \(seg.end)s")
}
let session = pipeline.createSession()
session.addAudio(chunk1)
let partial = session.process() // segments so far
session.addAudio(chunk2)
let updated = session.process() // re-clustered with all data
let final = session.finalize()
Streaming sessions track speaker identity across process() calls. The library handles re-clustering internally — apps work with stable speaker identities, not raw cluster labels.
// Define your profile type (library only needs id + embeddings)
struct MyProfile: SpeakerProfile, Codable {
var id: String
var name: String // app-owned metadata, not part of protocol
var embeddings: [[Float]]
}
// Pre-load known speakers into a session
let saved: [MyProfile] = loadFromDisk()
let session = pipeline.createSession(knownSpeakers: saved)
session.addAudio(chunk)
let result = session.process()
// Each segment tells you how the speaker was identified
for seg in result.segments {
switch seg.identification {
case .unknown:
print("\(seg.speaker.label): \(seg.start)s - \(seg.end)s")
case .autoMatched(let speakerID, let confidence):
let name = saved.first { $0.id == speakerID }?.name ?? speakerID
print("\(name) (\(Int(confidence * 100))%): \(seg.start)s - \(seg.end)s")
case .pinned(let speakerID):
let name = saved.first { $0.id == speakerID }?.name ?? speakerID
print("\(name) [pinned]: \(seg.start)s - \(seg.end)s")
}
}
// Pin segments to a speaker (captures embeddings, survives re-clustering)
let snapshot = result.segments
.filter { $0.speaker.label == "SPEAKER_02" }
.map { ($0.start, $0.end) }
session.pinSegments(snapshot, toSpeaker: myProfile)
// Export enriched embeddings after session
let final = session.finalize()
if let embs = final.enrichedEmbeddings(for: myProfile) {
myProfile.embeddings = embs
saveToDisk(myProfile)
}
For batch (non-streaming) diarization, match speakers against profiles directly:
let matches = SpeakerMatcher.match(speakers: result.speakers, against: profiles)
for match in matches {
if let profile = match.profile {
print("\(match.speaker.label) → \(profile.name) (confidence: \(match.confidence))")
}
}
// Embedding utilities
let sim = SpeakerMatcher.cosineSimilarity(a, b)
let centroid = SpeakerMatcher.centroid(from: embeddings)
Sources/AxiiDiarization/
Audio/FBank.swift Kaldi-compatible mel filterbank (80 bins, Accelerate/vDSP)
Audio/NeMoFBank.swift NeMo-compatible mel filterbank for Sortformer (128 bins)
Models/ResNet34CoreML.swift ResNet34 CoreML wrapper (Apple Neural Engine)
Models/SortformerSegmentation.swift Sortformer v2.1 CoreML segmentation
Pipeline/DiarizationPipeline.swift Types, clustering, reconstruction
Pipeline/DiarizationPipeline+Steps.swift Segmentation + embedding extraction
Pipeline/DiarizationSession.swift Streaming session (incremental processing)
Pipeline/Clustering.swift AHC with centroid linkage
Speaker/SpeakerMatcher.swift SpeakerProfile protocol + matching + embedding math
Tools/Sources/DiarCLI/
DiarCLI.swift CLI (run, profile)
Tools/Sources/DiarUI/
DiarUIApp.swift SwiftUI app with AppDelegate window activation
AudioService.swift Audio loading (any format) + AVAudioPlayer playback
TimelineView.swift Canvas-based segment visualization with playhead
DiarUIViewModel.swift Pipeline + streaming + FluidAudio transcription + sync
ContentView.swift Toolbar, timeline, transcript, speaker panel
Tests/AxiiDiarizationTests/
ClusteringTests.swift AHC clustering unit tests
HelperTests.swift Pipeline helper tests (activationsToSegments, maskAudio)
SpeakerMatcherTests.swift Matching + embedding math tests
SpeakerTrackingTests.swift Identity tracking (pin, auto-match, enriched embeddings)
eval/
eval_swift.py DER benchmark against VoxConverse
eval_embeddings.py Within-recording embedding quality (EER)
eval_cross_recording.py Cross-recording speaker ID with AMI corpus
convert_resnet34_coreml.py ResNet34 -> CoreML conversion
convert_sortformer_coreml.py Sortformer -> CoreML conversion
requirements.txt Python dependencies
Place 7 VoxConverse test files (.wav + .rttm) into TestData/{simple,medium,hard}/. These are gitignored due to size.
# All 7 files with 10s window (recommended)
.venv/bin/python3 eval/eval_swift.py --window-duration 10
# Quick smoke test, specific files, save baseline
.venv/bin/python3 eval/eval_swift.py --category simple
.venv/bin/python3 eval/eval_swift.py --files akthc,ampme
.venv/bin/python3 eval/eval_swift.py --save-baseline
Results (Sortformer v2.1, 10s window, CoreML):
| File | Category | DER | Speakers (pred/gt) | Time |
|---|---|---|---|---|
| akthc (114s) | simple | 3.8% | 2/2 | 0.6s |
| ampme (168s) | simple | 2.5% | 3/3 | 0.6s |
| afjiv (156s) | medium | 7.7% | 5/5 | 0.6s |
| aisvi (510s) | medium | 3.0% | 8/8 | 1.3s |
| cjfer (600s) | hard | 11.1% | 19/15 | 1.6s |
| ldnro (1095s) | hard | 4.0% | 17/15 | 2.5s |
| wewoz (147s) | hard | 5.2% | 13/12 | 0.6s |
| Mean | 5.3% |
Window duration comparison:
| Window | Mean DER | Speaker count correct |
|---|---|---|
| 5s | 6.4% | 0/7 |
| 10s | 5.3% | 4/7 |
| 15s | 5.8% | 4/7 |
| 30s | 10.0% | 4/7 |
# Within-recording speaker verification (EER)
python3 eval/eval_embeddings.py
# Cross-recording speaker identification (AMI corpus)
python3 eval/eval_cross_recording.py --max-groups 10
| Metric | Value | Notes |
|---|---|---|
| Within-recording EER | 1.89% | 40 speakers, 400K+ pairs |
| Cross-recording identification | 94.8% | 41 speakers, 10 meeting groups, 480 tests |
| Mean profile stability | 0.71 | Cosine sim of same speaker across meetings |
The cross-recording evaluation uses the AMI Meeting Corpus Mix-Headset audio (all microphones mixed), representing a worst case for speaker separation. The script loads actual participant IDs from meetings.xml to handle label inconsistencies across meetings.
xcodebuild test -scheme AxiiDiarization -derivedDataPath .build/xcode -destination 'platform=macOS'
50 tests covering clustering, pipeline helpers, speaker matching (protocol, greedy assignment, embedding math), and speaker identity tracking (identification types, enriched embeddings, registered speakers). No model files required.
SwiftUI app for validating diarization quality. Loads any audio format (MP3/M4A/WAV), visualizes speaker segments on a timeline, plays audio with synchronized transcript scrolling, and transcribes using FluidAudio (Parakeet TDT v3).
# Build (use Release for performance)
cd Tools && xcodebuild -scheme DiarUI -derivedDataPath ../.build/xcode -destination 'platform=macOS' -configuration Release build
.build/xcode/Build/Products/Release/DiarUI
Features:
First run downloads the Parakeet model (~600MB). Profiles are saved to ~/Library/Application Support/DiarUI/profiles.json.
MIT License. See LICENSE for details.
Model weights have separate licenses:
18 commits
Swift
50.1%
Python
49.9%