File size: 10,186 Bytes
549c3f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The perception loop β€” where seeing and hearing become identity (CHT-025…027).

The identity runtime answers "who is here?" from a stream of claims, but it makes
none itself: on the robot the claims come from a camera and a microphone, and
this is the object that turns one into the other. It closes the two paths the
runtime's docstring describes only in the abstract::

    frame  β†’ detect  β†’ FaceObservation β†’ face provider  β†’ claim β†’ runtime.observe
    window β†’ segment β†’ SpeechSegment   β†’ voice provider β†’ claim β†’ runtime.observe

and, on a fixed cadence, calls ``runtime.tick`` so that presence *decays* when
observation stops β€” because leaving is the absence of observations, and without a
tick past the staleness horizon the room would never empty (see
``IdentityRuntime.tick``).

The control-loop discipline it follows
--------------------------------------
The reference conversation app runs one thread that owns its control surface and
paces itself off a monotonic clock; the production roadmap holds every subsystem
to that pattern (Β§"Non-negotiables"). This loop obeys it:

- **Single owner.** One thread runs :meth:`PerceptionLoop.run`. It is the sole
  mutator of the runtime β€” it threads each immutable ``RuntimeStep.runtime``
  forward and no one else touches it. Other threads learn what happened through
  the ``on_step`` callback, which fires on the loop thread.
- **Monotonic cadence.** The tick interval is measured against ``time.monotonic``,
  never wall time, so a clock adjustment cannot make presence decay early or
  stall. The wall clock is used only for the ``now`` stamped on claims, where a
  real timestamp is what a replay wants.
- **Stop-event driven.** :meth:`run` loops until a ``threading.Event`` is set and
  exits promptly even mid-wait, so shutting the robot down releases the loop at
  once rather than after one more poll.

