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 +31 -0
- murmr-0.1.0/CLAUDE.md +51 -0
- murmr-0.1.0/LICENSE +21 -0
- murmr-0.1.0/PKG-INFO +150 -0
- murmr-0.1.0/README.md +118 -0
- murmr-0.1.0/pyproject.toml +61 -0
- murmr-0.1.0/src/murmr/__init__.py +18 -0
- murmr-0.1.0/src/murmr/_audio.py +88 -0
- murmr-0.1.0/src/murmr/_chunker.py +120 -0
- murmr-0.1.0/src/murmr/_client.py +230 -0
- murmr-0.1.0/src/murmr/_errors.py +81 -0
- murmr-0.1.0/src/murmr/_streaming.py +148 -0
- murmr-0.1.0/src/murmr/_types.py +77 -0
- murmr-0.1.0/src/murmr/_validate.py +31 -0
- murmr-0.1.0/src/murmr/py.typed +0 -0
- murmr-0.1.0/src/murmr/resources/__init__.py +0 -0
- murmr-0.1.0/src/murmr/resources/jobs.py +119 -0
- murmr-0.1.0/src/murmr/resources/speech.py +456 -0
- murmr-0.1.0/src/murmr/resources/voices.py +134 -0
- murmr-0.1.0/tests/__init__.py +0 -0
- murmr-0.1.0/tests/conftest.py +1 -0
- murmr-0.1.0/tests/helpers.py +24 -0
- murmr-0.1.0/tests/test_audio.py +114 -0
- murmr-0.1.0/tests/test_chunker.py +77 -0
- murmr-0.1.0/tests/test_errors.py +101 -0
- murmr-0.1.0/tests/test_jobs.py +148 -0
- murmr-0.1.0/tests/test_long_form.py +142 -0
- murmr-0.1.0/tests/test_speech.py +203 -0
- murmr-0.1.0/tests/test_streaming.py +85 -0
- murmr-0.1.0/tests/test_validate.py +48 -0
- murmr-0.1.0/tests/test_voices.py +167 -0
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
|