Full Code of TalAter/annyang for AI

master 7d4344188c81 cached
36 files
163.2 KB
43.4k tokens
11 symbols
1 requests
Download .txt
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 <number> --repo TalAter/annyang --body "<message>"
./scripts/gh-bot issue close <number> --repo TalAter/annyang
./scripts/gh-bot issue close <number> --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 <number> --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
================================================
<!--- Provide a general summary of the issue in the Title above. -->

## Expected Behavior
<!--- If you're describing a bug, tell us what should happen. -->
<!--- If you're suggesting a change/improvement, tell us how it should work. -->

## Current Behavior
<!--- If you are describing a bug, tell us what happens instead of the expected behavior. -->
<!--- If you are suggesting a change/improvement, explain the difference from current behavior. -->

## Possible Solution
<!--- Not obligatory, but suggest a fix/reason for the bug, -->
<!--- or ideas for how to implement the addition or change. -->

## Steps to Reproduce (for bugs)
<!--- Provide a link to a live example or an unambiguous set of steps to -->
<!--- reproduce this bug. Include code to reproduce, if relevant. -->
1.
2.
3.
4.

## Context
<!--- How has this issue affected you? What are you trying to accomplish? -->
<!--- Providing context helps us come up with a solution that is most useful in the real world. -->

## Your Environment
<!--- Include as many relevant details about the environment you experienced the bug in. -->
* Version used:
* Browser name and version:
* Operating system and version (desktop or mobile):
* Link to your project:


================================================
FILE: .github/PULL_REQUEST_TEMPLATE.md
================================================
<!--- Provide a general summary of your changes in the Title above -->

## Description
<!--- Describe your changes in detail -->

## Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->

## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->

## Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] 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:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [ ] 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 `<script>` tags.
- **`getState()` method** — Returns `'idle'`, `'listening'`, or `'paused'`.
- **`state` property** (on default export) — Getter that returns the current state.
- **`addCallback` returns an unsubscribe function** — Previously returned `undefined`. Now returns a function you can call to remove that specific callback:
  ```js
  const unsub = annyang.addCallback('start', myFunc);
  unsub(); // removes myFunc from 'start' callbacks
  ```
- **`trigger()` works independently of speech recognition** — `trigger()` can now be used regardless of whether annyang is listening, paused, aborted, or even in browsers that don't support speech recognition. If you need the previous behavior, check `isListening()` before calling `trigger()` or within the triggered command's callback.
- **Safe to use in unsupported browsers** — Methods like `start()`, `addCommands()`, and `setLanguage()` no longer throw when SpeechRecognition is unavailable. Speech recognition simply won't activate, but command registration and `trigger()` still work.
- **`if (annyang)` no longer detects browser support** — Starting in v3, the annyang object is always defined. Use `annyang.isSpeechRecognitionSupported()` instead:

  ```js
  // v2
  if (annyang) {
    annyang.start();
  }

  // v3
  if (annyang.isSpeechRecognitionSupported()) {
    annyang.start();
  }
  ```

- **`init()` deprecated** — annyang initializes automatically when needed. Calling `init()` now logs a deprecation warning. Remove any calls to `init()`.
- **String-based command callbacks removed** — Passing function names as strings (e.g. `{'hello': 'myFunc'}`) is no longer supported. Pass functions directly: `{'hello': myFunc}`.
- **Duplicate command phrases now overwrite** — In v2, adding a command with the same phrase would register both callbacks. In v3, the new callback replaces the old one.

### Internal

- Switched bundler from Rollup to tsup
- Migrated source to TypeScript with strict mode
- Tests migrated to Vitest
- `parseResults` refactored to use `for...of` with early return


================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to annyang

Thank you for taking the time to get involved with annyang! :+1:

There are several ways you can help the project out:

