@glassly/miniapp 0.3.0-dev.1

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