speaker-verification
audio
nemotron
tyger
experimental
File size: 48,156 Bytes
0efd0f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
"""Async bridge from frozen live features to speaker-relative ASR turns."""
import asyncio,json,time,logging,re
from collections import deque
from pathlib import Path
import numpy as np
from speaker_runtime import LivePredictor
from speaker_turns import SpeakerMemory,SpeakerTurns,self_name,WAKE,wake_intent,choose_turn_text
from identity import unit

class SpeakerSession:
    def __init__(self,conversation,long_context='long',use_diarization=True):
        self.c=conversation;self.predictor=LivePredictor(long_context)
        config=Path(__file__).resolve().parent/'speaker_live/calibration.json'
        values=json.loads(config.read_text()) if config.exists() else {}
        self.memory=SpeakerMemory(threshold=float(values.get('threshold',.55)),margin=float(values.get('margin',.04)),
            center=self.predictor.fast.teacher_mean.detach().numpy().copy() if values.get('centered',True) else None)
        self.turns=SpeakerTurns(self.memory);self.tasks=set();self.queue=asyncio.Queue(maxsize=4)
        self.asr_lock=asyncio.Lock();self.worker=None;self.closed=False;self.last_notice=0.;self.last_barge=-100.
        self.pending_names={};self.decode_count=0;self.last_activity=None
        self.epoch=0;self.records=deque(maxlen=80);self.diar_offset=None;self.owners=deque(maxlen=2048)
        self.overlaps=deque(maxlen=512)
        self.asr_wakes=deque(maxlen=64)
        self.background_channels=set();self.channel_levels={};self.bridged_seconds=0.
        from diarization_client import DiarizationClient
        self.diar=DiarizationClient() if use_diarization else None
        self.diarization_active=False
        self.seen_channels=set();self.foreground_channel=None
        self.utterance_threshold=.50;self.utterance_margin=.08
        self.interrupt_checked_at=-100.;self.interrupt_reference=None
    def start(self):
        if self.worker is None:self.worker=asyncio.create_task(self.deliver_loop())
    async def close(self):
        self.closed=True
        for task in [*self.tasks,*([self.worker] if self.worker else [])]:task.cancel()
        await asyncio.gather(*self.tasks,*([self.worker] if self.worker else []),return_exceptions=True)
        if self.diar is not None:await self.diar.close()
        self.turns.clear();self.pending_names.clear();self.records.clear();self.owners.clear();self.overlaps.clear();self.asr_wakes.clear();self.background_channels.clear();self.channel_levels.clear()
        while not self.queue.empty():self.queue.get_nowait()
        self.worker=None;self.last_activity=None
        self.interrupt_reference=None
    async def clear(self):
        self.epoch+=1
        for task in list(self.tasks):task.cancel()
        await asyncio.gather(*list(self.tasks),return_exceptions=True)
        self.tasks.clear();self.turns.clear();self.pending_names.clear()
        while not self.queue.empty():self.queue.get_nowait()
        self.records.clear();self.diar_offset=None
        self.owners.clear()
        self.overlaps.clear()
        self.asr_wakes.clear()
        self.background_channels.clear();self.channel_levels.clear();self.bridged_seconds=0.
        self.seen_channels.clear();self.foreground_channel=None
        self.interrupt_reference=None;self.interrupt_checked_at=-100.
        if self.diar is not None:
            await self.diar.close()
            from diarization_client import DiarizationClient
            self.diar=DiarizationClient()
    def spawn(self,coroutine):
        task=asyncio.create_task(coroutine);self.tasks.add(task);task.add_done_callback(self.tasks.discard)
    async def notice(self,**fields):
        if not self.c.ws.closed:
            await self.c.ws.send_json({'type':'identity','enabled':self.c.identity_enabled,'mode':self.c.identity_mode,
                **self.turns.status(),'diarization_active':self.diarization_active,'utterance_threshold':self.utterance_threshold,**fields})
            if fields.get('warning'):await self.c.ws.send_json({'type':'warning','text':fields['warning']})
    async def transcribe(self,wave):
        if len(wave)<2560:return ''
        # A known person's short "yes" is valid. Right padding is silence,
        # never borrowed background audio from a different speaker.
        wave=np.pad(wave,(0,max(0,10240-len(wave))))
        async with self.asr_lock:
            self.decode_count+=1
            texts=await asyncio.to_thread(self.c.ears.service.engine.asr.transcribe,[np.asarray(wave,np.float32)])
            return (texts[0] if texts else '').strip()[:3000]

    def note_asr(self,text,at):
        # Keep explicit, same-name activation evidence across ASR revisions.
        # A later full-window decode may lose "Hey" although the incremental
        # decoder already recognized it. This is not an LLM-inferred wake.
        name=self_name(text)
        activated=wake_intent(text)
        if name and activated:self.asr_wakes.append((float(at),name.casefold()))
        active=[t for t in self.turns.tracks.values() if t.sid and not t.closed and at-t.last_speech<1.6]
        if len(active)==1:
            track=active[0]
            if activated:track.wake=True
            user=self.memory.users.get(track.sid)
            same_name=bool(name and (self_name(track.partial) or (user.name if user and user.named else '')).casefold()==name.casefold())
            # A room-wide partial cannot give a background-channel Guest the
            # name spoken by a different, not-yet-diarized foreground person.
            if same_name and (activated or track.wake):track.name_heard=True

    def observed_wake(self,track,text):
        name=self_name(text)
        return bool(name and any(track.start-.32<=at<=track.last_speech+.96 and value==name.casefold()
            for at,value in self.asr_wakes))

    async def confirm_interrupt(self,proposal,hop_end):
        """Confirm a native GRU proposal with known-user vs actual bot voice.

        No enrollment, ASR or invented BARGE. A reusable diarization channel
        alone is not sufficient: echoes can occupy an old user's channel.
        """
        end=hop_end/self.c.ears.sr
        if end-self.interrupt_checked_at<.30:return None
        self.interrupt_checked_at=end
        references=[(u.id,u.teacher.copy()) for u in self.memory.users.values() if u.teacher is not None]
        if not references:return None
        begin=max(0.,end-1.92,float(getattr(self.c,'speaker_reply_start_s',0.)))
        wave=self.audio_between(begin,end)
        if len(wave)<7680:return None
        # Remove quiet leading leakage before speaker verification, not from
        # the captured turn or native GRU input. Double talk is not separated.
        n=len(wave)//320;levels=np.sqrt(np.mean(wave[:n*320].reshape(n,320)**2,axis=1))
        active=np.flatnonzero(levels>=max(.003,float(levels.max())*.25))
        if len(active)<24:return None # at least 480 ms of audible evidence
        query=wave[max(0,int(active[0])*320-160):min(len(wave),(int(active[-1])+1)*320+160)]
        query=np.pad(query,(0,max(0,10240-len(query))))
        playback=self.c.playback;rid=playback.rid;epoch=self.epoch
        size=min(playback.n16,4*16000)
        reference=playback.audio[:size].copy()
        cached=self.interrupt_reference
        def score():
            from speaker_teacher import get_cached_teacher
            teacher=get_cached_teacher()
            reuse=bool(cached and cached[0]==rid and size<cached[1]*1.8)
            # While waiting for admission/first TTS audio there is no new
            # playback to echo. Retain the previous bot cue for acoustic tails
            # if available, without preventing known users cancelling a wait.
            bot=(cached[2] if cached else None) if size<10240 else cached[2] if reuse else unit(teacher.embed(reference))
            user=unit(teacher.embed(query))
            values=sorted(((float(user@v),sid) for sid,v in references),reverse=True)
            return bot,cached[1] if reuse else size,float(user@bot) if bot is not None else -1.,values
        try:bot,bot_size,bot_score,values=await asyncio.to_thread(score)
        except Exception:return None
        if self.closed or self.epoch!=epoch or not self.c.speaking or playback.rid!=rid:return None
        if size>=10240:self.interrupt_reference=(rid,bot_size,bot)
        best,sid=values[0];runner=max(bot_score,values[1][0] if len(values)>1 else -1.)
        # Preserve quick, strong native interruptions when the already-bound
        # person's independent fast cue agrees. Use the same bounded acoustic
        # continuity floor as full-turn verification, never for new users.
        recent=self.turns.tracks.get(sid);user=self.memory.users.get(sid)
        recently_owned=bool(recent is not None and not recent.closed and end-recent.last_speech<=.64
            and recent.speech_s>=.48)
        # A short ECAPA window alone can confuse two male voices. Require
        # independent live ownership, or a much stronger full known-voice match
        # when the diarizer assigned a genuinely new channel.
        accepted=bool(best>=self.utterance_threshold and best-runner>=self.utterance_margin
            and (recently_owned or (best>=max(.70,self.utterance_threshold) and best-runner>=.20)))
        continuity=bool(float(proposal.get('yield_prob',0))>=.8 and recent is not None and user is not None
            and not recent.closed and end-recent.last_speech<=.64 and len(recent.fast)>=2
            and self.memory.similarity(recent.vector(),user.fast)>=self.memory.threshold-.12
            and best>=max(.4,self.utterance_threshold-.08) and best-runner>=max(.12,self.utterance_margin))
        accepted=accepted or continuity
        if getattr(self.c,'synthetic_diagnostics',False):
            await self.c.ws.send_json({'type':'synthetic_barge_verification','accepted':accepted,
                'user_cosine':best,'bot_cosine':bot_score,'margin':best-runner,'evidence_seconds':len(query)/16000,
                'strong_native_continuity':continuity})
        return sid if accepted and sid in self.memory.users else None

    async def playback_voice(self):
        playback=getattr(getattr(self,'c',None),'playback',None)
        if playback is None or playback.n16<10240:return None
        rid=playback.rid;size=min(playback.n16,64000);epoch=self.epoch
        cached=self.interrupt_reference
        if cached and cached[0]==rid and size<cached[1]*1.8:return cached[2]
        reference=playback.audio[:size].copy()
        def embed():
            from speaker_teacher import get_cached_teacher
            return unit(get_cached_teacher().embed(reference))
        try:bot=await asyncio.to_thread(embed)
        except Exception:return None
        if self.closed or self.epoch!=epoch or playback.rid!=rid:return None
        self.interrupt_reference=(rid,size,bot);return bot

    async def transcribe_turn(self,track):
        wave=self.submission_wave(track)
        start=max(track.start,track.recognition_start or track.start)
        # Post-roll rescues word endings, but must not turn quiet trailing TV
        # into the enrollment voiceprint. Identity keeps the original span.
        identity_length=max(0,round((track.last_speech-start)*16000))
        track.verification_audio=wave[:identity_length].copy()
        # A reused channel can become confident only after the wake phrase.
        # Decode several bounded views of that one-second pre-roll as ONE
        # native batch. Prefix interference otherwise hides a perfectly
        # audible self-introduction and silently maps the new person to the
        # old channel. Never invent a wake/name or remove ordinary content.
        preroll=(track.voiced_start-track.start) if track.voiced_start is not None else 0.
        offsets=[0.,.16,.32,.80] if preroll>=.8 and len(wave)>=25600 else [0.]
        views=[np.pad(wave[round(offset*16000):],(0,max(0,10240-len(wave[round(offset*16000):])))) for offset in offsets]
        owned=track.owned_waveform();owned=np.pad(owned,(0,max(0,10240-len(owned))))
        def decode():
            output=[];batch=views+[owned]
            for at in range(0,len(batch),4):output.extend(self.c.ears.service.engine.asr.transcribe(batch[at:at+4]))
            return output
        async with self.asr_lock:
            self.decode_count+=len(views)+1
            results=await asyncio.to_thread(decode)
        texts=[str(text).strip()[:3000] for text in results[:len(views)]]
        owned_text=str(results[-1]).strip()[:3000] if results else ''
        text,chosen=choose_turn_text(texts,owned_text)
        track.asr_crop_verified=bool(text and chosen is not None and chosen>0)
        if chosen is not None and chosen>0:track.submission_audio=views[chosen].copy()
        if chosen==-1:track.submission_audio=owned.copy()
        if getattr(self.c,'synthetic_diagnostics',False):
            await self.c.ws.send_json({'type':'synthetic_asr_views','texts':texts,'owned_text':owned_text,
                'offsets':offsets,'chosen':chosen,'word_origin_consistent':text is not None})
        return text
    async def candidate(self,track,wave,coverage=0.,continuous=False):
        try:
            cut=0.
            if continuous and len(wave)>=25600:
                offsets=[0.,.32,.64,.96]
                views=[np.pad(wave[round(offset*16000):],(0,max(0,10240-len(wave[round(offset*16000):])))) for offset in offsets]
                async with self.asr_lock:
                    self.decode_count+=len(views)
                    texts=await asyncio.to_thread(self.c.ears.service.engine.asr.transcribe,views)
                # Trim only verified pre-wake context; take the latest view
                # that STILL contains the greeting, never fabricate one.
                choices=[i for i,text in enumerate(texts) if wake_intent(text) and self_name(text)]
                if not choices:choices=[i for i,text in enumerate(texts) if wake_intent(text)]
                chosen=choices[-1] if choices else 0;text=(texts[chosen] if texts else '').strip()[:3000];cut=offsets[chosen]
            else:text=await self.transcribe(wave)
            if self.closed or not self.c.identity_enabled:return
            was_background=track.channel in self.background_channels
            recovered_start=None
            if continuous and coverage>=.4 and not was_background and self_name(text) and not wake_intent(text):
                recovered=await self.recover_intro_prefix(track,text)
                if recovered is not None:text,recovered_start=recovered
            background_wake_only=track.channel in self.background_channels and wake_intent(text) and not self_name(text)
            eligible=coverage>=.4
            if was_background and wake_intent(text) and self_name(text) and eligible and self.records:
                raw=self.audio_between(max(track.start,track.last_speech-8.),min(self.records[-1][1],track.last_speech+.8))
                room_text=await self.transcribe(raw)
                eligible=bool(self_name(room_text)==self_name(text) and wake_intent(room_text))
            # A long-observed TV channel can briefly own a new person's "Hey"
            # during a channel transition. Wait for an addressed complete
            # introduction instead of enrolling the TV as a permanent Guest.
            observed_wake=not was_background and continuous and coverage>=.4 and self.observed_wake(track,text)
            decision=self.turns.candidate_text(track.key,'' if background_wake_only or not eligible else text,observed_wake=observed_wake and eligible)
            if not decision and not wake_intent(text) and len(text.split())>=2 and track.speech_s>=1.28:
                decision=await self.recover_existing_channel(track,was_background)
            if not decision and track.channel is not None and track.channel not in self.turns.channels:
                if coverage<.35 or (not wake_intent(text) and len(text.split())>=2 and track.speech_s>=1.28):
                    self.background_channels.add(track.channel)
            if decision and continuous:
                if recovered_start is not None:
                    track.start=min(track.start,recovered_start);track.recognition_start=recovered_start
                else:track.recognition_start=track.start+cut
            await self.c.ws.send_json({'type':'asr_preview','text':text,
                    'status':'enrolled' if decision else 'awaiting_wake_or_stable_voice',
                    'source':'speaker_candidate','track':track.key,'audio_seconds':round(len(wave)/16000,2),
                    'voiced_seconds':round(track.speech_s,2),'channel':track.channel,
                    'energy_coverage':round(coverage,3),'continuous_audio':continuous})
            if decision:await self.notice(decision=decision)
        except asyncio.CancelledError:raise
        except Exception:
            track.decoding=False
            await self.notice(warning='Speaker-owned ASR was unavailable; enrollment not guessed.')

    async def recover_intro_prefix(self,track,text):
        """Verify a real greeting immediately before a new channel's name.

        A channel transition can split "Hey Robbie" from "I am ...". Do not
        invent a wake or transfer an unrelated room-wide partial: re-decode
        the bounded original microphone prefix and require the SAME explicit
        self-introduction. Only energy-eligible non-background candidates call
        this, and final owned-word/acoustic checks remain mandatory.
        """
        if not self.records:return None
        start=max(self.records[0][0],track.start-1.6)
        end=min(self.records[-1][1],track.last_speech+.8)
        if any(a<end and b>start for a,b in self.overlaps):return None
        wave=self.audio_between(start,end)
        if len(wave)<10240:return None
        offsets=[0.,.32,.64,.96]
        views=[np.pad(wave[round(offset*16000):],(0,max(0,10240-len(wave[round(offset*16000):])))) for offset in offsets]
        async with self.asr_lock:
            self.decode_count+=len(views)
            texts=await asyncio.to_thread(self.c.ears.service.engine.asr.transcribe,views)
        name=self_name(text)
        choices=[i for i,value in enumerate(texts) if wake_intent(value) and self_name(value)==name]
        if not choices:return None
        chosen=choices[-1]
        return texts[chosen].strip()[:3000],start+offsets[chosen]

    async def recover_existing_channel(self,track,was_background=False):
        """A returning named voice may acquire a new diarizer channel.

        Reassociate a known, acoustically verified identity only; never enroll
        an unrelated speaker from ordinary speech without a wake phrase.
        """
        if self.c.speaking or track.channel is None:return None
        references=[u for u in self.memory.users.values() if u.named and u.teacher is not None]
        if not references:return None
        query=track.owned_waveform()[-40960:]
        if len(query)<20480:return None
        levels=np.sqrt(np.mean(query[:len(query)//320*320].reshape(-1,320)**2,axis=1))
        if was_background:
            history=self.channel_levels.get(track.channel,deque())
            floor=float(np.median(history)) if history else 0.
            if float(np.quantile(levels,.75))<max(.008,floor*2.5):return None
        from speaker_teacher import get_cached_teacher
        epoch=self.epoch
        def embed():return unit(get_cached_teacher().embed(query))
        try:embedding=await asyncio.to_thread(embed)
        except Exception:return None
        if self.closed or epoch!=self.epoch or track.key not in self.turns.tracks:return None
        scores=sorted(((float(embedding@u.teacher),u.id) for u in references),reverse=True)
        best,sid=scores[0];runner=scores[1][0] if len(scores)>1 else -1.
        if best<max(.55,self.utterance_threshold) or best-runner<max(.12,self.utterance_margin):return None
        self.rebind(track,sid)
        # A channel that was already observed as TV/background cannot retain
        # this person's identity after a verified move to a new channel.
        for channel,who in list(self.turns.channels.items()):
            if who==sid and channel!=track.channel and channel in self.background_channels:self.turns.channels.pop(channel,None)
        self.background_channels.discard(track.channel)
        return {'kind':'enrolled','id':sid,'name':self.memory.users[sid].name,'new_user':False,
            'accepted':True,'source':'Full known-voice match on a new diarization channel','cosine':best}
    async def observe(self,event,hop_end):
        c=self.c;at=hop_end/c.ears.sr
        prediction=await asyncio.to_thread(self.predictor.predict,event)
        begin=at-len(event['audio16'])/16000
        self.records.append((begin,at,prediction,event['audio16'].copy()))
        diarized=False
        if self.diar is not None:
            if self.diar_offset is None:self.diar_offset=begin
            try:
                response=await self.diar.feed(event['audio16']);self.diarization_active=True;diarized=True
                values=np.asarray(response['probabilities'],np.float32).reshape(-1,8)
                if len(values):
                    labels=[]
                    for row in values:
                        active=[int(ch) for ch in np.flatnonzero(row>=.55)]
                        new=[ch for ch in active if ch not in self.seen_channels]
                        self.seen_channels.update(active)
                        if len(new)==1:self.foreground_channel=new[0]
                        known=[ch for ch in active if self.turns.channels.get(ch) in self.memory.users]
                        known_ids={self.turns.channels[ch] for ch in known}
                        if len(known_ids)==1:label=max(known,key=lambda ch:row[ch])
                        elif len(known_ids)>1:label=-1 # two enrolled people: don't guess mixed words
                        elif self.foreground_channel in active:label=self.foreground_channel
                        elif len(active)==1:label=active[0]
                        else:label=-1
                        labels.append(label)
                    labels=np.asarray(labels)
                    boundaries=np.r_[0,np.flatnonzero(np.diff(labels)!=0)+1,len(labels)]
                    for a,b in zip(boundaries[:-1],boundaries[1:]):
                        start=self.diar_offset+(response['start_frame']+int(a))*.01
                        end=self.diar_offset+(response['start_frame']+int(b))*.01
                        source=next((p for s,t,p,x in self.records if s<end<=t+.0001),prediction)
                        cue=dict(source);label=int(labels[a]);wave=self.audio_between(start,end)
                        bridged=self.bridge_gap(label,cue,wave,start,end,values[a:b].mean(0))
                        if bridged is not None:
                            label=bridged;cue['provisional_bridge']=True;self.bridged_seconds+=end-start
                        if label>=0:
                            cue.update(kind='speech',confidence=max(.65,cue.get('confidence',0.)),diarization_channel=label)
                        else:
                            cue.update(kind='silence',confidence=0.,embedding=None)
                            # Ambiguous overlaps do not put mixed speech into
                            # ASR, but a genuinely active enrolled person has
                            # not yet stopped talking just because they overlap.
                            active=np.flatnonzero(values[a:b].mean(0)>=.55)
                            if len(active)>1:
                                known_ids=[self.turns.channels.get(int(ch)) for ch in active
                                    if self.turns.channels.get(int(ch)) in self.memory.users]
                                if len(set(known_ids))>1:self.overlaps.append((start,end))
                                for ch in active:
                                    sid=self.turns.channels.get(int(ch));track=self.turns.tracks.get(sid)
                                    if track is not None:track.last_speech=end
                        await self.step(cue,wave,end)
            except asyncio.CancelledError:raise
            except Exception:
                await self.diar.close();self.diar=None;self.diarization_active=False
                await c.ws.send_json({'type':'warning','text':'Diarization unavailable: using the less reliable V4 embedding-only fallback. Speaker strictness is adjustable.'})
        if not diarized:await self.step(prediction,event['audio16'],at)
        if time.monotonic()-self.last_notice>.65:
            self.last_notice=time.monotonic();await self.notice(prediction=self.last_activity or {'kind':prediction['kind']})

    def bridge_gap(self,label,cue,wave,start,end,probabilities):
        """Repair a short foreground-channel dropout, never permanent binding.

        Only established quiet background channels (or silence with a strong
        speech cue) are eligible. Require louder energy, same recent fast
        speaker vector and <=1.6 s since DIRECT diarizer evidence. This avoids
        both splitting a sentence and letting room noise hold its clock open.
        Full-utterance identity and word-origin checks still decide submission.
        """
        rms=float(np.sqrt(np.mean(wave*wave))) if len(wave) else 0.
        previous=self.channel_levels.get(label,deque())
        floor=float(np.median(previous)) if previous else 0.
        if label>=0:
            self.channel_levels.setdefault(label,deque(maxlen=128)).append(rms)
            if label not in self.background_channels or label in self.turns.channels:return None
        elif cue.get('kind')!='speech' or cue.get('confidence',0)<.8:return None
        vector=cue.get('raw_embedding',cue.get('embedding'))
        if vector is None:return None
        active=[t for t in self.turns.tracks.values() if t.sid in self.memory.users and t.channel is not None
            and not t.closed and t.fast and end-t.last_direct_speech<=1.6 and start>=t.start]
        if len(active)!=1:return None
        track=active[0];reference=max(track.levels,default=.01)
        similarity=self.memory.similarity(vector,track.vector())
        if rms<max(.004,floor*2.5,reference*.3) or similarity<.55:return None
        if probabilities[track.channel]<.15 and similarity<.75:return None
        return track.channel

    def audio_between(self,start,end):
        output=np.zeros(max(0,round((end-start)*16000)),np.float32)
        for a,b,_,wave in self.records:
            lo=max(start,a);hi=min(end,b)
            if hi<=lo:continue
            at=round((lo-start)*16000);source=round((lo-a)*16000);n=min(round((hi-lo)*16000),len(output)-at,len(wave)-source)
            if n>0:output[at:at+n]=wave[source:source+n]
        return output

    def submission_wave(self,track):
        """Keep phonemes through short diarizer dropouts, not source separation.

        Channel activity owns the *turn clock*, but zeroing every uncertain
        10-ms frame corrupts wake words/names and biases ECAPA. Reconstruct the
        bounded continuous sentence, excluding another enrolled user's frames
        and ambiguous enrolled overlap. Freeze it before the RAM ring rotates.
        """
        if track.submission_audio is not None:return track.submission_audio
        start=max(track.start,track.recognition_start or track.start)
        # The diarizer can stop owning a quiet final consonant before the
        # sentence ends. Use already-received quiet-clock audio as bounded
        # post-roll; do not delay the speaker-relative one-second endpoint.
        # Other known users/ambiguous overlaps are still masked below, and
        # independent owned/raw recognition still guards background words.
        available=self.records[-1][1] if self.records else track.last_speech
        end=min(available,track.last_speech+.80)
        wave=self.audio_between(start,end)
        provisional=self.memory.users.get(track.sid)
        introduction=bool(provisional and provisional.teacher is None and track.wake and self_name(track.partial))
        excluded=[] if introduction else [(owner[0],owner[1]) for owner in self.owners if owner[2] and owner[2]!=track.sid]
        excluded.extend(self.overlaps)
        for a,b in excluded:
            lo=max(0,round((a-start)*16000));hi=min(len(wave),round((b-start)*16000))
            if hi>lo:wave[lo:hi]=0
        return wave if len(wave) else track.waveform()

    def candidate_wave(self,track):
        selected=track.owned_waveform().copy();continuous=self.submission_wave(track).copy()
        energy=float(np.sum(continuous.astype(np.float64)**2))
        coverage=min(1.,float(np.sum(selected.astype(np.float64)**2))/max(energy,1e-10))
        # Only the candidate that owns most of the acoustic energy may repair
        # its internal channel gaps. A quiet TV/background channel cannot
        # borrow a louder person's wake phrase from the same raw microphone.
        coherent=bool(len(track.fast)>=2 and np.mean([self.memory.similarity(v,track.vector()) for v in track.fast])>=.8)
        competitor=max((float(np.sum(t.owned_waveform().astype(np.float64)**2)) for t in self.turns.tracks.values()
            if t is not track and not t.closed),default=0.)
        dominant=float(np.sum(selected.astype(np.float64)**2))>=1.25*competitor
        use_continuous=energy>1e-8 and (coverage>=.65 or
            (coverage>=.4 and coherent and dominant and track.channel not in self.background_channels))
        if use_continuous:
            # A coherent energy-winning new foreground can span an old/new
            # diarizer channel transition. Recover its full greeting; known
            # name/acoustic checks still decide whether enrollment is valid.
            if self.records:
                end=min(self.records[-1][1],track.last_speech+.8)
                continuous=self.audio_between(max(track.start,track.last_speech-8.),end)
                # Eligibility must describe the audio actually sent to ASR,
                # not an easier masked view that omitted a competing voice.
                energy=float(np.sum(continuous.astype(np.float64)**2))
                coverage=min(1.,float(np.sum(selected.astype(np.float64)**2))/max(energy,1e-10))
                if coverage<.4:use_continuous=False
        return (continuous if use_continuous else selected),coverage,use_continuous

    def rebind(self,track,sid):
        old=track.sid;spans=self.turns.rebind_channel(track.channel,sid) if track.channel is not None else []
        spans.append((old,track.voiced_start if track.voiced_start is not None else track.start,track.last_speech))
        updated=deque(maxlen=self.owners.maxlen)
        for owner in self.owners:
            a,b,who=owner[:3];channel=owner[3] if len(owner)>3 else track.channel
            if channel==track.channel and any(who==previous and a<end and b>start for previous,start,end in spans):who=sid
            updated.append((a,b,who,channel))
        self.owners=updated;track.sid=sid

    async def step(self,prediction,wave,at):
        c=self.c
        old_keys=set(self.turns.tracks)
        match=self.turns.update(prediction,wave,at,speaking=c.speaking)
        for key in set(self.turns.tracks)-old_keys:
            track=self.turns.tracks[key];begin=at-len(wave)/16000
            pre=max(self.records[0][0] if self.records else begin,min(track.start,begin-1.))
            # Preserve a clipped initial "Hey" while avoiding another known
            # person's preceding words. The pre-roll is not identity evidence.
            # The streaming diarizer can recognize a new foreground channel
            # late. Preserve its wake phrase, but never borrow a previous
            # enrolled person's words (including already closed turns).
            for owner in self.owners:
                a,b,sid=owner[:3]
                if sid and sid!=track.sid and b>pre and a<begin:pre=max(pre,b)
            conflict=any(t.sid and t.key!=key and t.last_speech>pre for t in self.turns.tracks.values())
            if pre<begin and not conflict:
                track.chunks.appendleft((pre,begin,self.audio_between(pre,begin)));track.start=pre
        self.owners.append((at-len(wave)/16000,at,match.get('id') if match else None,prediction.get('diarization_channel')))
        self.last_activity=match
        # Original TYGER/second-ear proposals own interruptions. Identity only
        # gates/replays a recent native proposal in server.py; it must never
        # create its own barge from a diarizer channel (including bot echo).
        for track in self.turns.decode_candidates():
            wave,coverage,continuous=self.candidate_wave(track)
            self.spawn(self.candidate(track,wave,coverage,continuous))
        for track in self.turns.due():
            try:
                track.submission_audio=self.submission_wave(track)
                self.queue.put_nowait(track)
            except asyncio.QueueFull:await c.ws.send_json({'type':'warning','text':'Several people spoke at once; please repeat the last turn.'})
    async def deliver_loop(self):
        try:
            while True:
                track=await self.queue.get()
                epoch=self.epoch
                try:
                    if getattr(self.c,'synthetic_diagnostics',False):
                        import base64
                        await self.c.ws.send_json({'type':'synthetic_audio_debug','start':track.start,'end':track.last_speech,
                            'audio':base64.b64encode(self.submission_wave(track).astype('<f2').tobytes()).decode(),
                            'track':track.key,'channel':track.channel})
                    text=await self.transcribe_turn(track)
                    if self.closed or not self.c.identity_enabled or epoch!=self.epoch:continue
                    if text is None:
                        await self.c.ws.send_json({'type':'ignored','reason':'Speech could not be separated confidently from background words. Please repeat; nothing was sent to Luna.'})
                        continue
                    text=text or track.partial
                    if self.observed_wake(track,text):track.wake=True
                    await self.c.ws.send_json({'type':'asr_preview','text':text,'status':'checking_identity',
                        'source':'speaker_turn','track':track.key,'audio_seconds':round(len(self.submission_wave(track))/16000,2),
                        'voiced_seconds':round(track.speech_s,2),'channel':track.channel})
                    await self.refine_identity(track,text,epoch=epoch)
                    if self.closed or epoch!=self.epoch:continue
                    context=self.turns.speaker_context(track,text)
                    if not text or context is None:
                        await self.c.ws.send_json({'type':'ignored','reason':'Speaker identity or words were uncertain. Please repeat or say Hey Robbie and introduce yourself again.'})
                        continue
                    # Wait for a halt acknowledgment rather than racing another
                    # generation. Microphone hops continue in their own task.
                    while (self.c.speaking or self.c.halting) and not self.closed:
                        await asyncio.sleep(.05)
                    if self.closed or epoch!=self.epoch or context['speaker_id'] not in self.memory.users:continue
                    self.c.current_speaker=context;self.pending_names[context['speaker_id']]=text
                    self.c.session.reset()
                    await self.notice(decision={'accepted':True,'kind':'enrolled','id':context['speaker_id'],'name':context['display_name'],
                        'reason':'One second without this speaker; background voices do not extend the turn'})
                    await self.c.ws.send_json({'type':'endpoint','transcript':text,'speaker_id':context['speaker_id'],
                        'speaker_name':context['display_name'],'by':'speaker_silence_1s','turn_s':round(track.last_speech-track.start,2)})
                    await self.c._send_turn(text)
                except asyncio.CancelledError:raise
                except Exception as exc:
                    logging.getLogger('robo.speakers').warning('Speaker submit failed: %s',type(exc).__name__)
                    await self.c.ws.send_json({'type':'warning','text':'Speaker turn could not be submitted; please repeat.'})
        except asyncio.CancelledError:pass

    async def refine_identity(self,track,text,epoch=None):
        """Full-utterance ECAPA checks channel reuse/merges, not fast timing.

        A diarization channel is NOT a permanent biometric identity: two similar
        voices can share it. Never overwrite Christoph's name with Robert's
        introduction just because the diarizer reused a channel.
        """
        current=self.memory.users.get(track.sid)
        if current is None:return
        introduction_name=self_name(text)
        enrollment_probe=current.teacher is None or bool(introduction_name and (wake_intent(text) or track.wake))
        embedding=None
        view_cosine=None;view_count=0
        try:
            from speaker_teacher import get_cached_teacher
            wave=self.submission_wave(track);wave=np.pad(wave,(0,max(0,10240-len(wave))))
            whole=track.verification_audio
            def embed_views():
                teacher=get_cached_teacher()
                if enrollment_probe and whole is not None and len(whole)>=10240:
                    return unit(teacher.embed(whole)),None,1
                vectors=[unit(teacher.embed(wave))]
                if whole is not None and len(whole)>=10240 and (len(whole)!=len(wave) or not np.array_equal(whole,wave)):
                    vectors.append(unit(teacher.embed(whole)))
                cosine=float(vectors[0]@vectors[1]) if len(vectors)>1 else None
                # When independent owned-word recognition verified the ASR
                # crop, the discarded prefix is not equally good identity
                # evidence. Preserve a 20% whole-sentence cross-check rather
                # than averaging a TV-heavy pre-roll at the same weight.
                if len(vectors)==2 and getattr(track,'asr_crop_verified',False) and len(wave)>=20480:
                    merged=.8*vectors[0]+.2*vectors[1]
                else:merged=np.mean(vectors,axis=0)
                return unit(merged),cosine,len(vectors)
            embedding,view_cosine,view_count=await asyncio.to_thread(embed_views)
        except Exception:pass
        if epoch is not None and (self.closed or epoch!=self.epoch):track.sid=None;return
        if view_cosine is not None and view_cosine<.5:
            track.sid=None
            await self.notice(warning='Speaker cues disagree across the full sentence and ASR crop. Please repeat with less overlap.')
            return
        if embedding is None:
            track.sid=None
            await self.notice(warning='Full-utterance speaker verification is unavailable. Please retry or explicitly use Talk without speaker filter.')
            return
        bot=await self.playback_voice();bot_score=float(embedding@bot) if bot is not None else None
        if epoch is not None and (self.closed or epoch!=self.epoch):track.sid=None;return
        known_best=max((float(embedding@u.teacher) for u in self.memory.users.values() if u.teacher is not None),default=-1.)
        if bot_score is not None and bot_score>=.65 and bot_score-known_best>=self.utterance_margin:
            track.sid=None
            await self.notice(warning='Likely playback echo rather than an enrolled user; no turn was submitted.')
            return
        quality_score=None;quality_margin=None
        name=self_name(text);wake=bool(wake_intent(text) or track.wake)
        incomplete=bool(re.search(r'\b(?:ich\s+(?:bin|heiße)|i\s+am|my\s+name\s+is)\s*[.!?…]*$',text,re.I))
        other_introduction=any(t.sid and t.sid!=current.id and t.wake and t.name_heard and not t.closed
            and t.start<=track.last_speech+1. and t.last_speech>=track.start-.32 for t in self.turns.tracks.values())
        other_introduction=other_introduction or any(track.start-.32<=at<=track.last_speech+1.6
            and value!=current.name.casefold() for at,value in self.asr_wakes)
        if wake and not name and (incomplete or (current.named and other_introduction)):
            # One introduction can straddle old/new diarizer channels. Its
            # clipped "Hey, I am…" prefix is not an additional anonymous user.
            # Leave the complete foreground introduction to establish its ID.
            track.sid=None;return
        # Incremental ASR labels are provisional until the first complete
        # acoustic reference exists (e.g. partial "Selber" -> final "Selma").
        # Correct that label in place, not by creating an orphan second user.
        if current.teacher is None and name and wake:
            existing=next((u for u in self.memory.users.values() if u.id!=current.id and u.named
                and u.name.casefold()==name.casefold() and u.teacher is not None),None)
            if existing is not None:
                if float(embedding@existing.teacher)<self.utterance_threshold:track.sid=None;return
                previous=current.id;self.rebind(track,existing.id);self.memory.users.pop(previous,None)
            else:
                self.memory.rename(current.id,name);current.teacher=unit(embedding)
            return
        # A wake-only Guest is provisional. Use the immediately following
        # full self-introduction as its reliable teacher reference instead of
        # rejecting it against a 640-ms wake fragment. Never overwrite a named
        # person. A repeated known name must still agree acoustically.
        if not current.named and name and (wake or self.memory.clock()-current.created<=15):
            existing=next((u for u in self.memory.users.values() if u.id!=current.id and u.named and u.name.casefold()==name.casefold()),None)
            if existing is not None:
                if existing.teacher is None or float(unit(embedding)@existing.teacher)<self.utterance_threshold:track.sid=None;return
                old=current.id;current=existing;self.rebind(track,existing.id);self.memory.users.pop(old,None)
            else:self.memory.rename(current.id,name);current.teacher=unit(embedding)
            return
        if name and current.named and current.name.casefold()!=name.casefold() and not wake:
            track.sid=None
            await self.notice(warning='A different name was heard without the wake phrase. Say “Hey Robbie, I am …” to join; an existing user will not be renamed.')
            return
        student_vote=self.memory.match(track.vector(),track.long_vector())
        scores=[{'name':u.name,'cosine':round(float(unit(embedding)@u.teacher),4)} for u in self.memory.users.values()
            if embedding is not None and u.teacher is not None]
        await self.notice(verification={'candidate':current.name,'reference_scores':scores,'wake':wake,
            'self_introduction':bool(name),'threshold':self.utterance_threshold,'views':view_count,'view_cosine':view_cosine,
            'student_vote':student_vote})
        if name and wake and current.named and current.name.casefold()!=name.casefold():
            existing=next((u for u in self.memory.users.values() if u.named and u.name.casefold()==name.casefold()),None)
            if existing is not None:
                # A repeated self-introduction may reuse a name only if its
                # acoustic reference also agrees; a spoken name is not proof.
                agree=embedding is not None and existing.teacher is not None and float(unit(embedding)@existing.teacher)>=self.utterance_threshold
                if not agree:track.sid=None;return
                user=existing
            else:
                user,_=self.memory.enroll(track.vector(),track.long_vector(),name,force_new=True)
                if user is None:track.sid=None;return
            self.rebind(track,user.id)
            current=user
        elif embedding is not None:
            references=[(float(unit(embedding)@u.teacher),u) for u in self.memory.users.values() if u.teacher is not None]
            references.sort(key=lambda x:x[0],reverse=True)
            if references:
                score,user=references[0];runner=references[1][0] if len(references)>1 else -1.
                if wake and not name and current.named and score<self.utterance_threshold:
                    guest,_=self.memory.enroll(track.vector(),track.long_vector(),force_new=True)
                    if guest is None:track.sid=None;return
                    guest.teacher=unit(embedding);self.rebind(track,guest.id)
                    return
                continuity=(len(references)>=2 and user.id==current.id and track.channel is not None
                    and self.turns.channels.get(track.channel)==current.id and
                    score>=max(.4,self.utterance_threshold-.08) and score-runner>=max(.12,self.utterance_margin))
                consensus=(score>=max(.55,self.utterance_threshold) and score-runner>=max(.04,self.utterance_margin/2)
                    and view_cosine is not None and view_cosine>=.9 and track.speech_s>=1.5
                    and (bot_score is None or score-bot_score>=.12))
                # A nearly tied teacher must not be resolved by reusable
                # diarizer-channel ownership. A separately calibrated student
                # can corroborate the SAME teacher winner, never overrule it.
                corroborated=(score>=self.utterance_threshold and score-runner>=0 and
                    view_cosine is not None and view_cosine>=.85 and track.speech_s>=1.28 and
                    student_vote.get('id')==user.id and
                    student_vote.get('similarity',-1)>=max(.70,self.memory.threshold) and
                    student_vote.get('margin',0)>=.10 and
                    (bot_score is None or score-bot_score>=.12))
                if (score>=self.utterance_threshold and score-runner>=self.utterance_margin) or continuity or consensus or corroborated:
                    if continuity and score<self.utterance_threshold:
                        await self.notice(continuity={'name':user.name,'cosine':score,'margin':score-runner,
                            'reason':'Established diarization channel + separated reference winner; bounded threshold relaxation'})
                    current=user;self.rebind(track,user.id)
                    quality_score=score;quality_margin=score-runner
                elif current.teacher is not None:
                    track.sid=None
                    await self.notice(warning='Full-utterance speaker cue was ambiguous; please repeat or reintroduce yourself.')
                    return
        if embedding is not None and current.teacher is None:current.teacher=unit(embedding)
        elif (quality_score is not None and current.named and track.speech_s>=1.5
              and quality_score>=max(.4,self.utterance_threshold-.08) and quality_margin>=max(.20,self.utterance_margin)
              and view_cosine is not None and (view_cosine>=.9 or
                  (quality_score>=max(.55,self.utterance_threshold) and view_cosine>=.85))
              and (bot_score is None or quality_score-bot_score>=.12)):
            anchor=current.teacher_anchor if current.teacher_anchor is not None else current.teacher
            if float(embedding@anchor)>=max(.4,self.utterance_threshold-.08):current.adapt_teacher(embedding)
    async def metadata(self,data):
        update=data.get('speaker_update')
        if not isinstance(update,dict):return
        sid=update.get('speaker_id');name=update.get('display_name');text=self.pending_names.pop(sid,None)
        user=self.memory.users.get(sid)
        if text and name==self_name(text) and user is not None and (not user.named or user.name.casefold()==name.casefold()):
            self.memory.rename(sid,name);await self.notice(metadata=update)