跳转至

语音

影片代码
import manimgx as m


class VoiceHero(m.Scene):
    def construct(self) -> None:
        square = m.MathTex("x^2", font_size=96).shift(2 * m.LEFT)
        derivative = m.MathTex("2x", font_size=96).shift(2 * m.RIGHT)
        arrow = m.Arrow(square.get_right(), derivative.get_left(), buff=0.4)
        script = m.Text(
            "The derivative of [x squared] is [two x].", font_size=32
        ).to_edge(m.DOWN)
        self.add(square, script)
        self.play(m.Indicate(square))
        self.play(m.GrowArrow(arrow), m.Write(derivative))
        self.wait()

say 用场景的语音说出一行,并在它说话时播放动画。将每个动画的词语放在方括号中,并按相同顺序给出动画:每个从它的第一个词开始。场景等待直到该行结束,因此各行不会重叠;改变词语,动画跟随新词语。

场景的语音通过文本转语音服务说话。ManimGX 将它说的内容保存在场景文件旁边名为 voice/ 的文件夹中,因此场景无需服务即可再次渲染,并且只有改变的行再次发送给它。语音说的内容成为视频的字幕。

say

代码
import manimgx as m


class SayExample(m.Scene):
    def construct(self) -> None:
        x2 = m.MathTex("x^2").shift(2 * m.LEFT)
        dx = m.MathTex("2x").shift(2 * m.RIGHT)
        self.add(x2)
        self.say(
            "The derivative of [x squared] is [two x].",
            m.Indicate(x2),
            m.Write(dx),
        )

朗读一段脚本,在其被朗读时播放动画;场景等待二者完成。

用方括号标记动画所属的词:给定的动画按顺序在括号标记的词期间播放,每个 在其词开始时开始,持续其词的朗读时长,或其自身运行时间(取较长者;空的 [] 在其所在处开始一个)。无括号时,动画在整个语音期间播放。括号不被 朗读。

self.say(script, *animations, voice=None)
script

要朗读的内容,每个动画对应一个括号区间(或无)。

*animations

动画,每个区间一个。

voice

朗读者;默认为场景的 voice。

返回 该语音,带有其各词及朗读时刻。

源码

manimgx\scene.py

def say(
    self, script: str, *animations: Animation, voice: Voice | None = None
) -> Speech:
    """朗读一段脚本,在其被朗读时播放动画;场景等待二者完成。

    用方括号标记动画所属的词:给定的动画按顺序在括号标记的词期间播放,每个
    在其词开始时开始,持续其词的朗读时长,或其自身运行时间(取较长者;空的
    `[]` 在其所在处开始一个)。无括号时,动画在整个语音期间播放。括号不被
    朗读。

    Args:
        script: 要朗读的内容,每个动画对应一个括号区间(或无)。
        *animations: 动画,每个区间一个。
        voice: 朗读者;默认为场景的 [`voice`][manimgx.Scene.voice]。

    Returns:
        该语音,带有其各词及朗读时刻。

    Examples:
        ```python
        import manimgx as m


        class SayExample(m.Scene):
            def construct(self) -> None:
                x2 = m.MathTex("x^2").shift(2 * m.LEFT)
                dx = m.MathTex("2x").shift(2 * m.RIGHT)
                self.add(x2)
                self.say(
                    "The derivative of [x squared] is [two x].",
                    m.Indicate(x2),
                    m.Write(dx),
                )
        ```
    """
    from manimgx.animation.timeline import AnimationGroup, Succession, Wait
    from manimgx.audio import spans, times

    text, found = spans(script)
    if found and len(found) != len(animations):
        raise ValueError(
            f"{len(found)} bracketed spans for {len(animations)} animations: give"
            " one animation per span"
        )
    speech = self.speech(text, voice)
    parts: list[Animation] = [speech]
    for anim, span in zip(
        animations, found or [None] * len(animations), strict=True
    ):
        start, end = (0.0, speech.duration) if span is None else times(speech, span)
        anim.run_time = max(anim.run_time, end - start)
        parts.append(Succession(Wait(start), anim) if start > 0 else anim)
    self.play(AnimationGroup(*parts))
    return speech

speech

场景的语音为 text 所说的内容,但不播放。

朗读一次后保存在场景文件旁的 voice/ 中(以文本命名),因此场景再次 渲染时不再朗读;用你自己相同内容的录音替换其中文件,即可改用该录音。 播放该语音、添加它(add_sound:它在场景 继续时朗读),或读取其 words。

