Repository: TalAter/annyang Branch: master Commit: 7d4344188c81 Files: 36 Total size: 163.2 KB Directory structure: gitextract_hn2s3bwm/ ├── .claude/ │ ├── hooks/ │ │ └── check-on-stop.sh │ ├── settings.json │ └── skills/ │ └── triage/ │ └── SKILL.md ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE.md │ └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── demo/ │ ├── css/ │ │ └── main.css │ └── index.html ├── docs/ │ ├── FAQ.md │ ├── README.md │ ├── api-footer.md │ └── api-intro.md ├── eslint.config.js ├── package.json ├── src/ │ └── annyang.ts ├── test/ │ ├── setupTests.js │ └── specs/ │ ├── annyang.test.ts │ ├── issues.test.ts │ └── no-speech-support.test.ts ├── test-manual/ │ ├── cjs-app.js │ ├── cjs.html │ ├── esm-app.js │ ├── esm.html │ ├── iife.html │ └── index.html ├── tsconfig.json ├── tsup.config.ts ├── typedoc.json └── vitest.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/hooks/check-on-stop.sh ================================================ #!/bin/bash INPUT=$(cat) # Prevent infinite loops — if we already triggered a continuation, skip if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then exit 0 fi cd "$(dirname "$0")/../.." || exit 0 ERRORS="" # 1. Prettier (autoformat, don't block on this) pnpm format > /dev/null 2>&1 # 2. ESLint ESLINT_OUTPUT=$(pnpm lint 2>&1) if [ $? -ne 0 ]; then ERRORS="${ERRORS}ESLint failed:\n${ESLINT_OUTPUT}\n\n" fi # 3. TypeScript type checking TYPECHECK_OUTPUT=$(pnpm typecheck 2>&1) if [ $? -ne 0 ]; then ERRORS="${ERRORS}TypeScript failed:\n${TYPECHECK_OUTPUT}\n\n" fi # 4. Tests TEST_OUTPUT=$(pnpm test 2>&1) if [ $? -ne 0 ]; then ERRORS="${ERRORS}Tests failed:\n${TEST_OUTPUT}\n\n" fi if [ -n "$ERRORS" ]; then echo -e "$ERRORS" >&2 exit 2 fi exit 0 ================================================ FILE: .claude/settings.json ================================================ { "hooks": { "PostToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs pnpm prettier --write 2>/dev/null; exit 0" } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "bash .claude/hooks/check-on-stop.sh" } ] } ] } } ================================================ FILE: .claude/skills/triage/SKILL.md ================================================ --- name: triage description: Triage and close GitHub issues on TalAter/annyang user-invocable: true allowed-tools: Bash, Read, Grep, Glob, Agent argument-hint: [issue-number or "list"] --- # GitHub Issue Triage for TalAter/annyang You are helping triage and close GitHub issues on the **TalAter/annyang** repository. ## How to post as the bot All `gh` commands that interact with issues MUST use the bot script so comments are posted as `annyang-triage[bot]`, not as the repo owner: ```bash ./scripts/gh-bot issue comment --repo TalAter/annyang --body "" ./scripts/gh-bot issue close --repo TalAter/annyang ./scripts/gh-bot issue close --repo TalAter/annyang --reason "not planned" ``` Never use bare `gh` for issue comments or closes — always use `./scripts/gh-bot`. ## Workflow 1. **If given an issue number**: fetch it with `gh issue view --repo TalAter/annyang --comments` (this read-only call can use regular `gh`) 2. **If asked to list issues**: use `gh issue list --repo TalAter/annyang` (read-only, regular `gh` is fine). To sort by newest, use `-S "sort:created-desc"`. Note: `gh issue list` has no `--sort` flag — use the `-S` search query instead. 3. **Read the issue and all comments carefully** before deciding on an action 4. **Present your proposed comment and action to the user** before posting. Wait for approval. 5. **Post using `./scripts/gh-bot`** once approved ## Closing reasons - `gh issue close` — default, resolved/completed - `gh issue close --reason "not planned"` — out of scope, won't fix, not a bug Choose the appropriate reason. Most stale or out-of-scope issues should use "not planned". ## Tone These are real people who took time to file issues. Be kind, patient, and helpful. - **Keep comments to 1-3 sentences.** Concise but warm. - **Never dismissive.** Even if the issue is out of scope or a misunderstanding, acknowledge what they were trying to do. - **Explain "why" when closing.** When something is outside annyang's control (browser behavior, platform limitations, third-party APIs), briefly explain why so the user learns something useful. - **Don't pile on.** If the issue was already answered in the comments, just close it — no need to add another comment. - **Be helpful with links.** When pointing to another issue or resource, briefly say why it's relevant. - **End positively when natural.** "Good luck with your project!" or "Hope that helps!" — but only when it fits. Don't force it. ### Do NOT - Use phrases like "this is a support question, not a bug" — it sounds dismissive - Be condescending about the user's level of knowledge - Apologize excessively — one brief acknowledgment is enough - Write walls of text — if it needs more than 3 sentences, something is wrong - Close without a comment unless the issue was already fully answered in existing comments ## Formatting **Always** render issue numbers as clickable links: `[#123](https://github.com/TalAter/annyang/issues/123)`. This applies everywhere — proposals, summaries, references within comments, conversation text. Never write a bare `#123`. ## After completing actions Always finish with a concise summary of what was done, with linked issue numbers. ================================================ FILE: .editorconfig ================================================ # EditorConfig helps developers define and maintain consistent # coding styles between different editors and IDEs # http://editorconfig.org root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true max_line_length = 120 ================================================ FILE: .gitattributes ================================================ demo/* linguist-documentation ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ ## Expected Behavior ## Current Behavior ## Possible Solution ## Steps to Reproduce (for bugs) 1. 2. 3. 4. ## Context ## Your Environment * Version used: * Browser name and version: * Operating system and version (desktop or mobile): * Link to your project: ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Description ## Motivation and Context ## How Has This Been Tested? ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) ## Checklist: - [ ] My code follows the code style of this project. - [ ] My change requires a change to the documentation. - [ ] I have updated the documentation accordingly. - [ ] I have read the **CONTRIBUTING** document. - [ ] I have added tests to cover my changes. - [ ] All new and existing tests passed. ================================================ FILE: .gitignore ================================================ node_modules dist npm-debug.log .idea .vscode .idx .DS_Store test-manual/dist test-manual/annyang.iife.min.js scripts ================================================ FILE: .prettierrc ================================================ { "singleQuote": true, "trailingComma": "es5", "printWidth": 120, "tabWidth": 2, "useTabs": false, "semi": true, "endOfLine": "lf", "insertPragma": false, "requirePragma": false, "arrowParens": "avoid", "overrides": [ { "files": "*.json", "options": { "parser": "json" } } ] } ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## 3.0.0 ### New Features / Breaking Changes - **TypeScript types included** — Full type definitions ship with the package. `addCallback` enforces correct callback signatures per event type. - **ESM/CJS/IIFE module support** — Works with `import`, `require()`, and ` ```` **Check out some [live speech recognition demos and advanced samples](https://www.talater.com/annyang), then read the full [API Docs](https://github.com/TalAter/annyang/blob/master/docs/README.md).** ## Adding a GUI You can easily add a GUI for the user to interact with Speech Recognition using [Speech KITT](https://github.com/TalAter/SpeechKITT). Speech KITT makes it easy to add a graphical interface for the user to start or stop Speech Recognition and see its current status. KITT also provides clear visual hints to the user on how to interact with your site using their voice, providing instructions and sample commands. Speech KITT is fully customizable and comes with many different themes, and instructions on how to create your own designs. [![Speech Recognition GUI with Speech KITT](https://raw.githubusercontent.com/TalAter/SpeechKITT/master/demo/speechkitt-demo.gif)](https://github.com/TalAter/SpeechKITT) For help with setting up a GUI with KITT, check out the [Speech KITT page](https://github.com/TalAter/SpeechKITT). ## Author Tal Ater: [@TalAter](https://twitter.com/TalAter) ## License Licensed under [MIT](https://github.com/TalAter/annyang/blob/master/LICENSE). ================================================ FILE: demo/css/main.css ================================================ /* ===== Design Tokens ===== */ :root { --bg: #0c0c0c; --bg-alt: #151514; --bg-code: #1a1917; --text: #f5f2ed; --text-body: #f5f2edbf; --text-muted: #f5f2ed66; --text-faint: #f5f2ed40; --accent: #ff6b35; --border: #f5f2ed0f; --font-heading: 'DM Sans', sans-serif; --font-body: 'Inter', sans-serif; --font-mono: 'JetBrains Mono', monospace; } /* ===== Reset ===== */ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } html { scroll-behavior: smooth; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } body { background: var(--bg); color: var(--text-body); font-family: var(--font-body); font-size: 18px; font-weight: 400; line-height: 1.6; } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } /* ===== Section Layout ===== */ section { padding: 120px 140px; } section.alt { background: var(--bg-alt); } /* ===== Section Labels ===== */ .section-label { font-family: var(--font-mono); font-size: 13px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent); margin-bottom: 24px; } /* ===== Typography ===== */ h1 { font-family: var(--font-heading); font-weight: 900; font-size: 120px; line-height: 1; letter-spacing: -0.03em; color: var(--text); margin-bottom: 32px; } h2 { font-family: var(--font-heading); font-weight: 900; font-size: 56px; line-height: 1.1; letter-spacing: -0.02em; color: var(--text); margin-bottom: 24px; } h2.footer-heading { font-size: 72px; line-height: 1.05; } .hero-description { font-size: 20px; color: var(--text-body); max-width: 540px; margin-bottom: 48px; line-height: 1.7; } .section-description { font-size: 20px; color: var(--text-body); max-width: 640px; margin-bottom: 32px; line-height: 1.7; } .em-lead { font-size: 28px; font-weight: 600; color: var(--text); margin-bottom: 16px; font-style: italic; } /* ===== Hero ===== */ .hero { position: relative; overflow: hidden; padding-top: 160px; padding-bottom: 160px; } .hero-glow { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 800px; height: 800px; background: radial-gradient(circle, rgba(255, 107, 53, 0.08) 0%, transparent 70%); pointer-events: none; } .hero-waveform { position: absolute; top: 120px; left: 100px; width: 600px; height: 320px; opacity: 0.12; pointer-events: none; } .hero-stats { font-family: var(--font-mono); font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 32px; } .hero-stats-dot { margin: 0 12px; color: var(--text-faint); } /* ===== Buttons ===== */ .btn { display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-body); font-size: 16px; font-weight: 600; border-radius: 100px; padding: 14px 32px; cursor: pointer; transition: all 0.2s ease; border: none; text-decoration: none; } .btn:hover { text-decoration: none; } .btn-primary { background: var(--accent); color: #fff; } .btn-primary:hover { background: #e55a28; } .btn-secondary { background: transparent; color: var(--text); border: 1px solid var(--text-faint); } .btn-secondary:hover { border-color: var(--text-muted); } /* ===== Voice Instruction Sections ===== */ .mic-icon { width: 20px; height: 20px; margin-right: 4px; vertical-align: middle; opacity: 0.5; } .voice-instruction { font-size: 20px; color: var(--text-body); margin-bottom: 12px; } .voice-instruction code { font-family: var(--font-mono); font-size: 18px; color: var(--accent); } .response-text { font-family: var(--font-heading); font-weight: 900; font-size: 80px; line-height: 1.1; color: var(--text); margin-top: 40px; display: none; } .response-text.visible { display: block; } /* ===== Code Blocks ===== */ .code-block { background: var(--bg-code); border-radius: 16px; padding: 36px 40px; overflow-x: auto; margin-top: 32px; max-width: 780px; } .code-block pre { margin: 0; font-family: var(--font-mono); font-size: 15px; line-height: 1.7; color: var(--text-body); white-space: pre; } /* Hand-painted syntax highlighting */ .kw { color: var(--accent); } .str { color: #d4a574; } .cm { color: var(--text-faint); } .fn { color: #c9b8a8; } .op { color: var(--text-muted); } /* ===== Gallery ===== */ .gallery { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 32px; } .gallery-item { width: 110px; height: 110px; border-radius: 8px; border: 1px solid rgba(255, 107, 53, 0.15); background: var(--bg-code); overflow: hidden; } .gallery-item img { width: 100%; height: 100%; object-fit: cover; display: block; } /* ===== TPS Report ===== */ #tpsreport { position: fixed; right: 40px; bottom: -600px; width: 320px; z-index: 100; border-radius: 4px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5); transform: rotate(-12deg); transition: bottom 0.6s ease; } #tpsreport.visible { bottom: -80px; } /* ===== Stats Bar ===== */ .stats-bar { display: flex; align-items: center; justify-content: center; gap: 0; padding: 80px 140px; background: var(--bg); } .stat { text-align: center; padding: 0 60px; } .stat-value { font-family: var(--font-heading); font-weight: 900; font-size: 48px; color: var(--accent); line-height: 1.2; } .stat-label { font-family: var(--font-mono); font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-top: 8px; } .stat-divider { width: 1px; height: 60px; background: var(--border); flex-shrink: 0; } /* ===== Footer CTA ===== */ .footer-cta { position: relative; overflow: hidden; text-align: center; padding: 140px 140px; } .footer-cta .hero-description { margin-left: auto; margin-right: auto; } .footer-cta .btn-group { display: flex; justify-content: center; gap: 16px; flex-wrap: wrap; } .footer-wave { position: absolute; bottom: 0; left: 0; width: 100%; opacity: 0.08; pointer-events: none; } .footer-arcs { position: absolute; top: 60px; right: 60px; opacity: 0.06; pointer-events: none; } /* ===== Copyright ===== */ .copyright { border-top: 1px solid var(--border); padding: 32px 140px; text-align: center; font-size: 14px; color: var(--text-faint); } .copyright a { color: var(--text-muted); } /* ===== Unsupported Banner ===== */ #unsupported { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: #1a1917; border-top: 1px solid var(--border); padding: 24px 40px; z-index: 200; text-align: center; } #unsupported.visible { display: block; } #unsupported h4 { font-family: var(--font-heading); font-weight: 900; font-size: 18px; color: var(--text); margin-bottom: 8px; } #unsupported p { font-size: 14px; color: var(--text-muted); } /* ===== Flickr Loader ===== */ #flickrLoader { margin-top: 24px; height: 24px; font-family: var(--font-mono); font-size: 14px; color: var(--text-muted); } #flickrLoader p { opacity: 0; transition: opacity 0.2s ease; } #flickrLoader.visible p { opacity: 1; } /* ===== Responsive ===== */ @media (max-width: 1200px) { section { padding: 100px 80px; } .stats-bar { padding: 60px 80px; } .footer-cta { padding: 120px 80px; } .copyright { padding: 32px 80px; } h1 { font-size: 88px; } .hero-waveform { width: 440px; height: 240px; left: 50px; } h2 { font-size: 44px; } h2.footer-heading { font-size: 56px; } .response-text { font-size: 64px; } .stat { padding: 0 40px; } } @media (max-width: 768px) { section { padding: 80px 32px; } .stats-bar { padding: 48px 32px; flex-wrap: wrap; gap: 32px; } .stat-divider { display: none; } .stat { padding: 0 24px; } .footer-cta { padding: 80px 32px; } .copyright { padding: 24px 32px; } h1 { font-size: 56px; } .hero-waveform { width: 280px; height: 150px; left: 10px; top: 100px; } h2 { font-size: 36px; } h2.footer-heading { font-size: 44px; } .hero { padding-top: 100px; padding-bottom: 100px; } .hero-description { font-size: 18px; } .response-text { font-size: 48px; } .stat-value { font-size: 36px; } .code-block { padding: 24px; border-radius: 12px; } .code-block pre { font-size: 13px; } .footer-cta .btn-group { flex-direction: column; align-items: center; } } @media (max-width: 480px) { section { padding: 60px 20px; } .stats-bar { padding: 40px 20px; } .footer-cta { padding: 60px 20px; } .copyright { padding: 20px; } h1 { font-size: 40px; margin-bottom: 20px; } .hero-waveform { width: 200px; height: 110px; left: 5px; top: 80px; } h2 { font-size: 28px; } h2.footer-heading { font-size: 32px; } .hero { padding-top: 80px; padding-bottom: 80px; } .response-text { font-size: 36px; } .stat-value { font-size: 28px; } .code-block { padding: 20px 16px; } .code-block pre { font-size: 12px; } .em-lead { font-size: 22px; } .gallery-item { width: 90px; height: 90px; } #tpsreport { width: 160px; right: 20px; } .btn { padding: 12px 24px; font-size: 15px; } } ================================================ FILE: demo/index.html ================================================ annyang! Easily add speech recognition to your site

