LS-EEND Streaming Speaker Diarization

July 7, 2026 · View on GitHub

Overview

LS-EEND (Long-Form Streaming End-to-End Neural Diarization) answers "who spoke when" in real-time. A causal Conformer encoder with a retention mechanism feeds an online attractor decoder that tracks speaker identities frame by frame, without separate VAD, segmentation, or clustering.

Key specs:

  • 4–10 simultaneous speakers depending on variant (see below)
  • ~100ms frame resolution (10 Hz output) at the default step size
  • Handles recordings up to one hour
  • 8000 Hz input sample rate (automatic resampling)
  • Chunk-in-chunk-out streaming; each CoreML call emits one step of committed frames
  • CoreML-optimized for Apple Silicon (CPU-only is fastest for this model)

Limitations:

  • 8000 Hz sample rate — lower audio fidelity than 16 kHz models
  • Speaker identity is local to the recording; persistent speaker enrollment may be unreliable
  • Variants are domain-specialized: using the wrong variant for a domain hurts accuracy

Variant Selection

Each variant is a separate CoreML model trained on a specific corpus. Choose the one that best matches your audio.

.ami — In-person meetings

Multi-speaker conference room recordings with close-talk and distant microphones. Best for: boardroom meetings, panel discussions, speakers in a shared physical space.

  • DER (AMI test set): 20.76%
  • Max speakers: 4

.callhome — Phone calls

Telephone conversations with codec noise and narrow bandwidth. Best for: call center recordings, customer service calls, telephony audio.

  • DER (CALLHOME test set): 12.11%
  • Max speakers: 7

.dihard2 — Difficult mixed conditions

Dinner parties, clinical interviews, conference rooms, multi-channel arrays, child speech. Best for: challenging acoustics, heavy overlap, non-standard recording setups.

  • DER (DIHARD II test set): 27.58%
  • Max speakers: 10

.dihard3 — In-the-wild conversations (default)

Podcasts, audiobooks, broadcast media, YouTube, field recordings — deliberately broad. Best for: unknown or mixed recording conditions; the safest general-purpose choice.

  • DER (DIHARD III test set): 19.61%
  • Max speakers: 10

Step Size

LSEENDStepSize selects how many output frames the model commits per CoreML call. Smaller steps reduce latency at the cost of more CoreML invocations; larger steps amortize per-call overhead and raise throughput. Each step size is a separately-converted CoreML bundle on HuggingFace, organized under 100ms/, 200ms/, … 500ms/ folders.

CaseFrames per callLatency floor
.step100ms (default)1~100 ms
.step200ms2~200 ms
.step300ms3~300 ms
.step400ms4~400 ms
.step500ms5~500 ms

The LSEENDStepSize enum is Int-backed (raw values 1–5) and CaseIterable.


Call Flow

Complete Audio

LSEENDDiarizer.processComplete(samples, sourceSampleRate:)
  |
  |-- resetStreamingState()                  zero framesFedToModel, reset feeder, clear finalized flag
  |-- timeline.reset(keepingSpeakers:)
  |-- addAudio(samples, sourceSampleRate:)
  |     |-- session.enqueueAudio(samples, withSampleRate:)   resample if needed, push into raw audio queue,
  |                                                          eagerly run STFT -> log10-mel -> CMN
  |
  |-- flush(finalizeOnCompletion: true, progressCallback:)
  |     |
  |     |-- session.drainRightContextWithSilence()    pad tail with silence so the last chunk completes
  |     |-- flush(recordFrames: true, progressCallback:)
  |     |     |
  |     |     |-- FOR EACH ready chunk:
  |     |     |     |-- session.emitNextChunk() -> LSEENDInput
  |     |     |     |     (loads mel features + decoderMask into the persistent input,
  |     |     |     |      derives warmupFrames from decoderMask zeros)
  |     |     |     |-- input.warmupFrames = max(min(convDelay - framesFedToModel, chunkSize), 0)
  |     |     |     |-- model.predict(from: input)
  |     |     |     |     |-- CoreML model.prediction(from: input)
  |     |     |     |     |-- read probs + 6 next-state tensors
  |     |     |     |     |     (enc_kv_new, enc_scale_new, enc_conv_cache_new,
  |     |     |     |     |      cnn_window_new, dec_kv_new, dec_scale_new)
  |     |     |     |     |-- swap state tensors into input.state in-place
  |     |     |     |     |-- strip warmup rows, return flat [Float] (frames * maxSpeakers)
  |     |     |     |-- append to newPreds; framesFedToModel += chunkSize; progressCallback
  |     |     |
  |     |     |-- timeline.addPredictions(finalizedPredictions: newPreds, tentativePredictions: [])
  |     |
  |     |-- timeline.finalize()              only when finalizeOnCompletion
  |
  |-- return DiarizerTimeline

