Skip to content

Offline diarizer: auto clustering and numSpeakers=2 return the same speaker count but different partitions (69% vs 92% correctly-attributed speech) #801

Description

@kasuki511

Summary

On a real 2-speaker conversation, OfflineDiarizerManager with .default config returns the correct speaker count (2) but a wrong partition. Pinning clustering.numSpeakers = 2 returns the same count with a materially different and correct partition.

Same N, different answer. That asymmetry is the core of this report: if auto already estimates 2, I would expect it to then partition as if N=2.

Scored against a pyannote speaker-diarization-community-1 reference on identical bytes (10ms frames, optimal label permutation, overlap-forgiven):

config speakers speech correctly attributed confused
.default (auto) 2 69.0% 25.07s
clustering.numSpeakers = 2 2 92.0% 6.09s

Detail

Audio: 86.5s, 16kHz mono, two speakers, a balanced 63/37 split by speech time. Speaker B holds 0.0-7.0 and then 62.0-86.9; speaker A holds the middle.

  • auto partitions at 82.0s: A 0.0-82.0, B 82.0-86.5. Speaker B's 25-second closing stretch is absorbed into A.
  • numSpeakers=2 partitions at 62.0s: 0-62 / 62-86.5, which matches the reference except for the 0-7 opening turn.
  • The 62.0 boundary is genuinely found by both engines independently, so segmentation is proposing it; the auto path just does not partition on it.
  • Deterministic: 3 identical runs, byte-identical output.

Other observations from a 20+ config sweep

  1. Clustering.threshold appears inverted relative to its docstring. It is documented as a "Euclidean distance threshold for unit-normalized embeddings", so lowering it should split more. Observed: 0.45, 0.4, 0.3 all merge to 1 speaker, while 0.5, 0.6 (stock), 0.7, 0.9, 1.1 are byte-identical to each other. Also numSpeakers=2 + threshold=0.5 reverts to the bad 69% partition, i.e. setting threshold to a value that is otherwise a no-op cancels the numSpeakers win. Is the direction intended, and is it meant to interact with numSpeakers this way?
  2. minSpeakers=2 alone is a no-op here (byte-identical to stock), which makes sense since auto already returns 2, so the floor never binds. minSpeakers=2, maxSpeakers=2 is byte-identical to numSpeakers=2. Worth a docs note: only an exact count (or min+max together) changes the partition; the floor alone cannot correct an under-partition.
  3. numSpeakers behaves as a target, not a constraint. On a separate 15.8s clip, numSpeakers=4 returned 3 and numSpeakers=3 returned 2. That seems reasonable, but it is not obvious from the API surface, and it does mean callers cannot rely on the requested count. A docs note would help.
  4. ZeroVoteReembed(enabled: true) at minDurationSeconds 0.4 / 0.25 / 0.1 is byte-identical to stock on this audio (with and without numSpeakers), so it does not address this case. Its doc comment describes a similar-sounding symptom ("silently absorbing whole speaker turns into the surrounding speaker's segment"), which is what led me to try it.
  5. Embedding.minSegmentDurationSeconds = 0.25 (vs stock 1.0) is also byte-identical here.

Environment

  • FluidAudio 0.15.5 (exact pin), models FluidInference/speaker-diarization-coreml.
  • Reproduced on macOS 26.5 (arm64, Swift 6.3) via a small harness calling OfflineDiarizerManager directly, and observed in an app on iPadOS (iPad Pro M2).
  • One caveat I want to be upfront about: on this clip the macOS harness reports 2 speakers where the iPad ANE reports 1. The device is worse. I have not explained that gap; it may be marginal numerical differences on a borderline cluster. On a 4-speaker control clip, harness and device agree exactly (4 vs 4).

What I can and cannot share

The audio is a private personal recording, so I cannot attach it. I am happy to:

  • run any config or patch you suggest against it and report numbers,
  • try to reproduce on a public corpus if you can point me at a case with a similar shape (one speaker opening briefly, then returning for a long closing stretch).

I also want to flag, in case it saves someone time: I initially "confirmed" this was unfixable by scoring configs against a truncated reference (I had capped the reference turn list, which hid the closing 25 seconds). That produced a confident and completely wrong conclusion until I uncapped it. Given #752 turned out to be placeholder ground truth in the benchmark, corrupted references seem to be an easy trap in this problem space.

Questions

  1. Is auto-vs-numSpeakers=N producing different partitions at the same N expected behaviour, or a bug in the count-estimation / VBx warm-start path?
  2. Is threshold's observed direction (lower = fewer clusters) intended?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions