基于离线合成的角色打字音效实现

Renpy

在不少视觉小说里,文本以打字机效果展现。很正常的想法是同时配置音效,也就是打字音。更进一步的做法是给每个角色配一个专属音色——冷淡的角色声音尖细克制,苍老的角色低沉沙哑,音色本身就参与角色塑造。这种设计在独立游戏里很常见(Undertale 大概是最出名的例子)。

Ren'Py 没有内置这个功能,因此需要我们自己实现。

本方案有三个特点:

  1. 音色开发时生成
  2. 触发依靠渲染管线而非时间计算(支持cps)
  3. 音色与文本相关

下面按顺序展开。

音色从哪来

打字音本质上是几十毫秒的单音脉冲,正是最经典的合成器场景。每个角色的音色用一份"声线参数表"描述,Python 脚本照着参数生成 16-bit 44.1kHz 的单声道 WAV,Ren'Py 运行时只负责播放。

参数如下:

PROFILE = {
    "waveform": "square",    # 方波 → 数字/冷感
    "freq": 580,             # 基频 (Hz)
    "freq_variance": 6,      # 变体音高抖动 ±6Hz(越小越"面瘫")
    "attack_ms": 1,          # 极短起音 → 精准切割感
    "decay_ms": 10,
    "sustain_level": 0.35,
    "release_ms": 8,
    "duration_ms": 35,
    "filter_type": "lowpass",
    "cutoff_hz": 3000,
    "q": 1.5,                # 适度共振 → 锐利
    "volume": 0.2,
}

合成流程是标准的减法合成四步:波形、滤波、ADSR 包络、音量,一路乘下来。

波形决定基本气质。

方波和锯齿波如果直接按数学公式生成,高频谐波会超过奈奎斯特频率产生混叠,听感发刺。解法是带限合成,只叠加不超过采样率一半的谐波:

# 方波:奇次谐波级数,超奈奎斯特频率的谐波直接丢弃
sig = np.zeros(num_samples)
for k in range(1, 41, 2):
    if k * freq > SAMPLE_RATE / 2:
        break
    sig += np.sin(k * phase) / k

值得一说的是变体。一个音高从头到尾重复会非常机械,所以每个角色按 freq_variance 生成 8 个音高变体,频率在 [freq - variance, freq + variance] 内均匀取值,顺序打乱后存成 0.wav7.wav,播放时从池子里取(怎么取见下文,并不是每次真随机)。随机种子是固定的,保证任何时候重跑脚本,产物都是一致的。

让声音跟着字走

Ren'Py 的逐字显示(slow text)是渲染层行为,引擎只提供 Character 级别的 callback 事件(showslow_doneend),不会告诉你"现在显示了几个字"。按 CPS 估算时间轴当然可行,但玩家一旦点鼠标快进、跳过,或者文本里混着 ruby 注音,估算就会漂移。

我的解法是直接从文本 widget 的渲染结果里读字形可见性。Ren'Py 的 Text.render() 在逐字模式下会给每个字形(glyph)打上一个 time 属性,表示该字形"应当出现"的时刻。那么把 Text.render() monkey-patch 掉,render 完成后从 widget.get_layout() 拿 Layout,遍历所有 glyph,数出 time <= st 的个数,就是当前帧真实可见的字符数。这个数字对快进、跳过、ruby 全部天然免疫,因为它就是渲染器自己的账本。

init -7 python:
    if not hasattr(renpy.text.text.Text, '_ts_original_render'):
        renpy.text.text.Text._ts_original_render = renpy.text.text.Text.render

        def _ts_patched_text_render(self, width, height, st, at):
            was_slow = getattr(self, 'slow', False)          # render 前记录!
            rv = renpy.text.text.Text._ts_original_render(self, width, height, st, at)
            _cls = globals().get('TypingSoundText')
            if _cls and _cls._active and was_slow:
                _cls.tick(self, st)
            return rv

        renpy.text.text.Text.render = _ts_patched_text_render

patch 本身很短,坑都藏在细节里。第一个细节是 was_slow 必须在 render 之前记录:CPS 为 0(瞬显)时,render 内部会把 self.slow 清成 False,render 之后再读就丢了,这是我实际踩过的坑。第二个细节是数 glyph 时要加 g.time >= 0 的条件:ruby 注音字形的 time 是 -1,不排除的话注音也会触发打字音。

