md2speech 0.1.0__py3-none-any.whl
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.
- md2speech/__init__.py +40 -0
- md2speech/__main__.py +6 -0
- md2speech/audio.py +147 -0
- md2speech/cli.py +204 -0
- md2speech/engine.py +90 -0
- md2speech/normalize.py +110 -0
- md2speech/reader.py +73 -0
- md2speech/synthesize.py +184 -0
- md2speech/voices.py +240 -0
- md2speech-0.1.0.dist-info/METADATA +396 -0
- md2speech-0.1.0.dist-info/RECORD +14 -0
- md2speech-0.1.0.dist-info/WHEEL +4 -0
- md2speech-0.1.0.dist-info/entry_points.txt +2 -0
- md2speech-0.1.0.dist-info/licenses/LICENSE +21 -0
md2speech/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Convert Markdown and plain text files to speech audio using Kokoro-82M."""
|
|
2
|
+
|
|
3
|
+
from md2speech.audio import AudioWriter
|
|
4
|
+
from md2speech.engine import TTSEngine
|
|
5
|
+
from md2speech.reader import extract_plain_text, read_document
|
|
6
|
+
from md2speech.synthesize import SynthesisResult, synthesize_file
|
|
7
|
+
from md2speech.voices import (
|
|
8
|
+
DEFAULT_LANG,
|
|
9
|
+
DEFAULT_VOICE,
|
|
10
|
+
LANG_CODES,
|
|
11
|
+
VOICES_BY_LANG,
|
|
12
|
+
default_voice_for_lang,
|
|
13
|
+
infer_lang_from_voice,
|
|
14
|
+
list_languages,
|
|
15
|
+
list_voices,
|
|
16
|
+
resolve_lang_code,
|
|
17
|
+
resolve_voice_and_lang,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AudioWriter",
|
|
24
|
+
"DEFAULT_LANG",
|
|
25
|
+
"DEFAULT_VOICE",
|
|
26
|
+
"LANG_CODES",
|
|
27
|
+
"SynthesisResult",
|
|
28
|
+
"TTSEngine",
|
|
29
|
+
"VOICES_BY_LANG",
|
|
30
|
+
"default_voice_for_lang",
|
|
31
|
+
"extract_plain_text",
|
|
32
|
+
"infer_lang_from_voice",
|
|
33
|
+
"list_languages",
|
|
34
|
+
"list_voices",
|
|
35
|
+
"read_document",
|
|
36
|
+
"resolve_lang_code",
|
|
37
|
+
"resolve_voice_and_lang",
|
|
38
|
+
"synthesize_file",
|
|
39
|
+
"__version__",
|
|
40
|
+
]
|
md2speech/__main__.py
ADDED
md2speech/audio.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Audio post-processing and format export."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import shutil
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import soundfile as sf
|
|
12
|
+
from scipy.signal import resample_poly
|
|
13
|
+
from pydub import AudioSegment
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
OUTPUT_SAMPLE_RATE = 44_100
|
|
18
|
+
FFMPEG_FORMATS = {"mp3", "ogg", "flac"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class WriteResult:
|
|
23
|
+
"""Metadata returned after writing an audio file."""
|
|
24
|
+
|
|
25
|
+
path: Path
|
|
26
|
+
duration_seconds: float
|
|
27
|
+
sample_rate: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AudioWriter:
|
|
31
|
+
"""Post-process and export synthesized audio to disk."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, sample_rate: int = OUTPUT_SAMPLE_RATE) -> None:
|
|
34
|
+
self.sample_rate = sample_rate
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def peak_normalize(audio: np.ndarray, target_dbfs: float = -1.5) -> np.ndarray:
|
|
38
|
+
"""Normalize audio peak to the target dBFS level."""
|
|
39
|
+
if audio.size == 0:
|
|
40
|
+
return audio
|
|
41
|
+
|
|
42
|
+
peak = np.max(np.abs(audio))
|
|
43
|
+
if peak == 0:
|
|
44
|
+
return audio
|
|
45
|
+
|
|
46
|
+
target_linear = 10 ** (target_dbfs / 20.0)
|
|
47
|
+
return (audio * (target_linear / peak)).astype(np.float32)
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def add_silence_padding(
|
|
51
|
+
audio: np.ndarray,
|
|
52
|
+
sample_rate: int,
|
|
53
|
+
pad_ms: float = 100,
|
|
54
|
+
) -> np.ndarray:
|
|
55
|
+
"""Add silence before and after the audio."""
|
|
56
|
+
if audio.size == 0:
|
|
57
|
+
return audio
|
|
58
|
+
|
|
59
|
+
pad_samples = int(sample_rate * pad_ms / 1000.0)
|
|
60
|
+
padding = np.zeros(pad_samples, dtype=np.float32)
|
|
61
|
+
return np.concatenate([padding, audio, padding])
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def resample(
|
|
65
|
+
audio: np.ndarray,
|
|
66
|
+
orig_sr: int,
|
|
67
|
+
target_sr: int = OUTPUT_SAMPLE_RATE,
|
|
68
|
+
) -> np.ndarray:
|
|
69
|
+
"""Resample audio using polyphase filtering."""
|
|
70
|
+
if audio.size == 0 or orig_sr == target_sr:
|
|
71
|
+
return audio.astype(np.float32)
|
|
72
|
+
|
|
73
|
+
gcd = np.gcd(orig_sr, target_sr)
|
|
74
|
+
up = target_sr // gcd
|
|
75
|
+
down = orig_sr // gcd
|
|
76
|
+
return resample_poly(audio, up, down).astype(np.float32)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def check_ffmpeg(format_name: str) -> None:
|
|
80
|
+
"""Raise a clear error if ffmpeg is required but not available."""
|
|
81
|
+
if format_name.lower() not in FFMPEG_FORMATS:
|
|
82
|
+
return
|
|
83
|
+
if shutil.which("ffmpeg") is None:
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
"Error: ffmpeg not found. "
|
|
86
|
+
f"{format_name.upper()} export requires ffmpeg.\n"
|
|
87
|
+
"Install:\n"
|
|
88
|
+
" macOS: brew install ffmpeg\n"
|
|
89
|
+
" Ubuntu: sudo apt install ffmpeg\n"
|
|
90
|
+
" Windows: winget install ffmpeg (or: choco install ffmpeg)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def process(
|
|
94
|
+
self,
|
|
95
|
+
audio: np.ndarray,
|
|
96
|
+
orig_sr: int,
|
|
97
|
+
*,
|
|
98
|
+
target_dbfs: float = -1.5,
|
|
99
|
+
pad_ms: float = 100,
|
|
100
|
+
) -> np.ndarray:
|
|
101
|
+
"""Apply the full post-processing chain."""
|
|
102
|
+
processed = self.peak_normalize(audio, target_dbfs=target_dbfs)
|
|
103
|
+
processed = self.resample(processed, orig_sr, self.sample_rate)
|
|
104
|
+
processed = self.add_silence_padding(processed, self.sample_rate, pad_ms=pad_ms)
|
|
105
|
+
return processed
|
|
106
|
+
|
|
107
|
+
def write_audio(
|
|
108
|
+
self,
|
|
109
|
+
audio: np.ndarray,
|
|
110
|
+
path: Path | str,
|
|
111
|
+
format_name: str = "mp3",
|
|
112
|
+
sample_rate: int | None = None,
|
|
113
|
+
) -> WriteResult:
|
|
114
|
+
"""Write audio to disk in the requested format."""
|
|
115
|
+
output_path = Path(path)
|
|
116
|
+
fmt = format_name.lower().lstrip(".")
|
|
117
|
+
sr = sample_rate or self.sample_rate
|
|
118
|
+
|
|
119
|
+
self.check_ffmpeg(fmt)
|
|
120
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
|
|
122
|
+
if fmt == "wav":
|
|
123
|
+
sf.write(str(output_path), audio, sr, subtype="PCM_16")
|
|
124
|
+
else:
|
|
125
|
+
self._write_with_pydub(audio, output_path, fmt, sr)
|
|
126
|
+
|
|
127
|
+
duration = len(audio) / sr if len(audio) > 0 else 0.0
|
|
128
|
+
logger.info("Wrote %s (%.1f s, %d Hz)", output_path, duration, sr)
|
|
129
|
+
return WriteResult(path=output_path, duration_seconds=duration, sample_rate=sr)
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _write_with_pydub(
|
|
133
|
+
audio: np.ndarray,
|
|
134
|
+
path: Path,
|
|
135
|
+
fmt: str,
|
|
136
|
+
sample_rate: int,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Encode compressed audio via pydub/ffmpeg."""
|
|
139
|
+
clipped = np.clip(audio, -1.0, 1.0)
|
|
140
|
+
pcm = (clipped * 32767).astype(np.int16)
|
|
141
|
+
segment = AudioSegment(
|
|
142
|
+
pcm.tobytes(),
|
|
143
|
+
frame_rate=sample_rate,
|
|
144
|
+
sample_width=2,
|
|
145
|
+
channels=1,
|
|
146
|
+
)
|
|
147
|
+
segment.export(str(path), format=fmt)
|
md2speech/cli.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Command-line interface for md2speech."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from md2speech import __version__
|
|
11
|
+
from md2speech.synthesize import DEFAULT_SPEED, synthesize_file
|
|
12
|
+
from md2speech.voices import (
|
|
13
|
+
DEFAULT_LANG,
|
|
14
|
+
DEFAULT_VOICE,
|
|
15
|
+
LANG_CODES,
|
|
16
|
+
format_language_list,
|
|
17
|
+
format_voice_list,
|
|
18
|
+
resolve_lang_code,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
SUPPORTED_FORMATS = ("mp3", "wav", "ogg", "flac")
|
|
24
|
+
|
|
25
|
+
CLI_EPILOG = """
|
|
26
|
+
examples:
|
|
27
|
+
md2speech article.md
|
|
28
|
+
md2speech notes.md -o out.mp3 --voice am_adam
|
|
29
|
+
md2speech doc.md --lang b --voice bf_emma
|
|
30
|
+
md2speech chapter.txt -f wav -o chapter.wav
|
|
31
|
+
md2speech --list-languages
|
|
32
|
+
md2speech --list-voices en-gb
|
|
33
|
+
|
|
34
|
+
requirements:
|
|
35
|
+
Python 3.11–3.12, ffmpeg on PATH for mp3/ogg/flac.
|
|
36
|
+
First run downloads the Kokoro model from Hugging Face (~500 MB).
|
|
37
|
+
|
|
38
|
+
install:
|
|
39
|
+
pipx install md2speech
|
|
40
|
+
|
|
41
|
+
docs:
|
|
42
|
+
https://github.com/evelasko/md2speech
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
47
|
+
parser = argparse.ArgumentParser(
|
|
48
|
+
prog="md2speech",
|
|
49
|
+
description=(
|
|
50
|
+
"Convert Markdown or plain text files to speech audio using Kokoro-82M "
|
|
51
|
+
"(free, local, offline TTS)."
|
|
52
|
+
),
|
|
53
|
+
epilog=CLI_EPILOG,
|
|
54
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--version",
|
|
58
|
+
action="version",
|
|
59
|
+
version=f"%(prog)s {__version__}",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"input",
|
|
63
|
+
nargs="?",
|
|
64
|
+
type=Path,
|
|
65
|
+
help="Path to a .md, .markdown, or .txt file",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"-o",
|
|
69
|
+
"--output",
|
|
70
|
+
type=Path,
|
|
71
|
+
default=None,
|
|
72
|
+
help="Output audio file path (default: same name as input with new extension)",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"-f",
|
|
76
|
+
"--format",
|
|
77
|
+
dest="output_format",
|
|
78
|
+
choices=SUPPORTED_FORMATS,
|
|
79
|
+
default="mp3",
|
|
80
|
+
help="Output audio format (default: mp3)",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--voice",
|
|
84
|
+
default=None,
|
|
85
|
+
help=(
|
|
86
|
+
f"Kokoro voice ID (default: {DEFAULT_VOICE} for American English). "
|
|
87
|
+
"Use --list-voices to see all options."
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--lang",
|
|
92
|
+
"--language",
|
|
93
|
+
dest="lang",
|
|
94
|
+
default=None,
|
|
95
|
+
metavar="CODE",
|
|
96
|
+
help=(
|
|
97
|
+
f'Kokoro language code or alias (default: inferred from voice, else "{DEFAULT_LANG}"). '
|
|
98
|
+
"Codes: "
|
|
99
|
+
+ ", ".join(f"{c}={name}" for c, name in sorted(LANG_CODES.items()))
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--speed",
|
|
104
|
+
type=float,
|
|
105
|
+
default=DEFAULT_SPEED,
|
|
106
|
+
help=f"Speech speed multiplier (default: {DEFAULT_SPEED})",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--no-normalize",
|
|
110
|
+
action="store_true",
|
|
111
|
+
help="Skip text normalization (numbers, abbreviations, etc.)",
|
|
112
|
+
)
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--paragraph-pause",
|
|
115
|
+
type=float,
|
|
116
|
+
default=0.4,
|
|
117
|
+
help="Seconds of silence between paragraphs (default: 0.4)",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--list-languages",
|
|
121
|
+
action="store_true",
|
|
122
|
+
help="List supported languages and exit",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--list-voices",
|
|
126
|
+
nargs="?",
|
|
127
|
+
const="all",
|
|
128
|
+
metavar="LANG",
|
|
129
|
+
help="List available voices, optionally filtered by language code",
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"-V",
|
|
133
|
+
"--verbose",
|
|
134
|
+
action="store_true",
|
|
135
|
+
help="Enable debug logging and synthesis progress",
|
|
136
|
+
)
|
|
137
|
+
return parser
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main(argv: list[str] | None = None) -> int:
|
|
141
|
+
"""CLI entry point. Returns exit code."""
|
|
142
|
+
parser = _build_parser()
|
|
143
|
+
args = parser.parse_args(argv)
|
|
144
|
+
|
|
145
|
+
if args.list_languages:
|
|
146
|
+
print(format_language_list())
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
if args.list_voices is not None:
|
|
150
|
+
lang_filter = None if args.list_voices == "all" else args.list_voices
|
|
151
|
+
if lang_filter is not None:
|
|
152
|
+
try:
|
|
153
|
+
lang_filter = resolve_lang_code(lang_filter)
|
|
154
|
+
except ValueError as exc:
|
|
155
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
print(format_voice_list(lang_filter))
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
if args.input is None:
|
|
161
|
+
parser.error("the following arguments are required: input")
|
|
162
|
+
|
|
163
|
+
log_level = logging.DEBUG if args.verbose else logging.INFO
|
|
164
|
+
logging.basicConfig(
|
|
165
|
+
level=log_level,
|
|
166
|
+
format="%(levelname)s: %(message)s",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
result = synthesize_file(
|
|
171
|
+
args.input,
|
|
172
|
+
output_path=args.output,
|
|
173
|
+
output_format=args.output_format,
|
|
174
|
+
voice=args.voice,
|
|
175
|
+
speed=args.speed,
|
|
176
|
+
lang=args.lang,
|
|
177
|
+
normalize=not args.no_normalize,
|
|
178
|
+
paragraph_pause=args.paragraph_pause,
|
|
179
|
+
show_progress=args.verbose,
|
|
180
|
+
verbose=args.verbose,
|
|
181
|
+
)
|
|
182
|
+
print(f"Wrote {result.path} ({result.duration_seconds:.1f}s)")
|
|
183
|
+
return 0
|
|
184
|
+
except FileNotFoundError as exc:
|
|
185
|
+
logger.error("%s", exc)
|
|
186
|
+
return 1
|
|
187
|
+
except ValueError as exc:
|
|
188
|
+
logger.error("%s", exc)
|
|
189
|
+
return 1
|
|
190
|
+
except RuntimeError as exc:
|
|
191
|
+
logger.error("%s", exc)
|
|
192
|
+
return 1
|
|
193
|
+
except KeyboardInterrupt:
|
|
194
|
+
logger.error("Interrupted")
|
|
195
|
+
return 130
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
logger.error("Unexpected error: %s", exc)
|
|
198
|
+
if args.verbose:
|
|
199
|
+
logger.exception("Details:")
|
|
200
|
+
return 1
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
sys.exit(main())
|
md2speech/engine.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Kokoro TTS engine wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import torch
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from kokoro import KPipeline
|
|
13
|
+
|
|
14
|
+
from md2speech.voices import DEFAULT_LANG, resolve_lang_code
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
KOKORO_SAMPLE_RATE = 24_000
|
|
19
|
+
DEFAULT_REPO = "hexgrad/Kokoro-82M"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _detect_device() -> str | None:
|
|
23
|
+
"""Pick the best available torch device, or CPU."""
|
|
24
|
+
if torch.cuda.is_available():
|
|
25
|
+
return "cuda"
|
|
26
|
+
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
|
27
|
+
return "mps"
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TTSEngine:
|
|
32
|
+
"""Thin wrapper around Kokoro's ``KPipeline`` with lazy model loading."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
lang_code: str = DEFAULT_LANG,
|
|
37
|
+
repo_id: str = DEFAULT_REPO,
|
|
38
|
+
device: str | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.lang_code = resolve_lang_code(lang_code)
|
|
41
|
+
self.repo_id = repo_id
|
|
42
|
+
self.device = device if device is not None else _detect_device()
|
|
43
|
+
self._pipeline: KPipeline | None = None
|
|
44
|
+
|
|
45
|
+
def _get_pipeline(self) -> KPipeline:
|
|
46
|
+
if self._pipeline is None:
|
|
47
|
+
from kokoro import KPipeline
|
|
48
|
+
|
|
49
|
+
logger.info(
|
|
50
|
+
"Loading Kokoro model from %s (device=%s). "
|
|
51
|
+
"First run may download weights from Hugging Face.",
|
|
52
|
+
self.repo_id,
|
|
53
|
+
self.device or "cpu",
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
self._pipeline = KPipeline(
|
|
57
|
+
lang_code=self.lang_code,
|
|
58
|
+
repo_id=self.repo_id,
|
|
59
|
+
device=self.device,
|
|
60
|
+
)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"Failed to load Kokoro model. Ensure you have network access "
|
|
64
|
+
"for the first download from Hugging Face."
|
|
65
|
+
) from exc
|
|
66
|
+
return self._pipeline
|
|
67
|
+
|
|
68
|
+
def synthesize(self, text: str, voice: str, speed: float = 1.0) -> np.ndarray:
|
|
69
|
+
"""Synthesize a short text string to a float32 numpy array at 24 kHz."""
|
|
70
|
+
if not text.strip():
|
|
71
|
+
return np.array([], dtype=np.float32)
|
|
72
|
+
|
|
73
|
+
pipeline = self._get_pipeline()
|
|
74
|
+
chunks: list[np.ndarray] = []
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
for result in pipeline(text, voice=voice, speed=speed):
|
|
78
|
+
audio = result.audio
|
|
79
|
+
if hasattr(audio, "detach"):
|
|
80
|
+
audio = audio.detach().cpu().numpy()
|
|
81
|
+
else:
|
|
82
|
+
audio = np.asarray(audio)
|
|
83
|
+
chunks.append(audio.astype(np.float32).flatten())
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
raise RuntimeError(f"TTS synthesis failed: {exc}") from exc
|
|
86
|
+
|
|
87
|
+
if not chunks:
|
|
88
|
+
return np.array([], dtype=np.float32)
|
|
89
|
+
|
|
90
|
+
return np.concatenate(chunks)
|
md2speech/normalize.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Text normalization for improved TTS pronunciation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import inflect
|
|
8
|
+
|
|
9
|
+
_ENGINE = inflect.engine()
|
|
10
|
+
|
|
11
|
+
_ABBREVIATIONS: dict[str, str] = {
|
|
12
|
+
"mr.": "Mister",
|
|
13
|
+
"mrs.": "Missus",
|
|
14
|
+
"ms.": "Miss",
|
|
15
|
+
"dr.": "Doctor",
|
|
16
|
+
"prof.": "Professor",
|
|
17
|
+
"sr.": "Senior",
|
|
18
|
+
"jr.": "Junior",
|
|
19
|
+
"st.": "Saint",
|
|
20
|
+
"vs.": "versus",
|
|
21
|
+
"etc.": "etcetera",
|
|
22
|
+
"e.g.": "for example",
|
|
23
|
+
"i.e.": "that is",
|
|
24
|
+
"approx.": "approximately",
|
|
25
|
+
"dept.": "department",
|
|
26
|
+
"inc.": "incorporated",
|
|
27
|
+
"ltd.": "limited",
|
|
28
|
+
"no.": "number",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_ORDINAL_RE = re.compile(r"\b(\d+)(st|nd|rd|th)\b", re.IGNORECASE)
|
|
32
|
+
_CURRENCY_RE = re.compile(r"\$(\d+(?:\.\d{1,2})?)")
|
|
33
|
+
_PERCENT_RE = re.compile(r"(\d+(?:\.\d+)?)\s*%")
|
|
34
|
+
_TIME_RE = re.compile(
|
|
35
|
+
r"\b(\d{1,2}):(\d{2})\s*(AM|PM|am|pm|a\.m\.|p\.m\.)?\b"
|
|
36
|
+
)
|
|
37
|
+
_PLAIN_NUMBER_RE = re.compile(r"\b(\d+(?:\.\d+)?)\b")
|
|
38
|
+
_ABBREV_RE = re.compile(
|
|
39
|
+
r"\b(" + "|".join(re.escape(k) for k in _ABBREVIATIONS) + r")",
|
|
40
|
+
re.IGNORECASE,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def normalize_text(text: str) -> str:
|
|
45
|
+
"""Expand abbreviations, numbers, currency, percentages, and times."""
|
|
46
|
+
if not text.strip():
|
|
47
|
+
return text
|
|
48
|
+
|
|
49
|
+
result = text
|
|
50
|
+
|
|
51
|
+
def _replace_abbrev(match: re.Match[str]) -> str:
|
|
52
|
+
key = match.group(1).lower()
|
|
53
|
+
return _ABBREVIATIONS.get(key, match.group(0))
|
|
54
|
+
|
|
55
|
+
result = _ABBREV_RE.sub(_replace_abbrev, result)
|
|
56
|
+
|
|
57
|
+
def _replace_ordinal(match: re.Match[str]) -> str:
|
|
58
|
+
number = int(match.group(1))
|
|
59
|
+
return _ENGINE.ordinal(_ENGINE.number_to_words(number))
|
|
60
|
+
|
|
61
|
+
result = _ORDINAL_RE.sub(_replace_ordinal, result)
|
|
62
|
+
|
|
63
|
+
def _replace_currency(match: re.Match[str]) -> str:
|
|
64
|
+
amount = match.group(1)
|
|
65
|
+
if "." in amount:
|
|
66
|
+
dollars, cents = amount.split(".", 1)
|
|
67
|
+
cents = cents.ljust(2, "0")[:2]
|
|
68
|
+
dollar_words = _ENGINE.number_to_words(int(dollars))
|
|
69
|
+
cent_words = _ENGINE.number_to_words(int(cents))
|
|
70
|
+
return f"{dollar_words} dollars and {cent_words} cents"
|
|
71
|
+
return f"{_ENGINE.number_to_words(int(amount))} dollars"
|
|
72
|
+
|
|
73
|
+
result = _CURRENCY_RE.sub(_replace_currency, result)
|
|
74
|
+
|
|
75
|
+
def _replace_percent(match: re.Match[str]) -> str:
|
|
76
|
+
value = match.group(1)
|
|
77
|
+
if "." in value:
|
|
78
|
+
words = _ENGINE.number_to_words(value)
|
|
79
|
+
else:
|
|
80
|
+
words = _ENGINE.number_to_words(int(value))
|
|
81
|
+
return f"{words} percent"
|
|
82
|
+
|
|
83
|
+
result = _PERCENT_RE.sub(_replace_percent, result)
|
|
84
|
+
|
|
85
|
+
def _replace_time(match: re.Match[str]) -> str:
|
|
86
|
+
hour = int(match.group(1))
|
|
87
|
+
minute = int(match.group(2))
|
|
88
|
+
period = match.group(3)
|
|
89
|
+
hour_words = _ENGINE.number_to_words(hour)
|
|
90
|
+
if minute == 0:
|
|
91
|
+
spoken = f"{hour_words} o'clock"
|
|
92
|
+
else:
|
|
93
|
+
minute_words = _ENGINE.number_to_words(minute)
|
|
94
|
+
spoken = f"{hour_words} {minute_words}"
|
|
95
|
+
if period:
|
|
96
|
+
period_clean = period.replace(".", "").upper()
|
|
97
|
+
if period_clean in {"AM", "PM"}:
|
|
98
|
+
spoken = f"{spoken} {period_clean}"
|
|
99
|
+
return spoken
|
|
100
|
+
|
|
101
|
+
result = _TIME_RE.sub(_replace_time, result)
|
|
102
|
+
|
|
103
|
+
def _replace_number(match: re.Match[str]) -> str:
|
|
104
|
+
value = match.group(1)
|
|
105
|
+
if "." in value:
|
|
106
|
+
return _ENGINE.number_to_words(value)
|
|
107
|
+
return _ENGINE.number_to_words(int(value))
|
|
108
|
+
|
|
109
|
+
result = _PLAIN_NUMBER_RE.sub(_replace_number, result)
|
|
110
|
+
return result
|
md2speech/reader.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""File reading and Markdown-to-plain-text extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
SUPPORTED_EXTENSIONS = {".txt", ".md", ".markdown"}
|
|
9
|
+
|
|
10
|
+
_FRONT_MATTER_RE = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
|
|
11
|
+
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
12
|
+
_FENCED_CODE_RE = re.compile(r"```[^\n]*\n.*?```", re.DOTALL)
|
|
13
|
+
_INLINE_CODE_RE = re.compile(r"`([^`]+)`")
|
|
14
|
+
_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]+\)")
|
|
15
|
+
_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]+\)")
|
|
16
|
+
_HEADING_RE = re.compile(r"^#{1,6}\s+", re.MULTILINE)
|
|
17
|
+
_BOLD_RE = re.compile(r"\*\*([^*]+)\*\*|__([^_]+)__")
|
|
18
|
+
_ITALIC_RE = re.compile(r"(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_)")
|
|
19
|
+
_STRIKE_RE = re.compile(r"~~([^~]+)~~")
|
|
20
|
+
_BLOCKQUOTE_RE = re.compile(r"^>\s?", re.MULTILINE)
|
|
21
|
+
_HR_RE = re.compile(r"^[-*_]{3,}\s*$", re.MULTILINE)
|
|
22
|
+
_LIST_MARKER_RE = re.compile(r"^[\s]*[-*+]\s+", re.MULTILINE)
|
|
23
|
+
_ORDERED_LIST_RE = re.compile(r"^[\s]*\d+\.\s+", re.MULTILINE)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_document(path: Path | str) -> str:
|
|
27
|
+
"""Read a text or Markdown file and return speakable plain text."""
|
|
28
|
+
file_path = Path(path)
|
|
29
|
+
if not file_path.exists():
|
|
30
|
+
raise FileNotFoundError(f"Input file not found: {file_path}")
|
|
31
|
+
|
|
32
|
+
suffix = file_path.suffix.lower()
|
|
33
|
+
if suffix not in SUPPORTED_EXTENSIONS:
|
|
34
|
+
supported = ", ".join(sorted(SUPPORTED_EXTENSIONS))
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Unsupported file extension '{suffix}'. Supported types: {supported}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
raw = file_path.read_text(encoding="utf-8-sig")
|
|
40
|
+
if suffix == ".txt":
|
|
41
|
+
return raw.strip()
|
|
42
|
+
|
|
43
|
+
return extract_plain_text(raw)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def extract_plain_text(markdown: str) -> str:
|
|
47
|
+
"""Convert Markdown source to speakable plain text.
|
|
48
|
+
|
|
49
|
+
Fenced code blocks (``` ... ```) are removed entirely — they are not
|
|
50
|
+
spoken. Inline code spans are kept and spoken literally.
|
|
51
|
+
"""
|
|
52
|
+
text = markdown.strip()
|
|
53
|
+
if not text:
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
text = _FRONT_MATTER_RE.sub("", text, count=1)
|
|
57
|
+
text = _HTML_COMMENT_RE.sub("", text)
|
|
58
|
+
text = _FENCED_CODE_RE.sub("", text)
|
|
59
|
+
text = _IMAGE_RE.sub(r"\1", text)
|
|
60
|
+
text = _LINK_RE.sub(r"\1", text)
|
|
61
|
+
text = _INLINE_CODE_RE.sub(r"\1", text)
|
|
62
|
+
text = _HEADING_RE.sub("", text)
|
|
63
|
+
text = _BOLD_RE.sub(lambda m: m.group(1) or m.group(2), text)
|
|
64
|
+
text = _ITALIC_RE.sub(lambda m: m.group(1) or m.group(2), text)
|
|
65
|
+
text = _STRIKE_RE.sub(r"\1", text)
|
|
66
|
+
text = _BLOCKQUOTE_RE.sub("", text)
|
|
67
|
+
text = _HR_RE.sub("", text)
|
|
68
|
+
text = _LIST_MARKER_RE.sub("", text)
|
|
69
|
+
text = _ORDERED_LIST_RE.sub("", text)
|
|
70
|
+
|
|
71
|
+
# Collapse runs of blank lines while preserving paragraph breaks.
|
|
72
|
+
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
|
73
|
+
return "\n\n".join(paragraphs)
|