speech.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import { onCleanup } from "solid-js"
  2. import { createStore } from "solid-js/store"
  3. // Minimal types to avoid relying on non-standard DOM typings
  4. type RecognitionResult = {
  5. 0: { transcript: string }
  6. isFinal: boolean
  7. }
  8. type RecognitionEvent = {
  9. results: RecognitionResult[]
  10. resultIndex: number
  11. }
  12. interface Recognition {
  13. continuous: boolean
  14. interimResults: boolean
  15. lang: string
  16. start: () => void
  17. stop: () => void
  18. onresult: ((e: RecognitionEvent) => void) | null
  19. onerror: ((e: { error: string }) => void) | null
  20. onend: (() => void) | null
  21. onstart: (() => void) | null
  22. }
  23. const COMMIT_DELAY = 250
  24. const appendSegment = (base: string, addition: string) => {
  25. const trimmed = addition.trim()
  26. if (!trimmed) return base
  27. if (!base) return trimmed
  28. const needsSpace = /\S$/.test(base) && !/^[,.;!?]/.test(trimmed)
  29. return `${base}${needsSpace ? " " : ""}${trimmed}`
  30. }
  31. const extractSuffix = (committed: string, hypothesis: string) => {
  32. const cleanHypothesis = hypothesis.trim()
  33. if (!cleanHypothesis) return ""
  34. const baseTokens = committed.trim() ? committed.trim().split(/\s+/) : []
  35. const hypothesisTokens = cleanHypothesis.split(/\s+/)
  36. let index = 0
  37. while (
  38. index < baseTokens.length &&
  39. index < hypothesisTokens.length &&
  40. baseTokens[index] === hypothesisTokens[index]
  41. ) {
  42. index += 1
  43. }
  44. if (index < baseTokens.length) return ""
  45. return hypothesisTokens.slice(index).join(" ")
  46. }
  47. export function createSpeechRecognition(opts?: {
  48. lang?: string
  49. onFinal?: (text: string) => void
  50. onInterim?: (text: string) => void
  51. }) {
  52. const hasSupport =
  53. typeof window !== "undefined" &&
  54. Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
  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 (hasSupport) {
  142. const Ctor: new () => Recognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition
  143. recognition = new Ctor()
  144. recognition.continuous = false
  145. recognition.interimResults = true
  146. recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US")
  147. recognition.onresult = (event: RecognitionEvent) => {
  148. if (!event.results.length) return
  149. let aggregatedFinal = ""
  150. let latestHypothesis = ""
  151. for (let i = 0; i < event.results.length; i += 1) {
  152. const result = event.results[i]
  153. const transcript = (result[0]?.transcript || "").trim()
  154. if (!transcript) continue
  155. if (result.isFinal) {
  156. aggregatedFinal = appendSegment(aggregatedFinal, transcript)
  157. } else {
  158. latestHypothesis = transcript
  159. }
  160. }
  161. if (aggregatedFinal) {
  162. cancelPendingCommit()
  163. const finalSuffix = extractSuffix(sessionCommitted, aggregatedFinal)
  164. if (finalSuffix) {
  165. sessionCommitted = appendSegment(sessionCommitted, finalSuffix)
  166. commitSegment(finalSuffix)
  167. }
  168. pendingHypothesis = ""
  169. lastInterimSuffix = ""
  170. shrinkCandidate = undefined
  171. setStore("interim", "")
  172. if (opts?.onInterim) opts.onInterim("")
  173. return
  174. }
  175. cancelPendingCommit()
  176. if (!latestHypothesis) {
  177. shrinkCandidate = undefined
  178. applyInterim("", "")
  179. return
  180. }
  181. const suffix = extractSuffix(sessionCommitted, latestHypothesis)
  182. if (!suffix) {
  183. if (!lastInterimSuffix) {
  184. shrinkCandidate = undefined
  185. applyInterim("", latestHypothesis)
  186. return
  187. }
  188. if (shrinkCandidate === "") {
  189. applyInterim("", latestHypothesis)
  190. return
  191. }
  192. shrinkCandidate = ""
  193. pendingHypothesis = latestHypothesis
  194. return
  195. }
  196. if (lastInterimSuffix && suffix.length < lastInterimSuffix.length) {
  197. if (shrinkCandidate === suffix) {
  198. applyInterim(suffix, latestHypothesis)
  199. return
  200. }
  201. shrinkCandidate = suffix
  202. pendingHypothesis = latestHypothesis
  203. return
  204. }
  205. shrinkCandidate = undefined
  206. applyInterim(suffix, latestHypothesis)
  207. }
  208. recognition.onerror = (e: { error: string }) => {
  209. clearRestart()
  210. cancelPendingCommit()
  211. lastInterimSuffix = ""
  212. shrinkCandidate = undefined
  213. if (e.error === "no-speech" && shouldContinue) {
  214. setStore("interim", "")
  215. if (opts?.onInterim) opts.onInterim("")
  216. scheduleRestart()
  217. return
  218. }
  219. shouldContinue = false
  220. setStore("isRecording", false)
  221. }
  222. recognition.onstart = () => {
  223. clearRestart()
  224. sessionCommitted = ""
  225. pendingHypothesis = ""
  226. cancelPendingCommit()
  227. lastInterimSuffix = ""
  228. shrinkCandidate = undefined
  229. setStore("interim", "")
  230. if (opts?.onInterim) opts.onInterim("")
  231. setStore("isRecording", true)
  232. }
  233. recognition.onend = () => {
  234. clearRestart()
  235. cancelPendingCommit()
  236. lastInterimSuffix = ""
  237. shrinkCandidate = undefined
  238. setStore("isRecording", false)
  239. if (shouldContinue) {
  240. scheduleRestart()
  241. }
  242. }
  243. }
  244. const start = () => {
  245. if (!recognition) return
  246. clearRestart()
  247. shouldContinue = true
  248. sessionCommitted = ""
  249. pendingHypothesis = ""
  250. cancelPendingCommit()
  251. lastInterimSuffix = ""
  252. shrinkCandidate = undefined
  253. setStore("interim", "")
  254. try {
  255. recognition.start()
  256. } catch {}
  257. }
  258. const stop = () => {
  259. if (!recognition) return
  260. shouldContinue = false
  261. clearRestart()
  262. promotePending()
  263. cancelPendingCommit()
  264. lastInterimSuffix = ""
  265. shrinkCandidate = undefined
  266. setStore("interim", "")
  267. if (opts?.onInterim) opts.onInterim("")
  268. try {
  269. recognition.stop()
  270. } catch {}
  271. }
  272. onCleanup(() => {
  273. shouldContinue = false
  274. clearRestart()
  275. promotePending()
  276. cancelPendingCommit()
  277. lastInterimSuffix = ""
  278. shrinkCandidate = undefined
  279. setStore("interim", "")
  280. if (opts?.onInterim) opts.onInterim("")
  281. try {
  282. recognition?.stop()
  283. } catch {}
  284. })
  285. return {
  286. isSupported: () => hasSupport,
  287. isRecording,
  288. committed,
  289. interim,
  290. start,
  291. stop,
  292. }
  293. }