miragea-mcp 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.
- miragea_mcp-0.1.0/LICENSE +21 -0
- miragea_mcp-0.1.0/PKG-INFO +133 -0
- miragea_mcp-0.1.0/README.md +110 -0
- miragea_mcp-0.1.0/pyproject.toml +34 -0
- miragea_mcp-0.1.0/setup.cfg +4 -0
- miragea_mcp-0.1.0/src/miragea_mcp/__init__.py +2 -0
- miragea_mcp-0.1.0/src/miragea_mcp/__main__.py +3 -0
- miragea_mcp-0.1.0/src/miragea_mcp/captions.py +136 -0
- miragea_mcp-0.1.0/src/miragea_mcp/client.py +144 -0
- miragea_mcp-0.1.0/src/miragea_mcp/core.py +287 -0
- miragea_mcp-0.1.0/src/miragea_mcp/server.py +98 -0
- miragea_mcp-0.1.0/src/miragea_mcp.egg-info/PKG-INFO +133 -0
- miragea_mcp-0.1.0/src/miragea_mcp.egg-info/SOURCES.txt +16 -0
- miragea_mcp-0.1.0/src/miragea_mcp.egg-info/dependency_links.txt +1 -0
- miragea_mcp-0.1.0/src/miragea_mcp.egg-info/entry_points.txt +2 -0
- miragea_mcp-0.1.0/src/miragea_mcp.egg-info/requires.txt +1 -0
- miragea_mcp-0.1.0/src/miragea_mcp.egg-info/top_level.txt +1 -0
- miragea_mcp-0.1.0/tests/test_core.py +108 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Miragea team
|
|
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,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: miragea-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for AI voiceover, voice cloning and captions: text of any length to speech with exact durations, SRT/VTT from local files, per-scene narration for Remotion. Works with Claude, Cursor, Codex.
|
|
5
|
+
Author: HeadVox
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yyert123/miragea-mcp
|
|
8
|
+
Project-URL: Repository, https://github.com/yyert123/miragea-mcp
|
|
9
|
+
Project-URL: Demo, https://huggingface.co/spaces/headvox/miragea-mcp
|
|
10
|
+
Project-URL: Voice engine, https://miragea.ai
|
|
11
|
+
Keywords: mcp,mcp-server,model-context-protocol,text-to-speech,tts,voiceover,voice-cloning,captions,subtitles,srt,remotion,claude,cursor,elevenlabs-alternative,miragea
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
16
|
+
Classifier: Topic :: Multimedia :: Video
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: mcp>=1.2
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Miragea MCP: voiceover, voice cloning and captions for Claude, Cursor and Codex
|
|
25
|
+
|
|
26
|
+
<!-- mcp-name: io.github.yyert123/miragea-mcp -->
|
|
27
|
+
|
|
28
|
+
An MCP server that gives your AI agent a real voice toolkit: **text of any length to speech** in your saved
|
|
29
|
+
or cloned voice, **exact durations** for every file, **readable SRT/VTT captions** from any local audio or
|
|
30
|
+
video, and **per-scene narration** with `durationInFrames` and word-level captions for Remotion and other
|
|
31
|
+
video timelines. Local stdio server, one line of config, no GPU. Voice and speech recognition by
|
|
32
|
+
[Miragea](https://miragea.ai).
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"miragea": {
|
|
38
|
+
"command": "uvx",
|
|
39
|
+
"args": ["miragea-mcp"],
|
|
40
|
+
"env": { "MIRAGEA_API_KEY": "your_key", "MIRAGEA_VOICE_ID": "optional_default_voice" }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Claude Code: `claude mcp add miragea -e MIRAGEA_API_KEY=your_key -- uvx miragea-mcp`.
|
|
47
|
+
No uv? `pip install miragea-mcp` and use `"command": "miragea-mcp"`. Check everything with
|
|
48
|
+
`uvx miragea-mcp --check`.
|
|
49
|
+
|
|
50
|
+
Then just ask:
|
|
51
|
+
|
|
52
|
+
- *"Read this changelog aloud in my voice and save it as release-notes.mp3."*
|
|
53
|
+
- *"Make captions for ~/Videos/talk.mp4."* / *"TikTok-style captions for clip.mov."*
|
|
54
|
+
- *"Narrate these 5 scenes for my Remotion video and set each scene's durationInFrames."*
|
|
55
|
+
|
|
56
|
+
## Tools
|
|
57
|
+
|
|
58
|
+
| Tool | What it does | Returns |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `check_setup` | Checks key, ffmpeg, saved voices, output folder; gives a fix for each problem | status + problems |
|
|
61
|
+
| `list_voices` | Voices saved in your Miragea account | `voice_id`, `name` |
|
|
62
|
+
| `speak` | Any length of text to speech (split at sentence ends under the 2,000-char API limit, joined back seamlessly). Saved voice, or clone from a public clip | file path, **exact seconds**, chunks |
|
|
63
|
+
| `transcribe` | Local audio/video to SRT + VTT: whole words (no mid-word splits), 2 x 42-char lines, min/max cue time; `style="shorts"` for 1-3 word uppercase captions | srt/vtt paths, text, language |
|
|
64
|
+
| `narrate_scenes` | One audio file per scene, `seconds`, `durationInFrames` (rounded up, never cut off), `startFrame`, a `narration.json` manifest; `captions=true` adds word-level `captions.json` in Remotion's `Caption` shape | manifest |
|
|
65
|
+
|
|
66
|
+
## What it does for you automatically
|
|
67
|
+
|
|
68
|
+
- **Long text**: chunked at sentence boundaries and stitched with a natural 120 ms pause. You never see
|
|
69
|
+
the 2,000-character limit.
|
|
70
|
+
- **Durations**: measured from the audio itself, so video timelines line up to the frame.
|
|
71
|
+
- **Captions**: speech-recognition segments are rebuilt into whole words and re-flowed with broadcast rules.
|
|
72
|
+
- **Uploads**: video files are reduced to a 16 kHz mono track before upload (fast, small).
|
|
73
|
+
- **Caching**: the same text, voice and model is never billed twice; the same file is never transcribed twice.
|
|
74
|
+
- **Safety**: existing files are never overwritten (`name-2.mp3`), the key is only read from the environment
|
|
75
|
+
and never logged, failed audio tasks are refunded by Miragea, and network retries reuse the same
|
|
76
|
+
idempotency key so a retry cannot double-charge.
|
|
77
|
+
- **Clear errors**: every error comes back as `{"error", "how_to_fix"}` for the agent to act on.
|
|
78
|
+
|
|
79
|
+
## Compared with other voice MCP servers
|
|
80
|
+
|
|
81
|
+
| | **miragea-mcp** | ElevenLabs MCP | Thin TTS wrappers (Edge, Kokoro, Azure...) |
|
|
82
|
+
|---|---|---|---|
|
|
83
|
+
| Local stdio server, maintained | Yes | Local server deprecated; hosted OAuth server instead | Varies |
|
|
84
|
+
| Reads/writes files anywhere you point it | Yes (default `~/Miragea`) | Local version restricted inputs to one base folder | Usually output folder only |
|
|
85
|
+
| Returns exact audio duration | Yes, every call | No (callers had to measure it) | Rarely |
|
|
86
|
+
| Text longer than one API request | Automatic split and join | Manual | Varies |
|
|
87
|
+
| Captions (SRT/VTT) from local files | Yes, reflowed whole words | Separate speech-to-text tool | No |
|
|
88
|
+
| Scene durations in frames + Remotion captions | Yes (`narrate_scenes`) | No | No |
|
|
89
|
+
| Your own cloned voice | Saved voice id or reference clip | Yes (paid plans) | Stock voices only |
|
|
90
|
+
| Billing | Pay-as-you-go Miragea points | Subscription credits | Free / cloud account |
|
|
91
|
+
|
|
92
|
+
Sources: ElevenLabs MCP [README](https://github.com/elevenlabs/elevenlabs-mcp) (deprecation notice) and the
|
|
93
|
+
[remotion-superpowers changelog](https://github.com/DojoCodingLabs/remotion-superpowers/blob/main/CHANGELOG.md)
|
|
94
|
+
(base-folder restriction, no duration returned).
|
|
95
|
+
|
|
96
|
+
## Configuration
|
|
97
|
+
|
|
98
|
+
| Env var | Required | Meaning |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `MIRAGEA_API_KEY` | yes | Your key from [miragea.ai](https://miragea.ai) |
|
|
101
|
+
| `MIRAGEA_VOICE_ID` | no | Default saved voice, so the agent never has to ask |
|
|
102
|
+
| `MIRAGEA_REF_URL`, `MIRAGEA_REF_TEXT` | no | Default reference clip (public URL) and its exact words |
|
|
103
|
+
| `MIRAGEA_OUTPUT_DIR` | no | Where files go when no path is given (default `~/Miragea`) |
|
|
104
|
+
|
|
105
|
+
Requirements: Python 3.10+. ffmpeg is optional (without it audio is saved as WAV and videos are uploaded whole).
|
|
106
|
+
|
|
107
|
+
## FAQ
|
|
108
|
+
|
|
109
|
+
**Which MCP clients work?** Any stdio MCP client: Claude Desktop, Claude Code, Cursor, Windsurf, VS Code
|
|
110
|
+
(Copilot agent mode), Codex CLI, Gemini CLI, Cline, Zed.
|
|
111
|
+
|
|
112
|
+
**How do I use my own voice?** Save your voice once in your Miragea account and set `MIRAGEA_VOICE_ID`, or
|
|
113
|
+
pass `reference_audio_url` (a public link to a clean 5-20 s clip) with `reference_text` (its exact words,
|
|
114
|
+
which uses the cheaper 1x rate). A direct upload of local reference clips is planned by Miragea; until then
|
|
115
|
+
the reference must be a public URL.
|
|
116
|
+
|
|
117
|
+
**What does it cost?** Speech is billed per character, captions at 3 points per audio second (live rates:
|
|
118
|
+
`GET https://miragea.ai/v1/capabilities`). Cached repeats are free.
|
|
119
|
+
|
|
120
|
+
**Is my key safe?** It is read from the server's environment only, never written to disk, logged or returned.
|
|
121
|
+
|
|
122
|
+
**Can I clone anyone's voice?** No. Only clone your own voice or one you have permission to use.
|
|
123
|
+
|
|
124
|
+
**Where are files saved?** Where you ask, or in `MIRAGEA_OUTPUT_DIR` (default `~/Miragea`). The cache lives in
|
|
125
|
+
`~/Miragea/.cache` and can be deleted at any time.
|
|
126
|
+
|
|
127
|
+
## Related
|
|
128
|
+
|
|
129
|
+
- [miragea-skills](https://github.com/yyert123/miragea-skills): Agent Skills for screen-recording voiceover, Shorts, captions and Remotion narration.
|
|
130
|
+
- [Retake](https://github.com/yyert123/retake): fix a flubbed voiceover line in your own voice (CLI + MCP).
|
|
131
|
+
- [HeadVox](https://github.com/yyert123/headvox): script to voiceover with word-aligned captions.
|
|
132
|
+
|
|
133
|
+
MIT License.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Miragea MCP: voiceover, voice cloning and captions for Claude, Cursor and Codex
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.yyert123/miragea-mcp -->
|
|
4
|
+
|
|
5
|
+
An MCP server that gives your AI agent a real voice toolkit: **text of any length to speech** in your saved
|
|
6
|
+
or cloned voice, **exact durations** for every file, **readable SRT/VTT captions** from any local audio or
|
|
7
|
+
video, and **per-scene narration** with `durationInFrames` and word-level captions for Remotion and other
|
|
8
|
+
video timelines. Local stdio server, one line of config, no GPU. Voice and speech recognition by
|
|
9
|
+
[Miragea](https://miragea.ai).
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"mcpServers": {
|
|
14
|
+
"miragea": {
|
|
15
|
+
"command": "uvx",
|
|
16
|
+
"args": ["miragea-mcp"],
|
|
17
|
+
"env": { "MIRAGEA_API_KEY": "your_key", "MIRAGEA_VOICE_ID": "optional_default_voice" }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Claude Code: `claude mcp add miragea -e MIRAGEA_API_KEY=your_key -- uvx miragea-mcp`.
|
|
24
|
+
No uv? `pip install miragea-mcp` and use `"command": "miragea-mcp"`. Check everything with
|
|
25
|
+
`uvx miragea-mcp --check`.
|
|
26
|
+
|
|
27
|
+
Then just ask:
|
|
28
|
+
|
|
29
|
+
- *"Read this changelog aloud in my voice and save it as release-notes.mp3."*
|
|
30
|
+
- *"Make captions for ~/Videos/talk.mp4."* / *"TikTok-style captions for clip.mov."*
|
|
31
|
+
- *"Narrate these 5 scenes for my Remotion video and set each scene's durationInFrames."*
|
|
32
|
+
|
|
33
|
+
## Tools
|
|
34
|
+
|
|
35
|
+
| Tool | What it does | Returns |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `check_setup` | Checks key, ffmpeg, saved voices, output folder; gives a fix for each problem | status + problems |
|
|
38
|
+
| `list_voices` | Voices saved in your Miragea account | `voice_id`, `name` |
|
|
39
|
+
| `speak` | Any length of text to speech (split at sentence ends under the 2,000-char API limit, joined back seamlessly). Saved voice, or clone from a public clip | file path, **exact seconds**, chunks |
|
|
40
|
+
| `transcribe` | Local audio/video to SRT + VTT: whole words (no mid-word splits), 2 x 42-char lines, min/max cue time; `style="shorts"` for 1-3 word uppercase captions | srt/vtt paths, text, language |
|
|
41
|
+
| `narrate_scenes` | One audio file per scene, `seconds`, `durationInFrames` (rounded up, never cut off), `startFrame`, a `narration.json` manifest; `captions=true` adds word-level `captions.json` in Remotion's `Caption` shape | manifest |
|
|
42
|
+
|
|
43
|
+
## What it does for you automatically
|
|
44
|
+
|
|
45
|
+
- **Long text**: chunked at sentence boundaries and stitched with a natural 120 ms pause. You never see
|
|
46
|
+
the 2,000-character limit.
|
|
47
|
+
- **Durations**: measured from the audio itself, so video timelines line up to the frame.
|
|
48
|
+
- **Captions**: speech-recognition segments are rebuilt into whole words and re-flowed with broadcast rules.
|
|
49
|
+
- **Uploads**: video files are reduced to a 16 kHz mono track before upload (fast, small).
|
|
50
|
+
- **Caching**: the same text, voice and model is never billed twice; the same file is never transcribed twice.
|
|
51
|
+
- **Safety**: existing files are never overwritten (`name-2.mp3`), the key is only read from the environment
|
|
52
|
+
and never logged, failed audio tasks are refunded by Miragea, and network retries reuse the same
|
|
53
|
+
idempotency key so a retry cannot double-charge.
|
|
54
|
+
- **Clear errors**: every error comes back as `{"error", "how_to_fix"}` for the agent to act on.
|
|
55
|
+
|
|
56
|
+
## Compared with other voice MCP servers
|
|
57
|
+
|
|
58
|
+
| | **miragea-mcp** | ElevenLabs MCP | Thin TTS wrappers (Edge, Kokoro, Azure...) |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| Local stdio server, maintained | Yes | Local server deprecated; hosted OAuth server instead | Varies |
|
|
61
|
+
| Reads/writes files anywhere you point it | Yes (default `~/Miragea`) | Local version restricted inputs to one base folder | Usually output folder only |
|
|
62
|
+
| Returns exact audio duration | Yes, every call | No (callers had to measure it) | Rarely |
|
|
63
|
+
| Text longer than one API request | Automatic split and join | Manual | Varies |
|
|
64
|
+
| Captions (SRT/VTT) from local files | Yes, reflowed whole words | Separate speech-to-text tool | No |
|
|
65
|
+
| Scene durations in frames + Remotion captions | Yes (`narrate_scenes`) | No | No |
|
|
66
|
+
| Your own cloned voice | Saved voice id or reference clip | Yes (paid plans) | Stock voices only |
|
|
67
|
+
| Billing | Pay-as-you-go Miragea points | Subscription credits | Free / cloud account |
|
|
68
|
+
|
|
69
|
+
Sources: ElevenLabs MCP [README](https://github.com/elevenlabs/elevenlabs-mcp) (deprecation notice) and the
|
|
70
|
+
[remotion-superpowers changelog](https://github.com/DojoCodingLabs/remotion-superpowers/blob/main/CHANGELOG.md)
|
|
71
|
+
(base-folder restriction, no duration returned).
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
| Env var | Required | Meaning |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `MIRAGEA_API_KEY` | yes | Your key from [miragea.ai](https://miragea.ai) |
|
|
78
|
+
| `MIRAGEA_VOICE_ID` | no | Default saved voice, so the agent never has to ask |
|
|
79
|
+
| `MIRAGEA_REF_URL`, `MIRAGEA_REF_TEXT` | no | Default reference clip (public URL) and its exact words |
|
|
80
|
+
| `MIRAGEA_OUTPUT_DIR` | no | Where files go when no path is given (default `~/Miragea`) |
|
|
81
|
+
|
|
82
|
+
Requirements: Python 3.10+. ffmpeg is optional (without it audio is saved as WAV and videos are uploaded whole).
|
|
83
|
+
|
|
84
|
+
## FAQ
|
|
85
|
+
|
|
86
|
+
**Which MCP clients work?** Any stdio MCP client: Claude Desktop, Claude Code, Cursor, Windsurf, VS Code
|
|
87
|
+
(Copilot agent mode), Codex CLI, Gemini CLI, Cline, Zed.
|
|
88
|
+
|
|
89
|
+
**How do I use my own voice?** Save your voice once in your Miragea account and set `MIRAGEA_VOICE_ID`, or
|
|
90
|
+
pass `reference_audio_url` (a public link to a clean 5-20 s clip) with `reference_text` (its exact words,
|
|
91
|
+
which uses the cheaper 1x rate). A direct upload of local reference clips is planned by Miragea; until then
|
|
92
|
+
the reference must be a public URL.
|
|
93
|
+
|
|
94
|
+
**What does it cost?** Speech is billed per character, captions at 3 points per audio second (live rates:
|
|
95
|
+
`GET https://miragea.ai/v1/capabilities`). Cached repeats are free.
|
|
96
|
+
|
|
97
|
+
**Is my key safe?** It is read from the server's environment only, never written to disk, logged or returned.
|
|
98
|
+
|
|
99
|
+
**Can I clone anyone's voice?** No. Only clone your own voice or one you have permission to use.
|
|
100
|
+
|
|
101
|
+
**Where are files saved?** Where you ask, or in `MIRAGEA_OUTPUT_DIR` (default `~/Miragea`). The cache lives in
|
|
102
|
+
`~/Miragea/.cache` and can be deleted at any time.
|
|
103
|
+
|
|
104
|
+
## Related
|
|
105
|
+
|
|
106
|
+
- [miragea-skills](https://github.com/yyert123/miragea-skills): Agent Skills for screen-recording voiceover, Shorts, captions and Remotion narration.
|
|
107
|
+
- [Retake](https://github.com/yyert123/retake): fix a flubbed voiceover line in your own voice (CLI + MCP).
|
|
108
|
+
- [HeadVox](https://github.com/yyert123/headvox): script to voiceover with word-aligned captions.
|
|
109
|
+
|
|
110
|
+
MIT License.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "miragea-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for AI voiceover, voice cloning and captions: text of any length to speech with exact durations, SRT/VTT from local files, per-scene narration for Remotion. Works with Claude, Cursor, Codex."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "HeadVox" }]
|
|
13
|
+
keywords = ["mcp", "mcp-server", "model-context-protocol", "text-to-speech", "tts", "voiceover", "voice-cloning", "captions", "subtitles", "srt", "remotion", "claude", "cursor", "elevenlabs-alternative", "miragea"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
19
|
+
"Topic :: Multimedia :: Video",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["mcp>=1.2"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/yyert123/miragea-mcp"
|
|
26
|
+
Repository = "https://github.com/yyert123/miragea-mcp"
|
|
27
|
+
Demo = "https://huggingface.co/spaces/headvox/miragea-mcp"
|
|
28
|
+
"Voice engine" = "https://miragea.ai"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
miragea-mcp = "miragea_mcp.server:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Turn Miragea's segment transcript into readable caption cues (stdlib only).
|
|
2
|
+
|
|
3
|
+
Miragea segments are timed by audio, not by words, and can split mid-word
|
|
4
|
+
("cen" | "ts"). We place every character of the full transcript in time, rebuild
|
|
5
|
+
whole words, then group words into cues that follow subtitle conventions.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
PRESETS = {
|
|
12
|
+
# Netflix-style limits: 42 chars/line, 2 lines, 1-7 s, ~17 chars/s.
|
|
13
|
+
"standard": {"max_chars": 42, "max_lines": 2, "max_dur": 7.0, "min_dur": 1.0, "max_words": 99},
|
|
14
|
+
# Short-form vertical video: punchy 1-3 word cues, one line.
|
|
15
|
+
"shorts": {"max_chars": 16, "max_lines": 1, "max_dur": 2.0, "min_dur": 0.35, "max_words": 3},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def char_times(text: str, segments: list) -> list:
|
|
20
|
+
alnum_pos = [i for i, c in enumerate(text) if c.isalnum()]
|
|
21
|
+
alnum = "".join(text[i].lower() for i in alnum_pos)
|
|
22
|
+
times = [None] * len(text)
|
|
23
|
+
cursor = 0
|
|
24
|
+
for seg in segments:
|
|
25
|
+
s = "".join(c.lower() for c in seg.get("text", "") if c.isalnum())
|
|
26
|
+
if not s:
|
|
27
|
+
continue
|
|
28
|
+
at = alnum.find(s, cursor)
|
|
29
|
+
if at < 0:
|
|
30
|
+
at = alnum.find(s[:4], cursor)
|
|
31
|
+
if at < 0:
|
|
32
|
+
continue
|
|
33
|
+
end = min(at + len(s), len(alnum_pos)) - 1
|
|
34
|
+
n = max(1, end - at)
|
|
35
|
+
for k in range(at, end + 1):
|
|
36
|
+
times[alnum_pos[k]] = seg["start"] + (k - at) / n * (seg["end"] - seg["start"])
|
|
37
|
+
cursor = end + 1
|
|
38
|
+
known = [i for i, t in enumerate(times) if t is not None]
|
|
39
|
+
if not known:
|
|
40
|
+
return times
|
|
41
|
+
j = 0
|
|
42
|
+
for i in range(len(times)):
|
|
43
|
+
if times[i] is None:
|
|
44
|
+
while j + 1 < len(known) and known[j + 1] < i:
|
|
45
|
+
j += 1
|
|
46
|
+
left = known[j] if known[j] < i else None
|
|
47
|
+
right = next((k for k in known[j:] if k > i), None)
|
|
48
|
+
if left is None:
|
|
49
|
+
times[i] = times[right]
|
|
50
|
+
elif right is None:
|
|
51
|
+
times[i] = times[left]
|
|
52
|
+
else:
|
|
53
|
+
times[i] = times[left] + (i - left) / (right - left) * (times[right] - times[left])
|
|
54
|
+
return times
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def words(transcript: dict) -> list:
|
|
58
|
+
"""Return [{'text', 'start', 'end'}] for every whole word."""
|
|
59
|
+
text = transcript.get("text") or " ".join(s.get("text", "") for s in transcript.get("segments", []))
|
|
60
|
+
times = char_times(text, transcript.get("segments", []))
|
|
61
|
+
out = []
|
|
62
|
+
for m in re.finditer(r"\S+", text):
|
|
63
|
+
idx = [i for i in range(m.start(), m.end()) if text[i].isalnum()] or [m.start()]
|
|
64
|
+
if times[idx[0]] is None:
|
|
65
|
+
continue
|
|
66
|
+
out.append({"text": m.group(), "start": times[idx[0]], "end": times[idx[-1]]})
|
|
67
|
+
# A word ends where the next begins at the latest; give the last word its segment end.
|
|
68
|
+
segs = transcript.get("segments") or []
|
|
69
|
+
if out and segs:
|
|
70
|
+
out[-1]["end"] = max(out[-1]["end"], segs[-1]["end"])
|
|
71
|
+
for a, b in zip(out, out[1:]):
|
|
72
|
+
a["end"] = max(a["end"], min(b["start"], a["start"] + 0.08))
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _wrap(text: str, max_chars: int, max_lines: int) -> str:
|
|
77
|
+
if len(text) <= max_chars or max_lines == 1:
|
|
78
|
+
return text
|
|
79
|
+
ws = text.split()
|
|
80
|
+
best, best_cost = text, 1e9
|
|
81
|
+
for k in range(1, len(ws)): # most balanced 2-line split
|
|
82
|
+
a, b = " ".join(ws[:k]), " ".join(ws[k:])
|
|
83
|
+
cost = abs(len(a) - len(b)) + (100 if max(len(a), len(b)) > max_chars else 0)
|
|
84
|
+
if cost < best_cost:
|
|
85
|
+
best, best_cost = a + "\n" + b, cost
|
|
86
|
+
return best
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_cues(ws: list, preset: str = "standard", upper: bool = False) -> list:
|
|
90
|
+
p = PRESETS[preset]
|
|
91
|
+
limit = p["max_chars"] * p["max_lines"]
|
|
92
|
+
cues, cur = [], []
|
|
93
|
+
|
|
94
|
+
def flush():
|
|
95
|
+
if cur:
|
|
96
|
+
txt = " ".join(w["text"] for w in cur)
|
|
97
|
+
if preset == "shorts":
|
|
98
|
+
txt = txt.strip(",;:")
|
|
99
|
+
cues.append({"start": cur[0]["start"], "end": cur[-1]["end"], "text": txt.upper() if upper else txt})
|
|
100
|
+
cur.clear()
|
|
101
|
+
|
|
102
|
+
for w in ws:
|
|
103
|
+
if cur:
|
|
104
|
+
joined = len(" ".join(x["text"] for x in cur)) + 1 + len(w["text"])
|
|
105
|
+
gap = w["start"] - cur[-1]["end"]
|
|
106
|
+
if (joined > limit or len(cur) >= p["max_words"] or gap > 0.6
|
|
107
|
+
or w["end"] - cur[0]["start"] > p["max_dur"]):
|
|
108
|
+
flush()
|
|
109
|
+
cur.append(w)
|
|
110
|
+
if re.search(r"[.!?…]['\")\]]*$", w["text"]) and not re.fullmatch(r"[A-Z]\.", w["text"]) and (preset == "shorts" or len(cur) >= 3):
|
|
111
|
+
flush()
|
|
112
|
+
elif preset == "standard" and re.search(r"[,;:—]$", w["text"]) and len(" ".join(x["text"] for x in cur)) > limit * 0.6:
|
|
113
|
+
flush()
|
|
114
|
+
flush()
|
|
115
|
+
# Minimum on-screen time without overlapping the next cue.
|
|
116
|
+
for a, b in zip(cues, cues[1:] + [None]):
|
|
117
|
+
want = a["start"] + p["min_dur"]
|
|
118
|
+
nxt = b["start"] - 0.04 if b else want
|
|
119
|
+
a["end"] = max(a["end"], min(want, nxt))
|
|
120
|
+
for c in cues:
|
|
121
|
+
c["text"] = _wrap(c["text"], p["max_chars"], p["max_lines"])
|
|
122
|
+
return cues
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _ts(t: float, sep: str) -> str:
|
|
126
|
+
ms = int(round(max(t, 0) * 1000))
|
|
127
|
+
return f"{ms // 3600000:02d}:{ms // 60000 % 60:02d}:{ms // 1000 % 60:02d}{sep}{ms % 1000:03d}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def to_srt(cues: list) -> str:
|
|
131
|
+
return "\n".join(f"{i}\n{_ts(c['start'], ',')} --> {_ts(c['end'], ',')}\n{c['text']}\n"
|
|
132
|
+
for i, c in enumerate(cues, 1))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def to_vtt(cues: list) -> str:
|
|
136
|
+
return "WEBVTT\n\n" + "\n".join(f"{_ts(c['start'], '.')} --> {_ts(c['end'], '.')}\n{c['text']}\n" for c in cues)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Minimal Miragea API client (https://miragea.ai/v1), standard library only.
|
|
2
|
+
|
|
3
|
+
The API key is read from the MIRAGEA_API_KEY environment variable and is never
|
|
4
|
+
logged, cached or written to disk.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE = "https://miragea.ai/v1"
|
|
20
|
+
# Miragea sits behind Cloudflare, which rejects default library user agents.
|
|
21
|
+
USER_AGENT = f"miragea-mcp/{__version__} (+https://github.com/yyert123/miragea-mcp)"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MirageaError(RuntimeError):
|
|
25
|
+
"""An API or network error with a human-readable fix hint."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, hint: str = "", status: int | None = None, code: str = ""):
|
|
28
|
+
self.hint = hint
|
|
29
|
+
self.status = status
|
|
30
|
+
self.code = code
|
|
31
|
+
super().__init__(message + (f"\n How to fix: {hint}" if hint else ""))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_HINTS = {
|
|
35
|
+
401: "Your MIRAGEA_API_KEY was rejected. Create a key at https://miragea.ai and export MIRAGEA_API_KEY=...",
|
|
36
|
+
402: "Your Miragea balance is too low for this job. Top up at https://miragea.ai.",
|
|
37
|
+
403: "This key has no API access. API access is enabled per account at https://miragea.ai.",
|
|
38
|
+
413: "The file is too large for one request (limit: 1024 MB / 90 min). Trim it first.",
|
|
39
|
+
429: "Rate limited or out of quota. Wait a minute and run the same command again (cached steps are not re-billed).",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Client:
|
|
44
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None, timeout: float = 60):
|
|
45
|
+
self.key = api_key or os.environ.get("MIRAGEA_API_KEY", "")
|
|
46
|
+
if not self.key:
|
|
47
|
+
raise MirageaError(
|
|
48
|
+
"MIRAGEA_API_KEY is not set.",
|
|
49
|
+
"Get a key at https://miragea.ai (API section), then run: export MIRAGEA_API_KEY=your_key",
|
|
50
|
+
code="missing_api_key",
|
|
51
|
+
)
|
|
52
|
+
self.base = (base_url or os.environ.get("MIRAGEA_BASE_URL") or DEFAULT_BASE).rstrip("/")
|
|
53
|
+
self.timeout = timeout
|
|
54
|
+
|
|
55
|
+
# -- low level -------------------------------------------------------
|
|
56
|
+
def _request(self, method: str, path: str, body: bytes | None = None, content_type: str | None = None,
|
|
57
|
+
idem_key: str | None = None) -> dict:
|
|
58
|
+
headers = {"Authorization": "Bearer " + self.key, "Accept": "application/json", "User-Agent": USER_AGENT}
|
|
59
|
+
if content_type:
|
|
60
|
+
headers["Content-Type"] = content_type
|
|
61
|
+
if idem_key:
|
|
62
|
+
headers["Idempotency-Key"] = idem_key
|
|
63
|
+
req = urllib.request.Request(self.base + path, data=body, headers=headers, method=method)
|
|
64
|
+
last: Exception | None = None
|
|
65
|
+
for attempt in range(4):
|
|
66
|
+
try:
|
|
67
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
68
|
+
return json.load(resp)
|
|
69
|
+
except urllib.error.HTTPError as exc:
|
|
70
|
+
try:
|
|
71
|
+
err = json.load(exc).get("error", {})
|
|
72
|
+
except Exception: # noqa: BLE001
|
|
73
|
+
err = {}
|
|
74
|
+
retryable = err.get("retryable") or exc.code in (502, 503, 504)
|
|
75
|
+
if retryable and attempt < 3:
|
|
76
|
+
time.sleep(2 * (attempt + 1))
|
|
77
|
+
continue
|
|
78
|
+
msg = err.get("message") or f"Miragea API returned HTTP {exc.code}"
|
|
79
|
+
raise MirageaError(msg, _HINTS.get(exc.code, ""), exc.code, err.get("code", "")) from None
|
|
80
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
81
|
+
last = exc
|
|
82
|
+
if attempt < 3:
|
|
83
|
+
time.sleep(2 * (attempt + 1))
|
|
84
|
+
continue
|
|
85
|
+
raise MirageaError(f"Could not reach {self.base}: {last}",
|
|
86
|
+
"Check your internet connection or MIRAGEA_BASE_URL, then re-run; the same request is retried safely.")
|
|
87
|
+
|
|
88
|
+
def wait(self, task: dict, timeout: float = 900) -> dict:
|
|
89
|
+
deadline = time.time() + timeout
|
|
90
|
+
while task.get("status") not in ("succeeded", "failed"):
|
|
91
|
+
if time.time() > deadline:
|
|
92
|
+
raise MirageaError(f"Task {task.get('task_id')} still {task.get('status')} after {timeout:.0f}s.",
|
|
93
|
+
"Re-run the same command later; finished results are cached.")
|
|
94
|
+
time.sleep(max(1.0, float(task.get("poll_after_seconds") or 3)))
|
|
95
|
+
task = self._request("GET", "/tasks/" + urllib.parse.quote(task["task_id"], safe=""))
|
|
96
|
+
if task["status"] == "failed":
|
|
97
|
+
err = task.get("error") or {}
|
|
98
|
+
raise MirageaError(f"Miragea task {task.get('task_id')} failed: {err.get('message', 'unknown error')}",
|
|
99
|
+
"Points are refunded automatically for failed audio tasks. Check the input and retry.")
|
|
100
|
+
return task
|
|
101
|
+
|
|
102
|
+
# -- endpoints -------------------------------------------------------
|
|
103
|
+
def capabilities(self) -> dict:
|
|
104
|
+
return self._request("GET", "/capabilities")
|
|
105
|
+
|
|
106
|
+
def voices(self, limit: int = 50, offset: int = 0) -> dict:
|
|
107
|
+
return self._request("GET", f"/actors?limit={int(limit)}&offset={int(offset)}")
|
|
108
|
+
|
|
109
|
+
def transcribe(self, path: str | Path) -> dict:
|
|
110
|
+
"""Upload a local audio/video file; returns {text, language, segments[{start,end,text}], srt}."""
|
|
111
|
+
path = Path(path)
|
|
112
|
+
rate = self.capabilities().get("subtitles", {}).get("points_per_second", 3)
|
|
113
|
+
boundary = "mskill-" + uuid.uuid4().hex
|
|
114
|
+
head = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"expected_rate\"\r\n\r\n{rate}\r\n"
|
|
115
|
+
f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{path.name}\"\r\n"
|
|
116
|
+
f"Content-Type: application/octet-stream\r\n\r\n").encode()
|
|
117
|
+
body = head + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
|
118
|
+
task = self._request("POST", "/subtitles/recognitions", body, "multipart/form-data; boundary=" + boundary,
|
|
119
|
+
str(uuid.uuid4()))
|
|
120
|
+
return self.wait(task)["result"]
|
|
121
|
+
|
|
122
|
+
def speak(self, text: str, out_path: str | Path, *, actor_id: str | None = None,
|
|
123
|
+
reference_audio_url: str | None = None, reference_text: str | None = None,
|
|
124
|
+
model: str = "expressive", fmt: str = "wav", speed: float | None = None) -> Path:
|
|
125
|
+
"""Generate speech in a saved voice (actor_id) or a cloned reference voice; saves to out_path."""
|
|
126
|
+
if not (actor_id or reference_audio_url):
|
|
127
|
+
raise MirageaError("No voice given.", "Pass voice_id (a voice saved in your Miragea account, see list_voices) or reference_audio_url (a public clip), or set MIRAGEA_VOICE_ID.")
|
|
128
|
+
payload: dict = {"text": text, "model": model, "format": fmt}
|
|
129
|
+
if actor_id:
|
|
130
|
+
payload["actor_id"] = actor_id
|
|
131
|
+
else:
|
|
132
|
+
payload["reference_audio_url"] = reference_audio_url
|
|
133
|
+
if reference_text:
|
|
134
|
+
payload["reference_text"] = reference_text
|
|
135
|
+
if speed:
|
|
136
|
+
payload["speed"] = speed
|
|
137
|
+
task = self._request("POST", "/audio/generations", json.dumps(payload).encode(), "application/json",
|
|
138
|
+
str(uuid.uuid4()))
|
|
139
|
+
url = self.wait(task)["result"]["audio_url"]
|
|
140
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
141
|
+
out_path = Path(out_path)
|
|
142
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
143
|
+
out_path.write_bytes(resp.read())
|
|
144
|
+
return out_path
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Voice, captions and scene narration on top of the Miragea API. No MCP imports here (easy to test)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import wave
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .captions import build_cues, to_srt, to_vtt, words
|
|
15
|
+
from .client import Client, MirageaError
|
|
16
|
+
|
|
17
|
+
MAX_CHARS = 1900 # API limit is 2000 per request; keep a margin
|
|
18
|
+
FORMATS = ("mp3", "wav", "flac", "opus", "aac")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------- helpers
|
|
22
|
+
def output_dir() -> Path:
|
|
23
|
+
d = Path(os.environ.get("MIRAGEA_OUTPUT_DIR") or Path.home() / "Miragea").expanduser()
|
|
24
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
return d
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def slug(text: str, n: int = 40) -> str:
|
|
29
|
+
s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
30
|
+
return (s[:n].rstrip("-") or "speech")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def unique(path: Path) -> Path:
|
|
34
|
+
"""Never overwrite the user's files: add -2, -3, ... when the name is taken."""
|
|
35
|
+
if not path.exists():
|
|
36
|
+
return path
|
|
37
|
+
i = 2
|
|
38
|
+
while True:
|
|
39
|
+
cand = path.with_name(f"{path.stem}-{i}{path.suffix}")
|
|
40
|
+
if not cand.exists():
|
|
41
|
+
return cand
|
|
42
|
+
i += 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_out(output_path: str | None, text: str, fmt: str) -> Path:
|
|
46
|
+
if output_path:
|
|
47
|
+
p = Path(output_path).expanduser()
|
|
48
|
+
if not p.is_absolute():
|
|
49
|
+
p = output_dir() / p
|
|
50
|
+
if p.suffix.lower().lstrip(".") not in FORMATS:
|
|
51
|
+
p = p.with_name(p.name + "." + fmt)
|
|
52
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
return unique(p)
|
|
54
|
+
return unique(output_dir() / f"{slug(text)}.{fmt}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def chunk_text(text: str, limit: int = MAX_CHARS) -> list[str]:
|
|
58
|
+
"""Split at sentence ends (then commas, then spaces) so every piece is under the API limit."""
|
|
59
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
60
|
+
if len(text) <= limit:
|
|
61
|
+
return [text] if text else []
|
|
62
|
+
parts, cur = [], ""
|
|
63
|
+
for sent in re.split(r"(?<=[.!?…])\s+", text):
|
|
64
|
+
while len(sent) > limit: # a monster sentence: cut at the last comma/space before the limit
|
|
65
|
+
cut = max(sent.rfind(", ", 0, limit), sent.rfind(" ", 0, limit))
|
|
66
|
+
cut = cut if cut > limit // 3 else limit
|
|
67
|
+
head, sent = sent[:cut + 1].strip(), sent[cut + 1:].strip()
|
|
68
|
+
if cur:
|
|
69
|
+
parts.append(cur)
|
|
70
|
+
cur = ""
|
|
71
|
+
parts.append(head)
|
|
72
|
+
if cur and len(cur) + 1 + len(sent) > limit:
|
|
73
|
+
parts.append(cur)
|
|
74
|
+
cur = sent
|
|
75
|
+
else:
|
|
76
|
+
cur = f"{cur} {sent}".strip()
|
|
77
|
+
if cur:
|
|
78
|
+
parts.append(cur)
|
|
79
|
+
return parts
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def wav_duration(path: Path) -> float:
|
|
83
|
+
with wave.open(str(path)) as w:
|
|
84
|
+
return w.getnframes() / float(w.getframerate())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def join_wavs(paths: list[Path], out: Path, gap: float = 0.12) -> None:
|
|
88
|
+
with wave.open(str(paths[0])) as w0:
|
|
89
|
+
params = w0.getparams()
|
|
90
|
+
silence = b"\x00" * int(params.framerate * gap) * params.sampwidth * params.nchannels
|
|
91
|
+
with wave.open(str(out), "wb") as o:
|
|
92
|
+
o.setparams(params)
|
|
93
|
+
for i, p in enumerate(paths):
|
|
94
|
+
with wave.open(str(p)) as w:
|
|
95
|
+
if (w.getframerate(), w.getsampwidth(), w.getnchannels()) != (params.framerate, params.sampwidth, params.nchannels):
|
|
96
|
+
raise MirageaError("Audio chunks came back in different formats.", "Re-run; this is unexpected.")
|
|
97
|
+
if i:
|
|
98
|
+
o.writeframes(silence)
|
|
99
|
+
o.writeframes(w.readframes(w.getnframes()))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def has_ffmpeg() -> bool:
|
|
103
|
+
return bool(shutil.which("ffmpeg"))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def encode(wav: Path, out: Path) -> None:
|
|
107
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(wav), str(out)], check=True)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def voice_from(voice_id: str | None, reference_audio_url: str | None, reference_text: str | None) -> dict:
|
|
111
|
+
voice_id = voice_id or (None if reference_audio_url else os.environ.get("MIRAGEA_VOICE_ID"))
|
|
112
|
+
ref = reference_audio_url or (None if voice_id else os.environ.get("MIRAGEA_REF_URL"))
|
|
113
|
+
if voice_id:
|
|
114
|
+
return {"actor_id": voice_id}
|
|
115
|
+
if ref:
|
|
116
|
+
return {"reference_audio_url": ref, "reference_text": reference_text or os.environ.get("MIRAGEA_REF_TEXT")}
|
|
117
|
+
raise MirageaError("No voice chosen.",
|
|
118
|
+
"Pass voice_id (call list_voices to see the voices saved in your Miragea account) or "
|
|
119
|
+
"reference_audio_url (a public URL of a clip you may clone) plus reference_text. "
|
|
120
|
+
"Set MIRAGEA_VOICE_ID in the server's env to make it the default.")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cache_dir() -> Path:
|
|
124
|
+
d = output_dir() / ".cache"
|
|
125
|
+
d.mkdir(exist_ok=True)
|
|
126
|
+
return d
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _speak_chunk(client: Client, text: str, voice: dict, model: str, speed: float | None) -> Path:
|
|
130
|
+
key = hashlib.sha256(json.dumps([text, voice, model, speed], sort_keys=True).encode()).hexdigest()[:24]
|
|
131
|
+
path = cache_dir() / f"{key}.wav"
|
|
132
|
+
if not (path.exists() and path.stat().st_size > 100):
|
|
133
|
+
tmp = path.with_name(path.stem + ".part.wav")
|
|
134
|
+
client.speak(text, tmp, model=model, fmt="wav", speed=speed, **voice)
|
|
135
|
+
tmp.replace(path)
|
|
136
|
+
return path
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------- tools
|
|
140
|
+
def speak(text: str, output_path: str | None = None, voice_id: str | None = None,
|
|
141
|
+
reference_audio_url: str | None = None, reference_text: str | None = None, model: str = "expressive",
|
|
142
|
+
speed: float | None = None, format: str = "mp3", client: Client | None = None) -> dict:
|
|
143
|
+
if not text or not text.strip():
|
|
144
|
+
raise MirageaError("Nothing to say.", "Pass the text to speak.")
|
|
145
|
+
fmt = format.lower().lstrip(".")
|
|
146
|
+
if fmt not in FORMATS:
|
|
147
|
+
raise MirageaError(f"Unsupported format '{format}'.", f"Use one of: {', '.join(FORMATS)}.")
|
|
148
|
+
voice = voice_from(voice_id, reference_audio_url, reference_text)
|
|
149
|
+
client = client or Client()
|
|
150
|
+
pieces = [_speak_chunk(client, c, voice, model, speed) for c in chunk_text(text)]
|
|
151
|
+
note = None
|
|
152
|
+
if fmt != "wav" and not has_ffmpeg():
|
|
153
|
+
fmt, note = "wav", "Saved as WAV because ffmpeg is not installed (install ffmpeg for mp3/flac/opus/aac)."
|
|
154
|
+
out = resolve_out(output_path, text, fmt)
|
|
155
|
+
with tempfile.TemporaryDirectory() as td:
|
|
156
|
+
joined = Path(td) / "joined.wav"
|
|
157
|
+
join_wavs(pieces, joined) if len(pieces) > 1 else shutil.copy(pieces[0], joined)
|
|
158
|
+
seconds = wav_duration(joined)
|
|
159
|
+
shutil.copy(joined, out) if fmt == "wav" else encode(joined, out)
|
|
160
|
+
res = {"path": str(out), "seconds": round(seconds, 3), "characters": len(text), "chunks": len(pieces),
|
|
161
|
+
"voice": voice.get("actor_id") or "reference clip", "model": model}
|
|
162
|
+
if note:
|
|
163
|
+
res["note"] = note
|
|
164
|
+
return res
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _sha(path: Path) -> str:
|
|
168
|
+
h = hashlib.sha256()
|
|
169
|
+
with open(path, "rb") as f:
|
|
170
|
+
for b in iter(lambda: f.read(1 << 20), b""):
|
|
171
|
+
h.update(b)
|
|
172
|
+
return h.hexdigest()[:16]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def transcript_of(path: Path, client: Client | None = None) -> dict:
|
|
176
|
+
cache = cache_dir() / f"transcript-{_sha(path)}.json"
|
|
177
|
+
if cache.exists():
|
|
178
|
+
return json.loads(cache.read_text())
|
|
179
|
+
client = client or Client()
|
|
180
|
+
with tempfile.TemporaryDirectory() as td:
|
|
181
|
+
src = path
|
|
182
|
+
if has_ffmpeg(): # upload a small 16 kHz mono track instead of a whole video
|
|
183
|
+
src = Path(td) / "audio.flac"
|
|
184
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(path), "-vn", "-ac", "1", "-ar", "16000", str(src)],
|
|
185
|
+
check=True)
|
|
186
|
+
r = client.transcribe(src)
|
|
187
|
+
data = {k: r.get(k) for k in ("text", "language", "segments")}
|
|
188
|
+
cache.write_text(json.dumps(data, ensure_ascii=False))
|
|
189
|
+
return data
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def transcribe(path: str, style: str = "standard", output_dir_: str | None = None,
|
|
193
|
+
client: Client | None = None) -> dict:
|
|
194
|
+
src = Path(path).expanduser()
|
|
195
|
+
if not src.exists():
|
|
196
|
+
raise MirageaError(f"File not found: {src}", "Pass an absolute path to an audio or video file.")
|
|
197
|
+
if style not in ("standard", "shorts"):
|
|
198
|
+
raise MirageaError(f"Unknown style '{style}'.", "Use 'standard' (2 lines x 42 chars) or 'shorts' (1-3 words).")
|
|
199
|
+
tr = transcript_of(src, client)
|
|
200
|
+
cues = build_cues(words(tr), style, upper=(style == "shorts"))
|
|
201
|
+
dest = Path(output_dir_).expanduser() if output_dir_ else src.parent
|
|
202
|
+
if not os.access(dest, os.W_OK):
|
|
203
|
+
dest = output_dir()
|
|
204
|
+
stem = src.stem + (".shorts" if style == "shorts" else "")
|
|
205
|
+
srt, vtt = unique(dest / f"{stem}.srt"), unique(dest / f"{stem}.vtt")
|
|
206
|
+
srt.write_text(to_srt(cues), encoding="utf-8")
|
|
207
|
+
vtt.write_text(to_vtt(cues), encoding="utf-8")
|
|
208
|
+
return {"text": tr.get("text"), "language": tr.get("language"), "srt": str(srt), "vtt": str(vtt),
|
|
209
|
+
"cues": len(cues), "seconds": round(cues[-1]["end"], 3) if cues else 0}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def remotion_captions(tr: dict, offset: float = 0.0) -> list[dict]:
|
|
213
|
+
"""Word-level captions in Remotion's Caption shape (@remotion/captions)."""
|
|
214
|
+
return [{"text": (" " if i else "") + w["text"], "startMs": round((w["start"] + offset) * 1000),
|
|
215
|
+
"endMs": round((w["end"] + offset) * 1000), "timestampMs": round(((w["start"] + w["end"]) / 2 + offset) * 1000),
|
|
216
|
+
"confidence": None} for i, w in enumerate(words(tr))]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def narrate_scenes(scenes: list, output_dir_: str | None = None, fps: int = 30, voice_id: str | None = None,
|
|
220
|
+
reference_audio_url: str | None = None, reference_text: str | None = None,
|
|
221
|
+
model: str = "expressive", captions: bool = False, client: Client | None = None) -> dict:
|
|
222
|
+
if not scenes:
|
|
223
|
+
raise MirageaError("No scenes given.", "Pass a list of scene texts, or objects like {id, text}.")
|
|
224
|
+
items = []
|
|
225
|
+
for i, s in enumerate(scenes, 1):
|
|
226
|
+
sid, text = (s.get("id") or f"scene-{i:02d}", s.get("text", "")) if isinstance(s, dict) else (f"scene-{i:02d}", str(s))
|
|
227
|
+
if not text.strip():
|
|
228
|
+
raise MirageaError(f"Scene {i} has no text.", "Give every scene some narration text.")
|
|
229
|
+
items.append((slug(sid, 30), text))
|
|
230
|
+
dest = Path(output_dir_).expanduser() if output_dir_ else output_dir() / "narration"
|
|
231
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
voice = voice_from(voice_id, reference_audio_url, reference_text)
|
|
233
|
+
client = client or Client()
|
|
234
|
+
out, t = [], 0.0
|
|
235
|
+
fmt = "mp3" if has_ffmpeg() else "wav"
|
|
236
|
+
all_caps: list[dict] = []
|
|
237
|
+
for sid, text in items:
|
|
238
|
+
pieces = [_speak_chunk(client, c, voice, model, None) for c in chunk_text(text)]
|
|
239
|
+
f = dest / f"{sid}.{fmt}" # stable names so a video project can reference them; regenerated in place
|
|
240
|
+
with tempfile.TemporaryDirectory() as td:
|
|
241
|
+
j = Path(td) / "j.wav"
|
|
242
|
+
join_wavs(pieces, j) if len(pieces) > 1 else shutil.copy(pieces[0], j)
|
|
243
|
+
secs = wav_duration(j)
|
|
244
|
+
shutil.copy(j, f) if fmt == "wav" else encode(j, f)
|
|
245
|
+
if captions:
|
|
246
|
+
all_caps += remotion_captions(transcript_of(j, client), offset=t)
|
|
247
|
+
frames = int(-(-secs * fps // 1)) # ceil, so audio is never cut off
|
|
248
|
+
out.append({"id": sid, "file": str(f), "seconds": round(secs, 3), "durationInFrames": frames,
|
|
249
|
+
"startFrame": int(round(t * fps)), "text": text})
|
|
250
|
+
t += frames / fps
|
|
251
|
+
manifest = {"fps": fps, "totalSeconds": round(t, 3), "totalFrames": sum(s["durationInFrames"] for s in out),
|
|
252
|
+
"scenes": out}
|
|
253
|
+
if captions:
|
|
254
|
+
cap = dest / "captions.json"
|
|
255
|
+
cap.write_text(json.dumps(all_caps, indent=1))
|
|
256
|
+
manifest["captions"] = str(cap)
|
|
257
|
+
(dest / "narration.json").write_text(json.dumps(manifest, indent=2))
|
|
258
|
+
manifest["manifest"] = str(dest / "narration.json")
|
|
259
|
+
return manifest
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def check_setup(client_factory=Client) -> dict:
|
|
263
|
+
report: dict = {"api_key": bool(os.environ.get("MIRAGEA_API_KEY")), "ffmpeg": has_ffmpeg(),
|
|
264
|
+
"output_dir": str(output_dir()), "default_voice": os.environ.get("MIRAGEA_VOICE_ID") or
|
|
265
|
+
("reference clip" if os.environ.get("MIRAGEA_REF_URL") else None), "problems": []}
|
|
266
|
+
if not report["ffmpeg"]:
|
|
267
|
+
report["problems"].append("ffmpeg not found: audio is saved as WAV and video files are uploaded whole. "
|
|
268
|
+
"Install: brew install ffmpeg / sudo apt install ffmpeg / winget install ffmpeg.")
|
|
269
|
+
if not report["api_key"]:
|
|
270
|
+
report["problems"].append("MIRAGEA_API_KEY is not set. Create a key at https://miragea.ai and add it to this "
|
|
271
|
+
"server's env in your MCP client config.")
|
|
272
|
+
return report
|
|
273
|
+
try:
|
|
274
|
+
c = client_factory()
|
|
275
|
+
caps = c.capabilities()
|
|
276
|
+
report["models"] = [m.get("id") for m in caps.get("audio", {}).get("models", [])]
|
|
277
|
+
report["max_chars_per_request"] = caps.get("audio", {}).get("max_text_length")
|
|
278
|
+
report["caption_points_per_second"] = caps.get("subtitles", {}).get("points_per_second")
|
|
279
|
+
v = c.voices(limit=50)
|
|
280
|
+
report["saved_voices"] = len(v.get("items", []))
|
|
281
|
+
if not v.get("items") and not report["default_voice"]:
|
|
282
|
+
report["problems"].append("No saved voices in this Miragea account yet. Create one at https://miragea.ai, "
|
|
283
|
+
"or pass reference_audio_url (a public clip) when speaking.")
|
|
284
|
+
except MirageaError as exc:
|
|
285
|
+
report["problems"].append(str(exc))
|
|
286
|
+
report["ok"] = not report["problems"]
|
|
287
|
+
return report
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""MCP server (stdio) exposing Miragea voice and captions to Claude, Cursor, Codex and other MCP clients."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, Optional, Union
|
|
7
|
+
|
|
8
|
+
from . import __version__, core
|
|
9
|
+
from .client import Client, MirageaError
|
|
10
|
+
|
|
11
|
+
INSTRUCTIONS = (
|
|
12
|
+
"Voiceover, voice cloning and captions via the Miragea API. Start with check_setup if unsure. "
|
|
13
|
+
"Use list_voices to find the user's saved voices; speak for any text (long text is split and joined "
|
|
14
|
+
"automatically); transcribe for SRT/VTT captions of a local audio/video file; narrate_scenes for "
|
|
15
|
+
"per-scene audio with exact durations (Remotion/HyperFrames/video timelines). Files are written to "
|
|
16
|
+
"MIRAGEA_OUTPUT_DIR (default ~/Miragea) unless a path is given, and existing files are never overwritten. "
|
|
17
|
+
"Only clone voices the user owns or has permission to use."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _safe(fn, *a, **kw) -> dict:
|
|
22
|
+
try:
|
|
23
|
+
return fn(*a, **kw)
|
|
24
|
+
except MirageaError as exc: # return a readable error with the fix instead of a stack trace
|
|
25
|
+
return {"error": str(exc).split("\n")[0], "how_to_fix": exc.hint or None}
|
|
26
|
+
except Exception as exc: # noqa: BLE001
|
|
27
|
+
return {"error": f"{type(exc).__name__}: {exc}", "how_to_fix": "Check the input paths and try again."}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_server():
|
|
31
|
+
try:
|
|
32
|
+
from mcp.server.fastmcp import FastMCP
|
|
33
|
+
except ImportError: # MCP SDK 2.x
|
|
34
|
+
from mcp.server.mcpserver import MCPServer as FastMCP # type: ignore
|
|
35
|
+
mcp = FastMCP("miragea", instructions=INSTRUCTIONS)
|
|
36
|
+
|
|
37
|
+
@mcp.tool()
|
|
38
|
+
def check_setup() -> dict:
|
|
39
|
+
"""Check the API key, ffmpeg, saved voices and output folder, with a fix for every problem found."""
|
|
40
|
+
return _safe(core.check_setup)
|
|
41
|
+
|
|
42
|
+
@mcp.tool()
|
|
43
|
+
def list_voices(limit: int = 50, offset: int = 0) -> dict:
|
|
44
|
+
"""List voices saved in the user's Miragea account (ids to pass as voice_id)."""
|
|
45
|
+
def run():
|
|
46
|
+
r = Client().voices(limit, offset)
|
|
47
|
+
return {"voices": [{"voice_id": v.get("actor_id") or v.get("id"), "name": v.get("name")}
|
|
48
|
+
for v in r.get("items", [])], "has_more": r.get("has_more", False)}
|
|
49
|
+
return _safe(run)
|
|
50
|
+
|
|
51
|
+
@mcp.tool()
|
|
52
|
+
def speak(text: str, output_path: Optional[str] = None, voice_id: Optional[str] = None,
|
|
53
|
+
reference_audio_url: Optional[str] = None, reference_text: Optional[str] = None,
|
|
54
|
+
model: str = "expressive", speed: Optional[float] = None, format: str = "mp3") -> dict:
|
|
55
|
+
"""Turn text of any length into speech in a saved voice (voice_id) or a voice cloned from a public
|
|
56
|
+
clip (reference_audio_url + its exact words in reference_text, billed at the cheaper 1x rate).
|
|
57
|
+
Returns the saved file path and its exact duration in seconds. format: mp3, wav, flac, opus, aac.
|
|
58
|
+
model: 'expressive' (default) or 'standard'. speed: 0.25-4."""
|
|
59
|
+
return _safe(core.speak, text, output_path, voice_id, reference_audio_url, reference_text, model, speed, format)
|
|
60
|
+
|
|
61
|
+
@mcp.tool()
|
|
62
|
+
def transcribe(path: str, style: str = "standard", output_dir: Optional[str] = None) -> dict:
|
|
63
|
+
"""Transcribe a local audio or video file into readable SRT and VTT captions (whole words, line-length
|
|
64
|
+
and timing rules applied). style: 'standard' (2 lines x 42 chars) or 'shorts' (1-3 words, uppercase).
|
|
65
|
+
Results are cached, so asking again for the same file is free."""
|
|
66
|
+
return _safe(core.transcribe, path, style, output_dir)
|
|
67
|
+
|
|
68
|
+
@mcp.tool()
|
|
69
|
+
def narrate_scenes(scenes: list[Union[str, dict[str, Any]]], output_dir: Optional[str] = None, fps: int = 30,
|
|
70
|
+
voice_id: Optional[str] = None, reference_audio_url: Optional[str] = None,
|
|
71
|
+
reference_text: Optional[str] = None, model: str = "expressive",
|
|
72
|
+
captions: bool = False) -> dict:
|
|
73
|
+
"""Voice a list of scenes (strings or {id, text}) as one audio file per scene and return each scene's
|
|
74
|
+
exact seconds, durationInFrames and startFrame at the given fps, plus a narration.json manifest.
|
|
75
|
+
captions=true also writes word-level captions.json in Remotion's Caption format. Unchanged scenes are
|
|
76
|
+
cached and cost nothing on re-runs."""
|
|
77
|
+
return _safe(core.narrate_scenes, scenes, output_dir, fps, voice_id, reference_audio_url, reference_text,
|
|
78
|
+
model, captions)
|
|
79
|
+
|
|
80
|
+
return mcp
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv=None) -> int:
|
|
84
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
85
|
+
if argv and argv[0] in ("-V", "--version"):
|
|
86
|
+
print(f"miragea-mcp {__version__}")
|
|
87
|
+
return 0
|
|
88
|
+
if argv and argv[0] in ("--check", "check"):
|
|
89
|
+
print(json.dumps(core.check_setup(), indent=2))
|
|
90
|
+
return 0
|
|
91
|
+
if argv and argv[0] in ("-h", "--help"):
|
|
92
|
+
print("miragea-mcp: MCP server (stdio) for Miragea voiceover and captions.\n"
|
|
93
|
+
" miragea-mcp run the server (configure it in your MCP client)\n"
|
|
94
|
+
" miragea-mcp --check check key, ffmpeg and voices\n"
|
|
95
|
+
"Env: MIRAGEA_API_KEY (required), MIRAGEA_VOICE_ID, MIRAGEA_REF_URL, MIRAGEA_REF_TEXT, MIRAGEA_OUTPUT_DIR")
|
|
96
|
+
return 0
|
|
97
|
+
build_server().run()
|
|
98
|
+
return 0
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: miragea-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for AI voiceover, voice cloning and captions: text of any length to speech with exact durations, SRT/VTT from local files, per-scene narration for Remotion. Works with Claude, Cursor, Codex.
|
|
5
|
+
Author: HeadVox
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yyert123/miragea-mcp
|
|
8
|
+
Project-URL: Repository, https://github.com/yyert123/miragea-mcp
|
|
9
|
+
Project-URL: Demo, https://huggingface.co/spaces/headvox/miragea-mcp
|
|
10
|
+
Project-URL: Voice engine, https://miragea.ai
|
|
11
|
+
Keywords: mcp,mcp-server,model-context-protocol,text-to-speech,tts,voiceover,voice-cloning,captions,subtitles,srt,remotion,claude,cursor,elevenlabs-alternative,miragea
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
16
|
+
Classifier: Topic :: Multimedia :: Video
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: mcp>=1.2
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Miragea MCP: voiceover, voice cloning and captions for Claude, Cursor and Codex
|
|
25
|
+
|
|
26
|
+
<!-- mcp-name: io.github.yyert123/miragea-mcp -->
|
|
27
|
+
|
|
28
|
+
An MCP server that gives your AI agent a real voice toolkit: **text of any length to speech** in your saved
|
|
29
|
+
or cloned voice, **exact durations** for every file, **readable SRT/VTT captions** from any local audio or
|
|
30
|
+
video, and **per-scene narration** with `durationInFrames` and word-level captions for Remotion and other
|
|
31
|
+
video timelines. Local stdio server, one line of config, no GPU. Voice and speech recognition by
|
|
32
|
+
[Miragea](https://miragea.ai).
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"miragea": {
|
|
38
|
+
"command": "uvx",
|
|
39
|
+
"args": ["miragea-mcp"],
|
|
40
|
+
"env": { "MIRAGEA_API_KEY": "your_key", "MIRAGEA_VOICE_ID": "optional_default_voice" }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Claude Code: `claude mcp add miragea -e MIRAGEA_API_KEY=your_key -- uvx miragea-mcp`.
|
|
47
|
+
No uv? `pip install miragea-mcp` and use `"command": "miragea-mcp"`. Check everything with
|
|
48
|
+
`uvx miragea-mcp --check`.
|
|
49
|
+
|
|
50
|
+
Then just ask:
|
|
51
|
+
|
|
52
|
+
- *"Read this changelog aloud in my voice and save it as release-notes.mp3."*
|
|
53
|
+
- *"Make captions for ~/Videos/talk.mp4."* / *"TikTok-style captions for clip.mov."*
|
|
54
|
+
- *"Narrate these 5 scenes for my Remotion video and set each scene's durationInFrames."*
|
|
55
|
+
|
|
56
|
+
## Tools
|
|
57
|
+
|
|
58
|
+
| Tool | What it does | Returns |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `check_setup` | Checks key, ffmpeg, saved voices, output folder; gives a fix for each problem | status + problems |
|
|
61
|
+
| `list_voices` | Voices saved in your Miragea account | `voice_id`, `name` |
|
|
62
|
+
| `speak` | Any length of text to speech (split at sentence ends under the 2,000-char API limit, joined back seamlessly). Saved voice, or clone from a public clip | file path, **exact seconds**, chunks |
|
|
63
|
+
| `transcribe` | Local audio/video to SRT + VTT: whole words (no mid-word splits), 2 x 42-char lines, min/max cue time; `style="shorts"` for 1-3 word uppercase captions | srt/vtt paths, text, language |
|
|
64
|
+
| `narrate_scenes` | One audio file per scene, `seconds`, `durationInFrames` (rounded up, never cut off), `startFrame`, a `narration.json` manifest; `captions=true` adds word-level `captions.json` in Remotion's `Caption` shape | manifest |
|
|
65
|
+
|
|
66
|
+
## What it does for you automatically
|
|
67
|
+
|
|
68
|
+
- **Long text**: chunked at sentence boundaries and stitched with a natural 120 ms pause. You never see
|
|
69
|
+
the 2,000-character limit.
|
|
70
|
+
- **Durations**: measured from the audio itself, so video timelines line up to the frame.
|
|
71
|
+
- **Captions**: speech-recognition segments are rebuilt into whole words and re-flowed with broadcast rules.
|
|
72
|
+
- **Uploads**: video files are reduced to a 16 kHz mono track before upload (fast, small).
|
|
73
|
+
- **Caching**: the same text, voice and model is never billed twice; the same file is never transcribed twice.
|
|
74
|
+
- **Safety**: existing files are never overwritten (`name-2.mp3`), the key is only read from the environment
|
|
75
|
+
and never logged, failed audio tasks are refunded by Miragea, and network retries reuse the same
|
|
76
|
+
idempotency key so a retry cannot double-charge.
|
|
77
|
+
- **Clear errors**: every error comes back as `{"error", "how_to_fix"}` for the agent to act on.
|
|
78
|
+
|
|
79
|
+
## Compared with other voice MCP servers
|
|
80
|
+
|
|
81
|
+
| | **miragea-mcp** | ElevenLabs MCP | Thin TTS wrappers (Edge, Kokoro, Azure...) |
|
|
82
|
+
|---|---|---|---|
|
|
83
|
+
| Local stdio server, maintained | Yes | Local server deprecated; hosted OAuth server instead | Varies |
|
|
84
|
+
| Reads/writes files anywhere you point it | Yes (default `~/Miragea`) | Local version restricted inputs to one base folder | Usually output folder only |
|
|
85
|
+
| Returns exact audio duration | Yes, every call | No (callers had to measure it) | Rarely |
|
|
86
|
+
| Text longer than one API request | Automatic split and join | Manual | Varies |
|
|
87
|
+
| Captions (SRT/VTT) from local files | Yes, reflowed whole words | Separate speech-to-text tool | No |
|
|
88
|
+
| Scene durations in frames + Remotion captions | Yes (`narrate_scenes`) | No | No |
|
|
89
|
+
| Your own cloned voice | Saved voice id or reference clip | Yes (paid plans) | Stock voices only |
|
|
90
|
+
| Billing | Pay-as-you-go Miragea points | Subscription credits | Free / cloud account |
|
|
91
|
+
|
|
92
|
+
Sources: ElevenLabs MCP [README](https://github.com/elevenlabs/elevenlabs-mcp) (deprecation notice) and the
|
|
93
|
+
[remotion-superpowers changelog](https://github.com/DojoCodingLabs/remotion-superpowers/blob/main/CHANGELOG.md)
|
|
94
|
+
(base-folder restriction, no duration returned).
|
|
95
|
+
|
|
96
|
+
## Configuration
|
|
97
|
+
|
|
98
|
+
| Env var | Required | Meaning |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `MIRAGEA_API_KEY` | yes | Your key from [miragea.ai](https://miragea.ai) |
|
|
101
|
+
| `MIRAGEA_VOICE_ID` | no | Default saved voice, so the agent never has to ask |
|
|
102
|
+
| `MIRAGEA_REF_URL`, `MIRAGEA_REF_TEXT` | no | Default reference clip (public URL) and its exact words |
|
|
103
|
+
| `MIRAGEA_OUTPUT_DIR` | no | Where files go when no path is given (default `~/Miragea`) |
|
|
104
|
+
|
|
105
|
+
Requirements: Python 3.10+. ffmpeg is optional (without it audio is saved as WAV and videos are uploaded whole).
|
|
106
|
+
|
|
107
|
+
## FAQ
|
|
108
|
+
|
|
109
|
+
**Which MCP clients work?** Any stdio MCP client: Claude Desktop, Claude Code, Cursor, Windsurf, VS Code
|
|
110
|
+
(Copilot agent mode), Codex CLI, Gemini CLI, Cline, Zed.
|
|
111
|
+
|
|
112
|
+
**How do I use my own voice?** Save your voice once in your Miragea account and set `MIRAGEA_VOICE_ID`, or
|
|
113
|
+
pass `reference_audio_url` (a public link to a clean 5-20 s clip) with `reference_text` (its exact words,
|
|
114
|
+
which uses the cheaper 1x rate). A direct upload of local reference clips is planned by Miragea; until then
|
|
115
|
+
the reference must be a public URL.
|
|
116
|
+
|
|
117
|
+
**What does it cost?** Speech is billed per character, captions at 3 points per audio second (live rates:
|
|
118
|
+
`GET https://miragea.ai/v1/capabilities`). Cached repeats are free.
|
|
119
|
+
|
|
120
|
+
**Is my key safe?** It is read from the server's environment only, never written to disk, logged or returned.
|
|
121
|
+
|
|
122
|
+
**Can I clone anyone's voice?** No. Only clone your own voice or one you have permission to use.
|
|
123
|
+
|
|
124
|
+
**Where are files saved?** Where you ask, or in `MIRAGEA_OUTPUT_DIR` (default `~/Miragea`). The cache lives in
|
|
125
|
+
`~/Miragea/.cache` and can be deleted at any time.
|
|
126
|
+
|
|
127
|
+
## Related
|
|
128
|
+
|
|
129
|
+
- [miragea-skills](https://github.com/yyert123/miragea-skills): Agent Skills for screen-recording voiceover, Shorts, captions and Remotion narration.
|
|
130
|
+
- [Retake](https://github.com/yyert123/retake): fix a flubbed voiceover line in your own voice (CLI + MCP).
|
|
131
|
+
- [HeadVox](https://github.com/yyert123/headvox): script to voiceover with word-aligned captions.
|
|
132
|
+
|
|
133
|
+
MIT License.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/miragea_mcp/__init__.py
|
|
5
|
+
src/miragea_mcp/__main__.py
|
|
6
|
+
src/miragea_mcp/captions.py
|
|
7
|
+
src/miragea_mcp/client.py
|
|
8
|
+
src/miragea_mcp/core.py
|
|
9
|
+
src/miragea_mcp/server.py
|
|
10
|
+
src/miragea_mcp.egg-info/PKG-INFO
|
|
11
|
+
src/miragea_mcp.egg-info/SOURCES.txt
|
|
12
|
+
src/miragea_mcp.egg-info/dependency_links.txt
|
|
13
|
+
src/miragea_mcp.egg-info/entry_points.txt
|
|
14
|
+
src/miragea_mcp.egg-info/requires.txt
|
|
15
|
+
src/miragea_mcp.egg-info/top_level.txt
|
|
16
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcp>=1.2
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
miragea_mcp
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Offline tests: a fake Miragea client writes real WAV files, so the whole pipeline runs without a key."""
|
|
2
|
+
import json
|
|
3
|
+
import wave
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from miragea_mcp import core
|
|
9
|
+
from miragea_mcp.client import MirageaError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeClient:
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.calls = []
|
|
15
|
+
|
|
16
|
+
def speak(self, text, out_path, **kw):
|
|
17
|
+
self.calls.append((text, kw))
|
|
18
|
+
n = int(16000 * (0.05 * len(text.split()))) # 50 ms per word
|
|
19
|
+
with wave.open(str(out_path), "wb") as w:
|
|
20
|
+
w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000)
|
|
21
|
+
w.writeframes(b"\x01\x00" * n)
|
|
22
|
+
return Path(out_path)
|
|
23
|
+
|
|
24
|
+
def transcribe(self, path):
|
|
25
|
+
return {"text": "Hello world.", "language": "English",
|
|
26
|
+
"segments": [{"start": 0.0, "end": 1.0, "text": "Hello wor"}, {"start": 1.0, "end": 1.5, "text": "ld."}]}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(autouse=True)
|
|
30
|
+
def env(tmp_path, monkeypatch):
|
|
31
|
+
monkeypatch.setenv("MIRAGEA_OUTPUT_DIR", str(tmp_path / "out"))
|
|
32
|
+
monkeypatch.setenv("MIRAGEA_API_KEY", "test")
|
|
33
|
+
for k in ("MIRAGEA_VOICE_ID", "MIRAGEA_REF_URL", "MIRAGEA_REF_TEXT"):
|
|
34
|
+
monkeypatch.delenv(k, raising=False)
|
|
35
|
+
monkeypatch.setattr(core, "has_ffmpeg", lambda: False) # pure-Python path
|
|
36
|
+
return tmp_path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_chunking_respects_limit_and_keeps_all_text():
|
|
40
|
+
text = " ".join(["A normal sentence, with a comma, for testing."] * 120)
|
|
41
|
+
parts = core.chunk_text(text)
|
|
42
|
+
assert len(parts) > 1 and all(len(p) <= core.MAX_CHARS for p in parts)
|
|
43
|
+
assert " ".join(parts) == text
|
|
44
|
+
giant = "word " * 1000 # no sentence end at all
|
|
45
|
+
assert all(len(p) <= core.MAX_CHARS for p in core.chunk_text(giant))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_speak_long_text_joins_chunks_and_reports_exact_duration():
|
|
49
|
+
fc = FakeClient()
|
|
50
|
+
text = " ".join(["This is sentence number one of many."] * 80)
|
|
51
|
+
r = core.speak(text, voice_id="v1", client=fc)
|
|
52
|
+
assert r["chunks"] == len(fc.calls) > 1
|
|
53
|
+
assert r["path"].endswith(".wav") and "note" in r # no ffmpeg -> WAV with an explanation
|
|
54
|
+
words = len(text.split())
|
|
55
|
+
assert abs(r["seconds"] - (words * 0.05 + 0.12 * (r["chunks"] - 1))) < 0.02
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_speak_never_overwrites_and_caches():
|
|
59
|
+
fc = FakeClient()
|
|
60
|
+
a = core.speak("Hello there.", voice_id="v1", format="wav", client=fc)
|
|
61
|
+
b = core.speak("Hello there.", voice_id="v1", format="wav", client=fc)
|
|
62
|
+
assert a["path"] != b["path"] and len(fc.calls) == 1 # second call served from cache, new file name
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_voice_resolution_and_errors(monkeypatch):
|
|
66
|
+
with pytest.raises(MirageaError, match="No voice"):
|
|
67
|
+
core.speak("Hi.", client=FakeClient())
|
|
68
|
+
monkeypatch.setenv("MIRAGEA_VOICE_ID", "env-voice")
|
|
69
|
+
assert core.voice_from(None, None, None) == {"actor_id": "env-voice"}
|
|
70
|
+
assert core.voice_from(None, "https://x/y.wav", "words")["reference_audio_url"] == "https://x/y.wav"
|
|
71
|
+
with pytest.raises(MirageaError, match="Unsupported format"):
|
|
72
|
+
core.speak("Hi.", format="midi", client=FakeClient())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_narrate_scenes_frames_and_captions(tmp_path):
|
|
76
|
+
fc = FakeClient()
|
|
77
|
+
m = core.narrate_scenes(["One two three four.", {"id": "Outro!", "text": "Bye now."}], str(tmp_path / "n"),
|
|
78
|
+
fps=30, voice_id="v", captions=True, client=fc)
|
|
79
|
+
s1, s2 = m["scenes"]
|
|
80
|
+
assert s1["durationInFrames"] == 6 and s2["startFrame"] == 6 and s2["id"] == "outro"
|
|
81
|
+
assert m["totalFrames"] == s1["durationInFrames"] + s2["durationInFrames"]
|
|
82
|
+
caps = json.loads(Path(m["captions"]).read_text())
|
|
83
|
+
assert caps[0]["text"] == "Hello" and caps[1]["text"] == " world." # whole words even when segments split
|
|
84
|
+
assert caps[2]["startMs"] >= s2["startFrame"] / 30 * 1000 - 1 # second scene offset applied
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_transcribe_writes_srt_vtt(tmp_path):
|
|
88
|
+
f = tmp_path / "a.wav"
|
|
89
|
+
FakeClient().speak("x y", f)
|
|
90
|
+
r = core.transcribe(str(f), client=FakeClient())
|
|
91
|
+
assert Path(r["srt"]).read_text().startswith("1\n00:00:00,000")
|
|
92
|
+
assert Path(r["vtt"]).read_text().startswith("WEBVTT")
|
|
93
|
+
with pytest.raises(MirageaError, match="not found"):
|
|
94
|
+
core.transcribe(str(tmp_path / "missing.mp4"), client=FakeClient())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_check_setup_without_key(monkeypatch):
|
|
98
|
+
monkeypatch.delenv("MIRAGEA_API_KEY")
|
|
99
|
+
r = core.check_setup()
|
|
100
|
+
assert r["api_key"] is False and any("MIRAGEA_API_KEY" in p for p in r["problems"])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_server_registers_tools():
|
|
104
|
+
from miragea_mcp.server import build_server
|
|
105
|
+
srv = build_server()
|
|
106
|
+
import asyncio
|
|
107
|
+
names = {t.name for t in asyncio.run(srv.list_tools())}
|
|
108
|
+
assert names == {"check_setup", "list_voices", "speak", "transcribe", "narrate_scenes"}
|