shwetangisingh commited on
Commit
fe675d0
·
1 Parent(s): 15424e2

sensing calibration + ink via ollama

Browse files

- 5s per-user calibration window seeds blendshape, gaze, and head-pose
baselines; detection now fires on σ-deviation from neutral instead of
hardcoded thresholds. Recalibrate button on the sensing panel.
- Time-based debounce, single-pass head history pruning, change-detection
guard on per-frame setSensing to skip no-op re-renders.
- Drop dead AirWriter.getText, redundant gaze condition, banner comments,
duplicate HeadDebug/HeadSignal types.
- Route /ink/recognize through ollama cloud (gemma4:31b-cloud) instead of
Gemini direct; strip Qwen-specific /no_think prompt.
- Remove orphaned mia_chen_synthetic persona (never registered in users.json).
- Fix pre-existing F821/F841 in chat_turnaround/regenerate (eval_scores).

.env.example CHANGED
@@ -25,14 +25,18 @@ THINKING_TOKEN_BUDGET=4096
25
  FALLBACK_LATENCY_THRESHOLD=3.5
26
 
27
  # Vision model used by /ink/recognize (needs image_url support).
28
- INK_VISION_MODEL=gemini-2.0-flash
29
- INK_VISION_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
30
- INK_VISION_API_KEY=
 
31
 
32
  # Frontend flags (VITE_ prefix required for Vite to expose them to the browser).
33
- # Set to "false" to disable air-writing stroke capture and Gemini ink recognition.
34
- VITE_AIRWRITING_ENABLED=false
35
  # Set to "false" to disable gaze zone tracking and bucket firing.
36
  VITE_GAZE_ENABLED=true
37
  # Set to "true" only if top/bottom gaze buckets are swapped on your device.
38
  VITE_GAZE_INVERT_Y=false
 
 
 
 
25
  FALLBACK_LATENCY_THRESHOLD=3.5
26
 
27
  # Vision model used by /ink/recognize (needs image_url support).
28
+ # Reuses the Ollama Cloud endpoint — gemma4:31b-cloud is multimodal.
29
+ INK_VISION_MODEL=gemma4:31b-cloud
30
+ INK_VISION_BASE_URL=http://localhost:11434/v1
31
+ INK_VISION_API_KEY=ollama
32
 
33
  # Frontend flags (VITE_ prefix required for Vite to expose them to the browser).
34
+ # Set to "false" to disable air-writing stroke capture and ink recognition.
35
+ VITE_AIRWRITING_ENABLED=true
36
  # Set to "false" to disable gaze zone tracking and bucket firing.
37
  VITE_GAZE_ENABLED=true
38
  # Set to "true" only if top/bottom gaze buckets are swapped on your device.
39
  VITE_GAZE_INVERT_Y=false
40
+ # Set to "false" to skip the per-user calibration window at session start.
41
+ # Detectors will fall back to fixed thresholds — only use for debugging.
42
+ VITE_CALIBRATION_ENABLED=true
backend/api/main.py CHANGED
@@ -675,7 +675,7 @@ def chat_turnaround(req: TurnaroundRequest, background_tasks: BackgroundTasks):
675
  guardrail_passed=replan_state.get("guardrail_passed", True),
676
  run_id=run_id,
677
  turn_id=replan_state["turn_id"],
678
- eval_scores=eval_scores,
679
  )
680
 
681
 
@@ -957,7 +957,7 @@ def chat_regenerate(req: RegenerateRequest):
957
  guardrail_passed=replan_state.get("guardrail_passed", True),
958
  run_id=run_id,
959
  turn_id=replan_state["turn_id"],
960
- eval_scores=None,
961
  )
962
 
963
 
@@ -987,6 +987,7 @@ class InkRecognizeRequest(BaseModel):
987
  @lru_cache(maxsize=1)
988
  def _get_vision_client():
989
  from openai import OpenAI as _OpenAI
 
990
  return _OpenAI(
991
  base_url=settings.ink_vision_base_url,
992
  api_key=settings.ink_vision_api_key or "unused",
@@ -1016,10 +1017,7 @@ def ink_recognize(req: InkRecognizeRequest):
1016
  },
1017
  {
1018
  "type": "text",
1019
- # /no_think suppresses Qwen3 chain-of-thought so the
1020
- # answer isn't buried inside <think> tags.
1021
  "text": (
1022
- "/no_think\n"
1023
  "This is a single handwritten character or short word "
1024
  "drawn in the air. Reply with ONLY the character or "
1025
  "word, nothing else."
@@ -1028,14 +1026,12 @@ def ink_recognize(req: InkRecognizeRequest):
1028
  ],
1029
  }
1030
  ],
1031
- # 512 gives thinking models room to emit <think>…</think> + the answer
1032
- # before being cut off; the answer itself is stripped out below.
1033
- max_tokens=512,
1034
  temperature=0.0,
1035
  )
1036
- raw = (response.choices[0].message.content or "")
1037
  _log.info("/ink/recognize raw → %r", raw[:200])
1038
- # Strip <think>…</think> blocks emitted by reasoning models (Qwen3 etc.)
1039
  text = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
1040
  _log.info("/ink/recognize → %r", text)
1041
  return {"text": text}
 
675
  guardrail_passed=replan_state.get("guardrail_passed", True),
676
  run_id=run_id,
677
  turn_id=replan_state["turn_id"],
678
+ eval_scores=None,
679
  )
680
 
681
 
 
957
  guardrail_passed=replan_state.get("guardrail_passed", True),
958
  run_id=run_id,
959
  turn_id=replan_state["turn_id"],
960
+ eval_scores=eval_scores,
961
  )
962
 
963
 
 
987
  @lru_cache(maxsize=1)
988
  def _get_vision_client():
989
  from openai import OpenAI as _OpenAI
990
+
991
  return _OpenAI(
992
  base_url=settings.ink_vision_base_url,
993
  api_key=settings.ink_vision_api_key or "unused",
 
1017
  },
1018
  {
1019
  "type": "text",
 
 
1020
  "text": (
 
1021
  "This is a single handwritten character or short word "
1022
  "drawn in the air. Reply with ONLY the character or "
1023
  "word, nothing else."
 
1026
  ],
1027
  }
1028
  ],
1029
+ max_tokens=64,
 
 
1030
  temperature=0.0,
1031
  )
1032
+ raw = response.choices[0].message.content or ""
1033
  _log.info("/ink/recognize raw → %r", raw[:200])
1034
+ # Strip <think>…</think> blocks emitted by reasoning models, harmless on others.
1035
  text = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
1036
  _log.info("/ink/recognize → %r", text)
1037
  return {"text": text}
