@mentra/miniapp 0.3.0-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/README.md +259 -0
  2. package/dist/background/index.d.ts +59 -0
  3. package/dist/background/index.d.ts.map +1 -0
  4. package/dist/background/index.js +25 -0
  5. package/dist/background/index.js.map +1 -0
  6. package/dist/background/register.d.ts +38 -0
  7. package/dist/background/register.d.ts.map +1 -0
  8. package/dist/background/register.js +75 -0
  9. package/dist/background/register.js.map +1 -0
  10. package/dist/dev-reload.d.ts +21 -0
  11. package/dist/dev-reload.d.ts.map +1 -0
  12. package/dist/dev-reload.js +62 -0
  13. package/dist/dev-reload.js.map +1 -0
  14. package/dist/envelope.d.ts +23 -0
  15. package/dist/envelope.d.ts.map +1 -0
  16. package/dist/envelope.js +47 -0
  17. package/dist/envelope.js.map +1 -0
  18. package/dist/globals.d.ts +54 -0
  19. package/dist/globals.d.ts.map +1 -0
  20. package/dist/globals.js +12 -0
  21. package/dist/globals.js.map +1 -0
  22. package/dist/index.d.ts +58 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +31 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/modules/actions.d.ts +54 -0
  27. package/dist/modules/actions.d.ts.map +1 -0
  28. package/dist/modules/actions.js +122 -0
  29. package/dist/modules/actions.js.map +1 -0
  30. package/dist/modules/auth.d.ts +25 -0
  31. package/dist/modules/auth.d.ts.map +1 -0
  32. package/dist/modules/auth.js +52 -0
  33. package/dist/modules/auth.js.map +1 -0
  34. package/dist/modules/base64.d.ts +16 -0
  35. package/dist/modules/base64.d.ts.map +1 -0
  36. package/dist/modules/base64.js +92 -0
  37. package/dist/modules/base64.js.map +1 -0
  38. package/dist/modules/blob.d.ts +161 -0
  39. package/dist/modules/blob.d.ts.map +1 -0
  40. package/dist/modules/blob.js +277 -0
  41. package/dist/modules/blob.js.map +1 -0
  42. package/dist/modules/camera.d.ts +137 -0
  43. package/dist/modules/camera.d.ts.map +1 -0
  44. package/dist/modules/camera.js +104 -0
  45. package/dist/modules/camera.js.map +1 -0
  46. package/dist/modules/cloud.d.ts +34 -0
  47. package/dist/modules/cloud.d.ts.map +1 -0
  48. package/dist/modules/cloud.js +70 -0
  49. package/dist/modules/cloud.js.map +1 -0
  50. package/dist/modules/dashboard.d.ts +16 -0
  51. package/dist/modules/dashboard.d.ts.map +1 -0
  52. package/dist/modules/dashboard.js +27 -0
  53. package/dist/modules/dashboard.js.map +1 -0
  54. package/dist/modules/display.d.ts +106 -0
  55. package/dist/modules/display.d.ts.map +1 -0
  56. package/dist/modules/display.js +57 -0
  57. package/dist/modules/display.js.map +1 -0
  58. package/dist/modules/events.d.ts +167 -0
  59. package/dist/modules/events.d.ts.map +1 -0
  60. package/dist/modules/events.js +123 -0
  61. package/dist/modules/events.js.map +1 -0
  62. package/dist/modules/glasses.d.ts +28 -0
  63. package/dist/modules/glasses.d.ts.map +1 -0
  64. package/dist/modules/glasses.js +39 -0
  65. package/dist/modules/glasses.js.map +1 -0
  66. package/dist/modules/heading.d.ts +16 -0
  67. package/dist/modules/heading.d.ts.map +1 -0
  68. package/dist/modules/heading.js +20 -0
  69. package/dist/modules/heading.js.map +1 -0
  70. package/dist/modules/imu.d.ts +31 -0
  71. package/dist/modules/imu.d.ts.map +1 -0
  72. package/dist/modules/imu.js +40 -0
  73. package/dist/modules/imu.js.map +1 -0
  74. package/dist/modules/input.d.ts +34 -0
  75. package/dist/modules/input.d.ts.map +1 -0
  76. package/dist/modules/input.js +50 -0
  77. package/dist/modules/input.js.map +1 -0
  78. package/dist/modules/led.d.ts +35 -0
  79. package/dist/modules/led.d.ts.map +1 -0
  80. package/dist/modules/led.js +39 -0
  81. package/dist/modules/led.js.map +1 -0
  82. package/dist/modules/location.d.ts +27 -0
  83. package/dist/modules/location.d.ts.map +1 -0
  84. package/dist/modules/location.js +35 -0
  85. package/dist/modules/location.js.map +1 -0
  86. package/dist/modules/mic.d.ts +44 -0
  87. package/dist/modules/mic.d.ts.map +1 -0
  88. package/dist/modules/mic.js +67 -0
  89. package/dist/modules/mic.js.map +1 -0
  90. package/dist/modules/miniapps.d.ts +72 -0
  91. package/dist/modules/miniapps.d.ts.map +1 -0
  92. package/dist/modules/miniapps.js +48 -0
  93. package/dist/modules/miniapps.js.map +1 -0
  94. package/dist/modules/navigation.d.ts +326 -0
  95. package/dist/modules/navigation.d.ts.map +1 -0
  96. package/dist/modules/navigation.js +386 -0
  97. package/dist/modules/navigation.js.map +1 -0
  98. package/dist/modules/permissions.d.ts +58 -0
  99. package/dist/modules/permissions.d.ts.map +1 -0
  100. package/dist/modules/permissions.js +66 -0
  101. package/dist/modules/permissions.js.map +1 -0
  102. package/dist/modules/phone.d.ts +61 -0
  103. package/dist/modules/phone.d.ts.map +1 -0
  104. package/dist/modules/phone.js +94 -0
  105. package/dist/modules/phone.js.map +1 -0
  106. package/dist/modules/pivots/engine.d.ts +169 -0
  107. package/dist/modules/pivots/engine.d.ts.map +1 -0
  108. package/dist/modules/pivots/engine.js +779 -0
  109. package/dist/modules/pivots/engine.js.map +1 -0
  110. package/dist/modules/pivots/geometry.d.ts +84 -0
  111. package/dist/modules/pivots/geometry.d.ts.map +1 -0
  112. package/dist/modules/pivots/geometry.js +371 -0
  113. package/dist/modules/pivots/geometry.js.map +1 -0
  114. package/dist/modules/pivots/instructions.d.ts +139 -0
  115. package/dist/modules/pivots/instructions.d.ts.map +1 -0
  116. package/dist/modules/pivots/instructions.js +280 -0
  117. package/dist/modules/pivots/instructions.js.map +1 -0
  118. package/dist/modules/speaker.d.ts +163 -0
  119. package/dist/modules/speaker.d.ts.map +1 -0
  120. package/dist/modules/speaker.js +250 -0
  121. package/dist/modules/speaker.js.map +1 -0
  122. package/dist/modules/storage.d.ts +50 -0
  123. package/dist/modules/storage.d.ts.map +1 -0
  124. package/dist/modules/storage.js +99 -0
  125. package/dist/modules/storage.js.map +1 -0
  126. package/dist/modules/stream.d.ts +129 -0
  127. package/dist/modules/stream.d.ts.map +1 -0
  128. package/dist/modules/stream.js +52 -0
  129. package/dist/modules/stream.js.map +1 -0
  130. package/dist/modules/system.d.ts +45 -0
  131. package/dist/modules/system.d.ts.map +1 -0
  132. package/dist/modules/system.js +42 -0
  133. package/dist/modules/system.js.map +1 -0
  134. package/dist/modules/transcription.d.ts +67 -0
  135. package/dist/modules/transcription.d.ts.map +1 -0
  136. package/dist/modules/transcription.js +106 -0
  137. package/dist/modules/transcription.js.map +1 -0
  138. package/dist/modules/translation.d.ts +59 -0
  139. package/dist/modules/translation.d.ts.map +1 -0
  140. package/dist/modules/translation.js +119 -0
  141. package/dist/modules/translation.js.map +1 -0
  142. package/dist/modules/ui.d.ts +186 -0
  143. package/dist/modules/ui.d.ts.map +1 -0
  144. package/dist/modules/ui.js +293 -0
  145. package/dist/modules/ui.js.map +1 -0
  146. package/dist/protocol.d.ts +294 -0
  147. package/dist/protocol.d.ts.map +1 -0
  148. package/dist/protocol.js +320 -0
  149. package/dist/protocol.js.map +1 -0
  150. package/dist/react/MentraProvider.d.ts +33 -0
  151. package/dist/react/MentraProvider.d.ts.map +1 -0
  152. package/dist/react/MentraProvider.js +15 -0
  153. package/dist/react/MentraProvider.js.map +1 -0
  154. package/dist/react/MiniappHeader.d.ts +44 -0
  155. package/dist/react/MiniappHeader.d.ts.map +1 -0
  156. package/dist/react/MiniappHeader.js +32 -0
  157. package/dist/react/MiniappHeader.js.map +1 -0
  158. package/dist/react/index.d.ts +13 -0
  159. package/dist/react/index.d.ts.map +1 -0
  160. package/dist/react/index.js +9 -0
  161. package/dist/react/index.js.map +1 -0
  162. package/dist/react/useCapabilities.d.ts +26 -0
  163. package/dist/react/useCapabilities.d.ts.map +1 -0
  164. package/dist/react/useCapabilities.js +27 -0
  165. package/dist/react/useCapabilities.js.map +1 -0
  166. package/dist/react/useCapsuleHeaderStyle.d.ts +27 -0
  167. package/dist/react/useCapsuleHeaderStyle.d.ts.map +1 -0
  168. package/dist/react/useCapsuleHeaderStyle.js +36 -0
  169. package/dist/react/useCapsuleHeaderStyle.js.map +1 -0
  170. package/dist/react/useColorScheme.d.ts +18 -0
  171. package/dist/react/useColorScheme.d.ts.map +1 -0
  172. package/dist/react/useColorScheme.js +24 -0
  173. package/dist/react/useColorScheme.js.map +1 -0
  174. package/dist/react/useConnected.d.ts +17 -0
  175. package/dist/react/useConnected.d.ts.map +1 -0
  176. package/dist/react/useConnected.js +39 -0
  177. package/dist/react/useConnected.js.map +1 -0
  178. package/dist/react/useRpc.d.ts +29 -0
  179. package/dist/react/useRpc.d.ts.map +1 -0
  180. package/dist/react/useRpc.js +82 -0
  181. package/dist/react/useRpc.js.map +1 -0
  182. package/dist/react/useSafeArea.d.ts +21 -0
  183. package/dist/react/useSafeArea.d.ts.map +1 -0
  184. package/dist/react/useSafeArea.js +22 -0
  185. package/dist/react/useSafeArea.js.map +1 -0
  186. package/dist/session.d.ts +310 -0
  187. package/dist/session.d.ts.map +1 -0
  188. package/dist/session.js +653 -0
  189. package/dist/session.js.map +1 -0
  190. package/dist/sub-path-types.test-d.d.ts +28 -0
  191. package/dist/sub-path-types.test-d.d.ts.map +1 -0
  192. package/dist/sub-path-types.test-d.js +37 -0
  193. package/dist/sub-path-types.test-d.js.map +1 -0
  194. package/dist/transport/auto.d.ts +35 -0
  195. package/dist/transport/auto.d.ts.map +1 -0
  196. package/dist/transport/auto.js +125 -0
  197. package/dist/transport/auto.js.map +1 -0
  198. package/dist/transport/dispatch.d.ts +43 -0
  199. package/dist/transport/dispatch.d.ts.map +1 -0
  200. package/dist/transport/dispatch.js +91 -0
  201. package/dist/transport/dispatch.js.map +1 -0
  202. package/dist/transport/local-socket.d.ts +28 -0
  203. package/dist/transport/local-socket.d.ts.map +1 -0
  204. package/dist/transport/local-socket.js +80 -0
  205. package/dist/transport/local-socket.js.map +1 -0
  206. package/dist/transport/mock.d.ts +54 -0
  207. package/dist/transport/mock.d.ts.map +1 -0
  208. package/dist/transport/mock.js +205 -0
  209. package/dist/transport/mock.js.map +1 -0
  210. package/dist/transport/postmessage.d.ts +36 -0
  211. package/dist/transport/postmessage.d.ts.map +1 -0
  212. package/dist/transport/postmessage.js +78 -0
  213. package/dist/transport/postmessage.js.map +1 -0
  214. package/dist/transport/types.d.ts +25 -0
  215. package/dist/transport/types.d.ts.map +1 -0
  216. package/dist/transport/types.js +11 -0
  217. package/dist/transport/types.js.map +1 -0
  218. package/dist/ui/index.d.ts +98 -0
  219. package/dist/ui/index.d.ts.map +1 -0
  220. package/dist/ui/index.js +32 -0
  221. package/dist/ui/index.js.map +1 -0
  222. package/package.json +75 -0
  223. package/src/background/index.ts +152 -0
  224. package/src/background/register.ts +85 -0
  225. package/src/dev-reload.ts +60 -0
  226. package/src/envelope.ts +54 -0
  227. package/src/globals.ts +62 -0
  228. package/src/index.ts +171 -0
  229. package/src/modules/actions.ts +156 -0
  230. package/src/modules/auth.ts +68 -0
  231. package/src/modules/base64.ts +87 -0
  232. package/src/modules/blob.ts +341 -0
  233. package/src/modules/camera.ts +196 -0
  234. package/src/modules/cloud.ts +94 -0
  235. package/src/modules/dashboard.ts +31 -0
  236. package/src/modules/display.ts +122 -0
  237. package/src/modules/events.ts +264 -0
  238. package/src/modules/glasses.ts +44 -0
  239. package/src/modules/heading.ts +23 -0
  240. package/src/modules/imu.ts +44 -0
  241. package/src/modules/input.ts +71 -0
  242. package/src/modules/led.ts +61 -0
  243. package/src/modules/location.ts +39 -0
  244. package/src/modules/mic.ts +74 -0
  245. package/src/modules/miniapps.ts +90 -0
  246. package/src/modules/navigation.ts +670 -0
  247. package/src/modules/permissions.ts +84 -0
  248. package/src/modules/phone.ts +120 -0
  249. package/src/modules/pivots/engine.ts +863 -0
  250. package/src/modules/pivots/geometry.ts +409 -0
  251. package/src/modules/pivots/instructions.ts +343 -0
  252. package/src/modules/speaker.ts +328 -0
  253. package/src/modules/storage.ts +109 -0
  254. package/src/modules/stream.ts +163 -0
  255. package/src/modules/system.ts +76 -0
  256. package/src/modules/transcription.ts +128 -0
  257. package/src/modules/translation.ts +149 -0
  258. package/src/modules/ui.ts +498 -0
  259. package/src/protocol.ts +371 -0
  260. package/src/react/MentraProvider.tsx +50 -0
  261. package/src/react/MiniappHeader.tsx +122 -0
  262. package/src/react/index.ts +12 -0
  263. package/src/react/useCapabilities.ts +29 -0
  264. package/src/react/useCapsuleHeaderStyle.ts +54 -0
  265. package/src/react/useColorScheme.ts +26 -0
  266. package/src/react/useConnected.ts +47 -0
  267. package/src/react/useRpc.ts +106 -0
  268. package/src/react/useSafeArea.ts +40 -0
  269. package/src/session.ts +878 -0
  270. package/src/sub-path-types.test-d.ts +49 -0
  271. package/src/transport/auto.ts +143 -0
  272. package/src/transport/dispatch.ts +106 -0
  273. package/src/transport/local-socket.ts +93 -0
  274. package/src/transport/mock.ts +248 -0
  275. package/src/transport/postmessage.ts +95 -0
  276. package/src/transport/types.ts +26 -0
  277. package/src/ui/index.ts +122 -0
@@ -0,0 +1,343 @@
1
+ /**
2
+ * @fileoverview Routes-API instruction parsing helpers for the pivot
3
+ * engine.
4
+ *
5
+ * The Routes REST API returns per-step `navigationInstruction.instructions`
6
+ * strings like "Turn left onto Octavia Blvd" that unambiguously name the
7
+ * road being entered. Parsing these is the most reliable way to label a
8
+ * turn — more reliable than `StepInfo.road` from the Android Nav SDK,
9
+ * which lags one step behind for the live currentStep + uses a destination
10
+ * placeholder for the arrival leg.
11
+ *
12
+ * Originally lived in the Navigation miniapp's NavigationPage.tsx; moved
13
+ * into the SDK so every miniapp using `navigation.onPivot()` gets the
14
+ * same accurate road names without having to re-implement the parser.
15
+ *
16
+ * All helpers are pure — no side effects, no shared state.
17
+ */
18
+
19
+ import type {LatLng, ManeuverKind} from "../navigation"
20
+ import {bearingDeg, haversineMeters, signedAngleDiff} from "./geometry"
21
+
22
+ /**
23
+ * Bare direction words that can sneak through the regex when the
24
+ * instruction's "onto"/"on" clause is followed directly by a bare
25
+ * direction ("Head south on the right"). Reject these so the parser
26
+ * returns null and the caller can hide the label instead of showing
27
+ * "the right" as a road name.
28
+ */
29
+ const DIRECTION_WORDS = new Set(["right", "left", "the right", "the left", "north", "south", "east", "west"])
30
+
31
+ /**
32
+ * Maneuver verbs that appear in stacked instructions like
33
+ * "Turn left, then turn right onto Linden St" or fragments like
34
+ * "Continue onto Octavia". If a captured "road" contains any of
35
+ * these, it's not a road name — it's leftover instruction syntax
36
+ * from a multi-clause sentence. The parser rejects the capture.
37
+ */
38
+ const MANEUVER_VERBS = /\b(turn|slight|sharp|continue|head|merge|exit|uturn|u-turn|then|keep)\b/i
39
+
40
+ /**
41
+ * Pull a short road name out of a Routes-API instruction string.
42
+ * Instructions look like:
43
+ * "Turn left onto Octavia Blvd"
44
+ * "Slight right onto Octavia St"
45
+ * "Turn right onto Haight St\nDestination will be on the right"
46
+ *
47
+ * Returns null when the parse fails (e.g. instruction is missing, the
48
+ * "onto" clause is absent, or the captured text contains another
49
+ * maneuver verb / direction word). Callers treat null as "no road
50
+ * name available" and hide the label.
51
+ *
52
+ * Only "onto" is matched here — `\bon\b` was previously used as a
53
+ * fallback for depart steps ("Head north on Market St") but it
54
+ * over-matched, capturing maneuver verbs from stacked instructions
55
+ * ("Turn left, then turn right on..."). The depart step is the
56
+ * very first step and is dropped by the slice(0, -1) loop before
57
+ * this parser is called anyway, so removing the fallback costs us
58
+ * nothing real.
59
+ */
60
+ export function roadNameFromInstruction(instruction?: string | null): string | null {
61
+ if (!instruction) return null
62
+ // Only the first line — drop "Destination will be on the right" etc.
63
+ const firstLine = instruction.split("\n")[0] ?? ""
64
+ // Match "onto ROAD" only. The "on" fallback was removed because it
65
+ // matched inside maneuver fragments and captured verbs as fake road
66
+ // names. Real turn instructions always use "onto"; depart steps
67
+ // (the only legitimate "on" users) aren't passed through this path.
68
+ const m = firstLine.match(/\bonto\s+(.+)$/i)
69
+ let raw = (m ? m[1] : "").trim()
70
+ if (!raw) return null
71
+ // A captured road name never contains a comma. If it does, we
72
+ // grabbed the rest of a multi-clause sentence by mistake.
73
+ if (raw.includes(",")) return null
74
+ // Cut trailing direction/continuation clauses the API appends.
75
+ raw = raw.split(/\s+(?:toward|towards|to|and|then|for)\b/i)[0]?.trim() ?? ""
76
+ // Strip a unit/suite suffix ("Hayes St #116" → "Hayes St").
77
+ raw = raw.replace(/\s+#.*$/, "").trim()
78
+ if (!raw) return null
79
+ // Reject a bare direction word that slipped through.
80
+ if (DIRECTION_WORDS.has(raw.toLowerCase())) return null
81
+ // Reject anything containing maneuver verbs — the regex caught the
82
+ // wrong clause of a stacked instruction. Real road names don't
83
+ // contain "turn", "slight", "continue", etc.
84
+ if (MANEUVER_VERBS.test(raw)) return null
85
+ return raw
86
+ }
87
+
88
+ /**
89
+ * Equality check for road names that's robust to suffix variation
90
+ * ("Gough St" vs "Gough Street"), case, and trailing punctuation.
91
+ * Used to drop "stay on the same road" turns where the routes API
92
+ * names a turn but the user perceives a single continuous street.
93
+ */
94
+ const ROAD_SUFFIXES =
95
+ /\b(st|street|ave|avenue|blvd|boulevard|rd|road|dr|drive|ln|lane|way|ct|court|pl|place|ter|terrace|hwy|highway)\b\.?/g
96
+ function normalizeRoad(road: string): string {
97
+ return road.toLowerCase().replace(ROAD_SUFFIXES, "").replace(/[^a-z0-9 ]/g, "").replace(/\s+/g, " ").trim()
98
+ }
99
+ export function sameRoad(a: string, b: string): boolean {
100
+ return normalizeRoad(a) === normalizeRoad(b)
101
+ }
102
+
103
+ /**
104
+ * Coarse left/right classifier for a maneuver string. Collapses all
105
+ * left variants (TURN_LEFT, SLIGHT_LEFT, SHARP_LEFT, UTURN_LEFT) to
106
+ * "left" and the rights to "right". Returns null when the maneuver
107
+ * isn't a directional turn (STRAIGHT, NAME_CHANGE, ARRIVE, etc.).
108
+ */
109
+ export function turnDirection(maneuver?: string | null): "left" | "right" | null {
110
+ if (!maneuver) return null
111
+ const m = maneuver.toUpperCase()
112
+ if (m.includes("LEFT")) return "left"
113
+ if (m.includes("RIGHT")) return "right"
114
+ return null
115
+ }
116
+
117
+ /**
118
+ * How sharply the polyline actually bends at a given junction, in
119
+ * degrees [0, 180]. Probe walks outward from the polyline point
120
+ * nearest the junction until ~PROBE_METERS in each direction, then
121
+ * compares incoming vs outgoing bearings. The wide probe (22m) is
122
+ * deliberate: real street corners are rounded over 20–30m and a
123
+ * tighter probe under-reports the angle.
124
+ *
125
+ * Used to drop phantom turns the Routes API names at complex
126
+ * interchanges (Market → Gough → Market) where the drawn polyline is
127
+ * visually one straight line. Returns null when the polyline is too
128
+ * short to measure.
129
+ */
130
+ const PROBE_METERS = 22
131
+ export function bendAngleAt(points: LatLng[], junction: LatLng): number | null {
132
+ const signed = signedBendAt(points, junction)
133
+ return signed == null ? null : Math.abs(signed)
134
+ }
135
+
136
+ /**
137
+ * Signed polyline bend at a junction. Positive = right turn, negative
138
+ * = left turn, in [-180, 180]. Used to pick a pivot's left/right
139
+ * direction from geometry when the Routes API's first-step maneuver
140
+ * is misleading (e.g. a curb-alignment micro-jog labeled TURN_LEFT
141
+ * right before the real right turn off Hayes onto Gough).
142
+ */
143
+ export function signedBendAt(points: LatLng[], junction: LatLng): number | null {
144
+ if (points.length < 3) return null
145
+ let mid = 0
146
+ let bestDist = Infinity
147
+ for (let i = 0; i < points.length; i++) {
148
+ const d = haversineMeters(points[i], junction)
149
+ if (d < bestDist) {
150
+ bestDist = d
151
+ mid = i
152
+ }
153
+ }
154
+ let before = mid
155
+ while (before > 0 && haversineMeters(points[before], points[mid]) < PROBE_METERS) before--
156
+ let after = mid
157
+ while (after < points.length - 1 && haversineMeters(points[after], points[mid]) < PROBE_METERS) after++
158
+ if (before === mid || after === mid) return null
159
+ const incoming = bearingDeg(points[before], points[mid])
160
+ const outgoing = bearingDeg(points[mid], points[after])
161
+ return signedAngleDiff(outgoing, incoming)
162
+ }
163
+
164
+ /**
165
+ * Minimum bend (degrees) for a junction to count as a real turn worth
166
+ * a pivot. Below this the route is effectively straight through the
167
+ * point; the Routes API may name it as a turn for legal/lane reasons
168
+ * but the user perceives no direction change.
169
+ */
170
+ export const MIN_TURN_ANGLE_DEG = 30
171
+
172
+ /**
173
+ * Shape of a Routes-API computed step (matches `ComputedRouteStep` in
174
+ * the navigation module). Re-declared locally to avoid a circular
175
+ * import; the field set this helper needs is small.
176
+ */
177
+ type ComputedStep = {
178
+ lat: number
179
+ lng: number
180
+ endLat: number
181
+ endLng: number
182
+ distanceMeters: number
183
+ maneuver?: ManeuverKind
184
+ instruction?: string
185
+ /**
186
+ * Pre-resolved road name from the host's hybrid resolver (Phase 1).
187
+ * Preferred over `instruction` parsing when present — same parser
188
+ * runs host-side, just earlier in the pipeline. Older callers that
189
+ * only pass `instruction` still work via the parse fallback below.
190
+ */
191
+ road?: string | null
192
+ }
193
+
194
+ /**
195
+ * A pivot derived from Routes-API computed steps. Carries the
196
+ * instruction-parsed road labels alongside the geometric corner,
197
+ * ready to be merged into the SDK's `Pivot` shape by the caller.
198
+ *
199
+ * `fromRoad` is required — we drop pivots whose entry road couldn't
200
+ * be parsed. `toRoad` is nullable because the LAST real turn before
201
+ * arrival has a known fromRoad but unknown toRoad (the arrival
202
+ * step's instruction is "Destination will be on the right", no road
203
+ * name in it). The pivot engine treats those nulls as candidates
204
+ * for the reverse-geocode fallback.
205
+ */
206
+ export type InstructionPivot = {
207
+ lat: number
208
+ lng: number
209
+ fromRoad: string
210
+ toRoad: string | null
211
+ direction: "left" | "right" | null
212
+ maneuver: ManeuverKind
213
+ }
214
+
215
+ /**
216
+ * Derive accurate turn pivots from a Routes-API step list. Each
217
+ * computed step's `instruction` describes the maneuver that BEGINS
218
+ * that step ("Turn left onto Guerrero St"). A pivot sits at
219
+ * `step[i].end` — the junction where you leave step[i]'s road and
220
+ * turn onto step[i+1]'s road. So for each pair (step[i], step[i+1]):
221
+ *
222
+ * fromRoad = roadNameFromInstruction(step[i].instruction)
223
+ * toRoad = roadNameFromInstruction(step[i+1].instruction)
224
+ * anchor = (step[i].endLat, step[i].endLng)
225
+ * maneuver = step[i+1].maneuver (the actual turn type at the corner)
226
+ *
227
+ * A pivot survives these filters:
228
+ * 1. `fromRoad` parsed successfully from step[i]'s instruction.
229
+ * Drop the pivot otherwise — we don't know the road the user
230
+ * came from, and labeling the dot wouldn't make sense.
231
+ * 2. If `toRoad` parsed too, it must differ from `fromRoad` (drop
232
+ * "stay on same road" jogs). If `toRoad` failed to parse, keep
233
+ * the pivot with `toRoad: null` so the engine can recover it
234
+ * via reverse-geocode. This is what keeps the LAST real turn
235
+ * before arrival in the list — that step's instruction is
236
+ * "Destination will be on the right" with no road name, but
237
+ * the turn itself is real and the user needs to see it.
238
+ * 3. The drawn polyline bends ≥ MIN_TURN_ANGLE_DEG at the junction
239
+ * (drops phantom turns where the geometry is visually straight).
240
+ *
241
+ * The LAST step (the arrival leg itself) is dropped by slice(0, -1) —
242
+ * the destination isn't a turn.
243
+ *
244
+ * Mirrors the Navigation miniapp's preview-turn extraction so live
245
+ * pivots match preview accuracy.
246
+ */
247
+ export function extractPivotsFromComputedSteps(
248
+ steps: ComputedStep[] | undefined,
249
+ polyline: LatLng[],
250
+ ): InstructionPivot[] {
251
+ if (!steps || steps.length < 2) return []
252
+
253
+ // Phantom A→B→A collapse. The Routes API sometimes decomposes a
254
+ // single perceived turn into 3-5 micro-steps that briefly bounce
255
+ // onto an intersecting road and back ("Market → Octavia → Market →
256
+ // Gough" for a single right turn off Market onto Gough). Walking
257
+ // the resolved road sequence and dropping any single-entry sandwich
258
+ // between two same-road entries collapses those jogs before we
259
+ // pair adjacent entries into pivots — otherwise the extractor emits
260
+ // a bogus "Market → Octavia" pivot AND a bogus "Octavia → Market"
261
+ // pivot at the same corner. Repeated up to 4 passes to catch
262
+ // A,B,A,B,A patterns where the same intersection contributes more
263
+ // than one jog.
264
+ //
265
+ // Mirrors the collapse pass the Navigation miniapp runs before
266
+ // building its preview/live turn dots — moving it here means every
267
+ // downstream consumer (the on-screen banner, the glasses display)
268
+ // gets the corrected pivots without each rebuilding the logic.
269
+ type Annotated = {stepIdx: number; name: string | null}
270
+ const initial: Annotated[] = steps.map((s, i) => ({
271
+ stepIdx: i,
272
+ name: s.road ?? roadNameFromInstruction(s.instruction),
273
+ }))
274
+ let annotated = initial
275
+ for (let pass = 0; pass < 4; pass++) {
276
+ const collapsed: Annotated[] = []
277
+ for (let i = 0; i < annotated.length; i++) {
278
+ const prev = collapsed[collapsed.length - 1]
279
+ const here = annotated[i]
280
+ const next = annotated[i + 1]
281
+ if (
282
+ prev?.name &&
283
+ next?.name &&
284
+ here.name &&
285
+ !sameRoad(prev.name, here.name) &&
286
+ sameRoad(prev.name, next.name)
287
+ ) {
288
+ // `here` is a sandwiched micro-jog — drop it. `next` will be
289
+ // collapsed against `prev` on the same-road check below.
290
+ continue
291
+ }
292
+ collapsed.push(here)
293
+ }
294
+ if (collapsed.length === annotated.length) break
295
+ annotated = collapsed
296
+ }
297
+
298
+ const out: InstructionPivot[] = []
299
+ // Pair each annotated entry with the next one. Note we no longer
300
+ // walk `steps` directly — `annotated` is the collapsed view, so
301
+ // adjacent annotated entries already represent the real road
302
+ // transitions. `stepIdx` is preserved so we can still look up the
303
+ // junction coordinates and maneuver from the original `steps` array.
304
+ for (let i = 0; i < annotated.length - 1; i++) {
305
+ const here = annotated[i]
306
+ const nextAnn = annotated[i + 1]
307
+ const s = steps[here.stepIdx]
308
+ const next = steps[nextAnn.stepIdx]
309
+ const fromRoad = here.name
310
+ const toRoad = nextAnn.name
311
+ // fromRoad is required; toRoad is nullable (last-turn-before-arrival
312
+ // has no road name in the arrival step's instruction).
313
+ if (!fromRoad) continue
314
+ if (toRoad && sameRoad(fromRoad, toRoad)) continue
315
+ if (!Number.isFinite(s.endLat) || !Number.isFinite(s.endLng)) continue
316
+
317
+ const junction = {lat: s.endLat, lng: s.endLng}
318
+ const signedBend = signedBendAt(polyline, junction)
319
+ // No polyline to measure → keep (don't drop a labeled turn just
320
+ // because geometry was unavailable).
321
+ if (signedBend != null && Math.abs(signedBend) < MIN_TURN_ANGLE_DEG) continue
322
+
323
+ // Direction precedence: trust polyline geometry first. The Routes
324
+ // API decomposes some junctions into micro-step jogs whose first
325
+ // step's maneuver lies (e.g. a "TURN_LEFT" curb-alignment jog
326
+ // right before the actual right turn off Hayes onto Gough).
327
+ // Geometry doesn't lie. Falls back to the maneuver string when
328
+ // the polyline is too short to measure a bend.
329
+ const geomDir: "left" | "right" | null =
330
+ signedBend == null ? null : signedBend > 0 ? "right" : "left"
331
+ const direction = geomDir ?? turnDirection(next.maneuver)
332
+
333
+ out.push({
334
+ lat: s.endLat,
335
+ lng: s.endLng,
336
+ fromRoad,
337
+ toRoad,
338
+ direction,
339
+ maneuver: next.maneuver ?? (direction === "left" ? "TURN_LEFT" : "TURN_RIGHT"),
340
+ })
341
+ }
342
+ return out
343
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * @fileoverview SpeakerModule — phone-side audio output.
3
+ *
4
+ * Mirrors cloud SDK v3's SpeakerManager naming. Audio *input* (transcription,
5
+ * audio chunks, VAD) lives on session.mic — the split is by I/O direction.
6
+ *
7
+ * Imperative surface:
8
+ * speaker.play({audioUrl}) — play an arbitrary URL via the phone's
9
+ * AudioPlaybackService.
10
+ * speaker.speak(text) — send a SPEAK request. Phone streams cloud
11
+ * TTS when connected and falls back to local
12
+ * offline TTS when cloud is unavailable.
13
+ * Resolves when playback completes; rejects
14
+ * with a TTS_* error code on cloud failure.
15
+ * speaker.createStream(opts) — open a live PCM output stream (16-bit LE).
16
+ * Returns a SpeakerStreamWriter: write() PCM
17
+ * chunks as they arrive (e.g. from a WebSocket
18
+ * in the background), close() to drain, abort()
19
+ * to drop. write() resolves with {bufferedMs}
20
+ * so producers can throttle.
21
+ * speaker.stop() — stop any audio this miniapp is playing.
22
+ *
23
+ * State observability:
24
+ * speaker.state — current SpeakerState (sync getter).
25
+ * speaker.isPlaying — true iff state === "playing".
26
+ * speaker.onStateChange(h) — fires on every state transition.
27
+ *
28
+ * State machine (per miniapp):
29
+ * idle ─── speak()/play() ──► loading ──► playing ──► stopped
30
+ * │ │ │
31
+ * └── error ───┴── stop ───┘
32
+ *
33
+ * `error` is transient — fires once with errorCode set, then settles to
34
+ * `stopped` so isPlaying reads false correctly.
35
+ */
36
+
37
+ import {MiniappErrorCode, MiniappRequestType} from "../protocol"
38
+ import {MiniappSession} from "../session"
39
+ import {bytesToBase64, toUint8Array} from "./base64"
40
+ import type {UnsubscribeFn} from "./events"
41
+
42
+ export interface PlayAudioOptions {
43
+ audioUrl: string
44
+ volume?: number
45
+ stopOtherAudio?: boolean
46
+ }
47
+
48
+ export interface SpeakOptions {
49
+ voice_id?: string
50
+ voice_settings?: Record<string, unknown>
51
+ volume?: number
52
+ stopOtherAudio?: boolean
53
+ }
54
+
55
+ export interface SpeakResult {
56
+ /** True if playback completed; false if playback was interrupted. */
57
+ completed: boolean
58
+ }
59
+
60
+ export type SpeakerState = "idle" | "loading" | "playing" | "stopped" | "error"
61
+
62
+ // ── live PCM output stream ───────────────────────────────────────────────────
63
+
64
+ /** Sample rates the native chunk player accepts. */
65
+ export type SpeakerStreamSampleRate = 16000 | 24000 | 48000
66
+
67
+ export interface SpeakerStreamOptions {
68
+ /** PCM sample rate in Hz. Default 16000. */
69
+ sampleRate?: SpeakerStreamSampleRate
70
+ /** Channel count. v1 is mono only. Default 1. */
71
+ channels?: 1
72
+ /** Playback volume 0..1. Default 1. */
73
+ volume?: number
74
+ /** Stop any other audio this miniapp is playing first. Default true. */
75
+ stopOtherAudio?: boolean
76
+ }
77
+
78
+ export interface SpeakerStreamWriteResult {
79
+ /** Milliseconds of audio queued host-side but not yet played. */
80
+ bufferedMs: number
81
+ }
82
+
83
+ /** Raw bytes per bridge write. PCM chunks are small; keep messages snappy. */
84
+ export const SPEAKER_WRITE_CHUNK_BYTES = 256 * 1024
85
+ // Every non-final base64 slice must decode to complete 16-bit samples. Six
86
+ // raw bytes map to eight base64 chars, preserving both base64 and PCM framing.
87
+ const SPEAKER_WRITE_CHUNK_B64 = Math.floor(SPEAKER_WRITE_CHUNK_BYTES / 6) * 8
88
+
89
+ /**
90
+ * Soft backpressure ceiling. When the host reports more than this buffered,
91
+ * it holds the WRITE reply until the buffer drains below it, so a producer
92
+ * that awaits each write self-throttles to realtime.
93
+ */
94
+ export const SPEAKER_STREAM_MAX_BUFFERED_MS = 2000
95
+
96
+ /**
97
+ * Live PCM writer returned by `speaker.createStream()`. 16-bit little-endian
98
+ * PCM only — decode compressed formats (MP3/Opus) server-side before sending.
99
+ *
100
+ * Call `write()` as chunks arrive (each is auto-split into bridge-safe
101
+ * messages), then `close()` to drain and finish, or `abort()` to drop
102
+ * immediately. Shaped like BlobWriter so the two stream APIs feel the same.
103
+ */
104
+ export class SpeakerStreamWriter {
105
+ private settled = false
106
+ private writeChain: Promise<void> = Promise.resolve()
107
+
108
+ constructor(
109
+ private readonly session: MiniappSession,
110
+ readonly streamId: string,
111
+ ) {}
112
+
113
+ /**
114
+ * Append raw PCM bytes (16-bit LE). Auto-chunked. Resolves with the host's
115
+ * buffered-ms after the last chunk. The host holds the reply while its
116
+ * buffer is above the backpressure ceiling, so a producer that awaits each
117
+ * write self-throttles to realtime.
118
+ */
119
+ async write(chunk: Uint8Array | ArrayBuffer): Promise<SpeakerStreamWriteResult> {
120
+ this.assertOpen()
121
+ const bytes = toUint8Array(chunk)
122
+ if (bytes.byteLength === 0) throw new Error("PCM chunk cannot be empty")
123
+ if (bytes.byteLength % 2 !== 0) throw new Error("PCM chunk must contain complete 16-bit samples")
124
+ return this.enqueueWrite(async () => {
125
+ let last: SpeakerStreamWriteResult = {bufferedMs: 0}
126
+ for (let off = 0; off < bytes.length; off += SPEAKER_WRITE_CHUNK_BYTES) {
127
+ last = await this.send(bytesToBase64(bytes.subarray(off, off + SPEAKER_WRITE_CHUNK_BYTES)))
128
+ }
129
+ return last
130
+ })
131
+ }
132
+
133
+ /** Append already-base64-encoded PCM bytes (e.g. relayed straight off a WS frame). */
134
+ async writeBase64(b64: string): Promise<SpeakerStreamWriteResult> {
135
+ this.assertOpen()
136
+ if (b64.length === 0 || b64.length % 4 !== 0) throw new Error("PCM base64 must be non-empty and padded")
137
+ const padding = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0
138
+ const decodedBytes = (b64.length / 4) * 3 - padding
139
+ if (decodedBytes % 2 !== 0) throw new Error("PCM base64 must contain complete 16-bit samples")
140
+ return this.enqueueWrite(async () => {
141
+ let last: SpeakerStreamWriteResult = {bufferedMs: 0}
142
+ // Slice on 4-char boundaries so each chunk is whole base64 groups.
143
+ for (let off = 0; off < b64.length; off += SPEAKER_WRITE_CHUNK_B64) {
144
+ last = await this.send(b64.slice(off, off + SPEAKER_WRITE_CHUNK_B64))
145
+ }
146
+ return last
147
+ })
148
+ }
149
+
150
+ /** Drain the remaining buffer and finish. Resolves when playback has ended. */
151
+ async close(): Promise<{durationMs?: number}> {
152
+ this.assertOpen()
153
+ this.settled = true
154
+ await this.writeChain
155
+ // Draining takes as long as the buffered audio — opt out of the default
156
+ // request timeout like play()/speak() do.
157
+ const res = await this.session.sendRequest<{durationMs?: number} | null>(
158
+ {type: MiniappRequestType.SPEAKER_STREAM_CLOSE, streamId: this.streamId},
159
+ {timeoutMs: 0},
160
+ )
161
+ return res ?? {}
162
+ }
163
+
164
+ /** Stop immediately and drop any buffered audio. Idempotent. */
165
+ async abort(): Promise<void> {
166
+ if (this.settled) return
167
+ this.settled = true
168
+ await this.session.sendRequest<void>({
169
+ type: MiniappRequestType.SPEAKER_STREAM_ABORT,
170
+ streamId: this.streamId,
171
+ })
172
+ }
173
+
174
+ private assertOpen(): void {
175
+ if (this.settled) throw new Error("SpeakerStreamWriter is already closed/aborted")
176
+ }
177
+
178
+ private async send(base64: string): Promise<SpeakerStreamWriteResult> {
179
+ const res = await this.session.sendRequest<SpeakerStreamWriteResult | null>({
180
+ type: MiniappRequestType.SPEAKER_STREAM_WRITE,
181
+ streamId: this.streamId,
182
+ base64,
183
+ })
184
+ return res ?? {bufferedMs: 0}
185
+ }
186
+
187
+ /** Preserve PCM ordering even when a producer issues overlapping writes. */
188
+ private enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
189
+ const result = this.writeChain.then(operation)
190
+ this.writeChain = result.then(
191
+ () => undefined,
192
+ () => undefined,
193
+ )
194
+ return result
195
+ }
196
+ }
197
+
198
+ export interface SpeakerStateEvent {
199
+ state: SpeakerState
200
+ /** When state === "error", the underlying error code (TTS_*, INTERNAL). */
201
+ errorCode?: string
202
+ errorMessage?: string
203
+ /** When state === "stopped", how many ms the playback ran (best-effort). */
204
+ durationMs?: number
205
+ }
206
+
207
+ export class SpeakerModule {
208
+ private _state: SpeakerState = "idle"
209
+ private _lastEvent: SpeakerStateEvent = {state: "idle"}
210
+
211
+ constructor(private readonly session: MiniappSession) {}
212
+
213
+ /** Current speaker playback state. */
214
+ get state(): SpeakerState {
215
+ return this._state
216
+ }
217
+
218
+ /** True iff state === "playing". */
219
+ get isPlaying(): boolean {
220
+ return this._state === "playing"
221
+ }
222
+
223
+ /** Play a URL. Resolves when playback completes on the phone. */
224
+ async play(options: PlayAudioOptions): Promise<void> {
225
+ // Playback length is unbounded (a clip can run for minutes), and the host
226
+ // only sends its REQUEST_RESULT when playback finishes or is interrupted, so
227
+ // opt out of the default request timeout — otherwise a long clip would reject
228
+ // with ACTION_TIMEOUT mid-playback. A `stop()` or disconnect still settles it.
229
+ await this.session.sendRequest<void>(
230
+ {
231
+ type: MiniappRequestType.PLAY_AUDIO,
232
+ audioUrl: options.audioUrl,
233
+ volume: options.volume,
234
+ stopOtherAudio: options.stopOtherAudio ?? false,
235
+ },
236
+ {timeoutMs: 0},
237
+ )
238
+ }
239
+
240
+ /**
241
+ * Speak text through the phone. The host streams cloud TTS when connected,
242
+ * then falls back to local offline TTS.
243
+ *
244
+ * Rejects with a MiniappRequestError containing a `code` field on cloud-side
245
+ * TTS failures: `TTS_TEXT_TOO_LONG`, `TTS_INVALID_VOICE`, `TTS_UPSTREAM_ERROR`.
246
+ */
247
+ async speak(text: string, options: SpeakOptions = {}): Promise<SpeakResult> {
248
+ try {
249
+ // Like play(): resolves only when TTS playback completes, so opt out of the
250
+ // default request timeout (long text can outlast it). Settled by the host
251
+ // result, a stop(), or disconnect.
252
+ const result = await this.session.sendRequest<SpeakResult | null>(
253
+ {
254
+ type: MiniappRequestType.SPEAK,
255
+ text,
256
+ voice_id: options.voice_id,
257
+ voice_settings: options.voice_settings,
258
+ volume: options.volume,
259
+ stopOtherAudio: options.stopOtherAudio ?? false,
260
+ },
261
+ {timeoutMs: 0},
262
+ )
263
+ return result ?? {completed: true}
264
+ } catch (err) {
265
+ // Normalize so callers can `catch (e) { if (e.code === "TTS_TEXT_TOO_LONG") ...`
266
+ if (err && typeof err === "object" && "code" in err) {
267
+ throw err
268
+ }
269
+ throw {code: MiniappErrorCode.INTERNAL, message: String(err)}
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Open a live PCM output stream (16-bit LE) to the phone's audio playback
275
+ * service. Use from the background to play audio that arrives in chunks
276
+ * (e.g. live meeting audio over a WebSocket) with low latency — unlike
277
+ * `play()`, nothing has to be a file or URL first.
278
+ *
279
+ * One stream per miniapp: opening a second closes the first. Rejects with
280
+ * `NOT_IMPLEMENTED` on hosts that predate streaming. Miniapps that depend on
281
+ * this API must declare `minHostVersion: "2.13.0"` or newer.
282
+ */
283
+ async createStream(options: SpeakerStreamOptions = {}): Promise<SpeakerStreamWriter> {
284
+ if (
285
+ options.volume !== undefined &&
286
+ (!Number.isFinite(options.volume) || options.volume < 0 || options.volume > 1)
287
+ ) {
288
+ throw new RangeError("speaker stream volume must be between 0 and 1")
289
+ }
290
+ const res = await this.session.sendRequest<{streamId: string}>({
291
+ type: MiniappRequestType.SPEAKER_STREAM_OPEN,
292
+ sampleRate: options.sampleRate ?? 16000,
293
+ channels: options.channels ?? 1,
294
+ volume: options.volume,
295
+ stopOtherAudio: options.stopOtherAudio ?? true,
296
+ })
297
+ return new SpeakerStreamWriter(this.session, res.streamId)
298
+ }
299
+
300
+ /** Stop any audio this miniapp is currently playing. */
301
+ stop(): void {
302
+ this.session.sendOneShot({type: MiniappRequestType.STOP_AUDIO})
303
+ }
304
+
305
+ /**
306
+ * Subscribe to speaker state transitions. Fires for every change. Does NOT
307
+ * fire immediately with the current value — call `state` separately if you
308
+ * want the seed.
309
+ */
310
+ onStateChange(handler: (event: SpeakerStateEvent) => void): UnsubscribeFn {
311
+ return this.session.on("speakerState", handler)
312
+ }
313
+
314
+ /** @internal — applied by MiniappSession on inbound SPEAKER_STATE envelope. */
315
+ _applyState(event: SpeakerStateEvent): void {
316
+ // Idempotent: skip if state didn't change. Error events are transient
317
+ // and are not deduped against the prior state — they're informational
318
+ // and the phone immediately follows up with `stopped`.
319
+ if (event.state === this._state && event.state !== "error") return
320
+ this._state = event.state
321
+ this._lastEvent = event
322
+ }
323
+
324
+ /** @internal — for tests. */
325
+ _getLastEvent(): SpeakerStateEvent {
326
+ return {...this._lastEvent}
327
+ }
328
+ }