processComplete(audioFileURL:) is identical, except the first step is session.enqueueAudioFile(at: url) instead of enqueueAudio(samples, withSampleRate:).

Streaming

LSEENDDiarizer.addAudio(samples, sourceSampleRate:)
  |
  |-- session.enqueueAudio(samples, withSampleRate:)   resample if needed, push into raw audio queue,
                                                       eagerly run STFT -> log10-mel -> CMN

LSEENDDiarizer.process()
  |
  |-- flush(recordFrames: true, progressCallback: nil)
  |     |
  |     |-- FOR EACH ready chunk in session.melQueue:
  |     |     |-- session.emitNextChunk() -> LSEENDInput
  |     |     |-- compute warmupFrames from convDelay - framesFedToModel
  |     |     |-- model.predict(from: input)
  |     |     |     (CoreML forward + in-place state swap, returns probs for chunkSize - warmup frames)
  |     |     |-- append to newPreds; framesFedToModel += chunkSize
  |     |
  |     |-- timeline.addPredictions(finalizedPredictions: newPreds, tentativePredictions: [])
  |
  |-- return DiarizerTimelineUpdate?
LSEENDDiarizer.finalizeSession()
  |
  |-- session.drainRightContextWithSilence()   pad tail with silence so the final chunk completes
  |-- process()                                 one last flush pass
  |-- timeline.finalize()
  |-- return DiarizerTimelineUpdate?

Notes on the current streaming pipeline:

  • One CoreML call commits chunkSize model frames (minus warmup). There is no separate per-frame ingest/decode split.
  • Recurrent state lives on LSEENDInput.state and is swapped in place by LSEENDModel.predict. There is no external state-copy / speculative-preview branch — tentativePredictions is always passed empty, so DiarizerTimelineUpdate.tentativeSegments is currently unused by LS-EEND.
  • The model already emits sigmoided probabilities (probs), and there are no boundary tracks to crop — output speaker count equals maxSpeakers.

File Structure

Sources/FluidAudio/Diarizer/LS-EEND/
├── LSEENDDiarizer.swift       # LSEENDDiarizer (Diarizer protocol impl)
├── LSEENDInference.swift      # LSEENDModel, LSEENDInput
├── LSEENDPreprocessor.swift   # LSEENDFeatureProvider, StreamingChunkQueue
└── LSEENDTypes.swift          # LSEENDMetadata, LSEENDState, LSEENDError, variant typealiases

DER computation lives in the shared Sources/FluidAudio/Diarizer/DiarizationDER.swift (not LS-EEND-specific). RTTM parsing for benchmarks lives in Sources/FluidAudioCLI/Utils/RTTMParser.swift.


LSEENDDiarizer

The primary entry point. Implements the Diarizer protocol — the same API as SortformerDiarizer.

Initialization

Three constructors, picked based on whether you want the model downloaded inline:

// Async convenience: downloads (or reuses cached) model and initializes in one step.
let diarizer = try await LSEENDDiarizer(
    variant: .dihard3,
    stepSize: .step100ms,
    timelineConfig: nil                 // optional DiarizerTimelineConfig
)

// Lazy: build empty, then `initialize(...)` later.
let diarizer = LSEENDDiarizer(timelineConfig: nil)
try await diarizer.initialize(variant: .ami)