Every seam is injected
----------------------
Hardware sits behind :class:`CameraSource` / :class:`MicSource`; detection,
segmentation, and identification behind their existing Protocols; and both the
wall clock and the monotonic clock are constructor arguments. So the same object
a robot drives with real devices and real clocks, a test drives with synthetic
frames, a fake runtime, and a scripted clock β€” the loop's behaviour is a function
of its inputs and replays exactly, which is the whole reason the runtime was kept
clock-free beneath it.
"""

from __future__ import annotations

import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Final

from chittios_core.identity.face.detector import FaceDetectorProtocol, FaceObservation
from chittios_core.identity.runtime import IdentityRuntime, RuntimeStep
from chittios_core.identity.types import IdentityProvider
from chittios_core.identity.voice.segmenter import SegmenterProtocol, SpeechSegment
from chittios_core.perception.sources import CameraSource, MicSource

#: How often presence is aged, in seconds. Well below ``fusion.decay``'s 300 s
#: staleness horizon so the room empties promptly after someone leaves rather
#: than a horizon-length afterward, yet coarse enough that ticking costs nothing
#: against the single-digit-Hz claim rate the runtime is built for.
DEFAULT_TICK_INTERVAL_S: Final = 1.0

#: How long the loop waits between polling cycles when it is otherwise idle.
#: 20 Hz is far faster than frames or utterances arrive, so a present face is
#: never missed for want of looking, while the wait keeps a quiet loop off the
#: CPU. It is also the upper bound on how long a stop takes to be noticed.
DEFAULT_POLL_INTERVAL_S: Final = 0.05


def _wall_clock() -> datetime:
    """The default source of the ``now`` stamped on claims and ticks."""
    return datetime.now(UTC)


@dataclass(frozen=True, slots=True)
class FacePipeline:
    """The three seams that turn a frame into a face claim.

    Grouped so the loop takes one face argument and one voice argument instead of
    six loose collaborators, and so a caller wires a modality as a unit.

    Attributes:
        source: Where frames come from.
        detector: Locates faces in a frame; returns them largest-first.
        provider: Identifies one detected face, emitting the Β§6.2 claim.
    """

    source: CameraSource
    detector: FaceDetectorProtocol
    provider: IdentityProvider[FaceObservation]


@dataclass(frozen=True, slots=True)
class VoicePipeline:
    """The three seams that turn an audio window into voice claims.

    Attributes:
        source: Where audio windows come from.
        segmenter: Cuts a window into utterances, in the order spoken.
        provider: Identifies the speaker of one utterance, emitting the claim.
    """

    source: MicSource
    segmenter: SegmenterProtocol
    provider: IdentityProvider[SpeechSegment]


class PerceptionLoop:
    """Drives camera and microphone into an :class:`IdentityRuntime`.

    Construct with the two modality pipelines and a runtime, then hand
    :meth:`run` a stop event on a dedicated thread::

        loop = PerceptionLoop(face_pipeline, voice_pipeline, runtime, on_step=react)
        stop = threading.Event()
        threading.Thread(target=loop.run, args=(stop,), daemon=True).start()
        ...
        stop.set()  # released promptly

    Args:
        face: The frame β†’ claim pipeline.
        voice: The window β†’ claim pipeline.
        runtime: The identity runtime to drive. Owned exclusively by the loop
            thread once :meth:`run` is entered; read the live one via
            :attr:`runtime`.
        tick_interval_s: How often presence is aged, measured monotonically.
        poll_interval_s: How long to wait between polling cycles when idle, and
            the upper bound on stop latency.
        on_step: Called with every :class:`RuntimeStep` the runtime produces β€”
            each observation and each tick β€” on the loop thread. This is how a
            caller reacts to greetings, an authorization change, or a
            re-enrollment prompt. ``None`` discards the steps.
        clock: The wall clock stamped on claims and ticks. Injected for
            deterministic replay; defaults to ``datetime.now(UTC)``.
        monotonic: The monotonic clock the tick cadence is measured against.
            Injected for deterministic tests; defaults to ``time.monotonic``.
    """

    def __init__(
        self,
        face: FacePipeline,
        voice: VoicePipeline,
        runtime: IdentityRuntime,
        *,
        tick_interval_s: float = DEFAULT_TICK_INTERVAL_S,
        poll_interval_s: float = DEFAULT_POLL_INTERVAL_S,
        on_step: Callable[[RuntimeStep], None] | None = None,
        clock: Callable[[], datetime] = _wall_clock,
        monotonic: Callable[[], float] = time.monotonic,
    ) -> None:
        self._face = face
        self._voice = voice
        self._runtime = runtime
        self._tick_interval_s = tick_interval_s
        self._poll_interval_s = poll_interval_s
        self._on_step = on_step
        self._clock = clock
        self._monotonic = monotonic
        self._next_tick_at = 0.0

    @property
    def runtime(self) -> IdentityRuntime:
        """The current runtime β€” the latest state the loop has threaded to."""
        return self._runtime

    def run(self, stop: threading.Event) -> None:
        """Poll both modalities and age presence until ``stop`` is set.

        Returns when the event is set. Each cycle observes whatever the camera
        and microphone offer, ages presence if the cadence is due, then waits β€”
        interruptibly, so a stop set during the wait returns at once.
        """
        # Anchor the first tick a full interval out rather than firing one
        # immediately on an empty room.
        self._next_tick_at = self._monotonic() + self._tick_interval_s

        while not stop.is_set():
            self._observe_faces()
            self._observe_speech()
            self._maybe_tick(self._monotonic())

            # Event.wait is the interruptible pace: it returns True the moment
            # the event is set, so shutdown never waits out a full interval.
            if stop.wait(self._poll_interval_s):
                break

    # ── modality paths ───────────────────────────────────────────────────

    def _observe_faces(self) -> None:
        """Fold a claim for every face currently in view.

        Every detected face is observed, not just the nearest: presence is
        per-person, and only a claim per face lets the room hold everyone who is
        actually there. All faces in one frame share its capture instant.
        """
        frame = self._face.source.read()
        if frame is None:
            return

        now = self._clock()
        for face in self._face.detector.detect(frame):
            observation = FaceObservation(frame_bgr=frame, face=face)
            claim = self._face.provider.identify(observation, observed_at=now)
            self._fold(self._runtime.observe(claim, now=now))

    def _observe_speech(self) -> None:
        """Fold a claim for every utterance in the latest audio window."""
        window = self._voice.source.read()
        if window is None:
            return

        now = self._clock()
        for segment in self._voice.segmenter.segment(window):
            claim = self._voice.provider.identify(segment, observed_at=now)
            self._fold(self._runtime.observe(claim, now=now))

    def _maybe_tick(self, now_monotonic: float) -> None:
        """Age presence if a tick interval has elapsed since the last one."""
        if now_monotonic < self._next_tick_at:
            return
        self._next_tick_at = now_monotonic + self._tick_interval_s
        self._fold(self._runtime.tick(self._clock()))

    def _fold(self, step: RuntimeStep) -> None:
        """Thread the new runtime forward and surface the step to the caller."""
        self._runtime = step.runtime
        if self._on_step is not None:
            self._on_step(step)