Race Engineer internals¶
How a telemetry condition becomes a spoken sentence, and every rule that decides whether it is worth saying. User-facing behavior is in Race Engineer.
Split of responsibility¶
GT7 → backend detectors → callout event (WebSocket) → browser queue → speech
The backend decides what happened, whether it is reliable, what to say, how urgent it is and when it stops being worth saying. The browser decides whether this device speaks, which voice, how loud, and which categories the user wants.
Keeping detection server-side means every client behaves identically and a phone doesn't need the telemetry history to work out fuel range.
Module layout (backend/app/race_engineer/):
| File | Role |
|---|---|
models.py |
VoiceCallout, categories, verbosity presets, the per-event SPECS policy table |
formatter.py |
numbers → speech ("1:32.487" → "one minute thirty-two point five") |
state.py |
packet clock, lap history, the context detectors read |
thresholds.py |
trigger/clear values and persistence windows |
detectors/ |
lap, pace, race, fuel, engine, tires, coaching (also chassis) |
manager.py |
category filtering, cooldowns, dedupe, priority, expiry, diagnostics |
Reusing what already exists¶
Race Engineer adds no second copy of logic the datalogger already has:
- Position changes come from
processing/live_events.py, the same debounced watcher the webhooks use (GT7 flips the position field every few frames in side-by-side racing; a second detector could disagree with the notifications). - Lap validity uses
counts_for_best/invalidated_best/span_confirmedfromprocessing/laps.py(see below). A lap the logger only half-saw gets a GT7 lap time — a short one — but is never announced and never becomes the reference. - Chassis events (lockups, wheelspin) come from
processing/events.py, computed once at lap save. Coaching only aggregates them across laps. - Corner numbers come from
processing/analysis.pydetect_corners, run on the session-best lap. - Fuel uses
processing/strategy.py, a port of the frontend'sprojectStrategy(last three same-car laps, partial-lap outliers dropped). The two copies must stay in sync so voice and the strategy widget never disagree.
Timing comes from packet ids¶
Every persistence window and cooldown is measured on a packet clock
(state.PacketClock) derived from the console's packet counter, exactly like lap
timing. GT7 keeps streaming at 60 Hz while paused, so wall-clock timers would count
a paused game as elapsed time; a dropped datagram widens one step instead of
silently shortening every window.
The only wall-clock value is created_at_ms on the emitted event — and even that is
not what the browser expires on (see below).
Reliability rules¶
Every detector uses at least one of these, because "reliability over frequency" is the whole point:
Persistence — a value must hold past a window before it counts. Instantaneous facts (lap completed, position changed, final lap) are exempt.
Hysteresis — separate trigger and clear thresholds, so a value hovering on the limit doesn't chatter:
| Condition | Trigger | Clear | Persistence |
|---|---|---|---|
| Water temperature high | 110 °C | 106 °C | 5 s |
| Water temperature critical | 120 °C | 116 °C | 5 s |
| Oil temperature high | 130 °C | 126 °C | 5 s |
| Oil temperature critical | 140 °C | 136 °C | 5 s |
| Oil pressure low | < 2.0 bar (above 2000 rpm) | 2.4 bar | 1.5 s |
| Tires hot | 110 °C | 104 °C | 5 s |
| Fuel low | < 3.0 laps | 3.5 laps | 2 s |
| Fuel critical | < 1.5 laps | 1.8 laps | 2 s |
Trigger values mirror frontend/src/lib/alerts.ts THRESHOLDS so the voice and the
dashboard's colors agree. Change one, change the other.
Cooldowns — per dedupe key, on the packet clock. Lap-scoped callouts have no timer at all: their key contains the lap number, so a repeat is simply a duplicate.
Escalation — a request whose severity exceeds the last one for the same key bypasses the cooldown. "Water temperature high" → "critical" is heard immediately instead of waiting out a 20 s timer.
Deduplication — semantic keys (fuel_short:2, position:2:4,
lap_time:2:8) are remembered for 10 minutes. The browser keeps its own short-lived
cache of ids, which is what makes a reconnect safe.
Priority and expiry¶
Priorities follow the spec's bands: 90–100 critical (may interrupt speech), 75–89
race-critical, 60–74 important, 40–59 coaching, 20–39 informational. Only callouts
that are both interrupt and ≥ 90 cancel speech in progress.
Each callout carries ttl_ms, created_at_ms and expires_at_ms. The browser
expires on ttl_ms measured from its own receipt time, not on expires_at_ms:
a phone or a Pi without NTP can be minutes off, and a stale callout is worse than no
callout — but so is dropping every callout because a clock disagrees.
Detection cost¶
on_packet runs at 60 Hz and does comparisons and counters only. Anything that
walks a lap's samples runs in on_lap. The one genuinely expensive step, corner
detection on a new session-best lap (10–90 ms), runs on a worker thread via
asyncio.to_thread at a lap boundary, and only when a category that names corners
(coaching or chassis) is enabled.
Detection does not run at all unless a browser has registered itself as voice-capable
(TelemetryService.engineer_active), so the feature costs nothing for users who
never enable it.
Which laps may be compared¶
Every coaching callout compares one lap against another by position on the track. That is only meaningful if both laps cover the same track, which is exactly what a half-recorded lap does not.
LapProcessor._apply_span_guard judges each lap by its distance span against recent
laps, and re-judges every lap of the session each time a new one arrives — the
yardstick moves, so a verdict fixed at lap time would go stale. Dropping a lap
promotes the fastest remaining full lap rather than blanking the best, and the stored
rows are rewritten in both directions (mark_session_laps_partial).
Calibrated against 850 real recorded laps:
| Value | Why | |
|---|---|---|
| Yardstick | median span of the last 5 laps | 12 of those 850 laps ran longer than their session's median (one by 44 % — an off-track excursion); against a max-based yardstick a single such lap makes every normal lap look partial |
| Full-lap ratio | 97 % | 98 % of laps sit within 0.5 % of their session median, and no legitimate lap fell below 94.7 % |
| Ratio before 3 laps | 93 % | two laps that disagree are ambiguous — "this one is 6 % short" and "that one ran 6 % wide" are the same picture — so only a flagrant shortfall counts until a third lap settles it |
span_confirmed |
2 of the last laps agreeing | reached by lap 3 in a typical session |
The failure this exists for, from the user's own data: a session whose lap 1 covered 87.9 % of the track was recorded as the fastest lap of the session, because GT7 reported a real (short) time for it.
span_confirmed is what CoachingDetector gates on, and it reaches the browser as
coaching_ready in race_engineer_status so the panel can explain the silence.
Coaching¶
Repeated lockups / wheelspin / bottoming buckets detect_events output by 120 m
of track distance across the last three laps, keyed by wheel, and needs three
occurrences past a severity gate before it speaks. The bucket's mean distance is
mapped to a corner number via detect_corners — looking ahead for braking events
(a lockup happens before the corner it belongs to) and behind for wheelspin (which
happens on exit), within 250 m. Without a corner the wording falls back to "the next
braking zone".
At most one driving observation is emitted per lap. Bottoming is the exception: it
is setup feedback in the chassis category, so it does not consume the coaching
slot — a driver who has switched coaching off still hears that the car is grounding.
Pace trend (detectors/pace.py) averages the last three counted laps and compares
them to the session best — the pace the driver has already proven on this track, in
this car. It speaks at 0.8 s off, clears at 0.4 s, and stays quiet whenever any lap in
the window is itself on the pace: a window containing a personal best is the opposite
of a slump, and "your pace is dropping" seconds after one reads as a bug. A recovery
increments a spell counter that is part of the dedupe key, so a second slump later in
the stint is speakable again while a still-slipping one escalates instead of repeating.
Braking point (_braking_point) compares where the brake first goes past 20 % in
the approach to each corner against the reference lap, and speaks only when every one
of the last two laps is on the same side of it and the mildest of them is still more
than 10 m off. Reporting the mildest lap rather than the worst gives a driver a marker
they can trust; one early stop is a moment, not a habit. Independent of time loss on
purpose — "am I braking too early?" deserves an answer even on a lap that was quick
elsewhere.
Corner time loss slices time_delta_series (current lap vs session best) at each
corner's entry and exit and reports the worst loss above 150 ms. Corners whose extent
wraps past the start line are skipped — the delta series restarts at zero there, so
the subtraction would be meaningless. It only fires on laps that were slower overall:
telling a driver who just set a personal best where they "lost time" reads as a bug.
It then explains how the corner was driven differently, by comparing the same two laps directly:
| Measure | How | Noise floor |
|---|---|---|
| Braking point | first sample above 20 % brake within 250 m before the corner's entry | 5 m |
| Apex speed | minimum speed between entry and exit | 2 km/h |
Either measure is dropped when it is inside its noise floor or missing (a flat-out corner has no braking point), and when neither survives the callout falls back to the plain "most time was lost in turn six" wording — inventing a cause for the loss would send the driver chasing a marker they already hit.
Distances and speeds are spoken in the units of GT7_RACE_ENGINEER_UNITS
(ctx.units). It is a server setting because the text is worded server-side; the
dashboard's own km/h-vs-mph toggle only affects what is displayed.
WebSocket protocol¶
/ws/live gained a client → server direction. Anything unparseable is ignored, so
pages from older builds (which send pings, or nothing) keep working. These messages
are not token-gated even when GT7_ADMIN_TOKEN is set: claiming voice output is
a driver-device action on a read surface, not an admin one.
Server → browser:
| Message | Payload |
|---|---|
voice_callout |
the VoiceCallout (id, event_type, text, category, priority, ttl, interrupt, dedupe key, message_key/message_args, metadata) |
voice_output_status |
{active_client_id} |
race_engineer_status |
enabled/active, verbosity, categories, coaching_ready, connected voice clients |
Browser → server:
| Message | Payload |
|---|---|
client_capabilities |
{client_id, page, voice_supported, voice_enabled} |
claim_voice_output |
{client_id} |
release_voice_output |
{client_id} |
voice_callout_ack |
{callout_id, client_id, status, spoken_at_ms} |
Callouts travel on the event lane of the per-client queue (never dropped, unlike telemetry frames): staleness is handled by expiry, not by dropping messages. Acknowledgements are diagnostics only and never gate the pipeline.
A claim is only accepted from the socket that registered that client_id, and only
from a dash or engineer page. A reconnecting client with the previous id gets its
claim back; no other device is ever promoted automatically.
message_key and message_args ride along with the English text from day one, so
localized wording can be produced in the browser later without changing the event
model.
Browser side¶
frontend/src/lib/speech.ts holds the queue: at most three messages, ordered by
priority, expired entries dropped before speaking, duplicates rejected by id and
dedupe key, and a watchdog that cancels and moves on when an utterance never reports
back (some engines never fire end, which would otherwise wedge the queue for the
rest of the session).
A failing speech engine is reported, not swallowed: SpeechSynthesisErrorEvent.error
is turned into a sentence, shown in the panel, sent back in the ack's reason and
logged server-side, because "the browser refused to make a sound" and "the backend
produced nothing" are indistinguishable to a driver otherwise. canceled and
interrupted are filtered out first — those are our own cancel() coming back
(a critical callout, a disconnect, the Test voice button) and are not faults.
speechSynthesis.resume() runs before every utterance, since Chrome leaves the
engine paused after some tab-visibility changes and speak() then does nothing at
all. The watchdog is two-phase — 10 s for the utterance to start, then a
length-based window for it to finish — because Chrome's default voices are often
network-backed and take seconds to begin; one deadline measured against completion
cancels speech that was only slow. After three consecutive failures the queue stops attempting speech and falls
back to captions, because each attempt otherwise costs a watchdog timeout during
which the messages behind it expire.
Voice selection falls back in order: the stored voiceURI, another voice in the same
language, the browser's English default, then anything installed. Nothing assumes a
particular voice name exists.
store/engineer.ts owns the per-device preferences (localStorage key
gt7-race-engineer-settings-v1) and the client id. Runtime state — the queue, what is
being spoken, server status — is deliberately excluded from persistence: callouts are
live events, not saved notifications.