// Direct: caller already owns an LSEENDModel.
let model = try await LSEENDModel.loadFromHuggingFace(variant: .callhome)
let diarizer = try LSEENDDiarizer(model: model)

Post-processing knobs (onset/offset thresholds, padding, minimum-on/off durations) now flow through DiarizerTimelineConfig, not initializer parameters:

let config = DiarizerTimelineConfig(onsetThreshold: 0.4, onsetPadFrames: 1)
let diarizer = try await LSEENDDiarizer(variant: .dihard3, timelineConfig: config)

Loading Models

// Async download (or cache hit) + initialize in place.
try await diarizer.initialize(
    variant: .callhome,
    stepSize: .step100ms,
    cacheDirectory: nil,                // optional; defaults to ~/Library/Application Support/FluidAudio/Models
    computeUnits: .cpuOnly,
    progressHandler: { progress in /* ... */ }
)

// Or load the model yourself and hand it in.
let model = try await LSEENDModel.loadFromHuggingFace(variant: .callhome)
try diarizer.initialize(model: model)

Offline Processing

// From a file URL (resamples to the model's target sample rate automatically).
let timeline = try diarizer.processComplete(audioFileURL: audioURL)

// From raw samples (specify the source sample rate if it is not 8 kHz).
let timeline = try diarizer.processComplete(
    samples,
    sourceSampleRate: 16_000,
    keepingEnrolledSpeakers: nil,        // Bool? — nil means "keep if no segments yet"
    finalizeOnCompletion: true,
    progressCallback: { processed, total, chunks in
        print("\(processed)/\(total) samples (\(chunks) chunks)")
    }
)

Streaming

sourceSampleRate is optional — only required when the audio is not already at the model's target rate.

// Push audio incrementally.
try diarizer.addAudio(audioChunk, sourceSampleRate: 16_000)
if let update = try diarizer.process() {
    for segment in update.finalizedSegments { /* ... */ }
}

// Convenience: add + process in one call.
if let update = try diarizer.process(samples: audioChunk, sourceSampleRate: 16_000) {
    /* ... */
}

// Flush remaining frames at end of stream.
try diarizer.finalizeSession()
let finalTimeline = diarizer.timeline

Notes:

  • addAudio and process both throw. process() returns nil when there are not yet enough samples buffered to emit a chunk.
  • finalizeSession() pads the remaining audio with silence so the final partial chunk can complete, then runs one last process() pass and finalizes the timeline. It is @discardableResult.
  • The LS-EEND streaming path does not currently populate update.tentativeSegments — all emitted frames are committed.

Speaker Enrollment

Enrollment warms LS-EEND with a known speaker before the live stream starts. It keeps the active streaming session, resets the visible timeline back to frame 0, and stores the speaker name on the chosen slot inside the DiarizerTimeline.

let speaker = try diarizer.enrollSpeaker(
    withAudio: enrollmentAudio,
    sourceSampleRate: 16_000,
    named: "Alice",
    overwritingAssignedSpeakerName: false
)

// Later complete-buffer runs can keep the enrolled session state.
let timeline = try diarizer.processComplete(
    meetingAudio,
    sourceSampleRate: 16_000,
    keepingEnrolledSpeakers: true
)

Notes:

  • Enrollment is per diarizer instance. Recreate or reset() the diarizer to start a fresh session.
  • Enrollment can help with live identity continuity, but it is still less reliable than the WeSpeaker/Pyannote speaker database.
  • Speaker slots are still chronological. Use overwritingAssignedSpeakerName: false if you want enrollment to fail instead of replacing the name on an already-named slot.

Enrollment Limitations (Integration Feedback)

Real-world integration testing with 4-speaker audio reveals specific enrollment weaknesses compared to Sortformer. The current enrollSpeaker implementation works by playing the enrollment audio through the live session, then picking the slot that accumulated the most speech activity during that audio — preferring slots that did not exist before the enrollment audio was played:

