[
  {
    "path": ".gitignore",
    "content": "# General\n.DS_Store\n__MACOSX/\n.AppleDouble\n.LSOverride\nIcon[\n]\n\n# Thumbnails\n._*\n\n# Files that might appear in the root of a volume\n.DocumentRevisions-V100\n.fseventsd\n.Spotlight-V100\n.TemporaryItems\n.Trashes\n.VolumeIcon.icns\n.com.apple.timemachine.donotpresent\n\n# Directories potentially created on remote AFP share\n.AppleDB\n.AppleDesktop\nNetwork Trash Folder\nTemporary Items\n.apdisk\n\n\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[codz]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#   Usually these files are written by a python script from a template\n#   before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py.cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n# Pipfile.lock\n\n# UV\n#   Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n# uv.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n# poetry.lock\n# poetry.toml\n\n# pdm\n#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n#   pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.\n#   https://pdm-project.org/en/latest/usage/project/#working-with-version-control\n# pdm.lock\n# pdm.toml\n.pdm-python\n.pdm-build/\n\n# pixi\n#   Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.\n# pixi.lock\n#   Pixi creates a virtual environment in the .pixi directory, just like venv module creates one\n#   in the .venv directory. It is recommended not to include this directory in version control.\n.pixi\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# Redis\n*.rdb\n*.aof\n*.pid\n\n# RabbitMQ\nmnesia/\nrabbitmq/\nrabbitmq-data/\n\n# ActiveMQ\nactivemq-data/\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.envrc\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n#   JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n#   be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n#   and can be added to the global gitignore or merged into this file.  For a more nuclear\n#   option (not recommended) you can uncomment the following to ignore the entire idea folder.\n# .idea/\n\n# Abstra\n#   Abstra is an AI-powered process automation framework.\n#   Ignore directories containing user credentials, local state, and settings.\n#   Learn more at https://abstra.io/docs\n.abstra/\n\n# Visual Studio Code\n#   Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore \n#   that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore\n#   and can be added to the global gitignore or merged into this file. However, if you prefer, \n#   you could uncomment the following to ignore the entire vscode folder\n# .vscode/\n\n# Ruff stuff:\n.ruff_cache/\n\n# PyPI configuration file\n.pypirc\n\n# Marimo\nmarimo/_static/\nmarimo/_lsp/\n__marimo__/\n\n# Streamlit\n.streamlit/secrets.toml"
  },
  {
    "path": "LICENSE",
    "content": "                             Apache License\n                       Version 2.0, January 2004\n                    http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n    Definitions.\n\n    \"License\" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.\n\n    \"Licensor\" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.\n\n    \"Legal Entity\" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, \"control\" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.\n\n    \"You\" (or \"Your\") shall mean an individual or Legal Entity exercising permissions granted by this License.\n\n    \"Source\" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.\n\n    \"Object\" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.\n\n    \"Work\" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).\n\n    \"Derivative Works\" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.\n\n    \"Contribution\" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, \"submitted\" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as \"Not a Contribution.\"\n\n    \"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.\n\n    Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.\n\n    Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.\n\n    Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:\n\n    (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a \"NOTICE\" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.\n\n    Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.\n\n    Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.\n\n    Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.\n\n    Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.\n\n    Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n  To apply the Apache License to your work, attach the following\n  boilerplate notice, with the fields enclosed by brackets \"[]\"\n  replaced with your own identifying information. (Don't include\n  the brackets!)  The text should be enclosed in the appropriate\n  comment syntax for the file format. We also recommend that a\n  file or class name and description of purpose be included on the\n  same \"printed page\" as the copyright notice for easier\n  identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at\n\n   http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License."
  },
  {
    "path": "MANIFEST.in",
    "content": "include README.md\ninclude LICENSE\ninclude requirements.txt\nrecursive-include kittentts *.py\nrecursive-include kittentts *.json\nrecursive-include kittentts *.txt\nrecursive-include kittentts *.onnx\nglobal-exclude __pycache__\nglobal-exclude *.py[co]\n"
  },
  {
    "path": "README.md",
    "content": "# Kitten TTS\n\n<p align=\"center\">\n  <img width=\"607\" height=\"255\" alt=\"Kitten TTS\" src=\"https://github.com/user-attachments/assets/f4646722-ba78-4b25-8a65-81bacee0d4f6\" />\n</p>\n\n<p align=\"center\">\n  <a href=\"https://huggingface.co/spaces/KittenML/KittenTTS-Demo\"><img src=\"https://img.shields.io/badge/Demo-Hugging%20Face%20Spaces-orange\" alt=\"Hugging Face Demo\"></a>\n  <a href=\"https://discord.com/invite/VJ86W4SURW\"><img src=\"https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white\" alt=\"Discord\"></a>\n  <a href=\"https://kittenml.com\"><img src=\"https://img.shields.io/badge/Website-kittenml.com-blue\" alt=\"Website\"></a>\n  <a href=\"LICENSE\"><img src=\"https://img.shields.io/badge/License-Apache_2.0-green.svg\" alt=\"License\"></a>\n</p>\n\n> **New:** Kitten TTS v0.8 is out -- 15M, 40M, and 80M parameter models now available.\n\nKitten TTS is an open-source, lightweight text-to-speech library built on ONNX. With models ranging from 15M to 80M parameters (25-80 MB on disk), it delivers high-quality voice synthesis on CPU without requiring a GPU.\n\n> **Status:** Developer preview -- APIs may change between releases.\n\n**Commercial support is available.** For integration assistance, custom voices, or enterprise licensing, [contact us](https://docs.google.com/forms/d/e/1FAIpQLSc49erSr7jmh3H2yeqH4oZyRRuXm0ROuQdOgWguTzx6SMdUnQ/viewform?usp=preview).\n\n## Table of Contents\n\n- [Features](#features)\n- [Available Models](#available-models)\n- [Demo](#demo)\n- [Quick Start](#quick-start)\n- [API Reference](#api-reference)\n- [System Requirements](#system-requirements)\n- [Roadmap](#roadmap)\n- [Commercial Support](#commercial-support)\n- [Community and Support](#community-and-support)\n- [License](#license)\n\n## Features\n\n- **Ultra-lightweight** -- Model sizes from 25 MB (int8) to 80 MB, suitable for edge deployment\n- **CPU-optimized** -- ONNX-based inference runs efficiently without a GPU\n- **8 built-in voices** -- Bella, Jasper, Luna, Bruno, Rosie, Hugo, Kiki, and Leo\n- **Adjustable speech speed** -- Control playback rate via the `speed` parameter\n- **Text preprocessing** -- Built-in pipeline handles numbers, currencies, units, and more\n- **24 kHz output** -- High-quality audio at a standard sample rate\n\n## Available Models\n\n| Model | Parameters | Size | Download |\n|---|---|---|---|\n| kitten-tts-mini | 80M | 80 MB | [KittenML/kitten-tts-mini-0.8](https://huggingface.co/KittenML/kitten-tts-mini-0.8) |\n| kitten-tts-micro | 40M | 41 MB | [KittenML/kitten-tts-micro-0.8](https://huggingface.co/KittenML/kitten-tts-micro-0.8) |\n| kitten-tts-nano | 15M | 56 MB | [KittenML/kitten-tts-nano-0.8](https://huggingface.co/KittenML/kitten-tts-nano-0.8-fp32) |\n| kitten-tts-nano (int8) | 15M | 25 MB | [KittenML/kitten-tts-nano-0.8-int8](https://huggingface.co/KittenML/kitten-tts-nano-0.8-int8) |\n\n> **Note:** Some users have reported issues with the `kitten-tts-nano-0.8-int8` model. If you encounter problems, please [open an issue](https://github.com/KittenML/KittenTTS/issues).\n\n## Demo\n\nhttps://github.com/user-attachments/assets/d80120f2-c751-407e-a166-068dd1dd9e8d\n\n### Try it online\n\nTry Kitten TTS directly in your browser on [Hugging Face Spaces](https://huggingface.co/spaces/KittenML/KittenTTS-Demo).\n\n## Quick Start\n\n### Prerequisites\n\n- Python 3.8 or later\n- pip\n\n### Installation\n\n```bash\npip install https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl\n```\n\n### Basic Usage\n\n```python\nfrom kittentts import KittenTTS\n\nmodel = KittenTTS(\"KittenML/kitten-tts-mini-0.8\")\naudio = model.generate(\"This high-quality TTS model runs without a GPU.\", voice=\"Jasper\")\n\nimport soundfile as sf\nsf.write(\"output.wav\", audio, 24000)\n```\n\n### Advanced Usage\n\n```python\n# Adjust speech speed (default: 1.0)\naudio = model.generate(\"Hello, world.\", voice=\"Luna\", speed=1.2)\n\n# Save directly to a file\nmodel.generate_to_file(\"Hello, world.\", \"output.wav\", voice=\"Bruno\", speed=0.9)\n\n# List available voices\nprint(model.available_voices)\n# ['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo']\n```\n\n## API Reference\n\n### `KittenTTS(model_name, cache_dir=None)`\n\nLoad a model from Hugging Face Hub.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `model_name` | `str` | `\"KittenML/kitten-tts-nano-0.8\"` | Hugging Face repository ID |\n| `cache_dir` | `str` | `None` | Local directory for caching downloaded model files |\n\n### `model.generate(text, voice, speed, clean_text)`\n\nSynthesize speech from text, returning a NumPy array of audio samples at 24 kHz.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `text` | `str` | -- | Input text to synthesize |\n| `voice` | `str` | `\"expr-voice-5-m\"` | Voice name (see available voices) |\n| `speed` | `float` | `1.0` | Speech speed multiplier |\n| `clean_text` | `bool` | `False` | Preprocess text (expand numbers, currencies, etc.) |\n\n### `model.generate_to_file(text, output_path, voice, speed, sample_rate, clean_text)`\n\nSynthesize speech and write directly to an audio file.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `text` | `str` | -- | Input text to synthesize |\n| `output_path` | `str` | -- | Path to save the audio file |\n| `voice` | `str` | `\"expr-voice-5-m\"` | Voice name |\n| `speed` | `float` | `1.0` | Speech speed multiplier |\n| `sample_rate` | `int` | `24000` | Audio sample rate in Hz |\n| `clean_text` | `bool` | `True` | Preprocess text (expand numbers, currencies, etc.) |\n\n### `model.available_voices`\n\nReturns a list of available voice names: `['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo']`\n\n## System Requirements\n\n- **Operating system:** Linux, macOS, or Windows\n- **Python:** 3.8 or later\n- **Hardware:** Runs on CPU; no GPU required\n- **Disk space:** 25-80 MB depending on model variant\n\nA virtual environment (conda, venv, or similar) is recommended to avoid dependency conflicts.\n\n## Roadmap\n\n- [ ] Release optimized inference engine\n- [ ] Release mobile SDK\n- [ ] Release higher quality TTS models\n- [ ] Release multilingual TTS\n- [ ] Release KittenASR\n- [ ] Need anything else? [Let us know](https://github.com/KittenML/KittenTTS/issues)\n\n## Commercial Support\n\nWe offer commercial support for teams integrating Kitten TTS into their products. This includes integration assistance, custom voice development, and enterprise licensing.\n\n[Contact us](https://docs.google.com/forms/d/e/1FAIpQLSc49erSr7jmh3H2yeqH4oZyRRuXm0ROuQdOgWguTzx6SMdUnQ/viewform?usp=preview) or email info@stellonlabs.com to discuss your requirements.\n\n## Community and Support\n\n- **Discord:** [Join the community](https://discord.com/invite/VJ86W4SURW)\n- **Website:** [kittenml.com](https://kittenml.com)\n- **Custom support:** [Request form](https://docs.google.com/forms/d/e/1FAIpQLSc49erSr7jmh3H2yeqH4oZyRRuXm0ROuQdOgWguTzx6SMdUnQ/viewform?usp=preview)\n- **Email:** info@stellonlabs.com\n- **Issues:** [GitHub Issues](https://github.com/KittenML/KittenTTS/issues)\n\n## License\n\nThis project is licensed under the [Apache License 2.0](LICENSE).\n"
  },
  {
    "path": "example.py",
    "content": "from kittentts import KittenTTS\n\n# it will run blazing fast on any GPU. But this example will run on CPU.\n\n# Step 1: Load the model\nm = KittenTTS(\"KittenML/kitten-tts-mini-0.8\") # 80M version (highest quality)\n# m = KittenTTS(\"KittenML/kitten-tts-micro-0.8\") # 40M version (balances speed and quality )\n# m = KittenTTS(\"KittenML/kitten-tts-nano-0.8\") # 15M version (tiny and faster )\n\n\n# Step 2: Generate the audio \n\n# this is a sample from the TinyStories dataset. \ntext =\"\"\"One day, a little girl named Lily found a needle in her room. She knew it was difficult to play with it because it was sharp. \"\"\"\n\n\n# available_voices : ['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo']\nvoice = 'Bruno'\n\n\n\naudio = m.generate(text=text, voice=voice )\n\n# Save the audio\nimport soundfile as sf\nsf.write('output.wav', audio, 24000)\nprint(f\"Audio saved to output.wav\")"
  },
  {
    "path": "kittentts/__index__.py",
    "content": "from kittentts.get_model import get_model\n\n    "
  },
  {
    "path": "kittentts/__init__.py",
    "content": "from kittentts.get_model import get_model, KittenTTS\n\n__version__ = \"0.1.0\"\n__author__ = \"KittenML\"\n__description__ = \"Ultra-lightweight text-to-speech model with just 15 million parameters\"\n\n__all__ = [\"get_model\", \"KittenTTS\"]\n"
  },
  {
    "path": "kittentts/get_model.py",
    "content": "import json\nimport os\nfrom huggingface_hub import hf_hub_download\nfrom .onnx_model import KittenTTS_1_Onnx\n\n\nclass KittenTTS:\n    \"\"\"Main KittenTTS class for text-to-speech synthesis.\"\"\"\n    \n    def __init__(self, model_name=\"KittenML/kitten-tts-nano-0.8\", cache_dir=None):\n        \"\"\"Initialize KittenTTS with a model from Hugging Face.\n        \n        Args:\n            model_name: Hugging Face repository ID or model name\n            cache_dir: Directory to cache downloaded files\n        \"\"\"\n        # Handle different model name formats\n        if \"/\" not in model_name:\n            # If just model name provided, assume it's from KittenML\n            repo_id = f\"KittenML/{model_name}\"\n        else:\n            repo_id = model_name\n            \n        self.model = download_from_huggingface(repo_id=repo_id, cache_dir=cache_dir)\n    \n    def generate(self, text, voice=\"expr-voice-5-m\", speed=1.0, clean_text=False):\n        \"\"\"Generate audio from text.\n        \n        Args:\n            text: Input text to synthesize\n            voice: Voice to use for synthesis\n            speed: Speech speed (1.0 = normal)\n            \n        Returns:\n            Audio data as numpy array\n        \"\"\"\n        print(f\"Generating audio for text: {text}\")\n        return self.model.generate(text, voice=voice, speed=speed, clean_text=clean_text)\n    \n    def generate_to_file(self, text, output_path, voice=\"expr-voice-5-m\", speed=1.0, sample_rate=24000):\n        \"\"\"Generate audio from text and save to file.\n        \n        Args:\n            text: Input text to synthesize\n            output_path: Path to save the audio file\n            voice: Voice to use for synthesis\n            speed: Speech speed (1.0 = normal)\n            sample_rate: Audio sample rate\n        \"\"\"\n        return self.model.generate_to_file(text, output_path, voice=voice, speed=speed, sample_rate=sample_rate)\n    \n    @property\n    def available_voices(self):\n        \"\"\"Get list of available voices.\"\"\"\n        return self.model.all_voice_names\n\n\ndef download_from_huggingface(repo_id=\"KittenML/kitten-tts-nano-0.1\", cache_dir=None):\n    \"\"\"Download model files from Hugging Face repository.\n    \n    Args:\n        repo_id: Hugging Face repository ID\n        cache_dir: Directory to cache downloaded files\n        \n    Returns:\n        KittenTTS_1_Onnx: Instantiated model ready for use\n    \"\"\"\n    # Download config file first\n    config_path = hf_hub_download(\n        repo_id=repo_id,\n        filename=\"config.json\",\n        cache_dir=cache_dir\n    )\n    \n    # Load config\n    with open(config_path, 'r') as f:\n        config = json.load(f)\n\n    if config.get(\"type\") not in [\"ONNX1\", \"ONNX2\"]:\n        raise ValueError(\"Unsupported model type.\")\n\n    # Download model and voices files based on config\n    model_path = hf_hub_download(\n        repo_id=repo_id,\n        filename=config[\"model_file\"],\n        cache_dir=cache_dir\n    )\n    \n    voices_path = hf_hub_download(\n        repo_id=repo_id,\n        filename=config[\"voices\"],\n        cache_dir=cache_dir\n    )\n    \n    # Instantiate and return model\n    model = KittenTTS_1_Onnx(model_path=model_path, voices_path=voices_path, speed_priors=config.get(\"speed_priors\", {}) , voice_aliases=config.get(\"voice_aliases\", {}))\n    \n    return model\n\n\ndef get_model(repo_id=\"KittenML/kitten-tts-nano-0.1\", cache_dir=None):\n    \"\"\"Get a KittenTTS model (legacy function for backward compatibility).\"\"\"\n    return KittenTTS(repo_id, cache_dir)\n"
  },
  {
    "path": "kittentts/onnx_model.py",
    "content": "from misaki import en, espeak\nimport numpy as np\nimport phonemizer\nimport soundfile as sf\nimport onnxruntime as ort\nfrom .preprocess import TextPreprocessor\n\ndef basic_english_tokenize(text):\n    \"\"\"Basic English tokenizer that splits on whitespace and punctuation.\"\"\"\n    import re\n    tokens = re.findall(r\"\\w+|[^\\w\\s]\", text)\n    return tokens\n\ndef ensure_punctuation(text):\n    \"\"\"Ensure text ends with punctuation. If not, add a comma.\"\"\"\n    text = text.strip()\n    if not text:\n        return text\n    if text[-1] not in '.!?,;:':\n        text = text + ','\n    return text\n\n\ndef chunk_text(text, max_len=400):\n    \"\"\"Split text into chunks for processing long texts.\"\"\"\n    import re\n    \n    sentences = re.split(r'[.!?]+', text)\n    chunks = []\n    \n    for sentence in sentences:\n        sentence = sentence.strip()\n        if not sentence:\n            continue\n        \n        if len(sentence) <= max_len:\n            chunks.append(ensure_punctuation(sentence))\n        else:\n            # Split long sentences by words\n            words = sentence.split()\n            temp_chunk = \"\"\n            for word in words:\n                if len(temp_chunk) + len(word) + 1 <= max_len:\n                    temp_chunk += \" \" + word if temp_chunk else word\n                else:\n                    if temp_chunk:\n                        chunks.append(ensure_punctuation(temp_chunk.strip()))\n                    temp_chunk = word\n            if temp_chunk:\n                chunks.append(ensure_punctuation(temp_chunk.strip()))\n    \n    return chunks\n\n\nclass TextCleaner:\n    def __init__(self, dummy=None):\n        _pad = \"$\"\n        _punctuation = ';:,.!?¡¿—…\"«»\"\" '\n        _letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'\n        _letters_ipa = \"ɑɐɒæɓʙβɔɕçɗɖðʤəɘɚɛɜɝɞɟʄɡɠɢʛɦɧħɥʜɨɪʝɭɬɫɮʟɱɯɰŋɳɲɴøɵɸθœɶʘɹɺɾɻʀʁɽʂʃʈʧʉʊʋⱱʌɣɤʍχʎʏʑʐʒʔʡʕʢǀǁǂǃˈˌːˑʼʴʰʱʲʷˠˤ˞↓↑→↗↘'̩'ᵻ\"\n\n        symbols = [_pad] + list(_punctuation) + list(_letters) + list(_letters_ipa)\n        \n        dicts = {}\n        for i in range(len(symbols)):\n            dicts[symbols[i]] = i\n\n        self.word_index_dictionary = dicts\n\n    def __call__(self, text):\n        indexes = []\n        for char in text:\n            try:\n                indexes.append(self.word_index_dictionary[char])\n            except KeyError:\n                pass\n        return indexes\n\n\nclass KittenTTS_1_Onnx:\n    def __init__(self, model_path=\"kitten_tts_nano_preview.onnx\", voices_path=\"voices.npz\", speed_priors={}, voice_aliases={}):\n        \"\"\"Initialize KittenTTS with model and voice data.\n        \n        Args:\n            model_path: Path to the ONNX model file\n            voices_path: Path to the voices NPZ file\n        \"\"\"\n        self.model_path = model_path\n        self.voices = np.load(voices_path) \n        self.session = ort.InferenceSession(model_path)\n        \n        self.phonemizer = phonemizer.backend.EspeakBackend(\n            language=\"en-us\", preserve_punctuation=True, with_stress=True\n        )\n        self.text_cleaner = TextCleaner()\n        self.speed_priors = speed_priors\n        \n        # Available voices\n        self.available_voices = [\n            'expr-voice-2-m', 'expr-voice-2-f', 'expr-voice-3-m', 'expr-voice-3-f', \n            'expr-voice-4-m', 'expr-voice-4-f', 'expr-voice-5-m', 'expr-voice-5-f'\n        ]\n        self.all_voice_names = ['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo']\n        self.voice_aliases = voice_aliases\n\n        self.preprocessor = TextPreprocessor(remove_punctuation=False)\n    \n    def _prepare_inputs(self, text: str, voice: str, speed: float = 1.0) -> dict:\n        \"\"\"Prepare ONNX model inputs from text and voice parameters.\"\"\"\n        if voice in self.voice_aliases:\n            voice = self.voice_aliases[voice]\n\n        if voice not in self.available_voices:\n            raise ValueError(f\"Voice '{voice}' not available. Choose from: {self.available_voices}\")\n        \n        if voice in self.speed_priors:\n            speed = speed * self.speed_priors[voice]\n        \n        # Phonemize the input text\n        phonemes_list = self.phonemizer.phonemize([text])\n        \n        # Process phonemes to get token IDs\n        phonemes = basic_english_tokenize(phonemes_list[0])\n        phonemes = ' '.join(phonemes)\n        tokens = self.text_cleaner(phonemes)\n        \n        # Add start and end tokens\n        tokens.insert(0, 0)\n        tokens.append(10)\n        tokens.append(0)\n        \n        input_ids = np.array([tokens], dtype=np.int64)\n        ref_id =  min(len(text), self.voices[voice].shape[0] - 1)\n        ref_s = self.voices[voice][ref_id:ref_id+1]\n        \n        return {\n            \"input_ids\": input_ids,\n            \"style\": ref_s,\n            \"speed\": np.array([speed], dtype=np.float32),\n        }\n    \n    def generate(self, text: str, voice: str = \"expr-voice-5-m\", speed: float = 1.0, clean_text: bool=True) -> np.ndarray:\n        out_chunks = []\n        if clean_text:\n            text = self.preprocessor(text)\n        for text_chunk in chunk_text(text):\n            out_chunks.append(self.generate_single_chunk(text_chunk, voice, speed))\n        return np.concatenate(out_chunks, axis=-1)\n\n    def generate_single_chunk(self, text: str, voice: str = \"expr-voice-5-m\", speed: float = 1.0) -> np.ndarray:\n        \"\"\"Synthesize speech from text.\n        \n        Args:\n            text: Input text to synthesize\n            voice: Voice to use for synthesis\n            speed: Speech speed (1.0 = normal)\n            \n        Returns:\n            Audio data as numpy array\n        \"\"\"\n        onnx_inputs = self._prepare_inputs(text, voice, speed)\n        \n        outputs = self.session.run(None, onnx_inputs)\n        \n        # Trim audio\n        audio = outputs[0][..., :-5000]\n\n        return audio\n    \n    def generate_to_file(self, text: str, output_path: str, voice: str = \"expr-voice-5-m\", \n                          speed: float = 1.0, sample_rate: int = 24000, clean_text: bool=True) -> None:\n        \"\"\"Synthesize speech and save to file.\n        \n        Args:\n            text: Input text to synthesize\n            output_path: Path to save the audio file\n            voice: Voice to use for synthesis\n            speed: Speech speed (1.0 = normal)\n            sample_rate: Audio sample rate\n            clean_text: If true, it will cleanup the text. Eg. replace numbers with words.\n        \"\"\"\n        audio = self.generate(text, voice, speed, clean_text=clean_text)\n        sf.write(output_path, audio, sample_rate)\n        print(f\"Audio saved to {output_path}\")\n\n"
  },
  {
    "path": "kittentts/preprocess.py",
    "content": "\"\"\"\ntext_preprocessing.py\nA comprehensive text preprocessing library for NLP pipelines.\n\"\"\"\n\nimport re\nimport unicodedata\nfrom typing import Optional\n\n\n# ─────────────────────────────────────────────\n# Number → Words conversion\n# ─────────────────────────────────────────────\n\n_ONES = [\n    \"\", \"one\", \"two\", \"three\", \"four\", \"five\", \"six\", \"seven\", \"eight\", \"nine\",\n    \"ten\", \"eleven\", \"twelve\", \"thirteen\", \"fourteen\", \"fifteen\", \"sixteen\",\n    \"seventeen\", \"eighteen\", \"nineteen\",\n]\n_TENS = [\"\", \"\", \"twenty\", \"thirty\", \"forty\", \"fifty\", \"sixty\", \"seventy\", \"eighty\", \"ninety\"]\n_SCALE = [\"\", \"thousand\", \"million\", \"billion\", \"trillion\"]\n\n_ORDINAL_EXCEPTIONS = {\n    \"one\": \"first\", \"two\": \"second\", \"three\": \"third\", \"four\": \"fourth\",\n    \"five\": \"fifth\", \"six\": \"sixth\", \"seven\": \"seventh\", \"eight\": \"eighth\",\n    \"nine\": \"ninth\", \"twelve\": \"twelfth\",\n}\n\n_CURRENCY_SYMBOLS = {\n    \"$\": \"dollar\", \"€\": \"euro\", \"£\": \"pound\", \"¥\": \"yen\",\n    \"₹\": \"rupee\", \"₩\": \"won\", \"₿\": \"bitcoin\",\n}\n\n_ROMAN = [\n    (1000, \"M\"), (900, \"CM\"), (500, \"D\"), (400, \"CD\"),\n    (100, \"C\"),  (90, \"XC\"),  (50, \"L\"),  (40, \"XL\"),\n    (10, \"X\"),   (9, \"IX\"),   (5, \"V\"),   (4, \"IV\"), (1, \"I\"),\n]\n_RE_ROMAN = re.compile(\n    r\"\\b(M{0,4})(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})\\b\"\n)\n\n\ndef _three_digits_to_words(n: int) -> str:\n    \"\"\"Convert a number 0–999 to English words.\"\"\"\n    if n == 0:\n        return \"\"\n    parts = []\n    hundreds = n // 100\n    remainder = n % 100\n    if hundreds:\n        parts.append(f\"{_ONES[hundreds]} hundred\")\n    if remainder < 20:\n        if remainder:\n            parts.append(_ONES[remainder])\n    else:\n        tens_word = _TENS[remainder // 10]\n        ones_word = _ONES[remainder % 10]\n        parts.append(f\"{tens_word}-{ones_word}\" if ones_word else tens_word)\n    return \" \".join(parts)\n\n\ndef number_to_words(n: int) -> str:\n    \"\"\"\n    Convert an integer to its English word representation.\n\n    Examples:\n        1200      → \"twelve hundred\"\n        1000      → \"one thousand\"\n        1_000_000 → \"one million\"\n        -42       → \"negative forty-two\"\n        0         → \"zero\"\n    \"\"\"\n    if not isinstance(n, int):\n        n = int(n)\n    if n == 0:\n        return \"zero\"\n    if n < 0:\n        return f\"negative {number_to_words(-n)}\"\n\n    # X00–X999 read as \"X hundred\" (e.g. 1200 → \"twelve hundred\")\n    # Exclude exact multiples of 1000 (1000 → \"one thousand\", not \"ten hundred\")\n    if 100 <= n <= 9999 and n % 100 == 0 and n % 1000 != 0:\n        hundreds = n // 100\n        if hundreds < 20:\n            return f\"{_ONES[hundreds]} hundred\"\n\n    parts = []\n    for i, scale in enumerate(_SCALE):\n        chunk = n % 1000\n        if chunk:\n            chunk_words = _three_digits_to_words(chunk)\n            parts.append(f\"{chunk_words} {scale}\".strip() if scale else chunk_words)\n        n //= 1000\n        if n == 0:\n            break\n\n    return \" \".join(reversed(parts))\n\n\ndef float_to_words(value, decimal_sep: str = \"point\") -> str:\n    \"\"\"\n    Convert a float (or numeric string) to words, reading decimal digits individually.\n    Accepts a string to preserve trailing zeros (e.g. \"1.50\" → \"one point five zero\").\n\n    Examples:\n        3.14   → \"three point one four\"\n        -0.5   → \"negative zero point five\"\n        \"3.10\" → \"three point one zero\"\n        1.007  → \"one point zero zero seven\"\n    \"\"\"\n    text = value if isinstance(value, str) else f\"{value}\"\n    negative = text.startswith(\"-\")\n    if negative:\n        text = text[1:]\n\n    if \".\" in text:\n        int_part, dec_part = text.split(\".\", 1)\n        int_words = number_to_words(int(int_part)) if int_part else \"zero\"\n        # Read each decimal digit individually; \"0\" → \"zero\"\n        digit_map = [\"zero\"] + _ONES[1:]  # index 0 → \"zero\"\n        dec_words = \" \".join(digit_map[int(d)] for d in dec_part)\n        result = f\"{int_words} {decimal_sep} {dec_words}\"\n    else:\n        result = number_to_words(int(text))\n\n    return f\"negative {result}\" if negative else result\n\n\ndef roman_to_int(s: str) -> int:\n    \"\"\"Convert a Roman numeral string to an integer.\"\"\"\n    val = {\"I\": 1, \"V\": 5, \"X\": 10, \"L\": 50,\n           \"C\": 100, \"D\": 500, \"M\": 1000}\n    result = 0\n    prev = 0\n    for ch in reversed(s.upper()):\n        curr = val[ch]\n        result += curr if curr >= prev else -curr\n        prev = curr\n    return result\n\n\n# ─────────────────────────────────────────────\n# Regex patterns\n# ─────────────────────────────────────────────\n\n_RE_URL      = re.compile(r\"https?://\\S+|www\\.\\S+\")\n_RE_EMAIL    = re.compile(r\"\\b[\\w.+-]+@[\\w-]+\\.[a-z]{2,}\\b\", re.IGNORECASE)\n_RE_HASHTAG  = re.compile(r\"#\\w+\")\n_RE_MENTION  = re.compile(r\"@\\w+\")\n_RE_HTML     = re.compile(r\"<[^>]+>\")\n_RE_PUNCT    = re.compile(r\"[^\\w\\s.,?!;:\\-\\u2014\\u2013\\u2026]\")\n_RE_SPACES   = re.compile(r\"\\s+\")\n\n# Number: do NOT match a leading minus if it is immediately preceded by a letter\n# (handles \"gpt-3\", \"gpl-3\", \"v-2\" etc.)\n_RE_NUMBER   = re.compile(r\"(?<![a-zA-Z])-?[\\d,]+(?:\\.\\d+)?\")\n\n# Ordinals: 1st, 2nd, 3rd, 4th … 21st, 101st …\n_RE_ORDINAL  = re.compile(r\"\\b(\\d+)(st|nd|rd|th)\\b\", re.IGNORECASE)\n\n# Percentages: 50%, 3.5%\n_RE_PERCENT  = re.compile(r\"(-?[\\d,]+(?:\\.\\d+)?)\\s*%\")\n\n# Currency: $100, €1,200.50, £50, $85K, $2.5M (optional scale suffix)\n_RE_CURRENCY = re.compile(r\"([$€£¥₹₩₿])\\s*([\\d,]+(?:\\.\\d+)?)\\s*([KMBT])?(?![a-zA-Z\\d])\")\n\n# Time: 3:30pm, 14:00, 3:30 AM — requires 2-digit minutes so \"3:0\" (score) doesn't match\n_RE_TIME     = re.compile(r\"\\b(\\d{1,2}):(\\d{2})(?::(\\d{2}))?\\s*(am|pm)?\\b\", re.IGNORECASE)\n\n# Ranges: 10-20, 100-200 (both sides numeric, hyphen between them)\n_RE_RANGE    = re.compile(r\"(?<!\\w)(\\d+)-(\\d+)(?!\\w)\")\n\n# Version/model names: gpt-3, gpt-3.5, v2.0, Python-3.10, GPL-3\n# Letter(s) + hyphen + digit(s) [+ more version parts]\n_RE_MODEL_VER = re.compile(r\"\\b([a-zA-Z][a-zA-Z0-9]*)-(\\d[\\d.]*)(?=[^\\d.]|$)\")\n\n# Measurement units glued to numbers: 100km, 50kg, 25°C, 5GB\n_RE_UNIT     = re.compile(r\"(\\d+(?:\\.\\d+)?)\\s*(km|kg|mg|ml|gb|mb|kb|tb|hz|khz|mhz|ghz|mph|kph|°[cCfF]|[cCfF]°|ms|ns|µs)\\b\",\n                          re.IGNORECASE)\n\n# Scale suffixes (uppercase only to avoid ambiguity): 7B, 340M, 1.5K, 2T\n# Must NOT be preceded by a letter (so 'MB' is handled by unit regex first)\n_RE_SCALE    = re.compile(r\"(?<![a-zA-Z])(\\d+(?:\\.\\d+)?)\\s*([KMBT])(?![a-zA-Z\\d])\")\n\n# Scientific notation: 1e-4, 2.5e10, 6.022E23\n_RE_SCI      = re.compile(r\"(?<![a-zA-Z\\d])(-?\\d+(?:\\.\\d+)?)[eE]([+-]?\\d+)(?![a-zA-Z\\d])\")\n\n# Fractions: 1/2, 3/4, 2/3\n_RE_FRACTION = re.compile(r\"\\b(\\d+)\\s*/\\s*(\\d+)\\b\")\n\n# Decades: 80s, 90s, 1980s, 2020s (number ending in 0 followed by 's')\n_RE_DECADE   = re.compile(r\"\\b(\\d{1,3})0s\\b\")\n\n# Leading decimal (no digit before the dot): .5, .75\n_RE_LEAD_DEC = re.compile(r\"(?<!\\d)\\.([\\d])\")\n\n\n# ─────────────────────────────────────────────\n# Expansion helpers\n# ─────────────────────────────────────────────\n\ndef _ordinal_suffix(n: int) -> str:\n    \"\"\"Return the ordinal word for n (e.g. 1 → 'first', 5 → 'fifth', 21 → 'twenty-first').\"\"\"\n    word = number_to_words(n)\n    # For hyphenated compounds like \"twenty-one\", convert only the last part\n    if \"-\" in word:\n        prefix, last = word.rsplit(\"-\", 1)\n        joiner = \"-\"\n    else:\n        parts = word.rsplit(\" \", 1)\n        prefix, last, joiner = (parts[0], parts[1], \" \") if len(parts) == 2 else (\"\", parts[0], \"\")\n\n    # Check exception table\n    for base, ordinal in _ORDINAL_EXCEPTIONS.items():\n        if last == base:\n            last_ord = ordinal\n            break\n    else:\n        # General rule\n        if last.endswith(\"t\"):\n            last_ord = last + \"h\"\n        elif last.endswith(\"e\"):\n            last_ord = last[:-1] + \"th\"\n        else:\n            last_ord = last + \"th\"\n\n    return f\"{prefix}{joiner}{last_ord}\" if prefix else last_ord\n\n\ndef expand_ordinals(text: str) -> str:\n    \"\"\"\n    Convert ordinal numbers to words.\n\n    Examples:\n        \"1st place\"  → \"first place\"\n        \"2nd floor\"  → \"second floor\"\n        \"3rd base\"   → \"third base\"\n        \"21st century\" → \"twenty-first century\"\n        \"100th day\"  → \"one hundredth day\"\n    \"\"\"\n    def _replace(m: re.Match) -> str:\n        return _ordinal_suffix(int(m.group(1)))\n    return _RE_ORDINAL.sub(_replace, text)\n\n\ndef expand_percentages(text: str) -> str:\n    \"\"\"\n    Expand percentage expressions.\n\n    Examples:\n        \"50% off\"    → \"fifty percent off\"\n        \"3.5% rate\"  → \"three point five percent rate\"\n        \"-2% change\" → \"negative two percent change\"\n    \"\"\"\n    def _replace(m: re.Match) -> str:\n        raw = m.group(1).replace(\",\", \"\")\n        if \".\" in raw:\n            return float_to_words(float(raw)) + \" percent\"\n        return number_to_words(int(raw)) + \" percent\"\n    return _RE_PERCENT.sub(_replace, text)\n\n\ndef expand_currency(text: str) -> str:\n    \"\"\"\n    Expand currency amounts, including optional scale suffixes.\n\n    Examples:\n        \"$100\"      → \"one hundred dollars\"\n        \"€1,200.50\" → \"twelve hundred euros and fifty cents\"\n        \"£9.99\"     → \"nine pounds and ninety-nine cents\"\n        \"$85K\"      → \"eighty five thousand dollars\"\n        \"$2.5M\"     → \"two point five million dollars\"\n    \"\"\"\n    _scale_map = {\"K\": \"thousand\", \"M\": \"million\", \"B\": \"billion\", \"T\": \"trillion\"}\n\n    def _replace(m: re.Match) -> str:\n        symbol = m.group(1)\n        raw = m.group(2).replace(\",\", \"\")\n        scale_suffix = m.group(3)          # e.g. \"K\", \"M\", or None\n        unit = _CURRENCY_SYMBOLS.get(symbol, \"\")\n\n        if scale_suffix:\n            # e.g. $85K → \"eighty five thousand dollars\"\n            scale_word = _scale_map[scale_suffix]\n            num = float_to_words(raw) if \".\" in raw else number_to_words(int(raw))\n            return f\"{num} {scale_word} {unit}{'s' if unit else ''}\".strip()\n\n        if \".\" in raw:\n            int_part, dec_part = raw.split(\".\", 1)\n            dec_val = int(dec_part[:2].ljust(2, \"0\"))\n            int_words = number_to_words(int(int_part))\n            result = f\"{int_words} {unit}s\" if unit else int_words\n            if dec_val:\n                cents = number_to_words(dec_val)\n                result += f\" and {cents} cent{'s' if dec_val != 1 else ''}\"\n        else:\n            val = int(raw)\n            words = number_to_words(val)\n            result = f\"{words} {unit}{'s' if val != 1 and unit else ''}\" if unit else words\n        return result\n\n    return _RE_CURRENCY.sub(_replace, text)\n\n\ndef expand_time(text: str) -> str:\n    \"\"\"\n    Expand time expressions.\n\n    Examples:\n        \"3:30pm\"  → \"three thirty pm\"\n        \"14:00\"   → \"fourteen hundred\"\n        \"9:05 AM\" → \"nine oh five am\"\n        \"12:00pm\" → \"twelve pm\"\n    \"\"\"\n    def _replace(m: re.Match) -> str:\n        h = int(m.group(1))\n        mins = int(m.group(2))\n        suffix = (\" \" + m.group(4).lower()) if m.group(4) else \"\"\n        h_words = number_to_words(h)\n        if mins == 0:\n            return f\"{h_words} hundred{suffix}\" if not m.group(4) else f\"{h_words}{suffix}\"\n        elif mins < 10:\n            return f\"{h_words} oh {number_to_words(mins)}{suffix}\"\n        else:\n            return f\"{h_words} {number_to_words(mins)}{suffix}\"\n    return _RE_TIME.sub(_replace, text)\n\n\ndef expand_ranges(text: str) -> str:\n    \"\"\"\n    Expand numeric ranges.\n\n    Examples:\n        \"10-20 items\"   → \"ten to twenty items\"\n        \"pages 100-200\" → \"pages one hundred to two hundred\"\n        \"2020-2024\"     → \"twenty twenty to twenty twenty-four\"\n    \"\"\"\n    def _replace(m: re.Match) -> str:\n        lo = number_to_words(int(m.group(1)))\n        hi = number_to_words(int(m.group(2)))\n        return f\"{lo} to {hi}\"\n    return _RE_RANGE.sub(_replace, text)\n\n\ndef expand_model_names(text: str) -> str:\n    \"\"\"\n    Normalise version/model names that use letter-hyphen-number patterns,\n    so the number is not misread as negative.\n\n    Examples:\n        \"GPT-3\"      → \"GPT 3\"\n        \"gpt-3.5\"    → \"gpt 3.5\"\n        \"GPL-3\"      → \"GPL 3\"\n        \"Python-3.10\"→ \"Python 3.10\"\n        \"v2.0\"       stays as \"v2.0\" (no hyphen — handled by number replacement)\n        \"IPv6\"       stays as \"IPv6\"\n    \"\"\"\n    return _RE_MODEL_VER.sub(lambda m: f\"{m.group(1)} {m.group(2)}\", text)\n\n\ndef expand_units(text: str) -> str:\n    \"\"\"\n    Expand common measurement units glued to numbers.\n\n    Examples:\n        \"100km\"  → \"one hundred kilometers\"\n        \"50kg\"   → \"fifty kilograms\"\n        \"25°C\"   → \"twenty-five degrees Celsius\"\n        \"5GB\"    → \"five gigabytes\"\n    \"\"\"\n    _unit_map = {\n        \"km\": \"kilometers\", \"kg\": \"kilograms\", \"mg\": \"milligrams\",\n        \"ml\": \"milliliters\", \"gb\": \"gigabytes\", \"mb\": \"megabytes\",\n        \"kb\": \"kilobytes\", \"tb\": \"terabytes\",\n        \"hz\": \"hertz\", \"khz\": \"kilohertz\", \"mhz\": \"megahertz\", \"ghz\": \"gigahertz\",\n        \"mph\": \"miles per hour\", \"kph\": \"kilometers per hour\",\n        \"ms\": \"milliseconds\", \"ns\": \"nanoseconds\", \"µs\": \"microseconds\",\n        \"°c\": \"degrees Celsius\", \"c°\": \"degrees Celsius\",\n        \"°f\": \"degrees Fahrenheit\", \"f°\": \"degrees Fahrenheit\",\n    }\n    def _replace(m: re.Match) -> str:\n        raw = m.group(1)\n        unit = m.group(2).lower()\n        expanded = _unit_map.get(unit, m.group(2))\n        num = float_to_words(float(raw)) if \".\" in raw else number_to_words(int(raw))\n        return f\"{num} {expanded}\"\n    return _RE_UNIT.sub(_replace, text)\n\n\ndef expand_roman_numerals(text: str, context_words: bool = True) -> str:\n    \"\"\"\n    Expand Roman numerals that appear as standalone tokens (optionally\n    only when preceded by a title-like word to avoid false positives).\n\n    Examples:\n        \"World War II\"     → \"World War two\"\n        \"Chapter IV\"       → \"Chapter four\"\n        \"Louis XIV\"        → \"Louis fourteen\"\n        \"mix I with V\"     → left unchanged (ambiguous single letters)\n    \"\"\"\n    _TITLE_WORDS = re.compile(\n        r\"\\b(war|chapter|part|volume|act|scene|book|section|article|\"\n        r\"king|queen|pope|louis|henry|edward|george|william|james|\"\n        r\"phase|round|level|stage|class|type|version|episode|season)\\b\",\n        re.IGNORECASE,\n    )\n\n    def _replace(m: re.Match) -> str:\n        roman = m.group(0)\n        if not roman.strip():\n            return roman\n        # Skip single ambiguous letters (I, V, X) unless context present\n        if len(roman) == 1 and roman in \"IVX\":\n            # Only expand if preceded by a title word\n            start = m.start()\n            preceding = text[max(0, start - 30): start]\n            if not _TITLE_WORDS.search(preceding):\n                return roman\n        try:\n            val = roman_to_int(roman)\n            if val == 0:\n                return roman\n            return number_to_words(val)\n        except Exception:\n            return roman\n\n    return _RE_ROMAN.sub(_replace, text)\n\n\ndef normalize_leading_decimals(text: str) -> str:\n    \"\"\"\n    Normalise bare leading-decimal floats so the number pipeline handles them.\n\n    Examples:\n        \".5 teaspoons\" → \"0.5 teaspoons\"\n        \"-.25 adjustment\" → \"-0.25 adjustment\"\n    \"\"\"\n    # Handle -.5 → -0.5 and .5 → 0.5\n    text = re.sub(r\"(?<!\\d)(-)\\.([\\d])\", r\"\\g<1>0.\\2\", text)\n    return _RE_LEAD_DEC.sub(r\"0.\\1\", text)\n\n\ndef expand_scientific_notation(text: str) -> str:\n    \"\"\"\n    Expand scientific-notation numbers to spoken form.\n\n    Examples:\n        \"1e-4\"    → \"one times ten to the negative four\"\n        \"2.5e10\"  → \"two point five times ten to the ten\"\n        \"6.022E23\"→ \"six point zero two two times ten to the twenty three\"\n    \"\"\"\n    def _replace(m: re.Match) -> str:\n        coeff_raw = m.group(1)\n        exp = int(m.group(2))\n        coeff_words = float_to_words(coeff_raw) if \".\" in coeff_raw else number_to_words(int(coeff_raw))\n        exp_words = number_to_words(abs(exp))\n        sign = \"negative \" if exp < 0 else \"\"\n        return f\"{coeff_words} times ten to the {sign}{exp_words}\"\n    return _RE_SCI.sub(_replace, text)\n\n\ndef expand_scale_suffixes(text: str) -> str:\n    \"\"\"\n    Expand standalone uppercase scale suffixes attached to numbers.\n\n    Examples:\n        \"7B parameters\" → \"seven billion parameters\"\n        \"340M model\"    → \"three hundred forty million model\"\n        \"1.5K salary\"   → \"one point five thousand salary\"\n        \"$100K budget\"  → \"$100K budget\"  (currency handled upstream)\n    \"\"\"\n    _map = {\"K\": \"thousand\", \"M\": \"million\", \"B\": \"billion\", \"T\": \"trillion\"}\n\n    def _replace(m: re.Match) -> str:\n        raw = m.group(1)\n        suffix = m.group(2)\n        scale_word = _map.get(suffix, suffix)\n        num = float_to_words(raw) if \".\" in raw else number_to_words(int(raw))\n        return f\"{num} {scale_word}\"\n\n    return _RE_SCALE.sub(_replace, text)\n\n\ndef expand_fractions(text: str) -> str:\n    \"\"\"\n    Expand simple numeric fractions.\n\n    Examples:\n        \"1/2 cup\"  → \"one half cup\"\n        \"3/4 mile\" → \"three quarters mile\"\n        \"2/3 done\" → \"two thirds done\"\n        \"5/8 inch\" → \"five eighths inch\"\n    \"\"\"\n    def _replace(m: re.Match) -> str:\n        num = int(m.group(1))\n        den = int(m.group(2))\n        if den == 0:\n            return m.group()\n        num_words = number_to_words(num)\n        if den == 2:\n            denom_word = \"half\" if num == 1 else \"halves\"\n        elif den == 4:\n            denom_word = \"quarter\" if num == 1 else \"quarters\"\n        else:\n            denom_word = _ordinal_suffix(den)\n            if num != 1:\n                denom_word += \"s\"\n        return f\"{num_words} {denom_word}\"\n\n    return _RE_FRACTION.sub(_replace, text)\n\n\ndef expand_decades(text: str) -> str:\n    \"\"\"\n    Expand decade expressions to words.\n\n    Examples:\n        \"the 80s\"    → \"the eighties\"\n        \"the 1980s\"  → \"the nineteen eighties\"\n        \"the 2020s\"  → \"the twenty twenties\"\n        \"'90s music\" → \"nineties music\"\n    \"\"\"\n    _decade_map = {\n        0: \"hundreds\", 1: \"tens\", 2: \"twenties\", 3: \"thirties\", 4: \"forties\",\n        5: \"fifties\", 6: \"sixties\", 7: \"seventies\", 8: \"eighties\", 9: \"nineties\",\n    }\n\n    def _replace(m: re.Match) -> str:\n        base = int(m.group(1))          # e.g. 8 for \"80s\", 198 for \"1980s\"\n        decade_digit = base % 10\n        decade_word = _decade_map.get(decade_digit, \"\")\n        if base < 10:\n            return decade_word\n        century_part = base // 10       # e.g. 19 for 198\n        return f\"{number_to_words(century_part)} {decade_word}\"\n\n    return _RE_DECADE.sub(_replace, text)\n\n\ndef expand_ip_addresses(text: str) -> str:\n    \"\"\"\n    Expand IPv4 addresses to spoken digits per octet.\n\n    Examples:\n        \"192.168.1.1\"  → \"one nine two dot one six eight dot one dot one\"\n        \"10.0.0.1\"     → \"one zero dot zero dot zero dot one\"\n    \"\"\"\n    _d = {\"0\": \"zero\", \"1\": \"one\", \"2\": \"two\", \"3\": \"three\", \"4\": \"four\",\n          \"5\": \"five\", \"6\": \"six\", \"7\": \"seven\", \"8\": \"eight\", \"9\": \"nine\"}\n\n    def _octet(s: str) -> str:\n        return \" \".join(_d[c] for c in s)\n\n    def _replace(m: re.Match) -> str:\n        return \" dot \".join(_octet(g) for g in m.groups())\n\n    return re.sub(r\"\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\b\", _replace, text)\n\n\ndef expand_phone_numbers(text: str) -> str:\n    \"\"\"\n    Expand US phone numbers to spoken digits before range expansion claims the hyphens.\n\n    Examples:\n        \"555-1234\"       → \"five five five one two three four\"\n        \"555-123-4567\"   → \"five five five one two three four five six seven\"\n        \"1-800-555-0199\" → \"one eight zero zero five five five zero one nine nine\"\n    \"\"\"\n    _d = {\"0\": \"zero\", \"1\": \"one\", \"2\": \"two\", \"3\": \"three\", \"4\": \"four\",\n          \"5\": \"five\", \"6\": \"six\", \"7\": \"seven\", \"8\": \"eight\", \"9\": \"nine\"}\n\n    def _digits(s: str) -> str:\n        return \" \".join(_d[c] for c in s)\n\n    def _join(*groups) -> str:\n        return \" \".join(_digits(g) for g in groups)\n\n    # Match longest pattern first to avoid partial matches\n    # 11-digit: 1-800-555-0199\n    text = re.sub(r\"(?<!\\d-)(?<!\\d)\\b(\\d{1,2})-(\\d{3})-(\\d{3})-(\\d{4})\\b(?!-\\d)\",\n                  lambda m: _join(*m.groups()), text)\n    # 10-digit: 555-123-4567\n    text = re.sub(r\"(?<!\\d-)(?<!\\d)\\b(\\d{3})-(\\d{3})-(\\d{4})\\b(?!-\\d)\",\n                  lambda m: _join(*m.groups()), text)\n    # 7-digit local: 555-1234 (not preceded or followed by digit-hyphen to avoid sub-matching)\n    text = re.sub(r\"(?<!\\d-)\\b(\\d{3})-(\\d{4})\\b(?!-\\d)\",\n                  lambda m: _join(*m.groups()), text)\n    return text\n\n\n# ─────────────────────────────────────────────\n# Core preprocessing functions\n# ─────────────────────────────────────────────\n\ndef replace_numbers(text: str, replace_floats: bool = True) -> str:\n    \"\"\"\n    Replace all numeric tokens with their word equivalents.\n\n    Examples:\n        \"There are 1200 students\" → \"There are twelve hundred students\"\n        \"Pi is 3.14\"              → \"Pi is three point one four\"\n        \"gpt-3 rocks\"             → \"gpt-3 rocks\"  (hyphen not treated as minus)\n    \"\"\"\n    def _replace(m: re.Match) -> str:\n        raw = m.group().replace(\",\", \"\")\n        try:\n            if \".\" in raw and replace_floats:\n                # Pass raw string so trailing zeros are preserved (\"1.50\" → \"one point five zero\")\n                return float_to_words(raw)\n            else:\n                return number_to_words(int(float(raw)))\n        except (ValueError, OverflowError):\n            return m.group()\n    return _RE_NUMBER.sub(_replace, text)\n\n\ndef to_lowercase(text: str) -> str:\n    \"\"\"Convert text to lowercase.\"\"\"\n    return text.lower()\n\n\ndef remove_urls(text: str, replacement: str = \"\") -> str:\n    \"\"\"Remove URLs from text.\"\"\"\n    return _RE_URL.sub(replacement, text).strip()\n\n\ndef remove_emails(text: str, replacement: str = \"\") -> str:\n    \"\"\"Remove email addresses from text.\"\"\"\n    return _RE_EMAIL.sub(replacement, text).strip()\n\n\ndef remove_html_tags(text: str) -> str:\n    \"\"\"Strip HTML tags from text.\"\"\"\n    return _RE_HTML.sub(\" \", text)\n\n\ndef remove_hashtags(text: str, replacement: str = \"\") -> str:\n    \"\"\"Remove hashtags (e.g. #NLP) from text.\"\"\"\n    return _RE_HASHTAG.sub(replacement, text)\n\n\ndef remove_mentions(text: str, replacement: str = \"\") -> str:\n    \"\"\"Remove @mentions from text.\"\"\"\n    return _RE_MENTION.sub(replacement, text)\n\n\ndef remove_punctuation(text: str) -> str:\n    \"\"\"Remove non-prosodic punctuation, keeping marks that affect speech rhythm and intonation.\"\"\"\n    return _RE_PUNCT.sub(\" \", text)\n\n\ndef remove_extra_whitespace(text: str) -> str:\n    \"\"\"Collapse multiple whitespace characters into a single space and strip ends.\"\"\"\n    return _RE_SPACES.sub(\" \", text).strip()\n\n\ndef normalize_unicode(text: str, form: str = \"NFC\") -> str:\n    \"\"\"Normalize unicode characters (NFC, NFD, NFKC, or NFKD).\"\"\"\n    return unicodedata.normalize(form, text)\n\n\ndef remove_accents(text: str) -> str:\n    \"\"\"Remove diacritical marks (accents) from characters.\"\"\"\n    nfkd = unicodedata.normalize(\"NFD\", text)\n    return \"\".join(c for c in nfkd if unicodedata.category(c) != \"Mn\")\n\n\ndef expand_contractions(text: str) -> str:\n    \"\"\"\n    Expand common English contractions.\n\n    Examples:\n        \"don't\"   → \"do not\"\n        \"they're\" → \"they are\"\n        \"I've\"    → \"I have\"\n    \"\"\"\n    contractions = {\n        r\"\\bcan't\\b\":   \"cannot\",\n        r\"\\bwon't\\b\":   \"will not\",\n        r\"\\bshan't\\b\":  \"shall not\",\n        r\"\\bain't\\b\":   \"is not\",\n        r\"\\blet's\\b\":   \"let us\",\n        r\"\\b(\\w+)n't\\b\": r\"\\1 not\",\n        r\"\\b(\\w+)'re\\b\": r\"\\1 are\",\n        r\"\\b(\\w+)'ve\\b\": r\"\\1 have\",\n        r\"\\b(\\w+)'ll\\b\": r\"\\1 will\",\n        r\"\\b(\\w+)'d\\b\":  r\"\\1 would\",\n        r\"\\b(\\w+)'m\\b\":  r\"\\1 am\",\n        r\"\\bit's\\b\":    \"it is\",\n    }\n    for pattern, replacement in contractions.items():\n        text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)\n    return text\n\n\ndef remove_stopwords(text: str, stopwords: Optional[set] = None) -> str:\n    \"\"\"\n    Remove stopwords from text.\n\n    Args:\n        stopwords: Set of words to remove. Uses a built-in English set if None.\n    \"\"\"\n    if stopwords is None:\n        stopwords = {\n            \"a\", \"an\", \"the\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\",\n            \"for\", \"of\", \"with\", \"by\", \"from\", \"is\", \"was\", \"are\", \"were\",\n            \"be\", \"been\", \"being\", \"have\", \"has\", \"had\", \"do\", \"does\", \"did\",\n            \"will\", \"would\", \"could\", \"should\", \"may\", \"might\", \"this\", \"that\",\n            \"these\", \"those\", \"it\", \"its\", \"i\", \"me\", \"my\", \"we\", \"our\",\n            \"you\", \"your\", \"he\", \"she\", \"him\", \"her\", \"they\", \"them\", \"their\",\n        }\n    tokens = text.split()\n    return \" \".join(t for t in tokens if t.lower() not in stopwords)\n\n\n# ─────────────────────────────────────────────\n# Pipeline helper\n# ─────────────────────────────────────────────\n\nclass TextPreprocessor:\n    \"\"\"\n    Configurable preprocessing pipeline.\n\n    Usage:\n        pp = TextPreprocessor(\n            lowercase=True,\n            replace_numbers=True,\n            remove_urls=True,\n            remove_html=True,\n            remove_punctuation=True,\n        )\n        clean = pp(\"GPT-3 costs $0.002 per token — 50% cheaper than before!\")\n        # → \"gpt three costs zero dollars and zero point two cents per token fifty percent cheaper than before\"\n    \"\"\"\n\n    def __init__(\n        self,\n        lowercase: bool = True,\n        replace_numbers: bool = True,\n        replace_floats: bool = True,\n        expand_contractions: bool = True,\n        expand_model_names: bool = True,\n        expand_ordinals: bool = True,\n        expand_percentages: bool = True,\n        expand_currency: bool = True,\n        expand_time: bool = True,\n        expand_ranges: bool = True,\n        expand_units: bool = True,\n        expand_scale_suffixes: bool = True,\n        expand_scientific_notation: bool = True,\n        expand_fractions: bool = True,\n        expand_decades: bool = True,\n        expand_phone_numbers: bool = True,\n        expand_ip_addresses: bool = True,\n        normalize_leading_decimals: bool = True,\n        expand_roman_numerals: bool = False,\n        remove_urls: bool = True,\n        remove_emails: bool = True,\n        remove_html: bool = True,\n        remove_hashtags: bool = False,\n        remove_mentions: bool = False,\n        remove_punctuation: bool = True,\n        remove_stopwords: bool = False,\n        stopwords: Optional[set] = None,\n        normalize_unicode: bool = True,\n        remove_accents: bool = False,\n        remove_extra_whitespace: bool = True,\n    ):\n        self.config = {k: v for k, v in locals().items() if k != \"self\"}\n        self._stopwords = stopwords\n\n    def __call__(self, text: str) -> str:\n        return self.process(text)\n\n    def process(self, text: str) -> str:\n        cfg = self.config\n\n        if cfg[\"normalize_unicode\"]:\n            text = normalize_unicode(text)\n        if cfg[\"remove_html\"]:\n            text = remove_html_tags(text)\n        if cfg[\"remove_urls\"]:\n            text = remove_urls(text)\n        if cfg[\"remove_emails\"]:\n            text = remove_emails(text)\n        if cfg[\"remove_hashtags\"]:\n            text = remove_hashtags(text)\n        if cfg[\"remove_mentions\"]:\n            text = remove_mentions(text)\n        if cfg[\"expand_contractions\"]:\n            text = expand_contractions(text)\n        # IP addresses before normalize_leading_decimals (IPs contain dots before digits)\n        if cfg[\"expand_ip_addresses\"]:\n            text = expand_ip_addresses(text)\n        # Normalise bare leading decimals early so downstream regexes see \"0.5\" not \".5\"\n        if cfg[\"normalize_leading_decimals\"]:\n            text = normalize_leading_decimals(text)\n        # Expand special forms before generic number replacement\n        if cfg[\"expand_currency\"]:\n            text = expand_currency(text)\n        if cfg[\"expand_percentages\"]:\n            text = expand_percentages(text)\n        # Scientific notation before model-name expansion (e.g. \"1e-4\" contains \"e-4\")\n        if cfg[\"expand_scientific_notation\"]:\n            text = expand_scientific_notation(text)\n        if cfg[\"expand_time\"]:\n            text = expand_time(text)\n        if cfg[\"expand_ordinals\"]:\n            text = expand_ordinals(text)\n        if cfg[\"expand_units\"]:\n            text = expand_units(text)\n        # Scale suffixes after units (units handles \"MB\"/\"GB\"; this handles bare \"B\"/\"M\")\n        if cfg[\"expand_scale_suffixes\"]:\n            text = expand_scale_suffixes(text)\n        if cfg[\"expand_fractions\"]:\n            text = expand_fractions(text)\n        if cfg[\"expand_decades\"]:\n            text = expand_decades(text)\n        # Phone numbers before ranges, otherwise NNN-NNNN is treated as a range\n        if cfg[\"expand_phone_numbers\"]:\n            text = expand_phone_numbers(text)\n        if cfg[\"expand_ranges\"]:\n            text = expand_ranges(text)\n        if cfg[\"expand_model_names\"]:\n            text = expand_model_names(text)\n        if cfg[\"expand_roman_numerals\"]:\n            text = expand_roman_numerals(text)\n        if cfg[\"replace_numbers\"]:\n            text = replace_numbers(text, replace_floats=cfg[\"replace_floats\"])\n        if cfg[\"remove_accents\"]:\n            text = remove_accents(text)\n        if cfg[\"remove_punctuation\"]:\n            text = remove_punctuation(text)\n        if cfg[\"lowercase\"]:\n            text = to_lowercase(text)\n        if cfg[\"remove_stopwords\"]:\n            text = remove_stopwords(text, self._stopwords)\n        if cfg[\"remove_extra_whitespace\"]:\n            text = remove_extra_whitespace(text)\n\n        return text\n\n\n# ─────────────────────────────────────────────\n# Quick demo\n# ─────────────────────────────────────────────\n\nif __name__ == \"__main__\":\n    pp = TextPreprocessor()\n\n    cases = [\n        # ── Numbers ────────────────────────────────────────────────────\n        (\"Plain integer\",              \"There are 1200 students and 42 teachers.\"),\n        (\"Large number\",               \"The project costs $1,000,000 and took 365 days.\"),\n        (\"Negative number\",            \"Temperature dropped to -5 degrees overnight.\"),\n        (\"Float\",                      \"Pi is approximately 3.14159.\"),\n        (\"Float trailing zero\",        \"The voltage is 1.50 volts.\"),\n        (\"Leading decimal\",            \"Add .5 teaspoons of salt and .25 cup of milk.\"),\n        (\"Negative leading decimal\",   \"A -.05 correction was applied.\"),\n        (\"Zero\",                       \"There were 0 errors and 0.0 warnings.\"),\n        (\"Comma thousands\",            \"The population is 7,900,000,000.\"),\n        # ── Scientific notation ─────────────────────────────────────────\n        (\"Scientific e-notation\",      \"Learning rate is 1e-4, weight decay 1e-5.\"),\n        (\"Scientific capital E\",       \"Avogadro's number is 6.022E23.\"),\n        (\"Scientific large exp\",       \"The signal is 2.5e10 Hz.\"),\n        # ── Scale suffixes ─────────────────────────────────────────────\n        (\"Model params B\",             \"We trained a 7B parameter model and a 13B variant.\"),\n        (\"Model params M\",             \"The 340M model beat the 7B on MMLU.\"),\n        (\"Scale suffix K\",             \"The salary was $85K per year.\"),\n        # ── Currency ───────────────────────────────────────────────────\n        (\"Dollar amount\",              \"A coffee costs $4.99 here.\"),\n        (\"Euro amount\",                \"Rent is €1,200 per month.\"),\n        (\"Pound with cents\",           \"The book is £9.99.\"),\n        # ── Percentages ────────────────────────────────────────────────\n        (\"Percentage\",                 \"Inflation rose by 3.5% last quarter.\"),\n        (\"Negative percentage\",        \"Stocks fell -2% today.\"),\n        # ── Ordinals ───────────────────────────────────────────────────\n        (\"Ordinals 1st/2nd/3rd\",       \"She finished 1st, he came 2nd, I was 3rd.\"),\n        (\"Ordinal 21st\",               \"It's the 21st century and the 100th anniversary.\"),\n        (\"Ordinal 42nd\",               \"He ran his 42nd marathon.\"),\n        (\"Ordinal 33rd\",               \"On the 33rd floor.\"),\n        # ── Fractions ──────────────────────────────────────────────────\n        (\"Half\",                       \"Cut the recipe in 1/2.\"),\n        (\"Quarters\",                   \"Add 3/4 cup of sugar and 1/4 teaspoon of salt.\"),\n        (\"Thirds\",                     \"The team completed 2/3 of the project.\"),\n        (\"Eighths\",                    \"The pipe is 5/8 inch in diameter.\"),\n        # ── Time ───────────────────────────────────────────────────────\n        (\"12-hour time\",               \"The meeting starts at 3:30pm.\"),\n        (\"24-hour time\",               \"Departure at 14:00.\"),\n        (\"Time with oh\",               \"Alarm set for 9:05 AM.\"),\n        (\"Midnight\",                   \"The server restarts at 0:00.\"),\n        # ── Decades ────────────────────────────────────────────────────\n        (\"Bare decade\",                \"The 80s music scene was iconic.\"),\n        (\"Full decade\",                \"She grew up listening to 1990s grunge.\"),\n        (\"2000s\",                      \"The 2000s brought social media.\"),\n        (\"2020s\",                      \"AI took off in the 2020s.\"),\n        (\"Apostrophe decade\",          \"Born in the '90s, raised on 2000s pop.\"),\n        # ── Ranges ─────────────────────────────────────────────────────\n        (\"Numeric range\",              \"Read pages 10-20 for homework.\"),\n        (\"Year range\",                 \"The war lasted from 2020-2024.\"),\n        (\"Temperature range\",          \"Store between 5-10 degrees.\"),\n        # ── Model / version names ───────────────────────────────────────\n        (\"GPT-3\",                      \"gpt-3 is pretty sick.\"),\n        (\"GPT-3.5\",                    \"They upgraded to GPT-3.5 last month.\"),\n        (\"GPL-3 license\",              \"This project is licensed under GPL-3.\"),\n        (\"Python version\",             \"Requires Python-3.10 or higher.\"),\n        (\"Multiple versions\",          \"Both CUDA-11 and CUDA-12 are supported.\"),\n        # ── Units ──────────────────────────────────────────────────────\n        (\"Distance\",                   \"The trail is 42km long.\"),\n        (\"Weight\",                     \"Each package weighs 500kg.\"),\n        (\"Temperature °C\",             \"Water boils at 100°C.\"),\n        (\"Data size GB\",               \"Download the 2.5GB model file.\"),\n        (\"Frequency GHz\",              \"The CPU runs at 3.6GHz.\"),\n        (\"Latency ms\",                 \"Average latency is 12ms.\"),\n        # ── HTML / URLs / emails ───────────────────────────────────────\n        (\"HTML tags\",                  \"<b>Hello</b> World! It's a great day.\"),\n        (\"URL and email\",              \"Visit https://example.com or email hello@example.com.\"),\n        (\"Hashtags and mentions\",      \"#NLP @user great post!\"),\n        # ── Contractions ───────────────────────────────────────────────\n        (\"Contractions\",               \"I don't know, won't you help? They've already left.\"),\n        (\"Ain't / let's\",              \"Ain't no mountain high enough. Let's go!\"),\n        # ── Edge / tricky cases ─────────────────────────────────────────\n        (\"Score / ratio\",              \"The final score was 3:0.\"),\n        (\"Aspect ratio\",               \"The display is 16:9.\"),\n        (\"IP address\",                 \"Connect to server at 192.168.1.1 on port 8080.\"),\n        (\"Phone number\",               \"Call us at 555-1234 or 1-800-555-0199.\"),\n        (\"Negative vs. hyphen\",        \"On a scale of -10 to 10, she rated it 8.\"),\n        (\"Ellipsis\",                   \"He paused... then spoke.\"),\n        (\"Em dash number\",             \"The result — 42 — surprised everyone.\"),\n        # ── Mixed / real-world ──────────────────────────────────────────\n        (\"Research abstract\",          \"We trained a 7B parameter model for 100 epochs at 1e-4 learning rate.\"),\n        (\"GPT benchmark\",              \"GPT-4 scored 90% on the benchmark — 15% better than GPT-3.5.\"),\n        (\"News headline\",              \"Fed raises rates by 0.25%, S&P 500 drops 1.2%.\"),\n        (\"Startup pitch\",              \"We raised $2.5M in seed funding and are growing 20% month-over-month.\"),\n        (\"Tech spec\",                  \"The M3 chip runs at 4.05GHz with a 40M transistor GPU and 8GB RAM.\"),\n    ]\n\n    print(\"=\" * 70)\n    print(\"TextPreprocessor Demo\")\n    print(\"=\" * 70)\n    for label, text in cases:\n        print(f\"\\n  [{label}]\")\n        print(f\"  IN : {text}\")\n        print(f\"  OUT: {pp(text)}\")\n\n    print(\"\\n\" + \"=\" * 70)\n    print(\"number_to_words\")\n    print(\"=\" * 70)\n    for n in [0, 1, 12, 19, 20, 99, 100, 1000, 1200, 15_000, 1_000_000, -42, 999_999_999]:\n        print(f\"  {n:>15,} → {number_to_words(n)}\")\n\n    print(\"\\n\" + \"=\" * 70)\n    print(\"float_to_words\")\n    print(\"=\" * 70)\n    for f in [3.14, -0.5, 1200.99, 3.10, 1.007, 0.001]:\n        print(f\"  {f} → {float_to_words(f)}\")\n\n    print(\"\\n\" + \"=\" * 70)\n    print(\"expand_roman_numerals  (opt-in)\")\n    print(\"=\" * 70)\n    pp_roman = TextPreprocessor(expand_roman_numerals=True)\n    for text in [\"World War II ended in 1945.\", \"Chapter IV begins here.\", \"Louis XIV was king.\"]:\n        print(f\"  IN : {text}\")\n        print(f\"  OUT: {pp_roman(text)}\")\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"setuptools>=45\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"kittentts\"\nversion = \"0.8.1\"\ndescription = \"Ultra-lightweight text-to-speech model with just 15 million parameters\"\nreadme = \"README.md\"\nrequires-python = \">=3.8\"\nlicense = {text = \"Apache 2.0\"}\nauthors = [\n    {name = \"KittenML\"}\n]\nkeywords = [\"text-to-speech\", \"tts\", \"speech-synthesis\", \"neural-networks\", \"onnx\"]\nclassifiers = [\n    \"Topic :: Multimedia :: Sound/Audio :: Speech\",\n    \"Topic :: Scientific/Engineering :: Artificial Intelligence\",\n]\ndependencies = [\n    \"num2words\",\n    \"spacy\",\n    \"espeakng_loader\",\n    \"misaki[en]>=0.9.4\",\n    \"onnxruntime\",\n    \"soundfile\",\n    \"numpy\",\n    \"huggingface_hub\",\n]\n\n[project.urls]\nHomepage = \"https://github.com/kittenml/kittentts\"\nRepository = \"https://github.com/kittenml/kittentts\"\nIssues = \"https://github.com/kittenml/kittentts/issues\"\n\n[tool.setuptools.packages.find]\nwhere = [\".\"]\ninclude = [\"kittentts*\"]\n\n[tool.setuptools.package-data]\nkittentts = [\"*.json\", \"*.txt\", \"*.onnx\"]\n"
  },
  {
    "path": "requirements.txt",
    "content": "num2words\nspacy\nespeakng_loader\nmisaki[en]>=0.9.4\nonnxruntime\nsoundfile\nnumpy\nhuggingface_hub\n"
  },
  {
    "path": "setup.py",
    "content": "from setuptools import setup, find_packages\n\nwith open(\"README.md\", \"r\", encoding=\"utf-8\") as fh:\n    long_description = fh.read()\n\nsetup(\n    name=\"kittentts\",\n    version=\"0.8.1\",\n    author=\"KittenML\",\n    author_email=\"\",\n    description=\"Ultra-lightweight text-to-speech model with just 15 million parameters\",\n    long_description=long_description,\n    long_description_content_type=\"text/markdown\",\n    url=\"https://github.com/kittenml/kittentts\",\n    packages=find_packages(),\n    classifiers=[\n        \"Development Status :: 3 - Alpha\",\n        \"Intended Audience :: Developers\",\n        \"License :: OSI Approved :: MIT License\",\n        \"Operating System :: OS Independent\",\n        \"Programming Language :: Python :: 3\",\n        \"Programming Language :: Python :: 3.8\",\n        \"Programming Language :: Python :: 3.9\",\n        \"Programming Language :: Python :: 3.10\",\n        \"Programming Language :: Python :: 3.11\",\n        \"Programming Language :: Python :: 3.12\",\n        \"Topic :: Multimedia :: Sound/Audio :: Speech\",\n        \"Topic :: Scientific/Engineering :: Artificial Intelligence\",\n    ],\n    python_requires=\">=3.8\",\n    install_requires=[\n        \"num2words\",\n        \"spacy\",\n        \"espeakng_loader\",\n        \"misaki[en]>=0.9.4\",\n        \"onnxruntime\",\n        \"soundfile\",\n        \"numpy\",\n        \"huggingface_hub\",\n    ],\n    keywords=\"text-to-speech, tts, speech-synthesis, neural-networks, onnx\",\n    project_urls={\n        \"Bug Reports\": \"https://github.com/kittenml/kittentts/issues\",\n        \"Source\": \"https://github.com/kittenml/kittentts\",\n    },\n)\n"
  }
]