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
79 changes: 79 additions & 0 deletions core/config/meta/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,85 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Component: "toggle",
Order: 69,
},
"pipeline.voice_recognition.model": {
Section: "pipeline",
Label: "Voice Recognition Model",
Description: "Speaker-recognition backend model used to gate the pipeline behind speaker verification. Leave empty to disable the voice gate.",
Component: "model-select",
AutocompleteProvider: ProviderModels,
Order: 70,
},
"pipeline.voice_recognition.mode": {
Section: "pipeline",
Label: "Voice Gate Mode",
Description: "How callers are authorized: 'identify' matches the speaker 1:N against the voice registry; 'verify' matches 1:few against the configured reference audios.",
Component: "select",
Options: []FieldOption{
{Value: "identify", Label: "identify (registry)"},
{Value: "verify", Label: "verify (references)"},
},
Order: 71,
},
"pipeline.voice_recognition.threshold": {
Section: "pipeline",
Label: "Voice Gate Threshold",
Description: "Maximum cosine distance between the caller and an authorized speaker that still counts as a match. Lower is stricter. Default 0.25 is tuned for the ECAPA-TDNN encoder on VoxCeleb.",
Component: "slider",
Min: f64(0.01),
Max: f64(2),
Step: f64(0.01),
Order: 72,
},
"pipeline.voice_recognition.when": {
Section: "pipeline",
Label: "Voice Gate When",
Description: "How often to verify the speaker: 'every' checks each utterance; 'first' verifies once and then trusts the session.",
Component: "select",
Options: []FieldOption{
{Value: "every", Label: "every utterance"},
{Value: "first", Label: "first only"},
},
Order: 73,
},
"pipeline.voice_recognition.on_reject": {
Section: "pipeline",
Label: "Voice Gate On Reject",
Description: "What to do with an unauthorized utterance: 'drop_event' drops it and emits an error event to the client; 'drop_silent' drops it quietly.",
Component: "select",
Options: []FieldOption{
{Value: "drop_event", Label: "drop + error event"},
{Value: "drop_silent", Label: "drop silently"},
},
Order: 74,
},
"pipeline.voice_recognition.anti_spoofing": {
Section: "pipeline",
Label: "Voice Gate Anti-Spoofing",
Description: "Enable the backend liveness/anti-spoofing check (verify mode only) to reject replayed or synthesized audio.",
Component: "toggle",
Order: 75,
},
"pipeline.voice_recognition.allow.names": {
Section: "pipeline",
Label: "Voice Gate Allowed Names",
Description: "Identify mode: authorize only registry identities whose name matches one of these exactly. Empty allows any registered identity.",
Component: "string-list",
Order: 76,
},
"pipeline.voice_recognition.allow.labels": {
Section: "pipeline",
Label: "Voice Gate Allowed Labels",
Description: "Identify mode: authorize any registry identity carrying one of these label keys. Empty allows any registered identity.",
Component: "string-list",
Order: 77,
},
"pipeline.voice_recognition.references": {
Section: "pipeline",
Label: "Voice Gate References",
Description: "Verify mode: the authorized reference speakers, each with a name and an audio file path the caller's voice is matched against.",
Component: "json-editor",
Order: 78,
},

