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.
- chatterbook-0.1.0/.github/workflows/workflow.yml +37 -0
- chatterbook-0.1.0/.gitignore +7 -0
- chatterbook-0.1.0/LICENSE +21 -0
- chatterbook-0.1.0/PKG-INFO +73 -0
- chatterbook-0.1.0/README.md +36 -0
- chatterbook-0.1.0/pyproject.toml +32 -0
- chatterbook-0.1.0/src/chatterbook/__init__.py +17 -0
- chatterbook-0.1.0/src/chatterbook/converter.py +134 -0
- chatterbook-0.1.0/src/chatterbook/epub.py +61 -0
- chatterbook-0.1.0/src/chatterbook/exceptions.py +28 -0
- chatterbook-0.1.0/src/chatterbook/styles.py +35 -0
- chatterbook-0.1.0/tests/test_converter.py +110 -0
- chatterbook-0.1.0/tests/test_epub.py +35 -0
- chatterbook-0.1.0/uv.lock +4016 -0
|
@@ -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,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."
|