开关则交给 Character callback。音效路径不写在组件里,而是作为自定义参数挂在 Character 上——Ren'Py 会把 cb_ 前缀的参数原样塞进 callback 的 kwargs

def _typing_sound_callback(event, interact=True, **kwargs):
    if event in ("slow_done", "end"):
        TypingSoundText.deactivate()          # 文本显示完毕 → 停止并清队列
        return
    if event == "show" and interact:
        sound_file = kwargs.get("typing_sound")     # 来自 cb_typing_sound
        char_key  = kwargs.get("typing_char_key", "narrator")
        TypingSoundText.activate(sound_file, char_key)
define hero = Character("主角", kind=speaker,
    cb_typing_sound=_typing_variants("hero"),   # → 8 个变体的路径列表
    cb_typing_char_key="hero")

更妙的是 kind 继承:定义一个带 callback 的基角色,所有 kind= 它的子角色自动继承整套机制,逐个角色只需指定音效目录。没配置 cb_typing_sound 的角色,kwargs 里没有这个 key,callback 直接跳过,天然 opt-in。

触发频率方面,每个字符都响会太密,所以按"每 N 个字符"触发:维护上一帧的可见字符数,当 revealed // interval 跨过边界时触发一次。interval 做成玩家可调的持久化设置,默认 3。瞬显模式单独处理:整段文字一帧出现,只播一声,用一个标志位防止重复。

选音

现在的做法是把触发窗口内所有字符的码点做加权异或,再对变体数取模:

window_start = old_triggers * interval
window_end   = min(new_triggers * interval, revealed)
h = 0
for i in range(window_start, window_end):
    c = visible_chars[i]
    h ^= (c if isinstance(c, int) else ord(c)) * (i - window_start + 1)
sound = sound_files[h % len(sound_files)]

效果是同一组字符永远映射到同一个音效:文字不同,音高大概率不同;文字相同,音高必然相同。既保留了起伏,又消灭了纯随机的不适感。附带一个特性——同一句台词重放时,它的"声音指纹"是不变的。

一些坑

最后记录几个工程上的坑,都比较琐碎,但每一个都真实发生过。

音频要放在独立通道上。在 voice 混音器下注册一个专用的 typing 通道,打字音就能跟随语音音量,也不与 BGM、SE 抢通道。播放用 queue 而不是 play,多个 blip 首尾相接无缝排队,不会互相切断。

init python:
    renpy.music.register_channel("typing", mixer="voice", loop=False)

点太快会"漏音"。玩家在文字显示中途点击,下一句台词立刻开始,但通道里还排着上一句没播完的 blip。解法是在 deactivate 时把队列清干净:channel.keep_queue = 0channel.dequeue(True),丢掉所有排队中的音效但不打断当前这一个(操作要放在 renpy.audio.audio.lock 里)。

每角色独立音量也值得做。合成参数里的 volume 只解决了角色之间的相对响度,玩家往往还想单独调某个角色,所以运行时要维护一个 persistent 字典,每次触发前 renpy.music.set_volume(vol, channel="typing"),设置界面按角色生成滑条即可。

快进必须静音。renpy.is_skipping() 时 callback 和 tick 都直接返回,否则快进时 blip 声会变成噪音轰炸。

monkey-patch 要防热重载。Ren'Py 脚本热重载会清空 store 变量,patch 逻辑如果引用了 store 里的旧对象就会 NameError,重复 patch 还会无限递归。三个防御:原始 render 存在 Text 类属性上(类属性在 reload 中存活);仅当 _ts_original_render 不存在时才 patch;每次调用时用 globals().get('TypingSoundText') 运行时查找类。

开销方面可以放心:未激活时,patch 只增加一次 getattr 和一次布尔判断,所有 UI 文本(按钮、菜单)的 render 因为 was_slow 为 False,一行额外逻辑都不会执行。

接入一台新的 Ren'Py 项目,步骤就是上文出现过的所有零件:跑一遍合成脚本得到 game/audio/sound/typing/{char}/0~7.wav,注册通道,抄入 TypingSoundText 类和 patch,Character 挂上 callback 和 cb_typing_sound,完事。调音色只需要改参数表重跑脚本,不碰运行时代码;反过来改运行时逻辑,也不需要重新合成——两边只通过一个装着 0.wav7.wav 的文件夹相遇。

Buy Me A Coffee