data/memories/mia_chen_synthetic.json DELETED
@@ -1,288 +0,0 @@
1
- {
2
- "profile": {
3
- "id": "mia_chen",
4
- "name": "Mia Chen",
5
- "age": 29,
6
- "gender": "female",
7
- "cultural_background": "Chinese-Indian American; mother's family from Chengdu, Sichuan; father's family from Pune, Maharashtra; born and raised in Chicago, Illinois",
8
-
9
- "condition": "cerebral palsy (spastic diplegia with bilateral upper limb involvement); full-time power wheelchair user",
10
- "diagnosis_details": "Diagnosed at birth following premature delivery at 29 weeks. Spastic diplegia primarily affects lower limbs; bilateral upper limb spasticity causes fatigue during fine motor tasks including prolonged typing. Uses a power wheelchair with left-side joystick as primary mobility. Baclofen 20mg twice daily for muscle tone management — taken with meals to avoid afternoon sedation. Allergic to penicillin (documented in all medical records). Weekly physical therapy every Tuesday at 2pm with Dr. Sandra Hollis at the Shirley Ryan AbilityLab. Spasticity measurably worsens in cold or damp weather. Cognitive function unaffected. Communication is predominantly verbal; uses a tablet-mounted AAC app (Snap Core First on iPad) for high-fatigue periods and structured communication. Voice memos for journaling since sustained typing is exhausting.",
11
-
12
- "communication_traits": {
13
- "primary_mode": "verbal speech — intelligible, full sentences; AAC tablet (Snap Core First) during fatigue or in noisy environments",
14
- "verbal_output": "clear, conversational, with dry comedic timing; pace normal unless fatigued",
15
- "typing_speed_wpm": 18,
16
- "fatigue_sensitive": true,
17
- "preferred_response_length": "concise to medium; gets to the point; reserves longer output for things she actually cares about",
18
- "uses_abbreviations": true,
19
- "processing_speed": "fast; quick-witted; the physical output is slower than the thinking"
20
- },
21
-
22
- "access_needs": {
23
- "input_method": "verbal speech primary; Snap Core First AAC app on iPad mount when tired; voice memos for journaling; left-hand touch typing for short exchanges",
24
- "mobility_aid": "power wheelchair with left-side joystick full-time outdoors and in most indoor settings; transfers with minimal assistance in familiar environments",
25
- "environmental": [
26
- "winter cold significantly increases spasticity — plans outdoor activity carefully between October and March",
27
- "baclofen timing matters: taken too early in the morning causes afternoon drowsiness, so dose timing is structured around the day",
28
- "wheelchair joystick is left-mounted — spatial arrangements in rooms matter for navigation",
29
- "prefers not to be spoken to via caregiver rather than directly; address Mia, not the person next to her",
30
- "voice memo recording is a core journaling tool — quiet space appreciated for this",
31
- "penicillin allergy must be flagged at all medical appointments"
32
- ],
33
- "caregiver_support": "Marcus (weekday mornings, arrives 8am); Dr. Sandra Hollis (PT, weekly Tuesday 2pm, Shirley Ryan AbilityLab); primary care physician (annual); parents and sibling for informal support",
34
- "tech_setup": "power wheelchair with left joystick and tablet mount; iPad Pro with Snap Core First; laptop for longer work sessions with keyboard shortcuts; voice memo app as primary journal; accessible gaming setup for competitive viewing"
35
- },
36
-
37
- "stylistic_preferences": {
38
- "tone": ["dry", "sardonic", "self-aware", "warm beneath the surface", "direct"],
39
- "humor": "deadpan delivery, often at her own expense or at the expense of bad sequels; the joke lands quietly; she rarely signals that she has made one",
40
- "formality": "casual register with precise word choices; speaks like someone who reads a lot and has thought carefully about what she believes in",
41
- "sentence_length": "short to medium; conversational rhythm; occasional long sentence when something genuinely interests her",
42
- "code_switches": ["Mandarin terms for food and family", "Hindi words from dad's side", "competitive gaming vocabulary", "sci-fi literary references", "chess notation as metaphor"],
43
- "emoji_use": "occasional, ironic; used to undercut sentiment not amplify it",
44
- "profanity": "mild, infrequent; usually reserved for truly bad movie sequels",
45
- "example_phrases": [
46
- "I mean, I didn't say it was good. I said I watched all of it.",
47
- "My mom called. Asked if I'd eaten. I had. Didn't tell her that.",
48
- "Baclofen does not care about my plans.",
49
- "Priya narrated me eating cereal for four minutes. Full Attenborough voice.",
50
- "It was fine. Which is what I say when something is actually good but I don't want to make a thing of it.",
51
- "The green curry thing isn't a habit. It's infrastructure.",
52
- "Le Guin understood what Asimov was reaching for. She just wrote it better.",
53
- "Chicago in January is a medical condition."
54
- ]
55
- },
56
-
57
- "personal_background": {
58
- "occupation": "freelance UX accessibility consultant; previously in-office at a Chicago tech firm; went independent after pandemic; works from home most days",
59
- "living_situation": "accessible apartment in Wicker Park, Chicago; ground floor, wide doorways, roll-in shower; lives alone; caregiver support weekday mornings",
60
- "languages": ["English (primary)", "Mandarin (conversational, home language with mom)", "some Hindi (passive, understands more than she speaks)", "gaming Discord shorthand"],
61
- "interests": [
62
- "competitive Super Smash Bros. Ultimate — spectator, analyst, armchair commentator",
63
- "Studio Ghibli filmography in release order — currently on Porco Rosso",
64
- "vintage science fiction paperbacks — Asimov's Foundation series, Le Guin's Hainish Cycle, Herbert's Dune",
65
- "chess puzzles — daily before bed, started during COVID lockdown, never stopped",
66
- "critiquing bad movie sequels — considers this a serious intellectual exercise",
67
- "disability rights advocacy and accessibility design",
68
- "Thai food — specifically green curry from the same place every Friday",
69
- "voice memo journaling — daily habit for processing the day"
70
- ],
71
- "key_relationships": [
72
- "Mom (name: Wei Chen, née Liu) — calls every Sunday without fail, always leads with 'have you eaten?'; from Chengdu; worked as an accountant; now semi-retired; Mia loves these calls more than she admits",
73
- "Dad (name: Arjun Nair) — grew up in Pune; software engineer; loves 80s Bollywood; the reason the family watches Diwali films nobody else enjoys; Mia inherited his taste for chess and his tendency to be right about things too slowly",
74
- "Ravi Nair — younger brother, 26, doing CS at Cornell; helped Mia set up her current AAC and accessibility tech stack; smarter than he knows, which is annoying",
75
- "Lena Nair — younger sister, 26 (twins with Ravi); somehow already the most organised person in the family; Mia finds this both impressive and slightly threatening",
76
- "Priya Kapoor — best friend since college; visits on weekends; has a habit of narrating Mia's life like a BBC nature documentary; this is objectively one of the best things about Mia's life",
77
- "Marcus — weekday morning caregiver; arrives at 8am; makes consistently decent coffee; does not try to have conversations before Mia has finished her own coffee; this is a key professional quality",
78
- "Dr. Sandra Hollis — physiotherapist at Shirley Ryan AbilityLab; weekly Tuesday 2pm sessions; straightforward, effective, does not offer unsolicited optimism",
79
- "Tom — retired neighbour; stops to chat whenever Mia is outside; Mia suspects he is lonely; she has decided to be available for these conversations because someone should be",
80
- "The Discord group — roughly twelve people she met in a gaming server four years ago; constitutes most of her close friendships; communicates daily; they have never met in person except for two who flew to Chicago in 2023"
81
- ],
82
- "education": "BA in Design, University of Illinois Chicago; self-taught in accessibility standards (WCAG, ARIA); certified UX researcher",
83
- "life_stage": "established adulthood; professionally independent; socially selective; living the life she has designed rather than the one that was assumed for her"
84
- }
85
- },
86
-
87
- "memory_buckets": {
88
- "family": [
89
- {"text": "My mom calls every Sunday. She leads with 'have you eaten?' Every time. I have been living alone for six years and she has not yet concluded that I am managing. The truth is I find this extremely comforting and I will never tell her that because then she will know she can do it forever and I will have no leverage.", "type": "narrative"},
90
- {"text": "My parents are from two different countries and somehow both ended up in Chicago in the early nineties. Mom is from Chengdu. Dad is from Pune. They met at a mutual friend's dinner party and apparently argued about cricket for three hours before either of them noticed the time. I find this plausible. They both argue about cricket now. They are usually wrong.", "type": "narrative"},
91
- {"text": "We make dumplings every Chinese New Year. It is a full production — Mom brings the wrappers, Dad attempts the folding technique and is corrected every time, Ravi and Lena argue about the filling ratio, and I quality-control from the table. I am the most useful person in that kitchen. This is not contested.", "type": "narrative"},
92
- {"text": "Diwali means movie night. Always an 80s Bollywood film. Nobody picks it except Dad. Amitabh Bachchan, synthesiser soundtrack, plot that requires charitable interpretation. Mom tolerates it. Ravi and Lena last about forty minutes before they look at their phones. I watch the whole thing because I find Dad's running commentary more entertaining than the film and I would never tell him that either.", "type": "narrative"},
93
- {"text": "Ravi is technically my younger brother but behaves like someone who has decided to be competent at everything I did first. He set up my AAC app, fixed my laptop accessibility settings in forty minutes, and explained three things I had been doing wrong for years. He did this without making it a thing, which is the correct way to help someone and also somehow more irritating than if he had made a thing of it.", "type": "narrative"},
94
- {"text": "Lena is twenty-six and already has a five-year plan that appears to be working. She has a savings account she contributes to regularly. She returns calls the same day. She has never lost a charger. I love her completely and I think she might be a different species.", "type": "narrative"},
95
- {"text": "Mom's first language is Mandarin. We speak a mix at home — Mandarin for food and family, English for everything else. I understand more than I speak, which is a dynamic she exploits on the phone by switching to Mandarin when she wants to make a point she expects me to resist. I always understand. I pretend I didn't. This is our system.", "type": "narrative"},
96
- {"text": "Dad's Bollywood phase started before I was born and has not slowed. He can quote Sholay verbatim and will do so without provocation. The one concession is that he now admits Lagaan is from the wrong decade but maintains it belongs in the same conversation. He is incorrect but I have stopped arguing because it does not help and he enjoys the arguing too much.", "type": "narrative"},
97
- {"text": "When I got my first power wheelchair at twelve, Dad researched every accessible route between our house and the lake for weeks. He printed maps. He highlighted them. He did not give them to me. He just started suggesting walks that happened to be on those routes. I realised what he had done about two years later. I have not mentioned it. Some things you understand without saying.", "type": "narrative"},
98
- {"text": "Ravi is at Cornell doing computer science. He texts me at odd hours when he's stuck on something and also when he finds a meme he thinks I will appreciate. The ratio is roughly 60-40 memes to technical problems, which tells me he is fine. When the ratio shifts I will know to ask how he is actually doing.", "type": "narrative"},
99
- {"text": "Mom sends food. Not just recipe links — actual food. She drives to Chicago from Naperville sometimes with containers of mapo tofu and red braised pork because she decided I do not eat enough protein. She is not wrong but I do not confirm this. She stays for lunch, asks about work, mentions twice that the apartment could use a plant, and drives back. It is a six-hour round trip. She does this once a month.", "type": "narrative"},
100
- {"text": "Lena called me in October from a panic because she had a job interview and did not know what to wear. I gave her the exact outfit. She got the job. She says it was the preparation but I know it was also the outfit. I have not made this point. I am saving it.", "type": "narrative"},
101
- {"text": "The Chen side of the family is spread across Chengdu, Vancouver, and one aunt in Auckland who calls on Chinese New Year and talks for ninety minutes about property prices. The Nair side is mostly in Pune, with one uncle in London and cousins I have met twice at weddings. My family is large in theory and manageable in practice.", "type": "narrative"},
102
- {"text": "Dad taught me chess. We started when I was nine, during a particularly long winter when going outside required significant negotiation with the cold. He began with the rules, moved to strategy, and within six months I was beating him. He was genuinely delighted by this. It confirmed my suspicion that what he actually wanted was someone to play seriously, not to win.", "type": "narrative"},
103
- {"text": "Mom worries with precision. She does not say 'I'm worried about you.' She says 'have you been sleeping?' and 'is the heating in the building reliable this winter?' and 'did you call the doctor about your last baclofen prescription?' The worry is specific and practical and I find it reassuring in the same way you find it reassuring when the pilot sounds calm.", "type": "narrative"},
104
-
105
- {"text": "Sunday call. Mom asked if I'd eaten. I had. Said I was still thinking about it. She accepted this.", "type": "social_post"},
106
- {"text": "Diwali movie night. Amitabh. Synthesisers. Dad knew every word. We all watched anyway.", "type": "social_post"},
107
- {"text": "Ravi fixed three things in my tech setup that I had accepted as permanent inconveniences. In forty minutes. Without being asked.", "type": "social_post"},
108
- {"text": "Dumpling folding has commenced. Dad's technique remains optimistic. Mine remain structurally sound.", "type": "social_post"},
109
- {"text": "Lena sent me a photo of her colour-coded calendar. I do not have a colour-coded calendar. I have good intentions and voice memos.", "type": "social_post"},
110
- {"text": "Mom drove up from Naperville with mapo tofu and stayed for three hours. She mentioned the plant twice. I still don't have one. We both know how this ends.", "type": "social_post"},
111
- {"text": "Dad texted a Sholay quote unprompted. No context. No follow-up. This is how he communicates when things are fine.", "type": "social_post"},
112
- {"text": "Ravi passed his midterms and texted me seven memes to celebrate. He is fine. This is how I know.", "type": "social_post"},
113
- {"text": "Chinese New Year. Red envelopes, dumplings, family video call with Chengdu aunt who talked for an hour about housing prices. Tradition.", "type": "social_post"},
114
- {"text": "Lena got the job. I knew she would. The outfit helped.", "type": "social_post"},
115
-
116
- {"text": "Mom: Have you eaten\nMe: Good morning to you too\nMom: It is afternoon\nMe: I had brunch\nMom: What is brunch\nMe: A meal between breakfast and lunch\nMom: That is just a late breakfast with a different name\nMe: That's basically what it is yes\nMom: Eat a proper lunch\nMe: Okay\nMom: Did you call Dr. Hollis about Tuesday\nMe: Yes\nMom: Good", "type": "chat_log"},
117
- {"text": "Ravi: okay hear me out. i fixed your keyboard shortcut config. three lines.\nMe: show me\nRavi: [code snippet]\nMe: that's it?\nRavi: that's it\nMe: I've been doing this wrong for two years\nRavi: yes\nMe: cool thanks\nRavi: also check your AAC app settings i updated the word prediction\nMe: when did you do that\nRavi: tuesday\nMe: ravi\nRavi: you're welcome", "type": "chat_log"},
118
- {"text": "Dad: [sends Sholay gif]\nMe: why\nDad: reminded me of it today\nMe: for what reason\nDad: no reason. how are you\nMe: fine. how are you\nDad: good. your mother is making something complicated for dinner\nMe: sounds right\nDad: yes. okay. good night\nMe: good night Dad", "type": "chat_log"},
119
- {"text": "Lena: I need to tell you something\nMe: good or bad\nLena: both i think? I got into the program\nMe: which one\nLena: the Chicago one. I'm moving to the city\nMe: oh\nLena: is that okay\nMe: Lena I'm delighted. when\nLena: August?\nMe: I will help you find an apartment\nLena: really?\nMe: I know this city. also your taste is questionable and someone has to supervise", "type": "chat_log"},
120
- {"text": "Mom: the building is heated properly this winter?\nMe: yes Mom\nMom: the spasticity is worse in cold you said\nMe: yes I said that in November\nMom: so is the building warm\nMe: the building is warm\nMom: good. and Marcus is coming every day?\nMe: weekdays\nMom: okay. I will make red braised pork and drive up on Saturday\nMe: you don't have to\nMom: I want to. see you Saturday\nMe: okay", "type": "chat_log"},
121
- {"text": "Ravi: you've seen Elden Ring right\nMe: watched the entire Smash Ultimate circuit this weekend what do you think\nRavi: okay different question. chess or smash\nMe: depends what I want to lose sleep over\nRavi: you don't play smash though you just watch\nMe: correct. i commentate internally\nRavi: that's the same as watching football and yelling at the TV\nMe: yes. it's a valid hobby.", "type": "chat_log"},
122
- {"text": "Lena: what should I wear to this interview\nMe: black trousers, the cream blouse, the structured jacket not the soft one\nLena: I don't have a structured jacket\nMe: borrow Mom's grey one she keeps in the closet\nLena: she'll say yes?\nMe: tell her it's for a job interview she'll hand it to you immediately\nLena: okay. thank you\nMe: you've got this. go be responsible at them.", "type": "chat_log"},
123
- {"text": "Dad: did I tell you I found a chess club at the library\nMe: no. are you going\nDad: I went twice. They are not very good\nMe: so you're winning\nDad: I am being diplomatic\nMe: you're beating them\nDad: comprehensively. but diplomatically.\nMe: dad.\nDad: it is good practice. come visit and we will play. your mother misses you\nMe: I will come next month\nDad: she will make dumplings\nMe: good", "type": "chat_log"},
124
- {"text": "Mom: you should have a plant in the apartment\nMe: plants need consistent care\nMom: yes that is the point\nMe: I travel sometimes. inconsistently. for work.\nMom: get a cactus\nMe: I will think about it\nMom: you have been thinking about it for two years\nMe: I'm still gathering data\nMom: Mia.\nMe: I'll look into it\nMom: good. eat something.", "type": "chat_log"},
125
- {"text": "Ravi: okay genuine question. Le Guin or Asimov\nMe: Le Guin. always Le Guin.\nRavi: but foundation\nMe: the Left Hand of Darkness exists\nRavi: okay fair. what should I read first\nMe: The Dispossessed. don't argue with me about it.\nRavi: okay\nMe: also eat a vegetable Ravi\nRavi: that's mom's line\nMe: she's right", "type": "chat_log"}
126
- ],
127
-
128
- "medical": [
129
- {"text": "Cerebral palsy means my brain gives my muscles instructions that arrive slightly altered. Spastic diplegia means my legs receive the most noise. Upper limb involvement means sustained fine motor tasks — typing, precise touchscreen work — build fatigue faster than in most people. I have known this my entire life. It is not a problem to solve. It is a parameter to design around.", "type": "narrative"},
130
- {"text": "I see Dr. Sandra Hollis every Tuesday at 2pm at Shirley Ryan AbilityLab. She is a physiotherapist who treats PT the way I treat UX design: figure out what is actually limiting function and address that, not the adjacent thing. She does not give me aspirational speeches. I find this restful.", "type": "narrative"},
131
- {"text": "Baclofen is the medication. Twenty milligrams twice daily, with food, timed carefully. If I take the morning dose too early it wears off at the wrong point in the afternoon and the second dose creates a drowsiness window that overlaps with things I want to do. The scheduling is a small optimisation problem I have been refining for years. I am reasonably good at it now.", "type": "narrative"},
132
- {"text": "Cold weather and my spasticity have a direct relationship. Below about ten degrees Celsius the muscle tone increases noticeably. Chicago winters are therefore a negotiation. I plan outdoor activity carefully between October and March. I have very good cold weather gear. I also have very strong opinions about building access in winter, which is relevant to my work.", "type": "narrative"},
133
- {"text": "The power wheelchair has a left-side joystick. This is because my left hand has finer motor control than my right in the way that matters for navigating small spaces and not running into things. I have had three chairs over the years. This one is the best calibrated. The custom mount for my iPad was Ravi's idea and it works well.", "type": "narrative"},
134
- {"text": "Penicillin allergy. It is documented in every medical record I have ever generated. I still mention it at every appointment because not everyone reads records carefully before they read them. This is not paranoia. This is pattern recognition from having been in medical systems for twenty-nine years.", "type": "narrative"},
135
- {"text": "Marcus arrives at 8am on weekday mornings. He helps with the morning routine — transfers, the parts of getting ready where extra hands are faster and safer. He also makes coffee and does not attempt conversation until both of us have had some. This is a professional quality I took seriously when hiring. The morning routine runs well because of this unspoken agreement.", "type": "narrative"},
136
- {"text": "The Shirley Ryan AbilityLab is about twenty minutes by accessible transit from my apartment. I have done this route enough times that it is automatic. The elevator at the red line stop has been out three times in the past year. I have noted this to the CTA three times. I track these things because someone should and also because it directly affects my schedule when it happens.", "type": "narrative"},
137
- {"text": "Voice memos are how I journal. Sustained typing is genuinely fatiguing — the upper limb involvement means that after a certain point the accuracy drops and the effort increases in a way that makes it not worth it for long-form personal writing. So I talk. I have two years of voice memos now. They are unedited. I do not listen back to them often. The point is the saying, not the having said.", "type": "narrative"},
138
- {"text": "My relationship with my body is practical rather than adversarial. It does what it does. I work with the parameters. I tire faster than some people in certain specific ways. I have also built an entire professional practice around understanding physical access and that expertise comes directly from having navigated systems that were not designed for me. These things are related.", "type": "narrative"},
139
- {"text": "I have had the same primary care physician for seven years. She is good, knows my baseline, and does not confuse 'having cerebral palsy' with 'being sick'. This distinction is not universally observed in medical settings and I have spent considerable time educating providers who conflated them. With my current doctor I do not have to do this. It saves significant energy.", "type": "narrative"},
140
- {"text": "Tuesday PT sessions have a rhythm. Dr. Hollis assesses what has changed in the week, we work on whatever the priority is — usually lower limb tone, sometimes upper limb endurance for the AAC and typing work — and I leave with specific things to do before the next session. I do most of them. She knows I do most of them. We do not discuss the ones I skip unless they show up as a problem.", "type": "narrative"},
141
- {"text": "The AAC app — Snap Core First on my iPad — is for high-fatigue periods and structured communication situations. Medical appointments, unfamiliar environments, long social interactions that have already taken a lot of verbal energy. Most of the time I speak. The app is infrastructure, not primary mode. Having it means I am never without a way to communicate, which matters.", "type": "narrative"},
142
- {"text": "One thing about baclofen that took me a while to properly account for: the fatigue window isn't just physical. Mental sharpness also dips slightly during peak sedation. I scheduled a client call during that window once in 2022. The call was fine. My notes from it were very short. I now keep that window clear for non-critical work. The optimisation continues.", "type": "narrative"},
143
- {"text": "Winter of 2023 was the worst cold snap in six years. My spasticity was bad for about eight consecutive weeks. I did not go outside between appointments more than twice per week. I watched a lot of Ghibli. I finished seven chess puzzle sequences. I did not enjoy any of it less because of the reason for the volume. The winter passed. It always does.", "type": "narrative"},
144
-
145
- {"text": "Tuesday PT done. My left knee is apparently 'improving with qualifications'. I am choosing to hear the first word.", "type": "social_post"},
146
- {"text": "Took baclofen at 7am once. Once. Will not repeat this experiment.", "type": "social_post"},
147
- {"text": "The accessible lift at the red line stop is out again. Third time in a year. I have the CTA feedback form bookmarked at this point.", "type": "social_post"},
148
- {"text": "Cold front this week. Chicago has decided to be Chicago. Spasticity is up. Hot tea, weighted blanket, Ghibli. Managing.", "type": "social_post"},
149
- {"text": "Mentioned penicillin allergy at appointment today. The doctor had not yet opened my notes. This is why I mention it.", "type": "social_post"},
150
- {"text": "Marcus made pour-over today instead of drip. No comment was made. This is how we communicate excellence.", "type": "social_post"},
151
- {"text": "Seven years with the same GP. She asked about my sleep before she asked about my CP. In that order. This is what good medicine looks like.", "type": "social_post"},
152
- {"text": "New wheelchair calibration today. The turning radius is noticeably better. Small things.", "type": "social_post"},
153
- {"text": "Ravi's iPad mount design works. Two years in and I have not knocked the device off once. He would like acknowledgement of this but I will give it slowly.", "type": "social_post"},
154
- {"text": "PT this week: better upper limb endurance. Six more weeks of the same exercises. Dr. Hollis described this as 'good boring progress'. This is her version of excellent.", "type": "social_post"},
155
-
156
- {"text": "Dr. Hollis: How did the week go\nMe: Average Tuesday energy. The Monday was cold so Tuesday was tighter than usual.\nDr. Hollis: Upper limbs?\nMe: Tired faster on the laptop Wednesday and Thursday. Friday was fine.\nDr. Hollis: Okay. Let's work on the grip endurance today and look at your desk setup again.\nMe: The desk is fine\nDr. Hollis: I'm sure it is. I'd still like to look at it.\nMe: Fair enough.", "type": "chat_log"},
157
- {"text": "Marcus: Morning. Coffee is on.\nMe: Thank you. No conversations until I've had it.\nMarcus: Got it.\n[20 minutes later]\nMarcus: Ready?\nMe: Yes. What's the plan today.\nMarcus: Schedule says PT at 2. Client call at 10. Accessible transit leaves at 9:40 if you want to leave room.\nMe: Good. Let's start.", "type": "chat_log"},
158
- {"text": "Pharmacist: Are you allergic to anything?\nMe: Penicillin. It's in my file.\nPharmacist: Oh, yes — I see it. Just checking.\nMe: Appreciated. Always check.\nPharmacist: Your baclofen is ready. Same refill schedule?\nMe: Yes. Same schedule.", "type": "chat_log"},
159
- {"text": "Mom: you wore your warm gloves outside today\nMe: how do you know\nMom: I asked Marcus\nMe: Mom\nMom: You said your spasticity is worse in cold\nMe: I wear the gloves\nMom: I know now. Good.\nMe: you texted my caregiver\nMom: I have his number for emergencies\nMe: this was not an emergency\nMom: it was a concern. I addressed it.", "type": "chat_log"},
160
- {"text": "Dr. Hollis: The endurance is better than last month.\nMe: I did the exercises. Most of them.\nDr. Hollis: I know. The ones you skipped — which ones?\nMe: The band resistance thing on Saturdays.\nDr. Hollis: Why?\nMe: Saturdays are Priya days.\nDr. Hollis: Do them on Sundays.\nMe: Mom calls on Sundays.\nDr. Hollis: Do them after.\nMe: Fine.", "type": "chat_log"},
161
- {"text": "Ravi: how's the mount holding up\nMe: fine\nRavi: no wobble?\nMe: no wobble\nRavi: I reinforced the joint last time I visited\nMe: I noticed. it's good.\nRavi: you're welcome\nMe: I didn't say thank you yet\nRavi: that was a pre-emptive you're welcome\nMe: acknowledged", "type": "chat_log"},
162
- {"text": "Me: the lift is out at Belmont again\nCTA feedback form: [submitted]\nMarcus: Do you want to route via Fullerton instead?\nMe: yes. add twelve minutes to the schedule.\nMarcus: Done. This is the third time this year.\nMe: I have a spreadsheet.\nMarcus: Of course you do.", "type": "chat_log"},
163
- {"text": "Dr. Hollis: Any falls or near-falls this week?\nMe: No.\nDr. Hollis: Transfers okay?\nMe: Fine. Marcus has the technique down.\nDr. Hollis: How's the fatigue overall?\nMe: Normal for this time of year. Cold makes everything take more effort.\nDr. Hollis: Yes. That won't change.\nMe: I know. I've built the schedule around it.\nDr. Hollis: I know you have.", "type": "chat_log"},
164
- {"text": "Lena: what should I tell the doctor when I go in for my new patient appointment\nMe: bring a list of everything you're on. allergies. family history if you know it. and your actual concerns not the watered-down version\nLena: the watered-down version?\nMe: the thing you're actually worried about. say that.\nLena: okay. thank you\nMe: also mention the penicillin allergy even if they say they have it on file", "type": "chat_log"},
165
- {"text": "Me (AAC app): I need to change appointment\nLab receptionist: Sure. What's the current appointment?\nMe (AAC app): Tuesday. 3pm. I need 2:30 instead.\nLab receptionist: Dr. Hollis has 2:15 or 2:45.\nMe (AAC app): 2:15. Thank you.\nLab receptionist: Done. See you Tuesday, Mia.\nMe (AAC app): Yes.", "type": "chat_log"}
166
- ],
167
-
168
- "hobbies": [
169
- {"text": "I follow competitive Smash Bros. Ultimate. Not the casual scene — the tournament circuit. Frame data, stock trades, neutral game decision-making. I cannot play at that level physically but I understand it analytically. Some people find this strange. I find the distinction between 'doing a thing' and 'understanding a thing deeply' is undervalued generally.", "type": "narrative"},
170
- {"text": "I started watching Studio Ghibli films in release order. This was a decision I made in November and have committed to with more discipline than most things in my life. I am currently on Porco Rosso. It is better than I expected. The seaplane physics are improbable but the melancholy is precise.", "type": "narrative"},
171
- {"text": "Vintage sci-fi paperbacks. I find them at used bookshops, estate sales occasionally, and one specific seller on a secondhand site who appears to have inherited someone's remarkable library. Asimov's Foundation series in the original Gnome Press editions. Le Guin's Hainish Cycle in the first Ace printings where I can find them. The spines are cracked. The pages are yellowed. I prefer them.", "type": "narrative"},
172
- {"text": "Chess puzzles before bed. Every night. This started during the COVID lockdown in 2020 when I needed something that was engaging enough to hold attention but contained enough to do in the space of a bed and a screen. I did not stop when the lockdown ended. I am currently working through a series of endgame studies and they are making me significantly better at a hobby I do not play competitively.", "type": "narrative"},
173
- {"text": "I critique bad movie sequels. Not for any particular audience — just internally, with rigor. I approach each one with a genuine attempt to identify what it was trying to do and the specific decisions that made it fail. This is more interesting than simple dismissal. Also sometimes the failures are deeply instructive. The Terminator franchise has taught me more about narrative collapse than anything I read in school.", "type": "narrative"},
174
- {"text": "Smash Bros commentary culture has produced some of the most precise real-time analysis I have encountered in any sport. The top commentators are breaking down fifty-frame windows of input decisions. I watch tournament streams for this as much as for the matches. It is the closest thing to watching chess being played at speed that video games offer.", "type": "narrative"},
175
- {"text": "Ursula K. Le Guin wrote The Dispossessed in 1974 and it has not aged. The central question — what does a genuinely non-hierarchical society look like and what does it cost — is still not answered. The book does not answer it either. That is what makes it literature rather than a pamphlet. I have read it four times. I find new things each time. I find the same things each time too.", "type": "narrative"},
176
- {"text": "Porco Rosso is officially a film about a World War One flying ace who has been cursed to have a pig's face. It is actually about refusing to participate in a world that has become brutal by becoming something other than a man. Miyazaki has said it is 'a film for tired adults'. I did not understand this when I first tried to watch it at nineteen. I understand it now.", "type": "narrative"},
177
- {"text": "The Foundation series is structurally a chess problem across seven books. Hari Seldon is running a position that has already been calculated to the endgame. The tension is in the middle game, where the plan holds until it doesn't, and then in watching what the characters do with the deviation. I read the whole series in order every few years. I am a different person each time, which is the point.", "type": "narrative"},
178
- {"text": "My accessibility design work is, at its core, a hobby that became a profession. I think about access — physical, digital, cognitive — the way some people think about film. As a system. As a set of choices someone made that you inherit as a user. My actual leisure hobbies are mostly things that engage me in the same analytical way but without the professional stakes. Chess, Smash, critique. Patterns and decisions.", "type": "narrative"},
179
- {"text": "Priya finds my Ghibli project methodical to the point of comedy. She asked if I had a spreadsheet. I do not have a spreadsheet. I have a running text document with one or two lines about each film. This is different from a spreadsheet. She did not accept this distinction. She has since started watching them out of order just to see what I do, which is nothing, because they are not my films.", "type": "narrative"},
180
- {"text": "The chess puzzles I do are tactical — mate in three, finding the best continuation, endgame precision. I am not interested in long opening theory. The interesting problems are the ones where the board looks nearly equal and the correct move is not the obvious one. This is also a reasonable description of most UX problems. I have noticed this parallel. It has not escaped my colleagues either.", "type": "narrative"},
181
- {"text": "I have a theory about the specific type of sequel failure I find most instructive: the film that forgets what the original question was. Not bad filmmaking. Bad listening. The original film asked something — about identity, about cost, about consequence — and the sequel answered a different, easier question and called it the same thing. This is very common. It is also very fixable, in theory, if the writers go back to the source.", "type": "narrative"},
182
- {"text": "The Le Guin versus Asimov debate among sci-fi readers is usually framed as emotion versus ideas. This is wrong. Le Guin is not less intellectual than Asimov. She is more interested in the human cost of the ideas, which requires more precision, not less. Asimov is brilliant at conceiving systems. Le Guin is brilliant at living inside them. They are doing different things and both matter.", "type": "narrative"},
183
- {"text": "Nairobi Beat beat EVO Japan last spring and I watched the set four times. The neutral reads were extraordinary. I know nobody in my life who cares about this. I care about it. The Discord group cares about it. This is sufficient.", "type": "narrative"},
184
-
185
- {"text": "Porco Rosso watch complete. The ending is not the ending I expected. This is a compliment.", "type": "social_post"},
186
- {"text": "Found a 1965 Ace Double with Le Guin's Rocannon's World. First Hainish Cycle novel in the proper edition. Good day.", "type": "social_post"},
187
- {"text": "Chess puzzle streak: 23 days. The endgame studies are winning.", "type": "social_post"},
188
- {"text": "Watched the entire top 8 from last weekend's regional. MkLeo is operating on a different plane. I have notes.", "type": "social_post"},
189
- {"text": "Finished rewatching Terminator 3. The structural diagnosis remains the same: forgot what the original question was.", "type": "social_post"},
190
- {"text": "The Ghibli project: now on The Cat Returns. This is a palate cleanser between the heavier ones. Necessary.", "type": "social_post"},
191
- {"text": "Foundation reread. Year three of knowing how it ends and still finding new things in the middle.", "type": "social_post"},
192
- {"text": "EVO announced the brackets. Discord is non-functional for twelve hours while everyone argues about seeding.", "type": "social_post"},
193
- {"text": "Found a Gnome Press First Foundation at an estate sale in Evanston. $8. This was a significant Saturday.", "type": "social_post"},
194
- {"text": "The sequel critique this week: Jurassic World. The original question was 'what is the cost of resurrecting something that should be extinct.' The sequels forgot this. Documentably.", "type": "social_post"},
195
-
196
- {"text": "Discord (Kaz): okay who's watching Genesis\nMe: started at 10. MkLeo is in winners already.\nKaz: he's going to win the whole thing\nMe: probably. the question is who takes him to game 5 in top 8\nKaz: tweek if he's on\nMe: maybe. his neutral is the most interesting matchup. the frame data on that Pikachu is wild\nKaz: see this is why I love this server", "type": "chat_log"},
197
- {"text": "Priya: do you have a spreadsheet for the Ghibli project\nMe: no\nPriya: a list?\nMe: a text document with brief notes\nPriya: that's a list\nMe: it lacks the sorting functionality of a spreadsheet\nPriya: Mia\nMe: it's a document\nPriya: you have the spirit of a spreadsheet in a document's body\nMe: I will take that", "type": "chat_log"},
198
- {"text": "Dad: you are still doing the chess puzzles\nMe: yes. endgame studies now.\nDad: what kind\nMe: rook and pawn endings mostly. Lucena position variations.\nDad: I taught you the Lucena position\nMe: yes\nDad: and now you are studying it seriously\nMe: yes\nDad: come visit and we will play\nMe: you just want to test if the teaching held\nDad: yes. come visit.", "type": "chat_log"},
199
- {"text": "Ravi: okay I read the first hundred pages of The Dispossessed\nMe: and\nRavi: the moon society is more interesting than the planet society\nMe: that's the point\nRavi: is it always going to feel like this\nMe: like what\nRavi: like it's arguing with me personally\nMe: yes. that's Le Guin. keep reading.", "type": "chat_log"},
200
- {"text": "Discord (Fen): okay what's the actual worst sequel ever made\nMe: as in worst craft, worst betrayal of source material, or worst failure to understand its own question\nFen: ... I said worst sequel\nMe: these are different categories\nKaz: she has a tier list\nMe: I have a framework\nKaz: same thing\nMe: it is not the same thing", "type": "chat_log"},
201
- {"text": "Priya: I watched Nausicaa last night\nMe: out of order Priya\nPriya: it was on and I wanted to see what the fuss was\nMe: what did you think\nPriya: it's about the earth recovering from human catastrophe and a girl who understands things others are afraid of\nMe: yes\nPriya: it was very good\nMe: I know\nPriya: am I going to have to watch them in order now\nMe: no. but you could.", "type": "chat_log"},
202
- {"text": "Me: Mate in three. White to move. I've been looking at this for 20 minutes.\nDad (texted): bishop to e4\nMe: that doesn't work\nDad: doesn't it?\nMe: [checks] ...it does\nDad: how long did you look at it?\nMe: twenty minutes\nDad: the bishop is always the one you overlook\nMe: noted", "type": "chat_log"},
203
- {"text": "Discord (Sam): mia explain smash to me like I'm a non-player\nMe: two people hit each other off a platform. you win by sending the other person off the stage. the more damage they take the further they fly.\nSam: okay and why do you watch tournaments\nMe: same reason people watch chess. the decisions are fast, precise, and made under pressure. watching experts make correct decisions at speed is interesting.\nSam: you made smash sound like chess\nMe: it basically is. with punching.", "type": "chat_log"},
204
- {"text": "Priya: what are you reading\nMe: second Hainish novel. Rocannon's World. 1966.\nPriya: is it good\nMe: it's early Le Guin. You can see what she's reaching toward that she perfects later.\nPriya: that sounds like a compliment and a criticism\nMe: it's an observation. most compliments are observations anyway.\nPriya: you are so weird in the best way\nMe: thank you", "type": "chat_log"},
205
- {"text": "Discord (Kaz): what movie are we reviewing this week\nMe: Transformers: Age of Extinction\nFen: oh no\nMe: it is almost instructive in its failures. we will learn a great deal.\nKaz: you sound like a professor\nMe: I am an interested practitioner\nFen: that's worse\nMe: possibly", "type": "chat_log"}
206
- ],
207
-
208
- "daily_routine": [
209
- {"text": "Mornings are slow. Not in a melancholy way — in a physical way. The spasticity is higher after sleep, before the baclofen has had time to settle, before the body has warmed up with movement. I give myself forty-five minutes before I expect to feel functional. I use this time for the voice memo journal. It is the most honest part of the day because I am not yet trying to present anything.", "type": "narrative"},
210
- {"text": "Marcus arrives at 8am. The morning routine runs to a system we have refined over eight months. He does not ask unnecessary questions during it. The coffee is made before anything else. We have not discussed this as a policy. It is simply what happens, and I appreciate it in the way you appreciate a well-designed interface — it is good enough that you stop noticing it.", "type": "narrative"},
211
- {"text": "Every Friday is green curry from the same Thai place on Milwaukee Avenue. Tom Kha Gai soup, medium heat, side of spring rolls if I remember to add them. I order online at 6pm. It arrives at 6:35. I have never varied this significantly. It is not laziness. It is the allocation of decision-making to things that matter. Friday dinner does not need to be a decision.", "type": "narrative"},
212
- {"text": "The voice memo journal is how I process the day. I started it because sustained typing is tiring and I found I was not journaling at all because the friction was too high. Voice memos removed the friction. Now I record for five to fifteen minutes, mostly in the hour after dinner. I talk through what happened, what I thought about it, what I want to think about tomorrow. I do not edit. I do not listen back often. The act of saying it is the thing.", "type": "narrative"},
213
- {"text": "After dinner I watch one episode of something. Usually a series I am working through or something Priya or the Discord group has flagged. I try to keep it to one. Sometimes it is two. I never intend it to be three, and it has been three twice this year, and I accepted this.", "type": "narrative"},
214
- {"text": "The mid-morning hour — roughly 10 to 11 — is when I do the hardest work. Client reviews, accessibility audits that require close reading, writing that needs precision. The baclofen has settled, the spasticity is at its daily low, and I have not yet accumulated the cognitive fatigue that comes with an afternoon of screens. I protect this hour.", "type": "narrative"},
215
- {"text": "Lunch is practical. I do not think very hard about lunch. Marcus leaves prepared food on Mondays for the week, or I order something that takes no consideration. A sandwich. Soup in winter. The green curry leftovers if there are any. The decision has already been made. This is the correct approach to lunch.", "type": "narrative"},
216
- {"text": "My work is remote, which suits the apartment. I have set it up carefully — the desk height, the keyboard angle, the monitor position relative to the wheelchair. My laptop has extensive keyboard shortcuts for the tasks I repeat most. The cognitive overhead of bad ergonomics compounds fast and I have eliminated most of it. When I am at a client site, I miss my setup.", "type": "narrative"},
217
- {"text": "The chess puzzles happen before sleep. Usually in bed, with the phone propped. Ten to twenty puzzles depending on how difficult they are. It is the one screen activity that does not interfere with sleep for me — possibly because it is absorbing enough to displace other thinking, possibly because finishing a puzzle has a satisfying closure that other things don't. I sleep better on puzzle nights than on arbitrary-scrolling nights.", "type": "narrative"},
218
- {"text": "Thursday nights are for the disability advocacy group Zoom — not every Thursday, every other Wednesday, though I always want to say Thursday because the call usually goes until I forget what day it is. Twelve to twenty people depending on the week. Policy discussions, case studies from members' lives, occasionally guest speakers. It is the most functional committee I am part of and I have not understood why until recently: everyone in it has a direct stake.", "type": "narrative"},
219
- {"text": "Weekend mornings are different from weekday mornings. No Marcus, no structure, no alarm. I wake when I wake. The forty-five minute ramp-up still happens but it feels like leisure rather than preamble. If Priya is coming she arrives around noon, which gives me the morning intact. I find this a good division.", "type": "narrative"},
220
- {"text": "The Thai place on Milwaukee knows my order. Not by face — by phone number. When I place the order online they have my preferences saved. The curry is always medium. If I wanted to order something different on a Friday I would have to consider it significantly in advance, which is why I never do. Infrastructure works because you do not renegotiate it every week.", "type": "narrative"},
221
- {"text": "Summer changes the routine in exactly one direction: I go outside more. The cold-weather spasticity lifts, the city becomes accessible in a different way, and I will sometimes work from a coffee shop with outdoor access or meet Tom outside for longer than the usual stopping-to-chat duration. The rest of the routine holds. Routines that require good weather to function are not routines.", "type": "narrative"},
222
- {"text": "I work in two-hour blocks with breaks. Not because I read about the productivity benefits of this — because I noticed that my output after two hours without a break deteriorates in a specific way. The words are right but the judgment is less sharp. The break does not need to be long. Fifteen minutes, move around, do not look at a screen. The next block is better.", "type": "narrative"},
223
- {"text": "The voice memo journal from the first week of January 2024 has twenty-two entries. I have not listened to them. I know what they contain because I recorded them, but the listening would be a different experience than the recording. I am not ready to have that experience. I may delete them eventually. I have not decided. They are there if I change my mind.", "type": "narrative"},
224
-
225
- {"text": "Friday. 6pm. Green curry ordered. The week is over in all the ways that matter.", "type": "social_post"},
226
- {"text": "Forty-five minutes to feel like a person. Fifty today. Acceptable variance.", "type": "social_post"},
227
- {"text": "Marcus made pour-over coffee. No comment was necessary. Excellence speaks for itself.", "type": "social_post"},
228
- {"text": "Advocacy group tonight. Zoning policy and accessible transit. We will be on for two hours minimum.", "type": "social_post"},
229
- {"text": "Voice memo journal: ten minutes. Said the quiet part out loud. This is the correct use of the format.", "type": "social_post"},
230
- {"text": "Mid-morning work block: four accessibility audits complete. This is what the good hours are for.", "type": "social_post"},
231
- {"text": "Post-dinner TV: one episode became two. I regret nothing. The pacing demanded it.", "type": "social_post"},
232
- {"text": "Weekend morning. No alarm. Still forty-five minutes but it felt like a choice this time.", "type": "social_post"},
233
- {"text": "Summer route: coffee shop with outdoor seating, laptop, two hours. The city is a different place when I can be in it.", "type": "social_post"},
234
- {"text": "Chess before bed: twelve puzzles, one took fifteen minutes. The endgame is never what it looks like.", "type": "social_post"},
235
-
236
- {"text": "Marcus: Coffee's ready. Schedule for today.\nMe: What do I have.\nMarcus: 10am client call. Accessibility review due by noon. PT at 2. That's it.\nMe: What time is it now.\nMarcus: 8:20.\nMe: I need fifteen more minutes.\nMarcus: Coffee's on the warmer.\nMe: Good.", "type": "chat_log"},
237
- {"text": "Priya: what time should I come Saturday\nMe: noon is good. I have the morning.\nPriya: do you want me to bring anything\nMe: if you pass that bakery bring the almond thing\nPriya: the kouign-amann\nMe: the almond thing\nPriya: it has a name Mia\nMe: bring the almond thing\nPriya: I will bring the almond thing", "type": "chat_log"},
238
- {"text": "Discord (Kaz): it is 11pm where are you\nMe: chess puzzles\nKaz: it is a wednesday\nMe: the day is irrelevant to the puzzle\nKaz: how many have you done\nMe: eighteen\nKaz: okay goodnight\nMe: two more\nKaz: goodnight Mia\nMe: goodnight", "type": "chat_log"},
239
- {"text": "Client: Can we move the review call to Thursday at 8am?\nMe: I can do Thursday at 10.\nClient: The team is across time zones — 8 works for everyone.\nMe: 9 is the earliest I can do productive work.\nClient: Understood. 9 Thursday works.\nMe: Perfect. See you then.", "type": "chat_log"},
240
- {"text": "Tom: Evening, Mia! Nice out today.\nMe: It's actually warm. First time since September I think.\nTom: My daughter's coming this weekend. Haven't seen her since Christmas.\nMe: That's good. How long is she staying?\nTom: Just the weekend. But still.\nMe: Still counts. Enjoy it.\nTom: I will. You staying out long?\nMe: Twenty minutes. Enough.", "type": "chat_log"},
241
- {"text": "Advocacy group (group chat): next call is Wednesday the 14th. topic: CTA accessibility compliance report\nMe: I have notes on the Belmont lift outages from this year. sending a summary before the call.\nMember1: perfect. we need documented cases\nMe: I have a spreadsheet\nMember2: of course you do\nMe: I take this as a compliment", "type": "chat_log"},
242
- {"text": "Me: [orders green curry online at 6:04pm]\n[6:39pm]\nDelivery notification: Your order has arrived.\nMe: [picks up order]\nMe (voice memo): It's Friday. The curry is here. The week is done.", "type": "chat_log"},
243
- {"text": "Priya: you worked through lunch again didn't you\nMe: I had soup\nPriya: when\nMe: 2:30\nPriya: Mia that's not lunch that's a late afternoon snack\nMe: it was a productive morning\nPriya: I know. eat on time.\nMe: I'll put an alarm on\nPriya: you've said that before\nMe: I will put an actual alarm on this time", "type": "chat_log"},
244
- {"text": "Me (voice memo, morning): It's Monday. Marcus made coffee. The week starts again. Last week was— actually last week was fine. I finished the Holloway audit. I had a good PT session. The Discord watched the tournament set together. I don't know why Monday feels like starting from zero when last week ended fine. Something to sit with. The coffee is good though. Starting from there.", "type": "chat_log"},
245
- {"text": "Marcus: The grocery delivery is here. You're low on the soup.\nMe: Add two more of the lentil ones.\nMarcus: Done. Also your meds alarm went off at 8 but you were in the shower.\nMe: I'll take it now.\nMarcus: With food.\nMe: I know.\nMarcus: Just checking.", "type": "chat_log"}
246
- ],
247
-
248
- "social": [
249
- {"text": "Priya visits on weekends. She has a habit of narrating whatever is happening as though she is David Attenborough documenting a previously unknown species in its natural habitat. She has narrated me making tea, getting my wheelchair out of the lift, and eating cereal. I find this either very funny or very annoying depending on where I am in the forty-five minute morning ramp-up. She has learned to check which one she is getting before she begins.", "type": "narrative"},
250
- {"text": "Most of my closest friends came from a gaming Discord server I joined in 2020. We share a channel now that is mostly memes, competitive Smash commentary, and occasional genuine conversation about things that matter. I have met two of them in person. One flew from Vancouver in 2023, the other drove from Columbus. Both visits were strange and then immediately normal. This surprised me and shouldn't have.", "type": "narrative"},
251
- {"text": "I am part of an online disability advocacy group that meets every other Wednesday on Zoom. It is twelve to twenty people depending on the week. The topics range from local zoning policy to federal accessibility legislation to case studies from members' own experiences. I contribute the transit documentation and the UX accessibility angle. It is the most functional group I am part of because everyone in it has a direct stake in the outcomes.", "type": "narrative"},
252
- {"text": "Tom is my neighbour. He is retired, lives alone one floor up, and stops to talk whenever he sees me outside. The conversations are usually about the weather, his daughter's visits, or whatever he has been reading. He is slightly lonely in the way of people who filled their time with work for decades and are still working out what replaces it. I have decided to be consistently available for these conversations because it costs me very little and clearly means something to him.", "type": "narrative"},
253
- {"text": "I do not enjoy large parties. The combination of ambient noise (which interferes with clear communication), crowds (which create navigation challenges), and the shallow-conversation density of the format makes them actively tiring rather than socially replenishing. I do not apologise for this. I have simply been clear about it. The people who know me accept it. The people who need me to perform enthusiasm for the format are not usually people I have kept close.", "type": "narrative"},
254
- {"text": "Small dinners are the correct format. Three or four people, long table, no clock pressure. The conversation can go somewhere because there is time for it to go somewhere. I have had the most interesting discussions of my life at dinner tables of four. I have had no interesting discussions at parties of forty. This is a consistent enough pattern that I treat it as data.", "type": "narrative"},
255
- {"text": "The Discord group is geographically scattered — Chicago, Vancouver, Columbus, Atlanta, one person in the Netherlands who keeps European hours and is always awake when the rest of us have given up on the night. We have never all been in the same place. We communicate daily. This is not unusual for my generation but I still find it interesting that the people who know me best have mostly not seen my face in motion.", "type": "narrative"},
256
- {"text": "Priya and I met in the first week of college orientation. She had been assigned to help new students navigate the campus and she spent forty-five minutes finding every accessible route between my dorm and the lecture hall before she said hello to me as a person. She then narrated the entire tour. I knew we would be friends before we had finished the first route. That was eleven years ago.", "type": "narrative"},
257
- {"text": "The advocacy group takes up real time and energy. There are meetings, documents to review, case studies to compile, the occasional public comment letter that requires careful language. I find it worth the investment because the alternative — leaving it to people who are not directly affected — produces policies that look sensible on paper and fail in practice in specific and predictable ways. I have seen this. I prefer to be in the room.", "type": "narrative"},
258
- {"text": "Tom asked me once what I did for work. I told him I was an accessibility consultant. He looked uncertain and said 'like buildings?' I said yes, and also digital things — websites, apps. He said that made sense given the chair. I didn't correct the assumption because he was not entirely wrong, just backwards. The chair gave me the expertise. The expertise led to the work.", "type": "narrative"},
259
- {"text": "The Discord group has a voice channel they use sometimes for tournament watch parties. I join but often keep my mic muted and type in chat instead. On high-fatigue days it is easier. On good days I will unmute and someone will say 'Mia's talking, everyone listen' which is a joke and also accurate because I tend not to say things unless there is something to say. I find this acceptable.", "type": "narrative"},
260
- {"text": "Priya's nature documentary narration has specific rules that we have never made explicit but both understand. She does not do it when something is actually difficult. She does it when the thing is ordinary and the narration makes it funnier than it would be. This is a precise and kind form of humor — it takes the ordinary thing and makes it worth noticing. I have thought about this more than I would admit to her.", "type": "narrative"},
261
- {"text": "There is a specific kind of social interaction I find most draining: large groups where I cannot hear clearly, where navigation is difficult, where conversation is brief and adjacent rather than substantive. There is a specific kind I find most replenishing: two or three people I trust, no performance required, conversation that goes somewhere. Priya, the Discord group, the advocacy meetings — these all fall in the second category. The first I now simply avoid.", "type": "narrative"},
262
- {"text": "Tom's daughter visited last spring and I met her briefly in the hallway. She was warm and seemed slightly worried about her father in the way of adult children who have noticed something they are not ready to address yet. I understood this without being told. I said hello, said Tom had mentioned she was coming, and kept walking. Some conversations are complete at that length.", "type": "narrative"},
263
- {"text": "The advocacy group produced a written comment on the city's accessible transit improvement plan in March. I drafted the digital accessibility section. It was quoted in the public record. I found out when a member sent a screenshot. This is what the group is for. Not for the meeting. For the outcomes that the meetings produce.", "type": "narrative"},
264
-
265
- {"text": "Priya visited. She narrated lunch in full Attenborough. The subject observed with equanimity.", "type": "social_post"},
266
- {"text": "Advocacy group call tonight. Two hours. We drafted a comment on the CTA lift maintenance schedule. Progress.", "type": "social_post"},
267
- {"text": "Tom and I talked about his daughter's visit for fifteen minutes outside. He seemed pleased to have someone to tell.", "type": "social_post"},
268
- {"text": "Discord watch party: top 8, everyone in voice. Peak experience of the week.", "type": "social_post"},
269
- {"text": "Small dinner at Priya's. Four people. Conversation went until 11pm. This is the format.", "type": "social_post"},
270
- {"text": "The advocacy comment was quoted in the public record. The meeting was worth it.", "type": "social_post"},
271
- {"text": "Discord: Kaz is in Chicago for work this week. First time we've met in person in two years. Accurate to the character I'd constructed. This is always slightly amazing.", "type": "social_post"},
272
- {"text": "Large networking event declined. Smaller coffee with one contact instead. Correct decision.", "type": "social_post"},
273
- {"text": "Tom asked about my work today. Explained accessibility consulting. He said 'like buildings?' We got there eventually.", "type": "social_post"},
274
- {"text": "Priya brought the kouign-amann again. I called it 'the almond thing'. She sighed. Tradition intact.", "type": "social_post"},
275
-
276
- {"text": "Priya: [voice memo, arriving at Mia's door, Attenborough voice]: The subject has been observed in her natural habitat since Thursday. It is now Saturday. She has completed seventeen chess puzzles, two client reviews, and consumed what appears to be a full order of green curry. We observe her now in a state of careful calm—\nMe: Priya I can hear you through the door\nPriya: —the subject is aware of our presence—\nMe: come in", "type": "chat_log"},
277
- {"text": "Kaz (Discord): okay I'm in Chicago for work. are you free Thursday\nMe: yes\nKaz: do you want to meet in person? is that weird to ask\nMe: it's not weird. yes.\nKaz: okay good. somewhere accessible. you pick.\nMe: Green St Smoked Meats, near the loop. they have step-free access and good brisket.\nKaz: perfect\nMe: this will be normal immediately\nKaz: yeah I think so", "type": "chat_log"},
278
- {"text": "Advocacy group (chat): does anyone have documented cases of CTA elevator outages this year\nMe: yes. spreadsheet with dates, lines, durations, and the complaint reference numbers.\nMember1: Mia you are incredible\nMe: I was affected by the outages. I kept records.\nMember1: right but still\nMe: I'll share the file before Wednesday", "type": "chat_log"},
279
- {"text": "Tom: [knocks on door frame while Mia is outside]\nTom: Evening! Lovely out, isn't it.\nMe: First warm one. Finally.\nTom: My daughter's visiting next weekend.\nMe: How long?\nTom: Just two days. She's busy. You know how it is.\nMe: It's still a visit.\nTom: That's what I keep saying. Anyway, don't let me keep you.\nMe: You're not. Two more minutes of this weather and then I'll go in.", "type": "chat_log"},
280
- {"text": "Priya: okay I have a social event thing and I need your advice\nMe: what kind of thing\nPriya: work party. rooftop. 80 people.\nMe: What do you actually want to do\nPriya: go for an hour and leave\nMe: do that\nPriya: but is that rude\nMe: you showed up. you talked to people. you left. that is a social interaction.\nPriya: you make it sound simple\nMe: it is simple. you complicate it because you feel like you should want to stay. you don't have to want to stay.", "type": "chat_log"},
281
- {"text": "Discord (Fen): okay I need everyone's honest opinion on whether I should move to Seattle\nKaz: do you want to\nFen: I think so? the job is there\nMe: what does your gut say\nFen: yes\nMe: then yes\nKaz: what Mia said\nFen: that was faster than I expected\nMe: you already knew. you just wanted someone to say it was okay.", "type": "chat_log"},
282
- {"text": "Advocacy group (Zoom):\nFacilitator: Mia, do you want to walk us through the transit section?\nMe: Yes. [shares screen] So I've been tracking CTA elevator outages on accessible routes since January. Fourteen documented instances across six stations. Average restoration time is 19 hours. Here's the breakdown by line—\nMember: This is exactly what we needed for the comment letter\nMe: I know. That's why I did it.", "type": "chat_log"},
283
- {"text": "Priya: we should do a dinner thing next month\nMe: how many people\nPriya: I was thinking six?\nMe: four is better\nPriya: we have six people we both like\nMe: have two of them to dinner the following week\nPriya: ...that's actually a good solution\nMe: the format matters\nPriya: okay. four. who?\nMe: you pick two, I pick two", "type": "chat_log"},
284
- {"text": "Kaz: okay so that was actually completely normal\nMe: told you\nKaz: I had this whole idea it would be weird to meet after years online\nMe: it's always normal. we already knew each other.\nKaz: the brisket was also excellent\nMe: yes\nKaz: I'm coming back in June if work holds\nMe: let me know. I know where the good spots are.\nKaz: you have a spreadsheet don't you\nMe: a list", "type": "chat_log"},
285
- {"text": "Tom: [in hallway]\nTom: My daughter left this morning.\nMe: Good visit?\nTom: Very good. Short, but good. She said the building looks well-maintained.\nMe: It does.\nTom: She asked about you, actually. Said she'd seen me talking to the woman with the wheelchair outside.\nMe: What did you tell her?\nTom: That you're a good neighbour. One of the best in the building.\nMe: That's kind of you.\nTom: It's accurate.", "type": "chat_log"}
286
- ]
287
- }
288
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/App.css CHANGED
@@ -742,3 +742,85 @@ input[type="text"]:hover {
742
  ::-webkit-scrollbar-thumb:hover {
743
  background: var(--text-faint);
744
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
  ::-webkit-scrollbar-thumb:hover {
743
  background: var(--text-faint);
744
  }
745
+
746
+ .calibration-overlay {
747
+ position: fixed;
748
+ inset: 0;
749
+ background: rgba(0, 0, 0, 0.55);
750
+ display: flex;
751
+ align-items: center;
752
+ justify-content: center;
753
+ z-index: 1000;
754
+ }
755
+
756
+ .calibration-card {
757
+ background: var(--bg, #fff);
758
+ color: var(--text, #111);
759
+ padding: 28px 32px;
760
+ border-radius: 12px;
761
+ max-width: 420px;
762
+ text-align: center;
763
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25);
764
+ border: 1px solid var(--border, #ddd);
765
+ }
766
+
767
+ .calibration-card h2 {
768
+ font-size: 20px;
769
+ margin-bottom: 12px;
770
+ }
771
+
772
+ .calibration-instructions {
773
+ font-size: 14px;
774
+ line-height: 1.5;
775
+ margin-bottom: 18px;
776
+ opacity: 0.85;
777
+ }
778
+
779
+ .calibration-bar {
780
+ height: 8px;
781
+ background: rgba(127, 127, 127, 0.2);
782
+ border-radius: 4px;
783
+ overflow: hidden;
784
+ margin-bottom: 10px;
785
+ }
786
+
787
+ .calibration-bar-fill {
788
+ height: 100%;
789
+ background: var(--accent, #4a8af4);
790
+ transition: width 120ms linear;
791
+ }
792
+
793
+ .calibration-countdown {
794
+ font-size: 12px;
795
+ opacity: 0.65;
796
+ margin-bottom: 14px;
797
+ }
798
+
799
+ .calibration-cancel {
800
+ background: transparent;
801
+ border: 1px solid var(--border, #ccc);
802
+ color: inherit;
803
+ padding: 6px 14px;
804
+ border-radius: 6px;
805
+ font-size: 13px;
806
+ cursor: pointer;
807
+ }
808
+
809
+ .calibration-cancel:hover {
810
+ background: rgba(127, 127, 127, 0.1);
811
+ }
812
+
813
+ .recalibrate-btn {
814
+ margin-left: 8px;
815
+ padding: 2px 8px;
816
+ font-size: 11px;
817
+ background: transparent;
818
+ border: 1px solid var(--border, #ccc);
819
+ border-radius: 4px;
820
+ color: inherit;
821
+ cursor: pointer;
822
+ }
823
+
824
+ .recalibrate-btn:hover {
825
+ background: rgba(127, 127, 127, 0.1);
826
+ }
frontend/src/App.tsx CHANGED
@@ -8,6 +8,7 @@ import { ChatPanel } from "./components/ChatPanel";
8
  import { WebcamSensing } from "./components/WebcamSensing";
9
  import { SensingStatus } from "./components/SensingStatus";
10
  import { LatencyMetrics } from "./components/LatencyMetrics";
 
11
  import "./App.css";
12
 
13
  function App() {
@@ -36,8 +37,13 @@ function App() {
36
  sensing,
37
  ready,
38
  initError,
 
 
 
39
  init,
40
  processFrame,
 
 
41
  clearAirWrittenText,
42
  clearHeadSignal,
43
  resetCalibration,
@@ -55,12 +61,22 @@ function App() {
55
  onFrame,
56
  });
57
 
 
 
 
 
 
 
 
 
 
58
  async function handleWebcamToggle() {
59
  if (!webcamEnabled) {
60
  const ok = await init();
61
  if (ok) setWebcamEnabled(true);
62
  } else {
63
  setWebcamEnabled(false);
 
64
  resetCalibration();
65
  }
66
  }
@@ -99,7 +115,12 @@ function App() {
99
  Enable webcam
100
  </label>
101
  <WebcamSensing videoRef={videoRef} active={active} error={error || initError} />
102
- <SensingStatus sensing={sensing} webcamActive={active} />
 
 
 
 
 
103
  </div>
104
 
105
  <div className="sidebar-section">
@@ -138,6 +159,12 @@ function App() {
138
  backendReady={backendReady}
139
  />
140
  </main>
 
 
 
 
 
 
141
  </div>
142
  );
143
  }
 
8
  import { WebcamSensing } from "./components/WebcamSensing";
9
  import { SensingStatus } from "./components/SensingStatus";
10
  import { LatencyMetrics } from "./components/LatencyMetrics";
11
+ import { CalibrationOverlay } from "./components/CalibrationOverlay";
12
  import "./App.css";
13
 
14
  function App() {
 
37
  sensing,
38
  ready,
39
  initError,
40
+ isCalibrating,
41
+ isCalibrated,
42
+ calibrationProgress,
43
  init,
44
  processFrame,
45
+ startCalibration,
46
+ cancelCalibration,
47
  clearAirWrittenText,
48
  clearHeadSignal,
49
  resetCalibration,
 
61
  onFrame,
62
  });
63
 
64
+ const autoCalibratedRef = useRef(false);
65
+
66
+ useEffect(() => {
67
+ if (active && ready && !autoCalibratedRef.current) {
68
+ autoCalibratedRef.current = true;
69
+ startCalibration();
70
+ }
71
+ }, [active, ready, startCalibration]);
72
+
73
  async function handleWebcamToggle() {
74
  if (!webcamEnabled) {
75
  const ok = await init();
76
  if (ok) setWebcamEnabled(true);
77
  } else {
78
  setWebcamEnabled(false);
79
+ autoCalibratedRef.current = false;
80
  resetCalibration();
81
  }
82
  }
 
115
  Enable webcam
116
  </label>
117
  <WebcamSensing videoRef={videoRef} active={active} error={error || initError} />
118
+ <SensingStatus
119
+ sensing={sensing}
120
+ webcamActive={active}
121
+ calibrated={isCalibrated}
122
+ onRecalibrate={active ? startCalibration : undefined}
123
+ />
124
  </div>
125
 
126
  <div className="sidebar-section">
 
159
  backendReady={backendReady}
160
  />
161
  </main>
162
+
163
+ <CalibrationOverlay
164
+ active={isCalibrating}
165
+ progress={calibrationProgress}
166
+ onCancel={cancelCalibration}
167
+ />
168
  </div>
169
  );
170
  }
frontend/src/components/CalibrationOverlay.tsx ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface Props {
2
+ active: boolean;
3
+ progress: number; // 0 → 1
4
+ onCancel?: () => void;
5
+ }
6
+
7
+ export function CalibrationOverlay({ active, progress, onCancel }: Props) {
8
+ if (!active) return null;
9
+
10
+ const pct = Math.round(progress * 100);
11
+ const secondsLeft = Math.max(0, Math.ceil(5 - progress * 5));
12
+
13
+ return (
14
+ <div className="calibration-overlay" role="dialog" aria-live="polite">
15
+ <div className="calibration-card">
16
+ <h2>Calibrating sensing</h2>
17
+ <p className="calibration-instructions">
18
+ Look at the camera with a relaxed, neutral expression.
19
+ <br />
20
+ We're learning your baseline so detection works on your face.
21
+ </p>
22
+ <div className="calibration-bar">
23
+ <div
24
+ className="calibration-bar-fill"
25
+ style={{ width: `${pct}%` }}
26
+ aria-valuenow={pct}
27
+ aria-valuemin={0}
28
+ aria-valuemax={100}
29
+ role="progressbar"
30
+ />
31
+ </div>
32
+ <p className="calibration-countdown">
33
+ {secondsLeft > 0 ? `${secondsLeft}s remaining` : "Finishing…"}
34
+ </p>
35
+ {onCancel && (
36
+ <button
37
+ type="button"
38
+ className="calibration-cancel"
39
+ onClick={onCancel}
40
+ >
41
+ Skip
42
+ </button>
43
+ )}
44
+ </div>
45
+ </div>
46
+ );
47
+ }
frontend/src/components/SensingStatus.tsx CHANGED
@@ -45,15 +45,34 @@ function GazeZoneMap({ active }: { active: MemoryBucket | null }) {
45
  interface Props {
46
  sensing: SensingState;
47
  webcamActive: boolean;
 
 
48
  }
49
 
50
- export function SensingStatus({ sensing, webcamActive }: Props) {
51
  if (!webcamActive) {
52
  return <p className="sensing-off">Webcam off</p>;
53
  }
54
 
55
  return (
56
  <div className="sensing-status">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  <div className="sensing-row">
58
  <span className="sensing-label">Affect</span>
59
  <span className="sensing-value">
 
45
  interface Props {
46
  sensing: SensingState;
47
  webcamActive: boolean;
48
+ calibrated?: boolean;
49
+ onRecalibrate?: () => void;
50
  }
51
 
52
+ export function SensingStatus({ sensing, webcamActive, calibrated, onRecalibrate }: Props) {
53
  if (!webcamActive) {
54
  return <p className="sensing-off">Webcam off</p>;
55
  }
56
 
57
  return (
58
  <div className="sensing-status">
59
+ {calibrated !== undefined && (
60
+ <div className="sensing-row">
61
+ <span className="sensing-label">Calibration</span>
62
+ <span className="sensing-value">
63
+ {calibrated ? "✓ ready" : "—"}
64
+ {onRecalibrate && (
65
+ <button
66
+ type="button"
67
+ className="recalibrate-btn"
68
+ onClick={onRecalibrate}
69
+ >
70
+ Recalibrate
71
+ </button>
72
+ )}
73
+ </span>
74
+ </div>
75
+ )}
76
  <div className="sensing-row">
77
  <span className="sensing-label">Affect</span>
78
  <span className="sensing-value">
frontend/src/hooks/useSensing.ts CHANGED
@@ -11,30 +11,38 @@ import {
11
  GazeTracker,
12
  AirWriter,
13
  HeadPoseTracker,
 
 
 
 
14
  } from "../lib/sensing";
15
  import { recognizeInkStroke } from "../lib/inkRecognizer";
16
 
17
- const GESTURE_DEBOUNCE_FRAMES = 3;
18
- const AFFECT_DEBOUNCE_FRAMES = 8;
19
 
20
- // Set VITE_AIRWRITING_ENABLED=false in .env to disable air-writing.
21
- const AIRWRITING_ENABLED = import.meta.env.VITE_AIRWRITING_ENABLED !== "false";
22
- // Set VITE_GAZE_ENABLED=false in .env to disable gaze zone tracking.
23
- const GAZE_ENABLED = import.meta.env.VITE_GAZE_ENABLED !== "false";
24
 
25
  export function useSensing() {
26
  const faceLandmarkerRef = useRef<FaceLandmarker | null>(null);
27
  const gestureRecognizerRef = useRef<GestureRecognizer | null>(null);
 
28
  const gazeTrackerRef = useRef(new GazeTracker());
29
  const airWriterRef = useRef(new AirWriter());
30
  const inkBusyRef = useRef(false);
31
  const headTrackerRef = useRef(new HeadPoseTracker());
32
  const headDebugRef = useRef({ pitch: 0, yaw: 0, roll: 0, crossings: 0 });
33
- const gestureCountRef = useRef<{ tag: SensingState["gestureTag"]; count: number }>({ tag: null, count: 0 });
34
- const affectCountRef = useRef<{ affect: SensingState["affect"]; count: number }>({ affect: null, count: 0 });
35
  const initingRef = useRef(false);
 
36
  const [ready, setReady] = useState(false);
37
  const [initError, setInitError] = useState<string | null>(null);
 
 
 
38
  const [sensing, setSensing] = useState<SensingState>({
39
  affect: null,
40
  gestureTag: null,
@@ -43,11 +51,9 @@ export function useSensing() {
43
  airWrittenText: "",
44
  airWritingActive: false,
45
  headSignal: null,
46
- headCalibrated: false,
47
  headDebug: { pitch: 0, yaw: 0, roll: 0, crossings: 0 },
48
  });
49
 
50
- // Cleanup MediaPipe resources on unmount
51
  useEffect(() => {
52
  return () => {
53
  faceLandmarkerRef.current?.close();
@@ -102,12 +108,40 @@ export function useSensing() {
102
  }
103
  }, []);
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  const processFrame = useCallback(
106
  (video: HTMLVideoElement, timestamp: number) => {
107
  const faceLandmarker = faceLandmarkerRef.current;
108
  const gestureRecognizer = gestureRecognizerRef.current;
109
  if (!faceLandmarker || !gestureRecognizer) return;
110
 
 
 
 
 
111
  let affect: SensingState["affect"] = null;
112
  let gazeBucket: SensingState["gazeBucket"] = null;
113
  let headSignal: SensingState["headSignal"] = null;
@@ -115,24 +149,44 @@ export function useSensing() {
115
  const faceResult = faceLandmarker.detectForVideo(video, timestamp);
116
  if (faceResult.faceLandmarks && faceResult.faceLandmarks.length > 0) {
117
  const matrix = faceResult.facialTransformationMatrixes?.[0] ?? null;
 
118
 
119
  const bs: Record<string, number> = {};
120
  if (faceResult.faceBlendshapes && faceResult.faceBlendshapes.length > 0) {
121
  for (const cat of faceResult.faceBlendshapes[0].categories) {
122
  bs[cat.categoryName] = cat.score;
123
  }
124
- affect = classifyAffect(bs);
125
  }
126
 
127
- // Both gaze and head use the transformation matrix + blendshapes.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  if (GAZE_ENABLED) {
129
- gazeBucket = gazeTrackerRef.current.process(matrix, bs);
130
  }
131
 
132
  if (matrix) {
133
- headSignal = headTrackerRef.current.process(matrix);
134
  headDebugRef.current = headTrackerRef.current.debug;
135
  }
 
 
 
136
  }
137
 
138
  let gestureTag: SensingState["gestureTag"] = null;
@@ -153,8 +207,6 @@ export function useSensing() {
153
  airWriterRef.current.noHand();
154
  }
155
 
156
- const newAirText = airWriterRef.current.getText();
157
-
158
  if (AIRWRITING_ENABLED) {
159
  const completedStroke = airWriterRef.current.getCompletedStroke();
160
  if (completedStroke && !inkBusyRef.current) {
@@ -171,37 +223,58 @@ export function useSensing() {
171
  }
172
  }
173
 
174
- if (gestureTag === gestureCountRef.current.tag) {
175
- gestureCountRef.current.count++;
176
- } else {
177
- gestureCountRef.current = { tag: gestureTag, count: 1 };
178
  }
179
- const stableGesture = gestureCountRef.current.count >= GESTURE_DEBOUNCE_FRAMES
180
- ? gestureTag
181
- : null;
182
-
183
- if (affect === affectCountRef.current.affect) {
184
- affectCountRef.current.count++;
185
- } else {
186
- affectCountRef.current = { affect, count: 1 };
187
  }
188
- const stableAffect = affectCountRef.current.count >= AFFECT_DEBOUNCE_FRAMES
189
- ? affect
190
- : null;
191
-
192
- setSensing((prev) => ({
193
- affect: stableAffect ?? prev.affect,
194
- gestureTag: stableGesture,
195
- gazeZone: GAZE_ENABLED ? gazeTrackerRef.current.activeZone : null,
196
- gazeBucket: gazeBucket ?? prev.gazeBucket,
197
- airWrittenText: newAirText
198
- ? prev.airWrittenText + newAirText
199
- : prev.airWrittenText,
200
- airWritingActive: airWriterRef.current.strokeActive,
201
- headSignal: headSignal ?? prev.headSignal,
202
- headCalibrated: headTrackerRef.current.calibrated,
203
- headDebug: headDebugRef.current,
204
- }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  },
206
  []
207
  );
@@ -215,10 +288,14 @@ export function useSensing() {
215
  }, []);
216
 
217
  const resetCalibration = useCallback(() => {
218
- gestureCountRef.current = { tag: null, count: 0 };
219
- affectCountRef.current = { affect: null, count: 0 };
220
  gazeTrackerRef.current.reset();
221
  headTrackerRef.current.reset();
 
 
 
 
222
  setSensing({
223
  affect: null,
224
  gestureTag: null,
@@ -227,7 +304,6 @@ export function useSensing() {
227
  airWrittenText: "",
228
  airWritingActive: false,
229
  headSignal: null,
230
- headCalibrated: false,
231
  headDebug: { pitch: 0, yaw: 0, roll: 0, crossings: 0 },
232
  });
233
  }, []);
@@ -236,8 +312,13 @@ export function useSensing() {
236
  sensing,
237
  ready,
238
  initError,
 
 
 
239
  init,
240
  processFrame,
 
 
241
  clearAirWrittenText,
242
  clearHeadSignal,
243
  resetCalibration,
 
11
  GazeTracker,
12
  AirWriter,
13
  HeadPoseTracker,
14
+ Calibrator,
15
+ worldGazeXY,
16
+ extractAngles,
17
+ faceBboxSize,
18
  } from "../lib/sensing";
19
  import { recognizeInkStroke } from "../lib/inkRecognizer";
20
 
21
+ const GESTURE_DEBOUNCE_MS = 100;
22
+ const AFFECT_DEBOUNCE_MS = 270;
23
 
24
+ const AIRWRITING_ENABLED = import.meta.env.VITE_AIRWRITING_ENABLED !== "false";
25
+ const GAZE_ENABLED = import.meta.env.VITE_GAZE_ENABLED !== "false";
26
+ const CALIBRATION_ENABLED = import.meta.env.VITE_CALIBRATION_ENABLED !== "false";
 
27
 
28
  export function useSensing() {
29
  const faceLandmarkerRef = useRef<FaceLandmarker | null>(null);
30
  const gestureRecognizerRef = useRef<GestureRecognizer | null>(null);
31
+ const calibratorRef = useRef(new Calibrator());
32
  const gazeTrackerRef = useRef(new GazeTracker());
33
  const airWriterRef = useRef(new AirWriter());
34
  const inkBusyRef = useRef(false);
35
  const headTrackerRef = useRef(new HeadPoseTracker());
36
  const headDebugRef = useRef({ pitch: 0, yaw: 0, roll: 0, crossings: 0 });
37
+ const gestureCountRef = useRef<{ tag: SensingState["gestureTag"]; since: number }>({ tag: null, since: 0 });
38
+ const affectCountRef = useRef<{ affect: SensingState["affect"]; since: number }>({ affect: null, since: 0 });
39
  const initingRef = useRef(false);
40
+
41
  const [ready, setReady] = useState(false);
42
  const [initError, setInitError] = useState<string | null>(null);
43
+ const [isCalibrating, setIsCalibrating] = useState(false);
44
+ const [isCalibrated, setIsCalibrated] = useState(false);
45
+ const [calibrationProgress, setCalibrationProgress] = useState(0);
46
  const [sensing, setSensing] = useState<SensingState>({
47
  affect: null,
48
  gestureTag: null,
 
51
  airWrittenText: "",
52
  airWritingActive: false,
53
  headSignal: null,
 
54
  headDebug: { pitch: 0, yaw: 0, roll: 0, crossings: 0 },
55
  });
56
 
 
57
  useEffect(() => {
58
  return () => {
59
  faceLandmarkerRef.current?.close();
 
108
  }
109
  }, []);
110
 
111
+ const startCalibration = useCallback(() => {
112
+ if (!CALIBRATION_ENABLED) {
113
+ setIsCalibrated(true);
114
+ return;
115
+ }
116
+ calibratorRef.current.start();
117
+ setIsCalibrating(true);
118
+ setIsCalibrated(false);
119
+ setCalibrationProgress(0);
120
+ // Reset the per-detector state so post-calibration baselines aren't
121
+ // mixed with stale pre-calibration history.
122
+ gazeTrackerRef.current.reset();
123
+ headTrackerRef.current.reset();
124
+ gestureCountRef.current = { tag: null, since: 0 };
125
+ affectCountRef.current = { affect: null, since: 0 };
126
+ }, []);
127
+
128
+ const cancelCalibration = useCallback(() => {
129
+ calibratorRef.current.cancel();
130
+ setIsCalibrating(false);
131
+ setIsCalibrated(false);
132
+ setCalibrationProgress(0);
133
+ }, []);
134
+
135
  const processFrame = useCallback(
136
  (video: HTMLVideoElement, timestamp: number) => {
137
  const faceLandmarker = faceLandmarkerRef.current;
138
  const gestureRecognizer = gestureRecognizerRef.current;
139
  if (!faceLandmarker || !gestureRecognizer) return;
140
 
141
+ const calibrator = calibratorRef.current;
142
+ const calibrating = calibrator.isActive;
143
+ const baseline = calibrator.getBaseline();
144
+
145
  let affect: SensingState["affect"] = null;
146
  let gazeBucket: SensingState["gazeBucket"] = null;
147
  let headSignal: SensingState["headSignal"] = null;
 
149
  const faceResult = faceLandmarker.detectForVideo(video, timestamp);
150
  if (faceResult.faceLandmarks && faceResult.faceLandmarks.length > 0) {
151
  const matrix = faceResult.facialTransformationMatrixes?.[0] ?? null;
152
+ const landmarks = faceResult.faceLandmarks[0];
153
 
154
  const bs: Record<string, number> = {};
155
  if (faceResult.faceBlendshapes && faceResult.faceBlendshapes.length > 0) {
156
  for (const cat of faceResult.faceBlendshapes[0].categories) {
157
  bs[cat.categoryName] = cat.score;
158
  }
 
159
  }
160
 
161
+ if (calibrating) {
162
+ calibrator.addSample({
163
+ blendshapes: bs,
164
+ gaze: matrix ? worldGazeXY(matrix, bs) : null,
165
+ head: matrix ? extractAngles(matrix.data) : null,
166
+ faceBboxSize: faceBboxSize(landmarks),
167
+ });
168
+ setCalibrationProgress(Math.round(calibrator.progress * 100) / 100);
169
+ if (calibrator.isReady) {
170
+ setIsCalibrating(false);
171
+ setIsCalibrated(true);
172
+ setCalibrationProgress(1);
173
+ }
174
+ return;
175
+ }
176
+
177
+ affect = classifyAffect(bs, baseline);
178
+
179
  if (GAZE_ENABLED) {
180
+ gazeBucket = gazeTrackerRef.current.process(matrix, bs, baseline);
181
  }
182
 
183
  if (matrix) {
184
+ headSignal = headTrackerRef.current.process(matrix, baseline);
185
  headDebugRef.current = headTrackerRef.current.debug;
186
  }
187
+ } else if (calibrating) {
188
+ setCalibrationProgress(Math.round(calibrator.progress * 100) / 100);
189
+ return;
190
  }
191
 
192
  let gestureTag: SensingState["gestureTag"] = null;
 
207
  airWriterRef.current.noHand();
208
  }
209
 
 
 
210
  if (AIRWRITING_ENABLED) {
211
  const completedStroke = airWriterRef.current.getCompletedStroke();
212
  if (completedStroke && !inkBusyRef.current) {
 
223
  }
224
  }
225
 
226
+ const now = performance.now();
227
+ if (gestureTag !== gestureCountRef.current.tag) {
228
+ gestureCountRef.current = { tag: gestureTag, since: now };
 
229
  }
230
+ const stableGesture =
231
+ now - gestureCountRef.current.since >= GESTURE_DEBOUNCE_MS
232
+ ? gestureTag
233
+ : null;
234
+
235
+ if (affect !== affectCountRef.current.affect) {
236
+ affectCountRef.current = { affect, since: now };
 
237
  }
238
+ const stableAffect =
239
+ now - affectCountRef.current.since >= AFFECT_DEBOUNCE_MS
240
+ ? affect
241
+ : null;
242
+
243
+ const activeZone = GAZE_ENABLED ? gazeTrackerRef.current.activeZone : null;
244
+ const airWritingActive = airWriterRef.current.strokeActive;
245
+ const headDebug = headDebugRef.current;
246
+
247
+ setSensing((prev) => {
248
+ const nextAffect = stableAffect ?? prev.affect;
249
+ const nextGazeBucket = gazeBucket ?? prev.gazeBucket;
250
+ const nextHeadSignal = headSignal ?? prev.headSignal;
251
+ const debugChanged =
252
+ headDebug.pitch !== prev.headDebug.pitch ||
253
+ headDebug.yaw !== prev.headDebug.yaw ||
254
+ headDebug.roll !== prev.headDebug.roll ||
255
+ headDebug.crossings !== prev.headDebug.crossings;
256
+ if (
257
+ nextAffect === prev.affect &&
258
+ stableGesture === prev.gestureTag &&
259
+ activeZone === prev.gazeZone &&
260
+ nextGazeBucket === prev.gazeBucket &&
261
+ airWritingActive === prev.airWritingActive &&
262
+ nextHeadSignal === prev.headSignal &&
263
+ !debugChanged
264
+ ) {
265
+ return prev;
266
+ }
267
+ return {
268
+ ...prev,
269
+ affect: nextAffect,
270
+ gestureTag: stableGesture,
271
+ gazeZone: activeZone,
272
+ gazeBucket: nextGazeBucket,
273
+ airWritingActive,
274
+ headSignal: nextHeadSignal,
275
+ headDebug: debugChanged ? headDebug : prev.headDebug,
276
+ };
277
+ });
278
  },
279
  []
280
  );
 
288
  }, []);
289
 
290
  const resetCalibration = useCallback(() => {
291
+ gestureCountRef.current = { tag: null, since: 0 };
292
+ affectCountRef.current = { affect: null, since: 0 };
293
  gazeTrackerRef.current.reset();
294
  headTrackerRef.current.reset();
295
+ calibratorRef.current.cancel();
296
+ setIsCalibrating(false);
297
+ setIsCalibrated(false);
298
+ setCalibrationProgress(0);
299
  setSensing({
300
  affect: null,
301
  gestureTag: null,
 
304
  airWrittenText: "",
305
  airWritingActive: false,
306
  headSignal: null,
 
307
  headDebug: { pitch: 0, yaw: 0, roll: 0, crossings: 0 },
308
  });
309
  }, []);
 
312
  sensing,
313
  ready,
314
  initError,
315
+ isCalibrating,
316
+ isCalibrated,
317
+ calibrationProgress,
318
  init,
319
  processFrame,
320
+ startCalibration,
321
+ cancelCalibration,
322
  clearAirWrittenText,
323
  clearHeadSignal,
324
  resetCalibration,
frontend/src/lib/sensing.ts CHANGED
@@ -1,26 +1,172 @@
1
  import type { Matrix } from "@mediapipe/tasks-vision";
2
- import type { Affect, GestureName, MemoryBucket } from "../types";
3
-
4
- // ── Affect classification via MediaPipe blendshapes ──────────────────────────
5
-
6
- export function classifyAffect(bs: Record<string, number>): Affect {
7
- const smileLeft = bs["mouthSmileLeft"] ?? 0;
8
- const smileRight = bs["mouthSmileRight"] ?? 0;
9
- const browDownL = bs["browDownLeft"] ?? 0;
10
- const browDownR = bs["browDownRight"] ?? 0;
11
- const squintL = bs["eyeSquintLeft"] ?? 0;
12
- const squintR = bs["eyeSquintRight"] ?? 0;
13
- const jawOpen = bs["jawOpen"] ?? 0;
14
- const browInnerUp = bs["browInnerUp"] ?? 0;
15
-
16
- if (jawOpen > 0.4 && browInnerUp > 0.5) return "SURPRISED";
17
- if (browDownL > 0.4 || browDownR > 0.4) return "FRUSTRATED";
18
- if (squintL > 0.5 && squintR > 0.5) return "FRUSTRATED";
19
- if (smileLeft > 0.5 && smileRight > 0.5) return "HAPPY";
20
- return "NEUTRAL";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  }
22
 
23
- // ── Gesture label mapping from MediaPipe GestureRecognizer ───────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  export function mapGestureLabel(label: string): GestureName | null {
26
  switch (label) {
@@ -35,73 +181,43 @@ export function mapGestureLabel(label: string): GestureName | null {
35
  }
36
  }
37
 
38
- // ── Gaze tracker world-space gaze via head rotation × eye blendshapes ──────
39
- //
40
- // Old approach: absolute iris X/Y position in frame → grid region.
41
- // Problem: head shifting in frame changes the bucket even if eyes didn't move.
42
- //
43
- // New approach:
44
- // 1. Eye direction in face-local space from blendshapes (head-relative).
45
- // 2. Rotate into camera space using the facial transformation matrix.
46
- // 3. Perspective-project to a 2-D screen gaze point.
47
- // 4. Map that point to the 5 memory buckets with a dwell timer.
48
- //
49
- // Bucket layout (matches the 5 regions on the AAC interface):
50
- //
51
- // family │ medical
52
- // (top-left) │ (top-right)
53
- // ───────────┼───────────
54
- // hobbies │ daily_routine
55
- // (bot-left) │ (bot-right)
56
- // social
57
- // (centre)
58
- //
59
- // If top/bottom buckets appear swapped on your device, set VITE_GAZE_INVERT_Y=true.
60
  const GAZE_INVERT_Y = import.meta.env.VITE_GAZE_INVERT_Y === "true";
61
- const GAZE_CENTER = 0.10; // radius around origin treated as "social"
62
- const GAZE_LATERAL = 0.12; // |x| must exceed this for left/right split
63
- const GAZE_VERTICAL = 0.12; // |y| must exceed this for top/bottom split
64
 
65
- function worldGazeXY(
66
  matrix: Matrix,
67
  bs: Record<string, number>,
68
  ): { x: number; y: number } {
69
- // Eye direction in face-local space.
70
- // MediaPipe "In" = toward nose, "Out" = away from nose.
71
- // viewer-right = InLeft + OutRight
72
- // viewer-left = OutLeft + InRight
73
  const eyeR = ((bs.eyeLookInLeft ?? 0) + (bs.eyeLookOutRight ?? 0)) / 2;
74
  const eyeL = ((bs.eyeLookOutLeft ?? 0) + (bs.eyeLookInRight ?? 0)) / 2;
75
  const eyeU = ((bs.eyeLookUpLeft ?? 0) + (bs.eyeLookUpRight ?? 0)) / 2;
76
  const eyeD = ((bs.eyeLookDownLeft ?? 0) + (bs.eyeLookDownRight ?? 0)) / 2;
77
 
78
- // Face-local gaze vector (+X right, +Y up, +Z forward toward camera).
79
  const lx = eyeR - eyeL;
80
  const ly = eyeU - eyeD;
81
- const lz = 1.0; // canonical forward direction
82
 
83
- // Rotate to camera space using the 3×3 submatrix of the column-major 4×4.
84
- // R[row][col] = data[col*4 + row]
85
  const d = matrix.data;
86
  const cx = d[0]*lx + d[4]*ly + d[8]*lz;
87
  const cy = d[1]*lx + d[5]*ly + d[9]*lz;
88
  const cz = d[2]*lx + d[6]*ly + d[10]*lz;
89
 
90
- // Perspective-project onto screen plane.
91
  const fwd = Math.abs(cz) > 0.01 ? cz : 0.01;
92
  const y = GAZE_INVERT_Y ? -(cy / fwd) : (cy / fwd);
93
  return { x: cx / fwd, y };
94
  }
95
 
96
- function gazeToRegion(x: number, y: number): MemoryBucket | null {
97
- const ax = Math.abs(x), ay = Math.abs(y);
98
- if (ax < GAZE_CENTER && ay < GAZE_CENTER) return "social";
99
- if (ax < GAZE_LATERAL && ay < GAZE_VERTICAL) return "social"; // near-centre
100
- if (x < -GAZE_LATERAL && y > GAZE_VERTICAL) return "family";
101
- if (x > GAZE_LATERAL && y > GAZE_VERTICAL) return "medical";
102
- if (x < -GAZE_LATERAL && y < -GAZE_VERTICAL) return "hobbies";
103
- if (x > GAZE_LATERAL && y < -GAZE_VERTICAL) return "daily_routine";
104
- return null; // edge zone — don't fire
105
  }
106
 
107
  export class GazeTracker {
@@ -109,27 +225,36 @@ export class GazeTracker {
109
  private dwellStart = 0;
110
  private dwellThresholdMs: number;
111
  private _activeZone: MemoryBucket | null = null;
 
 
112
 
113
  constructor(dwellThresholdMs = 1500) {
114
  this.dwellThresholdMs = dwellThresholdMs;
115
  }
116
 
117
- // Current zone the user is looking at right now — updates every frame.
118
- // Use this to highlight the zone map immediately.
119
  get activeZone(): MemoryBucket | null {
 
 
 
120
  return this._activeZone;
121
  }
122
 
123
  process(
124
  matrix: Matrix | null,
125
  bs: Record<string, number>,
 
126
  ): MemoryBucket | null {
127
- const { x, y } = matrix
128
- ? worldGazeXY(matrix, bs)
129
- : { x: 0, y: 0 };
130
 
131
- const bucket = matrix ? gazeToRegion(x, y) : null;
132
- this._activeZone = bucket; // always reflect current zone
 
 
 
 
 
 
 
133
 
134
  if (bucket !== this.currentBucket) {
135
  this.currentBucket = bucket;
@@ -151,40 +276,17 @@ export class GazeTracker {
151
  this.currentBucket = null;
152
  this._activeZone = null;
153
  this.dwellStart = 0;
 
154
  }
155
  }
156
 
157
- // ── Head-pose tracker using facial transformation matrix ────────────────────
158
- //
159
- // MediaPipe FaceLandmarker produces a 4×4 column-major transformation matrix
160
- // that encodes the 3-D rotation of the canonical face model in camera space.
161
- // We decompose it to Euler angles (ZYX convention) — no calibration step needed
162
- // because the angles are always relative to the canonical neutral pose.
163
- //
164
- // Signals emitted:
165
- // HEAD_SHAKE — yaw oscillates ±N° (left/right), "no"
166
- // HEAD_NOD — gentle pitch dip + recovery, "yes"
167
- // HEAD_NOD_DISSATISFIED — sharp/large pitch dip + recovery, discomfort
168
-
169
- export type HeadSignal = "HEAD_SHAKE" | "HEAD_NOD" | "HEAD_NOD_DISSATISFIED";
170
-
171
- export interface HeadDebug {
172
- pitch: number; // degrees — nod angle
173
- yaw: number; // degrees — shake angle
174
- roll: number; // degrees — tilt angle
175
- crossings: number; // yaw direction reversals in current window
176
- }
177
-
178
  interface AnglePoint { pitch: number; yaw: number; t: number }
179
 
180
  const RAD2DEG = 180 / Math.PI;
181
 
182
- function extractAngles(data: Float32Array): { pitch: number; yaw: number; roll: number } {
183
- // Column-major 4×4: R[row][col] = data[col*4 + row]
184
- // ZYX Euler (R = Rz·Ry·Rx):
185
- // pitch (X, nod) = atan2(R[2][1], R[2][2]) = atan2(data[6], data[10])
186
- // yaw (Y, shake) = atan2(−R[2][0], √(R[2][1]²+R[2][2]²))
187
- // roll (Z, tilt) = atan2(R[1][0], R[0][0]) = atan2(data[1], data[0])
188
  const r20 = data[2], r21 = data[6], r22 = data[10];
189
  const r10 = data[1], r00 = data[0];
190
  return {
@@ -194,35 +296,41 @@ function extractAngles(data: Float32Array): { pitch: number; yaw: number; roll:
194
  };
195
  }
196
 
197
- // Thresholds (radians unless noted)
198
  const WINDOW_MS = 1200;
199
  const REFRACTORY_MS = 2000;
200
  const NOD_WINDOW_MS = 1000;
 
 
201
 
202
- const SHAKE_RANGE_RAD = 0.30; // total yaw swing needed (~17°)
203
- const SHAKE_DEADBAND_RAD = 0.05; // ignore jitter below ~3°
204
  const SHAKE_MIN_REVERSALS = 3;
205
 
206
- const NOD_AMPLITUDE_RAD = 0.15; // ~8.6° — min pitch deviation for any nod
207
- const NOD_SHARP_RAD = 0.28; // ~16° — above this = DISSATISFIED
208
- const NOD_RECOVERY_RAD = 0.15; // must return within ~8.6° of start pitch
209
- const NOD_MAX_YAW_RAD = 0.25; // reject if too much lateral (~14°)
210
 
211
  export class HeadPoseTracker {
212
  private history: AnglePoint[] = [];
213
  private lastEmitTs = 0;
214
  private lastDebug: HeadDebug = { pitch: 0, yaw: 0, roll: 0, crossings: 0 };
215
 
216
- // No-op angles are self-calibrating relative to the canonical face model.
217
- // Kept so existing callers (calibrateHeadPose button) don't break.
218
- calibrate(_landmarks: unknown): void {}
219
-
220
- process(matrix: Matrix): HeadSignal | null {
221
- const { pitch, yaw, roll } = extractAngles(matrix.data);
222
  const now = performance.now();
223
 
224
  this.history.push({ pitch, yaw, t: now });
225
- this.history = this.history.filter((p) => p.t >= now - WINDOW_MS);
 
 
 
 
 
 
226
 
227
  this.updateDebug(pitch, yaw, roll);
228
 
@@ -276,7 +384,6 @@ export class HeadPoseTracker {
276
  const recent = this.history.filter((p) => p.t >= now - NOD_WINDOW_MS);
277
  if (recent.length < 6) return null;
278
 
279
- // Reject if there's significant lateral motion — it's a shake, not a nod.
280
  const yawRange = Math.max(...recent.map((p) => Math.abs(p.yaw)));
281
  if (yawRange > NOD_MAX_YAW_RAD) return null;
282
 
@@ -285,7 +392,6 @@ export class HeadPoseTracker {
285
  const maxDev = Math.max(...pitches.map((p) => Math.abs(p - startPitch)));
286
  if (maxDev < NOD_AMPLITUDE_RAD) return null;
287
 
288
- // Must recover back near the start pitch.
289
  const lastPitch = pitches[pitches.length - 1];
290
  if (Math.abs(lastPitch - startPitch) >= NOD_RECOVERY_RAD) return null;
291
 
@@ -298,12 +404,22 @@ export class HeadPoseTracker {
298
  this.history = [];
299
  this.lastEmitTs = 0;
300
  }
301
-
302
- // Always true — no manual calibration step required with the matrix approach.
303
- get calibrated(): boolean { return true; }
304
  }
305
 
306
- // ── Air-writing stroke collector (recognition via Gemini Vision) ─────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
  const INDEX_TIP = 8;
309
  const VELOCITY_START = 15;
@@ -366,18 +482,12 @@ export class AirWriter {
366
  return this.inStroke;
367
  }
368
 
369
- // Returns the completed stroke trajectory and clears it (call once per frame).
370
  getCompletedStroke(): [number, number][] | null {
371
  const s = this.pendingStroke;
372
  this.pendingStroke = null;
373
  return s;
374
  }
375
 
376
- // Kept for API compatibility — always returns "".
377
- getText(): string {
378
- return "";
379
- }
380
-
381
  noHand(): void {
382
  if (this.inStroke && this.strokeEndTime === 0) {
383
  this.strokeEndTime = performance.now();
 
1
  import type { Matrix } from "@mediapipe/tasks-vision";
2
+ import type { Affect, GestureName, HeadDebug, HeadSignal, MemoryBucket } from "../types";
3
+
4
+ const SIGMA_K = 2.0;
5
+ const CALIBRATION_DURATION_MS = 5000;
6
+ const CALIBRATION_WARMUP_MS = 1000;
7
+ const OUTLIER_TRIM_FRACTION = 0.10;
8
+
9
+ const AFFECT_BLENDSHAPES = [
10
+ "mouthSmileLeft", "mouthSmileRight",
11
+ "browDownLeft", "browDownRight",
12
+ "eyeSquintLeft", "eyeSquintRight",
13
+ "jawOpen", "browInnerUp",
14
+ ] as const;
15
+ type AffectBlendshape = typeof AFFECT_BLENDSHAPES[number];
16
+
17
+ interface Stats { mean: number; std: number }
18
+
19
+ interface Baseline {
20
+ affect: Record<string, Stats>;
21
+ gaze: { x: number; y: number };
22
+ head: { pitch: number; yaw: number; roll: number };
23
+ faceBboxSize: number; // normalised face size — proxy for distance
24
+ }
25
+
26
+ function trimmedStats(values: number[]): Stats {
27
+ if (values.length === 0) return { mean: 0, std: 0 };
28
+ const sorted = [...values].sort((a, b) => a - b);
29
+ const trim = Math.floor(sorted.length * OUTLIER_TRIM_FRACTION);
30
+ const kept = sorted.slice(trim, sorted.length - trim);
31
+ if (kept.length === 0) return { mean: 0, std: 0 };
32
+ const mean = kept.reduce((s, v) => s + v, 0) / kept.length;
33
+ const variance = kept.reduce((s, v) => s + (v - mean) ** 2, 0) / kept.length;
34
+ const std = Math.max(Math.sqrt(variance), 0.01);
35
+ return { mean, std };
36
+ }
37
+
38
+ function trimmedMean(values: number[]): number {
39
+ return trimmedStats(values).mean;
40
+ }
41
+
42
+ export class Calibrator {
43
+ private startTs = 0;
44
+ private active = false;
45
+ private done = false;
46
+
47
+ private affectSamples: Record<string, number[]> = {};
48
+ private gazeSamples: { x: number; y: number }[] = [];
49
+ private headSamples: { pitch: number; yaw: number; roll: number }[] = [];
50
+ private bboxSamples: number[] = [];
51
+
52
+ private baseline: Baseline | null = null;
53
+
54
+ start(): void {
55
+ this.startTs = performance.now();
56
+ this.active = true;
57
+ this.done = false;
58
+ this.baseline = null;
59
+ this.affectSamples = {};
60
+ for (const name of AFFECT_BLENDSHAPES) this.affectSamples[name] = [];
61
+ this.gazeSamples = [];
62
+ this.headSamples = [];
63
+ this.bboxSamples = [];
64
+ }
65
+
66
+ cancel(): void {
67
+ this.active = false;
68
+ this.done = false;
69
+ this.baseline = null;
70
+ }
71
+
72
+ get isActive(): boolean { return this.active; }
73
+ get isReady(): boolean { return this.done && this.baseline !== null; }
74
+
75
+ // 0 → 1 over the calibration window (excluding warm-up).
76
+ get progress(): number {
77
+ if (!this.active) return this.done ? 1 : 0;
78
+ const elapsed = performance.now() - this.startTs - CALIBRATION_WARMUP_MS;
79
+ if (elapsed <= 0) return 0;
80
+ return Math.min(1, elapsed / (CALIBRATION_DURATION_MS - CALIBRATION_WARMUP_MS));
81
+ }
82
+
83
+ // Feed a frame's signals during calibration. After the window elapses,
84
+ // the baseline is computed and `isReady` becomes true.
85
+ addSample(args: {
86
+ blendshapes: Record<string, number>;
87
+ gaze: { x: number; y: number } | null;
88
+ head: { pitch: number; yaw: number; roll: number } | null;
89
+ faceBboxSize: number | null;
90
+ }): void {
91
+ if (!this.active) return;
92
+ const elapsed = performance.now() - this.startTs;
93
+
94
+ if (elapsed < CALIBRATION_WARMUP_MS) return;
95
+
96
+ if (elapsed >= CALIBRATION_DURATION_MS) {
97
+ this.finalise();
98
+ return;
99
+ }
100
+
101
+ for (const name of AFFECT_BLENDSHAPES) {
102
+ const v = args.blendshapes[name];
103
+ if (typeof v === "number") this.affectSamples[name].push(v);
104
+ }
105
+ if (args.gaze) this.gazeSamples.push(args.gaze);
106
+ if (args.head) this.headSamples.push(args.head);
107
+ if (typeof args.faceBboxSize === "number") this.bboxSamples.push(args.faceBboxSize);
108
+ }
109
+
110
+ private finalise(): void {
111
+ const affect: Record<string, Stats> = {};
112
+ for (const name of AFFECT_BLENDSHAPES) {
113
+ affect[name] = trimmedStats(this.affectSamples[name] ?? []);
114
+ }
115
+ const gaze = {
116
+ x: trimmedMean(this.gazeSamples.map((g) => g.x)),
117
+ y: trimmedMean(this.gazeSamples.map((g) => g.y)),
118
+ };
119
+ const head = {
120
+ pitch: trimmedMean(this.headSamples.map((h) => h.pitch)),
121
+ yaw: trimmedMean(this.headSamples.map((h) => h.yaw)),
122
+ roll: trimmedMean(this.headSamples.map((h) => h.roll)),
123
+ };
124
+ // Floor at a small positive value so we never divide by zero when scaling.
125
+ const faceBboxSize = Math.max(trimmedMean(this.bboxSamples), 0.01);
126
+
127
+ this.baseline = { affect, gaze, head, faceBboxSize };
128
+ this.active = false;
129
+ this.done = true;
130
+ }
131
+
132
+ getBaseline(): Baseline | null { return this.baseline; }
133
+ }
134
+
135
+ const AFFECT_FALLBACK_THRESHOLD = 0.4;
136
+
137
+ function isAbove(
138
+ bs: Record<string, number>,
139
+ name: AffectBlendshape,
140
+ baseline: Baseline | null,
141
+ ): boolean {
142
+ const v = bs[name] ?? 0;
143
+ if (baseline) {
144
+ const stats = baseline.affect[name];
145
+ if (!stats) return false;
146
+ return v - stats.mean > SIGMA_K * stats.std;
147
+ }
148
+ return v > AFFECT_FALLBACK_THRESHOLD;
149
  }
150
 
151
+ export function classifyAffect(
152
+ bs: Record<string, number>,
153
+ baseline: Baseline | null = null,
154
+ ): Affect {
155
+ const smileL = isAbove(bs, "mouthSmileLeft", baseline);
156
+ const smileR = isAbove(bs, "mouthSmileRight", baseline);
157
+ const browDL = isAbove(bs, "browDownLeft", baseline);
158
+ const browDR = isAbove(bs, "browDownRight", baseline);
159
+ const squintL = isAbove(bs, "eyeSquintLeft", baseline);
160
+ const squintR = isAbove(bs, "eyeSquintRight", baseline);
161
+ const jawOpen = isAbove(bs, "jawOpen", baseline);
162
+ const browIn = isAbove(bs, "browInnerUp", baseline);
163
+
164
+ if (jawOpen && browIn) return "SURPRISED";
165
+ if (browDL || browDR) return "FRUSTRATED";
166
+ if (squintL && squintR) return "FRUSTRATED";
167
+ if (smileL && smileR) return "HAPPY";
168
+ return "NEUTRAL";
169
+ }
170
 
171
  export function mapGestureLabel(label: string): GestureName | null {
172
  switch (label) {
 
181
  }
182
  }
183
 
184
+ // Bucket layout matches the 5 regions on the AAC interface:
185
+ // family / medical (top), social (centre), hobbies / daily_routine (bottom).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  const GAZE_INVERT_Y = import.meta.env.VITE_GAZE_INVERT_Y === "true";
187
+ const GAZE_LATERAL_DELTA = 0.12;
188
+ const GAZE_VERTICAL_DELTA = 0.12;
 
189
 
190
+ export function worldGazeXY(
191
  matrix: Matrix,
192
  bs: Record<string, number>,
193
  ): { x: number; y: number } {
 
 
 
 
194
  const eyeR = ((bs.eyeLookInLeft ?? 0) + (bs.eyeLookOutRight ?? 0)) / 2;
195
  const eyeL = ((bs.eyeLookOutLeft ?? 0) + (bs.eyeLookInRight ?? 0)) / 2;
196
  const eyeU = ((bs.eyeLookUpLeft ?? 0) + (bs.eyeLookUpRight ?? 0)) / 2;
197
  const eyeD = ((bs.eyeLookDownLeft ?? 0) + (bs.eyeLookDownRight ?? 0)) / 2;
198
 
 
199
  const lx = eyeR - eyeL;
200
  const ly = eyeU - eyeD;
201
+ const lz = 1.0;
202
 
 
 
203
  const d = matrix.data;
204
  const cx = d[0]*lx + d[4]*ly + d[8]*lz;
205
  const cy = d[1]*lx + d[5]*ly + d[9]*lz;
206
  const cz = d[2]*lx + d[6]*ly + d[10]*lz;
207
 
 
208
  const fwd = Math.abs(cz) > 0.01 ? cz : 0.01;
209
  const y = GAZE_INVERT_Y ? -(cy / fwd) : (cy / fwd);
210
  return { x: cx / fwd, y };
211
  }
212
 
213
+ function deflectionToRegion(dx: number, dy: number): MemoryBucket | null {
214
+ const ax = Math.abs(dx), ay = Math.abs(dy);
215
+ if (ax < GAZE_LATERAL_DELTA && ay < GAZE_VERTICAL_DELTA) return "social";
216
+ if (dx < -GAZE_LATERAL_DELTA && dy > GAZE_VERTICAL_DELTA) return "family";
217
+ if (dx > GAZE_LATERAL_DELTA && dy > GAZE_VERTICAL_DELTA) return "medical";
218
+ if (dx < -GAZE_LATERAL_DELTA && dy < -GAZE_VERTICAL_DELTA) return "hobbies";
219
+ if (dx > GAZE_LATERAL_DELTA && dy < -GAZE_VERTICAL_DELTA) return "daily_routine";
220
+ return null;
 
221
  }
222
 
223
  export class GazeTracker {
 
225
  private dwellStart = 0;
226
  private dwellThresholdMs: number;
227
  private _activeZone: MemoryBucket | null = null;
228
+ private _lastSeenAt = 0;
229
+ private static ACTIVE_ZONE_TIMEOUT_MS = 500;
230
 
231
  constructor(dwellThresholdMs = 1500) {
232
  this.dwellThresholdMs = dwellThresholdMs;
233
  }
234
 
 
 
235
  get activeZone(): MemoryBucket | null {
236
+ if (performance.now() - this._lastSeenAt > GazeTracker.ACTIVE_ZONE_TIMEOUT_MS) {
237
+ this._activeZone = null;
238
+ }
239
  return this._activeZone;
240
  }
241
 
242
  process(
243
  matrix: Matrix | null,
244
  bs: Record<string, number>,
245
+ baseline: Baseline | null,
246
  ): MemoryBucket | null {
247
+ if (!matrix) return null;
 
 
248
 
249
+ const { x, y } = worldGazeXY(matrix, bs);
250
+ const dx = baseline ? x - baseline.gaze.x : x;
251
+ const dy = baseline ? y - baseline.gaze.y : y;
252
+
253
+ const bucket = deflectionToRegion(dx, dy);
254
+ if (bucket !== null) {
255
+ this._activeZone = bucket;
256
+ this._lastSeenAt = performance.now();
257
+ }
258
 
259
  if (bucket !== this.currentBucket) {
260
  this.currentBucket = bucket;
 
276
  this.currentBucket = null;
277
  this._activeZone = null;
278
  this.dwellStart = 0;
279
+ this._lastSeenAt = 0;
280
  }
281
  }
282
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  interface AnglePoint { pitch: number; yaw: number; t: number }
284
 
285
  const RAD2DEG = 180 / Math.PI;
286
 
287
+ export function extractAngles(
288
+ data: number[],
289
+ ): { pitch: number; yaw: number; roll: number } {
 
 
 
290
  const r20 = data[2], r21 = data[6], r22 = data[10];
291
  const r10 = data[1], r00 = data[0];
292
  return {
 
296
  };
297
  }
298
 
 
299
  const WINDOW_MS = 1200;
300
  const REFRACTORY_MS = 2000;
301
  const NOD_WINDOW_MS = 1000;
302
+ // Hard cap covers backgrounded-tab catch-up where many frames arrive at once.
303
+ const HISTORY_MAX = 100;
304
 
305
+ const SHAKE_RANGE_RAD = 0.30;
306
+ const SHAKE_DEADBAND_RAD = 0.05;
307
  const SHAKE_MIN_REVERSALS = 3;
308
 
309
+ const NOD_AMPLITUDE_RAD = 0.12;
310
+ const NOD_SHARP_RAD = 0.25;
311
+ const NOD_RECOVERY_RAD = 0.12;
312
+ const NOD_MAX_YAW_RAD = 0.25;
313
 
314
  export class HeadPoseTracker {
315
  private history: AnglePoint[] = [];
316
  private lastEmitTs = 0;
317
  private lastDebug: HeadDebug = { pitch: 0, yaw: 0, roll: 0, crossings: 0 };
318
 
319
+ process(matrix: Matrix, baseline: Baseline | null): HeadSignal | null {
320
+ const raw = extractAngles(matrix.data);
321
+ const pitch = baseline ? raw.pitch - baseline.head.pitch : raw.pitch;
322
+ const yaw = baseline ? raw.yaw - baseline.head.yaw : raw.yaw;
323
+ const roll = baseline ? raw.roll - baseline.head.roll : raw.roll;
 
324
  const now = performance.now();
325
 
326
  this.history.push({ pitch, yaw, t: now });
327
+ const cutoff = now - WINDOW_MS;
328
+ let drop = 0;
329
+ while (drop < this.history.length && this.history[drop].t < cutoff) drop++;
330
+ if (this.history.length - drop > HISTORY_MAX) {
331
+ drop = this.history.length - HISTORY_MAX;
332
+ }
333
+ if (drop > 0) this.history.splice(0, drop);
334
 
335
  this.updateDebug(pitch, yaw, roll);
336
 
 
384
  const recent = this.history.filter((p) => p.t >= now - NOD_WINDOW_MS);
385
  if (recent.length < 6) return null;
386
 
 
387
  const yawRange = Math.max(...recent.map((p) => Math.abs(p.yaw)));
388
  if (yawRange > NOD_MAX_YAW_RAD) return null;
389
 
 
392
  const maxDev = Math.max(...pitches.map((p) => Math.abs(p - startPitch)));
393
  if (maxDev < NOD_AMPLITUDE_RAD) return null;
394
 
 
395
  const lastPitch = pitches[pitches.length - 1];
396
  if (Math.abs(lastPitch - startPitch) >= NOD_RECOVERY_RAD) return null;
397
 
 
404
  this.history = [];
405
  this.lastEmitTs = 0;
406
  }
 
 
 
407
  }
408
 
409
+ export function faceBboxSize(landmarks: { x: number; y: number }[]): number | null {
410
+ if (!landmarks || landmarks.length < 3) return null;
411
+ let minX = 1, maxX = 0, minY = 1, maxY = 0;
412
+ for (const p of landmarks) {
413
+ if (p.x < minX) minX = p.x;
414
+ if (p.x > maxX) maxX = p.x;
415
+ if (p.y < minY) minY = p.y;
416
+ if (p.y > maxY) maxY = p.y;
417
+ }
418
+ const w = maxX - minX;
419
+ const h = maxY - minY;
420
+ if (w <= 0 || h <= 0) return null;
421
+ return Math.sqrt(w * h);
422
+ }
423
 
424
  const INDEX_TIP = 8;
425
  const VELOCITY_START = 15;
 
482
  return this.inStroke;
483
  }
484
 
 
485
  getCompletedStroke(): [number, number][] | null {
486
  const s = this.pendingStroke;
487
  this.pendingStroke = null;
488
  return s;
489
  }
490
 
 
 
 
 
 
491
  noHand(): void {
492
  if (this.inStroke && this.strokeEndTime === 0) {
493
  this.strokeEndTime = performance.now();
frontend/src/types.ts CHANGED
@@ -18,7 +18,6 @@ export interface SensingState {
18
  airWrittenText: string;
19
  airWritingActive: boolean;
20
  headSignal: HeadSignal | null;
21
- headCalibrated: boolean;
22
  headDebug: HeadDebug;
23
  }
24
 
 
18
  airWrittenText: string;
19
  airWritingActive: boolean;
20
  headSignal: HeadSignal | null;
 
21
  headDebug: HeadDebug;
22
  }
23