self.speech(text, voice=None)
text

要朗读的内容。

voice

朗读者;默认为场景的 voice。

返回 该语音。

源码

manimgx\scene.py

def speech(self, text: str, voice: Voice | None = None) -> Speech:
    """场景的语音为 `text` 所说的内容,但不播放。

    朗读一次后保存在场景文件旁的 `voice/` 中(以文本命名),因此场景再次
    渲染时不再朗读;用你自己相同内容的录音替换其中文件,即可改用该录音。
    播放该语音、添加它([`add_sound`][manimgx.Scene.add_sound]:它在场景
    继续时朗读),或读取其 [`words`][manimgx.Speech.words]。

    Args:
        text: 要朗读的内容。
        voice: 朗读者;默认为场景的 [`voice`][manimgx.Scene.voice]。

    Returns:
        该语音。
    """
    from manimgx.audio import Fal, cached

    # the scene's voice, as given: a function set on the class is not a method of it
    own = self.__dict__.get("voice", inspect.getattr_static(type(self), "voice"))
    if isinstance(own, staticmethod):
        own = own.__func__
    return cached(voice or own or Fal(), text, _voice_folder(self))

voice

朗读场景 say 的语音:任何 voice;None 表示 fal.ai 的默认模型 (Fal())。

Speech

语音:一种知道自己说什么以及每个词何时被说出的声音。

语音由一个 voice 返回。它像任何声音一样播放;Scene.say 还按其词语为动画计时。

m.Speech(source, *, text, words=(), rate=None)
source

音频:文件的路径、文件的字节或采样点。

text

它说了什么。

words

每个词及其被说出的时间,如果 voice 知道;否则从 音频中估算。

rate

采样点的速率;仅用于采样点。

源码

manimgx\audio\sound.py

def __init__(
    self,
    source: Source,
    *,
    text: str,
    words: Sequence[Word] = (),
    rate: int | None = None,
) -> None:
    super().__init__(source, rate=rate)
    self.text = text
    self._words = tuple(words)  # the source's: before trims and speed

words

其词语,按顺序排列,以及每个词被说出的时间(以从其开始的秒数为单位): voice 提供的,或从音频中估算的;裁剪或变速会随 音频移动它们。

speech.words
源码

manimgx\audio\sound.py

def words(self) -> tuple[Word, ...]:
    """其词语,按顺序排列,以及每个词被说出的时间(以从其开始的秒数为单位):
    voice 提供的,或从音频中估算的;裁剪或变速会随
    音频移动它们。"""
    if not self._words:
        self._words = estimate(self.text, _source_samples(self))
    e = self._edit
    if e.start == 0 and e.speed == 1 and e.end is None:
        return self._words
    end = math.inf if e.end is None else e.end
    return tuple(
        Word(w.text, (w.start - e.start) / e.speed, (w.end - e.start) / e.speed)
        for w in self._words
        if e.start <= w.start < end
    )

语音

ManimGX 提供的语音是 fal.ai 的:m.voices.Fal,附带你选择的模型及其设置。你的编辑器显示每个模型的设置。

Fal

在场景类中定义一个声音:

voice = m.voices.Fal(
    "fal-ai/minimax/speech-2.8-hd",
    voice_setting={"voice_id": "Calm_Woman", "speed": 1.1},
)

fal.ai 上的文本转语音模型,作为声音使用:Fal(model, **settings),其中 settings 为该模型自身的设置(默认模型为 ElevenV3)。

m.voices.Fal(model='fal-ai/elevenlabs/tts/eleven-v3', /, *, key=None, **settings)
model

模型在 fal 上的 ID。

key

fal 的 API 密钥;默认取 FAL_KEY。

模型的设置。

源码

manimgx\audio\fal.py

def __init__(
    self,
    model: Model = "fal-ai/elevenlabs/tts/eleven-v3",
    /,
    *,
    key: str | None = None,
    **settings: object,
) -> None:
    spec = _MODELS.get(model)
    if spec is None:
        raise ValueError(f"fal: no model {model!r} here: {', '.join(_MODELS)}")
    known = spec.settings.__annotations__
    if unknown := sorted(settings.keys() - known.keys()):
        raise TypeError(
            f"{model} takes {', '.join(known)}; not {', '.join(unknown)}"
        )
    object.__setattr__(self, "model", model)
    object.__setattr__(
        self, "settings", {k: _ordered(v) for k, v in sorted(settings.items())}
    )
    object.__setattr__(self, "key", key)