annyang!

A tiny JavaScript library that adds voice commands to any project — websites, home automation, accessibility tools, VR, drones, and more.

2kb · Zero dependencies · MIT license

Go ahead, try it…

Say "Hello!"

annyang!

That's cool, but in the real world it's not all kittens and hello world.

No problem, say "Show TPS report"

TPS Report cover

Simple. Here's all the code.

import annyang from 'annyang';

const commands = {
  'hello': () => alert('Hello!'),
  'show tps report': () => document.getElementById('tpsreport').show()
};

annyang.addCommands(commands);
annyang.start();

annyang understands commands with named variables, splats, and optional words.

const commands = {
  // Splats (*) capture multi-word text at the end of a command.
  // "Show me Batman and Robin" calls showFlickr('Batman and Robin')
  'show me *query': showFlickr,

  // Named variables (:name) capture a single word anywhere.
  // "calculate October stats" calls calculateStats('October')
  'calculate :month stats': calculateStats,

  // Optional words are wrapped in parentheses.
  // Matches both "say hello friend" and "say hello to my little friend"
  'say hello (to my little) friend': greeting
};

annyang.addCommands(commands);
annyang.start();

It looks like your browser doesn't support speech recognition.

annyang works with all browsers, progressively enhancing those that support the SpeechRecognition standard.

annyang commands can even be triggered manually in unsupported browsers (e.g., “Show me snowboarding”)

Please visit talater.com/annyang in a supported browser like Chrome.