Bounded probabilities: LS-EEND emits sigmoid-of-cosine outputs, so raw per-slot probabilities saturate roughly between sigmoid(-1) and sigmoid(1) (~0.2–0.8). They will not reach the 0.9+ confidence levels external post-processing might suggest. The in-library enrollment path does not surface those scores directly; it works off cumulative speech activity per slot during the enrollment audio.

Close-voice slot rejection: When the enrollment audio is close enough to an already-enrolled speaker that no new slot opens during enrollment, the most-active slot will be an existing one. With overwritingAssignedSpeakerName: false, that causes a hard failure (snapshot rollback, nil return). With the default true, the existing slot gets renamed instead — usable, but not the "fresh registration" the caller likely wanted. In a 4-speaker test, 3 speakers enrolled cleanly; the 4th failed because no new slot emerged.

Root cause: LS-EEND is an end-to-end model, so there is no API for per-slot similarity outputs or explicit slot-lock assignment. If you need a custom enrollment scheme (per-slot scoring, Hungarian assignment, etc.) you have to build it directly on LSEENDModel.predict + LSEENDFeatureProvider. Suppressing existing attractors may be a path forward, but this has not been validated.

Training data gap: Sortformer was trained on a large volume of real-world data, giving it stronger generalization for speaker identity. LS-EEND was trained primarily on simulated data and then fine-tuned on real data — the base model without fine-tuning performs poorly.

Properties

PropertyTypeDescription
timelineDiarizerTimelineAccumulated finalized results
isAvailableBoolWhether the model is loaded
numFramesProcessedIntTotal committed frames processed
targetSampleRateInt?Expected input sample rate (8000)
modelFrameHzDouble?Output frame rate (~10.0 Hz)
numSpeakersInt?Model output slot count (maxSpeakers)

Lifecycle

try diarizer.finalizeSession()   // Flush trailing context before reading final output
diarizer.reset()                 // Reset streaming state for a new audio stream (keeps model loaded)
diarizer.cleanup()               // Release all resources including the loaded model

LSEENDModel

Lower-level model wrapper. Use this when you need direct access to per-chunk predictions, want to manage the feature pipeline yourself, or are building tooling around the model.

Creating the Model

// From HuggingFace (cached after first call).
let model = try await LSEENDModel.loadFromHuggingFace(
    variant: .dihard3,                 // default
    stepSize: .step100ms,              // default
    cacheDirectory: nil,               // optional; defaults to ~/Library/Application Support/FluidAudio/Models
    computeUnits: .cpuOnly,            // .cpuOnly is fastest for this model
    progressHandler: { progress in /* ... */ }
)

// Or load directly from a local mlmodelc.
let model = try LSEENDModel(modelURL: localURL, computeUnits: .cpuOnly)

LSEENDMetadata is decoded from the config value in the CoreML model's creatorDefinedKey user metadata — there is no separate JSON metadata file.

Properties

PropertyTypeDescription
metadataLSEENDMetadataDecoded model configuration

Inference

let probs = try model.predict(from: input)
// probs is flat [Float], row-major, shape (chunkSize - input.warmupFrames) * metadata.maxSpeakers,
// with sigmoid already applied.

predict is internally serialized (an NSLock guards the CoreML call) and updates input.state in place via output backings.


LSEENDInput

MLFeatureProvider that carries the per-chunk inputs and recurrent state for one LSEENDModel.predict call.

let input = try LSEENDInput(from: model.metadata)        // fresh state
let input = try LSEENDInput(from: model.metadata, state: existingState)  // resume from snapshot
PropertyTypeDescription
stateLSEENDStateSix recurrent-state tensors (~Copyable). Updated in place by predict.
melFeaturesMLMultiArray[1, melFrames, nMels], fed to the model's features input
decoderMaskMLMultiArray[chunkSize], fed to the model's valid_mask input
warmupFramesIntNumber of leading output frames to strip (warmup region inside decoderMask)
featureNamesSet<String>The MLFeatureProvider feature set this input exposes
// Reload features into the same input (no allocation on the hot path).
try input.loadInputs(
    melFeatures: newMelBuffer,           // any AccelerateBuffer of Float
    decoderMask: newDecoderMaskBuffer,
    warmupFrames: nil                    // nil => count zeros in newDecoderMaskBuffer
)

