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
49 changes: 49 additions & 0 deletions core/config/meta/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,55 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Component: "json-editor",
Order: 78,
},
"pipeline.voice_recognition.enforce": {
Section: "pipeline",
Label: "Voice Gate Enforce",
Description: "Whether the gate rejects unauthorized speakers. Enabled (default) drops unauthorized utterances before the LLM. Disabled still resolves and surfaces the speaker (for the conversation.item.speaker event and personalization) but never drops a turn.",
Component: "toggle",
Order: 80,
},
"pipeline.voice_recognition.identity.announce": {
Section: "pipeline",
Label: "Speaker Identity Announce",
Description: "Emit a conversation.item.speaker event to the client naming the recognized speaker. When set, identity is resolved on every turn even if 'when' is 'first'.",
Component: "toggle",
Order: 81,
},
"pipeline.voice_recognition.identity.announce_unknown": {
Section: "pipeline",
Label: "Speaker Identity Announce Unknown",
Description: "Also emit the conversation.item.speaker event (with matched=false) when no confident match is found. Default only announces on a match.",
Component: "toggle",
Order: 82,
},
"pipeline.voice_recognition.identity.personalize": {
Section: "pipeline",
Label: "Speaker Identity Personalize",
Description: "Inform the LLM who is speaking so it can tailor replies. Enables the name and system-note injection below.",
Component: "toggle",
Order: 83,
},
"pipeline.voice_recognition.identity.inject_name": {
Section: "pipeline",
Label: "Speaker Identity Inject Name",
Description: "Personalization: set the per-message OpenAI 'name' field on each user turn to the recognized speaker.",
Component: "toggle",
Order: 84,
},
"pipeline.voice_recognition.identity.inject_system_note": {
Section: "pipeline",
Label: "Speaker Identity Inject System Note",
Description: "Personalization: append a 'The current speaker is <name>.' note to the system message reflecting the latest speaker.",
Component: "toggle",
Order: 85,
},
"pipeline.voice_recognition.identity.note_unknown": {
Section: "pipeline",
Label: "Speaker Identity Note Unknown",
Description: "Personalization: when the speaker is unidentified, append 'The current speaker is unknown.' to the system message so the model can ask who it is talking to.",
Component: "toggle",
Order: 86,
},
"pipeline.max_history_items": {
Section: "pipeline",
Label: "Max History Items",
Expand Down
48 changes: 48 additions & 0 deletions core/config/model_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,13 @@ type PipelineVoiceRecognition struct {
Allow VoiceRecognitionAllow `yaml:"allow,omitempty" json:"allow,omitempty"`
// References are the authorized reference speakers (verify mode).
References []VoiceReference `yaml:"references,omitempty" json:"references,omitempty"`
// Enforce controls the authorization gate. A nil value or true rejects
// unauthorized speakers (the historical behavior). false resolves the
// speaker's identity for surfacing/personalization but never drops a turn.
Enforce *bool `yaml:"enforce,omitempty" json:"enforce,omitempty"`
// Identity surfaces the recognized speaker to the client and the LLM. It is
// independent of Enforce: identity can be surfaced without gating.
Identity *VoiceIdentityConfig `yaml:"identity,omitempty" json:"identity,omitempty"`
}

// @Description VoiceRecognitionAllow filters authorized registry identities.
Expand All @@ -785,6 +792,25 @@ type VoiceReference struct {
Audio string `yaml:"audio,omitempty" json:"audio,omitempty"`
}

// @Description VoiceIdentityConfig surfaces the recognized speaker to the realtime
// client and the LLM. When set, identity is resolved on every turn even if the
// gate's When is "first" (the gate still authorizes only once).
type VoiceIdentityConfig struct {
// Announce emits a conversation.item.speaker event to the client.
Announce bool `yaml:"announce,omitempty" json:"announce,omitempty"`
// AnnounceUnknown also emits the event when there is no confident match.
AnnounceUnknown bool `yaml:"announce_unknown,omitempty" json:"announce_unknown,omitempty"`
// Personalize informs the LLM who is speaking.
Personalize bool `yaml:"personalize,omitempty" json:"personalize,omitempty"`
// InjectName sets the per-message name field on each user turn.
InjectName bool `yaml:"inject_name,omitempty" json:"inject_name,omitempty"`
// InjectSystemNote maintains a "current speaker" note in the system message.
InjectSystemNote bool `yaml:"inject_system_note,omitempty" json:"inject_system_note,omitempty"`
// NoteUnknown adds a "the current speaker is unknown" note (enables the model
// to ask who it is talking to).
NoteUnknown bool `yaml:"note_unknown,omitempty" json:"note_unknown,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
Expand All @@ -793,6 +819,28 @@ func (p Pipeline) VoiceGateEnabled() bool {
return p.VoiceRecognition != nil
}

// EnforceGate reports whether the gate rejects unauthorized speakers. A nil
// Enforce means "enforce" so existing configs keep gating.
func (p PipelineVoiceRecognition) EnforceGate() bool {
return p.Enforce == nil || *p.Enforce
}

// IdentityEnabled reports whether the speaker's identity must be resolved for
// surfacing or personalization.
func (p PipelineVoiceRecognition) IdentityEnabled() bool {
return p.Identity != nil && (p.Identity.Announce || p.Identity.Personalize)
}

// AnnounceEnabled reports whether to emit the conversation.item.speaker event.
func (p PipelineVoiceRecognition) AnnounceEnabled() bool {
return p.Identity != nil && p.Identity.Announce
}

// PersonalizeEnabled reports whether to inform the LLM of the speaker.
func (p PipelineVoiceRecognition) PersonalizeEnabled() bool {
return p.Identity != nil && p.Identity.Personalize
}

// Normalize fills in defaults in place for omitted fields.
func (v *PipelineVoiceRecognition) Normalize() {
if v.Mode == "" {
Expand Down
28 changes: 28 additions & 0 deletions core/config/voice_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,32 @@ var _ = Describe("PipelineVoiceRecognition", func() {
Expect((Pipeline{VoiceRecognition: &PipelineVoiceRecognition{}}).VoiceGateEnabled()).To(BeTrue())
})
})

Describe("Enforce / Identity helpers", func() {
It("treats a nil Enforce as enforcing (backward compatible)", func() {
v := PipelineVoiceRecognition{Model: "spk"}
Expect(v.EnforceGate()).To(BeTrue())
})
It("honors an explicit enforce:false", func() {
off := false
v := PipelineVoiceRecognition{Model: "spk", Enforce: &off}
Expect(v.EnforceGate()).To(BeFalse())
})
It("reports identity disabled when no identity block is set", func() {
v := PipelineVoiceRecognition{Model: "spk"}
Expect(v.IdentityEnabled()).To(BeFalse())
Expect(v.AnnounceEnabled()).To(BeFalse())
Expect(v.PersonalizeEnabled()).To(BeFalse())
})
It("reports identity enabled when announce or personalize is on", func() {
v := PipelineVoiceRecognition{Model: "spk", Identity: &VoiceIdentityConfig{Announce: true}}
Expect(v.IdentityEnabled()).To(BeTrue())
Expect(v.AnnounceEnabled()).To(BeTrue())
Expect(v.PersonalizeEnabled()).To(BeFalse())

v2 := PipelineVoiceRecognition{Model: "spk", Identity: &VoiceIdentityConfig{Personalize: true}}
Expect(v2.IdentityEnabled()).To(BeTrue())
Expect(v2.PersonalizeEnabled()).To(BeTrue())
})
})
})
157 changes: 108 additions & 49 deletions core/http/endpoints/openai/realtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -1311,28 +1311,32 @@ func commitUtterance(ctx context.Context, utt []byte, session *Session, conv *Co
// turn wastes only transcription compute, which has no side effects. The
// transcript is still emitted to the same peer that sent the audio, which
// reveals nothing new to them.
type gateOutcome struct {
allowed bool
matched string
reason string
err error
}
var gateCh chan gateOutcome
runGate := false
// Resolve the speaker when the gate must authorize this turn, or when identity
// surfacing/personalization needs a fresh identity. Identity resolution
// ignores the when:first short-circuit (that only skips re-authorization).
type resolveOutcome struct {
res resolution
err error
}
var resolveCh chan resolveOutcome
runResolve := false
if session.voiceGate != nil && session.InputAudioTranscription != nil {
skip := false
if session.voiceGate.cfg.When == config.VoiceGateWhenFirst {
enforce := session.voiceGate.cfg.EnforceGate()
gateNeedsAuth := enforce
if enforce && session.voiceGate.cfg.When == config.VoiceGateWhenFirst {
session.gateMu.Lock()
skip = session.voiceVerified
if session.voiceVerified {
gateNeedsAuth = false
}
session.gateMu.Unlock()
}
if !skip {
runGate = true
gateCh = make(chan gateOutcome, 1)
if gateNeedsAuth || session.voiceGate.cfg.IdentityEnabled() {
runResolve = true
resolveCh = make(chan resolveOutcome, 1)
wavPath := f.Name()
go func() {
allowed, matched, reason, gerr := session.voiceGate.Authorize(ctx, wavPath)
gateCh <- gateOutcome{allowed: allowed, matched: matched, reason: reason, err: gerr}
r, rerr := session.voiceGate.Resolve(ctx, wavPath)
resolveCh <- resolveOutcome{res: r, err: rerr}
}()
}
}
Expand All @@ -1348,8 +1352,8 @@ func commitUtterance(ctx context.Context, utt []byte, session *Session, conv *Co
if err != nil {
// Drain the gate goroutine before returning so its in-flight read of
// the temp WAV finishes before the deferred os.Remove fires.
if runGate {
<-gateCh
if runResolve {
<-resolveCh
}
sendError(t, "transcription_failed", err.Error(), "", "event_TODO")
return
Expand All @@ -1361,41 +1365,58 @@ func commitUtterance(ctx context.Context, utt []byte, session *Session, conv *Co
return
}

// Join on the gate before any side-effecting step.
if runGate {
out := <-gateCh
allowed := out.allowed
reason := out.reason
// Join on the resolution before any side-effecting step.
var speaker *types.Speaker
if runResolve {
out := <-resolveCh
enforce := session.voiceGate.cfg.EnforceGate()

if out.err != nil {
// Fail closed: a gate that cannot decide must not let audio through.
xlog.Error("voice recognition gate error", "error", out.err)
allowed = false
reason = "verification error"
}
alreadyVerified := false
if session.voiceGate.cfg.When == config.VoiceGateWhenFirst {
session.gateMu.Lock()
alreadyVerified = session.voiceVerified
session.gateMu.Unlock()
}
proceed, markVerified := session.voiceGate.decide(alreadyVerified, allowed)
if !proceed {
xlog.Debug("voice recognition gate rejected utterance", "reason", reason)
if session.voiceGate.cfg.OnReject == config.VoiceGateRejectEvent {
sendError(t, "speaker_not_authorized", "speaker not authorized: "+reason, "", "event_TODO")
if enforce {
// Fail closed: a gate that cannot decide must not let audio through.
xlog.Error("voice recognition gate error", "error", out.err)
if session.voiceGate.cfg.OnReject == config.VoiceGateRejectEvent {
sendError(t, "speaker_not_authorized", "speaker not authorized: verification error", "", "event_TODO")
}
return
}
return
// Non-enforcing: degrade to an unknown speaker and continue.
xlog.Warn("voice identity resolve failed; continuing as unknown speaker", "error", out.err)
} else {
s := out.res.speaker
speaker = &s
}
xlog.Debug("voice recognition gate authorized utterance", "speaker", out.matched)
if markVerified {
session.gateMu.Lock()
session.voiceVerified = true
session.gateMu.Unlock()

if enforce {
alreadyVerified := false
if session.voiceGate.cfg.When == config.VoiceGateWhenFirst {
session.gateMu.Lock()
alreadyVerified = session.voiceVerified
session.gateMu.Unlock()
}
allowed, reason := false, "verification error"
if out.err == nil {
allowed, reason = session.voiceGate.authorize(out.res)
}
proceed, markVerified := session.voiceGate.decide(alreadyVerified, allowed)
if !proceed {
xlog.Debug("voice recognition gate rejected utterance", "reason", reason)
if session.voiceGate.cfg.OnReject == config.VoiceGateRejectEvent {
sendError(t, "speaker_not_authorized", "speaker not authorized: "+reason, "", "event_TODO")
}
return
}
if markVerified {
session.gateMu.Lock()
session.voiceVerified = true
session.gateMu.Unlock()
}
xlog.Debug("voice recognition gate authorized utterance", "speaker", out.res.speaker.Name)
}
}

if !session.TranscriptionOnly {
generateResponse(ctx, session, utt, transcript, conv, t)
generateResponse(ctx, session, utt, transcript, speaker, conv, t)
}
}

Expand All @@ -1419,15 +1440,28 @@ func runVAD(ctx context.Context, session *Session, adata []int16) ([]schema.VADS
return resp.Segments, nil
}

// speakerNote renders the system-prompt note for the current speaker. Returns
// an empty string when there is no name and unknown notes are disabled.
func speakerNote(s *types.Speaker, noteUnknown bool) string {
if s != nil && s.Matched && s.Name != "" {
return "The current speaker is " + s.Name + "."
}
if noteUnknown {
return "The current speaker is unknown."
}
return ""
}

// Function to generate a response based on the conversation
func generateResponse(ctx context.Context, session *Session, utt []byte, transcript string, conv *Conversation, t Transport) {
func generateResponse(ctx context.Context, session *Session, utt []byte, transcript string, speaker *types.Speaker, conv *Conversation, t Transport) {
xlog.Debug("Generating realtime response...")

// Create user message item
item := types.MessageItemUnion{
User: &types.MessageItemUser{
ID: generateItemID(),
Status: types.ItemStatusCompleted,
ID: generateItemID(),
Status: types.ItemStatusCompleted,
Speaker: speaker,
Content: []types.MessageContentInput{
{
Type: types.MessageContentTypeInputAudio,
Expand All @@ -1445,6 +1479,17 @@ func generateResponse(ctx context.Context, session *Session, utt []byte, transcr
Item: item,
})

// Surface the recognized speaker to the client. Skip the event for an
// unidentified speaker unless announce_unknown is set.
if speaker != nil && session.voiceGate != nil && session.voiceGate.cfg.AnnounceEnabled() {
if speaker.Matched || session.voiceGate.cfg.Identity.AnnounceUnknown {
sendEvent(t, types.ConversationItemSpeakerEvent{
ItemID: item.User.ID,
Speaker: *speaker,
})
}
}

triggerResponse(ctx, session, conv, t, nil)
}

Expand Down Expand Up @@ -1508,13 +1553,20 @@ func triggerResponseAtTurn(ctx context.Context, session *Session, conv *Conversa
})

imgIndex := 0
var lastUserSpeaker *types.Speaker
personalize := session.voiceGate != nil && session.voiceGate.cfg.PersonalizeEnabled()
conv.Lock.Lock()
items := trimRealtimeItems(conv.Items, session.MaxHistoryItems)
for _, item := range items {
if item.User != nil {
msg := schema.Message{
Role: string(types.MessageRoleUser),
}
lastUserSpeaker = item.User.Speaker
if personalize && session.voiceGate.cfg.Identity.InjectName &&
item.User.Speaker != nil && item.User.Speaker.Matched && item.User.Speaker.Name != "" {
msg.Name = item.User.Speaker.Name
}
textContent := ""
nrOfImgsInMessage := 0
for _, content := range item.User.Content {
Expand Down Expand Up @@ -1601,6 +1653,13 @@ func triggerResponseAtTurn(ctx context.Context, session *Session, conv *Conversa
}
conv.Lock.Unlock()

if personalize && session.voiceGate.cfg.Identity.InjectSystemNote {
if note := speakerNote(lastUserSpeaker, session.voiceGate.cfg.Identity.NoteUnknown); note != "" {
conversationHistory[0].StringContent += "\n\n" + note
conversationHistory[0].Content = conversationHistory[0].StringContent
}
}

var images []string
for _, m := range conversationHistory {
images = append(images, m.StringImages...)
Expand Down
Loading
Loading