================================================ FILE: docs/FAQ.md ================================================ # Frequently Asked Questions - [What languages are supported?](#what-languages-are-supported) - [Why does the browser repeatedly ask for permission to use the microphone?](#why-does-the-browser-repeatedly-ask-for-permission-to-use-the-microphone) - [What can I do to make speech recognition results return faster?](#what-can-i-do-to-make-speech-recognition-results-return-faster) - [How can I contribute to annyang's development?](#how-can-i-contribute-to-annyangs-development) - [Why does Speech Recognition repeatedly starts and stops?](#why-does-speech-recognition-repeatedly-starts-and-stops) - [Can annyang work offline?](#can-annyang-work-offline) - [Which browsers are supported?](#which-browsers-are-supported) - [How does annyang work with and without speech recognition?](#how-does-annyang-work-with-and-without-speech-recognition) - [Can annyang be used to capture the full text spoken by the user?](#can-annyang-be-used-to-capture-the-full-text-spoken-by-the-user) - [Can I detect when the user starts and stops speaking?](#can-i-detect-when-the-user-starts-and-stops-speaking) - [Can annyang be used in Chromium or Electron?](#can-annyang-be-used-in-chromium-or-electron) - [Can annyang be used in Cordova?](#can-annyang-be-used-in-cordova) ## What languages are supported? Language support is up to each browser. While there isn't an official list of supported languages in Chrome, here is a list based on [anecdotal evidence](http://stackoverflow.com/a/14302134/338039). - Afrikaans `af` - Basque `eu` - Bulgarian `bg` - Catalan `ca` - Arabic (Egypt) `ar-EG` - Arabic (Jordan) `ar-JO` - Arabic (Kuwait) `ar-KW` - Arabic (Lebanon) `ar-LB` - Arabic (Qatar) `ar-QA` - Arabic (UAE) `ar-AE` - Arabic (Morocco) `ar-MA` - Arabic (Iraq) `ar-IQ` - Arabic (Algeria) `ar-DZ` - Arabic (Bahrain) `ar-BH` - Arabic (Lybia) `ar-LY` - Arabic (Oman) `ar-OM` - Arabic (Saudi Arabia) `ar-SA` - Arabic (Tunisia) `ar-TN` - Arabic (Yemen) `ar-YE` - Czech `cs` - Dutch `nl-NL` - English (Australia) `en-AU` - English (Canada) `en-CA` - English (India) `en-IN` - English (New Zealand) `en-NZ` - English (South Africa) `en-ZA` - English(UK) `en-GB` - English(US) `en-US` - Finnish `fi` - French `fr-FR` - Galician `gl` - German `de-DE` - Greek `el-GR` - Hebrew `he` - Hungarian `hu` - Icelandic `is` - Italian `it-IT` - Indonesian `id` - Japanese `ja` - Korean `ko` - Latin `la` - Mandarin Chinese `zh-CN` - Traditional Taiwan `zh-TW` - Simplified China zh-CN `?` - Simplified Hong Kong `zh-HK` - Yue Chinese (Traditional Hong Kong) `zh-yue` - Malaysian `ms-MY` - Norwegian `no-NO` - Polish `pl` - Pig Latin `xx-piglatin` - Portuguese `pt-PT` - Portuguese (Brasil) `pt-br` - Romanian `ro-RO` - Russian `ru` - Serbian `sr-SP` - Slovak `sk` - Spanish (Argentina) `es-AR` - Spanish (Bolivia) `es-BO` - Spanish (Chile) `es-CL` - Spanish (Colombia) `es-CO` - Spanish (Costa Rica) `es-CR` - Spanish (Dominican Republic) `es-DO` - Spanish (Ecuador) `es-EC` - Spanish (El Salvador) `es-SV` - Spanish (Guatemala) `es-GT` - Spanish (Honduras) `es-HN` - Spanish (Mexico) `es-MX` - Spanish (Nicaragua) `es-NI` - Spanish (Panama) `es-PA` - Spanish (Paraguay) `es-PY` - Spanish (Peru) `es-PE` - Spanish (Puerto Rico) `es-PR` - Spanish (Spain) `es-ES` - Spanish (US) `es-US` - Spanish (Uruguay) `es-UY` - Spanish (Venezuela) `es-VE` - Swedish `sv-SE` - Turkish `tr` - Zulu `zu` ## Why does the browser repeatedly ask for permission to use the microphone? Chrome's speech recognition behaves differently based on the protocol used: - `https://` Asks for permission once and remembers the choice. - `http://` Asks for permission repeatedly **on every page load**. Results are also returned significantly slower in HTTP. For a great user experience, don't compromise on anything less than HTTPS. ## What can I do to make speech recognition results return faster? First, remember that because the actual speech-to-text processing is done in the cloud, a faster connection can mean faster results. Second, when the speech recognition is in continuous mode, results are returned slower (the browser waits after you finish talking to see if there's anything else you'd like to add). Turning continuous mode off tends to make the browser return recognized results much faster. To start annyang in non-continuous mode, you can pass `continuous: false` in the options object that `annyang.start()` accepts. You will most likely want to also turn on `autoRestart` if you do that. You can read more about both options in the [annyang API Docs](https://github.com/TalAter/annyang/blob/master/docs/README.md#startoptions) For example: ```javascript annyang.start({ autoRestart: true, continuous: false }); ``` Note that these settings are already the default if you are using HTTPS. If you are using HTTP, continuous mode will be turned on by default (resulting in slower recognition) to prevent [repeated security notices](#why-does-the-browser-repeatedly-ask-for-permission-to-use-the-microphone). ## How can I contribute to annyang's development? There are three main ways for you to help. Check out the [CONTRIBUTING](https://github.com/TalAter/annyang/blob/master/CONTRIBUTING.md) guide for more details. ## Why does Speech Recognition repeatedly starts and stops? The most common reason for this is because you have opened more than one tab or window that uses Speech Recognition in your browser at the same time (e.g. if you open annyang's homepage in one tab, and the Speech Recognition app you are developing in another). When a browser detects that one tab has started Speech Recognition, it aborts all other Speech Recognition processes in other tabs. annyang detects when it is aborted by an external process and restarts itself. If you have two windows aborting each other, and restarting themselves you may experience Speech Recognition starting and stopping over and over again. Another possible reason for this might be that you are offline. ## Can annyang work offline? No. annyang relies on the browser's own speech recognition engine. In Chrome, this engine performs recognition in the cloud. ## Which browsers are supported? annyang works with all browsers that implement the Speech Recognition interface of the Web Speech API (such as Google Chrome, and Samsung Internet). To check if the user's browser supports speech recognition, use `isSpeechRecognitionSupported()`: ```javascript if (!annyang.isSpeechRecognitionSupported()) { console.log('Speech Recognition is not supported'); } ``` You can find out the current state of browser support on [caniuse.com](https://caniuse.com/speech-recognition). Even in unsupported browsers, annyang is safe to use. You can register commands and trigger them programmatically with `trigger()`, which works independently of the speech recognition engine: ```javascript annyang.addCommands({ 'show help': () => showHelpOverlay(), 'go to :page': page => navigateTo(page), }); if (annyang.isSpeechRecognitionSupported()) { annyang.start(); // Voice input triggers commands } else { // Provide an alternative input method goButton.addEventListener('click', () => { annyang.trigger('go to ' + pageInput.value); }); } ``` ## How does annyang work with and without speech recognition? `isListening()`, `getState()`, and the `start`/`end`/`soundstart`/`error*` callbacks reflect the state of the browser's speech recognition engine — whether the microphone is active, paused, or off. You can use these to drive UI that shows the user whether the browser is listening. In unsupported browsers, these are safe to call but will always report that the engine is inactive (`isListening()` returns `false`, `getState()` returns `'idle'`). `trigger()` allows manual invocation of commands regardless of speech recognition support. The same commands registered with `addCommands()` can be matched either through speech recognition in a supported browser or programmatically via `trigger()`. This means you can use `trigger()` to provide a fallback input in unsupported browsers, or to invoke commands from your own code alongside speech recognition in supported ones. `trigger()` does not depend on the listening state — it works whether annyang is listening, paused, aborted, or was never started. ## Can annyang be used to capture the full text spoken by the user? Yes. You can listen to the `result` event which is triggered whenever speech is recognized. This event will fire with a list of possible phrases the user may have said, regardless of whether any of them matched an annyang command or not. You can even do this without registering any commands: ```javascript annyang.addCallback('result', function (phrases) { console.log('I think the user said: ', phrases[0]); console.log('But then again, it could be any of the following: ', phrases); }); ``` Alternatively, you may choose to only capture what the user said when it matches an annyang command (`resultMatch`), or when it does not match a command (`resultNoMatch`). ```javascript annyang.addCallback('resultMatch', function (userSaid, commandText, phrases) { console.log(userSaid); // sample output: 'hello' console.log(commandText); // sample output: 'hello (there)' console.log(phrases); // sample output: ['hello', 'halo', 'yellow', 'polo', 'hello kitty'] }); annyang.addCallback('resultNoMatch', function (phrases) { console.log('I think the user said: ', phrases[0]); console.log('But then again, it could be any of the following: ', phrases); }); ``` ## Can I detect when the user starts and stops speaking? Yes. Sometimes. You can detect when a sound is first detected by the microphone with the `soundstart` event. Unfortunately, due to a [bug in Chrome](https://bugs.chromium.org/p/chromium/issues/detail?id=572697&thanks=572697&ts=1451323087), this event will only fire once in every speech recognition session. If you are in non-continuous mode and annyang is restarting after every sentence recognized (the default in HTTPS), this will not be a problem. Because speech recognition will abort and restart, soundstart will fire again correctly. The following code will detect when a user starts and stops speaking. ```javascript annyang.addCallback('soundstart', function () { console.log('sound detected'); }); annyang.addCallback('result', function () { console.log('sound stopped'); }); ``` ## Can annyang be used in Chromium or Electron? Yes, however, you must create your own Chromium keys and are limited to 50 requests/day. To do this you'll need to provide your own keys at runtime by following the instructions for [Acquiring Keys](https://www.chromium.org/developers/how-tos/api-keys) in the Chromium developer docs. ## Can annyang be used in Cordova? Crosswalk (the Chromium-based WebView for Cordova) has been discontinued. If your Cordova WebView supports the Web Speech API, annyang will work. Otherwise, consider using `trigger()` to invoke commands programmatically from a native speech recognition plugin. ================================================ FILE: docs/README.md ================================================ # Quick Tutorial, Intro, and Demos The quickest way to get started is to visit the [annyang homepage](https://www.talater.com/annyang/). For a more in-depth look at annyang, read on. # API Reference **annyang** *** # annyang ## Functions ### abort() > **abort**(): `void` Defined in: [annyang.ts:369](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L369) Stop listening and turn off the mic. Alternatively, to only temporarily pause annyang responding to commands without stopping the SpeechRecognition engine or closing the mic, use pause() instead. #### Returns `void` #### See [pause()](#pause) *** ### addCallback() > **addCallback**\<`T`\>(`type`, `callback`, `context?`): () => `void` Defined in: [annyang.ts:457](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L457) Add a callback function to be called in case one of the following events happens: * `start` - Fired as soon as the browser's Speech Recognition engine starts listening. * `soundstart` - Fired as soon as any sound (possibly speech) has been detected. This will fire once per Speech Recognition starting. See https://is.gd/annyang_sound_start. * `error` - Fired when the browser's Speech Recognition engine returns an error, this generic error callback will be followed by more accurate error callbacks (both will fire if both are defined). The Callback function will be called with the error event as the first argument. * `errorNetwork` - Fired when Speech Recognition fails because of a network error. The Callback function will be called with the error event as the first argument. * `errorPermissionBlocked` - Fired when the browser blocks the permission request to use Speech Recognition. The Callback function will be called with the error event as the first argument. * `errorPermissionDenied` - Fired when the user blocks the permission request to use Speech Recognition. The Callback function will be called with the error event as the first argument. * `end` - Fired when the browser's Speech Recognition engine stops. * `result` - Fired as soon as some speech was identified. This generic callback will be followed by either the `resultMatch` or `resultNoMatch` callbacks. The Callback functions for this event will be called with an array of possible phrases the user said as the first argument. * `resultMatch` - Fired when annyang was able to match between what the user said and a registered command. The Callback functions for this event will be called with three arguments in the following order: * The phrase the user said that matched a command. * The command that was matched. * An array of possible alternative phrases the user might have said. * `resultNoMatch` - Fired when what the user said didn't match any of the registered commands. Callback functions for this event will be called with an array of possible phrases the user might have said as the first argument. #### Examples: ````javascript annyang.addCallback('resultMatch', (userSaid, commandText, phrases) => { console.log(userSaid); // sample output: 'hello' console.log(commandText); // sample output: 'hello (there)' console.log(phrases); // sample output: ['hello', 'halo', 'yellow', 'polo', 'hello kitty'] }); // Returns an unsubscribe function const unsubscribe = annyang.addCallback('error', () => { console.log('There was an error!'); }); unsubscribe(); // removes the callback ```` #### Type Parameters ##### T `T` *extends* keyof `CallbackMap` #### Parameters ##### type `T` Name of event that will trigger this callback ##### callback `CallbackMap`\[`T`\] The function to call when event is triggered ##### context? `object` = `undefined` Optional context for the callback function #### Returns A function that removes this callback when called > (): `void` ##### Returns `void` *** ### addCommands() > **addCommands**(`commands`, `resetCommands?`): `void` Defined in: [annyang.ts:265](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L265) Add commands that annyang will respond to. By default this will add to the existing commands. Pass `true` as the second parameter to remove all existing commands first. #### Examples: ````javascript const commands1 = {'hello :name': helloFunction, 'howdy': helloFunction}; const commands2 = {'hi': helloFunction}; annyang.addCommands(commands1); annyang.addCommands(commands2); // annyang will now listen for all three commands defined in commands1 and commands2 annyang.addCommands(commands2, true); // annyang will now only listen for the command in commands2 ```` #### Parameters ##### commands `CommandsList` Commands that annyang should listen for ##### resetCommands? `boolean` = `false` Remove all existing commands before adding new commands? * #### Returns `void` #### See [Commands Object](#commands-object) *** ### debug() > **debug**(`newState?`): `void` Defined in: [annyang.ts:569](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L569) Turn on the output of debug messages to the console. #### Parameters ##### newState? `boolean` = `true` Turn debug messages on or off #### Returns `void` *** ### getSpeechRecognizer() > **getSpeechRecognizer**(): `SpeechRecognition` \| `undefined` Defined in: [annyang.ts:601](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L601) Returns the instance of the browser's SpeechRecognition object used by annyang. Useful in case you want direct access to the browser's Speech Recognition engine. #### Returns `SpeechRecognition` \| `undefined` SpeechRecognition The browser's Speech Recognizer instance currently used by annyang *** ### getState() > **getState**(): `AnnyangState` Defined in: [annyang.ts:544](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L544) Returns the current state of annyang. #### Returns `AnnyangState` The current state *** ### ~~init()~~ > **init**(): `void` Defined in: [annyang.ts:608](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L608) #### Returns `void` #### Deprecated annyang no longer requires manual initialization. It initializes automatically on `start()` or `addCommands()`. Remove any calls to `init()`. *** ### isListening() > **isListening**(): `boolean` Defined in: [annyang.ts:533](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L533) Returns true if speech recognition is currently on. Returns false if speech recognition is off or annyang is paused. #### Returns `boolean` true if SpeechRecognition is on and annyang is not paused *** ### isSpeechRecognitionSupported() > **isSpeechRecognitionSupported**(): `boolean` Defined in: [annyang.ts:232](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L232) Is SpeechRecognition supported in this environment? #### Returns `boolean` true if SpeechRecognition is supported by the browser *** ### pause() > **pause**(): `void` Defined in: [annyang.ts:383](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L383) Pause listening. annyang will stop responding to commands (until the resume or start methods are called), without turning off the browser's SpeechRecognition engine or the mic. Alternatively, to stop the SpeechRecognition engine and close the mic, use abort() instead. #### Returns `void` #### See [abort()](#abort) *** ### removeCallback() > **removeCallback**(`type?`, `callback?`): `void` Defined in: [annyang.ts:512](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L512) Remove callbacks from events. - Pass an event name and a callback command to remove that callback command from that event type. - Pass just an event name to remove all callback commands from that event type. - Pass undefined as event name and a callback command to remove that callback command from all event types. - Pass no params to remove all callback commands from all event types. #### Examples: ````javascript annyang.addCallback('start', myFunction1); annyang.addCallback('start', myFunction2); annyang.addCallback('end', myFunction1); annyang.addCallback('end', myFunction2); // Remove all callbacks from all events: annyang.removeCallback(); // Remove all callbacks attached to end event: annyang.removeCallback('end'); // Remove myFunction2 from being called on start: annyang.removeCallback('start', myFunction2); // Remove myFunction1 from being called on all events: annyang.removeCallback(undefined, myFunction1); ```` #### Parameters ##### type? keyof CallbackMap Name of event type to remove callback from ##### callback? The callback function to remove () => `void` | () => `void` | () => `void` | (`phrases`) => `void` | (`userSaid`, `commandText`, `phrases`) => `void` | (`phrases`) => `void` | (`event`) => `void` | (`event`) => `void` | (`event`) => `void` | (`event`) => `void` #### Returns `void` undefined *** ### removeCommands() > **removeCommands**(`commandsToRemove?`): `void` Defined in: [annyang.ts:306](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L306) Remove existing commands. Called with a single phrase, an array of phrases, or with no params to remove all commands. #### Examples: ````javascript const commands = {'hello': helloFunction, 'howdy': helloFunction, 'hi': helloFunction}; // Remove all existing commands annyang.removeCommands(); // Add some commands annyang.addCommands(commands); // Don't respond to hello annyang.removeCommands('hello'); // Don't respond to howdy or hi annyang.removeCommands(['howdy', 'hi']); ```` #### Parameters ##### commandsToRemove? Commands to remove `string` | `string`[] #### Returns `void` *** ### resume() > **resume**(): `void` Defined in: [annyang.ts:391](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L391) Resumes listening and restore command callback execution when a command is matched. If SpeechRecognition was aborted (stopped), start it. #### Returns `void` *** ### setLanguage() > **setLanguage**(`language`): `void` Defined in: [annyang.ts:556](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L556) Set the language the user will speak in. If this method is not called, annyang defaults to 'en-US'. #### Parameters ##### language `string` The language (locale) #### Returns `void` #### See [Languages](https://github.com/TalAter/annyang/blob/master/docs/FAQ.md#what-languages-are-supported) *** ### start() > **start**(`options?`): `void` Defined in: [annyang.ts:340](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L340) Start listening. It's a good idea to call this after adding some commands first (but not mandatory) Receives an optional options object which supports the following options: - `autoRestart` (boolean) Should annyang restart itself if it is closed indirectly (e.g. because of silence or window conflicts)? - `continuous` (boolean) Allow forcing continuous mode on or off. annyang is pretty smart about this, so only set this if you know what you're doing. - `paused` (boolean) Start annyang in paused mode. #### Examples: ````javascript // Start listening, don't restart automatically annyang.start({ autoRestart: false }); // Start listening, don't restart automatically, stop recognition after first phrase recognized annyang.start({ autoRestart: false, continuous: false }); ```` #### Parameters ##### options? `StartOptions` = `{}` Optional options. #### Returns `void` *** ### trigger() > **trigger**(`sentences?`): `void` Defined in: [annyang.ts:591](https://github.com/TalAter/annyang/blob/17be9d5c272f8beb449c5bb269e947c996e5adff/src/annyang.ts#L591) Match text against registered commands and fire the corresponding callbacks. Works independently of the speech recognition engine — does not require `start()`, and works even in environments where SpeechRecognition is not supported. Can accept either a string containing a single sentence or an array containing multiple sentences to be checked in order until one of them matches a command (similar to the way Speech Recognition Alternatives are parsed) #### Examples: ````javascript annyang.trigger('Time for some thrilling heroics'); annyang.trigger( ['Time for some thrilling heroics', 'Time for some thrilling aerobics'] ); ```` #### Parameters ##### sentences? A sentence as a string or an array of strings of possible sentences `string` | `string`[] #### Returns `void` # Good to Know ## Commands Object annyang understands commands with `named variables`, `splats`, and `optional words`. - Use `named variables` for one-word arguments in your command. - Use `splats` to capture multi-word text at the end of your command (greedy). - Use `optional words` or phrases to define a part of the command as optional. #### Examples: ````html ```` ### Using Regular Expressions in commands For advanced commands, you can pass a regular expression object, instead of a simple string command. This is done by passing an object containing two properties: `regexp`, and `callback` instead of the function. #### Examples: ````javascript const calculateFunction = month => { console.log(month); } const commands = { // This example will accept any word as the "month" 'calculate :month stats': calculateFunction, // This example will only accept months which are at the start of a quarter 'calculate :quarter stats': {'regexp': /^calculate (January|April|July|October) stats$/, 'callback': calculateFunction} } ```` ================================================ FILE: docs/api-footer.md ================================================ # Good to Know ## Commands Object annyang understands commands with `named variables`, `splats`, and `optional words`. - Use `named variables` for one-word arguments in your command. - Use `splats` to capture multi-word text at the end of your command (greedy). - Use `optional words` or phrases to define a part of the command as optional. #### Examples: ````html ```` ### Using Regular Expressions in commands For advanced commands, you can pass a regular expression object, instead of a simple string command. This is done by passing an object containing two properties: `regexp`, and `callback` instead of the function. #### Examples: ````javascript const calculateFunction = month => { console.log(month); } const commands = { // This example will accept any word as the "month" 'calculate :month stats': calculateFunction, // This example will only accept months which are at the start of a quarter 'calculate :quarter stats': {'regexp': /^calculate (January|April|July|October) stats$/, 'callback': calculateFunction} } ```` ================================================ FILE: docs/api-intro.md ================================================ # Quick Tutorial, Intro, and Demos The quickest way to get started is to visit the [annyang homepage](https://www.talater.com/annyang/). For a more in-depth look at annyang, read on. # API Reference ================================================ FILE: eslint.config.js ================================================ import tseslint from 'typescript-eslint'; import eslintConfigPrettier from 'eslint-config-prettier'; export default tseslint.config( { ignores: ['dist/', 'node_modules/'], }, ...tseslint.configs.recommended, eslintConfigPrettier, { languageOptions: { parserOptions: { ecmaVersion: 2020, sourceType: 'module', projectService: true, tsconfigRootDir: import.meta.dirname, }, }, rules: { 'no-console': 'off', 'max-len': ['error', { code: 120, ignoreComments: true }], }, }, { files: ['test/**/*.test.ts', 'test/**/*.js'], languageOptions: { parserOptions: { projectService: false, }, }, rules: { 'max-len': 'off', }, }, { files: ['src/annyang.ts'], rules: { 'no-use-before-define': 'off', }, }, ); ================================================ FILE: package.json ================================================ { "name": "annyang", "version": "3.0.0", "description": "A JavaScript library for adding voice commands to your site, using speech recognition", "keywords": ["speech", "recognition", "voice", "commands", "speechrecognition"], "homepage": "https://www.talater.com/annyang/", "bugs": { "url": "https://github.com/TalAter/annyang/issues" }, "repository": { "type": "git", "url": "git+https://github.com/TalAter/annyang.git" }, "license": "MIT", "author": "Tal Ater (https://www.talater.com/)", "type": "module", "exports": { ".": { "import": { "types": "./dist/annyang.d.ts", "default": "./dist/annyang.js" }, "require": { "types": "./dist/annyang.d.cts", "default": "./dist/annyang.cjs" } } }, "main": "./dist/annyang.cjs", "module": "./dist/annyang.js", "types": "./dist/annyang.d.ts", "files": ["dist"], "sideEffects": false, "engines": { "node": ">=18" }, "scripts": { "build": "tsup", "dev": "tsup --watch", "test": "vitest run", "test:watch": "vitest", "lint": "eslint src test", "format": "prettier --write src test package.json", "format:check": "prettier --check src test package.json", "typecheck": "tsc --noEmit", "docs": "typedoc && cat docs/api-intro.md docs/README.md docs/api-footer.md > docs/README.tmp && mv docs/README.tmp docs/README.md", "test:manual": "pnpm build && cp dist/annyang.iife.min.js test-manual/ && npx esbuild test-manual/esm-app.js --bundle --alias:annyang=./dist/annyang.js --outfile=test-manual/dist/esm-app.js && npx esbuild test-manual/cjs-app.js --bundle --alias:annyang=./dist/annyang.cjs --outfile=test-manual/dist/cjs-app.js && npx http-server test-manual -p 8081 -c-1 -o", "demo": "concurrently -n build,serve -c blue,green \"tsup --watch\" \"npx http-server . -p 8080 -c-1\"", "prepublishOnly": "pnpm test && pnpm lint && pnpm typecheck && pnpm build && pnpm docs" }, "devDependencies": { "@types/dom-speech-recognition": "^0.0.7", "concurrently": "^9.2.1", "corti": "^2.1.0", "eslint": "^10.0.2", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.1", "tsup": "^8.5.1", "typedoc": "^0.28.17", "typedoc-plugin-markdown": "^4.10.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", "vitest": "^4.0.18" }, "packageManager": "pnpm@10.30.3", "pnpm": { "overrides": { "flatted": ">=3.4.0" } } } ================================================ FILE: src/annyang.ts ================================================ const MIN_RESTART_INTERVAL_MS = 1000; const RESTART_WARNING_INTERVAL = 10; let recognition: SpeechRecognition; let listening: boolean = false; let autoRestart: boolean = true; let debugState: boolean = false; const debugStyle: string = 'font-weight: bold; color: #00f;'; export interface CallbackMap { start: () => void; end: () => void; soundstart: () => void; result: (phrases: string[]) => void; resultMatch: (userSaid: string, commandText: string, phrases: string[]) => void; resultNoMatch: (phrases: string[]) => void; error: (event: SpeechRecognitionErrorEvent) => void; errorNetwork: (event: SpeechRecognitionErrorEvent) => void; errorPermissionBlocked: (event: SpeechRecognitionErrorEvent) => void; errorPermissionDenied: (event: SpeechRecognitionErrorEvent) => void; } export type CallbackType = keyof CallbackMap; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyFunction = (...args: any[]) => void; interface StoredCallback { callback: AnyFunction; context: object | undefined; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const commandsList: Map void }> = new Map(); const callbacks: Map = new Map([ ['start', []], ['error', []], ['end', []], ['soundstart', []], ['result', []], ['resultMatch', []], ['resultNoMatch', []], ['errorNetwork', []], ['errorPermissionBlocked', []], ['errorPermissionDenied', []], ]); let lastStartedAt: number = 0; let autoRestartCount: number = 0; let pauseListening: boolean = false; // The command matching code is a modified version of Backbone.Router by Jeremy Ashkenas, under the MIT license. const optionalParam = /\s*\((.*?)\)\s*/g; const optionalRegex = /(\(\?:[^)]+\))\?/g; const namedParam = /(\(\?)?:\w+/g; const splatParam = /\*\w+/g; const escapeRegExp = /[-{}[\]+?.,\\^$|#]/g; const commandToRegExp = (command: string) => { const parsedCommand = command .replace(escapeRegExp, '\\$&') .replace(optionalParam, '(?:$1)?') .replace(namedParam, (match, optional) => { return optional ? match : '([^\\s]+)'; }) .replace(splatParam, '(.*?)') .replace(optionalRegex, '\\s*$1?\\s*'); return new RegExp(`^${parsedCommand}$`, 'i'); }; // Get the SpeechRecognition object, accounting for possible browser prefixes const getSpeechRecognition = () => globalThis.SpeechRecognition || globalThis.webkitSpeechRecognition; // Check if annyang is already initialized const isInitialized = () => { return recognition !== undefined; }; // Method for logging to the console when debug mode is on const logMessage = (text: string, extraParameters?: string | string[]) => { if (debugState) { if (text.indexOf('%c') === -1 && !extraParameters) { console.log(text); } else { console.log(text, extraParameters || debugStyle); } } }; // Add a command to the commands list const registerCommand = (command: RegExp, callback: AnyFunction, originalPhrase: string) => { commandsList.set(originalPhrase, { command, callback }); logMessage(`Command successfully loaded: %c${originalPhrase}`, debugStyle); }; // This method receives an array of callbacks and invokes each of them const invokeCallbacks = (callbacksArr: StoredCallback[] = [], ...args: unknown[]) => { callbacksArr.forEach(cb => { cb.callback.apply(cb.context, args); }); }; // Initialize annyang const init = () => { if (!getSpeechRecognition()) { return; } // Abort previous instances of recognition already running if (recognition && recognition.abort) { recognition.abort(); } // initiate SpeechRecognition recognition = new (getSpeechRecognition())(); // Set the max number of alternative transcripts to try and match with a command recognition.maxAlternatives = 5; // In HTTPS, turn off continuous mode for faster results. // In HTTP, turn on continuous mode for much slower results, but no repeating security notices recognition.continuous = globalThis.location.protocol === 'http:'; // Sets the language to the default 'en-US'. This can be changed with annyang.setLanguage() recognition.lang = 'en-US'; recognition.onstart = () => { listening = true; invokeCallbacks(callbacks.get('start')); }; recognition.onsoundstart = () => { invokeCallbacks(callbacks.get('soundstart')); }; recognition.onerror = event => { invokeCallbacks(callbacks.get('error'), event); switch (event.error) { case 'network': invokeCallbacks(callbacks.get('errorNetwork'), event); break; case 'not-allowed': case 'service-not-allowed': // if permission to use the mic is denied, turn off auto-restart autoRestart = false; // determine if permission was denied by user or automatically. if (new Date().getTime() - lastStartedAt < 200) { invokeCallbacks(callbacks.get('errorPermissionBlocked'), event); } else { invokeCallbacks(callbacks.get('errorPermissionDenied'), event); } break; default: break; } }; recognition.onend = () => { listening = false; invokeCallbacks(callbacks.get('end')); // annyang will auto restart if it is closed automatically and not by user action. if (autoRestart) { // play nicely with the browser, and never restart annyang automatically more than once per second const timeSinceLastStart = new Date().getTime() - lastStartedAt; autoRestartCount += 1; if (autoRestartCount % RESTART_WARNING_INTERVAL === 0) { logMessage( 'Speech Recognition is repeatedly stopping and starting. See http://is.gd/annyang_restarts for tips.' ); } if (timeSinceLastStart < MIN_RESTART_INTERVAL_MS) { setTimeout(() => { start({ paused: pauseListening }); }, MIN_RESTART_INTERVAL_MS - timeSinceLastStart); } else { start({ paused: pauseListening }); } } }; recognition.onresult = (event: SpeechRecognitionEvent) => { if (pauseListening) { logMessage('Speech heard, but annyang is paused'); return; } // Map the results to an array const SpeechRecognitionResults = event.results[event.resultIndex]; const results = Array.from(SpeechRecognitionResults, result => result.transcript); parseResults(results); }; }; // If annyang isn't initialized, initialize it const initIfNeeded = () => { if (!isInitialized()) { init(); } }; const parseResults = (recognitionResults: string[]) => { invokeCallbacks(callbacks.get('result'), recognitionResults); // Log all recognition alternatives for debugging, regardless of match for (const rawText of recognitionResults) { logMessage(`Speech recognized: %c${rawText.trim()}`, debugStyle); } // Try to match each alternative to a command for (const rawText of recognitionResults) { const commandText = rawText.trim(); for (const [originalPhrase, currentCommand] of commandsList) { const matchedCommand = currentCommand.command.exec(commandText); if (matchedCommand) { const parameters = matchedCommand.slice(1); logMessage(`command matched: %c${originalPhrase}`, debugStyle); if (parameters.length) { logMessage('with parameters', parameters); } currentCommand.callback(...parameters); invokeCallbacks(callbacks.get('resultMatch'), commandText, originalPhrase, recognitionResults); return; } } } invokeCallbacks(callbacks.get('resultNoMatch'), recognitionResults); }; /** * Is SpeechRecognition supported in this environment? * * @returns {boolean} true if SpeechRecognition is supported by the browser */ const isSpeechRecognitionSupported = () => !!getSpeechRecognition(); export type CommandCallback = (...args: string[]) => void; export interface CommandsList { [key: string]: | CommandCallback | { regexp: RegExp; callback: CommandCallback; }; } /** * Add commands that annyang will respond to. * By default this will add to the existing commands. Pass `true` as the second parameter to remove all existing commands first. * * #### Examples: * ````javascript * const commands1 = {'hello :name': helloFunction, 'howdy': helloFunction}; * const commands2 = {'hi': helloFunction}; * * annyang.addCommands(commands1); * annyang.addCommands(commands2); * // annyang will now listen for all three commands defined in commands1 and commands2 * * annyang.addCommands(commands2, true); * // annyang will now only listen for the command in commands2 * ```` * * @param {Object} commands - Commands that annyang should listen for * @param {boolean} [resetCommands=false] - Remove all existing commands before adding new commands? * @see [Commands Object](#commands-object) */ const addCommands = (commands: CommandsList, resetCommands = false) => { if (resetCommands) { commandsList.clear(); } for (const phrase of Object.keys(commands)) { const cb = commands[phrase]; if (typeof cb === 'function') { // convert command to regex then register the command registerCommand(commandToRegExp(phrase), cb, phrase); } else if (typeof cb === 'object' && cb.regexp instanceof RegExp) { // register the command registerCommand(new RegExp(cb.regexp.source, 'i'), cb.callback, phrase); } else { logMessage(`Can not register command: %c${phrase}`, debugStyle); } } }; /** * Remove existing commands. Called with a single phrase, an array of phrases, or with no params to remove all commands. * * #### Examples: * ````javascript * const commands = {'hello': helloFunction, 'howdy': helloFunction, 'hi': helloFunction}; * * // Remove all existing commands * annyang.removeCommands(); * * // Add some commands * annyang.addCommands(commands); * * // Don't respond to hello * annyang.removeCommands('hello'); * * // Don't respond to howdy or hi * annyang.removeCommands(['howdy', 'hi']); * ```` * @param {string|string[]|undefined} [commandsToRemove] - Commands to remove */ const removeCommands = (commandsToRemove?: string | string[] | undefined) => { if (commandsToRemove === undefined) { commandsList.clear(); } else { const commandsToRemoveArray = Array.isArray(commandsToRemove) ? commandsToRemove : [commandsToRemove]; commandsToRemoveArray.forEach(command => commandsList.delete(command)); } }; export interface StartOptions { autoRestart?: boolean; continuous?: boolean; paused?: boolean; } /** * Start listening. * It's a good idea to call this after adding some commands first (but not mandatory) * * Receives an optional options object which supports the following options: * * - `autoRestart` (boolean) Should annyang restart itself if it is closed indirectly (e.g. because of silence or window conflicts)? * - `continuous` (boolean) Allow forcing continuous mode on or off. annyang is pretty smart about this, so only set this if you know what you're doing. * - `paused` (boolean) Start annyang in paused mode. * * #### Examples: * ````javascript * // Start listening, don't restart automatically * annyang.start({ autoRestart: false }); * // Start listening, don't restart automatically, stop recognition after first phrase recognized * annyang.start({ autoRestart: false, continuous: false }); * ```` * @param {Object} [options] - Optional options. */ const start = (options: StartOptions = {}) => { if (!isSpeechRecognitionSupported()) { return; } initIfNeeded(); pauseListening = !!options.paused; if (options.autoRestart !== undefined) { autoRestart = !!options.autoRestart; } else { autoRestart = true; } if (options.continuous !== undefined) { recognition.continuous = !!options.continuous; } lastStartedAt = new Date().getTime(); try { recognition.start(); } catch (e: unknown) { logMessage(e instanceof Error ? e.message : String(e)); } }; /** * Stop listening and turn off the mic. * * Alternatively, to only temporarily pause annyang responding to commands without stopping the SpeechRecognition engine or closing the mic, use pause() instead. * @see [pause()](#pause) */ const abort = () => { autoRestart = false; autoRestartCount = 0; if (isInitialized()) { recognition.abort(); } }; /** * Pause listening. annyang will stop responding to commands (until the resume or start methods are called), without turning off the browser's SpeechRecognition engine or the mic. * * Alternatively, to stop the SpeechRecognition engine and close the mic, use abort() instead. * @see [abort()](#abort) */ const pause = () => { pauseListening = true; }; /** * Resumes listening and restore command callback execution when a command is matched. * If SpeechRecognition was aborted (stopped), start it. */ const resume = () => { start(); }; /** * Add a callback function to be called in case one of the following events happens: * * * `start` - Fired as soon as the browser's Speech Recognition engine starts listening. * * * `soundstart` - Fired as soon as any sound (possibly speech) has been detected. * * This will fire once per Speech Recognition starting. See https://is.gd/annyang_sound_start. * * * `error` - Fired when the browser's Speech Recognition engine returns an error, this generic error callback will be followed by more accurate error callbacks (both will fire if both are defined). * * The Callback function will be called with the error event as the first argument. * * * `errorNetwork` - Fired when Speech Recognition fails because of a network error. * * The Callback function will be called with the error event as the first argument. * * * `errorPermissionBlocked` - Fired when the browser blocks the permission request to use Speech Recognition. * * The Callback function will be called with the error event as the first argument. * * * `errorPermissionDenied` - Fired when the user blocks the permission request to use Speech Recognition. * * The Callback function will be called with the error event as the first argument. * * * `end` - Fired when the browser's Speech Recognition engine stops. * * * `result` - Fired as soon as some speech was identified. This generic callback will be followed by either the `resultMatch` or `resultNoMatch` callbacks. * * The Callback functions for this event will be called with an array of possible phrases the user said as the first argument. * * * `resultMatch` - Fired when annyang was able to match between what the user said and a registered command. * * The Callback functions for this event will be called with three arguments in the following order: * * * The phrase the user said that matched a command. * * The command that was matched. * * An array of possible alternative phrases the user might have said. * * * `resultNoMatch` - Fired when what the user said didn't match any of the registered commands. * * Callback functions for this event will be called with an array of possible phrases the user might have said as the first argument. * * #### Examples: * ````javascript * annyang.addCallback('resultMatch', (userSaid, commandText, phrases) => { * console.log(userSaid); // sample output: 'hello' * console.log(commandText); // sample output: 'hello (there)' * console.log(phrases); // sample output: ['hello', 'halo', 'yellow', 'polo', 'hello kitty'] * }); * * // Returns an unsubscribe function * const unsubscribe = annyang.addCallback('error', () => { * console.log('There was an error!'); * }); * unsubscribe(); // removes the callback * ```` * @param {string} type - Name of event that will trigger this callback * @param {function} callback - The function to call when event is triggered * @param {Object} [context] - Optional context for the callback function * @returns {function} A function that removes this callback when called */ const addCallback = ( type: T, callback: CallbackMap[T], context: object | undefined = undefined ): (() => void) => { const callbacksOfType = callbacks.get(type); if (typeof callback === 'function' && callbacksOfType) { const entry: StoredCallback = { callback: callback as AnyFunction, context, }; callbacksOfType.push(entry); return () => { const arr = callbacks.get(type); if (arr) { const idx = arr.indexOf(entry); if (idx !== -1) arr.splice(idx, 1); } }; } return () => {}; }; /** * Remove callbacks from events. * * - Pass an event name and a callback command to remove that callback command from that event type. * - Pass just an event name to remove all callback commands from that event type. * - Pass undefined as event name and a callback command to remove that callback command from all event types. * - Pass no params to remove all callback commands from all event types. * * #### Examples: * ````javascript * annyang.addCallback('start', myFunction1); * annyang.addCallback('start', myFunction2); * annyang.addCallback('end', myFunction1); * annyang.addCallback('end', myFunction2); * * // Remove all callbacks from all events: * annyang.removeCallback(); * * // Remove all callbacks attached to end event: * annyang.removeCallback('end'); * * // Remove myFunction2 from being called on start: * annyang.removeCallback('start', myFunction2); * * // Remove myFunction1 from being called on all events: * annyang.removeCallback(undefined, myFunction1); * ```` * * @param type Name of event type to remove callback from * @param callback The callback function to remove * @returns undefined */ const removeCallback = (type?: CallbackType, callback?: CallbackMap[CallbackType]) => { callbacks.forEach((callbacksArray, callbackType) => { if (type === undefined || type === callbackType) { if (callback === undefined) { callbacks.get(callbackType)!.length = 0; } else { callbacks.set( callbackType, callbacksArray.filter(cb => cb.callback !== callback) ); } } }); }; /** * Returns true if speech recognition is currently on. * Returns false if speech recognition is off or annyang is paused. * * @returns true if SpeechRecognition is on and annyang is not paused */ const isListening = () => { return listening && !pauseListening; }; export type AnnyangState = 'idle' | 'listening' | 'paused'; /** * Returns the current state of annyang. * * @returns {'idle' | 'listening' | 'paused'} The current state */ const getState = (): AnnyangState => { if (!listening) return 'idle'; if (pauseListening) return 'paused'; return 'listening'; }; /** * Set the language the user will speak in. If this method is not called, annyang defaults to 'en-US'. * * @param {string} language - The language (locale) * @see [Languages](https://github.com/TalAter/annyang/blob/master/docs/FAQ.md#what-languages-are-supported) */ const setLanguage = (language: string): void => { if (!isSpeechRecognitionSupported()) { return; } initIfNeeded(); recognition.lang = language; }; /** * Turn on the output of debug messages to the console. * * @param {boolean} [newState=true] - Turn debug messages on or off */ const debug = (newState: boolean = true): void => { debugState = !!newState; }; /** * Match text against registered commands and fire the corresponding callbacks. * Works independently of the speech recognition engine — does not require `start()`, and works even in * environments where SpeechRecognition is not supported. * * Can accept either a string containing a single sentence or an array containing multiple sentences to be checked * in order until one of them matches a command (similar to the way Speech Recognition Alternatives are parsed) * * #### Examples: * ````javascript * annyang.trigger('Time for some thrilling heroics'); * annyang.trigger( * ['Time for some thrilling heroics', 'Time for some thrilling aerobics'] * ); * ```` * * @param sentences - A sentence as a string or an array of strings of possible sentences */ const trigger = (sentences: string | string[] = []) => { parseResults(Array.isArray(sentences) ? sentences : [sentences]); }; /** * Returns the instance of the browser's SpeechRecognition object used by annyang. * Useful in case you want direct access to the browser's Speech Recognition engine. * * @returns SpeechRecognition The browser's Speech Recognizer instance currently used by annyang */ const getSpeechRecognizer = (): SpeechRecognition | undefined => { return recognition; }; /** * @deprecated annyang no longer requires manual initialization. It initializes automatically on `start()` or `addCommands()`. Remove any calls to `init()`. */ const initDeprecated = () => { console.warn( 'annyang.init() is deprecated and no longer needed. ' + 'annyang initializes automatically on start() or addCommands(). Remove this call.' ); }; export { abort, addCallback, addCommands, debug, getSpeechRecognizer, getState, initDeprecated as init, isListening, isSpeechRecognitionSupported, pause, removeCallback, removeCommands, resume, setLanguage, start, trigger, }; const annyang = { isSpeechRecognitionSupported, addCommands, removeCommands, start, abort, pause, resume, addCallback, removeCallback, isListening, setLanguage, trigger, debug, getSpeechRecognizer, getState, init: initDeprecated, get state() { return getState(); }, } as const; export default annyang; ================================================ FILE: test/setupTests.js ================================================ import { vi, beforeAll, afterAll } from 'vitest'; import { SpeechRecognition } from 'corti'; beforeAll(() => { vi.stubGlobal('SpeechRecognition', SpeechRecognition); vi.stubGlobal('location', { protocol: 'https:' }); }); afterAll(() => { vi.unstubAllGlobals(); }); ================================================ FILE: test/specs/annyang.test.ts ================================================ import { afterEach, beforeEach, describe, expect, it, test, vi, MockInstance } from 'vitest'; import type { CortiSpeechRecognition } from 'corti'; import annyangDefault from '../../src/annyang.ts'; import * as annyang from '../../src/annyang.ts'; import { isSpeechRecognitionSupported, start, isListening } from '../../src/annyang.ts'; const logFormatString = 'font-weight: bold; color: #00f;'; test('SpeechRecognition is mocked', () => { expect(globalThis.SpeechRecognition).toBeDefined(); expect(globalThis.SpeechRecognition.prototype).toHaveProperty('say', expect.any(Function)); }); test('Can import annyang as an object', () => { expect(annyang).toBeDefined(); expect(annyang.isSpeechRecognitionSupported).toBeInstanceOf(Function); expect(annyang.isSpeechRecognitionSupported()).toBe(true); }); test('Can import annyang as a default export', () => { expect(annyangDefault).toBeDefined(); expect(annyangDefault.isSpeechRecognitionSupported).toBeInstanceOf(Function); expect(annyangDefault.addCommands).toBeInstanceOf(Function); expect(annyangDefault.start).toBeInstanceOf(Function); }); test('Default export has state getter', () => { expect(annyangDefault.state).toBe('idle'); annyangDefault.start(); expect(annyangDefault.state).toBe('listening'); annyangDefault.pause(); expect(annyangDefault.state).toBe('paused'); annyangDefault.abort(); expect(annyangDefault.state).toBe('idle'); }); test('Can import individual named exports from annyang', () => { expect(isSpeechRecognitionSupported).toBeInstanceOf(Function); expect(isSpeechRecognitionSupported()).toBe(true); expect(isListening()).toBe(false); start(); expect(isListening()).toBe(true); }); describe('annyang', () => { let logSpy!: MockInstance; beforeEach(() => { vi.useFakeTimers(); logSpy = vi.spyOn(console, 'log'); annyang.debug(false); annyang.abort(); annyang.removeCommands(); annyang.removeCallback(); }); afterEach(() => { vi.useRealTimers(); logSpy.mockRestore(); }); it('should recognize when Speech Recognition engine was aborted and abort annyang', () => { annyang.start(); expect(annyang.isListening()).toBe(true); annyang.getSpeechRecognizer().abort(); expect(annyang.isListening()).toBe(false); }); it('should recognize when Speech Recognition engine is repeatedly aborted as soon as it is started and console.log about it once every 10 seconds', () => { const recognition = annyang.getSpeechRecognizer(); const onStart = () => { setTimeout(() => recognition.abort(), 1); }; recognition.addEventListener('start', onStart); annyang.debug(); annyang.start(); expect(logSpy).not.toHaveBeenCalled(); vi.advanceTimersByTime(10000); expect(logSpy).toHaveBeenCalledTimes(1); expect(logSpy).toHaveBeenCalledWith( 'Speech Recognition is repeatedly stopping and starting. See http://is.gd/annyang_restarts for tips.' ); vi.advanceTimersByTime(10000); expect(logSpy).toHaveBeenCalledTimes(2); recognition.removeEventListener('start', onStart); }); describe('isSpeechRecognitionSupported()', () => { it('should be a function', () => { expect(annyang.isSpeechRecognitionSupported).toBeInstanceOf(Function); }); it('should return true when SpeechRecognition is available in globalThis', () => { expect(annyang.isSpeechRecognitionSupported()).toBe(true); }); }); describe('debug()', () => { it('should be a function', () => { expect(annyang.isSpeechRecognitionSupported).toBeInstanceOf(Function); }); it('should turn on debug messages when called without a parameter', () => { annyang.debug(); annyang.addCommands({ 'test command': () => {} }); expect(logSpy).toHaveBeenCalled(); }); it('should turn on debug messages when called with a truthy parameter', () => { // @ts-expect-error testing invalid parameter annyang.debug(11); annyang.addCommands({ 'test command': () => {} }); expect(logSpy).toHaveBeenCalled(); }); it('should turn off debug messages when called with a falsy parameter', () => { // @ts-expect-error testing invalid parameter annyang.debug(0); annyang.addCommands({ 'test command': () => {} }); expect(logSpy).not.toHaveBeenCalled(); }); }); describe('addCommands()', () => { it('should be a function', () => { expect(annyang.addCommands).toBeInstanceOf(Function); }); it('should accept an object consisting of key (sentence) and value (callback function)', () => { expect(() => { annyang.addCommands({ 'Time for some thrilling heroics': () => {}, }); }).not.toThrowError(); }); describe('command matching', () => { let spyOnMatch!: MockInstance; beforeEach(() => { spyOnMatch = vi.fn(); }); it('should work when a command object with a single simple command is passed', () => { annyang.addCommands({ 'Time for some thrilling heroics': spyOnMatch }); annyang.start(); (annyang.getSpeechRecognizer() as CortiSpeechRecognition).say('Time for some thrilling heroics'); expect(spyOnMatch).toHaveBeenCalledTimes(1); }); }); describe('debug messages', () => { it('should write to console each command that was successfully added when debug is on', () => { expect(logSpy).toHaveBeenCalledTimes(0); annyang.debug(true); annyang.addCommands({ 'Time for some thrilling heroics': () => {}, }); expect(logSpy).toHaveBeenCalledTimes(1); expect(logSpy).toHaveBeenCalledWith( 'Command successfully loaded: %cTime for some thrilling heroics', logFormatString ); annyang.addCommands({ 'That sounds like something out of science fiction': () => {}, 'We should start dealing in those black-market beagles': () => {}, }); expect(logSpy).toHaveBeenCalledTimes(3); }); it('should not write to console commands added when debug is off', () => { annyang.debug(false); annyang.addCommands({ 'Time for some thrilling heroics': () => {}, }); annyang.addCommands({ 'That sounds like something out of science fiction': () => {}, 'We should start dealing in those black-market beagles': () => {}, }); expect(logSpy).not.toHaveBeenCalled(); }); it('should write to console when commands could not be added and debug is on', () => { annyang.debug(true); expect(logSpy).not.toHaveBeenCalled(); annyang.addCommands({ 'Time for some thrilling heroics': 'not_a_function', }); expect(logSpy).toHaveBeenCalledTimes(1); expect(logSpy).toHaveBeenCalledWith( 'Can not register command: %cTime for some thrilling heroics', logFormatString ); }); it('should not write to console when commands could not be added but debug is off', () => { annyang.debug(false); annyang.addCommands({ 'Time for some thrilling heroics': 'not_a_function', }); expect(logSpy).not.toHaveBeenCalled(); }); }); }); describe('removeCommands()', () => { let recognition; let spyOnMatch1!: MockInstance; let spyOnMatch2!: MockInstance; let spyOnMatch3!: MockInstance; let spyOnMatch4!: MockInstance; let spyOnMatch5!: MockInstance; beforeEach(() => { spyOnMatch1 = vi.fn(); spyOnMatch2 = vi.fn(); spyOnMatch3 = vi.fn(); spyOnMatch4 = vi.fn(); spyOnMatch5 = vi.fn(); annyang.addCommands({ 'Time for some (thrilling) heroics': spyOnMatch1, 'We should start dealing in those *merchandise': spyOnMatch2, 'That sounds like something out of science fiction': spyOnMatch3, 'too pretty': { regexp: /We are just too pretty for God to let us die/, callback: spyOnMatch4, }, "You can't take the :thing from me": spyOnMatch5, }); annyang.start({ continuous: true }); recognition = annyang.getSpeechRecognizer(); }); it('should be a function', () => { expect(annyang.removeCommands).toBeInstanceOf(Function); }); it('should remove a single command when its name is passed as a string in the first parameter', () => { annyang.removeCommands('Time for some (thrilling) heroics'); annyang.start(); recognition.say('Time for some thrilling heroics'); recognition.say('We should start dealing in those black-market beagles'); recognition.say('That sounds like something out of science fiction'); recognition.say('We are just too pretty for God to let us die'); recognition.say("You can't take the sky from me"); expect(spyOnMatch1).not.toHaveBeenCalled(); expect(spyOnMatch2).toHaveBeenCalledTimes(1); expect(spyOnMatch3).toHaveBeenCalledTimes(1); expect(spyOnMatch4).toHaveBeenCalledTimes(1); expect(spyOnMatch5).toHaveBeenCalledTimes(1); }); it('should remove multiple commands when their names are passed as an array in the first parameter', () => { annyang.removeCommands([ 'Time for some (thrilling) heroics', 'That sounds like something out of science fiction', ]); recognition.say('Time for some thrilling heroics'); recognition.say('We should start dealing in those black-market beagles'); recognition.say('That sounds like something out of science fiction'); recognition.say('We are just too pretty for God to let us die'); recognition.say("You can't take the sky from me"); expect(spyOnMatch1).not.toHaveBeenCalled(); expect(spyOnMatch2).toHaveBeenCalledTimes(1); expect(spyOnMatch3).not.toHaveBeenCalled(); expect(spyOnMatch4).toHaveBeenCalledTimes(1); expect(spyOnMatch5).toHaveBeenCalledTimes(1); }); it('should remove all commands when called with no parameters', () => { annyang.removeCommands(); recognition.say('Time for some heroics'); recognition.say('We should start dealing in those black-market beagles'); recognition.say('That sounds like something out of science fiction'); recognition.say('We are just too pretty for God to let us die'); recognition.say("You can't take the sky from me"); expect(spyOnMatch1).not.toHaveBeenCalled(); expect(spyOnMatch2).not.toHaveBeenCalled(); expect(spyOnMatch3).not.toHaveBeenCalled(); expect(spyOnMatch4).not.toHaveBeenCalled(); expect(spyOnMatch5).not.toHaveBeenCalled(); }); it('should remove a command with an optional word when its name is passed in the first parameter', () => { annyang.removeCommands('Time for some (thrilling) heroics'); recognition.say('Time for some heroics'); recognition.say('We should start dealing in those black-market beagles'); recognition.say('That sounds like something out of science fiction'); recognition.say('We are just too pretty for God to let us die'); recognition.say("You can't take the sky from me"); expect(spyOnMatch1).not.toHaveBeenCalled(); expect(spyOnMatch2).toHaveBeenCalledTimes(1); expect(spyOnMatch3).toHaveBeenCalledTimes(1); expect(spyOnMatch4).toHaveBeenCalledTimes(1); expect(spyOnMatch5).toHaveBeenCalledTimes(1); }); it('should remove a command with a named variable when its name is passed in the first parameter', () => { annyang.removeCommands("You can't take the :thing from me"); recognition.say('Time for some heroics'); recognition.say('We should start dealing in those black-market beagles'); recognition.say('That sounds like something out of science fiction'); recognition.say('We are just too pretty for God to let us die'); recognition.say("You can't take the sky from me"); expect(spyOnMatch1).toHaveBeenCalledTimes(1); expect(spyOnMatch2).toHaveBeenCalledTimes(1); expect(spyOnMatch3).toHaveBeenCalledTimes(1); expect(spyOnMatch4).toHaveBeenCalledTimes(1); expect(spyOnMatch5).not.toHaveBeenCalled(); }); it('should remove a command with a splat when its name is passed as a parameter', () => { annyang.removeCommands('We should start dealing in those *merchandise'); recognition.say('Time for some heroics'); recognition.say('We should start dealing in those black-market beagles'); recognition.say('That sounds like something out of science fiction'); recognition.say('We are just too pretty for God to let us die'); recognition.say("You can't take the sky from me"); expect(spyOnMatch1).toHaveBeenCalledTimes(1); expect(spyOnMatch2).not.toHaveBeenCalled(); expect(spyOnMatch3).toHaveBeenCalledTimes(1); expect(spyOnMatch4).toHaveBeenCalledTimes(1); expect(spyOnMatch5).toHaveBeenCalledTimes(1); }); it('should remove a regexp command when its name is passed as a parameter', () => { annyang.removeCommands('too pretty'); recognition.say('Time for some heroics'); recognition.say('We should start dealing in those black-market beagles'); recognition.say('That sounds like something out of science fiction'); recognition.say('We are just too pretty for God to let us die'); recognition.say("You can't take the sky from me"); expect(spyOnMatch1).toHaveBeenCalledTimes(1); expect(spyOnMatch2).toHaveBeenCalledTimes(1); expect(spyOnMatch3).toHaveBeenCalledTimes(1); expect(spyOnMatch4).not.toHaveBeenCalled(); expect(spyOnMatch5).toHaveBeenCalledTimes(1); }); }); describe('addCallback()', () => { it('should be a function', () => { expect(annyang.addCallback).toBeInstanceOf(Function); }); it('should return an unsubscribe function when a valid callback is added', () => { const unsub = annyang.addCallback('start', () => {}); expect(unsub).toBeInstanceOf(Function); }); it('should return a no-op function when called with invalid arguments', () => { // @ts-expect-error testing invalid parameter const unsub1 = annyang.addCallback(); expect(unsub1).toBeInstanceOf(Function); // @ts-expect-error testing invalid parameter const unsub2 = annyang.addCallback('blergh'); expect(unsub2).toBeInstanceOf(Function); // @ts-expect-error testing invalid parameter const unsub3 = annyang.addCallback('start'); expect(unsub3).toBeInstanceOf(Function); }); it('should remove callback when unsubscribe function is called', () => { const spy: MockInstance = vi.fn(); const unsub = annyang.addCallback('start', spy); annyang.start(); expect(spy).toHaveBeenCalledTimes(1); annyang.abort(); spy.mockClear(); unsub(); annyang.start(); expect(spy).not.toHaveBeenCalled(); }); it('should be able to register multiple callbacks to one event type', () => { const spy1: MockInstance = vi.fn(); const spy2: MockInstance = vi.fn(); annyang.addCallback('start', spy1); annyang.addCallback('start', spy2); expect(spy1).not.toHaveBeenCalled(); expect(spy2).not.toHaveBeenCalled(); annyang.start(); expect(spy1).toHaveBeenCalledTimes(1); expect(spy2).toHaveBeenCalledTimes(1); }); it('should run callbacks with `this` being undefined by default', () => { const spy1 = vi.fn(); const fn = function () { spy1(this); }; annyang.addCallback('start', fn); annyang.start(); expect(spy1).toHaveBeenCalledWith(undefined); }); it('should run callbacks in the scope where addCallback was called by default', () => { let counter = 0; const fn = function () { counter += 1; }; annyang.addCallback('start', fn); annyang.start(); expect(counter).toEqual(1); }); it('should run arrow function callbacks with `this` being the current scope in which addCallback was called', () => { const spy1 = vi.fn(); const fn = () => { spy1(this); }; annyang.addCallback('start', fn); annyang.start(); expect(spy1).toHaveBeenCalledWith(this); }); it('should run callbacks with `this` being equal to the context given as the third parameter', () => { const spy1 = vi.fn(); const obj = { counter: 0 }; const fn = function () { spy1(this); this.counter += 1; }; annyang.addCallback('start', fn, obj); annyang.start(); expect(spy1).toHaveBeenCalledWith(obj); expect(obj.counter).toEqual(1); }); it('should run arrow function callbacks with `this` being equal to the current context regardless of the context given as the third parameter', () => { const spy1: MockInstance = vi.fn(); const fn = () => { spy1(this); }; annyang.addCallback('start', fn, { a: 1 }); annyang.start(); expect(spy1).toHaveBeenCalledWith(this); }); }); describe('removeCallback()', () => { let spy1!: MockInstance; let spy2!: MockInstance; let spy3!: MockInstance; let spy4!: MockInstance; beforeEach(() => { spy1 = vi.fn(); spy2 = vi.fn(); spy3 = vi.fn(); spy4 = vi.fn(); annyang.addCallback('start', spy1); annyang.addCallback('start', spy2); annyang.addCallback('end', spy3); annyang.addCallback('end', spy4); }); it('should be a function', () => { expect(annyang.removeCallback).toBeInstanceOf(Function); }); it('should always return undefined', () => { expect(annyang.removeCallback()).toEqual(undefined); // @ts-expect-error testing invalid parameter expect(annyang.removeCallback('blergh')).toEqual(undefined); expect(annyang.removeCallback('start')).toEqual(undefined); expect(annyang.removeCallback('start', () => {})).toEqual(undefined); }); it('should delete all callbacks on all event types if passed undefined in both parameters', () => { annyang.removeCallback(); annyang.start(); annyang.abort(); expect(spy1).not.toHaveBeenCalled(); expect(spy2).not.toHaveBeenCalled(); expect(spy3).not.toHaveBeenCalled(); expect(spy4).not.toHaveBeenCalled(); }); it('should delete all callbacks of given function on all event types if 1st parameter is undefined and second parameter is a function', () => { annyang.addCallback('end', spy1); annyang.removeCallback(undefined, spy1); annyang.start(); annyang.abort(); expect(spy1).not.toHaveBeenCalled(); expect(spy2).toHaveBeenCalledTimes(1); expect(spy3).toHaveBeenCalledTimes(1); expect(spy4).toHaveBeenCalledTimes(1); }); it('should delete all callbacks on an event type if passed an event name and no second parameter', () => { annyang.removeCallback('start'); annyang.start(); annyang.abort(); expect(spy1).not.toHaveBeenCalled(); expect(spy2).not.toHaveBeenCalled(); expect(spy3).toHaveBeenCalledTimes(1); expect(spy4).toHaveBeenCalledTimes(1); }); it('should delete the callbacks on an event type matching the function passed as the second parameter', () => { annyang.removeCallback('start', spy2); annyang.start(); annyang.abort(); expect(spy1).toHaveBeenCalledTimes(1); expect(spy2).not.toHaveBeenCalled(); expect(spy3).toHaveBeenCalledTimes(1); expect(spy4).toHaveBeenCalledTimes(1); }); }); describe('getSpeechRecognizer()', () => { it('should be a function', () => { expect(annyang.getSpeechRecognizer).toBeInstanceOf(Function); }); it('should return the instance of SpeechRecognition used by annyang', () => { const spyOnStart: MockInstance = vi.fn(); const recognition = annyang.getSpeechRecognizer(); expect(recognition).toBeInstanceOf(globalThis.SpeechRecognition); // Make sure it's the one used by annyang recognition.addEventListener('start', spyOnStart); expect(spyOnStart).not.toHaveBeenCalled(); annyang.start(); expect(spyOnStart).toHaveBeenCalledTimes(1); }); }); describe('start()', () => { let recognition; let spyOnStart1!: MockInstance; let spyOnStart2!: MockInstance; beforeEach(() => { recognition = annyang.getSpeechRecognizer(); spyOnStart1 = vi.fn(); spyOnStart2 = vi.fn(); recognition.addEventListener('start', spyOnStart1); annyang.addCallback('start', spyOnStart2); }); it('should be a function', () => { expect(annyang.start).toBeInstanceOf(Function); }); it('should start annyang and SpeechRecognition if it was aborted', () => { expect(spyOnStart1).not.toHaveBeenCalled(); expect(spyOnStart2).not.toHaveBeenCalled(); expect(annyang.isListening()).toBe(false); annyang.start(); expect(annyang.isListening()).toBe(true); expect(spyOnStart1).toHaveBeenCalledTimes(1); expect(spyOnStart2).toHaveBeenCalledTimes(1); }); it('should resume annyang if it was paused', () => { annyang.start(); expect(annyang.isListening()).toBe(true); annyang.pause(); expect(annyang.isListening()).toBe(false); annyang.start(); expect(annyang.isListening()).toBe(true); }); it('should resume annyang if it was paused but not trigger start event', () => { expect(spyOnStart1).not.toHaveBeenCalled(); expect(spyOnStart2).not.toHaveBeenCalled(); annyang.start(); expect(annyang.isListening()).toBe(true); expect(spyOnStart1).toHaveBeenCalledTimes(1); expect(spyOnStart2).toHaveBeenCalledTimes(1); annyang.pause(); expect(annyang.isListening()).toBe(false); annyang.start(); expect(annyang.isListening()).toBe(true); expect(spyOnStart1).toHaveBeenCalledTimes(1); expect(spyOnStart2).toHaveBeenCalledTimes(1); }); it('should do nothing when annyang is already started and listening', () => { annyang.start(); expect(annyang.isListening()).toBe(true); expect(() => { annyang.start(); }).not.toThrowError(); expect(annyang.isListening()).toBe(true); expect(spyOnStart1).toHaveBeenCalledTimes(1); expect(spyOnStart2).toHaveBeenCalledTimes(1); }); it('should accept an options object as its first argument', () => { expect(() => { // @ts-expect-error testing invalid parameter annyang.start({ option: true }); }).not.toThrowError(); }); describe('options', () => { describe('autoRestart', () => { it('should cause annyang to restart after 1 second when Speech Recognition engine was aborted (when true)', () => { annyang.start({ autoRestart: true }); recognition.abort(); expect(annyang.isListening()).toBe(false); vi.advanceTimersByTime(999); expect(annyang.isListening()).toBe(false); vi.advanceTimersByTime(1); expect(annyang.isListening()).toBe(true); }); it('should cause annyang to not restart when Speech Recognition engine was aborted (when false)', () => { annyang.start({ autoRestart: false }); recognition.abort(); expect(annyang.isListening()).toBe(false); vi.advanceTimersByTime(10000); expect(annyang.isListening()).toBe(false); }); it('should default to true, even after an annyang.abort() call', () => { annyang.start(); annyang.abort(); annyang.start(); expect(annyang.isListening()).toBe(true); annyang.getSpeechRecognizer().abort(); expect(annyang.isListening()).toBe(false); vi.advanceTimersByTime(20000); expect(annyang.isListening()).toBe(true); }); }); describe('paused', () => { it('should cause annyang to start paused (when true)', () => { annyang.start({ paused: true }); expect(annyang.isListening()).toBe(false); }); it('should cause annyang to start not paused (when false)', () => { annyang.start({ paused: false }); expect(annyang.isListening()).toBe(true); }); }); describe('continuous', () => { let spyOnEnd!: MockInstance; let spyOnResult!: MockInstance; beforeEach(() => { spyOnEnd = vi.fn(); spyOnResult = vi.fn(); annyang.addCallback('end', spyOnEnd); annyang.addCallback('result', spyOnResult); }); it('should cause annyang to continuously listen to phrases even after matches are made (when true)', () => { annyang.start({ continuous: true }); expect(spyOnResult).not.toHaveBeenCalled(); expect(spyOnEnd).not.toHaveBeenCalled(); recognition.say('Time for some thrilling heroics'); expect(spyOnResult).toHaveBeenCalledTimes(1); expect(spyOnEnd).not.toHaveBeenCalled(); recognition.say('Time for some thrilling heroics'); expect(spyOnResult).toHaveBeenCalledTimes(2); expect(spyOnEnd).not.toHaveBeenCalled(); }); it('should cause annyang to stop after the first recognized phrase whether it matches or not (when false)', () => { annyang.start({ continuous: false }); expect(spyOnResult).not.toHaveBeenCalled(); expect(spyOnEnd).not.toHaveBeenCalled(); recognition.say('Time for some thrilling heroics'); expect(spyOnResult).toHaveBeenCalledTimes(1); expect(spyOnEnd).toHaveBeenCalledTimes(1); recognition.say('Time for some thrilling heroics'); expect(spyOnResult).toHaveBeenCalledTimes(1); expect(spyOnEnd).toHaveBeenCalledTimes(1); }); }); }); describe('deubg messages', () => { it('should write a message to log when annyang is already started and debug is on', () => { expect(logSpy).toHaveBeenCalledTimes(0); annyang.debug(true); annyang.start(); annyang.start(); expect(logSpy).toHaveBeenCalledTimes(1); expect(logSpy).toHaveBeenCalledWith( "Failed to execute 'start' on 'SpeechRecognition': recognition has already started." ); }); it('should not write a message to log when annyang is already started but debug is off', () => { expect(logSpy).toHaveBeenCalledTimes(0); annyang.debug(false); annyang.start(); annyang.start(); expect(logSpy).not.toHaveBeenCalled(); }); }); }); describe('abort()', () => { let spyOnEnd!: MockInstance; let recognition; beforeEach(() => { spyOnEnd = vi.fn(); recognition = annyang.getSpeechRecognizer(); recognition.addEventListener('end', spyOnEnd); }); it('should be a function', () => { expect(annyang.abort).toBeInstanceOf(Function); }); it('should stop SpeechRecognition and annyang if it is started', () => { annyang.start(); expect(spyOnEnd).toHaveBeenCalledTimes(0); expect(annyang.isListening()).toBe(true); annyang.abort(); expect(spyOnEnd).toHaveBeenCalledTimes(1); expect(annyang.isListening()).toBe(false); }); it('should stop Speech Recognition and annyang if it is paused', () => { annyang.start(); annyang.pause(); expect(spyOnEnd).toHaveBeenCalledTimes(0); expect(annyang.isListening()).toBe(false); annyang.abort(); expect(spyOnEnd).toHaveBeenCalledTimes(1); expect(annyang.isListening()).toBe(false); }); it('should do nothing when annyang is already stopped', () => { annyang.start(); annyang.abort(); expect(spyOnEnd).toHaveBeenCalledTimes(1); annyang.abort(); expect(spyOnEnd).toHaveBeenCalledTimes(1); }); it('should not throw an error when called before annyang initializes', () => { expect(() => { annyang.abort(); }).not.toThrowError(); }); }); describe('pause()', () => { let recognition; beforeEach(() => { annyang.start(); recognition = annyang.getSpeechRecognizer(); }); it('should be a function', () => { expect(annyang.pause).toBeInstanceOf(Function); }); it('should return undefined when called', () => { expect(annyang.pause()).toEqual(undefined); }); it('should cause commands not to fire even when a command phrase is matched', () => { const spyOnMatch: MockInstance = vi.fn(); annyang.addCommands({ 'Time for some thrilling heroics': spyOnMatch, }); annyang.pause(); recognition.say('Time for some thrilling heroics'); expect(spyOnMatch).not.toHaveBeenCalled(); }); it("should not stop the browser's Speech Recognition engine", () => { expect(recognition.isStarted()).toBe(true); annyang.pause(); expect(recognition.isStarted()).toBe(true); }); it('should leave annyang paused if called after annyang.abort()', () => { expect(annyang.isListening()).toBe(true); annyang.abort(); expect(annyang.isListening()).toBe(false); annyang.pause(); expect(annyang.isListening()).toBe(false); }); it("should leave the browser's Speech Recognition off, if called after annyang.abort()", () => { expect(recognition.isStarted()).toBe(true); annyang.abort(); expect(recognition.isStarted()).toBe(false); annyang.pause(); expect(recognition.isStarted()).toBe(false); }); describe('debug messages', () => { beforeEach(() => { annyang.pause(); }); it('should log a message if speech detected while paused and debug is on', () => { annyang.debug(); expect(logSpy).not.toHaveBeenCalled(); recognition.say('Time for some thrilling heroics'); expect(logSpy).toHaveBeenCalledTimes(1); expect(logSpy).toHaveBeenCalledWith('Speech heard, but annyang is paused'); }); it('should not log a message if speech detected while paused and debug is off', () => { annyang.debug(false); recognition.say('Time for some thrilling heroics'); expect(logSpy).not.toHaveBeenCalled(); }); }); }); describe('resume()', () => { let recognition; beforeEach(() => { annyang.start(); recognition = annyang.getSpeechRecognizer(); }); it('should be a function', () => { expect(annyang.resume).toBeInstanceOf(Function); }); it('should return undefined when called', () => { expect(annyang.resume()).toEqual(undefined); }); it('should leave speech recognition on and turn annyang on, if called when annyang is paused', () => { annyang.start(); annyang.pause(); expect(annyang.isListening()).toBe(false); expect(recognition.isStarted()).toBe(true); annyang.resume(); expect(annyang.isListening()).toBe(true); expect(recognition.isStarted()).toBe(true); }); it('should turn speech recognition and annyang on, if called when annyang is stopped', () => { annyang.abort(); expect(annyang.isListening()).toBe(false); expect(recognition.isStarted()).toBe(false); annyang.resume(); expect(annyang.isListening()).toBe(true); expect(recognition.isStarted()).toBe(true); }); it('should leave speech recognition and annyang on, if called when annyang is listening', () => { expect(annyang.isListening()).toBe(true); expect(recognition.isStarted()).toBe(true); annyang.resume(); expect(annyang.isListening()).toBe(true); expect(recognition.isStarted()).toBe(true); }); describe('debug messages', () => { it('should log a message if debug is on, and resume was called when annyang is listening', () => { annyang.debug(true); annyang.resume(); expect(logSpy).toHaveBeenCalledTimes(1); expect(logSpy).toHaveBeenCalledWith( "Failed to execute 'start' on 'SpeechRecognition': recognition has already started." ); }); it('should not log a message if debug is off, and resume was called when annyang is listening', () => { annyang.debug(false); annyang.resume(); expect(logSpy).not.toHaveBeenCalled(); }); }); }); describe('setLanguage()', () => { it('should be a function', () => { expect(annyang.setLanguage).toBeInstanceOf(Function); }); it('should return undefined when called', () => { // @ts-expect-error testing invalid parameter expect(annyang.setLanguage()).toEqual(undefined); }); it('should set the Speech Recognition engine to the value passed', () => { annyang.setLanguage('he'); expect(annyang.getSpeechRecognizer().lang).toEqual('he'); }); }); describe('isListening()', () => { it('should be a function', () => { expect(annyang.isListening).toBeInstanceOf(Function); }); it('should return false when called before annyang starts', () => { expect(annyang.isListening()).toBe(false); }); it('should return true when called after annyang starts', () => { annyang.start(); expect(annyang.isListening()).toBe(true); }); it('should return false when called after annyang aborts', () => { annyang.start(); annyang.abort(); expect(annyang.isListening()).toBe(false); }); it('should return false when called when annyang is paused', () => { annyang.start(); annyang.pause(); expect(annyang.isListening()).toBe(false); }); it('should return true when called after annyang is resumed', () => { annyang.start(); annyang.pause(); annyang.resume(); expect(annyang.isListening()).toBe(true); }); it('should return false when SpeechRecognition object is aborted directly', () => { annyang.start(); expect(annyang.isListening()).toBe(true); annyang.getSpeechRecognizer().abort(); expect(annyang.isListening()).toBe(false); }); }); describe('trigger()', () => { let spyOnCommand!: MockInstance; let spyOnResult!: MockInstance; beforeEach(() => { spyOnCommand = vi.fn(); spyOnResult = vi.fn(); annyang.addCommands({ 'Time for some thrilling heroics': spyOnCommand, }); annyang.start(); }); it('should always return undefined', () => { expect(annyang.trigger()).toEqual(undefined); expect(annyang.trigger('Time for some thrilling heroics')).toEqual(undefined); expect(annyang.trigger(['Time for some thrilling aerobics', 'Time for some thrilling heroics'])).toEqual( undefined ); }); it('should match a sentence passed as a string and execute it as if it was passed from Speech Recognition', () => { expect(spyOnCommand).not.toHaveBeenCalled(); annyang.trigger('Time for some thrilling heroics'); expect(spyOnCommand).toHaveBeenCalledTimes(1); }); it('should match a sentence passed as part of an array and execute it as if it was passed from Speech Recognition', () => { expect(spyOnCommand).not.toHaveBeenCalled(); annyang.trigger(['Time for some thrilling aerobics', 'Time for some thrilling heroics']); expect(spyOnCommand).toHaveBeenCalledTimes(1); }); it('should trigger a result event', () => { annyang.addCallback('result', spyOnResult); expect(spyOnResult).not.toHaveBeenCalled(); annyang.trigger('Result but not a match'); expect(spyOnResult).toHaveBeenCalledTimes(1); }); it('should trigger a resultMatch event if sentence matches a command', () => { annyang.addCallback('resultMatch', spyOnResult); expect(spyOnResult).not.toHaveBeenCalled(); annyang.trigger('Time for some thrilling heroics'); expect(spyOnResult).toHaveBeenCalledTimes(1); }); it('should trigger a resultNoMatch event if sentence does not match a command', () => { annyang.addCallback('resultNoMatch', spyOnResult); expect(spyOnResult).not.toHaveBeenCalled(); annyang.trigger('Result but not a match'); expect(spyOnResult).toHaveBeenCalledTimes(1); }); it('should trigger a matching command even if annyang is aborted or not started', () => { annyang.addCallback('resultMatch', spyOnResult); expect(spyOnResult).not.toHaveBeenCalled(); annyang.abort(); annyang.trigger('Time for some thrilling heroics'); expect(spyOnResult).toHaveBeenCalled(); }); it('should trigger a matching command even if annyang is paused', () => { annyang.addCallback('resultMatch', spyOnResult); expect(spyOnResult).not.toHaveBeenCalled(); annyang.pause(); annyang.trigger('Time for some thrilling heroics'); expect(spyOnResult).toHaveBeenCalled(); }); }); describe('events', () => { describe('start', () => { let spyOnStart!: MockInstance; beforeEach(() => { spyOnStart = vi.fn(); annyang.addCallback('start', spyOnStart); }); it('should fire callback when annyang aborts', () => { expect(spyOnStart).not.toHaveBeenCalled(); annyang.start(); expect(spyOnStart).toHaveBeenCalledTimes(1); }); it('should not fire callback when annyang resumes from a paused state', () => { expect(spyOnStart).not.toHaveBeenCalled(); annyang.start(); expect(spyOnStart).toHaveBeenCalledTimes(1); annyang.pause(); annyang.start(); expect(spyOnStart).toHaveBeenCalledTimes(1); }); it('should fire callback when annyang resumes from an aborted (stopped) state', () => { expect(spyOnStart).not.toHaveBeenCalled(); annyang.start(); expect(spyOnStart).toHaveBeenCalledTimes(1); annyang.abort(); annyang.start(); expect(spyOnStart).toHaveBeenCalledTimes(2); }); }); describe('end', () => { let spyOnEnd!: MockInstance; beforeEach(() => { spyOnEnd = vi.fn(); annyang.addCallback('end', spyOnEnd); }); it('should fire callback when annyang aborts', () => { annyang.start(); expect(spyOnEnd).toHaveBeenCalledTimes(0); annyang.abort(); expect(spyOnEnd).toHaveBeenCalledTimes(1); }); it('should not fire callback when annyang enters paused state', () => { annyang.start(); annyang.pause(); expect(spyOnEnd).toHaveBeenCalledTimes(0); }); it('should trigger when SpeechRecognition is directly aborted', () => { annyang.start(); annyang.getSpeechRecognizer().abort(); expect(spyOnEnd).toHaveBeenCalledTimes(1); }); }); describe('soundstart', () => { let spyOnSoundStart!: MockInstance; beforeEach(() => { spyOnSoundStart = vi.fn(); annyang.addCallback('soundstart', spyOnSoundStart); }); it('should fire callback when annyang detects sound', () => { expect(spyOnSoundStart).toHaveBeenCalledTimes(0); // Corti which is used to mock SpeechRecognition fires the soundstart event as soon as it starts annyang.start(); expect(spyOnSoundStart).toHaveBeenCalledTimes(1); }); it('should fire callback once when in continuous mode even when multiples phrases are said', () => { // Corti which is used to mock SpeechRecognition fires the soundstart event as soon as it starts annyang.start({ continuous: true }); const recognition = annyang.getSpeechRecognizer() as CortiSpeechRecognition; expect(spyOnSoundStart).toHaveBeenCalledTimes(1); recognition.say('Time for some thrilling heroics'); expect(spyOnSoundStart).toHaveBeenCalledTimes(1); recognition.say('That sounds like something out of science fiction'); expect(spyOnSoundStart).toHaveBeenCalledTimes(1); }); it('should fire callback multiple times in non-continuous mode with autorestart', () => { annyang.start({ continuous: false, autoRestart: true }); const recognition = annyang.getSpeechRecognizer() as CortiSpeechRecognition; recognition.say('Time for some thrilling heroics'); expect(spyOnSoundStart).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(1000); recognition.say('That sounds like something out of science fiction'); expect(spyOnSoundStart).toHaveBeenCalledTimes(2); }); }); describe('result', () => { let spyOnResult!: MockInstance; let recognition; beforeEach(() => { spyOnResult = vi.fn(); annyang.addCallback('result', spyOnResult); annyang.addCommands({ 'Time for some thrilling heroics': () => {}, }); annyang.start(); recognition = annyang.getSpeechRecognizer() as CortiSpeechRecognition; }); it('should fire callback when a result is returned from Speech Recognition and a command was matched', () => { expect(spyOnResult).not.toHaveBeenCalled(); recognition.say('Time for some thrilling heroics'); expect(spyOnResult).toHaveBeenCalledTimes(1); }); it('should fire callback when a result is returned from Speech Recognition and a command was not matched', () => { expect(spyOnResult).not.toHaveBeenCalled(); recognition.say('That sounds like something out of science fiction'); expect(spyOnResult).toHaveBeenCalledTimes(1); }); it('should call the callback with the first argument containing an array of all possible Speech Recognition Alternatives the user may have said', () => { expect(spyOnResult).not.toHaveBeenCalled(); recognition.say('That sounds like something out of science fiction'); expect(spyOnResult).toHaveBeenCalledTimes(1); expect(spyOnResult).toHaveBeenCalledWith([ 'That sounds like something out of science fiction', 'That sounds like something out of science fiction and so on', 'That sounds like something out of science fiction and so on and so forth', 'That sounds like something out of science fiction and so on and so forth and so on', 'That sounds like something out of science fiction and so on and so forth and so on and so forth', ]); }); }); describe('resultMatch', () => { let spyOnResultMatch!: MockInstance; let recognition; beforeEach(() => { spyOnResultMatch = vi.fn(); annyang.addCallback('resultMatch', spyOnResultMatch); annyang.addCommands({ 'Time for some (thrilling) heroics': () => {}, }); annyang.start(); recognition = annyang.getSpeechRecognizer() as CortiSpeechRecognition; }); it('should fire callback when a result is returned from Speech Recognition and a command was matched', () => { expect(spyOnResultMatch).not.toHaveBeenCalled(); recognition.say('Time for some thrilling heroics'); expect(spyOnResultMatch).toHaveBeenCalledTimes(1); }); it('should not fire callback when a result is returned from Speech Recognition and a command was not matched', () => { expect(spyOnResultMatch).not.toHaveBeenCalled(); recognition.say('That sounds like something out of science fiction'); expect(spyOnResultMatch).not.toHaveBeenCalled(); }); it('should call the callback with the first argument containing the phrase the user said that matched a command', () => { expect(spyOnResultMatch).not.toHaveBeenCalled(); recognition.say('Time for some heroics'); expect(spyOnResultMatch).toHaveBeenCalledTimes(1); expect(spyOnResultMatch).toHaveBeenCalledWith('Time for some heroics', expect.anything(), expect.anything()); }); it('should call the callback with the second argument containing the name of the matched command', () => { expect(spyOnResultMatch).not.toHaveBeenCalled(); recognition.say('Time for some heroics'); expect(spyOnResultMatch).toHaveBeenCalledTimes(1); expect(spyOnResultMatch).toHaveBeenCalledWith( expect.anything(), 'Time for some (thrilling) heroics', expect.anything() ); }); it('should call the callback with the third argument containing an array of all possible Speech Recognition Alternatives the user may have said', () => { expect(spyOnResultMatch).not.toHaveBeenCalled(); recognition.say('Time for some heroics'); expect(spyOnResultMatch).toHaveBeenCalledTimes(1); expect(spyOnResultMatch).toHaveBeenCalledWith(expect.anything(), expect.anything(), [ 'Time for some heroics', 'Time for some heroics and so on', 'Time for some heroics and so on and so forth', 'Time for some heroics and so on and so forth and so on', 'Time for some heroics and so on and so forth and so on and so forth', ]); }); }); describe('resultNoMatch', () => { let spyOnResultNoMatch!: MockInstance; let recognition; beforeEach(() => { spyOnResultNoMatch = vi.fn(); annyang.addCallback('resultNoMatch', spyOnResultNoMatch); annyang.addCommands({ 'Time for some (thrilling) heroics': () => {}, }); annyang.start(); recognition = annyang.getSpeechRecognizer() as CortiSpeechRecognition; }); it('should not fire callback when a result is returned from Speech Recognition and a command was matched', () => { expect(spyOnResultNoMatch).not.toHaveBeenCalled(); recognition.say('Time for some thrilling heroics'); expect(spyOnResultNoMatch).not.toHaveBeenCalled(); }); it('should fire callback when a result is returned from Speech Recognition and a command was not matched', () => { expect(spyOnResultNoMatch).not.toHaveBeenCalled(); recognition.say('That sounds like something out of science fiction'); expect(spyOnResultNoMatch).toHaveBeenCalledTimes(1); }); it('should call the callback with the first argument containing an array of all possible Speech Recognition Alternatives the user may have said', () => { expect(spyOnResultNoMatch).not.toHaveBeenCalled(); recognition.say('That sounds like something out of science fiction'); expect(spyOnResultNoMatch).toHaveBeenCalledTimes(1); expect(spyOnResultNoMatch).toHaveBeenCalledWith([ 'That sounds like something out of science fiction', 'That sounds like something out of science fiction and so on', 'That sounds like something out of science fiction and so on and so forth', 'That sounds like something out of science fiction and so on and so forth and so on', 'That sounds like something out of science fiction and so on and so forth and so on and so forth', ]); }); }); // describe('error', () => {}); // describe('errorNetwork', () => {}); // describe('errorPermissionBlocked', () => {}); // describe('errorPermissionDenied', () => {}); }); describe('result matching', () => { let spyOnMatch1!: MockInstance; let spyOnMatch2!: MockInstance; let spyOnMatch3!: MockInstance; let spyOnMatch4!: MockInstance; let spyOnMatch5!: MockInstance; let recognition; beforeEach(() => { spyOnMatch1 = vi.fn(); spyOnMatch2 = vi.fn(); spyOnMatch3 = vi.fn(); spyOnMatch4 = vi.fn(); spyOnMatch5 = vi.fn(); annyang.addCommands({ 'Time for some (thrilling) heroics': spyOnMatch1, 'That sounds like something out of science fiction and so on and so forth': spyOnMatch2, "You can't take the :thing from me": spyOnMatch3, 'We should start dealing in those *merchandise': spyOnMatch4, }); annyang.start({ continuous: true }); recognition = annyang.getSpeechRecognizer(); }); it('should match when phrase matches exactly', () => { expect(spyOnMatch1).not.toHaveBeenCalled(); recognition.say('Time for some heroics'); expect(spyOnMatch1).toHaveBeenCalledTimes(1); }); it('should match commands with a named variable as the last word in the sentence', () => { annyang.removeCommands(); annyang.addCommands({ "You can't take the sky from :whom": spyOnMatch5, }); recognition.say("You can't take the sky from me"); expect(spyOnMatch5).toHaveBeenCalledTimes(1); }); it('should match commands with a named variable in the middle of the sentence', () => { annyang.removeCommands(); annyang.addCommands({ "You can't take the :thing from me": spyOnMatch5, }); recognition.say("You can't take the sky from me"); expect(spyOnMatch5).toHaveBeenCalledTimes(1); }); it('should not match commands with more than one word in the position of a named variable', () => { recognition.say("You can't take the sky from me"); expect(spyOnMatch3).toHaveBeenCalledTimes(1); recognition.say("You can't take the stuff from me"); expect(spyOnMatch3).toHaveBeenCalledTimes(2); recognition.say("You can't take the sky and stuff from me"); expect(spyOnMatch3).toHaveBeenCalledTimes(2); }); it('should not match commands with nothing in the position of a named variable', () => { recognition.say("You can't take the sky from me"); expect(spyOnMatch3).toHaveBeenCalledTimes(1); recognition.say("You can't take the stuff from me"); expect(spyOnMatch3).toHaveBeenCalledTimes(2); recognition.say("You can't take the from me"); expect(spyOnMatch3).toHaveBeenCalledTimes(2); }); it('should pass named variables to the callback function', () => { recognition.say("You can't take the sky from me"); expect(spyOnMatch3).toHaveBeenLastCalledWith('sky'); recognition.say("You can't take the stuff from me"); expect(spyOnMatch3).toHaveBeenLastCalledWith('stuff'); }); it('should match commands with one or more words matched by splats', () => { recognition.say('We should start dealing in those beagles'); expect(spyOnMatch4).toHaveBeenCalledTimes(1); recognition.say('We should start dealing in those black-market beagles'); expect(spyOnMatch4).toHaveBeenCalledTimes(2); }); it('should match commands with nothing matched by splats', () => { recognition.say('We should start dealing in those'); expect(spyOnMatch4).toHaveBeenCalledTimes(1); }); it('should pass what was captured by splats to the callback function', () => { recognition.say('We should start dealing in those black-market beagles'); expect(spyOnMatch4).toHaveBeenCalledTimes(1); expect(spyOnMatch4).toHaveBeenCalledWith('black-market beagles'); }); it('should match commands with optional words when the word appears in the sentence', () => { recognition.say('Time for some thrilling heroics'); expect(spyOnMatch1).toHaveBeenCalledTimes(1); }); it('should match commands with optional words when the word does not appear in the sentence', () => { recognition.say('Time for some heroics'); expect(spyOnMatch1).toHaveBeenCalledTimes(1); }); it('should not match commands with optional words when a different word is in the sentence', () => { recognition.say('Time for some gorram heroics'); expect(spyOnMatch1).not.toHaveBeenCalled(); }); it('should not break when a command is removed by another command being called', () => { const spyMal: MockInstance = vi.fn(() => { annyang.removeCommands(); }); const spyWash: MockInstance = vi.fn(() => { annyang.removeCommands('Mal'); }); const commands = { Mal: spyMal, Wash: spyWash, }; annyang.removeCommands(); annyang.addCommands(commands); expect(() => { recognition.say('Mal'); }).not.toThrowError(); annyang.addCommands(commands, true); expect(() => { recognition.say('Wash'); }).not.toThrowError(); expect(spyMal).toHaveBeenCalledTimes(1); expect(spyWash).toHaveBeenCalledTimes(1); }); it('should not break when a command is added by another command being called', () => { const spyZoe: MockInstance = vi.fn(); const spyMal: MockInstance = vi.fn(() => { annyang.addCommands({ Zoe: spyZoe }); }); const commands = { Mal: spyMal, }; annyang.addCommands(commands, true); expect(() => { recognition.say('Mal'); }).not.toThrowError(); expect(() => { recognition.say('Zoe'); }).not.toThrowError(); expect(spyMal).toHaveBeenCalledTimes(1); expect(spyZoe).toHaveBeenCalledTimes(1); }); it('should match a commands even if the matched phrase is not the first SpeechRecognitionAlternative', () => { expect(spyOnMatch2).not.toHaveBeenCalled(); // Our SpeechRecognition mock will create SpeechRecognitionAlternatives that append "and so on and so forth" to the phrase said recognition.say('That sounds like something out of science fiction'); expect(spyOnMatch2).toHaveBeenCalledTimes(1); }); it('should overwrite previously defined commands in subsequent addCommands calls if the command phrase is already registered', () => { annyang.addCommands({ 'Time for some (thrilling) heroics': spyOnMatch5, }); recognition.say('Time for some thrilling heroics'); expect(spyOnMatch1).not.toHaveBeenCalled(); expect(spyOnMatch5).toHaveBeenCalledTimes(1); }); it('should not accept callbacks passed as string names (v3 breaking change)', () => { annyang.removeCommands(); annyang.debug(); annyang.addCommands({ // @ts-expect-error testing removed feature "You can't take the sky from me": 'spyOnMatch1', }); recognition.say("You can't take the sky from me"); expect(spyOnMatch1).not.toHaveBeenCalled(); }); it('should match commands passed as a command name and an object which consists of a regular expression and a callback', () => { annyang.removeCommands(); annyang.addCommands({ 'It is time': { regexp: /\w* for some thrilling.*/, callback: spyOnMatch5, }, }); recognition.say('Time for some thrilling heroics'); expect(spyOnMatch5).toHaveBeenCalledTimes(1); recognition.say('I feel the need for some thrilling heroics'); expect(spyOnMatch5).toHaveBeenCalledTimes(2); }); it('should pass variables from regular expression capturing groups to the callback function', () => { annyang.removeCommands(); annyang.addCommands({ 'It is time': { regexp: /Time for some (\w*) (\w*)/, callback: spyOnMatch5, }, }); recognition.say('Time for some thrilling heroics'); expect(spyOnMatch5).toHaveBeenCalledTimes(1); expect(spyOnMatch5).toHaveBeenCalledWith('thrilling', 'heroics'); }); describe('debug messages', () => { it('should write to console when a command matches if debug is on', () => { expect(logSpy).toHaveBeenCalledTimes(0); annyang.debug(true); recognition.say('Time for some thrilling heroics'); // 5 alternatives logged + 1 command matched = 6 expect(logSpy).toHaveBeenCalledTimes(6); expect(logSpy).toHaveBeenLastCalledWith( 'command matched: %cTime for some (thrilling) heroics', logFormatString ); }); it('should write to console with argument matched when command with an argument matches if debug is on', () => { expect(logSpy).toHaveBeenCalledTimes(0); annyang.debug(true); recognition.say("You can't take the sky from me"); // 5 alternatives logged + 1 command matched + 1 parameters = 7 expect(logSpy).toHaveBeenCalledTimes(7); expect(logSpy).toHaveBeenLastCalledWith('with parameters', ['sky']); }); it('should not write to console when a command matches if debug is off', () => { expect(logSpy).toHaveBeenCalledTimes(0); annyang.debug(false); recognition.say('Time for some thrilling heroics'); expect(logSpy).not.toHaveBeenCalled(); }); it('should write to console each speech recognition alternative that is recognized when a command matches', () => { expect(logSpy).toHaveBeenCalledTimes(0); annyang.debug(true); recognition.say('Time for some thrilling heroics'); expect(logSpy).toHaveBeenNthCalledWith( 1, 'Speech recognized: %cTime for some thrilling heroics', logFormatString ); expect(logSpy).toHaveBeenNthCalledWith( 2, 'Speech recognized: %cTime for some thrilling heroics and so on', logFormatString ); expect(logSpy).toHaveBeenNthCalledWith( 3, 'Speech recognized: %cTime for some thrilling heroics and so on and so forth', logFormatString ); expect(logSpy).toHaveBeenNthCalledWith( 4, 'Speech recognized: %cTime for some thrilling heroics and so on and so forth and so on', logFormatString ); expect(logSpy).toHaveBeenNthCalledWith( 5, 'Speech recognized: %cTime for some thrilling heroics and so on and so forth and so on and so forth', logFormatString ); }); it('should write to console each speech recognition alternative that is recognized when no command matches', () => { expect(logSpy).toHaveBeenCalledTimes(0); annyang.debug(true); recognition.say("Let's do some thrilling heroics"); expect(logSpy).toHaveBeenNthCalledWith( 1, "Speech recognized: %cLet's do some thrilling heroics", logFormatString ); expect(logSpy).toHaveBeenNthCalledWith( 2, "Speech recognized: %cLet's do some thrilling heroics and so on", logFormatString ); expect(logSpy).toHaveBeenNthCalledWith( 3, "Speech recognized: %cLet's do some thrilling heroics and so on and so forth", logFormatString ); expect(logSpy).toHaveBeenNthCalledWith( 4, "Speech recognized: %cLet's do some thrilling heroics and so on and so forth and so on", logFormatString ); expect(logSpy).toHaveBeenNthCalledWith( 5, "Speech recognized: %cLet's do some thrilling heroics and so on and so forth and so on and so forth", logFormatString ); }); }); }); describe('getState()', () => { it('should return "idle" when annyang has not been started', () => { expect(annyang.getState()).toBe('idle'); }); it('should return "listening" when annyang is started and not paused', () => { annyang.start(); expect(annyang.getState()).toBe('listening'); }); it('should return "paused" when annyang is paused', () => { annyang.start(); annyang.pause(); expect(annyang.getState()).toBe('paused'); }); it('should return "idle" after annyang is aborted', () => { annyang.start(); annyang.abort(); expect(annyang.getState()).toBe('idle'); }); }); describe('addCallback() unsubscribe', () => { it('should not affect other callbacks when one is unsubscribed', () => { const spy1: MockInstance = vi.fn(); const spy2: MockInstance = vi.fn(); const unsub1 = annyang.addCallback('start', spy1); annyang.addCallback('start', spy2); unsub1(); annyang.start(); expect(spy1).not.toHaveBeenCalled(); expect(spy2).toHaveBeenCalledTimes(1); }); }); describe('duplicate addCommands()', () => { it('should overwrite the callback when the same command phrase is added again', () => { const spy1: MockInstance = vi.fn(); const spy2: MockInstance = vi.fn(); const recognition = annyang.getSpeechRecognizer() as CortiSpeechRecognition; annyang.addCommands({ hello: spy1 }); annyang.addCommands({ hello: spy2 }); annyang.start(); recognition.say('hello'); expect(spy1).not.toHaveBeenCalled(); expect(spy2).toHaveBeenCalledTimes(1); }); }); }); ================================================ FILE: test/specs/issues.test.ts ================================================ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as annyang from '../../src/annyang.ts'; describe('Issues', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); describe('#193 - Speech recognition aborting while annyang is paused', () => { it('should not unpause annyang on restart', () => { annyang.start({ autoRestart: true, continuous: false }); annyang.pause(); annyang.getSpeechRecognizer().abort(); expect(annyang.isListening()).toBe(false); vi.advanceTimersByTime(2000); expect(annyang.isListening()).toBe(false); }); }); }); ================================================ FILE: test/specs/no-speech-support.test.ts ================================================ /** * Tests for environments where SpeechRecognition is NOT available. * This file must NOT use the Corti setup file. * * Configured via vitest workspace project "unsupported" in vitest.config.js. */ import { afterEach, describe, expect, it, vi } from 'vitest'; import * as annyang from '../../src/annyang.ts'; import annyangDefault from '../../src/annyang.ts'; import { isSpeechRecognitionSupported } from '../../src/annyang.ts'; describe('When SpeechRecognition is not supported', () => { it('globalThis.SpeechRecognition should be undefined', () => { expect(globalThis.SpeechRecognition).toBeUndefined(); expect(globalThis.webkitSpeechRecognition).toBeUndefined(); }); it('isSpeechRecognitionSupported() should return false (named export)', () => { expect(isSpeechRecognitionSupported()).toBe(false); }); it('isSpeechRecognitionSupported() should return false (namespace import)', () => { expect(annyang.isSpeechRecognitionSupported()).toBe(false); }); it('isSpeechRecognitionSupported() should return false (default export)', () => { expect(annyangDefault.isSpeechRecognitionSupported()).toBe(false); }); it('annyang object should still be defined', () => { expect(annyang).toBeDefined(); expect(annyangDefault).toBeDefined(); }); it('annyang methods should still be accessible', () => { expect(annyang.addCommands).toBeInstanceOf(Function); expect(annyang.start).toBeInstanceOf(Function); expect(annyang.abort).toBeInstanceOf(Function); expect(annyang.pause).toBeInstanceOf(Function); expect(annyang.resume).toBeInstanceOf(Function); }); describe('Methods should not throw', () => { afterEach(() => { annyang.abort(); annyang.removeCommands(); annyang.removeCallback(); }); it('addCommands() should not throw', () => { expect(() => annyang.addCommands({ 'test command': () => {} })).not.toThrow(); }); it('start() should not throw', () => { expect(() => annyang.start()).not.toThrow(); }); it('setLanguage() should not throw', () => { expect(() => annyang.setLanguage('en-US')).not.toThrow(); }); }); describe('State should reflect no speech engine', () => { afterEach(() => { annyang.abort(); annyang.removeCommands(); annyang.removeCallback(); }); it('isListening() should return false after start()', () => { annyang.start(); expect(annyang.isListening()).toBe(false); }); it('state should be idle after start()', () => { annyang.start(); expect(annyangDefault.state).toBe('idle'); }); }); describe('trigger() should work without speech recognition', () => { afterEach(() => { annyang.abort(); annyang.removeCommands(); annyang.removeCallback(); }); it('should fire a matching command callback', () => { const spy = vi.fn(); annyang.addCommands({ 'test command': spy }); annyang.trigger('test command'); expect(spy).toHaveBeenCalled(); }); it('should fire the result callback', () => { const spy = vi.fn(); annyang.addCallback('result', spy); annyang.trigger('anything'); expect(spy).toHaveBeenCalled(); }); it('should fire the resultMatch callback on match', () => { const spy = vi.fn(); annyang.addCommands({ 'test command': () => {} }); annyang.addCallback('resultMatch', spy); annyang.trigger('test command'); expect(spy).toHaveBeenCalled(); }); it('should fire the resultNoMatch callback on no match', () => { const spy = vi.fn(); annyang.addCommands({ 'test command': () => {} }); annyang.addCallback('resultNoMatch', spy); annyang.trigger('something else'); expect(spy).toHaveBeenCalled(); }); }); }); ================================================ FILE: test-manual/cjs-app.js ================================================ const annyang = require('annyang'); const log = msg => { document.getElementById('log').textContent += msg + '\n'; console.log(msg); }; annyang.addCommands({ hello: () => log('Command matched: hello'), }); annyang.debug(true); annyang.start(); log('✓ annyang loaded via CJS require + bundler — say "hello"'); ================================================ FILE: test-manual/cjs.html ================================================ annyang CJS test

