speech.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. import { onCleanup } from "solid-js"
  2. import { createStore } from "solid-js/store"
  3. import { getSpeechRecognitionCtor } from "@/utils/runtime-adapters"
  4. // Minimal types to avoid relying on non-standard DOM typings
  5. type RecognitionResult = {
  6. 0: { transcript: string }
  7. isFinal: boolean
  8. }
  9. type RecognitionEvent = {
  10. results: RecognitionResult[]
  11. resultIndex: number
  12. }
  13. interface Recognition {
  14. continuous: boolean
  15. interimResults: boolean
  16. lang: string
  17. start: () => void
  18. stop: () => void
  19. onresult: ((e: RecognitionEvent) => void) | null
  20. onerror: ((e: { error: string }) => void) | null
  21. onend: (() => void) | null
  22. onstart: (() => void) | null
  23. }
  24. const COMMIT_DELAY = 250
  25. const appendSegment = (base: string, addition: string) => {
  26. const trimmed = addition.trim()
  27. if (!trimmed) return base
  28. if (!base) return trimmed
  29. const needsSpace = /\S$/.test(base) && !/^[,.;!?]/.test(trimmed)
  30. return `${base}${needsSpace ? " " : ""}${trimmed}`
  31. }
  32. const extractSuffix = (committed: string, hypothesis: string) => {
  33. const cleanHypothesis = hypothesis.trim()
  34. if (!cleanHypothesis) return ""
  35. const baseTokens = committed.trim() ? committed.trim().split(/\s+/) : []
  36. const hypothesisTokens = cleanHypothesis.split(/\s+/)
  37. let index = 0
  38. while (
  39. index < baseTokens.length &&
  40. index < hypothesisTokens.length &&
  41. baseTokens[index] === hypothesisTokens[index]
  42. ) {
  43. index += 1
  44. }
  45. if (index < baseTokens.length) return ""
  46. return hypothesisTokens.slice(index).join(" ")
  47. }
  48. export function createSpeechRecognition(opts?: {
  49. lang?: string
  50. onFinal?: (text: string) => void
  51. onInterim?: (text: string) => void
  52. }) {
  53. const ctor = getSpeechRecognitionCtor<Recognition>(typeof window === "undefined" ? undefined : window)
  54. const hasSupport = Boolean(ctor)
  55. const [store, setStore] = createStore({
  56. isRecording: false,
  57. committed: "",
  58. interim: "",
  59. })
  60. const isRecording = () => store.isRecording
  61. const committed = () => store.committed
  62. const interim = () => store.interim
  63. let recognition: Recognition | undefined
  64. let shouldContinue = false
  65. let committedText = ""
  66. let sessionCommitted = ""
  67. let pendingHypothesis = ""
  68. let lastInterimSuffix = ""
  69. let shrinkCandidate: string | undefined
  70. let commitTimer: number | undefined
  71. let restartTimer: number | undefined
  72. const cancelPendingCommit = () => {
  73. if (commitTimer === undefined) return
  74. clearTimeout(commitTimer)
  75. commitTimer = undefined
  76. }
  77. const clearRestart = () => {
  78. if (restartTimer === undefined) return
  79. window.clearTimeout(restartTimer)
  80. restartTimer = undefined
  81. }
  82. const scheduleRestart = () => {
  83. clearRestart()
  84. if (!shouldContinue) return
  85. if (!recognition) return
  86. restartTimer = window.setTimeout(() => {
  87. restartTimer = undefined
  88. if (!shouldContinue) return
  89. if (!recognition) return
  90. try {
  91. recognition.start()
  92. } catch {}
  93. }, 150)
  94. }
  95. const commitSegment = (segment: string) => {
  96. const nextCommitted = appendSegment(committedText, segment)
  97. if (nextCommitted === committedText) return
  98. committedText = nextCommitted
  99. setStore("committed", committedText)
  100. if (opts?.onFinal) opts.onFinal(segment.trim())
  101. }
  102. const promotePending = () => {
  103. if (!pendingHypothesis) return
  104. const suffix = extractSuffix(sessionCommitted, pendingHypothesis)
  105. if (!suffix) {
  106. pendingHypothesis = ""
  107. return
  108. }
  109. sessionCommitted = appendSegment(sessionCommitted, suffix)
  110. commitSegment(suffix)
  111. pendingHypothesis = ""
  112. lastInterimSuffix = ""
  113. shrinkCandidate = undefined
  114. setStore("interim", "")
  115. if (opts?.onInterim) opts.onInterim("")
  116. }
  117. const applyInterim = (suffix: string, hypothesis: string) => {
  118. cancelPendingCommit()
  119. pendingHypothesis = hypothesis
  120. lastInterimSuffix = suffix
  121. shrinkCandidate = undefined
  122. setStore("interim", suffix)
  123. if (opts?.onInterim) {
  124. opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
  125. }
  126. if (!suffix) return
  127. const snapshot = hypothesis
  128. commitTimer = window.setTimeout(() => {
  129. if (pendingHypothesis !== snapshot) return
  130. const currentSuffix = extractSuffix(sessionCommitted, pendingHypothesis)
  131. if (!currentSuffix) return
  132. sessionCommitted = appendSegment(sessionCommitted, currentSuffix)
  133. commitSegment(currentSuffix)
  134. pendingHypothesis = ""
  135. lastInterimSuffix = ""
  136. shrinkCandidate = undefined
  137. setStore("interim", "")
  138. if (opts?.onInterim) opts.onInterim("")
  139. }, COMMIT_DELAY)
  140. }
  141. if (ctor) {
  142. recognition = new ctor()
  143. recognition.continuous = false
  144. recognition.interimResults = true
  145. recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US")
  146. recognition.onresult = (event: RecognitionEvent) => {
  147. if (!event.results.length) return
  148. let aggregatedFinal = ""
  149. let latestHypothesis = ""
  150. for (let i = 0; i < event.results.length; i += 1) {
  151. const result = event.results[i]
  152. const transcript = (result[0]?.transcript || "").trim()
  153. if (!transcript) continue
  154. if (result.isFinal) {
  155. aggregatedFinal = appendSegment(aggregatedFinal, transcript)
  156. } else {
  157. latestHypothesis = transcript
  158. }
  159. }
  160. if (aggregatedFinal) {
  161. cancelPendingCommit()
  162. const finalSuffix = extractSuffix(sessionCommitted, aggregatedFinal)
  163. if (finalSuffix) {
  164. sessionCommitted = appendSegment(sessionCommitted, finalSuffix)
  165. commitSegment(finalSuffix)
  166. }
  167. pendingHypothesis = ""
  168. lastInterimSuffix = ""
  169. shrinkCandidate = undefined
  170. setStore("interim", "")
  171. if (opts?.onInterim) opts.onInterim("")
  172. return
  173. }
  174. cancelPendingCommit()
  175. if (!latestHypothesis) {
  176. shrinkCandidate = undefined
  177. applyInterim("", "")
  178. return
  179. }
  180. const suffix = extractSuffix(sessionCommitted, latestHypothesis)
  181. if (!suffix) {
  182. if (!lastInterimSuffix) {
  183. shrinkCandidate = undefined
  184. applyInterim("", latestHypothesis)
  185. return
  186. }
  187. if (shrinkCandidate === "") {
  188. applyInterim("", latestHypothesis)
  189. return
  190. }
  191. shrinkCandidate = ""
  192. pendingHypothesis = latestHypothesis
  193. return
  194. }
  195. if (lastInterimSuffix && suffix.length < lastInterimSuffix.length) {
  196. if (shrinkCandidate === suffix) {
  197. applyInterim(suffix, latestHypothesis)
  198. return
  199. }
  200. shrinkCandidate = suffix
  201. pendingHypothesis = latestHypothesis
  202. return
  203. }
  204. shrinkCandidate = undefined
  205. applyInterim(suffix, latestHypothesis)
  206. }
  207. recognition.onerror = (e: { error: string }) => {
  208. clearRestart()
  209. cancelPendingCommit()
  210. lastInterimSuffix = ""
  211. shrinkCandidate = undefined
  212. if (e.error === "no-speech" && shouldContinue) {
  213. setStore("interim", "")
  214. if (opts?.onInterim) opts.onInterim("")
  215. scheduleRestart()
  216. return
  217. }
  218. shouldContinue = false
  219. setStore("isRecording", false)
  220. }
  221. recognition.onstart = () => {
  222. clearRestart()
  223. sessionCommitted = ""
  224. pendingHypothesis = ""
  225. cancelPendingCommit()
  226. lastInterimSuffix = ""
  227. shrinkCandidate = undefined
  228. setStore("interim", "")
  229. if (opts?.onInterim) opts.onInterim("")
  230. setStore("isRecording", true)
  231. }
  232. recognition.onend = () => {
  233. clearRestart()
  234. cancelPendingCommit()
  235. lastInterimSuffix = ""
  236. shrinkCandidate = undefined
  237. setStore("isRecording", false)
  238. if (shouldContinue) {
  239. scheduleRestart()
  240. }
  241. }
  242. }
  243. const start = () => {
  244. if (!recognition) return
  245. clearRestart()
  246. shouldContinue = true
  247. sessionCommitted = ""
  248. pendingHypothesis = ""
  249. cancelPendingCommit()
  250. lastInterimSuffix = ""
  251. shrinkCandidate = undefined
  252. setStore("interim", "")
  253. try {
  254. recognition.start()
  255. } catch {}
  256. }
  257. const stop = () => {
  258. if (!recognition) return
  259. shouldContinue = false
  260. clearRestart()
  261. promotePending()
  262. cancelPendingCommit()
  263. lastInterimSuffix = ""
  264. shrinkCandidate = undefined
  265. setStore("interim", "")
  266. if (opts?.onInterim) opts.onInterim("")
  267. try {
  268. recognition.stop()
  269. } catch {}
  270. }
  271. onCleanup(() => {
  272. shouldContinue = false
  273. clearRestart()
  274. promotePending()
  275. cancelPendingCommit()
  276. lastInterimSuffix = ""
  277. shrinkCandidate = undefined
  278. setStore("interim", "")
  279. if (opts?.onInterim) opts.onInterim("")
  280. try {
  281. recognition?.stop()
  282. } catch {}
  283. })
  284. return {
  285. isSupported: () => hasSupport,
  286. isRecording,
  287. committed,
  288. interim,
  289. start,
  290. stop,
  291. }
  292. }