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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Miragea MCP: voiceover, voice cloning and captions for AI agents (https://miragea.ai)."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ raise SystemExit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ miragea-mcp = miragea_mcp.server:main
@@ -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"}