input.resetState()                       // zero all recurrent state tensors

LSEENDFeatureProvider

Streaming feature pipeline that fronts LSEENDModel. Handles resampling, STFT, log-mel, cumulative mean normalization, context splicing, and chunk emission. This is the public surface that LSEENDDiarizer.processComplete and process() consume internally — pull it out only if you need a custom integration.

Not thread-safe. The internal lock guards readyChunks only; all mutation paths assume a single caller.

let feeder = try LSEENDFeatureProvider(from: model.metadata)

// Push raw audio. eagerPreprocessing: true (default) runs STFT/log-mel/CMN immediately.
try feeder.enqueueAudio(samples, withSampleRate: 16_000)

// Or push from a file (returns sample count read).
let count = try feeder.enqueueAudioFile(at: audioURL)

// Pad the tail before the final predict pass.
try feeder.drainRightContextWithSilence()

// Pull ready chunks until none remain.
while let input = try feeder.emitNextChunk() {
    let probs = try model.predict(from: input)
    // probs has shape (chunkSize - input.warmupFrames) * metadata.maxSpeakers
}

Methods

MethodReturnsDescription
enqueueAudio(_:withSampleRate:eagerPreprocessing:)VoidPush raw audio, optionally trigger feature extraction immediately
enqueueAudioFile(at:)Int (@discardableResult)Push audio from a file; returns sample count read
drainRightContextWithSilence(flush:)VoidPad the tail with silence so the final chunk completes
emitNextChunk()LSEENDInput?Load the next ready chunk's mel + mask into the persistent input, or nil
takeSnapshot()Snapshot (~Copyable)Snapshot for rollback (e.g. during enrollment)
rollback(to:keepingState:)VoidRestore from a snapshot
reset()VoidClear queues and reset state
PropertyTypeDescription
readyChunksIntNumber of chunks currently ready for emitNextChunk

Data Types

LSEENDMetadata (Codable)

Decoded from the CoreML model's config user metadata. Read via model.metadata.

PropertyTypeDescription
chunkSizeIntOutput frames committed per CoreML call (matches LSEENDStepSize.rawValue)
frameDurationSecondsFloatSeconds per output frame
maxSpeakersIntOutput speaker slot count
sampleRateIntRequired audio sample rate
maxNspksIntNumber of attractors (not output speakers)
hopLengthIntMel hop length
winLengthIntMel window length
nMelsIntNumber of mel bands
contextSizeIntMel context frames per output frame
subsamplingIntMel → prediction subsampling factor
convDelayIntRight-context output frames
nUnits, nHeads, encNLayers, decNLayers, convKernelSizeIntModel architecture parameters
headDim (computed)IntnUnits / nHeads
melFrames (computed)Int(chunkSize - 1) * subsampling + 2 * contextSize + 1
nFFT (computed)IntSmallest power of two >= winLength

LSEENDState (~Copyable)

Holds the six recurrent-state tensors threaded through every CoreML call:

FieldShape (read from metadata)
encRetKv[encNLayers, 1, nHeads, headDim, headDim]
encRetScale[encNLayers, 1]
encConvCache[encNLayers, 1, convKernelSize, nUnits]
cnnWindow[1, nUnits, 2 * convDelay]
decRetKv[decNLayers, maxNspks, nHeads, headDim, headDim]
decRetScale[decNLayers, 1]

Constructors: init(encRetKv:encRetScale:encConvCache:cnnWindow:decRetKv:decRetScale:) for explicit tensors, or init(from metadata:) to allocate ANE-aligned zero buffers. Methods: copy() throws -> LSEENDState, copy(to dst:), reset().

LSEENDFeatureProvider.Snapshot (~Copyable)

Opaque snapshot of feature-provider state for takeSnapshot() / rollback(to:keepingState:).

StreamingChunkQueue

