Skip to content

Commit 32c4770

Browse files
localai-botmudler
andauthored
feat(realtime): speaker-aware conversations - surface identity to client and LLM (#10424)
* feat(realtime): add voice_recognition enforce + identity config Add Enforce *bool and Identity *VoiceIdentityConfig to PipelineVoiceRecognition, plus EnforceGate/IdentityEnabled/ AnnounceEnabled/PersonalizeEnabled helpers. Enforce nil defaults to gating (backward compatible); identity surfacing is independent of the gate. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): add Speaker type and conversation.item.speaker event Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(realtime): split voiceGate into Resolve + authorize Split the speaker authorization into a Resolve step (embed once, produce a types.Speaker identity) and a pure authorize policy step, with a 0..100 confidence score mirroring /v1/voice/identify. The legacy Authorize wrapper is kept so existing specs stay green. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): resolve speaker per turn and emit conversation.item.speaker Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): personalize LLM turns with recognized speaker Set the per-message name field on each recognized user turn and append a current-speaker note to the system message, both gated by the voice recognition identity config. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(realtime): document speaker identity surfacing and personalization Document the new voice_recognition keys (enforce, identity.*) and the LocalAI-extension conversation.item.speaker server event in the realtime feature docs. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(realtime): cover when:first+identity re-resolution and multi-speaker history Add two integration specs to harden the speaker-aware realtime path: - when:first with an Identity block re-resolves the speaker every turn even though re-authorization is skipped after the first match: a later resolve error now fails closed, while a clean later resolve still surfaces and names the speaker. - multi-speaker history attribution: each user turn carries its own per-message name and the injected system note reflects the latest speaker. Test-only change; no production behavior was modified. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): surface speaker labels in conversation.item.speaker Carry the registered speaker's labels (identify mode) on types.Speaker so they flow into the conversation.item.speaker event and the stored item. Verify mode has no labels, so the field is omitted there. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): cover conversation.item.speaker over a real websocket Add a realtime-pipeline-identity config (verify mode, enforce:false, identity announce+announce_unknown+personalize) and two e2e specs driving the real server over a real WebSocket with the mock VoiceEmbed backend: an authorized speaker yields a conversation.item.speaker event naming e2e-speaker (matched true) and reaches response.done; an unauthorized speaker yields an unknown (matched false, no name) event and still responds, proving enforce:false never drops a turn. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(config): register voice_recognition enforce + identity fields The meta registry coverage test (TestAllFieldsHaveRegistryEntries) requires every config field to have an entry in core/config/meta/registry.go. The new voice_recognition.enforce and voice_recognition.identity.* fields were missing, failing tests-linux and tests-apple. Add registry entries (toggles) so the fields are surfaced in the model-config editor and the coverage test passes. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
1 parent e58870a commit 32c4770

15 files changed

Lines changed: 978 additions & 99 deletions

