use derive_more::IsVariant;
use mediatime::Timestamp;
use smol_str::SmolStr;
use crate::domain::{vo::VoiceFingerprint, Uuid7};
#[inline]
const fn is_negative_duration(d: Option<Timestamp>) -> bool {
match d {
None => false,
Some(ts) => ts.pts() < 0,
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Speaker<Id = Uuid7> {
id: Id,
audio_track_id: Id,
cluster_id: u32,
name: SmolStr,
speech_duration: Option<Timestamp>,
voiceprint: Option<VoiceFingerprint<Id>>,
person_id: Option<Id>,
}
impl Speaker<Uuid7> {
pub fn try_new(
id: Uuid7,
audio_track_id: Uuid7,
cluster_id: u32,
name: impl Into<SmolStr>,
) -> Result<Self, SpeakerError> {
if id.is_nil() {
return Err(SpeakerError::NilId);
}
if audio_track_id.is_nil() {
return Err(SpeakerError::NilAudioTrackId);
}
Ok(Self {
id,
audio_track_id,
cluster_id,
name: name.into(),
speech_duration: None,
voiceprint: None,
person_id: None,
})
}
}
impl<Id> Speaker<Id> {
#[inline(always)]
pub const fn id_ref(&self) -> &Id {
&self.id
}
#[inline(always)]
pub const fn audio_track_id_ref(&self) -> &Id {
&self.audio_track_id
}
#[inline(always)]
pub const fn cluster_id(&self) -> u32 {
self.cluster_id
}
#[inline(always)]
pub fn name(&self) -> &str {
self.name.as_str()
}
#[inline(always)]
pub const fn speech_duration_ref(&self) -> Option<&Timestamp> {
self.speech_duration.as_ref()
}
#[inline(always)]
pub const fn voiceprint_ref(&self) -> Option<&VoiceFingerprint<Id>> {
self.voiceprint.as_ref()
}
#[inline(always)]
pub const fn person_id_ref(&self) -> Option<&Id> {
self.person_id.as_ref()
}
#[inline(always)]
#[must_use]
pub fn with_name(mut self, name: impl Into<SmolStr>) -> Self {
self.name = name.into();
self
}
#[inline]
pub fn try_with_speech_duration(mut self, d: Option<Timestamp>) -> Result<Self, SpeakerError> {
if is_negative_duration(d) {
return Err(SpeakerError::NegativeSpeechDuration);
}
self.speech_duration = d;
Ok(self)
}
#[inline(always)]
#[must_use]
pub const fn with_cluster_id(mut self, cluster_id: u32) -> Self {
self.cluster_id = cluster_id;
self
}
#[inline(always)]
pub fn set_name(&mut self, name: impl Into<SmolStr>) -> &mut Self {
self.name = name.into();
self
}
#[inline]
pub const fn try_set_speech_duration(
&mut self,
d: Option<Timestamp>,
) -> Result<&mut Self, SpeakerError> {
if is_negative_duration(d) {
return Err(SpeakerError::NegativeSpeechDuration);
}
self.speech_duration = d;
Ok(self)
}
#[inline(always)]
pub const fn set_cluster_id(&mut self, cluster_id: u32) -> &mut Self {
self.cluster_id = cluster_id;
self
}
#[inline(always)]
#[must_use]
pub fn with_voiceprint(mut self, voiceprint: VoiceFingerprint<Id>) -> Self {
self.voiceprint = Some(voiceprint);
self
}
#[inline(always)]
#[must_use]
pub fn maybe_voiceprint(mut self, voiceprint: Option<VoiceFingerprint<Id>>) -> Self {
self.voiceprint = voiceprint;
self
}
#[inline(always)]
pub fn set_voiceprint(&mut self, voiceprint: VoiceFingerprint<Id>) -> &mut Self {
self.voiceprint = Some(voiceprint);
self
}
#[inline(always)]
pub fn update_voiceprint(&mut self, voiceprint: Option<VoiceFingerprint<Id>>) -> &mut Self {
self.voiceprint = voiceprint;
self
}
#[inline(always)]
pub fn clear_voiceprint(&mut self) -> &mut Self {
self.voiceprint = None;
self
}
#[inline(always)]
#[must_use]
pub fn with_person_id(mut self, person_id: Id) -> Self {
self.person_id = Some(person_id);
self
}
#[inline(always)]
#[must_use]
pub fn maybe_person_id(mut self, person_id: Option<Id>) -> Self {
self.person_id = person_id;
self
}
#[inline(always)]
pub fn set_person_id(&mut self, person_id: Id) -> &mut Self {
self.person_id = Some(person_id);
self
}
#[inline(always)]
pub fn update_person_id(&mut self, person_id: Option<Id>) -> &mut Self {
self.person_id = person_id;
self
}
#[inline(always)]
pub fn clear_person_id(&mut self) -> &mut Self {
self.person_id = None;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, IsVariant, thiserror::Error)]
#[non_exhaustive]
pub enum SpeakerError {
#[error("Speaker id must not be the nil UUID")]
NilId,
#[error("Speaker `audio_track_id` (FK → AudioTrack) must not be the nil UUID")]
NilAudioTrackId,
#[error("Speaker speech_duration must not be negative")]
NegativeSpeechDuration,
}
#[cfg(all(test, feature = "std"))]
mod tests {
use super::*;
#[test]
fn try_new_happy_path() {
let audio_track_id = Uuid7::new();
let s = Speaker::try_new(Uuid7::new(), audio_track_id, 2, "Jane")
.expect("valid construction must succeed");
assert_eq!(s.audio_track_id_ref(), &audio_track_id);
assert_eq!(s.cluster_id(), 2);
assert_eq!(s.name(), "Jane");
assert!(s.speech_duration_ref().is_none());
}
#[test]
fn try_new_anonymous_diarization_uses_empty_name() {
let s = Speaker::try_new(Uuid7::new(), Uuid7::new(), 0, "").unwrap();
assert!(s.name().is_empty());
assert_eq!(s.cluster_id(), 0);
}
#[test]
fn try_new_rejects_nil_id() {
let r = Speaker::try_new(Uuid7::nil(), Uuid7::new(), 0, "");
assert_eq!(r.err(), Some(SpeakerError::NilId));
assert!(SpeakerError::NilId.is_nil_id());
}
#[test]
fn try_new_rejects_nil_audio_track_id() {
let r = Speaker::try_new(Uuid7::new(), Uuid7::nil(), 0, "");
assert_eq!(r.err(), Some(SpeakerError::NilAudioTrackId));
assert!(SpeakerError::NilAudioTrackId.is_nil_audio_track_id());
}
#[test]
fn builders_and_setters_chain() {
let s = Speaker::try_new(Uuid7::new(), Uuid7::new(), 0, "")
.unwrap()
.with_name("Jane")
.with_cluster_id(3);
assert_eq!(s.name(), "Jane");
assert_eq!(s.cluster_id(), 3);
let mut s = s;
s.set_name("");
s.set_cluster_id(0);
assert!(s.name().is_empty());
assert_eq!(s.cluster_id(), 0);
}
fn tb() -> mediatime::Timebase {
mediatime::Timebase::new(1, core::num::NonZeroU32::new(1000).expect("nonzero"))
}
#[test]
fn try_with_speech_duration_rejects_negative() {
let s = Speaker::try_new(Uuid7::new(), Uuid7::new(), 0, "Jane").unwrap();
let neg = Timestamp::new(-1, tb());
assert_eq!(
s.clone().try_with_speech_duration(Some(neg)).err(),
Some(SpeakerError::NegativeSpeechDuration)
);
assert!(SpeakerError::NegativeSpeechDuration.is_negative_speech_duration());
}
#[test]
fn try_with_speech_duration_accepts_zero_positive_and_none() {
let s = Speaker::try_new(Uuid7::new(), Uuid7::new(), 0, "Jane").unwrap();
let z = s
.clone()
.try_with_speech_duration(Some(Timestamp::new(0, tb())))
.expect("zero duration accepted");
assert_eq!(z.speech_duration_ref().unwrap().pts(), 0);
let p = s
.clone()
.try_with_speech_duration(Some(Timestamp::new(5000, tb())))
.expect("positive duration accepted");
assert_eq!(p.speech_duration_ref().unwrap().pts(), 5000);
let n = s.try_with_speech_duration(None).expect("None accepted");
assert!(n.speech_duration_ref().is_none());
}
fn vfp() -> VoiceFingerprint<Uuid7> {
use crate::domain::vo::Provenance;
VoiceFingerprint::try_new(
Uuid7::new(),
192,
jiff::Timestamp::from_millisecond(1_700_000_000_000).expect("valid ts"),
Some(0.9),
Provenance::from_parts("ecapa-tdnn", "v1.0.0", "", "findit-indexer-0.1.0"),
)
.expect("valid voiceprint")
}
#[test]
fn voiceprint_and_person_default_to_none() {
let s = Speaker::try_new(Uuid7::new(), Uuid7::new(), 0, "").unwrap();
assert!(s.voiceprint_ref().is_none());
assert!(s.person_id_ref().is_none());
}
#[test]
fn voiceprint_full_option_mutator_vocabulary() {
let v = vfp();
let s = Speaker::try_new(Uuid7::new(), Uuid7::new(), 0, "")
.unwrap()
.with_voiceprint(v.clone());
assert_eq!(s.voiceprint_ref(), Some(&v));
let s = s.maybe_voiceprint(None);
assert!(s.voiceprint_ref().is_none());
let s = s.maybe_voiceprint(Some(v.clone()));
assert_eq!(s.voiceprint_ref(), Some(&v));
let mut s = s;
s.clear_voiceprint();
assert!(s.voiceprint_ref().is_none());
s.set_voiceprint(v.clone());
assert_eq!(s.voiceprint_ref(), Some(&v));
s.update_voiceprint(None);
assert!(s.voiceprint_ref().is_none());
s.update_voiceprint(Some(v.clone()));
assert_eq!(s.voiceprint_ref(), Some(&v));
}
#[test]
fn person_full_option_mutator_vocabulary() {
let pid = Uuid7::new();
let s = Speaker::try_new(Uuid7::new(), Uuid7::new(), 0, "")
.unwrap()
.with_person_id(pid);
assert_eq!(s.person_id_ref(), Some(&pid));
let s = s.maybe_person_id(None);
assert!(s.person_id_ref().is_none());
let s = s.maybe_person_id(Some(pid));
assert_eq!(s.person_id_ref(), Some(&pid));
let mut s = s;
s.clear_person_id();
assert!(s.person_id_ref().is_none());
s.set_person_id(pid);
assert_eq!(s.person_id_ref(), Some(&pid));
s.update_person_id(None);
assert!(s.person_id_ref().is_none());
s.update_person_id(Some(pid));
assert_eq!(s.person_id_ref(), Some(&pid));
}
#[test]
fn try_set_speech_duration_rejects_and_leaves_value_unchanged() {
let mut s = Speaker::try_new(Uuid7::new(), Uuid7::new(), 0, "Jane").unwrap();
s.try_set_speech_duration(Some(Timestamp::new(3000, tb())))
.unwrap();
assert_eq!(
s.try_set_speech_duration(Some(Timestamp::new(-10, tb())))
.err(),
Some(SpeakerError::NegativeSpeechDuration)
);
assert_eq!(s.speech_duration_ref().unwrap().pts(), 3000);
s.try_set_speech_duration(Some(Timestamp::new(0, tb())))
.unwrap();
assert_eq!(s.speech_duration_ref().unwrap().pts(), 0);
}
}