model

模型在 fal 上的 ID。

settings

模型的设置,按传入原样保存。

key

fal 的 API 密钥,若已传入则使用之;否则在声音朗读时读取 FAL_KEY。

模型

ElevenV3

ElevenLabs 的 Eleven v3(fal-ai/elevenlabs/tts/eleven-v3)的设置,该模型会为 其单词计时。其文本可携带音频标签:[whispers]、[laughs]、[excited]……

voice

ElevenLabs 声音库中某个声音的名称或 ID:Aria、Roger、Sarah、Laura、Charlie、 George、Callum、River、Liam、Charlotte、Alice、Matilda、Will、Jessica、Eric、Chris、 Brian、Daniel、Lily、Bill……(默认 "Rachel")。

stability

声音的稳定程度,从 0(富有表现力)到 1(稳定)(默认 0.5)。

language_code

语言,以 ISO 639-1 代码表示("en"、"de"……)(默认:文本自身的语言)。

apply_text_normalization

数字等内容在朗读前是否拼读出来(默认 "auto":由模型自行判断)。

voice

ElevenLabs 声音库中某个声音的名称或 ID:Aria、Roger、Sarah、Laura、Charlie、 George、Callum、River、Liam、Charlotte、Alice、Matilda、Will、Jessica、Eric、Chris、 Brian、Daniel、Lily、Bill……(默认 "Rachel")。

stability

声音的稳定程度,从 0(富有表现力)到 1(稳定)(默认 0.5)。

language_code

语言,以 ISO 639-1 代码表示("en"、"de"……)(默认:文本自身的语言)。

apply_text_normalization

数字等内容在朗读前是否拼读出来(默认 "auto":由模型自行判断)。

MiniMax

MiniMax Speech 2.8 HD(fal-ai/minimax/speech-2.8-hd)的设置。其文本可携带 停顿 <#0.5#>(以秒为单位)以及感叹词:(laughs)、(sighs)、(coughs)、 (clears throat)、(gasps)、(sniffs)、(groans)、(yawns)。

voice_setting

声音及其说话方式(MiniMaxVoice)。

audio_setting

生成的音频(MiniMaxAudio)。

language_boost

模型所关注的一种语言或方言(默认:无)。

normalization_setting

如何均衡响度 (MiniMaxLoudness)。

voice_modify

如何改变声音本身(MiniMaxTimbre)。

pronunciation_dict

如何读特定词语 (MiniMaxPronunciation)。

voice_setting

声音及其说话方式(MiniMaxVoice)。

audio_setting

生成的音频(MiniMaxAudio)。

language_boost

模型所关注的一种语言或方言(默认:无)。

normalization_setting

如何均衡响度 (MiniMaxLoudness)。

voice_modify

如何改变声音本身(MiniMaxTimbre)。

pronunciation_dict

如何读特定词语 (MiniMaxPronunciation)。

MiniMaxVoice

一个 MiniMax 声音及其说话方式。

voice_id

声音:Wise_Woman、Friendly_Person、Inspirational_girl、Deep_Voice_Man、Calm_Woman、 Casual_Guy、Lively_Girl、Patient_Man、Young_Knight、Determined_Man、Lovely_Girl、 Decent_Boy、Imposing_Manner、Elegant_Man、Abbess、Sweet_Girl_2、Exuberant_Girl,或 克隆声音的 ID(默认 "Wise_Woman")。

speed

语速,从 0.5 到 2(默认 1)。

vol

音量,从 0.01 到 10(默认 1)。

pitch

音高,以半音为单位,从 -12 到 12(默认 0)。

emotion

说话时带有的情绪(默认:按文本语气而定)。

english_normalization

英文数字是否朗读得更仔细、稍慢一些(默认 False)。

voice_id

声音:Wise_Woman、Friendly_Person、Inspirational_girl、Deep_Voice_Man、Calm_Woman、 Casual_Guy、Lively_Girl、Patient_Man、Young_Knight、Determined_Man、Lovely_Girl、 Decent_Boy、Imposing_Manner、Elegant_Man、Abbess、Sweet_Girl_2、Exuberant_Girl,或 克隆声音的 ID(默认 "Wise_Woman")。

