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 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
@@ -0,0 +1,6 @@
1
+ """Allow running as ``python -m md2speech``."""
2
+
3
+ from md2speech.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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)