* [Contributing code](#contributing-code)
* [Reporting Bugs](#reporting-bugs)
* [Feature Requests and Ideas](#feature-requests-and-ideas)

## How Can I Contribute?

### Contributing Code

A lot of annyang's functionality came from pull requests sent over GitHub. Here is how you can contribute too:

- [x] Fork the repository from the [annyang GitHub page](https://github.com/TalAter/annyang).
- [x] Clone a copy to your local machine with `$ git clone git@github.com:YOUR-GITHUB-USER-NAME/annyang.git`
- [x] Make sure you have *node.js* and *pnpm* installed on your machine.
- [x] Install all of annyang's development dependencies with pnpm. `$ cd annyang; pnpm install`
- [x] Run the tests to make sure everything runs smoothly: `$ pnpm test`
- [x] Add tests for your code. [See details below](#automated-testing).
- [x] Code, code, code. Changes should be done in `/src/annyang.ts`.
- [x] Run `$ pnpm test` and `$ pnpm lint` after making changes to verify that everything still works and the tests all pass.

  :bulb: A great alternative to repeatedly running tests is to run `$ pnpm test:watch` once, and leave this process running. It will continuously run all the tests every time you make a change to one of annyang's files. :+1:
- [x] Before committing your changes, the last step must always be running `$ pnpm test` and `$ pnpm lint`. This makes sure everything works.
- [x] Once you've made sure all your changes work correctly and have been committed, push your local changes back to github with `$ git push -u origin master`
- [x] Visit your fork on GitHub.com ([https://github.com/YOUR-USER-NAME/annyang](https://github.com/YOUR-USER-NAME/annyang)) and create a pull request for your changes.
- [x] Makes sure your pull request describes exactly what you changed and if it relates to an open issue references that issue (just include the issue number in the title like this: #49)

#### Important:

* Make sure to run `pnpm install`, `pnpm test`, and `pnpm lint` and make sure all tasks completed successfully before committing.
* Do not change the [API docs](https://github.com/TalAter/annyang/blob/master/docs/README.md) in `/docs/README.md` directly. This file is generated automatically, and your changes will be overwritten. Instead, update the relevant comments in `src/annyang.ts`
* Do not update the version number yourself.
* Please stick to the project's existing coding style. Coding styles don't need to have a consensus, they just need to be consistent :smile:.
* Push your changes to a topic branch in your fork of the repository. Your branch should be based on the `master` branch.
* When submitting [pull request](https://help.github.com/articles/using-pull-requests/), please elaborate as much as possible about the change, your motivation for the change, etc.

#### Build Commands

| Command | Description |
|---|---|
| `pnpm build` | Build ESM, CJS, and IIFE bundles with tsup |
| `pnpm test` | Run tests with Vitest |
| `pnpm test:watch` | Run tests in watch mode |
| `pnpm lint` | Run ESLint |
| `pnpm format` | Format code with Prettier |
| `pnpm typecheck` | Check types with TypeScript |
| `pnpm dev` | Build in watch mode |

#### Automated Testing

annyang is tested using [Vitest](https://vitest.dev/).

Please include tests for any changes you make:
* If you found a bug, please write a test that fails because of that bug, then fix the bug so that the test passes.
* If you are adding a new feature, write tests that thoroughly test every possible use of your code.
* If you are changing existing functionality, make sure to update existing tests so they pass. (This is a last resort move. Whenever possible try to maintain backward compatibility)

To simulate Speech Recognition in the testing environment, annyang uses a mock object called [Corti](https://github.com/TalAter/Corti) which mocks the browser's SpeechRecognition object. Corti also adds a number of utility functions to the SpeechRecognition object which simulate user actions (e.g. `say('Hello there')`), and allow checking the SpeechRecognition's status (e.g. `isListening() === true`).

### Reporting Bugs

Bugs are tracked as [GitHub issues](https://github.com/TalAter/annyang/issues). If you found a bug with annyang, the quickest way to get help would be to look through existing open and closed [GitHub issues](https://github.com/TalAter/annyang/issues?q=is%3Aissue). If the issue is already being discussed and hasn't been resolved yet, you can join the discussion and provide details about the problem you are having. If this is a new bug, please open a [new issue](https://github.com/TalAter/annyang/issues/new).

When you are creating a bug report, please include as many details as possible.

Explain the problem and include additional details to help maintainers reproduce the problem.

* Use a clear and descriptive title for the issue to identify the problem.
* Describe the exact steps which reproduce the problem. Share the relevant code to reproduce the issue if possible.
* Try to isolate the issue as much as possible, reducing unrelated code until you get to the minimal amount of code in which the bug still reproduces. This is the most important step to help the community solve the issue.

### Feature Requests and Ideas

We track discussions of new features, proposed changes, and other ideas as [GitHub issues](https://github.com/TalAter/annyang/issues). If you would like to discuss one of those, please first look through existing open and closed [GitHub issues](https://github.com/TalAter/annyang/issues?q=is%3Aissue) and see if there is already a discussion on this topic which you can join. If there isn't, please open a [new issue](https://github.com/TalAter/annyang/issues/new).

When discussing new ideas or proposing changes, please take the time to be as descriptive as possible about the topic at hand. Please take the time to explain the issue you are facing, or the problem you propose to solve in as much detail as possible.


================================================
FILE: LICENSE
================================================
The MIT License (MIT)

Copyright (c) 2024 Tal Ater

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


================================================
FILE: README.md
================================================
# annyang!

A tiny JavaScript Speech Recognition library that lets your users control your site with voice commands.

**annyang** has no dependencies, weighs just 2 KB, and is free to use and modify under the MIT license.

## Demo and Tutorial

[Play with some live speech recognition demos](https://www.talater.com/annyang)

## FAQ, Technical Documentation, and API Reference

- [annyang Frequently Asked Questions](https://github.com/TalAter/annyang/blob/master/docs/FAQ.md)
- [annyang API reference](https://github.com/TalAter/annyang/blob/master/docs/README.md)
- [annyang tutorial](https://www.talater.com/annyang)
- [CHANGELOG](https://github.com/TalAter/annyang/blob/master/CHANGELOG.md)

## Install

```sh
npm install annyang
```

## Hello World

It's as easy as installing annyang and defining the commands you want.

### ESM (recommended)

```js
import annyang from 'annyang';

if (annyang.isSpeechRecognitionSupported()) {
  // Let's define a command.
  const commands = {
    'hello': () => { alert('Hello world!'); },
    'search for *term': (term) => { console.log(`Searching for ${term}`); },
  };

  // Add our commands to annyang
  annyang.addCommands(commands);

  // Start listening.
  annyang.start();
}
```

### Named imports

```js
import { addCommands, start, isSpeechRecognitionSupported } from 'annyang';

if (isSpeechRecognitionSupported()) {
  addCommands({ 'hello': () => { alert('Hello world!'); } });
  start();
}
```

### CommonJS

```js
const annyang = require('annyang');
```

### Script tag (IIFE)

````html
<script src="dist/annyang.iife.min.js"></script>
<script>
if (annyang.isSpeechRecognitionSupported()) {
  // Let's define a command.
  const commands = {
    'hello': () => { alert('Hello world!'); }
  };

  // Add our commands to annyang
  annyang.addCommands(commands);

  // Start listening.
  annyang.start();
}
</script>
````

**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
================================================
<!doctype html>
<html lang="en">
  <head>
    <title>annyang! Easily add speech recognition to your site</title>
    <meta charset="utf-8" />
    <meta
      name="description"
      content="annyang is a JavaScript SpeechRecognition library that makes adding voice commands to your site super-easy. Let your users control your site with their voice."
    />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta property="og:image" content="https://www.talater.com/annyang/images/icon_speech.png" />
    <meta property="og:title" content="annyang! Easily add speech recognition to your site" />
    <meta property="og:url" content="https://www.talater.com/annyang/" />
    <meta property="og:site_name" content="annyang" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@900&family=Inter:wght@400;600&family=JetBrains+Mono:wght@400&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="css/main.css" />
    <!-- Google tag (gtag.js) -->
    <script async src="https://www.googletagmanager.com/gtag/js?id=G-3QP312P56G"></script>
    <script>
      window.dataLayer = window.dataLayer || [];
      function gtag() {
        dataLayer.push(arguments);
      }
      gtag('js', new Date());
      gtag('config', 'G-3QP312P56G');
    </script>
  </head>
  <body>
    <!-- 1. Hero -->
    <section class="hero">
      <div class="hero-glow"></div>
      <svg class="hero-waveform" width="560" height="300" viewBox="0 0 560 300" fill="none">
        <rect x="10" y="95" width="14" height="110" rx="7" fill="#FF6B35" />
        <rect x="40" y="50" width="14" height="200" rx="7" fill="#FF6B35" />
        <rect x="70" y="15" width="14" height="270" rx="7" fill="#FF6B35" />
        <rect x="100" y="55" width="14" height="190" rx="7" fill="#FF6B35" />
        <rect x="130" y="90" width="14" height="120" rx="7" fill="#FF6B35" />
        <rect x="160" y="25" width="14" height="250" rx="7" fill="#FF6B35" />
        <rect x="190" y="0" width="14" height="300" rx="7" fill="#FF6B35" />
        <rect x="220" y="40" width="14" height="220" rx="7" fill="#FF6B35" />
        <rect x="250" y="80" width="14" height="140" rx="7" fill="#FF6B35" />
        <rect x="280" y="10" width="14" height="280" rx="7" fill="#FF6B35" />
        <rect x="310" y="45" width="14" height="210" rx="7" fill="#FF6B35" />
        <rect x="340" y="100" width="14" height="100" rx="7" fill="#FF6B35" />
        <rect x="370" y="35" width="14" height="230" rx="7" fill="#FF6B35" />
        <rect x="400" y="70" width="14" height="160" rx="7" fill="#FF6B35" />
        <rect x="430" y="105" width="14" height="90" rx="7" fill="#FF6B35" />
        <rect x="460" y="60" width="14" height="180" rx="7" fill="#FF6B35" />
        <rect x="490" y="95" width="14" height="110" rx="7" fill="#FF6B35" />
        <rect x="520" y="120" width="14" height="60" rx="7" fill="#FF6B35" />
      </svg>
      <p class="section-label">Speech Recognition for the Web</p>
      <h1>annyang!</h1>
      <p class="hero-description">
        A tiny JavaScript library that adds voice commands to any project &mdash; websites, home automation,
        accessibility tools, VR, drones, and more.
      </p>
      <div class="hero-stats">
        <span>2kb</span>
        <span class="hero-stats-dot">&middot;</span>
        <span>Zero dependencies</span>
        <span class="hero-stats-dot">&middot;</span>
        <span>MIT license</span>
      </div>
    </section>

    <!-- 2. Hello -->
    <section id="section_hello" class="alt">
      <p class="em-lead">Go ahead, try it&hellip;</p>
      <p class="voice-instruction">
        <svg class="mic-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <rect x="9" y="1" width="6" height="12" rx="3" />
          <path d="M19 10v1a7 7 0 0 1-14 0v-1" />
          <line x1="12" y1="19" x2="12" y2="23" />
          <line x1="8" y1="23" x2="16" y2="23" />
        </svg>
        Say <code>"Hello!"</code>
      </p>
      <p id="hello" class="response-text">annyang!</p>
    </section>

    <!-- 3. Image Search -->
    <section id="section_image_search">
      <p class="em-lead">Let's try something more interesting&hellip;</p>
      <p class="voice-instruction">
        <svg class="mic-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <rect x="9" y="1" width="6" height="12" rx="3" />
          <path d="M19 10v1a7 7 0 0 1-14 0v-1" />
          <line x1="12" y1="19" x2="12" y2="23" />
          <line x1="8" y1="23" x2="16" y2="23" />
        </svg>
        Say <code>"Show me cute kittens!"</code>
      </p>
      <p class="voice-instruction">
        <svg class="mic-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <rect x="9" y="1" width="6" height="12" rx="3" />
          <path d="M19 10v1a7 7 0 0 1-14 0v-1" />
          <line x1="12" y1="19" x2="12" y2="23" />
          <line x1="8" y1="23" x2="16" y2="23" />
        </svg>
        Say <code>"Show me Arches National Park!"</code>
      </p>
      <p class="voice-instruction">
        <svg class="mic-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <rect x="9" y="1" width="6" height="12" rx="3" />
          <path d="M19 10v1a7 7 0 0 1-14 0v-1" />
          <line x1="12" y1="19" x2="12" y2="23" />
          <line x1="8" y1="23" x2="16" y2="23" />
        </svg>
        Now go wild. Say <code>"Show me&hellip;"</code> and make your demands!
      </p>
      <div id="flickrGallery" class="gallery"></div>
      <div id="flickrLoader"><p></p></div>
    </section>

    <!-- 4. TPS Report -->
    <section id="section_biz_use" class="alt">
      <p class="em-lead">That's cool, but in the real world it's not all kittens and hello world.</p>
      <p class="voice-instruction">
        <svg class="mic-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <rect x="9" y="1" width="6" height="12" rx="3" />
          <path d="M19 10v1a7 7 0 0 1-14 0v-1" />
          <line x1="12" y1="19" x2="12" y2="23" />
          <line x1="8" y1="23" x2="16" y2="23" />
        </svg>
        No problem, say <code>"Show TPS report"</code>
      </p>
      <img src="images/tpscover.jpg" alt="TPS Report cover" id="tpsreport" />
    </section>

    <!-- 5. Code: Basic -->
    <section id="section_code_sample_1">
      <p class="section-label">How did you do that?</p>
      <h2>Simple. Here's all the code.</h2>
      <div class="code-block">
        <pre><span class="kw">import</span> annyang <span class="kw">from</span> <span class="str">'annyang'</span><span class="op">;</span>

<span class="kw">const</span> commands <span class="op">= {</span>
  <span class="str">'hello'</span><span class="op">:</span> <span class="op">() =></span> <span class="fn">alert</span><span class="op">(</span><span class="str">'Hello!'</span><span class="op">),</span>
  <span class="str">'show tps report'</span><span class="op">:</span> <span class="op">() =></span> document<span class="op">.</span><span class="fn">getElementById</span><span class="op">(</span><span class="str">'tpsreport'</span><span class="op">).</span><span class="fn">show</span><span class="op">()</span>
<span class="op">};</span>

annyang<span class="op">.</span><span class="fn">addCommands</span><span class="op">(</span>commands<span class="op">);</span>
annyang<span class="op">.</span><span class="fn">start</span><span class="op">();</span></pre>
      </div>
    </section>

    <!-- 6. Code: Advanced -->
    <section id="section_code_sample_2" class="alt">
      <p class="section-label">What about more complicated commands?</p>
      <p class="section-description">
        annyang understands commands with <strong>named variables</strong>, <strong>splats</strong>, and
        <strong>optional words</strong>.
      </p>
      <div class="code-block">
        <pre><span class="kw">const</span> commands <span class="op">= {</span>
  <span class="cm">// Splats (*) capture multi-word text at the end of a command.</span>
  <span class="cm">// "Show me Batman and Robin" calls showFlickr('Batman and Robin')</span>
  <span class="str">'show me *query'</span><span class="op">:</span> showFlickr<span class="op">,</span>

  <span class="cm">// Named variables (:name) capture a single word anywhere.</span>
  <span class="cm">// "calculate October stats" calls calculateStats('October')</span>
  <span class="str">'calculate :month stats'</span><span class="op">:</span> calculateStats<span class="op">,</span>

  <span class="cm">// Optional words are wrapped in parentheses.</span>
  <span class="cm">// Matches both "say hello friend" and "say hello to my little friend"</span>
  <span class="str">'say hello (to my little) friend'</span><span class="op">:</span> greeting
<span class="op">};</span>

annyang<span class="op">.</span><span class="fn">addCommands</span><span class="op">(</span>commands<span class="op">);</span>
annyang<span class="op">.</span><span class="fn">start</span><span class="op">();</span></pre>
      </div>
    </section>

    <!-- 7. Footer CTA -->
    <section class="footer-cta alt">
      <svg
        class="footer-arcs"
        width="200"
        height="200"
        viewBox="0 0 200 200"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
      >
        <circle cx="100" cy="100" r="40" stroke="#F5F2ED" stroke-width="1" />
        <circle cx="100" cy="100" r="70" stroke="#F5F2ED" stroke-width="0.75" />
        <circle cx="100" cy="100" r="95" stroke="#F5F2ED" stroke-width="0.5" />
      </svg>
      <svg class="footer-wave" viewBox="0 0 1440 80" fill="none" xmlns="http://www.w3.org/2000/svg">
        <path d="M0 40 Q180 0 360 40 T720 40 T1080 40 T1440 40" stroke="#F5F2ED" stroke-width="1" fill="none" />
      </svg>
      <h2 class="footer-heading">Ready to get started?</h2>
      <p class="hero-description">Add voice commands to your site in minutes.</p>
      <div class="btn-group">
        <a href="https://github.com/TalAter/annyang" class="btn btn-primary">View on GitHub</a>
        <a href="https://github.com/TalAter/annyang/blob/master/docs/README.md" class="btn btn-secondary"
          >API Documentation</a
        >
      </div>
    </section>

    <!-- 9. Copyright -->
    <div class="copyright">
      <p>
        &copy; 2026 <a href="https://www.talater.com">Tal Ater</a> &middot; Free to use under the
        <a href="https://raw.github.com/TalAter/annyang/master/LICENSE">MIT license</a>
      </p>
      <p>
        Tal Ater retains creative control, spin-off rights and theme park approval for Mr. Banana Grabber, Baby Banana
        Grabber, and any other Banana Grabber family character that might emanate there from.
      </p>
    </div>

    <!-- Unsupported browser banner -->
    <div id="unsupported">
      <h4>It looks like your browser doesn't support speech recognition.</h4>
      <p>annyang works with all browsers, progressively enhancing those that support the SpeechRecognition standard.</p>
      <p>
        annyang commands can even be triggered manually in unsupported browsers (e.g., &ldquo;<a
          href="#"
          id="trigger-demo"
          >Show me snowboarding</a
        >&rdquo;)
      </p>
      <p>
        Please visit <a href="https://www.talater.com/annyang/">talater.com/annyang</a> in a supported browser like
        Chrome.
      </p>
    </div>

    <script type="module">
      import annyang from './annyang.js';

      const hello = () => {
        const el = document.getElementById('hello');
        el.classList.add('visible');
        el.scrollIntoView({ behavior: 'smooth', block: 'center' });
      };

      const showFlickr = tag => {
        const gallery = document.getElementById('flickrGallery');
        const loader = document.getElementById('flickrLoader');

        loader.querySelector('p').textContent = 'Searching for ' + tag;
        loader.classList.add('visible');

        const url =
          'https://api.flickr.com/services/rest/?method=flickr.photos.search' +
          '&api_key=a828a6571bb4f0ff8890f7a386d61975' +
          '&sort=interestingness-desc&per_page=6&format=json&nojsoncallback=1' +
          '&extras=url_q&media=photos' +
          '&tags=' +
          encodeURIComponent(tag);

        fetch(url)
          .then(r => r.json())
          .then(data => {
            loader.classList.remove('visible');
            const photos = data.photos.photo;
            photos.forEach(photo => {
              if (!photo.url_q || photo.server === '31337' || photo.server === '0') return;
              const div = document.createElement('div');
              div.className = 'gallery-item';
              const img = document.createElement('img');
              img.src = photo.url_q;
              img.alt = photo.title;
              img.onerror = () => div.remove();
              div.appendChild(img);
              gallery.appendChild(div);
            });
          });

        document.getElementById('section_image_search').scrollIntoView({ behavior: 'smooth' });
      };

      const showTPS = () => {
        const tps = document.getElementById('tpsreport');
        tps.classList.add('visible');
        setTimeout(() => tps.classList.remove('visible'), 3000);
      };

      const getStarted = () => {
        window.location.href = 'https://github.com/TalAter/annyang';
      };

      const commands = {
        'hello (there)': hello,
        'show me *tag': showFlickr,
        'show :type report': showTPS,
        "let's get started": getStarted,
      };

      annyang.debug();
      annyang.addCommands(commands);
      annyang.setLanguage('en');

      if (annyang.isSpeechRecognitionSupported()) {
        annyang.start();
      } else {
        document.getElementById('unsupported').classList.add('visible');
        document.getElementById('trigger-demo').addEventListener('click', e => {
          e.preventDefault();
          annyang.trigger('show me snowboarding');
        });
      }
    </script>
  </body>
</html>


================================================
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
<script>
const commands = {
  // annyang will capture anything after a splat (*) and pass it to the function.
  // For example saying "Show me Batman and Robin" will call showFlickr('Batman and Robin');
  'show me *tag': showFlickr,

  // A named variable is a one-word variable, that can fit anywhere in your command.
  // For example saying "calculate October stats" will call calculateStats('October');
  'calculate :month stats': calculateStats,

  // By defining a part of the following command as optional, annyang will respond
  // to both: "say hello to my little friend" as well as "say hello friend"
  'say hello (to my little) friend': greeting
};

const showFlickr = tag => {
  const url = 'http://api.flickr.com/services/rest/?tags='+tag;
  $.getJSON(url);
}

const calculateStats = month => {
  $('#stats').text('Statistics for '+month);
}

const greeting = () => {
  $('#greeting').text('Hello!');
}
</script>
````

### 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
<script>
const commands = {
  // annyang will capture anything after a splat (*) and pass it to the function.
  // For example saying "Show me Batman and Robin" will call showFlickr('Batman and Robin');
  'show me *tag': showFlickr,

  // A named variable is a one-word variable, that can fit anywhere in your command.
  // For example saying "calculate October stats" will call calculateStats('October');
  'calculate :month stats': calculateStats,

  // By defining a part of the following command as optional, annyang will respond
  // to both: "say hello to my little friend" as well as "say hello friend"
  'say hello (to my little) friend': greeting
};

const showFlickr = tag => {
  const url = 'http://api.flickr.com/services/rest/?tags='+tag;
  $.getJSON(url);
}

const calculateStats = month => {
  $('#stats').text('Statistics for '+month);
}

const greeting = () => {
  $('#greeting').text('Hello!');
}
</script>
````

### 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 <tal@talater.com> (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<string, { command: RegExp; callback: (...args: any[]) => void }> = new Map();
const callbacks: Map<CallbackType, StoredCallback[]> = 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 = <T extends CallbackType>(
  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
================================================
<!doctype html>
<html lang="en">
  <head>
    <title>annyang CJS test</title>
  </head>
  <body>
    <h1>annyang — CJS require, bundled with esbuild</h1>
    <pre id="log"></pre>
    <script src="dist/cjs-app.js"></script>
  </body>
</html>


================================================
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
================================================
<!doctype html>
<html lang="en">
  <head>
    <title>annyang ESM test</title>
  </head>
  <body>
    <h1>annyang — ESM import, bundled with esbuild</h1>
    <pre id="log"></pre>
    <script src="dist/esm-app.js"></script>
  </body>
</html>


================================================
FILE: test-manual/iife.html
================================================
<!doctype html>
<html lang="en">
  <head>
    <title>annyang IIFE test</title>
  </head>
  <body>
    <h1>annyang — IIFE (script tag)</h1>
    <pre id="log"></pre>
    <script src="annyang.iife.min.js"></script>
    <script>
      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 IIFE script tag — say "hello"');
    </script>
  </body>
</html>


================================================
FILE: test-manual/index.html
================================================
<!doctype html>
<html lang="en">
  <head>
    <title>annyang manual tests</title>
  </head>
  <body>
    <h1>annyang manual tests</h1>
    <p>Open devtools console, then click a test. Say "hello" to trigger the command.</p>
    <ul>
      <li><a href="iife.html">IIFE (script tag)</a></li>
      <li><a href="esm.html">ESM (import → esbuild bundle)</a></li>
      <li><a href="cjs.html">CJS (require → esbuild bundle)</a></li>
    </ul>
  </body>
</html>


================================================
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'],
        },
      },
    ],
  },
});
Download .txt
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
Download .txt
SYMBOL INDEX (11 symbols across 1 files)

FILE: src/annyang.ts
  constant MIN_RESTART_INTERVAL_MS (line 1) | const MIN_RESTART_INTERVAL_MS = 1000;
  constant RESTART_WARNING_INTERVAL (line 2) | const RESTART_WARNING_INTERVAL = 10;
  type CallbackMap (line 10) | interface CallbackMap {
  type CallbackType (line 23) | type CallbackType = keyof CallbackMap;
  type AnyFunction (line 26) | type AnyFunction = (...args: any[]) => void;
  type StoredCallback (line 28) | interface StoredCallback {
  type CommandCallback (line 234) | type CommandCallback = (...args: string[]) => void;
  type CommandsList (line 236) | interface CommandsList {
  type StartOptions (line 315) | interface StartOptions {
  type AnnyangState (line 537) | type AnnyangState = 'idle' | 'listening' | 'paused';
  method state (line 651) | get state() {
Condensed preview — 36 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (175K chars).
[
  {
    "path": ".claude/hooks/check-on-stop.sh",
    "chars": 785,
    "preview": "#!/bin/bash\nINPUT=$(cat)\n\n# Prevent infinite loops — if we already triggered a continuation, skip\nif [ \"$(echo \"$INPUT\" "
  },
  {
    "path": ".claude/settings.json",
    "chars": 468,
    "preview": "{\n  \"hooks\": {\n    \"PostToolUse\": [\n      {\n        \"matcher\": \"Write|Edit\",\n        \"hooks\": [\n          {\n            "
  },
  {
    "path": ".claude/skills/triage/SKILL.md",
    "chars": 3237,
    "preview": "---\nname: triage\ndescription: Triage and close GitHub issues on TalAter/annyang\nuser-invocable: true\nallowed-tools: Bash"
  },
  {
    "path": ".editorconfig",
    "chars": 310,
    "preview": "# EditorConfig helps developers define and maintain consistent\n# coding styles between different editors and IDEs\n# http"
  },
  {
    "path": ".gitattributes",
    "chars": 30,
    "preview": "demo/* linguist-documentation\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE.md",
    "chars": 1220,
    "preview": "<!--- Provide a general summary of the issue in the Title above. -->\n\n## Expected Behavior\n<!--- If you're describing a "
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "chars": 1355,
    "preview": "<!--- Provide a general summary of your changes in the Title above -->\n\n## Description\n<!--- Describe your changes in de"
  },
  {
    "path": ".gitignore",
    "chars": 118,
    "preview": "node_modules\ndist\nnpm-debug.log\n.idea\n.vscode\n.idx\n.DS_Store\ntest-manual/dist\ntest-manual/annyang.iife.min.js\nscripts\n"
  },
  {
    "path": ".prettierrc",
    "chars": 335,
    "preview": "{\n  \"singleQuote\": true,\n  \"trailingComma\": \"es5\",\n  \"printWidth\": 120,\n  \"tabWidth\": 2,\n  \"useTabs\": false,\n  \"semi\": t"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 2338,
    "preview": "# Changelog\n\n## 3.0.0\n\n### New Features / Breaking Changes\n\n- **TypeScript types included** — Full type definitions ship"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 6118,
    "preview": "# Contributing to annyang\n\nThank you for taking the time to get involved with annyang! :+1:\n\nThere are several ways you "
  },
  {
    "path": "LICENSE",
    "chars": 1075,
    "preview": "The MIT License (MIT)\n\nCopyright (c) 2024 Tal Ater\n\nPermission is hereby granted, free of charge, to any person obtainin"
  },
  {
    "path": "README.md",
    "chars": 3071,
    "preview": "# annyang!\n\nA tiny JavaScript Speech Recognition library that lets your users control your site with voice commands.\n\n**"
  },
  {
    "path": "demo/css/main.css",
    "chars": 9592,
    "preview": "/* ===== Design Tokens ===== */\n:root {\n  --bg: #0c0c0c;\n  --bg-alt: #151514;\n  --bg-code: #1a1917;\n  --text: #f5f2ed;\n "
  },
  {
    "path": "demo/index.html",
    "chars": 14258,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <title>annyang! Easily add speech recognition to your site</title>\n    <me"
  },
  {
    "path": "docs/FAQ.md",
    "chars": 10992,
    "preview": "# Frequently Asked Questions\n\n- [What languages are supported?](#what-languages-are-supported)\n- [Why does the browser r"
  },
  {
    "path": "docs/README.md",
    "chars": 14969,
    "preview": "# Quick Tutorial, Intro, and Demos\n\nThe quickest way to get started is to visit the [annyang homepage](https://www.talat"
  },
  {
    "path": "docs/api-footer.md",
    "chars": 1973,
    "preview": "\n# Good to Know\n\n## Commands Object\n\nannyang understands commands with `named variables`, `splats`, and `optional words`"
  },
  {
    "path": "docs/api-intro.md",
    "chars": 202,
    "preview": "# Quick Tutorial, Intro, and Demos\n\nThe quickest way to get started is to visit the [annyang homepage](https://www.talat"
  },
  {
    "path": "eslint.config.js",
    "chars": 855,
    "preview": "import tseslint from 'typescript-eslint';\nimport eslintConfigPrettier from 'eslint-config-prettier';\n\nexport default tse"
  },
  {
    "path": "package.json",
    "chars": 2507,
    "preview": "{\n  \"name\": \"annyang\",\n  \"version\": \"3.0.0\",\n  \"description\": \"A JavaScript library for adding voice commands to your si"
  },
  {
    "path": "src/annyang.ts",
    "chars": 21763,
    "preview": "const MIN_RESTART_INTERVAL_MS = 1000;\nconst RESTART_WARNING_INTERVAL = 10;\n\nlet recognition: SpeechRecognition;\nlet list"
  },
  {
    "path": "test/setupTests.js",
    "chars": 273,
    "preview": "import { vi, beforeAll, afterAll } from 'vitest';\nimport { SpeechRecognition } from 'corti';\n\nbeforeAll(() => {\n  vi.stu"
  },
  {
    "path": "test/specs/annyang.test.ts",
    "chars": 61128,
    "preview": "import { afterEach, beforeEach, describe, expect, it, test, vi, MockInstance } from 'vitest';\nimport type { CortiSpeechR"
  },
  {
    "path": "test/specs/issues.test.ts",
    "chars": 675,
    "preview": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport * as annyang from '../../src/annyang.t"
  },
  {
    "path": "test/specs/no-speech-support.test.ts",
    "chars": 3824,
    "preview": "/**\n * Tests for environments where SpeechRecognition is NOT available.\n * This file must NOT use the Corti setup file.\n"
  },
  {
    "path": "test-manual/cjs-app.js",
    "chars": 317,
    "preview": "const annyang = require('annyang');\n\nconst log = msg => {\n  document.getElementById('log').textContent += msg + '\\n';\n  "
  },
  {
    "path": "test-manual/cjs.html",
    "chars": 241,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <title>annyang CJS test</title>\n  </head>\n  <body>\n    <h1>annyang — CJS r"
  },
  {
    "path": "test-manual/esm-app.js",
    "chars": 311,
    "preview": "import annyang from 'annyang';\n\nconst log = msg => {\n  document.getElementById('log').textContent += msg + '\\n';\n  conso"
  },
  {
    "path": "test-manual/esm.html",
    "chars": 240,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <title>annyang ESM test</title>\n  </head>\n  <body>\n    <h1>annyang — ESM i"
  },
  {
    "path": "test-manual/iife.html",
    "chars": 590,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <title>annyang IIFE test</title>\n  </head>\n  <body>\n    <h1>annyang — IIFE"
  },
  {
    "path": "test-manual/index.html",
    "chars": 455,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <title>annyang manual tests</title>\n  </head>\n  <body>\n    <h1>annyang man"
  },
  {
    "path": "tsconfig.json",
    "chars": 385,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"strict\""
  },
  {
    "path": "tsup.config.ts",
    "chars": 350,
    "preview": "import { defineConfig } from 'tsup';\n\nexport default defineConfig([\n  {\n    entry: ['src/annyang.ts'],\n    format: ['esm"
  },
  {
    "path": "typedoc.json",
    "chars": 260,
    "preview": "{\n  \"$schema\": \"https://typedoc.org/schema.json\",\n  \"entryPoints\": [\"src/annyang.ts\"],\n  \"plugin\": [\"typedoc-plugin-mark"
  },
  {
    "path": "vitest.config.js",
    "chars": 454,
    "preview": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n  test: {\n    projects: [\n      {\n        t"
  }
]

About this extraction

This page contains the full source code of the TalAter/annyang GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 36 files (163.2 KB), approximately 43.4k tokens, and a symbol index with 11 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!