speed

语速,从 0.5 到 2(默认 1)。

vol

音量,从 0.01 到 10(默认 1)。

pitch

音高,以半音为单位,从 -12 到 12(默认 0)。

emotion

说话时带有的情绪(默认:按文本语气而定)。

english_normalization

英文数字是否朗读得更仔细、稍慢一些(默认 False)。

MiniMaxAudio

MiniMax 生成的音频。

sample_rate

每秒采样数(默认 32000)。

bitrate

每秒比特数,用于 MP3(默认 128000)。

format

文件格式(默认 "mp3")。

channel

声道数:1(单声道)或 2(立体声)(默认 1)。

sample_rate

每秒采样数(默认 32000)。

bitrate

每秒比特数,用于 MP3(默认 128000)。

format

文件格式(默认 "mp3")。

channel

声道数:1(单声道)或 2(立体声)(默认 1)。

MiniMaxLoudness

MiniMax 如何均衡音频响度。

enabled

是否启用(默认 True)。

target_loudness

目标响度,以 LUFS 为单位,从 -70 到 -10(默认 -18)。

target_range

响度范围,以 LU 为单位,从 0 到 20(默认 8)。

target_peak

最高峰值,以 dBTP 为单位,从 -3 到 0(默认 -0.5)。

enabled

是否启用(默认 True)。

target_loudness

目标响度,以 LUFS 为单位,从 -70 到 -10(默认 -18)。

target_range

响度范围,以 LU 为单位,从 0 到 20(默认 8)。

target_peak

最高峰值,以 dBTP 为单位,从 -3 到 0(默认 -0.5)。

MiniMaxTimbre

MiniMax 如何改变声音本身。

pitch

更高或更低,从 -100 到 100(默认 0)。

intensity

更强或更弱,从 -100 到 100(默认 0)。

timbre

音色的色彩,从 -100 到 100(默认 0)。

pitch

更高或更低,从 -100 到 100(默认 0)。

intensity

更强或更弱,从 -100 到 100(默认 0)。

timbre

音色的色彩,从 -100 到 100(默认 0)。

MiniMaxPronunciation

MiniMax 如何读特定词语。

tone_list

每个词及其发音:"text/(pronunciation)"(中文声调 1 到 5: "燕少飞/(yan4)(shao3)(fei1)")。

tone_list

每个词及其发音:"text/(pronunciation)"(中文声调 1 到 5: "燕少飞/(yan4)(shao3)(fei1)")。

Gemini

Google 的 Gemini 3.8 Flash TTS(google/gemini-3.8-flash-tts)的设置。其文本可 携带发声事件:<laugh>、<sigh>。

voice

声音(默认 "Kore")。

style_instructions

以文字描述如何朗读,例如:"Warm and unhurried, like a patient teacher."(默认: 无)。

voice

声音(默认 "Kore")。

style_instructions

以文字描述如何朗读,例如:"Warm and unhurried, like a patient teacher."(默认: 无)。

Inworld

Inworld TTS-1.5 Max(fal-ai/inworld-tts)的设置,每行最多可朗读 2,000 个 字符。

voice

声音,及其所属语言(默认 "Craig (en)")。

sample_rate_hertz

每秒采样数(默认 48000)。

voice

声音,及其所属语言(默认 "Craig (en)")。

sample_rate_hertz

每秒采样数(默认 48000)。

Qwen

阿里巴巴的 Qwen3-TTS 1.7B(fal-ai/qwen-3-tts/text-to-speech/1.7b)的设置。

voice

声音(默认:由模型决定)。

language

语言(默认 "Auto":文本自身的语言)。

prompt

以文字描述如何朗读(而非朗读什么内容)(默认:无)。

speaker_voice_embedding_file_url

克隆声音:由 fal-ai/qwen-3-tts/clone-voice 生成的说话人嵌入的 URL;它会覆盖 voice 和 prompt(默认:无)。

reference_text

克隆声音的录音所朗读的内容,有助于让合成声音更像原声(默认:无)。

temperature

朗读的变化程度,大于 0 且不超过 1(默认 0.9)。

top_k

采样:从 k 个最可能的声音中采样(默认 50)。

top_p

采样:从累计概率达到 p 的最可能的声音中采样,0 到 1(默认 1)。

repetition_penalty