core/config/meta/registry.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,55 @@ func DefaultRegistry() map[string]FieldMetaOverride {
457457
Component: "json-editor",
458458
Order: 78,
459459
},
460+
"pipeline.voice_recognition.enforce": {
461+
Section: "pipeline",
462+
Label: "Voice Gate Enforce",
463+
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.",
464+
Component: "toggle",
465+
Order: 80,
466+
},
467+
"pipeline.voice_recognition.identity.announce": {
468+
Section: "pipeline",
469+
Label: "Speaker Identity Announce",
470+
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'.",
471+
Component: "toggle",
472+
Order: 81,
473+
},
474+
"pipeline.voice_recognition.identity.announce_unknown": {
475+
Section: "pipeline",
476+
Label: "Speaker Identity Announce Unknown",
477+
Description: "Also emit the conversation.item.speaker event (with matched=false) when no confident match is found. Default only announces on a match.",
478+
Component: "toggle",
479+
Order: 82,
480+
},
481+
"pipeline.voice_recognition.identity.personalize": {
482+
Section: "pipeline",
483+
Label: "Speaker Identity Personalize",
484+
Description: "Inform the LLM who is speaking so it can tailor replies. Enables the name and system-note injection below.",
485+
Component: "toggle",
486+
Order: 83,
487+
},
488+
"pipeline.voice_recognition.identity.inject_name": {
489+
Section: "pipeline",
490+
Label: "Speaker Identity Inject Name",
491+
Description: "Personalization: set the per-message OpenAI 'name' field on each user turn to the recognized speaker.",
492+
Component: "toggle",
493+
Order: 84,
494+
},
495+
"pipeline.voice_recognition.identity.inject_system_note": {
496+
Section: "pipeline",
497+
Label: "Speaker Identity Inject System Note",
498+
Description: "Personalization: append a 'The current speaker is <name>.' note to the system message reflecting the latest speaker.",
499+
Component: "toggle",
500+
Order: 85,
501+
},
502+
"pipeline.voice_recognition.identity.note_unknown": {
503+
Section: "pipeline",
504+
Label: "Speaker Identity Note Unknown",
505+
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.",
506+
Component: "toggle",
507+
Order: 86,
508+
},
460509
"pipeline.max_history_items": {
461510
Section: "pipeline",
462511
Label: "Max History Items",

core/config/model_config.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,13 @@ type PipelineVoiceRecognition struct {
769769
Allow VoiceRecognitionAllow `yaml:"allow,omitempty" json:"allow,omitempty"`
770770
// References are the authorized reference speakers (verify mode).
771771
References []VoiceReference `yaml:"references,omitempty" json:"references,omitempty"`
772+
// Enforce controls the authorization gate. A nil value or true rejects
773+
// unauthorized speakers (the historical behavior). false resolves the
774+
// speaker's identity for surfacing/personalization but never drops a turn.
775+
Enforce *bool `yaml:"enforce,omitempty" json:"enforce,omitempty"`
776+
// Identity surfaces the recognized speaker to the client and the LLM. It is
777+
// independent of Enforce: identity can be surfaced without gating.
778+
Identity *VoiceIdentityConfig `yaml:"identity,omitempty" json:"identity,omitempty"`
772779
}
773780

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

795+
// @Description VoiceIdentityConfig surfaces the recognized speaker to the realtime
796+
// client and the LLM. When set, identity is resolved on every turn even if the
797+
// gate's When is "first" (the gate still authorizes only once).
798+
type VoiceIdentityConfig struct {
799+
// Announce emits a conversation.item.speaker event to the client.
800+
Announce bool `yaml:"announce,omitempty" json:"announce,omitempty"`
801+
// AnnounceUnknown also emits the event when there is no confident match.
802+
AnnounceUnknown bool `yaml:"announce_unknown,omitempty" json:"announce_unknown,omitempty"`
803+
// Personalize informs the LLM who is speaking.
804+
Personalize bool `yaml:"personalize,omitempty" json:"personalize,omitempty"`
805+
// InjectName sets the per-message name field on each user turn.
806+
InjectName bool `yaml:"inject_name,omitempty" json:"inject_name,omitempty"`
807+
// InjectSystemNote maintains a "current speaker" note in the system message.
808+
InjectSystemNote bool `yaml:"inject_system_note,omitempty" json:"inject_system_note,omitempty"`
809+
// NoteUnknown adds a "the current speaker is unknown" note (enables the model
810+
// to ask who it is talking to).
811+
NoteUnknown bool `yaml:"note_unknown,omitempty" json:"note_unknown,omitempty"`
812+
}
813+
788814
// VoiceGateEnabled reports whether a voice-recognition gate is configured. The
789815
// mere presence of the block is the intent signal: a present-but-incomplete
790816
// block (e.g. missing model) must fail closed at construction, not be silently
@@ -793,6 +819,28 @@ func (p Pipeline) VoiceGateEnabled() bool {
793819
return p.VoiceRecognition != nil
794820
}
795821

822+
// EnforceGate reports whether the gate rejects unauthorized speakers. A nil
823+
// Enforce means "enforce" so existing configs keep gating.
824+
func (p PipelineVoiceRecognition) EnforceGate() bool {
825+
return p.Enforce == nil || *p.Enforce
826+
}
827+
828+
// IdentityEnabled reports whether the speaker's identity must be resolved for
829+
// surfacing or personalization.
830+
func (p PipelineVoiceRecognition) IdentityEnabled() bool {
831+
return p.Identity != nil && (p.Identity.Announce || p.Identity.Personalize)
832+
}
833+
834+
// AnnounceEnabled reports whether to emit the conversation.item.speaker event.
835+
func (p PipelineVoiceRecognition) AnnounceEnabled() bool {
836+
return p.Identity != nil && p.Identity.Announce
837+
}
838+
839+
// PersonalizeEnabled reports whether to inform the LLM of the speaker.
840+
func (p PipelineVoiceRecognition) PersonalizeEnabled() bool {
841+
return p.Identity != nil && p.Identity.Personalize
842+
}
843+
796844
// Normalize fills in defaults in place for omitted fields.
797845
func (v *PipelineVoiceRecognition) Normalize() {
798846
if v.Mode == "" {

core/config/voice_gate_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,32 @@ var _ = Describe("PipelineVoiceRecognition", func() {
7070
Expect((Pipeline{VoiceRecognition: &PipelineVoiceRecognition{}}).VoiceGateEnabled()).To(BeTrue())
7171
})
7272
})
73+
74+
Describe("Enforce / Identity helpers", func() {
75+
It("treats a nil Enforce as enforcing (backward compatible)", func() {
76+
v := PipelineVoiceRecognition{Model: "spk"}
77+
Expect(v.EnforceGate()).To(BeTrue())
78+
})
79+
It("honors an explicit enforce:false", func() {
80+
off := false
81+
v := PipelineVoiceRecognition{Model: "spk", Enforce: &off}
82+
Expect(v.EnforceGate()).To(BeFalse())
83+
})
84+
It("reports identity disabled when no identity block is set", func() {
85+
v := PipelineVoiceRecognition{Model: "spk"}
86+
Expect(v.IdentityEnabled()).To(BeFalse())
87+
Expect(v.AnnounceEnabled()).To(BeFalse())
88+
Expect(v.PersonalizeEnabled()).To(BeFalse())
89+
})
90+
It("reports identity enabled when announce or personalize is on", func() {
91+
v := PipelineVoiceRecognition{Model: "spk", Identity: &VoiceIdentityConfig{Announce: true}}
92+
Expect(v.IdentityEnabled()).To(BeTrue())
93+
Expect(v.AnnounceEnabled()).To(BeTrue())
94+
Expect(v.PersonalizeEnabled()).To(BeFalse())
95+
96+
v2 := PipelineVoiceRecognition{Model: "spk", Identity: &VoiceIdentityConfig{Personalize: true}}
97+
Expect(v2.IdentityEnabled()).To(BeTrue())
98+
Expect(v2.PersonalizeEnabled()).To(BeTrue())
99+
})
100+
})
73101
})

