chatterbook 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ publish:
13
+ runs-on: ubuntu-latest
14
+ permissions:
15
+ contents: read
16
+ id-token: write
17
+
18
+ steps:
19
+ - name: Checkout
20
+ uses: actions/checkout@v5
21
+
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v7
24
+
25
+ - name: Set up Python
26
+ uses: actions/setup-python@v6
27
+ with:
28
+ python-version: "3.11"
29
+
30
+ - name: Test
31
+ run: uv run --extra dev pytest
32
+
33
+ - name: Build
34
+ run: uv build
35
+
36
+ - name: Publish to PyPI
37
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ __pycache__/
4
+ *.py[cod]
5
+ dist/
6
+ build/
7
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Small Turtle 2
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: chatterbook
3
+ Version: 0.1.0
4
+ Summary: Convert EPUB books into chapter WAV audio with Chatterbox Multilingual TTS.
5
+ Author: Small Turtle 2
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Small Turtle 2
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Requires-Python: >=3.10
29
+ Requires-Dist: beautifulsoup4>=4.12
30
+ Requires-Dist: chatterbox-tts<0.2,>=0.1.7
31
+ Requires-Dist: ebooklib>=0.18
32
+ Requires-Dist: torch>=2
33
+ Requires-Dist: torchaudio>=2
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=8; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # chatterbook
39
+
40
+ Convert EPUB books into chapter WAV audio with Chatterbox Multilingual TTS.
41
+
42
+ ```python
43
+ from chatterbook import convert_epub
44
+
45
+ convert_epub(
46
+ "book.epub",
47
+ output_dir="audio",
48
+ language="ko",
49
+ voice_path="voices/narrator.wav",
50
+ style="warm",
51
+ )
52
+ ```
53
+
54
+ `voice_path` is an optional short WAV reference clip for voice cloning. If it is
55
+ omitted, Chatterbox's bundled default conditionals are used.
56
+
57
+ ## Styles
58
+
59
+ - `neutral`: balanced default
60
+ - `warm`: slightly softer narration
61
+ - `dramatic`: more expressive narration
62
+
63
+ You can override a style with explicit generation values:
64
+
65
+ ```python
66
+ convert_epub(
67
+ "book.epub",
68
+ output_dir="audio",
69
+ language="ko",
70
+ exaggeration=0.7,
71
+ cfg_weight=0.3,
72
+ )
73
+ ```
@@ -0,0 +1,36 @@
1
+ # chatterbook
2
+
3
+ Convert EPUB books into chapter WAV audio with Chatterbox Multilingual TTS.
4
+
5
+ ```python
6
+ from chatterbook import convert_epub
7
+
8
+ convert_epub(
9
+ "book.epub",
10
+ output_dir="audio",
11
+ language="ko",
12
+ voice_path="voices/narrator.wav",
13
+ style="warm",
14
+ )
15
+ ```
16
+
17
+ `voice_path` is an optional short WAV reference clip for voice cloning. If it is
18
+ omitted, Chatterbox's bundled default conditionals are used.
19
+
20
+ ## Styles
21
+
22
+ - `neutral`: balanced default
23
+ - `warm`: slightly softer narration
24
+ - `dramatic`: more expressive narration
25
+
26
+ You can override a style with explicit generation values:
27
+
28
+ ```python
29
+ convert_epub(
30
+ "book.epub",
31
+ output_dir="audio",
32
+ language="ko",
33
+ exaggeration=0.7,
34
+ cfg_weight=0.3,
35
+ )
36
+ ```
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chatterbook"
7
+ version = "0.1.0"
8
+ description = "Convert EPUB books into chapter WAV audio with Chatterbox Multilingual TTS."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ authors = [
13
+ { name = "Small Turtle 2" },
14
+ ]
15
+ dependencies = [
16
+ "beautifulsoup4>=4.12",
17
+ "chatterbox-tts>=0.1.7,<0.2",
18
+ "ebooklib>=0.18",
19
+ "torch>=2",
20
+ "torchaudio>=2",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest>=8",
26
+ ]
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/chatterbook"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
@@ -0,0 +1,17 @@
1
+ from .converter import convert_epub
2
+ from .exceptions import (
3
+ ChatterbookError,
4
+ OutputExistsError,
5
+ UnsupportedLanguageError,
6
+ UnknownStyleError,
7
+ )
8
+ from .styles import STYLE_PRESETS
9
+
10
+ __all__ = [
11
+ "ChatterbookError",
12
+ "OutputExistsError",
13
+ "STYLE_PRESETS",
14
+ "UnknownStyleError",
15
+ "UnsupportedLanguageError",
16
+ "convert_epub",
17
+ ]
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .epub import extract_chapters
7
+ from .exceptions import OutputExistsError, UnsupportedLanguageError
8
+ from .styles import resolve_style
9
+
10
+ SUPPORTED_LANGUAGES = {
11
+ "ar",
12
+ "da",
13
+ "de",
14
+ "el",
15
+ "en",
16
+ "es",
17
+ "fi",
18
+ "fr",
19
+ "he",
20
+ "hi",
21
+ "it",
22
+ "ja",
23
+ "ko",
24
+ "ms",
25
+ "nl",
26
+ "no",
27
+ "pl",
28
+ "pt",
29
+ "ru",
30
+ "sv",
31
+ "sw",
32
+ "tr",
33
+ "zh",
34
+ }
35
+
36
+
37
+ def convert_epub(
38
+ epub_path: str | Path,
39
+ output_dir: str | Path,
40
+ *,
41
+ language: str,
42
+ voice_path: str | Path | None = None,
43
+ style: str = "neutral",
44
+ device: str | None = None,
45
+ t3_model: str | None = None,
46
+ overwrite: bool = False,
47
+ exaggeration: float | None = None,
48
+ cfg_weight: float | None = None,
49
+ temperature: float = 0.8,
50
+ repetition_penalty: float = 1.2,
51
+ min_p: float = 0.05,
52
+ top_p: float = 1.0,
53
+ ) -> list[Path]:
54
+ """Convert an EPUB into chapter WAV files.
55
+
56
+ The Chatterbox model is loaded lazily so importing chatterbook stays cheap.
57
+ """
58
+ language_id = language.lower()
59
+ if language_id not in SUPPORTED_LANGUAGES:
60
+ raise UnsupportedLanguageError(language, sorted(SUPPORTED_LANGUAGES))
61
+
62
+ epub_path = Path(epub_path)
63
+ output_dir = Path(output_dir)
64
+ voice_prompt_path = _resolve_voice_path(voice_path)
65
+ generation_style = resolve_style(
66
+ style,
67
+ exaggeration=exaggeration,
68
+ cfg_weight=cfg_weight,
69
+ )
70
+
71
+ chapters = extract_chapters(epub_path)
72
+ output_dir.mkdir(parents=True, exist_ok=True)
73
+
74
+ output_paths = [output_dir / chapter.filename for chapter in chapters]
75
+ existing_paths = [path for path in output_paths if path.exists()]
76
+ if existing_paths and not overwrite:
77
+ raise OutputExistsError(existing_paths)
78
+
79
+ model = _load_model(device=device, t3_model=t3_model)
80
+ torchaudio = _import_torchaudio()
81
+
82
+ written_paths: list[Path] = []
83
+ for chapter, output_path in zip(chapters, output_paths, strict=True):
84
+ wav = model.generate(
85
+ chapter.text,
86
+ language_id=language_id,
87
+ audio_prompt_path=str(voice_prompt_path) if voice_prompt_path else None,
88
+ exaggeration=generation_style.exaggeration,
89
+ cfg_weight=generation_style.cfg_weight,
90
+ temperature=temperature,
91
+ repetition_penalty=repetition_penalty,
92
+ min_p=min_p,
93
+ top_p=top_p,
94
+ )
95
+ torchaudio.save(str(output_path), wav, model.sr)
96
+ written_paths.append(output_path)
97
+
98
+ return written_paths
99
+
100
+
101
+ def _resolve_voice_path(voice_path: str | Path | None) -> Path | None:
102
+ if voice_path is None:
103
+ return None
104
+
105
+ path = Path(voice_path)
106
+ if not path.is_file():
107
+ raise FileNotFoundError(f"voice_path does not exist: {path}")
108
+ return path
109
+
110
+
111
+ def _load_model(*, device: str | None, t3_model: str | None) -> Any:
112
+ from chatterbox.mtl_tts import ChatterboxMultilingualTTS
113
+
114
+ selected_device = device or _default_device()
115
+ return ChatterboxMultilingualTTS.from_pretrained(
116
+ device=selected_device,
117
+ t3_model=t3_model,
118
+ )
119
+
120
+
121
+ def _default_device() -> str:
122
+ import torch
123
+
124
+ if torch.cuda.is_available():
125
+ return "cuda"
126
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
127
+ return "mps"
128
+ return "cpu"
129
+
130
+
131
+ def _import_torchaudio() -> Any:
132
+ import torchaudio
133
+
134
+ return torchaudio
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import unicodedata
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from bs4 import BeautifulSoup
9
+ from ebooklib import epub
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Chapter:
14
+ title: str
15
+ text: str
16
+ filename: str
17
+
18
+
19
+ def extract_chapters(epub_path: str | Path) -> list[Chapter]:
20
+ book = epub.read_epub(str(epub_path))
21
+ chapters: list[Chapter] = []
22
+
23
+ for itemref in book.spine:
24
+ item_id = itemref[0]
25
+ item = book.get_item_with_id(item_id)
26
+ if item is None:
27
+ continue
28
+ if isinstance(item, epub.EpubNav):
29
+ continue
30
+
31
+ html = item.get_content()
32
+ title, text = _extract_html_text(html)
33
+ if not text:
34
+ continue
35
+
36
+ chapter_number = len(chapters) + 1
37
+ title = title or f"Chapter {chapter_number}"
38
+ filename = f"{chapter_number:03d}-{_slugify(title)}.wav"
39
+ chapters.append(Chapter(title=title, text=text, filename=filename))
40
+
41
+ return chapters
42
+
43
+
44
+ def _extract_html_text(html: bytes) -> tuple[str, str]:
45
+ soup = BeautifulSoup(html, "html.parser")
46
+
47
+ for tag in soup(["script", "style", "nav"]):
48
+ tag.decompose()
49
+
50
+ heading = soup.find(["h1", "h2", "h3"])
51
+ title = heading.get_text(" ", strip=True) if heading else ""
52
+ text = soup.get_text(" ", strip=True)
53
+ text = re.sub(r"\s+", " ", text).strip()
54
+ return title, text
55
+
56
+
57
+ def _slugify(value: str) -> str:
58
+ value = unicodedata.normalize("NFKD", value)
59
+ value = value.encode("ascii", "ignore").decode("ascii")
60
+ value = re.sub(r"[^a-zA-Z0-9]+", "-", value).strip("-").lower()
61
+ return value or "chapter"
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from collections.abc import Sequence
5
+
6
+
7
+ class ChatterbookError(Exception):
8
+ """Base exception for chatterbook errors."""
9
+
10
+
11
+ class UnsupportedLanguageError(ChatterbookError, ValueError):
12
+ def __init__(self, language: str, supported_languages: Sequence[str]) -> None:
13
+ supported = ", ".join(supported_languages)
14
+ super().__init__(
15
+ f"Unsupported language '{language}'. Supported languages: {supported}"
16
+ )
17
+
18
+
19
+ class UnknownStyleError(ChatterbookError, ValueError):
20
+ def __init__(self, style: str, supported_styles: Sequence[str]) -> None:
21
+ supported = ", ".join(supported_styles)
22
+ super().__init__(f"Unknown style '{style}'. Supported styles: {supported}")
23
+
24
+
25
+ class OutputExistsError(ChatterbookError, FileExistsError):
26
+ def __init__(self, paths: Sequence[Path]) -> None:
27
+ joined = ", ".join(str(path) for path in paths)
28
+ super().__init__(f"Output file already exists: {joined}")
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .exceptions import UnknownStyleError
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class GenerationStyle:
10
+ exaggeration: float
11
+ cfg_weight: float
12
+
13
+
14
+ STYLE_PRESETS = {
15
+ "neutral": GenerationStyle(exaggeration=0.5, cfg_weight=0.5),
16
+ "warm": GenerationStyle(exaggeration=0.6, cfg_weight=0.45),
17
+ "dramatic": GenerationStyle(exaggeration=0.8, cfg_weight=0.55),
18
+ }
19
+
20
+
21
+ def resolve_style(
22
+ style: str,
23
+ *,
24
+ exaggeration: float | None = None,
25
+ cfg_weight: float | None = None,
26
+ ) -> GenerationStyle:
27
+ try:
28
+ preset = STYLE_PRESETS[style]
29
+ except KeyError as exc:
30
+ raise UnknownStyleError(style, sorted(STYLE_PRESETS)) from exc
31
+
32
+ return GenerationStyle(
33
+ exaggeration=preset.exaggeration if exaggeration is None else exaggeration,
34
+ cfg_weight=preset.cfg_weight if cfg_weight is None else cfg_weight,
35
+ )
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from types import SimpleNamespace
5
+
6
+ import pytest
7
+
8
+ import chatterbook.converter as converter
9
+ from chatterbook import convert_epub
10
+
11
+
12
+ class FakeModel:
13
+ sr = 24000
14
+
15
+ def __init__(self) -> None:
16
+ self.calls = []
17
+
18
+ def generate(self, text, **kwargs):
19
+ self.calls.append((text, kwargs))
20
+ return [[0.0]]
21
+
22
+
23
+ class FakeTorchaudio:
24
+ def __init__(self) -> None:
25
+ self.saved = []
26
+
27
+ def save(self, path, wav, sample_rate):
28
+ self.saved.append((path, wav, sample_rate))
29
+ Path(path).write_bytes(b"wav")
30
+
31
+
32
+ def test_convert_epub_passes_voice_path_and_style(monkeypatch, tmp_path):
33
+ fake_model = FakeModel()
34
+ fake_torchaudio = FakeTorchaudio()
35
+ voice_path = tmp_path / "voice.wav"
36
+ voice_path.write_bytes(b"voice")
37
+
38
+ monkeypatch.setattr(
39
+ converter,
40
+ "extract_chapters",
41
+ lambda _: [
42
+ SimpleNamespace(text="hello", filename="001-intro.wav"),
43
+ SimpleNamespace(text="world", filename="002-next.wav"),
44
+ ],
45
+ )
46
+ monkeypatch.setattr(converter, "_load_model", lambda **_: fake_model)
47
+ monkeypatch.setattr(converter, "_import_torchaudio", lambda: fake_torchaudio)
48
+
49
+ paths = convert_epub(
50
+ tmp_path / "book.epub",
51
+ tmp_path / "audio",
52
+ language="ko",
53
+ voice_path=voice_path,
54
+ style="warm",
55
+ )
56
+
57
+ assert [path.name for path in paths] == ["001-intro.wav", "002-next.wav"]
58
+ assert fake_model.calls[0][1]["audio_prompt_path"] == str(voice_path)
59
+ assert fake_model.calls[0][1]["language_id"] == "ko"
60
+ assert fake_model.calls[0][1]["exaggeration"] == 0.6
61
+ assert fake_model.calls[0][1]["cfg_weight"] == 0.45
62
+ assert len(fake_torchaudio.saved) == 2
63
+
64
+
65
+ def test_explicit_generation_values_override_style(monkeypatch, tmp_path):
66
+ fake_model = FakeModel()
67
+ monkeypatch.setattr(
68
+ converter,
69
+ "extract_chapters",
70
+ lambda _: [SimpleNamespace(text="hello", filename="001-intro.wav")],
71
+ )
72
+ monkeypatch.setattr(converter, "_load_model", lambda **_: fake_model)
73
+ monkeypatch.setattr(converter, "_import_torchaudio", lambda: FakeTorchaudio())
74
+
75
+ convert_epub(
76
+ tmp_path / "book.epub",
77
+ tmp_path / "audio",
78
+ language="en",
79
+ style="dramatic",
80
+ exaggeration=0.7,
81
+ cfg_weight=0.3,
82
+ )
83
+
84
+ call = fake_model.calls[0][1]
85
+ assert call["exaggeration"] == 0.7
86
+ assert call["cfg_weight"] == 0.3
87
+
88
+
89
+ def test_convert_epub_rejects_missing_voice_path(tmp_path):
90
+ with pytest.raises(FileNotFoundError):
91
+ convert_epub(
92
+ tmp_path / "book.epub",
93
+ tmp_path / "audio",
94
+ language="ko",
95
+ voice_path=tmp_path / "missing.wav",
96
+ )
97
+
98
+
99
+ def test_convert_epub_rejects_existing_output(monkeypatch, tmp_path):
100
+ monkeypatch.setattr(
101
+ converter,
102
+ "extract_chapters",
103
+ lambda _: [SimpleNamespace(text="hello", filename="001-intro.wav")],
104
+ )
105
+ output_dir = tmp_path / "audio"
106
+ output_dir.mkdir()
107
+ (output_dir / "001-intro.wav").write_bytes(b"exists")
108
+
109
+ with pytest.raises(FileExistsError):
110
+ convert_epub(tmp_path / "book.epub", output_dir, language="ko")
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from ebooklib import epub
4
+
5
+ from chatterbook.epub import extract_chapters
6
+
7
+
8
+ def test_extract_chapters_uses_spine_order(tmp_path):
9
+ book = epub.EpubBook()
10
+ book.set_identifier("fixture")
11
+ book.set_title("Fixture")
12
+ book.set_language("en")
13
+
14
+ first = epub.EpubHtml(title="First", file_name="first.xhtml", lang="en")
15
+ first.content = "<html><body><h1>First Title</h1><p>Hello first.</p></body></html>"
16
+ second = epub.EpubHtml(title="Second", file_name="second.xhtml", lang="en")
17
+ second.content = "<html><body><h1>Second Title</h1><p>Hello second.</p></body></html>"
18
+
19
+ book.add_item(first)
20
+ book.add_item(second)
21
+ book.add_item(epub.EpubNcx())
22
+ book.add_item(epub.EpubNav())
23
+ book.spine = ["nav", first, second]
24
+
25
+ path = tmp_path / "fixture.epub"
26
+ epub.write_epub(str(path), book)
27
+
28
+ chapters = extract_chapters(path)
29
+
30
+ assert [chapter.title for chapter in chapters] == ["First Title", "Second Title"]
31
+ assert [chapter.filename for chapter in chapters] == [
32
+ "001-first-title.wav",
33
+ "002-second-title.wav",
34
+ ]
35
+ assert chapters[0].text == "First Title Hello first."