避免自我重复的强度(默认 1.05)。

subtalker_dosample

第二阶段是否进行采样(默认 True)。

subtalker_temperature

第二阶段的温度,0 到 1(默认 0.9)。

subtalker_top_k

第二阶段的 top k(默认 50)。

subtalker_top_p

第二阶段的 top p,0 到 1(默认 1)。

max_new_tokens

生成声音的上限,以其编解码器的 token 数为单位,1 到 8192(此处默认 8192:fal 自身的默认值 200 可能会把长句截断)。

voice

声音(默认:由模型决定)。

language

语言(默认 "Auto":文本自身的语言)。

prompt

以文字描述如何朗读(而非朗读什么内容)(默认:无)。

speaker_voice_embedding_file_url

克隆声音:由 fal-ai/qwen-3-tts/clone-voice 生成的说话人嵌入的 URL;它会覆盖 voice 和 prompt(默认:无)。

reference_text

克隆声音的录音所朗读的内容,有助于让合成声音更像原声(默认:无)。

temperature

朗读的变化程度,大于 0 且不超过 1(默认 0.9)。

top_k

采样:从 k 个最可能的声音中采样(默认 50)。

top_p

采样:从累计概率达到 p 的最可能的声音中采样,0 到 1(默认 1)。

repetition_penalty

避免自我重复的强度(默认 1.05)。

subtalker_dosample

第二阶段是否进行采样(默认 True)。

subtalker_temperature

第二阶段的温度,0 到 1(默认 0.9)。

subtalker_top_k

第二阶段的 top k(默认 50)。

subtalker_top_p

第二阶段的 top p,0 到 1(默认 1)。

max_new_tokens

生成声音的上限,以其编解码器的 token 数为单位,1 到 8192(此处默认 8192:fal 自身的默认值 200 可能会把长句截断)。

字幕

add_subcaption

为影片添加字幕:content 从现在起加 offset 开始显示,持续 duration 秒。

影片的 captions 持有它,连同其语音的字幕; manimgx render 将其作为字幕写入视频旁。

self.add_subcaption(content, duration=1, offset=0)
content

字幕的文本。

duration

显示时长,以秒为单位。

offset

距现在多久后开始,以秒为单位。

源码

manimgx\scene.py

def add_subcaption(
    self, content: str, duration: float = 1, offset: float = 0
) -> None:
    """为影片添加字幕:`content` 从现在起加 `offset` 开始显示,持续
    `duration` 秒。

    影片的 [`captions`][manimgx.Film.captions] 持有它,连同其语音的字幕;
    `manimgx render` 将其作为字幕写入视频旁。

    Args:
        content: 字幕的文本。
        duration: 显示时长,以秒为单位。
        offset: 距现在多久后开始,以秒为单位。
    """
    start = float(clock.now) + offset
    self.film.subcaptions.append(Caption(start, start + duration, content))

自定义语音

语音是从文本到 Speech 的任意函数:一种知道它的词语以及每个词何时说出的声音。任何文本转语音服务都可以成为一个语音。

Voice

Voice = Callable[[str], Speech]

任何能说话的东西:一个从文本到 Speech 的函数。引入任何 文本转语音:将其调用包装在一个返回 Speech(audio, text=text) 的函数中。

Word

语音中的一个词,以及它何时被说出:从 start 到 end,以从 语音开始的秒数为单位。

m.voices.Word(text, start, end)

timed

text 的词语,由 voice 对其定时片段按顺序计时(其字符 或其标记):每个词从其第一个字母所在片段的开始到 其最后一个字母所在片段的结束。若片段未拼出文本的字母 (voice 将 "2" 说成 "two")则为空:其词语随后被估算。

m.voices.timed(text, pieces)
源码

manimgx\audio\sound.py

def timed(text: str, pieces: Sequence[tuple[str, float, float]]) -> tuple[Word, ...]:
    """`text` 的词语,由 voice 对其定时片段按顺序计时(其字符
    或其标记):每个词从其第一个字母所在片段的开始到
    其最后一个字母所在片段的结束。若片段未拼出文本的字母
    (voice 将 "2" 说成 "two")则为空:其词语随后被估算。"""
    # each letter of the pieces: its piece's times
    owner: list[tuple[float, float]] = []
    for piece, a, b in pieces:
        owner += [(a, b)] * sum(c.isalnum() for c in piece)
    letters = [c.lower() for c in text if c.isalnum()]
    spelled = [c.lower() for piece, _, _ in pieces for c in piece if c.isalnum()]
    if not letters or letters != spelled:
        return ()
    words, k = [], 0
    for m in _WORD.finditer(text):
        n = sum(c.isalnum() for c in m.group())
        if n:
            words.append(Word(m.group(), owner[k][0], owner[k + n - 1][1]))
            k += n
        else:  # punctuation: beside the preceding word, or the first following word
            at = words[-1].end if words else owner[0][0]
            words.append(Word(m.group(), at, at))
    return tuple(words)

