murmr 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.
murmr-0.1.0/.gitignore ADDED
@@ -0,0 +1,31 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ *.egg
7
+ dist/
8
+ build/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+
14
+ # Testing / coverage
15
+ .coverage
16
+ .pytest_cache/
17
+ htmlcov/
18
+
19
+ # Type checking / linting
20
+ .mypy_cache/
21
+ .ruff_cache/
22
+
23
+ # IDE
24
+ .vscode/
25
+ .idea/
26
+
27
+ # Benchmarks
28
+ .benchmarks/
29
+
30
+ # OS
31
+ .DS_Store
murmr-0.1.0/CLAUDE.md ADDED
@@ -0,0 +1,51 @@
1
+ # murmr Python SDK
2
+
3
+ ## Overview
4
+
5
+ Python SDK for the murmr TTS API. Mirrors the Node.js SDK (`@murmr/sdk`) in snake_case.
6
+
7
+ ## Structure
8
+
9
+ ```
10
+ src/murmr/
11
+ __init__.py # Public exports
12
+ _client.py # AsyncMurmrClient + MurmrClient
13
+ _types.py # Pydantic v2 response models (frozen)
14
+ _errors.py # MurmrError, MurmrChunkError
15
+ _validate.py # Input/ID validation
16
+ _streaming.py # SSE parsing, collect as WAV/PCM
17
+ _audio.py # WAV header, silence, concat
18
+ _chunker.py # Sentence-boundary text splitting
19
+ resources/
20
+ speech.py # Batch, streaming, long-form
21
+ voices.py # Voice design (batch + streaming)
22
+ jobs.py # Job polling
23
+ ```
24
+
25
+ ## Commands
26
+
27
+ ```bash
28
+ # Install
29
+ pip install -e ".[dev]"
30
+
31
+ # Test
32
+ pytest -v
33
+
34
+ # Type check
35
+ mypy src/murmr/
36
+
37
+ # Lint
38
+ ruff check src/ tests/
39
+
40
+ # Coverage
41
+ pytest --cov=murmr --cov-report=term-missing
42
+ ```
43
+
44
+ ## Conventions
45
+
46
+ - **Async-first, sync support:** Every resource has Async + Sync variants
47
+ - **kwargs, not models:** Methods take keyword arguments (like OpenAI SDK)
48
+ - **`input` maps to `text`:** Public API uses `input`, request body uses `text`
49
+ - **Pydantic v2 frozen models:** All response types are immutable
50
+ - **No httpx-sse:** SSE parsed manually via `iter_lines()`
51
+ - **Context managers:** Streaming methods return context managers that yield typed chunks
murmr-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 murmr
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.
murmr-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: murmr
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the murmr TTS API
5
+ Project-URL: Homepage, https://murmr.dev
6
+ Project-URL: Documentation, https://murmr.dev/docs
7
+ Project-URL: Repository, https://github.com/christi4nity/murmr-python
8
+ Author: murmr
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx<1.0,>=0.25
23
+ Requires-Dist: pydantic<3.0,>=2.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy>=1.0; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
28
+ Requires-Dist: pytest>=7.0; extra == 'dev'
29
+ Requires-Dist: respx>=0.21; extra == 'dev'
30
+ Requires-Dist: ruff>=0.4; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # murmr
34
+
35
+ Python SDK for the [murmr](https://murmr.dev) TTS API. Async-first with full sync support.
36
+
37
+ ```bash
38
+ pip install murmr
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ### Voice Design (describe any voice in natural language)
44
+
45
+ ```python
46
+ from murmr import MurmrClient
47
+
48
+ client = MurmrClient(api_key="murmr_sk_live_...")
49
+
50
+ # Generate speech with a voice description
51
+ wav = client.voices.design(
52
+ input="Hello, welcome to murmr!",
53
+ voice_description="A warm, friendly female voice with a slight British accent",
54
+ )
55
+
56
+ with open("output.wav", "wb") as f:
57
+ f.write(wav)
58
+ ```
59
+
60
+ ### Saved Voices (batch via RunPod Serverless)
61
+
62
+ ```python
63
+ # Submit a batch job
64
+ job = client.speech.create(input="Hello world", voice="voice_abc123")
65
+
66
+ # Wait for completion
67
+ result = client.speech.create_and_wait(input="Hello world", voice="voice_abc123")
68
+ audio = result.audio_bytes # decoded WAV
69
+ ```
70
+
71
+ ### Streaming
72
+
73
+ ```python
74
+ # Stream PCM audio chunks
75
+ with client.speech.stream(input="Hello world", voice="voice_abc123") as stream:
76
+ for chunk in stream:
77
+ pcm = chunk.audio_bytes # 24kHz mono 16-bit PCM
78
+ if chunk.done:
79
+ break
80
+ ```
81
+
82
+ ### Async
83
+
84
+ ```python
85
+ import asyncio
86
+ from murmr import AsyncMurmrClient
87
+
88
+ async def main():
89
+ async with AsyncMurmrClient(api_key="murmr_sk_live_...") as client:
90
+ wav = await client.voices.design(
91
+ input="Hello from async!",
92
+ voice_description="A deep male voice",
93
+ )
94
+
95
+ asyncio.run(main())
96
+ ```
97
+
98
+ ### Long-Form Audio
99
+
100
+ ```python
101
+ # Automatically chunks, retries, and concatenates
102
+ result = client.speech.create_long_form(
103
+ input=very_long_text,
104
+ voice="voice_abc123",
105
+ on_progress=lambda current, total, pct: print(f"{pct}%"),
106
+ )
107
+
108
+ with open("long_form.wav", "wb") as f:
109
+ f.write(result.audio)
110
+ ```
111
+
112
+ ## API Reference
113
+
114
+ ### Clients
115
+
116
+ | Class | Description |
117
+ |-------|-------------|
118
+ | `MurmrClient(api_key=...)` | Sync client (context manager) |
119
+ | `AsyncMurmrClient(api_key=...)` | Async client (async context manager) |
120
+
121
+ ### Speech (`client.speech`)
122
+
123
+ | Method | Returns | Description |
124
+ |--------|---------|-------------|
125
+ | `create(input, voice, ...)` | `AsyncJobResponse` | Submit batch job |
126
+ | `create_and_wait(input, voice, ...)` | `JobStatus` | Submit and poll until done |
127
+ | `stream(input, voice, ...)` | Context manager yielding `AudioStreamChunk` | Stream PCM chunks |
128
+ | `create_long_form(input, voice, ...)` | `LongFormResult` | Chunk + concat long text |
129
+
130
+ ### Voices (`client.voices`)
131
+
132
+ | Method | Returns | Description |
133
+ |--------|---------|-------------|
134
+ | `design(input, voice_description, ...)` | `bytes` (WAV) | Generate with voice description |
135
+ | `design_stream(input, voice_description, ...)` | Context manager yielding `AudioStreamChunk` | Stream voice design |
136
+
137
+ ### Jobs (`client.jobs`)
138
+
139
+ | Method | Returns | Description |
140
+ |--------|---------|-------------|
141
+ | `get(job_id)` | `JobStatus` | Get job status |
142
+ | `wait_for_completion(job_id, ...)` | `JobStatus` | Poll until done/failed |
143
+
144
+ ## Supported Languages
145
+
146
+ Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian
147
+
148
+ ## License
149
+
150
+ MIT
murmr-0.1.0/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # murmr
2
+
3
+ Python SDK for the [murmr](https://murmr.dev) TTS API. Async-first with full sync support.
4
+
5
+ ```bash
6
+ pip install murmr
7
+ ```
8
+
9
+ ## Quick Start
10
+
11
+ ### Voice Design (describe any voice in natural language)
12
+
13
+ ```python
14
+ from murmr import MurmrClient
15
+
16
+ client = MurmrClient(api_key="murmr_sk_live_...")
17
+
18
+ # Generate speech with a voice description
19
+ wav = client.voices.design(
20
+ input="Hello, welcome to murmr!",
21
+ voice_description="A warm, friendly female voice with a slight British accent",
22
+ )
23
+
24
+ with open("output.wav", "wb") as f:
25
+ f.write(wav)
26
+ ```
27
+
28
+ ### Saved Voices (batch via RunPod Serverless)
29
+
30
+ ```python
31
+ # Submit a batch job
32
+ job = client.speech.create(input="Hello world", voice="voice_abc123")
33
+
34
+ # Wait for completion
35
+ result = client.speech.create_and_wait(input="Hello world", voice="voice_abc123")
36
+ audio = result.audio_bytes # decoded WAV
37
+ ```
38
+
39
+ ### Streaming
40
+
41
+ ```python
42
+ # Stream PCM audio chunks
43
+ with client.speech.stream(input="Hello world", voice="voice_abc123") as stream:
44
+ for chunk in stream:
45
+ pcm = chunk.audio_bytes # 24kHz mono 16-bit PCM
46
+ if chunk.done:
47
+ break
48
+ ```
49
+
50
+ ### Async
51
+
52
+ ```python
53
+ import asyncio
54
+ from murmr import AsyncMurmrClient
55
+
56
+ async def main():
57
+ async with AsyncMurmrClient(api_key="murmr_sk_live_...") as client:
58
+ wav = await client.voices.design(
59
+ input="Hello from async!",
60
+ voice_description="A deep male voice",
61
+ )
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ ### Long-Form Audio
67
+
68
+ ```python
69
+ # Automatically chunks, retries, and concatenates
70
+ result = client.speech.create_long_form(
71
+ input=very_long_text,
72
+ voice="voice_abc123",
73
+ on_progress=lambda current, total, pct: print(f"{pct}%"),
74
+ )
75
+
76
+ with open("long_form.wav", "wb") as f:
77
+ f.write(result.audio)
78
+ ```
79
+
80
+ ## API Reference
81
+
82
+ ### Clients
83
+
84
+ | Class | Description |
85
+ |-------|-------------|
86
+ | `MurmrClient(api_key=...)` | Sync client (context manager) |
87
+ | `AsyncMurmrClient(api_key=...)` | Async client (async context manager) |
88
+
89
+ ### Speech (`client.speech`)
90
+
91
+ | Method | Returns | Description |
92
+ |--------|---------|-------------|
93
+ | `create(input, voice, ...)` | `AsyncJobResponse` | Submit batch job |
94
+ | `create_and_wait(input, voice, ...)` | `JobStatus` | Submit and poll until done |
95
+ | `stream(input, voice, ...)` | Context manager yielding `AudioStreamChunk` | Stream PCM chunks |
96
+ | `create_long_form(input, voice, ...)` | `LongFormResult` | Chunk + concat long text |
97
+
98
+ ### Voices (`client.voices`)
99
+
100
+ | Method | Returns | Description |
101
+ |--------|---------|-------------|
102
+ | `design(input, voice_description, ...)` | `bytes` (WAV) | Generate with voice description |
103
+ | `design_stream(input, voice_description, ...)` | Context manager yielding `AudioStreamChunk` | Stream voice design |
104
+
105
+ ### Jobs (`client.jobs`)
106
+
107
+ | Method | Returns | Description |
108
+ |--------|---------|-------------|
109
+ | `get(job_id)` | `JobStatus` | Get job status |
110
+ | `wait_for_completion(job_id, ...)` | `JobStatus` | Poll until done/failed |
111
+
112
+ ## Supported Languages
113
+
114
+ Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "murmr"
7
+ version = "0.1.0"
8
+ description = "Python SDK for the murmr TTS API"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "murmr" }]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "httpx>=0.25,<1.0",
27
+ "pydantic>=2.0,<3.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest>=7.0",
33
+ "pytest-asyncio>=0.23",
34
+ "respx>=0.21",
35
+ "mypy>=1.0",
36
+ "ruff>=0.4",
37
+ "pytest-cov>=4.0",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://murmr.dev"
42
+ Documentation = "https://murmr.dev/docs"
43
+ Repository = "https://github.com/christi4nity/murmr-python"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/murmr"]
47
+
48
+ [tool.pytest.ini_options]
49
+ asyncio_mode = "auto"
50
+ testpaths = ["tests"]
51
+
52
+ [tool.mypy]
53
+ strict = true
54
+ python_version = "3.9"
55
+
56
+ [tool.ruff]
57
+ target-version = "py39"
58
+ line-length = 100
59
+
60
+ [tool.ruff.lint]
61
+ select = ["E", "F", "I", "N", "W", "UP"]
@@ -0,0 +1,18 @@
1
+ """murmr - Python SDK for the murmr TTS API."""
2
+
3
+ from murmr._client import AsyncMurmrClient, MurmrClient
4
+ from murmr._errors import MurmrChunkError, MurmrError
5
+ from murmr._types import AsyncJobResponse, AudioStreamChunk, JobStatus, LongFormResult
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "AsyncMurmrClient",
11
+ "MurmrClient",
12
+ "MurmrError",
13
+ "MurmrChunkError",
14
+ "AsyncJobResponse",
15
+ "AudioStreamChunk",
16
+ "JobStatus",
17
+ "LongFormResult",
18
+ ]
@@ -0,0 +1,88 @@
1
+ """Audio utilities: WAV header construction, silence generation, PCM concatenation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+
7
+ SAMPLE_RATE = 24000
8
+ CHANNELS = 1
9
+ BITS_PER_SAMPLE = 16
10
+ BYTES_PER_SAMPLE = BITS_PER_SAMPLE // 8
11
+ WAV_HEADER_SIZE = 44
12
+
13
+
14
+ def create_wav_header(pcm_data_size: int) -> bytes:
15
+ """Create a 44-byte WAV header for raw PCM data.
16
+
17
+ Assumes 24kHz, mono, 16-bit signed little-endian PCM.
18
+ """
19
+ byte_rate = SAMPLE_RATE * CHANNELS * BYTES_PER_SAMPLE
20
+ block_align = CHANNELS * BYTES_PER_SAMPLE
21
+ file_size = WAV_HEADER_SIZE + pcm_data_size - 8
22
+
23
+ return struct.pack(
24
+ "<4sI4s4sIHHIIHH4sI",
25
+ b"RIFF",
26
+ file_size,
27
+ b"WAVE",
28
+ b"fmt ",
29
+ 16, # PCM chunk size
30
+ 1, # PCM format
31
+ CHANNELS,
32
+ SAMPLE_RATE,
33
+ byte_rate,
34
+ block_align,
35
+ BITS_PER_SAMPLE,
36
+ b"data",
37
+ pcm_data_size,
38
+ )
39
+
40
+
41
+ def generate_silence(duration_ms: int) -> bytes:
42
+ """Generate silence as raw PCM (16-bit, little-endian, all zeros)."""
43
+ num_samples = int((duration_ms / 1000) * SAMPLE_RATE)
44
+ return b"\x00" * (num_samples * BYTES_PER_SAMPLE)
45
+
46
+
47
+ def extract_pcm(wav_buffer: bytes) -> bytes:
48
+ """Extract raw PCM data from a WAV buffer by walking sub-chunks to find 'data'."""
49
+ if len(wav_buffer) < 12 or wav_buffer[:4] != b"RIFF":
50
+ return wav_buffer
51
+
52
+ offset = 12
53
+ while offset + 8 <= len(wav_buffer):
54
+ chunk_id = wav_buffer[offset : offset + 4]
55
+ chunk_size = struct.unpack_from("<I", wav_buffer, offset + 4)[0]
56
+ if chunk_id == b"data":
57
+ return wav_buffer[offset + 8 : offset + 8 + chunk_size]
58
+ offset += 8 + chunk_size
59
+
60
+ # Fallback: strip standard 44-byte header
61
+ return wav_buffer[WAV_HEADER_SIZE:]
62
+
63
+
64
+ def concatenate_pcm_chunks(
65
+ chunks: list[bytes],
66
+ silence_between_ms: int = 0,
67
+ ) -> bytes:
68
+ """Concatenate PCM chunks with optional silence, then wrap in a WAV container."""
69
+ if not chunks:
70
+ return b""
71
+
72
+ silence = generate_silence(silence_between_ms) if silence_between_ms > 0 else b""
73
+ parts: list[bytes] = []
74
+
75
+ for i, chunk in enumerate(chunks):
76
+ parts.append(chunk)
77
+ if silence and i < len(chunks) - 1:
78
+ parts.append(silence)
79
+
80
+ total_pcm = b"".join(parts)
81
+ return create_wav_header(len(total_pcm)) + total_pcm
82
+
83
+
84
+ def estimate_duration_ms(wav_buffer: bytes) -> int:
85
+ """Estimate audio duration in milliseconds from a WAV buffer."""
86
+ bytes_per_second = SAMPLE_RATE * CHANNELS * BYTES_PER_SAMPLE
87
+ data_size = max(0, len(wav_buffer) - WAV_HEADER_SIZE)
88
+ return round((data_size / bytes_per_second) * 1000)
@@ -0,0 +1,120 @@
1
+ """Sentence-boundary text splitting for long-form audio generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ # Split after sentence-ending punctuation (Western + CJK)
8
+ _SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+|(?<=[。!?])")
9
+
10
+ # Split after clause-level punctuation
11
+ _CLAUSE_SPLIT = re.compile(r"(?<=[,;:\u2014])\s+|(?<=[、;:])")
12
+
13
+
14
+ def split_into_chunks(text: str, max_chars: int = 3500) -> list[str]:
15
+ """Split text into chunks at sentence boundaries.
16
+
17
+ Never splits mid-sentence. Falls back to clause boundaries for long
18
+ sentences, then word boundaries as a last resort.
19
+
20
+ Args:
21
+ text: The input text to split.
22
+ max_chars: Maximum characters per chunk (100-4096).
23
+
24
+ Returns:
25
+ List of text chunks, each within max_chars.
26
+ """
27
+ if max_chars < 100:
28
+ raise ValueError("max_chars must be at least 100")
29
+ if max_chars > 4096:
30
+ raise ValueError("max_chars must be at most 4096 (API limit)")
31
+
32
+ trimmed = text.strip()
33
+ if not trimmed:
34
+ return []
35
+ if len(trimmed) <= max_chars:
36
+ return [trimmed]
37
+
38
+ sentences = _SENTENCE_SPLIT.split(trimmed)
39
+ chunks: list[str] = []
40
+ current = ""
41
+
42
+ for sentence in sentences:
43
+ stripped = sentence.strip()
44
+ if not stripped:
45
+ continue
46
+
47
+ if len(stripped) > max_chars:
48
+ if current:
49
+ chunks.append(current.strip())
50
+ current = ""
51
+ chunks.extend(_split_long_sentence(stripped, max_chars))
52
+ continue
53
+
54
+ combined = f"{current} {stripped}" if current else stripped
55
+
56
+ if len(combined) > max_chars:
57
+ chunks.append(current.strip())
58
+ current = stripped
59
+ else:
60
+ current = combined
61
+
62
+ if current.strip():
63
+ chunks.append(current.strip())
64
+
65
+ return chunks
66
+
67
+
68
+ def _split_long_sentence(sentence: str, max_chars: int) -> list[str]:
69
+ """Split a sentence that exceeds max_chars at clause boundaries."""
70
+ clauses = _CLAUSE_SPLIT.split(sentence)
71
+
72
+ if len(clauses) > 1:
73
+ chunks: list[str] = []
74
+ current = ""
75
+
76
+ for clause in clauses:
77
+ stripped = clause.strip()
78
+ if not stripped:
79
+ continue
80
+
81
+ if len(stripped) > max_chars:
82
+ if current:
83
+ chunks.append(current.strip())
84
+ current = ""
85
+ chunks.extend(_split_at_words(stripped, max_chars))
86
+ continue
87
+
88
+ combined = f"{current} {stripped}" if current else stripped
89
+ if len(combined) > max_chars:
90
+ chunks.append(current.strip())
91
+ current = stripped
92
+ else:
93
+ current = combined
94
+
95
+ if current.strip():
96
+ chunks.append(current.strip())
97
+
98
+ return chunks
99
+
100
+ return _split_at_words(sentence, max_chars)
101
+
102
+
103
+ def _split_at_words(text: str, max_chars: int) -> list[str]:
104
+ """Last-resort splitting at word boundaries."""
105
+ words = text.split()
106
+ chunks: list[str] = []
107
+ current = ""
108
+
109
+ for word in words:
110
+ combined = f"{current} {word}" if current else word
111
+ if len(combined) > max_chars and current:
112
+ chunks.append(current.strip())
113
+ current = word
114
+ else:
115
+ current = combined
116
+
117
+ if current.strip():
118
+ chunks.append(current.strip())
119
+
120
+ return chunks