Ring-buffer used internally by LSEENDFeatureProvider for both the raw audio queue and the mel queue. Public so advanced callers can compose their own pipelines; most users will not need it.


Feature Extraction

Feature extraction is handled by LSEENDFeatureProvider. LSEENDDiarizer consumes it transparently; the type is public so callers can wire their own STFT/CMN/queue plumbing on top of LSEENDModel.


Model Loading

LSEENDVariant

public typealias LSEENDVariant = ModelNames.LSEEND.Variant

LSEENDVariant.ami        // "ls_eend_ami"
LSEENDVariant.callhome   // "ls_eend_ch"
LSEENDVariant.dihard2    // "ls_eend_dih2"
LSEENDVariant.dihard3    // "ls_eend_dih3"
MemberDescription
repoModelNames.Repo the variant lives in
nameInternal model name (e.g. "ls_eend_dih3")
descriptionSame as name (CustomStringConvertible)
name(forStep:)"<name>_<step>", e.g. "ls_eend_dih3_100ms"
fileName(forStep:)"<step>/<name>_<step>.mlmodelc"

LSEENDStepSize

public typealias LSEENDStepSize = ModelNames.LSEEND.StepSize

LSEENDStepSize.step100ms   // rawValue 1, "100ms"
LSEENDStepSize.step200ms   // rawValue 2, "200ms"
LSEENDStepSize.step300ms   // rawValue 3, "300ms"
LSEENDStepSize.step400ms   // rawValue 4, "400ms"
LSEENDStepSize.step500ms   // rawValue 5, "500ms"

LSEENDModel.loadFromHuggingFace

public static func loadFromHuggingFace(
    variant: LSEENDVariant = .dihard3,
    stepSize: LSEENDStepSize = .step100ms,
    cacheDirectory: URL? = nil,
    computeUnits: MLComputeUnits = .cpuOnly,
    progressHandler: ProgressHandler? = nil
) async throws -> LSEENDModel

Downloads only the mlmodelc for the requested (variant, stepSize) pair. Cached under cacheDirectory (defaults to ~/Library/Application Support/FluidAudio/Models).


Evaluation

LS-EEND has no dedicated evaluation module. DER computation lives in the shared Sources/FluidAudio/Diarizer/DiarizationDER.swift:

let result = DiarizationDER.compute(
    ref: referenceSegments,            // [DERSpeakerSegment]
    hyp: hypothesisSegments,           // [DERSpeakerSegment]
    frameStep: 0.01,
    collar: 0.0
)
// result.der, result.confusion, result.falseAlarm, result.miss, result.mapping

RTTM parsing for benchmark pipelines lives in Sources/FluidAudioCLI/Utils/RTTMParser.swift.


Error Handling

All LS-EEND errors are thrown as LSEENDError and conform to LocalizedError.

CaseThrown when
.initializationFailed(String)Model load failed, or decoding config from CoreML metadata failed
.inferenceFailed(String)CoreML forward pass failed, or an expected output tensor was missing
.invalidInputSize(String)LSEENDInput.loadInputs rejected a mismatched buffer size
.notInitializedDiarizer method called before a model is loaded
do {
    let timeline = try diarizer.processComplete(audioFileURL: url)
} catch let error as LSEENDError {
    switch error {
    case .notInitialized: print("Call initialize(variant:) before processComplete")
    case .initializationFailed(let message): print("Model problem: \(message)")
    case .inferenceFailed(let message): print("Inference problem: \(message)")
    case .invalidInputSize(let message): print("Input problem: \(message)")
    }
}

Usage Examples

Offline File Processing

let diarizer = try await LSEENDDiarizer(variant: .ami)

let timeline = try diarizer.processComplete(audioFileURL: URL(fileURLWithPath: "meeting.wav"))
for speaker in timeline.speakers.values {
    for segment in speaker.finalizedSegments {
        print("\(segment.speakerLabel): \(segment.startTime)s – \(segment.endTime)s")
    }
}

Streaming from Microphone

let diarizer = try await LSEENDDiarizer(variant: .dihard3)