// --- Functions ---
"function.grammar.parallel_calls": {
Expand Down
121 changes: 121 additions & 0 deletions core/config/model_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,10 @@ type Pipeline struct {
// to enable_thinking=false backend metadata) without editing the underlying
// LLM model config. Unset leaves the LLM model config in charge.
DisableThinking *bool `yaml:"disable_thinking,omitempty" json:"disable_thinking,omitempty"`

// VoiceRecognition gates the pipeline behind speaker verification. Nil
// (block absent) means no gate, preserving existing behavior.
VoiceRecognition *PipelineVoiceRecognition `yaml:"voice_recognition,omitempty" json:"voice_recognition,omitempty"`
}

// ApplyReasoningEffort resolves the effective reasoning effort — a per-request
Expand Down Expand Up @@ -575,6 +579,123 @@ func (p Pipeline) ThinkingDisabled() bool {
return p.DisableThinking != nil && *p.DisableThinking
}

// Voice-recognition gate enum values.
const (
VoiceGateModeIdentify = "identify"
VoiceGateModeVerify = "verify"
VoiceGateWhenEvery = "every"
VoiceGateWhenFirst = "first"
VoiceGateRejectEvent = "drop_event"
VoiceGateRejectSilent = "drop_silent"

// defaultVoiceGateThreshold is the cosine-distance default tuned for the
// ECAPA-TDNN speaker encoder on VoxCeleb.
defaultVoiceGateThreshold = 0.25
)

// @Description PipelineVoiceRecognition gates a realtime pipeline behind speaker verification.
type PipelineVoiceRecognition struct {
// Model is the speaker-recognition backend model name.
Model string `yaml:"model,omitempty" json:"model,omitempty"`
// Mode is "identify" (1:N against the voice registry) or "verify"
// (1:few against reference audios).
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
// Threshold is the maximum cosine distance that still counts as a match.
Threshold float32 `yaml:"threshold,omitempty" json:"threshold,omitempty"`
// When is "every" (verify each utterance) or "first" (verify once, then
// trust the session).
When string `yaml:"when,omitempty" json:"when,omitempty"`
// OnReject is "drop_event" (drop + emit an error event) or "drop_silent"
// (drop quietly).
OnReject string `yaml:"on_reject,omitempty" json:"on_reject,omitempty"`
// AntiSpoofing enables the backend liveness check (verify mode only).
AntiSpoofing bool `yaml:"anti_spoofing,omitempty" json:"anti_spoofing,omitempty"`
// Allow filters which registry identities are authorized (identify mode).
Allow VoiceRecognitionAllow `yaml:"allow,omitempty" json:"allow,omitempty"`
// References are the authorized reference speakers (verify mode).
References []VoiceReference `yaml:"references,omitempty" json:"references,omitempty"`
}

// @Description VoiceRecognitionAllow filters authorized registry identities.
type VoiceRecognitionAllow struct {
// Names matches registered Metadata.Name exactly.
Names []string `yaml:"names,omitempty" json:"names,omitempty"`
// Labels authorizes any identity carrying a matching label key.
Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"`
}

// @Description VoiceReference is one authorized reference speaker for verify mode.
type VoiceReference struct {
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Audio string `yaml:"audio,omitempty" json:"audio,omitempty"`
}

// VoiceGateEnabled reports whether a voice-recognition gate is configured. The
// mere presence of the block is the intent signal: a present-but-incomplete
// block (e.g. missing model) must fail closed at construction, not be silently
// skipped here.
func (p Pipeline) VoiceGateEnabled() bool {
return p.VoiceRecognition != nil
}

// Normalize fills in defaults in place for omitted fields.
func (v *PipelineVoiceRecognition) Normalize() {
if v.Mode == "" {
v.Mode = VoiceGateModeIdentify
}
if v.When == "" {
v.When = VoiceGateWhenEvery
}
if v.OnReject == "" {
v.OnReject = VoiceGateRejectEvent
}
if v.Threshold == 0 {
v.Threshold = defaultVoiceGateThreshold
}
}

// Validate checks shape and enum values. registryAvailable indicates whether a
// VoiceRegistry exists (required by identify mode). Empty When/OnReject/Mode are
// treated as valid because Normalize defaults them.
func (v PipelineVoiceRecognition) Validate(registryAvailable bool) error {
if v.Model == "" {
return fmt.Errorf("voice_recognition: model is required")
}
switch v.Mode {
case "", VoiceGateModeIdentify:
if !registryAvailable {
return fmt.Errorf("voice_recognition mode 'identify' requires a voice registry")
}
case VoiceGateModeVerify:
if len(v.References) == 0 {
return fmt.Errorf("voice_recognition mode 'verify' requires at least one reference")
}
for i, r := range v.References {
if r.Audio == "" {
return fmt.Errorf("voice_recognition reference %d (%q) is missing an audio path", i, r.Name)
}
}
default:
return fmt.Errorf("voice_recognition: unknown mode %q", v.Mode)
}
switch v.When {
case "", VoiceGateWhenEvery, VoiceGateWhenFirst:
default:
return fmt.Errorf("voice_recognition: unknown when %q", v.When)
}
switch v.OnReject {
case "", VoiceGateRejectEvent, VoiceGateRejectSilent:
default:
return fmt.Errorf("voice_recognition: unknown on_reject %q", v.OnReject)
}
// A zero threshold means "unset" (Normalize defaults it); only validate an
// explicitly-set value. Cosine distance ranges 0..2.
if v.Threshold != 0 && (v.Threshold < 0 || v.Threshold > 2) {
return fmt.Errorf("voice_recognition: threshold %v out of range (0..2)", v.Threshold)
}
return nil
}

// @Description File configuration for model downloads
type File struct {
Filename string `yaml:"filename,omitempty" json:"filename,omitempty"`
Expand Down
73 changes: 73 additions & 0 deletions core/config/voice_gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package config

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("PipelineVoiceRecognition", func() {
Describe("Normalize", func() {
It("fills defaults for empty fields", func() {
v := PipelineVoiceRecognition{Model: "spk"}
v.Normalize()
Expect(v.Mode).To(Equal(VoiceGateModeIdentify))
Expect(v.When).To(Equal(VoiceGateWhenEvery))
Expect(v.OnReject).To(Equal(VoiceGateRejectEvent))
Expect(v.Threshold).To(BeNumerically("~", defaultVoiceGateThreshold, 1e-6))
})
It("keeps explicit values", func() {
v := PipelineVoiceRecognition{Model: "spk", Mode: VoiceGateModeVerify, When: VoiceGateWhenFirst, OnReject: VoiceGateRejectSilent, Threshold: 0.4}
v.Normalize()
Expect(v.Mode).To(Equal(VoiceGateModeVerify))
Expect(v.When).To(Equal(VoiceGateWhenFirst))
Expect(v.OnReject).To(Equal(VoiceGateRejectSilent))
Expect(v.Threshold).To(BeNumerically("~", 0.4, 1e-6))
})
})

Describe("Validate", func() {
It("requires a registry for identify mode", func() {
v := PipelineVoiceRecognition{Model: "spk", Mode: VoiceGateModeIdentify}
Expect(v.Validate(false)).To(HaveOccurred())
Expect(v.Validate(true)).ToNot(HaveOccurred())
})
It("requires references for verify mode", func() {
v := PipelineVoiceRecognition{Model: "spk", Mode: VoiceGateModeVerify}
Expect(v.Validate(false)).To(HaveOccurred())
v.References = []VoiceReference{{Name: "a", Audio: "/a.wav"}}
Expect(v.Validate(false)).ToNot(HaveOccurred())
})
It("rejects a reference with no audio path", func() {
v := PipelineVoiceRecognition{Model: "spk", Mode: VoiceGateModeVerify, References: []VoiceReference{{Name: "a"}}}
Expect(v.Validate(false)).To(HaveOccurred())
})
It("rejects unknown enum values", func() {
Expect((PipelineVoiceRecognition{Model: "spk", Mode: "bogus"}).Validate(true)).To(HaveOccurred())
Expect((PipelineVoiceRecognition{Model: "spk", Mode: VoiceGateModeIdentify, When: "bogus"}).Validate(true)).To(HaveOccurred())
Expect((PipelineVoiceRecognition{Model: "spk", Mode: VoiceGateModeIdentify, OnReject: "bogus"}).Validate(true)).To(HaveOccurred())
})
It("accepts a zero (unset) threshold", func() {
v := PipelineVoiceRecognition{Model: "spk", Mode: VoiceGateModeIdentify, Threshold: 0}
Expect(v.Validate(true)).ToNot(HaveOccurred())
})
It("rejects an out-of-range threshold", func() {
Expect((PipelineVoiceRecognition{Model: "spk", Mode: VoiceGateModeIdentify, Threshold: 5}).Validate(true)).To(HaveOccurred())
Expect((PipelineVoiceRecognition{Model: "spk", Mode: VoiceGateModeIdentify, Threshold: -1}).Validate(true)).To(HaveOccurred())
})
It("rejects an empty model", func() {
Expect((PipelineVoiceRecognition{Mode: VoiceGateModeIdentify}).Validate(true)).To(HaveOccurred())
})
})

Describe("VoiceGateEnabled", func() {
It("is false when block absent", func() {
Expect((Pipeline{}).VoiceGateEnabled()).To(BeFalse())
})
It("is true when a model is set", func() {
Expect((Pipeline{VoiceRecognition: &PipelineVoiceRecognition{Model: "spk"}}).VoiceGateEnabled()).To(BeTrue())
})
It("is true when the block is present even without a model (fails closed downstream)", func() {
Expect((Pipeline{VoiceRecognition: &PipelineVoiceRecognition{}}).VoiceGateEnabled()).To(BeTrue())
})
})
})
Loading
Loading