annyang — CJS require, bundled with esbuild


    
  



================================================
FILE: test-manual/esm-app.js
================================================
import annyang from 'annyang';

const log = msg => {
  document.getElementById('log').textContent += msg + '\n';
  console.log(msg);
};

annyang.addCommands({
  hello: () => log('Command matched: hello'),
});
annyang.debug(true);
annyang.start();
log('✓ annyang loaded via ESM import + bundler — say "hello"');


================================================
FILE: test-manual/esm.html
================================================


  
    annyang ESM test
  
  
    

annyang — ESM import, bundled with esbuild


    
  



================================================
FILE: test-manual/iife.html
================================================


  
    annyang IIFE test
  
  
    

annyang — IIFE (script tag)


    
    
  



================================================
FILE: test-manual/index.html
================================================


  
    annyang manual tests
  
  
    

annyang manual tests

Open devtools console, then click a test. Say "hello" to trigger the command.

================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "declaration": true, "declarationMap": true, "sourceMap": true, "outDir": "dist", "skipLibCheck": false, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "lib": ["ES2020", "DOM"] }, "include": ["src"] } ================================================ FILE: tsup.config.ts ================================================ import { defineConfig } from 'tsup'; export default defineConfig([ { entry: ['src/annyang.ts'], format: ['esm', 'cjs'], dts: true, clean: true, sourcemap: true, }, { entry: ['src/annyang.ts'], format: ['iife'], globalName: 'annyang', minify: true, outExtension: () => ({ js: '.iife.min.js' }), }, ]); ================================================ FILE: typedoc.json ================================================ { "$schema": "https://typedoc.org/schema.json", "entryPoints": ["src/annyang.ts"], "plugin": ["typedoc-plugin-markdown"], "out": "docs", "outputFileStrategy": "modules", "cleanOutputDir": false, "readme": "none", "excludeNotDocumented": true } ================================================ FILE: vitest.config.js ================================================ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { projects: [ { test: { name: 'supported', setupFiles: './test/setupTests.js', include: ['test/specs/annyang.test.ts', 'test/specs/issues.test.ts'], }, }, { test: { name: 'unsupported', include: ['test/specs/no-speech-support.test.ts'], }, }, ], }, });