audioEngine.installTap(onBus: 0, bufferSize: 1600, format: format) { buffer, _ in
    let samples = Array(UnsafeBufferPointer(
        start: buffer.floatChannelData![0], count: Int(buffer.frameLength)))
    do {
        try diarizer.addAudio(samples)
        if let update = try diarizer.process() {
            DispatchQueue.main.async { updateUI(diarizer.timeline) }
        }
    } catch {
        print("LS-EEND streaming error: \(error)")
    }
}

Low-Level Model + Feature Provider

let model = try await LSEENDModel.loadFromHuggingFace(variant: .callhome)
let feeder = try LSEENDFeatureProvider(from: model.metadata)

for chunk in chunkedAudio(samples, chunkSize: 800) {
    try feeder.enqueueAudio(chunk, withSampleRate: 8_000)
    while let input = try feeder.emitNextChunk() {
        let probs = try model.predict(from: input)
        // probs: [Float] row-major, frames * model.metadata.maxSpeakers, sigmoid applied
    }
}

// Flush the tail.
try feeder.drainRightContextWithSilence()
while let input = try feeder.emitNextChunk() {
    let probs = try model.predict(from: input)
    _ = probs
}

CLI

# Diarize a single file (default variant: ami, default step size: 500ms)
swift run fluidaudiocli lseend audio.wav
swift run fluidaudiocli lseend audio.wav --variant callhome
swift run fluidaudiocli lseend audio.wav --variant dihard3 --step-size 100ms --output result.json

# Benchmark on AMI (downloads dataset automatically)
swift run fluidaudiocli lseend-benchmark --auto-download --variant ami
swift run fluidaudiocli lseend-benchmark --variant callhome --threshold 0.35 --collar 0.25
swift run fluidaudiocli lseend-benchmark --variant dihard3 --output results.json --max-files 10

lseend flags

FlagDefaultDescription
--variantamiami | callhome | dihard2 | dihard3
--step-size500ms100ms | 200ms | 300ms | 400ms | 500ms
--threshold0.5Seeds onset + offset thresholds
--onset0.5Overrides --threshold for onset only
--offset0.5Overrides --threshold for offset only
--pad-onsetPadding before speech segments (seconds)
--pad-offsetPadding after speech segments (seconds)
--min-duration-onMinimum speech segment duration (seconds)
--min-duration-offMinimum silence duration (seconds)
--outputPath to save JSON results

lseend-benchmark flags

Accepts all of the lseend flags above, plus:

FlagDefaultDescription
--datasetamiami | voxconverse | callhome
--ami-splittestdev | test | train (only with --dataset ami)
--single-fileProcess a specific meeting (e.g. ES2004a)
--max-filesLimit number of files processed
--median-width1Median filter width in frames (1 = disabled)
--collar0.0 (AMI) / 0.25 (other)Collar around transitions (seconds)
--progress.lseend_progress.jsonResume state path
--resumeoffResume from previous progress file
--verboseoffPrint per-meeting debug output
--auto-downloadoffAuto-download AMI dataset if missing

Model Files on HuggingFace

Hosted at FluidInference/lseend-coreml. Each (variant, stepSize) pair is a separate mlmodelc; metadata is embedded in the CoreML model under the config user-metadata key (no separate JSON file). Files are downloaded automatically on first use and cached at ~/Library/Application Support/FluidAudio/Models/.

The on-disk layout is <step>/ls_eend_<short>_<step>.mlmodelc, where <short> is ami, ch, dih2, or dih3. Example for step 100ms:

VariantFile
.ami100ms/ls_eend_ami_100ms.mlmodelc
.callhome100ms/ls_eend_ch_100ms.mlmodelc
.dihard2100ms/ls_eend_dih2_100ms.mlmodelc
.dihard3100ms/ls_eend_dih3_100ms.mlmodelc

For other step sizes, replace 100ms in both the folder and filename with the desired step (200ms, 300ms, 400ms, or 500ms). The CLI and LSEENDModel.loadFromHuggingFace will download the requested file on first use.


References