core/http/endpoints/openai/realtime.go

Lines changed: 108 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,28 +1311,32 @@ func commitUtterance(ctx context.Context, utt []byte, session *Session, conv *Co
13111311
// turn wastes only transcription compute, which has no side effects. The
13121312
// transcript is still emitted to the same peer that sent the audio, which
13131313
// reveals nothing new to them.
1314-
type gateOutcome struct {
1315-
allowed bool
1316-
matched string
1317-
reason string
1318-
err error
1319-
}
1320-
var gateCh chan gateOutcome
1321-
runGate := false
1314+
// Resolve the speaker when the gate must authorize this turn, or when identity
1315+
// surfacing/personalization needs a fresh identity. Identity resolution
1316+
// ignores the when:first short-circuit (that only skips re-authorization).
1317+
type resolveOutcome struct {
1318+
res resolution
1319+
err error
1320+
}
1321+
var resolveCh chan resolveOutcome
1322+
runResolve := false
13221323
if session.voiceGate != nil && session.InputAudioTranscription != nil {
1323-
skip := false
1324-
if session.voiceGate.cfg.When == config.VoiceGateWhenFirst {
1324+
enforce := session.voiceGate.cfg.EnforceGate()
1325+
gateNeedsAuth := enforce
1326+
if enforce && session.voiceGate.cfg.When == config.VoiceGateWhenFirst {
13251327
session.gateMu.Lock()
1326-
skip = session.voiceVerified
1328+
if session.voiceVerified {
1329+
gateNeedsAuth = false
1330+
}
13271331
session.gateMu.Unlock()
13281332
}
1329-
if !skip {
1330-
runGate = true
1331-
gateCh = make(chan gateOutcome, 1)
1333+
if gateNeedsAuth || session.voiceGate.cfg.IdentityEnabled() {
1334+
runResolve = true
1335+
resolveCh = make(chan resolveOutcome, 1)
13321336
wavPath := f.Name()
13331337
go func() {
1334-
allowed, matched, reason, gerr := session.voiceGate.Authorize(ctx, wavPath)
1335-
gateCh <- gateOutcome{allowed: allowed, matched: matched, reason: reason, err: gerr}
1338+
r, rerr := session.voiceGate.Resolve(ctx, wavPath)
1339+
resolveCh <- resolveOutcome{res: r, err: rerr}
13361340
}()
13371341
}
13381342
}
@@ -1348,8 +1352,8 @@ func commitUtterance(ctx context.Context, utt []byte, session *Session, conv *Co
13481352
if err != nil {
13491353
// Drain the gate goroutine before returning so its in-flight read of
13501354
// the temp WAV finishes before the deferred os.Remove fires.
1351-
if runGate {
1352-
<-gateCh
1355+
if runResolve {
1356+
<-resolveCh
13531357
}
13541358
sendError(t, "transcription_failed", err.Error(), "", "event_TODO")
13551359
return
@@ -1361,41 +1365,58 @@ func commitUtterance(ctx context.Context, utt []byte, session *Session, conv *Co
13611365
return
13621366
}
13631367

1364-
// Join on the gate before any side-effecting step.
1365-
if runGate {
1366-
out := <-gateCh
1367-
allowed := out.allowed
1368-
reason := out.reason
1368+
// Join on the resolution before any side-effecting step.
1369+
var speaker *types.Speaker
1370+
if runResolve {
1371+
out := <-resolveCh
1372+
enforce := session.voiceGate.cfg.EnforceGate()
1373+
13691374
if out.err != nil {
1370-
// Fail closed: a gate that cannot decide must not let audio through.
1371-
xlog.Error("voice recognition gate error", "error", out.err)
1372-
allowed = false
1373-
reason = "verification error"
1374-
}
1375-
alreadyVerified := false
1376-
if session.voiceGate.cfg.When == config.VoiceGateWhenFirst {
1377-
session.gateMu.Lock()
1378-
alreadyVerified = session.voiceVerified
1379-
session.gateMu.Unlock()
1380-
}
1381-
proceed, markVerified := session.voiceGate.decide(alreadyVerified, allowed)
1382-
if !proceed {
1383-
xlog.Debug("voice recognition gate rejected utterance", "reason", reason)
1384-
if session.voiceGate.cfg.OnReject == config.VoiceGateRejectEvent {
1385-
sendError(t, "speaker_not_authorized", "speaker not authorized: "+reason, "", "event_TODO")
1375+
if enforce {
1376+
// Fail closed: a gate that cannot decide must not let audio through.
1377+
xlog.Error("voice recognition gate error", "error", out.err)
1378+
if session.voiceGate.cfg.OnReject == config.VoiceGateRejectEvent {
1379+
sendError(t, "speaker_not_authorized", "speaker not authorized: verification error", "", "event_TODO")
1380+
}
1381+
return
13861382
}
1387-
return
1383+
// Non-enforcing: degrade to an unknown speaker and continue.
1384+
xlog.Warn("voice identity resolve failed; continuing as unknown speaker", "error", out.err)
1385+
} else {
1386+
s := out.res.speaker
1387+
speaker = &s
13881388
}
1389-
xlog.Debug("voice recognition gate authorized utterance", "speaker", out.matched)
1390-
if markVerified {
1391-
session.gateMu.Lock()
1392-
session.voiceVerified = true
1393-
session.gateMu.Unlock()
1389+
1390+
if enforce {
1391+
alreadyVerified := false
1392+
if session.voiceGate.cfg.When == config.VoiceGateWhenFirst {
1393+
session.gateMu.Lock()
1394+
alreadyVerified = session.voiceVerified
1395+
session.gateMu.Unlock()
1396+
}
1397+
allowed, reason := false, "verification error"
1398+
if out.err == nil {
1399+
allowed, reason = session.voiceGate.authorize(out.res)
1400+
}
1401+
proceed, markVerified := session.voiceGate.decide(alreadyVerified, allowed)
1402+
if !proceed {
1403+
xlog.Debug("voice recognition gate rejected utterance", "reason", reason)
1404+
if session.voiceGate.cfg.OnReject == config.VoiceGateRejectEvent {
1405+
sendError(t, "speaker_not_authorized", "speaker not authorized: "+reason, "", "event_TODO")
1406+
}
1407+
return
1408+
}
1409+
if markVerified {
1410+
session.gateMu.Lock()
1411+
session.voiceVerified = true
1412+
session.gateMu.Unlock()
1413+
}
1414+
xlog.Debug("voice recognition gate authorized utterance", "speaker", out.res.speaker.Name)
13941415
}
13951416
}
13961417

13971418
if !session.TranscriptionOnly {
1398-
generateResponse(ctx, session, utt, transcript, conv, t)
1419+
generateResponse(ctx, session, utt, transcript, speaker, conv, t)
13991420
}
14001421
}
14011422

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

1443+
// speakerNote renders the system-prompt note for the current speaker. Returns
1444+
// an empty string when there is no name and unknown notes are disabled.
1445+
func speakerNote(s *types.Speaker, noteUnknown bool) string {
1446+
if s != nil && s.Matched && s.Name != "" {
1447+
return "The current speaker is " + s.Name + "."
1448+
}
1449+
if noteUnknown {
1450+
return "The current speaker is unknown."
1451+
}
1452+
return ""
1453+
}
1454+
14221455
// Function to generate a response based on the conversation
1423-
func generateResponse(ctx context.Context, session *Session, utt []byte, transcript string, conv *Conversation, t Transport) {
1456+
func generateResponse(ctx context.Context, session *Session, utt []byte, transcript string, speaker *types.Speaker, conv *Conversation, t Transport) {
14241457
xlog.Debug("Generating realtime response...")
14251458

14261459
// Create user message item
14271460
item := types.MessageItemUnion{
14281461
User: &types.MessageItemUser{
1429-
ID: generateItemID(),
1430-
Status: types.ItemStatusCompleted,
1462+
ID: generateItemID(),
1463+
Status: types.ItemStatusCompleted,
1464+
Speaker: speaker,
14311465
Content: []types.MessageContentInput{
14321466
{
14331467
Type: types.MessageContentTypeInputAudio,
@@ -1445,6 +1479,17 @@ func generateResponse(ctx context.Context, session *Session, utt []byte, transcr
14451479
Item: item,
14461480
})
14471481

1482+
// Surface the recognized speaker to the client. Skip the event for an
1483+
// unidentified speaker unless announce_unknown is set.
1484+
if speaker != nil && session.voiceGate != nil && session.voiceGate.cfg.AnnounceEnabled() {
1485+
if speaker.Matched || session.voiceGate.cfg.Identity.AnnounceUnknown {
1486+
sendEvent(t, types.ConversationItemSpeakerEvent{
1487+
ItemID: item.User.ID,
1488+
Speaker: *speaker,
1489+
})
1490+
}
1491+
}
1492+
14481493
triggerResponse(ctx, session, conv, t, nil)
14491494
}
14501495

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

15101555
imgIndex := 0
1556+
var lastUserSpeaker *types.Speaker
1557+
personalize := session.voiceGate != nil && session.voiceGate.cfg.PersonalizeEnabled()
15111558
conv.Lock.Lock()
15121559
items := trimRealtimeItems(conv.Items, session.MaxHistoryItems)
15131560
for _, item := range items {
15141561
if item.User != nil {
15151562
msg := schema.Message{
15161563
Role: string(types.MessageRoleUser),
15171564
}
1565+
lastUserSpeaker = item.User.Speaker
1566+
if personalize && session.voiceGate.cfg.Identity.InjectName &&
1567+
item.User.Speaker != nil && item.User.Speaker.Matched && item.User.Speaker.Name != "" {
1568+
msg.Name = item.User.Speaker.Name
1569+
}
15181570
textContent := ""
15191571
nrOfImgsInMessage := 0
15201572
for _, content := range item.User.Content {
@@ -1601,6 +1653,13 @@ func triggerResponseAtTurn(ctx context.Context, session *Session, conv *Conversa
16011653
}
16021654
conv.Lock.Unlock()
16031655

1656+
if personalize && session.voiceGate.cfg.Identity.InjectSystemNote {
1657+
if note := speakerNote(lastUserSpeaker, session.voiceGate.cfg.Identity.NoteUnknown); note != "" {
1658+
conversationHistory[0].StringContent += "\n\n" + note
1659+
conversationHistory[0].Content = conversationHistory[0].StringContent
1660+
}
1661+
}
1662+
16041663
var images []string
16051664
for _, m := range conversationHistory {
16061665
images = append(images, m.StringImages...)

0 commit comments

Comments
 (0)