estimate

text 中每个词在 samples 中被说出的时间,估算得出:音频的发声跨度 按词语的字母数分配给各词,逗号或句号后的停顿会获得更多份额。

m.voices.estimate(text, samples)
源码

manimgx\audio\sound.py

def estimate(text: str, samples: np.ndarray) -> tuple[Word, ...]:
    """`text` 中每个词在 `samples` 中被说出的时间,估算得出:音频的发声跨度
    按词语的字母数分配给各词,逗号或句号后的停顿会获得更多份额。"""
    words = _WORD.findall(text)
    if not words:
        return ()
    level = np.abs(samples).max(axis=1) if samples.ndim > 1 else np.abs(samples)
    hop = RATE // 100  # 10 ms
    frames = len(level) // hop
    loud = level[: frames * hop].reshape(frames, hop).max(axis=1) > 0.02
    voiced = np.flatnonzero(loud)
    if not len(voiced):
        total = len(samples) / RATE
        return tuple(
            Word(w, total * i / len(words), total * (i + 1) / len(words))
            for i, w in enumerate(words)
        )
    first, last = voiced[0], voiced[-1] + 1
    # weight: letters, plus a pause after a comma or a full stop
    weights = []
    for w in words:
        weights.append(len(w) + (6 if w[-1] in ".!?:;" else 3 if w[-1] == "," else 1))
    total_weight = sum(weights)
    edges = np.concatenate([[0], np.cumsum(weights)]) / total_weight
    span = (last - first) / 100
    times = first / 100 + edges * span
    return tuple(
        Word(
            w,
            float(times[i]),
            float(times[i + 1] - (weights[i] - len(w)) / total_weight * span),
        )
        for i, w in enumerate(words)
    )

cached

voice 为 text 说出的内容:说出一次,然后保存在 folder 中,作为其音频和 一个包含其词语的 JSON,以文本以及 voice 和文本的摘要命名。

音频可以被替换(相同词语的录音,引擎能读取的任何格式, 同名文件下):其词语随后从它重新计时。

m.voices.cached(voice, text, folder)
源码

manimgx\audio\sound.py

def cached(voice: Voice, text: str, folder: Path) -> Speech:
    """`voice` 为 `text` 说出的内容:说出一次,然后保存在 `folder` 中,作为其音频和
    一个包含其词语的 JSON,以文本以及 voice 和文本的摘要命名。

    音频可以被替换(相同词语的录音,引擎能读取的任何格式,
    同名文件下):其词语随后从它重新计时。
    """
    from manimgx._engine import digest

    identity = _identity(voice)
    key = f"{digest(identity.encode(), b'\0', text.encode()):016x}"
    slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:40]
    stem = folder / f"{slug}-{key[:10]}"
    meta = stem.with_suffix(".json")
    if meta.exists():
        record = json.loads(meta.read_text(encoding="utf-8"))
        audio = stem.with_suffix(record["suffix"]).read_bytes()
        same = record.get("audio") == f"{digest(audio):016x}"
        words = [Word(*w) for w in record["words"]] if same else []
        speech = Speech(audio, text=text, words=words)
        speech._edit = _Edit(**record.get("edit", {}))
        return speech
    speech = voice(text)
    folder.mkdir(parents=True, exist_ok=True)
    suffix, data = _stored(speech)
    stem.with_suffix(suffix).write_bytes(data)
    meta.write_text(
        json.dumps(
            {
                "text": text,
                "voice": identity,
                "suffix": suffix,
                "audio": f"{digest(data):016x}",
                "edit": dataclasses.asdict(speech._edit),
                "words": [[w.text, w.start, w.end] for w in speech._words],
            },
            indent=1,
        )
        + "\n",  # a text file, to commit
        encoding="utf-8",
    )
    return speech